mirror of
https://github.com/spice2x/spice2x.github.io.git
synced 2026-10-03 08:48:14 -07:00
fix line endings
This commit is contained in:
@@ -1,265 +1,265 @@
|
||||
#include "notifications.h"
|
||||
|
||||
#include <atomic>
|
||||
#include <deque>
|
||||
#include <mutex>
|
||||
#include <unordered_map>
|
||||
|
||||
#include "external/imgui/imgui.h"
|
||||
#include "external/imgui/imgui_internal.h"
|
||||
#include "external/fmt/include/fmt/format.h"
|
||||
|
||||
#include "overlay/overlay.h"
|
||||
#include "util/time.h"
|
||||
|
||||
namespace overlay::notifications {
|
||||
|
||||
bool ENABLED = true;
|
||||
Position POSITION = Position::BottomRight;
|
||||
|
||||
struct Notification {
|
||||
uint64_t id;
|
||||
std::string text;
|
||||
Severity severity;
|
||||
double created_ms;
|
||||
float duration_s;
|
||||
};
|
||||
|
||||
static std::mutex g_mutex;
|
||||
static std::deque<Notification> g_items;
|
||||
static std::atomic<uint64_t> g_next_id { 1 };
|
||||
static std::atomic<size_t> g_count { 0 };
|
||||
|
||||
// duration in seconds each notification stays visible
|
||||
static constexpr float DURATION_S = 3.0f;
|
||||
|
||||
// maximum number of notifications kept in the queue (oldest dropped beyond this)
|
||||
static constexpr size_t MAX_NOTIFICATIONS = 6;
|
||||
|
||||
// time (ms) over which a toast fades out at the end of its lifetime
|
||||
static constexpr float FADE_OUT_MS = 400.0f;
|
||||
|
||||
// fixed width of each toast window, in unscaled pixels
|
||||
static constexpr float TOAST_WIDTH = 320.0f;
|
||||
|
||||
// gap between the toast stack and the screen edges (right + bottom)
|
||||
static constexpr float TOAST_MARGIN = 20.0f;
|
||||
|
||||
// vertical gap between stacked toasts
|
||||
static constexpr float TOAST_SPACING = 8.0f;
|
||||
|
||||
// inner padding inside a toast window (horizontal / vertical)
|
||||
static constexpr float TOAST_PAD_X = 10.0f;
|
||||
static constexpr float TOAST_PAD_Y = 8.0f;
|
||||
|
||||
// width of the colored severity accent bar drawn on the left edge
|
||||
static constexpr float TOAST_ACCENT_W = 6.0f;
|
||||
|
||||
// base opacity of the toast background (0..1), multiplied by the fade alpha
|
||||
static constexpr float TOAST_BG_ALPHA = 0.85f;
|
||||
|
||||
static constexpr ImGuiWindowFlags TOAST_FLAGS =
|
||||
ImGuiWindowFlags_NoDecoration
|
||||
| ImGuiWindowFlags_NoInputs
|
||||
| ImGuiWindowFlags_NoNav
|
||||
| ImGuiWindowFlags_NoMove
|
||||
| ImGuiWindowFlags_NoSavedSettings
|
||||
| ImGuiWindowFlags_NoFocusOnAppearing
|
||||
| ImGuiWindowFlags_NoBringToFrontOnFocus
|
||||
| ImGuiWindowFlags_AlwaysAutoResize;
|
||||
|
||||
static ImU32 severity_accent(Severity sev) {
|
||||
switch (sev) {
|
||||
case Severity::Success: return IM_COL32(80, 200, 120, 255);
|
||||
case Severity::Warning: return IM_COL32(230, 180, 60, 255);
|
||||
case Severity::Error: return IM_COL32(220, 60, 60, 255);
|
||||
case Severity::Info:
|
||||
default: return IM_COL32(90, 160, 230, 255);
|
||||
}
|
||||
}
|
||||
|
||||
static bool is_expired(const Notification &n, double now_ms) {
|
||||
return (now_ms - n.created_ms) >= (n.duration_s * 1000.0);
|
||||
}
|
||||
|
||||
// returns 0.0 .. 1.0 fade alpha based on time remaining
|
||||
static float compute_alpha(const Notification &n, double now_ms) {
|
||||
const double remaining_ms = (n.duration_s * 1000.0) - (now_ms - n.created_ms);
|
||||
if (remaining_ms >= FADE_OUT_MS) {
|
||||
return 1.0f;
|
||||
}
|
||||
if (remaining_ms <= 0.0) {
|
||||
return 0.0f;
|
||||
}
|
||||
return static_cast<float>(remaining_ms / FADE_OUT_MS);
|
||||
}
|
||||
|
||||
// drop expired items and copy the rest under a single lock acquisition
|
||||
static std::vector<Notification> snapshot_and_prune(double now_ms) {
|
||||
std::vector<Notification> snapshot;
|
||||
std::lock_guard<std::mutex> lock(g_mutex);
|
||||
for (auto it = g_items.begin(); it != g_items.end();) {
|
||||
if (is_expired(*it, now_ms)) {
|
||||
it = g_items.erase(it);
|
||||
} else {
|
||||
++it;
|
||||
}
|
||||
}
|
||||
g_count.store(g_items.size(), std::memory_order_release);
|
||||
snapshot.assign(g_items.begin(), g_items.end());
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
// is the configured anchor on the right edge of the screen?
|
||||
static bool position_is_right(Position p) {
|
||||
return p == Position::BottomRight || p == Position::TopRight;
|
||||
}
|
||||
|
||||
// is the configured anchor on the bottom edge of the screen?
|
||||
static bool position_is_bottom(Position p) {
|
||||
return p == Position::BottomRight || p == Position::BottomLeft;
|
||||
}
|
||||
|
||||
// draw a single toast anchored to the configured corner; `cursor_y` is the
|
||||
// y-coordinate of the toast edge nearest the anchor (top edge for Top* anchors,
|
||||
// bottom edge for Bottom* anchors). returns its height in pixels.
|
||||
static float draw_toast(const Notification &n, float cursor_y, float alpha) {
|
||||
const float toast_width = apply_scaling(TOAST_WIDTH);
|
||||
const float margin = apply_scaling(TOAST_MARGIN);
|
||||
const ImVec2 &display = ImGui::GetIO().DisplaySize;
|
||||
const Position pos = POSITION;
|
||||
|
||||
const auto window_id = fmt::format("##spice_notif_{}", n.id);
|
||||
|
||||
// anchor x/pivot.x select the screen edge; pivot.y matches cursor_y semantics
|
||||
const float anchor_x = position_is_right(pos) ? (display.x - margin) : margin;
|
||||
const float pivot_x = position_is_right(pos) ? 1.0f : 0.0f;
|
||||
const float pivot_y = position_is_bottom(pos) ? 1.0f : 0.0f;
|
||||
ImGui::SetNextWindowPos(ImVec2(anchor_x, cursor_y),
|
||||
ImGuiCond_Always, ImVec2(pivot_x, pivot_y));
|
||||
ImGui::SetNextWindowSize(ImVec2(toast_width, 0.f), ImGuiCond_Always);
|
||||
ImGui::SetNextWindowBgAlpha(TOAST_BG_ALPHA * alpha);
|
||||
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_Alpha, alpha);
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding,
|
||||
ImVec2(apply_scaling(TOAST_PAD_X), apply_scaling(TOAST_PAD_Y)));
|
||||
|
||||
float height = 0.f;
|
||||
if (ImGui::Begin(window_id.c_str(), nullptr, TOAST_FLAGS)) {
|
||||
// keep toasts above other overlay windows (e.g. the persistent FPS
|
||||
// window, which may be toggled on after a toast already exists), but
|
||||
// tuck them behind a blocking modal so they get dimmed/occluded by the
|
||||
// modal backdrop instead of floating on top of it.
|
||||
ImGuiWindow *toast_window = ImGui::GetCurrentWindow();
|
||||
if (ImGuiWindow *modal = ImGui::GetTopMostPopupModal()) {
|
||||
ImGui::BringWindowToDisplayBehind(toast_window, modal);
|
||||
} else {
|
||||
ImGui::BringWindowToDisplayFront(toast_window);
|
||||
}
|
||||
|
||||
const ImVec2 win_pos = ImGui::GetWindowPos();
|
||||
const ImVec2 win_size = ImGui::GetWindowSize();
|
||||
|
||||
// accent bar on the left edge of the window
|
||||
const ImU32 accent = severity_accent(n.severity);
|
||||
const ImU32 accent_faded =
|
||||
(accent & 0x00FFFFFFu) | (static_cast<ImU32>(alpha * 255.0f) << 24);
|
||||
ImGui::GetWindowDrawList()->AddRectFilled(
|
||||
win_pos,
|
||||
ImVec2(win_pos.x + apply_scaling(TOAST_ACCENT_W), win_pos.y + win_size.y),
|
||||
accent_faded);
|
||||
|
||||
// small gutter past the accent bar, then wrapped text
|
||||
ImGui::Dummy(ImVec2(apply_scaling(2.0f), 0.f));
|
||||
ImGui::SameLine();
|
||||
ImGui::PushTextWrapPos(win_pos.x + win_size.x - apply_scaling(TOAST_PAD_X));
|
||||
ImGui::TextUnformatted(n.text.c_str());
|
||||
ImGui::PopTextWrapPos();
|
||||
|
||||
height = ImGui::GetWindowSize().y;
|
||||
}
|
||||
ImGui::End();
|
||||
|
||||
ImGui::PopStyleVar(2);
|
||||
return height;
|
||||
}
|
||||
|
||||
uint64_t add(Severity severity, std::string text) {
|
||||
if (!ENABLED || !overlay::ENABLED || overlay::OVERLAY == nullptr) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
Notification n {
|
||||
.id = g_next_id.fetch_add(1, std::memory_order_relaxed),
|
||||
.text = std::move(text),
|
||||
.severity = severity,
|
||||
.created_ms = get_performance_milliseconds(),
|
||||
.duration_s = DURATION_S,
|
||||
};
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(g_mutex);
|
||||
g_items.push_back(std::move(n));
|
||||
while (g_items.size() > MAX_NOTIFICATIONS) {
|
||||
g_items.pop_front();
|
||||
}
|
||||
g_count.store(g_items.size(), std::memory_order_release);
|
||||
}
|
||||
return n.id;
|
||||
}
|
||||
|
||||
uint64_t add_throttled(Severity severity, const std::string &key,
|
||||
double cooldown_seconds, std::string text) {
|
||||
if (!ENABLED || !overlay::ENABLED || overlay::OVERLAY == nullptr) {
|
||||
return 0;
|
||||
}
|
||||
// per-key last-emit timestamps live behind their own lock so we don't
|
||||
// hold g_mutex across the map lookup.
|
||||
static std::mutex throttle_mutex;
|
||||
static std::unordered_map<std::string, double> last_emit_ms;
|
||||
const double now_ms = get_performance_milliseconds();
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(throttle_mutex);
|
||||
auto it = last_emit_ms.find(key);
|
||||
if (it != last_emit_ms.end()
|
||||
&& (now_ms - it->second) < (cooldown_seconds * 1000.0)) {
|
||||
return 0;
|
||||
}
|
||||
last_emit_ms[key] = now_ms;
|
||||
}
|
||||
return add(severity, std::move(text));
|
||||
}
|
||||
|
||||
bool has_pending() {
|
||||
return g_count.load(std::memory_order_acquire) > 0;
|
||||
}
|
||||
|
||||
void draw() {
|
||||
const double now_ms = get_performance_milliseconds();
|
||||
const auto snapshot = snapshot_and_prune(now_ms);
|
||||
if (snapshot.empty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// stack from the anchored edge with newest toast at the anchor.
|
||||
// Bottom* anchors stack upward; Top* anchors stack downward.
|
||||
const float spacing = apply_scaling(TOAST_SPACING);
|
||||
const float margin = apply_scaling(TOAST_MARGIN);
|
||||
const bool bottom = position_is_bottom(POSITION);
|
||||
float cursor_y = bottom
|
||||
? (ImGui::GetIO().DisplaySize.y - margin)
|
||||
: margin;
|
||||
for (auto it = snapshot.rbegin(); it != snapshot.rend(); ++it) {
|
||||
const float alpha = compute_alpha(*it, now_ms);
|
||||
const float height = draw_toast(*it, cursor_y, alpha);
|
||||
cursor_y += bottom ? -(height + spacing) : (height + spacing);
|
||||
}
|
||||
}
|
||||
|
||||
void apply_game_default_position(const std::string &game_name) {
|
||||
if (game_name == "Reflec Beat") {
|
||||
POSITION = Position::TopRight;
|
||||
}
|
||||
// others keep the default (BottomRight)
|
||||
}
|
||||
}
|
||||
#include "notifications.h"
|
||||
|
||||
#include <atomic>
|
||||
#include <deque>
|
||||
#include <mutex>
|
||||
#include <unordered_map>
|
||||
|
||||
#include "external/imgui/imgui.h"
|
||||
#include "external/imgui/imgui_internal.h"
|
||||
#include "external/fmt/include/fmt/format.h"
|
||||
|
||||
#include "overlay/overlay.h"
|
||||
#include "util/time.h"
|
||||
|
||||
namespace overlay::notifications {
|
||||
|
||||
bool ENABLED = true;
|
||||
Position POSITION = Position::BottomRight;
|
||||
|
||||
struct Notification {
|
||||
uint64_t id;
|
||||
std::string text;
|
||||
Severity severity;
|
||||
double created_ms;
|
||||
float duration_s;
|
||||
};
|
||||
|
||||
static std::mutex g_mutex;
|
||||
static std::deque<Notification> g_items;
|
||||
static std::atomic<uint64_t> g_next_id { 1 };
|
||||
static std::atomic<size_t> g_count { 0 };
|
||||
|
||||
// duration in seconds each notification stays visible
|
||||
static constexpr float DURATION_S = 3.0f;
|
||||
|
||||
// maximum number of notifications kept in the queue (oldest dropped beyond this)
|
||||
static constexpr size_t MAX_NOTIFICATIONS = 6;
|
||||
|
||||
// time (ms) over which a toast fades out at the end of its lifetime
|
||||
static constexpr float FADE_OUT_MS = 400.0f;
|
||||
|
||||
// fixed width of each toast window, in unscaled pixels
|
||||
static constexpr float TOAST_WIDTH = 320.0f;
|
||||
|
||||
// gap between the toast stack and the screen edges (right + bottom)
|
||||
static constexpr float TOAST_MARGIN = 20.0f;
|
||||
|
||||
// vertical gap between stacked toasts
|
||||
static constexpr float TOAST_SPACING = 8.0f;
|
||||
|
||||
// inner padding inside a toast window (horizontal / vertical)
|
||||
static constexpr float TOAST_PAD_X = 10.0f;
|
||||
static constexpr float TOAST_PAD_Y = 8.0f;
|
||||
|
||||
// width of the colored severity accent bar drawn on the left edge
|
||||
static constexpr float TOAST_ACCENT_W = 6.0f;
|
||||
|
||||
// base opacity of the toast background (0..1), multiplied by the fade alpha
|
||||
static constexpr float TOAST_BG_ALPHA = 0.85f;
|
||||
|
||||
static constexpr ImGuiWindowFlags TOAST_FLAGS =
|
||||
ImGuiWindowFlags_NoDecoration
|
||||
| ImGuiWindowFlags_NoInputs
|
||||
| ImGuiWindowFlags_NoNav
|
||||
| ImGuiWindowFlags_NoMove
|
||||
| ImGuiWindowFlags_NoSavedSettings
|
||||
| ImGuiWindowFlags_NoFocusOnAppearing
|
||||
| ImGuiWindowFlags_NoBringToFrontOnFocus
|
||||
| ImGuiWindowFlags_AlwaysAutoResize;
|
||||
|
||||
static ImU32 severity_accent(Severity sev) {
|
||||
switch (sev) {
|
||||
case Severity::Success: return IM_COL32(80, 200, 120, 255);
|
||||
case Severity::Warning: return IM_COL32(230, 180, 60, 255);
|
||||
case Severity::Error: return IM_COL32(220, 60, 60, 255);
|
||||
case Severity::Info:
|
||||
default: return IM_COL32(90, 160, 230, 255);
|
||||
}
|
||||
}
|
||||
|
||||
static bool is_expired(const Notification &n, double now_ms) {
|
||||
return (now_ms - n.created_ms) >= (n.duration_s * 1000.0);
|
||||
}
|
||||
|
||||
// returns 0.0 .. 1.0 fade alpha based on time remaining
|
||||
static float compute_alpha(const Notification &n, double now_ms) {
|
||||
const double remaining_ms = (n.duration_s * 1000.0) - (now_ms - n.created_ms);
|
||||
if (remaining_ms >= FADE_OUT_MS) {
|
||||
return 1.0f;
|
||||
}
|
||||
if (remaining_ms <= 0.0) {
|
||||
return 0.0f;
|
||||
}
|
||||
return static_cast<float>(remaining_ms / FADE_OUT_MS);
|
||||
}
|
||||
|
||||
// drop expired items and copy the rest under a single lock acquisition
|
||||
static std::vector<Notification> snapshot_and_prune(double now_ms) {
|
||||
std::vector<Notification> snapshot;
|
||||
std::lock_guard<std::mutex> lock(g_mutex);
|
||||
for (auto it = g_items.begin(); it != g_items.end();) {
|
||||
if (is_expired(*it, now_ms)) {
|
||||
it = g_items.erase(it);
|
||||
} else {
|
||||
++it;
|
||||
}
|
||||
}
|
||||
g_count.store(g_items.size(), std::memory_order_release);
|
||||
snapshot.assign(g_items.begin(), g_items.end());
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
// is the configured anchor on the right edge of the screen?
|
||||
static bool position_is_right(Position p) {
|
||||
return p == Position::BottomRight || p == Position::TopRight;
|
||||
}
|
||||
|
||||
// is the configured anchor on the bottom edge of the screen?
|
||||
static bool position_is_bottom(Position p) {
|
||||
return p == Position::BottomRight || p == Position::BottomLeft;
|
||||
}
|
||||
|
||||
// draw a single toast anchored to the configured corner; `cursor_y` is the
|
||||
// y-coordinate of the toast edge nearest the anchor (top edge for Top* anchors,
|
||||
// bottom edge for Bottom* anchors). returns its height in pixels.
|
||||
static float draw_toast(const Notification &n, float cursor_y, float alpha) {
|
||||
const float toast_width = apply_scaling(TOAST_WIDTH);
|
||||
const float margin = apply_scaling(TOAST_MARGIN);
|
||||
const ImVec2 &display = ImGui::GetIO().DisplaySize;
|
||||
const Position pos = POSITION;
|
||||
|
||||
const auto window_id = fmt::format("##spice_notif_{}", n.id);
|
||||
|
||||
// anchor x/pivot.x select the screen edge; pivot.y matches cursor_y semantics
|
||||
const float anchor_x = position_is_right(pos) ? (display.x - margin) : margin;
|
||||
const float pivot_x = position_is_right(pos) ? 1.0f : 0.0f;
|
||||
const float pivot_y = position_is_bottom(pos) ? 1.0f : 0.0f;
|
||||
ImGui::SetNextWindowPos(ImVec2(anchor_x, cursor_y),
|
||||
ImGuiCond_Always, ImVec2(pivot_x, pivot_y));
|
||||
ImGui::SetNextWindowSize(ImVec2(toast_width, 0.f), ImGuiCond_Always);
|
||||
ImGui::SetNextWindowBgAlpha(TOAST_BG_ALPHA * alpha);
|
||||
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_Alpha, alpha);
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding,
|
||||
ImVec2(apply_scaling(TOAST_PAD_X), apply_scaling(TOAST_PAD_Y)));
|
||||
|
||||
float height = 0.f;
|
||||
if (ImGui::Begin(window_id.c_str(), nullptr, TOAST_FLAGS)) {
|
||||
// keep toasts above other overlay windows (e.g. the persistent FPS
|
||||
// window, which may be toggled on after a toast already exists), but
|
||||
// tuck them behind a blocking modal so they get dimmed/occluded by the
|
||||
// modal backdrop instead of floating on top of it.
|
||||
ImGuiWindow *toast_window = ImGui::GetCurrentWindow();
|
||||
if (ImGuiWindow *modal = ImGui::GetTopMostPopupModal()) {
|
||||
ImGui::BringWindowToDisplayBehind(toast_window, modal);
|
||||
} else {
|
||||
ImGui::BringWindowToDisplayFront(toast_window);
|
||||
}
|
||||
|
||||
const ImVec2 win_pos = ImGui::GetWindowPos();
|
||||
const ImVec2 win_size = ImGui::GetWindowSize();
|
||||
|
||||
// accent bar on the left edge of the window
|
||||
const ImU32 accent = severity_accent(n.severity);
|
||||
const ImU32 accent_faded =
|
||||
(accent & 0x00FFFFFFu) | (static_cast<ImU32>(alpha * 255.0f) << 24);
|
||||
ImGui::GetWindowDrawList()->AddRectFilled(
|
||||
win_pos,
|
||||
ImVec2(win_pos.x + apply_scaling(TOAST_ACCENT_W), win_pos.y + win_size.y),
|
||||
accent_faded);
|
||||
|
||||
// small gutter past the accent bar, then wrapped text
|
||||
ImGui::Dummy(ImVec2(apply_scaling(2.0f), 0.f));
|
||||
ImGui::SameLine();
|
||||
ImGui::PushTextWrapPos(win_pos.x + win_size.x - apply_scaling(TOAST_PAD_X));
|
||||
ImGui::TextUnformatted(n.text.c_str());
|
||||
ImGui::PopTextWrapPos();
|
||||
|
||||
height = ImGui::GetWindowSize().y;
|
||||
}
|
||||
ImGui::End();
|
||||
|
||||
ImGui::PopStyleVar(2);
|
||||
return height;
|
||||
}
|
||||
|
||||
uint64_t add(Severity severity, std::string text) {
|
||||
if (!ENABLED || !overlay::ENABLED || overlay::OVERLAY == nullptr) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
Notification n {
|
||||
.id = g_next_id.fetch_add(1, std::memory_order_relaxed),
|
||||
.text = std::move(text),
|
||||
.severity = severity,
|
||||
.created_ms = get_performance_milliseconds(),
|
||||
.duration_s = DURATION_S,
|
||||
};
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(g_mutex);
|
||||
g_items.push_back(std::move(n));
|
||||
while (g_items.size() > MAX_NOTIFICATIONS) {
|
||||
g_items.pop_front();
|
||||
}
|
||||
g_count.store(g_items.size(), std::memory_order_release);
|
||||
}
|
||||
return n.id;
|
||||
}
|
||||
|
||||
uint64_t add_throttled(Severity severity, const std::string &key,
|
||||
double cooldown_seconds, std::string text) {
|
||||
if (!ENABLED || !overlay::ENABLED || overlay::OVERLAY == nullptr) {
|
||||
return 0;
|
||||
}
|
||||
// per-key last-emit timestamps live behind their own lock so we don't
|
||||
// hold g_mutex across the map lookup.
|
||||
static std::mutex throttle_mutex;
|
||||
static std::unordered_map<std::string, double> last_emit_ms;
|
||||
const double now_ms = get_performance_milliseconds();
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(throttle_mutex);
|
||||
auto it = last_emit_ms.find(key);
|
||||
if (it != last_emit_ms.end()
|
||||
&& (now_ms - it->second) < (cooldown_seconds * 1000.0)) {
|
||||
return 0;
|
||||
}
|
||||
last_emit_ms[key] = now_ms;
|
||||
}
|
||||
return add(severity, std::move(text));
|
||||
}
|
||||
|
||||
bool has_pending() {
|
||||
return g_count.load(std::memory_order_acquire) > 0;
|
||||
}
|
||||
|
||||
void draw() {
|
||||
const double now_ms = get_performance_milliseconds();
|
||||
const auto snapshot = snapshot_and_prune(now_ms);
|
||||
if (snapshot.empty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// stack from the anchored edge with newest toast at the anchor.
|
||||
// Bottom* anchors stack upward; Top* anchors stack downward.
|
||||
const float spacing = apply_scaling(TOAST_SPACING);
|
||||
const float margin = apply_scaling(TOAST_MARGIN);
|
||||
const bool bottom = position_is_bottom(POSITION);
|
||||
float cursor_y = bottom
|
||||
? (ImGui::GetIO().DisplaySize.y - margin)
|
||||
: margin;
|
||||
for (auto it = snapshot.rbegin(); it != snapshot.rend(); ++it) {
|
||||
const float alpha = compute_alpha(*it, now_ms);
|
||||
const float height = draw_toast(*it, cursor_y, alpha);
|
||||
cursor_y += bottom ? -(height + spacing) : (height + spacing);
|
||||
}
|
||||
}
|
||||
|
||||
void apply_game_default_position(const std::string &game_name) {
|
||||
if (game_name == "Reflec Beat") {
|
||||
POSITION = Position::TopRight;
|
||||
}
|
||||
// others keep the default (BottomRight)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,55 +1,55 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
namespace overlay::notifications {
|
||||
|
||||
// master switch for the notification system; when false, add() is a no-op.
|
||||
// controlled by selecting "none" for the -notifypos launcher option.
|
||||
extern bool ENABLED;
|
||||
|
||||
enum class Severity {
|
||||
Info,
|
||||
Success,
|
||||
Warning,
|
||||
Error,
|
||||
};
|
||||
|
||||
// screen anchor for the toast stack. toasts stack away from the anchored edge.
|
||||
enum class Position {
|
||||
BottomRight,
|
||||
BottomLeft,
|
||||
TopRight,
|
||||
TopLeft,
|
||||
};
|
||||
|
||||
// current toast anchor. defaults to BottomRight; may be reassigned by
|
||||
// apply_game_default_position() or by the user via -notifypos.
|
||||
extern Position POSITION;
|
||||
|
||||
// apply the default toast position appropriate for a game (by display name,
|
||||
// as returned by eamuse_get_game()). called once after game autodetect, before
|
||||
// any user -notifypos override is applied.
|
||||
void apply_game_default_position(const std::string &game_name);
|
||||
|
||||
// add a notification (thread-safe). returns the assigned id, or 0 if the
|
||||
// notification was dropped (overlay disabled or notifications disabled).
|
||||
uint64_t add(Severity severity, std::string text);
|
||||
|
||||
// rate-limited variant of add(). suppresses the toast if another call with
|
||||
// the same `key` succeeded within the last `cooldown_seconds`. useful for
|
||||
// events that can fire every frame (e.g. a button held down). returns the
|
||||
// assigned id, or 0 if the toast was suppressed or dropped. thread-safe.
|
||||
uint64_t add_throttled(Severity severity, const std::string &key,
|
||||
double cooldown_seconds, std::string text);
|
||||
|
||||
// true if there is at least one notification that still needs to be drawn.
|
||||
// safe to call from the render thread without locking the underlying store.
|
||||
bool has_pending();
|
||||
|
||||
// draw all active notifications and prune expired ones.
|
||||
// must be called from the ImGui render thread inside a NewFrame/EndFrame pair.
|
||||
void draw();
|
||||
}
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
namespace overlay::notifications {
|
||||
|
||||
// master switch for the notification system; when false, add() is a no-op.
|
||||
// controlled by selecting "none" for the -notifypos launcher option.
|
||||
extern bool ENABLED;
|
||||
|
||||
enum class Severity {
|
||||
Info,
|
||||
Success,
|
||||
Warning,
|
||||
Error,
|
||||
};
|
||||
|
||||
// screen anchor for the toast stack. toasts stack away from the anchored edge.
|
||||
enum class Position {
|
||||
BottomRight,
|
||||
BottomLeft,
|
||||
TopRight,
|
||||
TopLeft,
|
||||
};
|
||||
|
||||
// current toast anchor. defaults to BottomRight; may be reassigned by
|
||||
// apply_game_default_position() or by the user via -notifypos.
|
||||
extern Position POSITION;
|
||||
|
||||
// apply the default toast position appropriate for a game (by display name,
|
||||
// as returned by eamuse_get_game()). called once after game autodetect, before
|
||||
// any user -notifypos override is applied.
|
||||
void apply_game_default_position(const std::string &game_name);
|
||||
|
||||
// add a notification (thread-safe). returns the assigned id, or 0 if the
|
||||
// notification was dropped (overlay disabled or notifications disabled).
|
||||
uint64_t add(Severity severity, std::string text);
|
||||
|
||||
// rate-limited variant of add(). suppresses the toast if another call with
|
||||
// the same `key` succeeded within the last `cooldown_seconds`. useful for
|
||||
// events that can fire every frame (e.g. a button held down). returns the
|
||||
// assigned id, or 0 if the toast was suppressed or dropped. thread-safe.
|
||||
uint64_t add_throttled(Severity severity, const std::string &key,
|
||||
double cooldown_seconds, std::string text);
|
||||
|
||||
// true if there is at least one notification that still needs to be drawn.
|
||||
// safe to call from the render thread without locking the underlying store.
|
||||
bool has_pending();
|
||||
|
||||
// draw all active notifications and prune expired ones.
|
||||
// must be called from the ImGui render thread inside a NewFrame/EndFrame pair.
|
||||
void draw();
|
||||
}
|
||||
|
||||
@@ -1,149 +1,149 @@
|
||||
#include "nostalgia_touch_piano.h"
|
||||
|
||||
#include <cmath>
|
||||
|
||||
#include "external/imgui/imgui_internal.h"
|
||||
#include "games/nost/touch_mode.h"
|
||||
|
||||
namespace overlay::windows {
|
||||
|
||||
static constexpr float BUTTON_WIDTH = 144.f;
|
||||
static constexpr float BUTTON_HEIGHT = 40.f;
|
||||
static constexpr float WINDOW_PADDING = 4.f;
|
||||
static constexpr float EDGE_MARGIN = 4.f;
|
||||
static constexpr float PIANO_HEIGHT_RATIO = 0.08f;
|
||||
static constexpr float PIANO_LEFT_GAP = 11.f;
|
||||
static constexpr float PIANO_RIGHT_GAP = 10.f;
|
||||
static constexpr uint32_t PIANO_KEY_COUNT = 28;
|
||||
|
||||
static constexpr ImU32 PIANO_KEY_COLOR = IM_COL32(255, 255, 255, 50);
|
||||
static constexpr ImU32 PIANO_KEY_ACTIVE_COLOR = IM_COL32(255, 48, 48, 160);
|
||||
static constexpr ImU32 PIANO_KEY_BORDER_COLOR = IM_COL32(0, 0, 0, 100);
|
||||
|
||||
struct ButtonPalette {
|
||||
ImVec4 normal;
|
||||
ImVec4 hovered;
|
||||
ImVec4 active;
|
||||
};
|
||||
|
||||
static const ButtonPalette NAV_MODE_PALETTE {
|
||||
ImVec4(0.10f, 0.45f, 0.28f, 0.72f),
|
||||
ImVec4(0.14f, 0.58f, 0.36f, 0.82f),
|
||||
ImVec4(0.08f, 0.34f, 0.21f, 0.90f),
|
||||
};
|
||||
static const ButtonPalette PIANO_MODE_PALETTE {
|
||||
ImVec4(0.15f, 0.32f, 0.62f, 0.72f),
|
||||
ImVec4(0.20f, 0.42f, 0.78f, 0.82f),
|
||||
ImVec4(0.10f, 0.24f, 0.50f, 0.90f),
|
||||
};
|
||||
|
||||
static void draw_piano_keys(
|
||||
const ImVec2 &display_size,
|
||||
LONG client_width,
|
||||
uint32_t key_state) {
|
||||
|
||||
if (display_size.x <= 0.f || display_size.y <= 0.f || client_width <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// this is only a visual guide; native touch routing owns the actual input
|
||||
const float left_gap = PIANO_LEFT_GAP * display_size.x / client_width;
|
||||
const float right_gap = PIANO_RIGHT_GAP * display_size.x / client_width;
|
||||
const float piano_width = display_size.x - left_gap - right_gap;
|
||||
if (piano_width <= 0.f) {
|
||||
return;
|
||||
}
|
||||
|
||||
const float key_width = piano_width / PIANO_KEY_COUNT;
|
||||
const float key_top = display_size.y * (1.f - PIANO_HEIGHT_RATIO);
|
||||
auto *draw_list = ImGui::GetBackgroundDrawList();
|
||||
|
||||
for (uint32_t key = 0; key < PIANO_KEY_COUNT; key++) {
|
||||
const ImVec2 key_min(left_gap + key * key_width, key_top);
|
||||
const ImVec2 key_max(left_gap + (key + 1) * key_width, display_size.y);
|
||||
const bool active = (key_state & (UINT32_C(1) << key)) != 0;
|
||||
draw_list->AddRectFilled(
|
||||
key_min,
|
||||
key_max,
|
||||
active ? PIANO_KEY_ACTIVE_COLOR : PIANO_KEY_COLOR);
|
||||
draw_list->AddRect(key_min, key_max, PIANO_KEY_BORDER_COLOR);
|
||||
}
|
||||
}
|
||||
|
||||
NostalgiaTouchPiano::NostalgiaTouchPiano(SpiceOverlay *overlay) : Window(overlay) {
|
||||
this->title = "Nostalgia Touch Piano";
|
||||
this->flags = ImGuiWindowFlags_NoTitleBar
|
||||
| ImGuiWindowFlags_NoResize
|
||||
| ImGuiWindowFlags_NoCollapse
|
||||
| ImGuiWindowFlags_NoMove
|
||||
| ImGuiWindowFlags_NoDocking
|
||||
| ImGuiWindowFlags_NoBackground
|
||||
| ImGuiWindowFlags_NoSavedSettings
|
||||
| ImGuiWindowFlags_NoNav
|
||||
| ImGuiWindowFlags_NoBringToFrontOnFocus;
|
||||
this->window_padding = overlay::apply_scaling_to_vector(WINDOW_PADDING, WINDOW_PADDING);
|
||||
this->set_active(true);
|
||||
}
|
||||
|
||||
void NostalgiaTouchPiano::calculate_initial_window() {
|
||||
this->init_size = overlay::apply_scaling_to_vector(
|
||||
BUTTON_WIDTH + WINDOW_PADDING * 2,
|
||||
BUTTON_HEIGHT + WINDOW_PADDING * 2);
|
||||
this->init_pos = overlay::apply_scaling_to_vector(EDGE_MARGIN, EDGE_MARGIN);
|
||||
}
|
||||
|
||||
void NostalgiaTouchPiano::build_content() {
|
||||
// keep the control anchored while the game window changes size or mode
|
||||
ImGui::SetWindowPos(
|
||||
overlay::apply_scaling_to_vector(EDGE_MARGIN, EDGE_MARGIN),
|
||||
ImGuiCond_Always);
|
||||
|
||||
// stay above regular overlay windows, but never cover a blocking modal
|
||||
ImGuiWindow *mode_window = ImGui::GetCurrentWindow();
|
||||
if (ImGuiWindow *modal = ImGui::GetTopMostPopupModal()) {
|
||||
ImGui::BringWindowToDisplayBehind(mode_window, modal);
|
||||
} else {
|
||||
ImGui::BringWindowToDisplayFront(mode_window);
|
||||
}
|
||||
|
||||
const bool nav_mode =
|
||||
games::nost::touch_mode::current_mode() == games::nost::touch_mode::Mode::Nav;
|
||||
const char *label = nav_mode ? "Nav Mode" : "Piano Mode";
|
||||
|
||||
// make the active routing mode recognizable without reading the label
|
||||
const auto &palette = nav_mode ? NAV_MODE_PALETTE : PIANO_MODE_PALETTE;
|
||||
ImGui::PushStyleColor(ImGuiCol_Button, palette.normal);
|
||||
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, palette.hovered);
|
||||
ImGui::PushStyleColor(ImGuiCol_ButtonActive, palette.active);
|
||||
ImGui::Button(label, overlay::apply_scaling_to_vector(BUTTON_WIDTH, BUTTON_HEIGHT));
|
||||
ImGui::PopStyleColor(3);
|
||||
|
||||
const auto &io = ImGui::GetIO();
|
||||
RECT client_rect {};
|
||||
if (io.DisplaySize.x > 0.f && io.DisplaySize.y > 0.f &&
|
||||
GetClientRect(this->overlay->get_window(), &client_rect)) {
|
||||
|
||||
// convert the rendered imgui rectangle into the client coordinates
|
||||
// used by hardware touch publication and piano-key mapping
|
||||
const auto item_min = ImGui::GetItemRectMin();
|
||||
const auto item_max = ImGui::GetItemRectMax();
|
||||
const auto client_width = client_rect.right - client_rect.left;
|
||||
const auto client_height = client_rect.bottom - client_rect.top;
|
||||
if (!nav_mode) {
|
||||
draw_piano_keys(
|
||||
io.DisplaySize,
|
||||
client_width,
|
||||
games::nost::touch_mode::piano_key_state());
|
||||
}
|
||||
|
||||
RECT button_bounds {
|
||||
static_cast<LONG>(std::lround(item_min.x * client_width / io.DisplaySize.x)),
|
||||
static_cast<LONG>(std::lround(item_min.y * client_height / io.DisplaySize.y)),
|
||||
static_cast<LONG>(std::lround(item_max.x * client_width / io.DisplaySize.x)),
|
||||
static_cast<LONG>(std::lround(item_max.y * client_height / io.DisplaySize.y)),
|
||||
};
|
||||
games::nost::touch_mode::publish_button_bounds(
|
||||
this->overlay->get_window(), button_bounds);
|
||||
}
|
||||
}
|
||||
}
|
||||
#include "nostalgia_touch_piano.h"
|
||||
|
||||
#include <cmath>
|
||||
|
||||
#include "external/imgui/imgui_internal.h"
|
||||
#include "games/nost/touch_mode.h"
|
||||
|
||||
namespace overlay::windows {
|
||||
|
||||
static constexpr float BUTTON_WIDTH = 144.f;
|
||||
static constexpr float BUTTON_HEIGHT = 40.f;
|
||||
static constexpr float WINDOW_PADDING = 4.f;
|
||||
static constexpr float EDGE_MARGIN = 4.f;
|
||||
static constexpr float PIANO_HEIGHT_RATIO = 0.08f;
|
||||
static constexpr float PIANO_LEFT_GAP = 11.f;
|
||||
static constexpr float PIANO_RIGHT_GAP = 10.f;
|
||||
static constexpr uint32_t PIANO_KEY_COUNT = 28;
|
||||
|
||||
static constexpr ImU32 PIANO_KEY_COLOR = IM_COL32(255, 255, 255, 50);
|
||||
static constexpr ImU32 PIANO_KEY_ACTIVE_COLOR = IM_COL32(255, 48, 48, 160);
|
||||
static constexpr ImU32 PIANO_KEY_BORDER_COLOR = IM_COL32(0, 0, 0, 100);
|
||||
|
||||
struct ButtonPalette {
|
||||
ImVec4 normal;
|
||||
ImVec4 hovered;
|
||||
ImVec4 active;
|
||||
};
|
||||
|
||||
static const ButtonPalette NAV_MODE_PALETTE {
|
||||
ImVec4(0.10f, 0.45f, 0.28f, 0.72f),
|
||||
ImVec4(0.14f, 0.58f, 0.36f, 0.82f),
|
||||
ImVec4(0.08f, 0.34f, 0.21f, 0.90f),
|
||||
};
|
||||
static const ButtonPalette PIANO_MODE_PALETTE {
|
||||
ImVec4(0.15f, 0.32f, 0.62f, 0.72f),
|
||||
ImVec4(0.20f, 0.42f, 0.78f, 0.82f),
|
||||
ImVec4(0.10f, 0.24f, 0.50f, 0.90f),
|
||||
};
|
||||
|
||||
static void draw_piano_keys(
|
||||
const ImVec2 &display_size,
|
||||
LONG client_width,
|
||||
uint32_t key_state) {
|
||||
|
||||
if (display_size.x <= 0.f || display_size.y <= 0.f || client_width <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// this is only a visual guide; native touch routing owns the actual input
|
||||
const float left_gap = PIANO_LEFT_GAP * display_size.x / client_width;
|
||||
const float right_gap = PIANO_RIGHT_GAP * display_size.x / client_width;
|
||||
const float piano_width = display_size.x - left_gap - right_gap;
|
||||
if (piano_width <= 0.f) {
|
||||
return;
|
||||
}
|
||||
|
||||
const float key_width = piano_width / PIANO_KEY_COUNT;
|
||||
const float key_top = display_size.y * (1.f - PIANO_HEIGHT_RATIO);
|
||||
auto *draw_list = ImGui::GetBackgroundDrawList();
|
||||
|
||||
for (uint32_t key = 0; key < PIANO_KEY_COUNT; key++) {
|
||||
const ImVec2 key_min(left_gap + key * key_width, key_top);
|
||||
const ImVec2 key_max(left_gap + (key + 1) * key_width, display_size.y);
|
||||
const bool active = (key_state & (UINT32_C(1) << key)) != 0;
|
||||
draw_list->AddRectFilled(
|
||||
key_min,
|
||||
key_max,
|
||||
active ? PIANO_KEY_ACTIVE_COLOR : PIANO_KEY_COLOR);
|
||||
draw_list->AddRect(key_min, key_max, PIANO_KEY_BORDER_COLOR);
|
||||
}
|
||||
}
|
||||
|
||||
NostalgiaTouchPiano::NostalgiaTouchPiano(SpiceOverlay *overlay) : Window(overlay) {
|
||||
this->title = "Nostalgia Touch Piano";
|
||||
this->flags = ImGuiWindowFlags_NoTitleBar
|
||||
| ImGuiWindowFlags_NoResize
|
||||
| ImGuiWindowFlags_NoCollapse
|
||||
| ImGuiWindowFlags_NoMove
|
||||
| ImGuiWindowFlags_NoDocking
|
||||
| ImGuiWindowFlags_NoBackground
|
||||
| ImGuiWindowFlags_NoSavedSettings
|
||||
| ImGuiWindowFlags_NoNav
|
||||
| ImGuiWindowFlags_NoBringToFrontOnFocus;
|
||||
this->window_padding = overlay::apply_scaling_to_vector(WINDOW_PADDING, WINDOW_PADDING);
|
||||
this->set_active(true);
|
||||
}
|
||||
|
||||
void NostalgiaTouchPiano::calculate_initial_window() {
|
||||
this->init_size = overlay::apply_scaling_to_vector(
|
||||
BUTTON_WIDTH + WINDOW_PADDING * 2,
|
||||
BUTTON_HEIGHT + WINDOW_PADDING * 2);
|
||||
this->init_pos = overlay::apply_scaling_to_vector(EDGE_MARGIN, EDGE_MARGIN);
|
||||
}
|
||||
|
||||
void NostalgiaTouchPiano::build_content() {
|
||||
// keep the control anchored while the game window changes size or mode
|
||||
ImGui::SetWindowPos(
|
||||
overlay::apply_scaling_to_vector(EDGE_MARGIN, EDGE_MARGIN),
|
||||
ImGuiCond_Always);
|
||||
|
||||
// stay above regular overlay windows, but never cover a blocking modal
|
||||
ImGuiWindow *mode_window = ImGui::GetCurrentWindow();
|
||||
if (ImGuiWindow *modal = ImGui::GetTopMostPopupModal()) {
|
||||
ImGui::BringWindowToDisplayBehind(mode_window, modal);
|
||||
} else {
|
||||
ImGui::BringWindowToDisplayFront(mode_window);
|
||||
}
|
||||
|
||||
const bool nav_mode =
|
||||
games::nost::touch_mode::current_mode() == games::nost::touch_mode::Mode::Nav;
|
||||
const char *label = nav_mode ? "Nav Mode" : "Piano Mode";
|
||||
|
||||
// make the active routing mode recognizable without reading the label
|
||||
const auto &palette = nav_mode ? NAV_MODE_PALETTE : PIANO_MODE_PALETTE;
|
||||
ImGui::PushStyleColor(ImGuiCol_Button, palette.normal);
|
||||
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, palette.hovered);
|
||||
ImGui::PushStyleColor(ImGuiCol_ButtonActive, palette.active);
|
||||
ImGui::Button(label, overlay::apply_scaling_to_vector(BUTTON_WIDTH, BUTTON_HEIGHT));
|
||||
ImGui::PopStyleColor(3);
|
||||
|
||||
const auto &io = ImGui::GetIO();
|
||||
RECT client_rect {};
|
||||
if (io.DisplaySize.x > 0.f && io.DisplaySize.y > 0.f &&
|
||||
GetClientRect(this->overlay->get_window(), &client_rect)) {
|
||||
|
||||
// convert the rendered imgui rectangle into the client coordinates
|
||||
// used by hardware touch publication and piano-key mapping
|
||||
const auto item_min = ImGui::GetItemRectMin();
|
||||
const auto item_max = ImGui::GetItemRectMax();
|
||||
const auto client_width = client_rect.right - client_rect.left;
|
||||
const auto client_height = client_rect.bottom - client_rect.top;
|
||||
if (!nav_mode) {
|
||||
draw_piano_keys(
|
||||
io.DisplaySize,
|
||||
client_width,
|
||||
games::nost::touch_mode::piano_key_state());
|
||||
}
|
||||
|
||||
RECT button_bounds {
|
||||
static_cast<LONG>(std::lround(item_min.x * client_width / io.DisplaySize.x)),
|
||||
static_cast<LONG>(std::lround(item_min.y * client_height / io.DisplaySize.y)),
|
||||
static_cast<LONG>(std::lround(item_max.x * client_width / io.DisplaySize.x)),
|
||||
static_cast<LONG>(std::lround(item_max.y * client_height / io.DisplaySize.y)),
|
||||
};
|
||||
games::nost::touch_mode::publish_button_bounds(
|
||||
this->overlay->get_window(), button_bounds);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
#pragma once
|
||||
|
||||
#include "overlay/window.h"
|
||||
|
||||
namespace overlay::windows {
|
||||
|
||||
// persistent mode control rendered independently of the main overlay visibility
|
||||
class NostalgiaTouchPiano : public Window {
|
||||
public:
|
||||
explicit NostalgiaTouchPiano(SpiceOverlay *overlay);
|
||||
|
||||
void calculate_initial_window() override;
|
||||
void build_content() override;
|
||||
};
|
||||
}
|
||||
#pragma once
|
||||
|
||||
#include "overlay/window.h"
|
||||
|
||||
namespace overlay::windows {
|
||||
|
||||
// persistent mode control rendered independently of the main overlay visibility
|
||||
class NostalgiaTouchPiano : public Window {
|
||||
public:
|
||||
explicit NostalgiaTouchPiano(SpiceOverlay *overlay);
|
||||
|
||||
void calculate_initial_window() override;
|
||||
void build_content() override;
|
||||
};
|
||||
}
|
||||
|
||||
+290
-290
@@ -1,290 +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();
|
||||
}
|
||||
}
|
||||
}
|
||||
#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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+131
-131
@@ -1,131 +1,131 @@
|
||||
#pragma once
|
||||
|
||||
#include <atomic>
|
||||
#include <condition_variable>
|
||||
#include <cstdint>
|
||||
#include <deque>
|
||||
#include <functional>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
|
||||
#include "external/rapidjson/fwd.h"
|
||||
#include "overlay/window.h"
|
||||
|
||||
namespace easywsclient {
|
||||
class WebSocket;
|
||||
}
|
||||
|
||||
namespace overlay::windows {
|
||||
|
||||
// OBS WebSocket connection settings, resolved once at launch from the merged
|
||||
// launcher options (command line + saved config) following the same pattern
|
||||
// as the other global launch settings in launcher.cpp
|
||||
extern bool OBS_CONTROL_ENABLED;
|
||||
extern std::string OBS_CONTROL_HOST;
|
||||
extern uint16_t OBS_CONTROL_PORT;
|
||||
extern std::string OBS_CONTROL_PASSWORD;
|
||||
|
||||
// when true, easywsclient's internal diagnostics are routed to the logger
|
||||
extern bool OBS_CONTROL_DEBUG;
|
||||
|
||||
// status snapshot shared between the OBS worker thread and the render thread
|
||||
struct OBSStatus {
|
||||
bool disabled = true;
|
||||
bool connected = false;
|
||||
bool identifying = false;
|
||||
std::string connection_error;
|
||||
|
||||
// name of the active program scene (read-only, from obs-websocket)
|
||||
std::string current_scene;
|
||||
|
||||
bool streaming = false;
|
||||
bool recording = false;
|
||||
bool record_paused = false;
|
||||
|
||||
// duration base values (milliseconds) and the local timestamp (ms since
|
||||
// steady epoch) at which they were last refreshed, so the UI can tick a
|
||||
// smooth timer between polls
|
||||
int64_t stream_duration_ms = 0;
|
||||
int64_t record_duration_ms = 0;
|
||||
int64_t stream_duration_base_tick = 0;
|
||||
int64_t record_duration_base_tick = 0;
|
||||
};
|
||||
|
||||
// in-flight user action used purely for UI feedback: when the user clicks a
|
||||
// control we remember what we asked for so the button can show a transitional
|
||||
// label and stay disabled until the observed OBS state matches the request
|
||||
// (or a short deadline lapses). owned solely by the render thread.
|
||||
enum class OBSAction {
|
||||
None,
|
||||
StreamStart, StreamStop,
|
||||
RecordStart, RecordStop,
|
||||
RecordPause, RecordResume,
|
||||
};
|
||||
|
||||
class OBSControl : public Window {
|
||||
public:
|
||||
OBSControl(SpiceOverlay *overlay);
|
||||
~OBSControl() override;
|
||||
|
||||
void build_content() override;
|
||||
|
||||
// thread-safe snapshot of the current status for external widgets (e.g. FPS)
|
||||
OBSStatus get_status();
|
||||
|
||||
// live (ticked) duration in ms from a base value/tick captured at last poll
|
||||
static int64_t live_duration_ms(int64_t base_ms, int64_t base_tick, bool ticking);
|
||||
|
||||
private:
|
||||
// worker thread entry + helpers (implementation owns the WebSocket)
|
||||
void worker_main();
|
||||
|
||||
// run one connected session loop until the socket closes or we stop;
|
||||
// returns true if the obs-websocket handshake reached "Identified", false
|
||||
// if the socket closed first (e.g. OBS rejected our auth)
|
||||
bool run_session(easywsclient::WebSocket *ws, const std::string &password,
|
||||
uint64_t &request_id);
|
||||
|
||||
// handle a single inbound obs-websocket message (parses + dispatches)
|
||||
void handle_message(easywsclient::WebSocket *ws, const std::string &message,
|
||||
const std::string &password, uint64_t &request_id,
|
||||
bool &identified);
|
||||
|
||||
// per-opcode handlers dispatched from handle_message
|
||||
using request_fn = std::function<void(const char *request_type)>;
|
||||
void handle_identified(bool &identified, const request_fn &request);
|
||||
void handle_event(const rapidjson::Value &d, const request_fn &request);
|
||||
void handle_response(const rapidjson::Value &d);
|
||||
|
||||
void enqueue_request(const std::string &request_type);
|
||||
|
||||
// sleep up to total_ms, waking early if the worker is asked to stop
|
||||
void interruptible_sleep(int total_ms);
|
||||
|
||||
// worker thread
|
||||
std::thread worker_thread;
|
||||
std::atomic<bool> worker_running { false };
|
||||
|
||||
// wakes interruptible_sleep immediately when worker_running is cleared,
|
||||
// so shutdown (and the reconnect backoff) never waits out a fixed delay
|
||||
std::mutex worker_mutex;
|
||||
std::condition_variable worker_cv;
|
||||
|
||||
// shared status (guarded by status_mutex)
|
||||
std::mutex status_mutex;
|
||||
OBSStatus status;
|
||||
|
||||
// outgoing user commands (guarded by command_mutex)
|
||||
std::mutex command_mutex;
|
||||
std::deque<std::string> command_queue;
|
||||
|
||||
// transient action feedback, touched only by the render thread (no sync):
|
||||
// remembers the last start/stop/pause request per output so the button can
|
||||
// show a "Starting.../Stopping..." label and stay disabled until OBS reports
|
||||
// the matching state, with *_deadline as a fallback if the update is missed
|
||||
OBSAction stream_pending = OBSAction::None;
|
||||
OBSAction record_pending = OBSAction::None;
|
||||
int64_t stream_pending_deadline = 0;
|
||||
int64_t record_pending_deadline = 0;
|
||||
};
|
||||
}
|
||||
#pragma once
|
||||
|
||||
#include <atomic>
|
||||
#include <condition_variable>
|
||||
#include <cstdint>
|
||||
#include <deque>
|
||||
#include <functional>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
|
||||
#include "external/rapidjson/fwd.h"
|
||||
#include "overlay/window.h"
|
||||
|
||||
namespace easywsclient {
|
||||
class WebSocket;
|
||||
}
|
||||
|
||||
namespace overlay::windows {
|
||||
|
||||
// OBS WebSocket connection settings, resolved once at launch from the merged
|
||||
// launcher options (command line + saved config) following the same pattern
|
||||
// as the other global launch settings in launcher.cpp
|
||||
extern bool OBS_CONTROL_ENABLED;
|
||||
extern std::string OBS_CONTROL_HOST;
|
||||
extern uint16_t OBS_CONTROL_PORT;
|
||||
extern std::string OBS_CONTROL_PASSWORD;
|
||||
|
||||
// when true, easywsclient's internal diagnostics are routed to the logger
|
||||
extern bool OBS_CONTROL_DEBUG;
|
||||
|
||||
// status snapshot shared between the OBS worker thread and the render thread
|
||||
struct OBSStatus {
|
||||
bool disabled = true;
|
||||
bool connected = false;
|
||||
bool identifying = false;
|
||||
std::string connection_error;
|
||||
|
||||
// name of the active program scene (read-only, from obs-websocket)
|
||||
std::string current_scene;
|
||||
|
||||
bool streaming = false;
|
||||
bool recording = false;
|
||||
bool record_paused = false;
|
||||
|
||||
// duration base values (milliseconds) and the local timestamp (ms since
|
||||
// steady epoch) at which they were last refreshed, so the UI can tick a
|
||||
// smooth timer between polls
|
||||
int64_t stream_duration_ms = 0;
|
||||
int64_t record_duration_ms = 0;
|
||||
int64_t stream_duration_base_tick = 0;
|
||||
int64_t record_duration_base_tick = 0;
|
||||
};
|
||||
|
||||
// in-flight user action used purely for UI feedback: when the user clicks a
|
||||
// control we remember what we asked for so the button can show a transitional
|
||||
// label and stay disabled until the observed OBS state matches the request
|
||||
// (or a short deadline lapses). owned solely by the render thread.
|
||||
enum class OBSAction {
|
||||
None,
|
||||
StreamStart, StreamStop,
|
||||
RecordStart, RecordStop,
|
||||
RecordPause, RecordResume,
|
||||
};
|
||||
|
||||
class OBSControl : public Window {
|
||||
public:
|
||||
OBSControl(SpiceOverlay *overlay);
|
||||
~OBSControl() override;
|
||||
|
||||
void build_content() override;
|
||||
|
||||
// thread-safe snapshot of the current status for external widgets (e.g. FPS)
|
||||
OBSStatus get_status();
|
||||
|
||||
// live (ticked) duration in ms from a base value/tick captured at last poll
|
||||
static int64_t live_duration_ms(int64_t base_ms, int64_t base_tick, bool ticking);
|
||||
|
||||
private:
|
||||
// worker thread entry + helpers (implementation owns the WebSocket)
|
||||
void worker_main();
|
||||
|
||||
// run one connected session loop until the socket closes or we stop;
|
||||
// returns true if the obs-websocket handshake reached "Identified", false
|
||||
// if the socket closed first (e.g. OBS rejected our auth)
|
||||
bool run_session(easywsclient::WebSocket *ws, const std::string &password,
|
||||
uint64_t &request_id);
|
||||
|
||||
// handle a single inbound obs-websocket message (parses + dispatches)
|
||||
void handle_message(easywsclient::WebSocket *ws, const std::string &message,
|
||||
const std::string &password, uint64_t &request_id,
|
||||
bool &identified);
|
||||
|
||||
// per-opcode handlers dispatched from handle_message
|
||||
using request_fn = std::function<void(const char *request_type)>;
|
||||
void handle_identified(bool &identified, const request_fn &request);
|
||||
void handle_event(const rapidjson::Value &d, const request_fn &request);
|
||||
void handle_response(const rapidjson::Value &d);
|
||||
|
||||
void enqueue_request(const std::string &request_type);
|
||||
|
||||
// sleep up to total_ms, waking early if the worker is asked to stop
|
||||
void interruptible_sleep(int total_ms);
|
||||
|
||||
// worker thread
|
||||
std::thread worker_thread;
|
||||
std::atomic<bool> worker_running { false };
|
||||
|
||||
// wakes interruptible_sleep immediately when worker_running is cleared,
|
||||
// so shutdown (and the reconnect backoff) never waits out a fixed delay
|
||||
std::mutex worker_mutex;
|
||||
std::condition_variable worker_cv;
|
||||
|
||||
// shared status (guarded by status_mutex)
|
||||
std::mutex status_mutex;
|
||||
OBSStatus status;
|
||||
|
||||
// outgoing user commands (guarded by command_mutex)
|
||||
std::mutex command_mutex;
|
||||
std::deque<std::string> command_queue;
|
||||
|
||||
// transient action feedback, touched only by the render thread (no sync):
|
||||
// remembers the last start/stop/pause request per output so the button can
|
||||
// show a "Starting.../Stopping..." label and stay disabled until OBS reports
|
||||
// the matching state, with *_deadline as a fallback if the update is missed
|
||||
OBSAction stream_pending = OBSAction::None;
|
||||
OBSAction record_pending = OBSAction::None;
|
||||
int64_t stream_pending_deadline = 0;
|
||||
int64_t record_pending_deadline = 0;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,434 +1,434 @@
|
||||
#include <winsock2.h>
|
||||
|
||||
#include "obs.h"
|
||||
|
||||
#include <chrono>
|
||||
|
||||
#include "external/easywsclient/easywsclient.hpp"
|
||||
#include "external/rapidjson/document.h"
|
||||
#include "external/rapidjson/stringbuffer.h"
|
||||
#include "external/rapidjson/writer.h"
|
||||
#include "external/hash-library/sha256.h"
|
||||
|
||||
#include "overlay/notifications.h"
|
||||
#include "util/crypt.h"
|
||||
#include "util/logging.h"
|
||||
|
||||
// defined in easywsclient.cpp; gates its internal diagnostic output
|
||||
extern bool EASYWSCLIENT_LOGGING_ENABLED;
|
||||
|
||||
using easywsclient::WebSocket;
|
||||
using namespace std::chrono;
|
||||
|
||||
// obs-websocket v5 message flow (https://github.com/obsproject/obs-websocket):
|
||||
// server -> op 0 Hello (may include an auth challenge)
|
||||
// client -> op 1 Identify (answers the challenge, picks rpcVersion)
|
||||
// server -> op 2 Identified (handshake done; requests may now be sent)
|
||||
// server -> op 5 Event (state changes: stream/record/scene/...)
|
||||
// client -> op 6 Request (e.g. GetStreamStatus, StartRecord)
|
||||
// server -> op 7 RequestResponse (reply to a Request, carries responseData)
|
||||
// Every message is { "op": <int>, "d": { ... } }. Event fields are nested under
|
||||
// d["eventData"] and request replies under d["responseData"], not in d directly.
|
||||
|
||||
namespace {
|
||||
|
||||
int64_t now_ms() {
|
||||
return duration_cast<milliseconds>(steady_clock::now().time_since_epoch()).count();
|
||||
}
|
||||
|
||||
// raw SHA256 digest -> base64 (obs-websocket v5 auth primitive)
|
||||
std::string sha256_base64(const std::string &input) {
|
||||
SHA256 hasher;
|
||||
hasher.add(input.data(), input.size());
|
||||
unsigned char digest[SHA256::HashBytes];
|
||||
hasher.getHash(digest);
|
||||
return crypt::base64_encode(reinterpret_cast<const uint8_t *>(digest), SHA256::HashBytes);
|
||||
}
|
||||
|
||||
// auth = base64(sha256(base64(sha256(password + salt)) + challenge))
|
||||
std::string compute_auth(const std::string &password, const std::string &salt,
|
||||
const std::string &challenge) {
|
||||
const std::string secret = sha256_base64(password + salt);
|
||||
return sha256_base64(secret + challenge);
|
||||
}
|
||||
|
||||
std::string build_identify(int rpc_version, const std::string &authentication) {
|
||||
rapidjson::StringBuffer sb;
|
||||
rapidjson::Writer<rapidjson::StringBuffer> w(sb);
|
||||
w.StartObject();
|
||||
w.Key("op"); w.Int(1);
|
||||
w.Key("d");
|
||||
w.StartObject();
|
||||
w.Key("rpcVersion"); w.Int(rpc_version);
|
||||
if (!authentication.empty()) {
|
||||
w.Key("authentication"); w.String(authentication.c_str());
|
||||
}
|
||||
w.EndObject();
|
||||
w.EndObject();
|
||||
return sb.GetString();
|
||||
}
|
||||
|
||||
std::string build_request(const std::string &request_type, uint64_t request_id) {
|
||||
rapidjson::StringBuffer sb;
|
||||
rapidjson::Writer<rapidjson::StringBuffer> w(sb);
|
||||
w.StartObject();
|
||||
w.Key("op"); w.Int(6);
|
||||
w.Key("d");
|
||||
w.StartObject();
|
||||
w.Key("requestType"); w.String(request_type.c_str());
|
||||
w.Key("requestId"); w.String(std::to_string(request_id).c_str());
|
||||
w.EndObject();
|
||||
w.EndObject();
|
||||
return sb.GetString();
|
||||
}
|
||||
|
||||
// read a numeric field as int64 ms (obs sends durations as integers/doubles)
|
||||
int64_t json_number(const rapidjson::Value &obj, const char *key) {
|
||||
if (obj.HasMember(key) && obj[key].IsNumber()) {
|
||||
return static_cast<int64_t>(obj[key].GetDouble());
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
bool json_bool(const rapidjson::Value &obj, const char *key) {
|
||||
return obj.HasMember(key) && obj[key].IsBool() && obj[key].GetBool();
|
||||
}
|
||||
|
||||
std::string json_string(const rapidjson::Value &obj, const char *key) {
|
||||
if (obj.HasMember(key) && obj[key].IsString()) {
|
||||
return obj[key].GetString();
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
// build the Identify (op 1) reply to a Hello (op 0), answering the auth
|
||||
// challenge if the server requires one
|
||||
std::string build_hello_response(const rapidjson::Value &d, const std::string &password) {
|
||||
int rpc_version = 1;
|
||||
if (d.HasMember("rpcVersion") && d["rpcVersion"].IsInt()) {
|
||||
rpc_version = d["rpcVersion"].GetInt();
|
||||
}
|
||||
std::string auth;
|
||||
if (d.HasMember("authentication") && d["authentication"].IsObject()) {
|
||||
const rapidjson::Value &a = d["authentication"];
|
||||
const std::string challenge = json_string(a, "challenge");
|
||||
const std::string salt = json_string(a, "salt");
|
||||
if (!challenge.empty()) {
|
||||
auth = compute_auth(password, salt, challenge);
|
||||
}
|
||||
}
|
||||
return build_identify(rpc_version, auth);
|
||||
}
|
||||
|
||||
// map an obs-websocket outputState to a user notification. `label` is the
|
||||
// output kind ("Streaming" or "Recording"). transitional states are ignored.
|
||||
void notify_output_state(const char *label, const std::string &state) {
|
||||
using overlay::notifications::Severity;
|
||||
|
||||
struct StateToast {
|
||||
const char *state;
|
||||
Severity severity;
|
||||
const char *verb;
|
||||
};
|
||||
static const StateToast TOASTS[] = {
|
||||
{ "OBS_WEBSOCKET_OUTPUT_STARTED", Severity::Success, "started" },
|
||||
{ "OBS_WEBSOCKET_OUTPUT_STOPPED", Severity::Info, "stopped" },
|
||||
{ "OBS_WEBSOCKET_OUTPUT_PAUSED", Severity::Warning, "paused" },
|
||||
{ "OBS_WEBSOCKET_OUTPUT_RESUMED", Severity::Info, "resumed" },
|
||||
};
|
||||
|
||||
for (const auto &toast : TOASTS) {
|
||||
if (state == toast.state) {
|
||||
overlay::notifications::add(toast.severity,
|
||||
"OBS: " + std::string(label) + " " + toast.verb);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
namespace overlay::windows {
|
||||
|
||||
// connection settings resolved at launch (see launcher.cpp)
|
||||
bool OBS_CONTROL_ENABLED = false;
|
||||
std::string OBS_CONTROL_HOST = "127.0.0.1";
|
||||
uint16_t OBS_CONTROL_PORT = 4455;
|
||||
std::string OBS_CONTROL_PASSWORD;
|
||||
bool OBS_CONTROL_DEBUG = false;
|
||||
|
||||
void OBSControl::enqueue_request(const std::string &request_type) {
|
||||
std::lock_guard<std::mutex> lock(this->command_mutex);
|
||||
this->command_queue.push_back(request_type);
|
||||
}
|
||||
|
||||
void OBSControl::interruptible_sleep(int total_ms) {
|
||||
std::unique_lock<std::mutex> lock(this->worker_mutex);
|
||||
this->worker_cv.wait_for(lock, milliseconds(total_ms),
|
||||
[this] { return !this->worker_running.load(); });
|
||||
}
|
||||
|
||||
void OBSControl::handle_message(WebSocket *ws, const std::string &message,
|
||||
const std::string &password, uint64_t &request_id, bool &identified) {
|
||||
|
||||
rapidjson::Document doc;
|
||||
if (doc.Parse(message.c_str()).HasParseError() || !doc.IsObject()) {
|
||||
return;
|
||||
}
|
||||
if (!doc.HasMember("op") || !doc["op"].IsInt()
|
||||
|| !doc.HasMember("d") || !doc["d"].IsObject()) {
|
||||
return;
|
||||
}
|
||||
const int op = doc["op"].GetInt();
|
||||
const rapidjson::Value &d = doc["d"];
|
||||
|
||||
// send an op 6 Request; each needs a unique id (we never match replies
|
||||
// back, so a simple incrementing counter is enough)
|
||||
const request_fn request = [&](const char *request_type) {
|
||||
ws->send(build_request(request_type, ++request_id));
|
||||
};
|
||||
|
||||
switch (op) {
|
||||
case 0: // Hello
|
||||
// server greeted us: reply with Identify, solving the auth
|
||||
// challenge inline if the server set a password
|
||||
ws->send(build_hello_response(d, password));
|
||||
break;
|
||||
case 2: // Identified
|
||||
this->handle_identified(identified, request);
|
||||
break;
|
||||
case 5: // Event
|
||||
this->handle_event(d, request);
|
||||
break;
|
||||
case 7: // RequestResponse
|
||||
this->handle_response(d);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void OBSControl::handle_identified(bool &identified, const request_fn &request) {
|
||||
// handshake complete: the connection is now usable for requests
|
||||
identified = true;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(this->status_mutex);
|
||||
this->status.connected = true;
|
||||
this->status.identifying = false;
|
||||
this->status.connection_error.clear();
|
||||
}
|
||||
log_info("obs", "connected and identified");
|
||||
|
||||
// pull the current scene/stream/record state so the UI starts accurate
|
||||
request("GetCurrentProgramScene");
|
||||
request("GetStreamStatus");
|
||||
request("GetRecordStatus");
|
||||
}
|
||||
|
||||
void OBSControl::handle_event(const rapidjson::Value &d, const request_fn &request) {
|
||||
const std::string type = json_string(d, "eventType");
|
||||
const bool has_data = d.HasMember("eventData") && d["eventData"].IsObject();
|
||||
|
||||
if (type == "StreamStateChanged") {
|
||||
if (has_data) {
|
||||
notify_output_state("Streaming", json_string(d["eventData"], "outputState"));
|
||||
}
|
||||
request("GetStreamStatus");
|
||||
} else if (type == "RecordStateChanged") {
|
||||
if (has_data) {
|
||||
notify_output_state("Recording", json_string(d["eventData"], "outputState"));
|
||||
}
|
||||
request("GetRecordStatus");
|
||||
} else if (type == "CurrentProgramSceneChanged" && has_data) {
|
||||
std::lock_guard<std::mutex> lock(this->status_mutex);
|
||||
this->status.current_scene = json_string(d["eventData"], "sceneName");
|
||||
}
|
||||
}
|
||||
|
||||
void OBSControl::handle_response(const rapidjson::Value &d) {
|
||||
const std::string type = json_string(d, "requestType");
|
||||
if (type.empty() || !d.HasMember("responseData") || !d["responseData"].IsObject()) {
|
||||
return;
|
||||
}
|
||||
const rapidjson::Value &rd = d["responseData"];
|
||||
|
||||
std::lock_guard<std::mutex> lock(this->status_mutex);
|
||||
if (type == "GetCurrentProgramScene") {
|
||||
// newer obs returns sceneName; older builds used the now-deprecated
|
||||
// currentProgramSceneName, so prefer it then fall back
|
||||
std::string scene = json_string(rd, "currentProgramSceneName");
|
||||
if (scene.empty()) {
|
||||
scene = json_string(rd, "sceneName");
|
||||
}
|
||||
this->status.current_scene = scene;
|
||||
} else if (type == "GetStreamStatus") {
|
||||
this->status.streaming = json_bool(rd, "outputActive");
|
||||
this->status.stream_duration_ms = json_number(rd, "outputDuration");
|
||||
this->status.stream_duration_base_tick = now_ms();
|
||||
} else if (type == "GetRecordStatus") {
|
||||
this->status.recording = json_bool(rd, "outputActive");
|
||||
this->status.record_paused = json_bool(rd, "outputPaused");
|
||||
this->status.record_duration_ms = json_number(rd, "outputDuration");
|
||||
this->status.record_duration_base_tick = now_ms();
|
||||
}
|
||||
}
|
||||
|
||||
bool OBSControl::run_session(WebSocket *ws, const std::string &password, uint64_t &request_id) {
|
||||
|
||||
// one iteration of a live connection: pump socket I/O, dispatch any
|
||||
// inbound messages, flush queued user commands, then refresh status
|
||||
bool identified = false;
|
||||
// handle_identified() issues the first GetStreamStatus/GetRecordStatus on
|
||||
// identify, so the periodic poll below just maintains the ~1s cadence
|
||||
auto last_status_poll = steady_clock::now();
|
||||
|
||||
// send a request with the next sequential id
|
||||
const auto request = [&](const char *request_type) {
|
||||
ws->send(build_request(request_type, ++request_id));
|
||||
};
|
||||
|
||||
while (this->worker_running.load() && ws->getReadyState() != WebSocket::CLOSED) {
|
||||
ws->poll(100);
|
||||
|
||||
ws->dispatch([&](const std::string &message) {
|
||||
this->handle_message(ws, message, password, request_id, identified);
|
||||
});
|
||||
|
||||
if (ws->getReadyState() == WebSocket::CLOSED) {
|
||||
break;
|
||||
}
|
||||
|
||||
// nothing may be sent until the op 2 Identified handshake completes
|
||||
if (!identified) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// drain user commands
|
||||
std::deque<std::string> pending;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(this->command_mutex);
|
||||
pending.swap(this->command_queue);
|
||||
}
|
||||
for (const auto &cmd : pending) {
|
||||
ws->send(build_request(cmd, ++request_id));
|
||||
}
|
||||
|
||||
// periodic status refresh (~1s) for live duration
|
||||
const auto now = steady_clock::now();
|
||||
if (now - last_status_poll >= milliseconds(1000)) {
|
||||
last_status_poll = now;
|
||||
request("GetStreamStatus");
|
||||
request("GetRecordStatus");
|
||||
}
|
||||
}
|
||||
|
||||
return identified;
|
||||
}
|
||||
|
||||
void OBSControl::worker_main() {
|
||||
|
||||
// connection settings are resolved once at launch into globals
|
||||
// (launcher.cpp, from the merged command-line + saved config options)
|
||||
if (!OBS_CONTROL_ENABLED) {
|
||||
log_info("obs", "disabled, not connecting");
|
||||
std::lock_guard<std::mutex> lock(this->status_mutex);
|
||||
this->status.disabled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const std::string url = "ws://" + OBS_CONTROL_HOST + ":" + std::to_string(OBS_CONTROL_PORT);
|
||||
const std::string password = OBS_CONTROL_PASSWORD;
|
||||
|
||||
// opt easywsclient's internal diagnostics in/out per the debug option
|
||||
EASYWSCLIENT_LOGGING_ENABLED = OBS_CONTROL_DEBUG;
|
||||
|
||||
// winsock is reference-counted: the app performs its own WSAStartup at
|
||||
// launch (which outlives this worker), so this paired Startup/Cleanup only
|
||||
// bumps the refcount and the WSACleanup below never tears down winsock for
|
||||
// the rest of the process
|
||||
WSADATA wsa_data;
|
||||
WSAStartup(MAKEWORD(2, 2), &wsa_data);
|
||||
log_info("obs", "enabled, connecting to {}", url);
|
||||
|
||||
uint64_t request_id = 0;
|
||||
|
||||
// the reconnect loop retries every 5s; latch the auth-failure warning so a
|
||||
// wrong password logs once, not on every retry. reset after any identified
|
||||
// session so a later genuine failure is reported again
|
||||
bool auth_warning_logged = false;
|
||||
|
||||
// reconnect loop: keep a session alive while enabled, retrying on drop
|
||||
while (this->worker_running.load()) {
|
||||
|
||||
// mark "connecting" for the UI before each attempt
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(this->status_mutex);
|
||||
this->status.disabled = false;
|
||||
this->status.connected = false;
|
||||
this->status.identifying = true;
|
||||
this->status.connection_error.clear();
|
||||
}
|
||||
|
||||
// open the TCP socket and perform the WebSocket handshake; null means
|
||||
// OBS is unreachable (not running / wrong port / obs-websocket off)
|
||||
WebSocket::pointer ws = WebSocket::from_url(url);
|
||||
if (ws == nullptr) {
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(this->status_mutex);
|
||||
this->status.identifying = false;
|
||||
this->status.connection_error = "Unable to connect";
|
||||
}
|
||||
interruptible_sleep(5000);
|
||||
continue;
|
||||
}
|
||||
|
||||
// blocks here pumping the connection until it closes or we stop.
|
||||
// a session that never reaches "Identified" was rejected by OBS,
|
||||
// overwhelmingly because the password is wrong or missing
|
||||
const bool identified = this->run_session(ws, password, request_id);
|
||||
|
||||
// session ended: close the socket cleanly and free it
|
||||
ws->close();
|
||||
ws->poll();
|
||||
delete ws;
|
||||
|
||||
if (!identified && this->worker_running.load()) {
|
||||
if (!auth_warning_logged) {
|
||||
log_warning("obs", "connection closed before identify; "
|
||||
"OBS likely rejected authentication (check the password)");
|
||||
auth_warning_logged = true;
|
||||
}
|
||||
} else if (identified) {
|
||||
// a good session resets the latch so a future failure logs again
|
||||
auth_warning_logged = false;
|
||||
}
|
||||
|
||||
// connection dropped: clear live state so the UI doesn't show stale
|
||||
// scene/stream/record info while disconnected
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(this->status_mutex);
|
||||
this->status.connected = false;
|
||||
this->status.identifying = false;
|
||||
if (this->status.connection_error.empty()) {
|
||||
this->status.connection_error =
|
||||
identified ? "Disconnected" : "Auth failed (check password)";
|
||||
}
|
||||
this->status.streaming = false;
|
||||
this->status.recording = false;
|
||||
this->status.record_paused = false;
|
||||
this->status.current_scene.clear();
|
||||
}
|
||||
|
||||
// clear any commands queued while disconnected
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(this->command_mutex);
|
||||
this->command_queue.clear();
|
||||
}
|
||||
|
||||
// wait before reconnecting (interruptible)
|
||||
interruptible_sleep(5000);
|
||||
}
|
||||
|
||||
WSACleanup();
|
||||
log_info("obs", "OBS overlay worker stopped");
|
||||
}
|
||||
}
|
||||
#include <winsock2.h>
|
||||
|
||||
#include "obs.h"
|
||||
|
||||
#include <chrono>
|
||||
|
||||
#include "external/easywsclient/easywsclient.hpp"
|
||||
#include "external/rapidjson/document.h"
|
||||
#include "external/rapidjson/stringbuffer.h"
|
||||
#include "external/rapidjson/writer.h"
|
||||
#include "external/hash-library/sha256.h"
|
||||
|
||||
#include "overlay/notifications.h"
|
||||
#include "util/crypt.h"
|
||||
#include "util/logging.h"
|
||||
|
||||
// defined in easywsclient.cpp; gates its internal diagnostic output
|
||||
extern bool EASYWSCLIENT_LOGGING_ENABLED;
|
||||
|
||||
using easywsclient::WebSocket;
|
||||
using namespace std::chrono;
|
||||
|
||||
// obs-websocket v5 message flow (https://github.com/obsproject/obs-websocket):
|
||||
// server -> op 0 Hello (may include an auth challenge)
|
||||
// client -> op 1 Identify (answers the challenge, picks rpcVersion)
|
||||
// server -> op 2 Identified (handshake done; requests may now be sent)
|
||||
// server -> op 5 Event (state changes: stream/record/scene/...)
|
||||
// client -> op 6 Request (e.g. GetStreamStatus, StartRecord)
|
||||
// server -> op 7 RequestResponse (reply to a Request, carries responseData)
|
||||
// Every message is { "op": <int>, "d": { ... } }. Event fields are nested under
|
||||
// d["eventData"] and request replies under d["responseData"], not in d directly.
|
||||
|
||||
namespace {
|
||||
|
||||
int64_t now_ms() {
|
||||
return duration_cast<milliseconds>(steady_clock::now().time_since_epoch()).count();
|
||||
}
|
||||
|
||||
// raw SHA256 digest -> base64 (obs-websocket v5 auth primitive)
|
||||
std::string sha256_base64(const std::string &input) {
|
||||
SHA256 hasher;
|
||||
hasher.add(input.data(), input.size());
|
||||
unsigned char digest[SHA256::HashBytes];
|
||||
hasher.getHash(digest);
|
||||
return crypt::base64_encode(reinterpret_cast<const uint8_t *>(digest), SHA256::HashBytes);
|
||||
}
|
||||
|
||||
// auth = base64(sha256(base64(sha256(password + salt)) + challenge))
|
||||
std::string compute_auth(const std::string &password, const std::string &salt,
|
||||
const std::string &challenge) {
|
||||
const std::string secret = sha256_base64(password + salt);
|
||||
return sha256_base64(secret + challenge);
|
||||
}
|
||||
|
||||
std::string build_identify(int rpc_version, const std::string &authentication) {
|
||||
rapidjson::StringBuffer sb;
|
||||
rapidjson::Writer<rapidjson::StringBuffer> w(sb);
|
||||
w.StartObject();
|
||||
w.Key("op"); w.Int(1);
|
||||
w.Key("d");
|
||||
w.StartObject();
|
||||
w.Key("rpcVersion"); w.Int(rpc_version);
|
||||
if (!authentication.empty()) {
|
||||
w.Key("authentication"); w.String(authentication.c_str());
|
||||
}
|
||||
w.EndObject();
|
||||
w.EndObject();
|
||||
return sb.GetString();
|
||||
}
|
||||
|
||||
std::string build_request(const std::string &request_type, uint64_t request_id) {
|
||||
rapidjson::StringBuffer sb;
|
||||
rapidjson::Writer<rapidjson::StringBuffer> w(sb);
|
||||
w.StartObject();
|
||||
w.Key("op"); w.Int(6);
|
||||
w.Key("d");
|
||||
w.StartObject();
|
||||
w.Key("requestType"); w.String(request_type.c_str());
|
||||
w.Key("requestId"); w.String(std::to_string(request_id).c_str());
|
||||
w.EndObject();
|
||||
w.EndObject();
|
||||
return sb.GetString();
|
||||
}
|
||||
|
||||
// read a numeric field as int64 ms (obs sends durations as integers/doubles)
|
||||
int64_t json_number(const rapidjson::Value &obj, const char *key) {
|
||||
if (obj.HasMember(key) && obj[key].IsNumber()) {
|
||||
return static_cast<int64_t>(obj[key].GetDouble());
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
bool json_bool(const rapidjson::Value &obj, const char *key) {
|
||||
return obj.HasMember(key) && obj[key].IsBool() && obj[key].GetBool();
|
||||
}
|
||||
|
||||
std::string json_string(const rapidjson::Value &obj, const char *key) {
|
||||
if (obj.HasMember(key) && obj[key].IsString()) {
|
||||
return obj[key].GetString();
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
// build the Identify (op 1) reply to a Hello (op 0), answering the auth
|
||||
// challenge if the server requires one
|
||||
std::string build_hello_response(const rapidjson::Value &d, const std::string &password) {
|
||||
int rpc_version = 1;
|
||||
if (d.HasMember("rpcVersion") && d["rpcVersion"].IsInt()) {
|
||||
rpc_version = d["rpcVersion"].GetInt();
|
||||
}
|
||||
std::string auth;
|
||||
if (d.HasMember("authentication") && d["authentication"].IsObject()) {
|
||||
const rapidjson::Value &a = d["authentication"];
|
||||
const std::string challenge = json_string(a, "challenge");
|
||||
const std::string salt = json_string(a, "salt");
|
||||
if (!challenge.empty()) {
|
||||
auth = compute_auth(password, salt, challenge);
|
||||
}
|
||||
}
|
||||
return build_identify(rpc_version, auth);
|
||||
}
|
||||
|
||||
// map an obs-websocket outputState to a user notification. `label` is the
|
||||
// output kind ("Streaming" or "Recording"). transitional states are ignored.
|
||||
void notify_output_state(const char *label, const std::string &state) {
|
||||
using overlay::notifications::Severity;
|
||||
|
||||
struct StateToast {
|
||||
const char *state;
|
||||
Severity severity;
|
||||
const char *verb;
|
||||
};
|
||||
static const StateToast TOASTS[] = {
|
||||
{ "OBS_WEBSOCKET_OUTPUT_STARTED", Severity::Success, "started" },
|
||||
{ "OBS_WEBSOCKET_OUTPUT_STOPPED", Severity::Info, "stopped" },
|
||||
{ "OBS_WEBSOCKET_OUTPUT_PAUSED", Severity::Warning, "paused" },
|
||||
{ "OBS_WEBSOCKET_OUTPUT_RESUMED", Severity::Info, "resumed" },
|
||||
};
|
||||
|
||||
for (const auto &toast : TOASTS) {
|
||||
if (state == toast.state) {
|
||||
overlay::notifications::add(toast.severity,
|
||||
"OBS: " + std::string(label) + " " + toast.verb);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
namespace overlay::windows {
|
||||
|
||||
// connection settings resolved at launch (see launcher.cpp)
|
||||
bool OBS_CONTROL_ENABLED = false;
|
||||
std::string OBS_CONTROL_HOST = "127.0.0.1";
|
||||
uint16_t OBS_CONTROL_PORT = 4455;
|
||||
std::string OBS_CONTROL_PASSWORD;
|
||||
bool OBS_CONTROL_DEBUG = false;
|
||||
|
||||
void OBSControl::enqueue_request(const std::string &request_type) {
|
||||
std::lock_guard<std::mutex> lock(this->command_mutex);
|
||||
this->command_queue.push_back(request_type);
|
||||
}
|
||||
|
||||
void OBSControl::interruptible_sleep(int total_ms) {
|
||||
std::unique_lock<std::mutex> lock(this->worker_mutex);
|
||||
this->worker_cv.wait_for(lock, milliseconds(total_ms),
|
||||
[this] { return !this->worker_running.load(); });
|
||||
}
|
||||
|
||||
void OBSControl::handle_message(WebSocket *ws, const std::string &message,
|
||||
const std::string &password, uint64_t &request_id, bool &identified) {
|
||||
|
||||
rapidjson::Document doc;
|
||||
if (doc.Parse(message.c_str()).HasParseError() || !doc.IsObject()) {
|
||||
return;
|
||||
}
|
||||
if (!doc.HasMember("op") || !doc["op"].IsInt()
|
||||
|| !doc.HasMember("d") || !doc["d"].IsObject()) {
|
||||
return;
|
||||
}
|
||||
const int op = doc["op"].GetInt();
|
||||
const rapidjson::Value &d = doc["d"];
|
||||
|
||||
// send an op 6 Request; each needs a unique id (we never match replies
|
||||
// back, so a simple incrementing counter is enough)
|
||||
const request_fn request = [&](const char *request_type) {
|
||||
ws->send(build_request(request_type, ++request_id));
|
||||
};
|
||||
|
||||
switch (op) {
|
||||
case 0: // Hello
|
||||
// server greeted us: reply with Identify, solving the auth
|
||||
// challenge inline if the server set a password
|
||||
ws->send(build_hello_response(d, password));
|
||||
break;
|
||||
case 2: // Identified
|
||||
this->handle_identified(identified, request);
|
||||
break;
|
||||
case 5: // Event
|
||||
this->handle_event(d, request);
|
||||
break;
|
||||
case 7: // RequestResponse
|
||||
this->handle_response(d);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void OBSControl::handle_identified(bool &identified, const request_fn &request) {
|
||||
// handshake complete: the connection is now usable for requests
|
||||
identified = true;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(this->status_mutex);
|
||||
this->status.connected = true;
|
||||
this->status.identifying = false;
|
||||
this->status.connection_error.clear();
|
||||
}
|
||||
log_info("obs", "connected and identified");
|
||||
|
||||
// pull the current scene/stream/record state so the UI starts accurate
|
||||
request("GetCurrentProgramScene");
|
||||
request("GetStreamStatus");
|
||||
request("GetRecordStatus");
|
||||
}
|
||||
|
||||
void OBSControl::handle_event(const rapidjson::Value &d, const request_fn &request) {
|
||||
const std::string type = json_string(d, "eventType");
|
||||
const bool has_data = d.HasMember("eventData") && d["eventData"].IsObject();
|
||||
|
||||
if (type == "StreamStateChanged") {
|
||||
if (has_data) {
|
||||
notify_output_state("Streaming", json_string(d["eventData"], "outputState"));
|
||||
}
|
||||
request("GetStreamStatus");
|
||||
} else if (type == "RecordStateChanged") {
|
||||
if (has_data) {
|
||||
notify_output_state("Recording", json_string(d["eventData"], "outputState"));
|
||||
}
|
||||
request("GetRecordStatus");
|
||||
} else if (type == "CurrentProgramSceneChanged" && has_data) {
|
||||
std::lock_guard<std::mutex> lock(this->status_mutex);
|
||||
this->status.current_scene = json_string(d["eventData"], "sceneName");
|
||||
}
|
||||
}
|
||||
|
||||
void OBSControl::handle_response(const rapidjson::Value &d) {
|
||||
const std::string type = json_string(d, "requestType");
|
||||
if (type.empty() || !d.HasMember("responseData") || !d["responseData"].IsObject()) {
|
||||
return;
|
||||
}
|
||||
const rapidjson::Value &rd = d["responseData"];
|
||||
|
||||
std::lock_guard<std::mutex> lock(this->status_mutex);
|
||||
if (type == "GetCurrentProgramScene") {
|
||||
// newer obs returns sceneName; older builds used the now-deprecated
|
||||
// currentProgramSceneName, so prefer it then fall back
|
||||
std::string scene = json_string(rd, "currentProgramSceneName");
|
||||
if (scene.empty()) {
|
||||
scene = json_string(rd, "sceneName");
|
||||
}
|
||||
this->status.current_scene = scene;
|
||||
} else if (type == "GetStreamStatus") {
|
||||
this->status.streaming = json_bool(rd, "outputActive");
|
||||
this->status.stream_duration_ms = json_number(rd, "outputDuration");
|
||||
this->status.stream_duration_base_tick = now_ms();
|
||||
} else if (type == "GetRecordStatus") {
|
||||
this->status.recording = json_bool(rd, "outputActive");
|
||||
this->status.record_paused = json_bool(rd, "outputPaused");
|
||||
this->status.record_duration_ms = json_number(rd, "outputDuration");
|
||||
this->status.record_duration_base_tick = now_ms();
|
||||
}
|
||||
}
|
||||
|
||||
bool OBSControl::run_session(WebSocket *ws, const std::string &password, uint64_t &request_id) {
|
||||
|
||||
// one iteration of a live connection: pump socket I/O, dispatch any
|
||||
// inbound messages, flush queued user commands, then refresh status
|
||||
bool identified = false;
|
||||
// handle_identified() issues the first GetStreamStatus/GetRecordStatus on
|
||||
// identify, so the periodic poll below just maintains the ~1s cadence
|
||||
auto last_status_poll = steady_clock::now();
|
||||
|
||||
// send a request with the next sequential id
|
||||
const auto request = [&](const char *request_type) {
|
||||
ws->send(build_request(request_type, ++request_id));
|
||||
};
|
||||
|
||||
while (this->worker_running.load() && ws->getReadyState() != WebSocket::CLOSED) {
|
||||
ws->poll(100);
|
||||
|
||||
ws->dispatch([&](const std::string &message) {
|
||||
this->handle_message(ws, message, password, request_id, identified);
|
||||
});
|
||||
|
||||
if (ws->getReadyState() == WebSocket::CLOSED) {
|
||||
break;
|
||||
}
|
||||
|
||||
// nothing may be sent until the op 2 Identified handshake completes
|
||||
if (!identified) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// drain user commands
|
||||
std::deque<std::string> pending;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(this->command_mutex);
|
||||
pending.swap(this->command_queue);
|
||||
}
|
||||
for (const auto &cmd : pending) {
|
||||
ws->send(build_request(cmd, ++request_id));
|
||||
}
|
||||
|
||||
// periodic status refresh (~1s) for live duration
|
||||
const auto now = steady_clock::now();
|
||||
if (now - last_status_poll >= milliseconds(1000)) {
|
||||
last_status_poll = now;
|
||||
request("GetStreamStatus");
|
||||
request("GetRecordStatus");
|
||||
}
|
||||
}
|
||||
|
||||
return identified;
|
||||
}
|
||||
|
||||
void OBSControl::worker_main() {
|
||||
|
||||
// connection settings are resolved once at launch into globals
|
||||
// (launcher.cpp, from the merged command-line + saved config options)
|
||||
if (!OBS_CONTROL_ENABLED) {
|
||||
log_info("obs", "disabled, not connecting");
|
||||
std::lock_guard<std::mutex> lock(this->status_mutex);
|
||||
this->status.disabled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const std::string url = "ws://" + OBS_CONTROL_HOST + ":" + std::to_string(OBS_CONTROL_PORT);
|
||||
const std::string password = OBS_CONTROL_PASSWORD;
|
||||
|
||||
// opt easywsclient's internal diagnostics in/out per the debug option
|
||||
EASYWSCLIENT_LOGGING_ENABLED = OBS_CONTROL_DEBUG;
|
||||
|
||||
// winsock is reference-counted: the app performs its own WSAStartup at
|
||||
// launch (which outlives this worker), so this paired Startup/Cleanup only
|
||||
// bumps the refcount and the WSACleanup below never tears down winsock for
|
||||
// the rest of the process
|
||||
WSADATA wsa_data;
|
||||
WSAStartup(MAKEWORD(2, 2), &wsa_data);
|
||||
log_info("obs", "enabled, connecting to {}", url);
|
||||
|
||||
uint64_t request_id = 0;
|
||||
|
||||
// the reconnect loop retries every 5s; latch the auth-failure warning so a
|
||||
// wrong password logs once, not on every retry. reset after any identified
|
||||
// session so a later genuine failure is reported again
|
||||
bool auth_warning_logged = false;
|
||||
|
||||
// reconnect loop: keep a session alive while enabled, retrying on drop
|
||||
while (this->worker_running.load()) {
|
||||
|
||||
// mark "connecting" for the UI before each attempt
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(this->status_mutex);
|
||||
this->status.disabled = false;
|
||||
this->status.connected = false;
|
||||
this->status.identifying = true;
|
||||
this->status.connection_error.clear();
|
||||
}
|
||||
|
||||
// open the TCP socket and perform the WebSocket handshake; null means
|
||||
// OBS is unreachable (not running / wrong port / obs-websocket off)
|
||||
WebSocket::pointer ws = WebSocket::from_url(url);
|
||||
if (ws == nullptr) {
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(this->status_mutex);
|
||||
this->status.identifying = false;
|
||||
this->status.connection_error = "Unable to connect";
|
||||
}
|
||||
interruptible_sleep(5000);
|
||||
continue;
|
||||
}
|
||||
|
||||
// blocks here pumping the connection until it closes or we stop.
|
||||
// a session that never reaches "Identified" was rejected by OBS,
|
||||
// overwhelmingly because the password is wrong or missing
|
||||
const bool identified = this->run_session(ws, password, request_id);
|
||||
|
||||
// session ended: close the socket cleanly and free it
|
||||
ws->close();
|
||||
ws->poll();
|
||||
delete ws;
|
||||
|
||||
if (!identified && this->worker_running.load()) {
|
||||
if (!auth_warning_logged) {
|
||||
log_warning("obs", "connection closed before identify; "
|
||||
"OBS likely rejected authentication (check the password)");
|
||||
auth_warning_logged = true;
|
||||
}
|
||||
} else if (identified) {
|
||||
// a good session resets the latch so a future failure logs again
|
||||
auth_warning_logged = false;
|
||||
}
|
||||
|
||||
// connection dropped: clear live state so the UI doesn't show stale
|
||||
// scene/stream/record info while disconnected
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(this->status_mutex);
|
||||
this->status.connected = false;
|
||||
this->status.identifying = false;
|
||||
if (this->status.connection_error.empty()) {
|
||||
this->status.connection_error =
|
||||
identified ? "Disconnected" : "Auth failed (check password)";
|
||||
}
|
||||
this->status.streaming = false;
|
||||
this->status.recording = false;
|
||||
this->status.record_paused = false;
|
||||
this->status.current_scene.clear();
|
||||
}
|
||||
|
||||
// clear any commands queued while disconnected
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(this->command_mutex);
|
||||
this->command_queue.clear();
|
||||
}
|
||||
|
||||
// wait before reconnecting (interruptible)
|
||||
interruptible_sleep(5000);
|
||||
}
|
||||
|
||||
WSACleanup();
|
||||
log_info("obs", "OBS overlay worker stopped");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user