From ce8d9d4522cc79c19eaefdf833aec0af3e07c530 Mon Sep 17 00:00:00 2001 From: WarmUpTill <19472752+WarmUpTill@users.noreply.github.com> Date: Sat, 11 Apr 2026 19:16:35 +0200 Subject: [PATCH 01/13] Add helpers to check, run, stop, and pause macros --- lib/macro/macro-helpers.cpp | 28 ++++++++++++++++++++++++---- lib/macro/macro-helpers.hpp | 10 ++++++++-- 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/lib/macro/macro-helpers.cpp b/lib/macro/macro-helpers.cpp index 01470c37..57ca7729 100644 --- a/lib/macro/macro-helpers.cpp +++ b/lib/macro/macro-helpers.cpp @@ -214,6 +214,20 @@ bool MacroIsPaused(const Macro *macro) return macro ? macro->Paused() : true; } +void SetMacroPaused(Macro *macro, bool paused) +{ + if (macro) { + macro->SetPaused(paused); + } +} + +void StopMacro(Macro *macro) +{ + if (macro) { + macro->Stop(); + } +} + bool MacroWasPausedSince( const Macro *macro, const std::chrono::high_resolution_clock::time_point &time) @@ -237,14 +251,20 @@ void AddMacroHelperThread(Macro *macro, std::thread &&newThread) macro->AddHelperThread(std::move(newThread)); } -bool RunMacroActions(Macro *macro) +bool RunMacroActions(Macro *macro, bool forceParallel, bool ignorePause) { - return macro && macro->PerformActions(true); + return macro && macro->PerformActions(true, forceParallel, ignorePause); } -bool RunMacroElseActions(Macro *macro) +bool RunMacroElseActions(Macro *macro, bool forceParallel, bool ignorePause) { - return macro && macro->PerformActions(false); + return macro && + macro->PerformActions(false, forceParallel, ignorePause); +} + +bool CheckMacroConditions(Macro *macro, bool ignorePause) +{ + return macro && macro->CheckConditions(ignorePause); } void ResetMacroConditionTimers(Macro *macro) diff --git a/lib/macro/macro-helpers.hpp b/lib/macro/macro-helpers.hpp index ab0a1a2a..c31bc7e3 100644 --- a/lib/macro/macro-helpers.hpp +++ b/lib/macro/macro-helpers.hpp @@ -58,6 +58,8 @@ LastMacroConditionCheckTime(const Macro *); EXPORT bool MacroIsStopped(const Macro *); EXPORT bool MacroIsPaused(const Macro *); +EXPORT void SetMacroPaused(Macro *, bool paused); +EXPORT void StopMacro(Macro *); EXPORT bool MacroWasPausedSince(const Macro *, const std::chrono::high_resolution_clock::time_point &); @@ -66,10 +68,14 @@ EXPORT bool MacroWasCheckedSinceLastStart(const Macro *); EXPORT void AddMacroHelperThread(Macro *, std::thread &&); EXPORT bool CheckMacros(); +EXPORT bool CheckMacroConditions(Macro *, bool ignorePause = false); -EXPORT bool RunMacroActions(Macro *); -bool RunMacroElseActions(Macro *); +EXPORT bool RunMacroActions(Macro *, bool forceParallel = false, + bool ignorePause = false); +EXPORT bool RunMacroElseActions(Macro *, bool forceParallel = false, + bool ignorePause = false); EXPORT bool RunMacros(); + void StopAllMacros(); EXPORT void LoadMacros(obs_data_t *obj); From ec7800597f7180196746a50278b81d285e2a4c71 Mon Sep 17 00:00:00 2001 From: WarmUpTill <19472752+WarmUpTill@users.noreply.github.com> Date: Sat, 11 Apr 2026 19:17:06 +0200 Subject: [PATCH 02/13] Call MacroRef::PostLoad() automatically --- lib/macro/macro-ref.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/macro/macro-ref.cpp b/lib/macro/macro-ref.cpp index db5897df..9e55728c 100644 --- a/lib/macro/macro-ref.cpp +++ b/lib/macro/macro-ref.cpp @@ -1,6 +1,8 @@ #include "macro-ref.hpp" #include "macro.hpp" +#include "plugin-state-helpers.hpp" + namespace advss { MacroRef::MacroRef(std::string name) @@ -19,6 +21,7 @@ void MacroRef::Load(obs_data_t *obj) auto name = obs_data_get_string(obj, "macro"); _postLoadName = name; _macro = GetWeakMacroByName(name); + AddPostLoadStep([this]() { MacroRef::PostLoad(); }); } void MacroRef::PostLoad() From c0d27cc0db5d069acf68c8b93ffb7c760efca03b Mon Sep 17 00:00:00 2001 From: WarmUpTill <19472752+WarmUpTill@users.noreply.github.com> Date: Sun, 19 Apr 2026 19:54:00 +0200 Subject: [PATCH 03/13] Add API to expand export / import dialog --- CMakeLists.txt | 3 + data/locale/en-US.ini | 3 + lib/macro/macro-export-builtin-extensions.cpp | 89 ++++++ lib/macro/macro-export-extensions.cpp | 17 ++ lib/macro/macro-export-extensions.hpp | 41 +++ lib/macro/macro-export-import-dialog.cpp | 257 +++++++++++++++--- lib/macro/macro-export-import-dialog.hpp | 26 +- lib/macro/macro-tab.cpp | 9 +- 8 files changed, 400 insertions(+), 45 deletions(-) create mode 100644 lib/macro/macro-export-builtin-extensions.cpp create mode 100644 lib/macro/macro-export-extensions.cpp create mode 100644 lib/macro/macro-export-extensions.hpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 6afeb9cb..887b53ae 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -134,6 +134,9 @@ target_sources( lib/macro/macro-dock-window.hpp lib/macro/macro-edit.cpp lib/macro/macro-edit.hpp + lib/macro/macro-export-builtin-extensions.cpp + lib/macro/macro-export-extensions.cpp + lib/macro/macro-export-extensions.hpp lib/macro/macro-export-import-dialog.cpp lib/macro/macro-export-import-dialog.hpp lib/macro/macro-helpers.cpp diff --git a/data/locale/en-US.ini b/data/locale/en-US.ini index 1c58b335..83c87593 100644 --- a/data/locale/en-US.ini +++ b/data/locale/en-US.ini @@ -202,6 +202,9 @@ AdvSceneSwitcher.macroTab.remove="Remove" AdvSceneSwitcher.macroTab.export="Export" AdvSceneSwitcher.macroTab.export.info="Paste the string below into the import dialog to import the selected macros:" AdvSceneSwitcher.macroTab.export.usePlainText="Use plain text" +AdvSceneSwitcher.macroTab.export.additionalContent="Export Additional Content" +AdvSceneSwitcher.macroTab.export.variables="Variables" +AdvSceneSwitcher.macroTab.export.actionQueues="Action Queues" AdvSceneSwitcher.macroTab.import="Import" AdvSceneSwitcher.macroTab.import.info="Paste the export string into the below text box to import macros:" AdvSceneSwitcher.macroTab.import.invalid="Invalid import data provided!" diff --git a/lib/macro/macro-export-builtin-extensions.cpp b/lib/macro/macro-export-builtin-extensions.cpp new file mode 100644 index 00000000..d0291099 --- /dev/null +++ b/lib/macro/macro-export-builtin-extensions.cpp @@ -0,0 +1,89 @@ +#include "macro-export-extensions.hpp" + +#include "action-queue.hpp" +#include "variable.hpp" + +#include + +namespace advss { + +static bool setup(); +static bool setupDone = setup(); + +static bool setup() +{ + // --- Variables --- + AddMacroExportExtension( + {"AdvSceneSwitcher.macroTab.export.variables", "variables", + [](obs_data_t *data, const QStringList &selectedIds) { + if (selectedIds.isEmpty()) { + SaveVariables(data); + return; + } + OBSDataArrayAutoRelease array = + obs_data_array_create(); + for (const auto &v : GetVariables()) { + const QString name = + QString::fromStdString(v->Name()); + if (!selectedIds.contains(name)) { + continue; + } + OBSDataAutoRelease item = obs_data_create(); + v->Save(item); + obs_data_array_push_back(array, item); + } + obs_data_set_array(data, "variables", array); + }, + [](obs_data_t *data, const QStringList &) { + ImportVariables(data); + }, + []() -> QList> { + QList> items; + for (const auto &v : GetVariables()) { + const QString name = + QString::fromStdString(v->Name()); + items.append({name, name}); + } + return items; + }}); + + // --- Action Queues --- + AddMacroExportExtension( + {"AdvSceneSwitcher.macroTab.export.actionQueues", + "actionQueues", + [](obs_data_t *data, const QStringList &selectedIds) { + if (selectedIds.isEmpty()) { + SaveActionQueues(data); + return; + } + OBSDataArrayAutoRelease array = + obs_data_array_create(); + for (const auto &q : GetActionQueues()) { + const QString name = + QString::fromStdString(q->Name()); + if (!selectedIds.contains(name)) { + continue; + } + OBSDataAutoRelease item = obs_data_create(); + q->Save(item); + obs_data_array_push_back(array, item); + } + obs_data_set_array(data, "actionQueues", array); + }, + [](obs_data_t *data, const QStringList &) { + ImportQueues(data); + }, + []() -> QList> { + QList> items; + for (const auto &q : GetActionQueues()) { + const QString name = + QString::fromStdString(q->Name()); + items.append({name, name}); + } + return items; + }}); + + return true; +} + +} // namespace advss diff --git a/lib/macro/macro-export-extensions.cpp b/lib/macro/macro-export-extensions.cpp new file mode 100644 index 00000000..5a49fc08 --- /dev/null +++ b/lib/macro/macro-export-extensions.cpp @@ -0,0 +1,17 @@ +#include "macro-export-extensions.hpp" + +namespace advss { + +static std::vector extensions; + +void AddMacroExportExtension(const MacroExportExtension &ext) +{ + extensions.emplace_back(ext); +} + +const std::vector &GetMacroExportExtensions() +{ + return extensions; +} + +} // namespace advss diff --git a/lib/macro/macro-export-extensions.hpp b/lib/macro/macro-export-extensions.hpp new file mode 100644 index 00000000..f994bb43 --- /dev/null +++ b/lib/macro/macro-export-extensions.hpp @@ -0,0 +1,41 @@ +#pragma once + +#include "export-symbol-helper.hpp" + +#include +#include +#include +#include +#include +#include + +namespace advss { + +struct MacroExportExtension { + const char *displayNameKey; + const char *jsonKey; + + // Export callback. + // Write content into 'data'. 'selectedIds' contains the item IDs the + // user explicitly checked; if empty (no per-item widget provided) save + // everything. + std::function + save; + + // Import callback. + // Read content from 'data'. 'selectedIds' contains the item IDs the + // user explicitly checked; if empty import every item found in 'data'. + std::function + load; + + // Optional: enumerate items available for export as (id, displayName) + // pairs. When nullptr the extension appears as a single all-or-nothing + // checkbox in the export dialog. + std::function>()> getExportItems = + nullptr; +}; + +EXPORT void AddMacroExportExtension(const MacroExportExtension &ext); +const std::vector &GetMacroExportExtensions(); + +} // namespace advss diff --git a/lib/macro/macro-export-import-dialog.cpp b/lib/macro/macro-export-import-dialog.cpp index 35290e94..6b548318 100644 --- a/lib/macro/macro-export-import-dialog.cpp +++ b/lib/macro/macro-export-import-dialog.cpp @@ -1,22 +1,160 @@ #include "macro-export-import-dialog.hpp" +#include "macro-export-extensions.hpp" #include "obs-module-helper.hpp" #include -#include -#include #include +#include +#include +#include +#include namespace advss { static bool usePlainText = false; -MacroExportImportDialog::MacroExportImportDialog(Type type) +// --------------------------------------------------------------------------- +// Compress / decompress helpers +// --------------------------------------------------------------------------- + +static QString compressMacroString(const QString &input) +{ + QByteArray inputData = input.toUtf8(); + auto compressedData = qCompress(inputData); + QByteArray encodedData = compressedData.toBase64(); + return QString::fromUtf8(encodedData); +} + +static QString decompressMacroString(const QString &input) +{ + QByteArray encodedData = input.toUtf8(); + QByteArray compressedData = QByteArray::fromBase64(encodedData); + auto outputData = qUncompress(compressedData); + return QString::fromUtf8(outputData); +} + +static bool isValidData(const QString &json) +{ + OBSDataAutoRelease data = + obs_data_create_from_json(json.toStdString().c_str()); + return !!data; +} + +// --------------------------------------------------------------------------- +// Extension section +// --------------------------------------------------------------------------- + +Section *MacroExportImportDialog::BuildExtensionSection() +{ + auto outerSection = new Section(300, this); + outerSection->AddHeaderWidget(new QLabel(obs_module_text( + "AdvSceneSwitcher.macroTab.export.additionalContent"))); + + auto outerContent = new QWidget(); + auto outerLayout = new QVBoxLayout(outerContent); + outerLayout->setContentsMargins(4, 4, 4, 4); + outerLayout->setSpacing(4); + + const auto &extensions = GetMacroExportExtensions(); + + // Build a sorted index list (alphabetical by translated display name). + std::vector order(extensions.size()); + std::iota(order.begin(), order.end(), 0); + std::sort(order.begin(), order.end(), [&](int a, int b) { + return QString::localeAwareCompare( + obs_module_text(extensions[a].displayNameKey), + obs_module_text(extensions[b].displayNameKey)) < + 0; + }); + + for (int extIdx : order) { + const auto &ext = extensions[extIdx]; + ExtensionUI ui; + + // Simple all-or-nothing checkbox (no sub-items). + if (!ext.getExportItems) { + ui.mainCheck = new QCheckBox( + obs_module_text(ext.displayNameKey), + outerContent); + ui.mainCheck->setChecked(false); + connect(ui.mainCheck, &QCheckBox::stateChanged, this, + &MacroExportImportDialog::UpdateExportString); + outerLayout->addWidget(ui.mainCheck); + _extensionUIs.append(std::move(ui)); + continue; + } + + // Extensions with per-item selection get their own inner + // Section: the main checkbox sits in the header (next to the + // toggle arrow) and the per-item checkboxes live in the + // collapsible content area. + const auto items = ext.getExportItems(); + if (items.isEmpty()) { + continue; + } + + auto innerSection = new Section(200, outerContent); + + ui.mainCheck = + new QCheckBox(obs_module_text(ext.displayNameKey)); + ui.mainCheck->setChecked(false); + innerSection->AddHeaderWidget(ui.mainCheck); + + auto subWidget = new QWidget(); + auto subLayout = new QVBoxLayout(subWidget); + subLayout->setContentsMargins(4, 2, 4, 2); + subLayout->setSpacing(2); + + for (const auto &[id, displayName] : items) { + auto cb = new QCheckBox(displayName, subWidget); + cb->setChecked(false); + subLayout->addWidget(cb); + ui.itemChecks.append({id, cb}); + connect(cb, &QCheckBox::stateChanged, this, + &MacroExportImportDialog::UpdateExportString); + } + + // Main checkbox toggles all sub-items. + // Capture uiIdx (position in _extensionUIs after append). + const int uiIdx = _extensionUIs.size(); + connect(ui.mainCheck, &QCheckBox::stateChanged, this, + [this, uiIdx](int state) { + if (state == Qt::PartiallyChecked) + return; + const bool checked = (state == Qt::Checked); + if (uiIdx < _extensionUIs.size()) { + for (auto &[id, cb] : + _extensionUIs[uiIdx].itemChecks) { + QSignalBlocker b(cb); + cb->setChecked(checked); + } + } + UpdateExportString(); + }); + + innerSection->SetContent(subWidget, false); + outerLayout->addWidget(innerSection); + _extensionUIs.append(std::move(ui)); + } + + outerSection->SetContent(outerContent, true); + return outerSection; +} + +// --------------------------------------------------------------------------- +// Constructor +// --------------------------------------------------------------------------- + +MacroExportImportDialog::MacroExportImportDialog(Type type, + const QString &baseJson) : QDialog(nullptr), + _baseJson(baseJson), _importExportString(new QPlainTextEdit(this)), _usePlainText(new QCheckBox(obs_module_text( "AdvSceneSwitcher.macroTab.export.usePlainText"))) { _importExportString->setReadOnly(type == Type::EXPORT_MACRO); + auto label = new QLabel(obs_module_text( type == Type::EXPORT_MACRO ? "AdvSceneSwitcher.macroTab.export.info" @@ -39,7 +177,12 @@ MacroExportImportDialog::MacroExportImportDialog(Type type) connect(_usePlainText, &QCheckBox::stateChanged, this, &MacroExportImportDialog::UsePlainTextChanged); - auto layout = new QVBoxLayout; + auto layout = new QVBoxLayout(this); + + if (type == Type::EXPORT_MACRO && !GetMacroExportExtensions().empty()) { + layout->addWidget(BuildExtensionSection()); + } + layout->addWidget(label); layout->addWidget(_importExportString); layout->addWidget(_usePlainText); @@ -47,32 +190,57 @@ MacroExportImportDialog::MacroExportImportDialog(Type type) setLayout(layout); setWindowTitle(obs_module_text("AdvSceneSwitcher.windowTitle")); + + if (type == Type::EXPORT_MACRO) { + RefreshExportText(); + } } -QString compressMacroString(const QString &input) +// --------------------------------------------------------------------------- +// Export string helpers +// --------------------------------------------------------------------------- + +QString MacroExportImportDialog::BuildExportJson() const { - QByteArray inputData = input.toUtf8(); - auto compressedData = qCompress(inputData); - QByteArray encodedData = compressedData.toBase64(); - return QString::fromUtf8(encodedData); + OBSDataAutoRelease data = + obs_data_create_from_json(_baseJson.toStdString().c_str()); + if (!data) { + return _baseJson; + } + + const auto &extensions = GetMacroExportExtensions(); + for (int i = 0; + i < (int)extensions.size() && i < (int)_extensionUIs.size(); ++i) { + if (!_extensionUIs[i].mainCheck->isChecked()) { + continue; + } + + QStringList selectedIds; + for (const auto &[id, cb] : _extensionUIs[i].itemChecks) { + if (cb->isChecked()) { + selectedIds << id; + } + } + extensions[i].save(data, selectedIds); + } + + const char *json = obs_data_get_json(data); + return json ? QString::fromUtf8(json) : QString{}; } -QString decompressMacroString(const QString &input) +void MacroExportImportDialog::RefreshExportText() { - QByteArray encodedData = input.toUtf8(); - QByteArray compressedData = QByteArray::fromBase64(encodedData); - auto outputData = qUncompress(compressedData); - return QString::fromUtf8(outputData); + const QString json = BuildExportJson(); + if (usePlainText) { + _importExportString->setPlainText(json); + } else { + _importExportString->setPlainText(compressMacroString(json)); + } } -void MacroExportImportDialog::ExportMacros(const QString &json) +void MacroExportImportDialog::UpdateExportString() { - MacroExportImportDialog dialog( - MacroExportImportDialog::Type::EXPORT_MACRO); - dialog._importExportString->setPlainText(compressMacroString(json)); - dialog.adjustSize(); - dialog.updateGeometry(); - dialog.exec(); + RefreshExportText(); } void MacroExportImportDialog::UsePlainTextChanged(int value) @@ -90,26 +258,45 @@ void MacroExportImportDialog::UsePlainTextChanged(int value) usePlainText = value; } -static bool isValidData(const QString &json) +// --------------------------------------------------------------------------- +// Static entry points +// --------------------------------------------------------------------------- + +void MacroExportImportDialog::ExportMacros(obs_data_t *macroData) { - OBSDataAutoRelease data = - obs_data_create_from_json(json.toStdString().c_str()); - return !!data; + const char *rawJson = obs_data_get_json(macroData); + const QString baseJson = rawJson ? QString::fromUtf8(rawJson) + : QString{}; + + MacroExportImportDialog dialog(Type::EXPORT_MACRO, baseJson); + dialog.adjustSize(); + dialog.updateGeometry(); + dialog.exec(); } bool MacroExportImportDialog::ImportMacros(QString &json) { - MacroExportImportDialog dialog( - MacroExportImportDialog::Type::IMPORT_MACRO); - if (dialog.exec() == QDialog::Accepted) { - json = decompressMacroString( - dialog._importExportString->toPlainText()); - if (!isValidData(json)) { // Fallback to support raw json format - json = dialog._importExportString->toPlainText(); - } - return true; + MacroExportImportDialog dialog(Type::IMPORT_MACRO); + if (dialog.exec() != QDialog::Accepted) { + return false; } - return false; + + json = decompressMacroString(dialog._importExportString->toPlainText()); + if (!isValidData(json)) { + // Fallback: support raw (uncompressed) JSON pasted directly. + json = dialog._importExportString->toPlainText(); + } + + // Invoke all extension load callbacks. + OBSDataAutoRelease data = + obs_data_create_from_json(json.toStdString().c_str()); + if (data) { + for (const auto &ext : GetMacroExportExtensions()) { + ext.load(data, {}); + } + } + + return true; } } // namespace advss diff --git a/lib/macro/macro-export-import-dialog.hpp b/lib/macro/macro-export-import-dialog.hpp index c180f454..040dfacd 100644 --- a/lib/macro/macro-export-import-dialog.hpp +++ b/lib/macro/macro-export-import-dialog.hpp @@ -1,25 +1,47 @@ #pragma once + +#include "section.hpp" + #include #include +#include #include +#include + namespace advss { class MacroExportImportDialog : public QDialog { Q_OBJECT public: enum class Type { EXPORT_MACRO, IMPORT_MACRO }; - MacroExportImportDialog(Type type); - static void ExportMacros(const QString &json); + static void ExportMacros(obs_data_t *macroData); static bool ImportMacros(QString &json); private slots: void UsePlainTextChanged(int); + void UpdateExportString(); private: + explicit MacroExportImportDialog(Type type, + const QString &baseJson = {}); + + // Per-extension widgets + struct ExtensionUI { + QCheckBox *mainCheck = nullptr; + // (itemId, checkbox) pairs - empty when no per-item selection. + QList> itemChecks; + }; + + Section *BuildExtensionSection(); + QString BuildExportJson() const; + void RefreshExportText(); + + QString _baseJson; QPlainTextEdit *_importExportString; QCheckBox *_usePlainText; + QList _extensionUIs; }; } // namespace advss diff --git a/lib/macro/macro-tab.cpp b/lib/macro/macro-tab.cpp index d5aba05b..ecc3ad43 100644 --- a/lib/macro/macro-tab.cpp +++ b/lib/macro/macro-tab.cpp @@ -1,5 +1,4 @@ #include "advanced-scene-switcher.hpp" -#include "action-queue.hpp" #include "macro-action-edit.hpp" #include "macro-condition-edit.hpp" #include "macro-export-import-dialog.hpp" @@ -316,13 +315,9 @@ void AdvSceneSwitcher::ExportMacros() const obs_data_array_push_back(macroArray, obj); } obs_data_set_array(data, "macros", macroArray); - SaveVariables(data); - SaveActionQueues(data); obs_data_set_string(data, "version", g_GIT_TAG); - auto json = obs_data_get_json(data); - QString exportString(json); - MacroExportImportDialog::ExportMacros(exportString); + MacroExportImportDialog::ExportMacros(data); } bool AdvSceneSwitcher::ResolveMacroImportNameConflict( @@ -384,8 +379,6 @@ void AdvSceneSwitcher::ImportMacros() ImportMacros(); return; } - ImportVariables(data); - ImportQueues(data); auto version = obs_data_get_string(data, "version"); if (strcmp(version, g_GIT_TAG) != 0) { From ba4d879b8d312efad6693c5b377886daa4228860 Mon Sep 17 00:00:00 2001 From: WarmUpTill <19472752+WarmUpTill@users.noreply.github.com> Date: Sun, 19 Apr 2026 20:47:23 +0200 Subject: [PATCH 04/13] Add option to export Websocket, Twitch, HTTP connections --- data/locale/en-US.ini | 3 ++ plugins/base/utils/connection-manager.cpp | 46 +++++++++++++++++++++++ plugins/http/http-server.cpp | 44 ++++++++++++++++++++++ plugins/twitch/token.cpp | 44 ++++++++++++++++++++++ 4 files changed, 137 insertions(+) diff --git a/data/locale/en-US.ini b/data/locale/en-US.ini index 83c87593..b925cccb 100644 --- a/data/locale/en-US.ini +++ b/data/locale/en-US.ini @@ -205,6 +205,9 @@ AdvSceneSwitcher.macroTab.export.usePlainText="Use plain text" AdvSceneSwitcher.macroTab.export.additionalContent="Export Additional Content" AdvSceneSwitcher.macroTab.export.variables="Variables" AdvSceneSwitcher.macroTab.export.actionQueues="Action Queues" +AdvSceneSwitcher.macroTab.export.httpServers="HTTP Servers" +AdvSceneSwitcher.macroTab.export.twitchConnections="Twitch Connections" +AdvSceneSwitcher.macroTab.export.websocketConnections="Websocket Connections" AdvSceneSwitcher.macroTab.import="Import" AdvSceneSwitcher.macroTab.import.info="Paste the export string into the below text box to import macros:" AdvSceneSwitcher.macroTab.import.invalid="Invalid import data provided!" diff --git a/plugins/base/utils/connection-manager.cpp b/plugins/base/utils/connection-manager.cpp index c869eda2..daa92aea 100644 --- a/plugins/base/utils/connection-manager.cpp +++ b/plugins/base/utils/connection-manager.cpp @@ -1,5 +1,6 @@ #include "connection-manager.hpp" #include "layout-helpers.hpp" +#include "macro-export-extensions.hpp" #include "name-dialog.hpp" #include "obs-module-helper.hpp" #include "plugin-state-helpers.hpp" @@ -24,6 +25,51 @@ bool setup() AddSaveStep(saveConnections); AddLoadStep(loadConnections); AddPluginCleanupStep([]() { connections.clear(); }); + AddMacroExportExtension( + {"AdvSceneSwitcher.macroTab.export.websocketConnections", + "websocketConnections", + [](obs_data_t *data, const QStringList &selectedIds) { + OBSDataArrayAutoRelease array = + obs_data_array_create(); + for (const auto &c : connections) { + if (!selectedIds.isEmpty() && + !selectedIds.contains( + QString::fromStdString(c->Name()))) + continue; + OBSDataAutoRelease item = obs_data_create(); + c->Save(item); + obs_data_array_push_back(array, item); + } + obs_data_set_array(data, "websocketConnections", + array); + }, + [](obs_data_t *data, const QStringList &) { + OBSDataArrayAutoRelease array = obs_data_get_array( + data, "websocketConnections"); + const size_t count = obs_data_array_count(array); + for (size_t i = 0; i < count; ++i) { + OBSDataAutoRelease item = + obs_data_array_item(array, i); + auto con = WSConnection::Create(); + con->Load(item); + if (!GetWeakConnectionByName(con->Name()) + .expired()) + continue; + connections.emplace_back(con); + ConnectionSelectionSignalManager::Instance() + ->Add(QString::fromStdString( + con->Name())); + } + }, + []() -> QList> { + QList> items; + for (const auto &c : connections) { + const QString name = + QString::fromStdString(c->Name()); + items.append({name, name}); + } + return items; + }}); return true; } diff --git a/plugins/http/http-server.cpp b/plugins/http/http-server.cpp index 4117b543..cd7d8810 100644 --- a/plugins/http/http-server.cpp +++ b/plugins/http/http-server.cpp @@ -1,6 +1,7 @@ #include "http-server.hpp" #include "layout-helpers.hpp" #include "log-helper.hpp" +#include "macro-export-extensions.hpp" #include "obs-module-helper.hpp" #include "plugin-state-helpers.hpp" @@ -32,6 +33,49 @@ bool setup() } httpServers.clear(); }); + AddMacroExportExtension( + {"AdvSceneSwitcher.macroTab.export.httpServers", "httpServers", + [](obs_data_t *data, const QStringList &selectedIds) { + OBSDataArrayAutoRelease array = + obs_data_array_create(); + for (const auto &s : httpServers) { + if (!selectedIds.isEmpty() && + !selectedIds.contains( + QString::fromStdString(s->Name()))) + continue; + OBSDataAutoRelease item = obs_data_create(); + s->Save(item); + obs_data_array_push_back(array, item); + } + obs_data_set_array(data, "httpServers", array); + }, + [](obs_data_t *data, const QStringList &) { + OBSDataArrayAutoRelease array = + obs_data_get_array(data, "httpServers"); + const size_t count = obs_data_array_count(array); + for (size_t i = 0; i < count; ++i) { + OBSDataAutoRelease item = + obs_data_array_item(array, i); + auto server = HttpServer::Create(); + server->Load(item); + if (!GetWeakHttpServerByName(server->Name()) + .expired()) + continue; + httpServers.emplace_back(server); + HttpServerSignalManager::Instance()->Add( + QString::fromStdString( + server->Name())); + } + }, + []() -> QList> { + QList> items; + for (const auto &s : httpServers) { + const QString name = + QString::fromStdString(s->Name()); + items.append({name, name}); + } + return items; + }}); return true; } diff --git a/plugins/twitch/token.cpp b/plugins/twitch/token.cpp index f62a4004..4aba4570 100644 --- a/plugins/twitch/token.cpp +++ b/plugins/twitch/token.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include #include @@ -88,6 +89,49 @@ static bool setupTwitchTokenSupport() AddSaveStep(saveConnections); AddLoadStep(loadConnections); AddPluginCleanupStep([]() { twitchTokens.clear(); }); + AddMacroExportExtension( + {"AdvSceneSwitcher.macroTab.export.twitchConnections", + "twitchConnections", + [](obs_data_t *data, const QStringList &selectedIds) { + OBSDataArrayAutoRelease array = + obs_data_array_create(); + for (const auto &t : twitchTokens) { + if (!selectedIds.isEmpty() && + !selectedIds.contains( + QString::fromStdString(t->Name()))) + continue; + OBSDataAutoRelease item = obs_data_create(); + t->Save(item); + obs_data_array_push_back(array, item); + } + obs_data_set_array(data, "twitchConnections", array); + }, + [](obs_data_t *data, const QStringList &) { + OBSDataArrayAutoRelease array = + obs_data_get_array(data, "twitchConnections"); + const size_t count = obs_data_array_count(array); + for (size_t i = 0; i < count; ++i) { + OBSDataAutoRelease item = + obs_data_array_item(array, i); + auto token = TwitchToken::Create(); + token->Load(item); + if (!GetWeakTwitchTokenByName(token->Name()) + .expired()) + continue; + twitchTokens.emplace_back(token); + TwitchConnectionSignalManager::Instance()->Add( + QString::fromStdString(token->Name())); + } + }, + []() -> QList> { + QList> items; + for (const auto &t : twitchTokens) { + const QString name = + QString::fromStdString(t->Name()); + items.append({name, name}); + } + return items; + }}); return true; } From e9b4bbfd42e47ea2838560ae43f939ee510b8af4 Mon Sep 17 00:00:00 2001 From: WarmUpTill <19472752+WarmUpTill@users.noreply.github.com> Date: Sat, 11 Apr 2026 22:42:49 +0200 Subject: [PATCH 05/13] Add calendar widget --- data/locale/en-US.ini | 7 + .../schedule/calendar/calendar-day-view.cpp | 493 ++++++++++++++++ .../schedule/calendar/calendar-day-view.hpp | 44 ++ plugins/schedule/calendar/calendar-event.hpp | 29 + .../schedule/calendar/calendar-month-view.cpp | 284 +++++++++ .../schedule/calendar/calendar-month-view.hpp | 57 ++ plugins/schedule/calendar/calendar-view.hpp | 49 ++ .../schedule/calendar/calendar-week-view.cpp | 542 ++++++++++++++++++ .../schedule/calendar/calendar-week-view.hpp | 45 ++ plugins/schedule/calendar/calendar-widget.cpp | 308 ++++++++++ plugins/schedule/calendar/calendar-widget.hpp | 90 +++ 11 files changed, 1948 insertions(+) create mode 100644 plugins/schedule/calendar/calendar-day-view.cpp create mode 100644 plugins/schedule/calendar/calendar-day-view.hpp create mode 100644 plugins/schedule/calendar/calendar-event.hpp create mode 100644 plugins/schedule/calendar/calendar-month-view.cpp create mode 100644 plugins/schedule/calendar/calendar-month-view.hpp create mode 100644 plugins/schedule/calendar/calendar-view.hpp create mode 100644 plugins/schedule/calendar/calendar-week-view.cpp create mode 100644 plugins/schedule/calendar/calendar-week-view.hpp create mode 100644 plugins/schedule/calendar/calendar-widget.cpp create mode 100644 plugins/schedule/calendar/calendar-widget.hpp diff --git a/data/locale/en-US.ini b/data/locale/en-US.ini index b925cccb..34e6db35 100644 --- a/data/locale/en-US.ini +++ b/data/locale/en-US.ini @@ -2711,6 +2711,13 @@ Basic.Settings.Video.FPSCommon="Common FPS Values" Basic.Settings.Video.FPSInteger="Integer FPS Value" Basic.Settings.Video.FPSFraction="Fractional FPS Value" +# Calendar widget +AdvSceneSwitcher.calendar.today="Today" +AdvSceneSwitcher.calendar.month="Month" +AdvSceneSwitcher.calendar.week="Week" +AdvSceneSwitcher.calendar.day="Day" +AdvSceneSwitcher.calendar.moreEvents="+%1 more" + # Legacy tabs below - please don't waste your time adding translations for these :) # Transition Tab AdvSceneSwitcher.transitionTab.title="Transition" diff --git a/plugins/schedule/calendar/calendar-day-view.cpp b/plugins/schedule/calendar/calendar-day-view.cpp new file mode 100644 index 00000000..db3860c1 --- /dev/null +++ b/plugins/schedule/calendar/calendar-day-view.cpp @@ -0,0 +1,493 @@ +#include "calendar-day-view.hpp" + +#include +#include +#include +#include +#include +#include + +#include + +namespace advss { + +// --------------------------------------------------------------------------- +// CalendarDayHeader – fixed date strip above the scroll area +// --------------------------------------------------------------------------- + +class CalendarDayHeader : public QWidget { +public: + explicit CalendarDayHeader(QWidget *parent = nullptr); + void SetDate(const QDate &date); + +protected: + void paintEvent(QPaintEvent *) override; + +private: + QDate _date; +}; + +CalendarDayHeader::CalendarDayHeader(QWidget *parent) : QWidget(parent) +{ + setFixedHeight(CalendarDayView::DAY_HEADER_HEIGHT); +} + +void CalendarDayHeader::SetDate(const QDate &date) +{ + _date = date; + update(); +} + +void CalendarDayHeader::paintEvent(QPaintEvent *) +{ + QPainter p(this); + p.setRenderHint(QPainter::Antialiasing); + + const QDate today = QDate::currentDate(); + const bool isToday = (_date == today); + QLocale locale; + + // Time-axis placeholder + p.fillRect(0, 0, CalendarDayView::TIME_AXIS_WIDTH, height(), + palette().button()); + + // Day column + const int colX = CalendarDayView::TIME_AXIS_WIDTH; + const int colW = width() - colX; + + QColor bg = palette().button().color(); + if (isToday) { + bg = palette().highlight().color().lighter(165); + } + p.fillRect(colX, 0, colW, height(), bg); + + p.setPen(palette().mid().color()); + p.drawLine(colX, 0, colX, height()); + + QFont f = p.font(); + f.setPixelSize(13); + f.setBold(isToday); + p.setFont(f); + p.setPen(isToday ? palette().highlight().color() + : palette().buttonText().color()); + + const QString label = + locale.dayName(_date.dayOfWeek(), QLocale::LongFormat) + " " + + QString::number(_date.day()) + " " + + locale.monthName(_date.month(), QLocale::ShortFormat) + " " + + QString::number(_date.year()); + p.drawText(colX, 0, colW, height(), Qt::AlignCenter, label); + + p.setPen(palette().mid().color()); + p.drawLine(0, height() - 1, width(), height() - 1); +} + +// --------------------------------------------------------------------------- +// CalendarDayTimeGrid – scrollable single-day painted time grid +// --------------------------------------------------------------------------- + +class CalendarDayTimeGrid : public QWidget { + Q_OBJECT + +public: + explicit CalendarDayTimeGrid(QWidget *parent = nullptr); + void SetDate(const QDate &date); + void SetEvents(const QList &events); + void ScrollToCurrentTime(QScrollArea *sa); + + QSize sizeHint() const override; + +signals: + void SlotClicked(const QDateTime &startTime); + void EventClicked(const QString &id); + void EventDoubleClicked(const QString &id); + +protected: + void paintEvent(QPaintEvent *) override; + void mousePressEvent(QMouseEvent *) override; + void mouseDoubleClickEvent(QMouseEvent *) override; + void timerEvent(QTimerEvent *) override; + +private: + struct EventLayout { + CalendarEvent event; + QTime drawStart; // clipped to the day's [00:00, 23:59:59] + QTime drawEnd; + int col; + int numCols; + }; + + int TimeToY(const QTime &t) const; + QTime YToTime(int y) const; + int ColWidth() const; + + QList LayoutDay() const; + QString EventIdAtPoint(const QPoint &pos) const; + QDateTime SlotAtPoint(const QPoint &pos) const; + + void DrawEvent(QPainter &p, const EventLayout &layout, int colW); + + QDate _date; + QList _events; + int _refreshTimerId = 0; +}; + +CalendarDayTimeGrid::CalendarDayTimeGrid(QWidget *parent) : QWidget(parent) +{ + _refreshTimerId = startTimer(60 * 1000); +} + +QSize CalendarDayTimeGrid::sizeHint() const +{ + return QSize(CalendarDayView::TIME_AXIS_WIDTH + 200, + 24 * CalendarDayView::PIXELS_PER_HOUR); +} + +void CalendarDayTimeGrid::timerEvent(QTimerEvent *e) +{ + if (e->timerId() == _refreshTimerId) { + update(); + } +} + +int CalendarDayTimeGrid::TimeToY(const QTime &t) const +{ + return (t.hour() * 60 + t.minute()) * CalendarDayView::PIXELS_PER_HOUR / + 60; +} + +QTime CalendarDayTimeGrid::YToTime(int y) const +{ + int totalMin = y * 60 / CalendarDayView::PIXELS_PER_HOUR; + totalMin = qBound(0, totalMin, 24 * 60 - 1); + totalMin = (totalMin / 15) * 15; // snap to 15-min increments + return QTime(totalMin / 60, totalMin % 60); +} + +int CalendarDayTimeGrid::ColWidth() const +{ + return qMax(0, width() - CalendarDayView::TIME_AXIS_WIDTH); +} + +void CalendarDayTimeGrid::SetDate(const QDate &date) +{ + _date = date; + update(); +} + +void CalendarDayTimeGrid::SetEvents(const QList &events) +{ + _events = events; + update(); +} + +void CalendarDayTimeGrid::ScrollToCurrentTime(QScrollArea *sa) +{ + if (!sa) { + return; + } + const int y = qMax(0, TimeToY(QTime::currentTime().addSecs(-3600))); + sa->verticalScrollBar()->setValue(y); +} + +static bool ClipEventToDayD(const CalendarEvent &ev, const QDate &date, + QTime &drawStart, QTime &drawEnd) +{ + if (!ev.start.isValid()) { + return false; + } + const QDateTime dayStart(date, QTime(0, 0, 0)); + const QDateTime dayEnd(date.addDays(1), QTime(0, 0, 0)); + const QDateTime evEnd = ev.EffectiveEnd(); + + if (ev.start >= dayEnd || evEnd <= dayStart) { + return false; + } + + drawStart = (ev.start < dayStart) ? QTime(0, 0, 0) : ev.start.time(); + drawEnd = (evEnd >= dayEnd) ? QTime(23, 59, 59) : evEnd.time(); + return true; +} + +QList CalendarDayTimeGrid::LayoutDay() const +{ + struct DayEvent { + CalendarEvent ev; + QTime drawStart; + QTime drawEnd; + }; + QList dayEvents; + for (const auto &ev : _events) { + QTime ds, de; + if (ClipEventToDayD(ev, _date, ds, de)) { + dayEvents.append({ev, ds, de}); + } + } + + std::sort(dayEvents.begin(), dayEvents.end(), + [](const DayEvent &a, const DayEvent &b) { + return a.drawStart < b.drawStart; + }); + + QList result; + QList columnEnds; + + for (const auto &de : dayEvents) { + int col = -1; + for (int i = 0; i < columnEnds.size(); ++i) { + if (columnEnds[i] <= de.drawStart) { + col = i; + columnEnds[i] = de.drawEnd; + break; + } + } + if (col == -1) { + col = columnEnds.size(); + columnEnds.append(de.drawEnd); + } + result.append({de.ev, de.drawStart, de.drawEnd, col, 0}); + } + + const int totalCols = qMax(1, (int)columnEnds.size()); + for (auto &layout : result) { + layout.numCols = totalCols; + } + return result; +} + +QString CalendarDayTimeGrid::EventIdAtPoint(const QPoint &pos) const +{ + if (pos.x() < CalendarDayView::TIME_AXIS_WIDTH) { + return {}; + } + const int colW = ColWidth(); + for (const auto &layout : LayoutDay()) { + const int subW = colW / layout.numCols; + const int x = CalendarDayView::TIME_AXIS_WIDTH + + layout.col * subW + 2; + const int w = subW - 4; + const int y = TimeToY(layout.drawStart); + const int h = qMax(TimeToY(layout.drawEnd) - y, 20); + if (QRect(x, y, w, h).contains(pos)) { + return layout.event.id; + } + } + return {}; +} + +QDateTime CalendarDayTimeGrid::SlotAtPoint(const QPoint &pos) const +{ + if (pos.x() < CalendarDayView::TIME_AXIS_WIDTH) { + return {}; + } + return QDateTime(_date, YToTime(pos.y())); +} + +void CalendarDayTimeGrid::DrawEvent(QPainter &p, const EventLayout &layout, + int colW) +{ + const auto &ev = layout.event; + const int subW = colW / layout.numCols; + const int x = CalendarDayView::TIME_AXIS_WIDTH + layout.col * subW + 2; + const int w = subW - 4; + const int y = TimeToY(layout.drawStart); + const int endY = TimeToY(layout.drawEnd); + const int h = qMax(endY - y, 20); + const QRect rect(x, y, w, h); + + p.setBrush(ev.color); + p.setPen(ev.color.darker(130)); + p.drawRoundedRect(rect, 3, 3); + + p.fillRect(x, y + 1, 3, h - 2, ev.color.darker(160)); + + p.setPen(Qt::white); + + QFont f = p.font(); + f.setPixelSize(10); + f.setBold(true); + p.setFont(f); + + const QRect textRect = rect.adjusted(6, 2, -2, -2); + if (h >= 34) { + QFont tf = f; + tf.setBold(false); + p.setFont(tf); + p.drawText(textRect, Qt::AlignTop | Qt::AlignLeft, + ev.start.toString("HH:mm")); + p.setFont(f); + p.drawText(textRect.adjusted(0, 14, 0, 0), + Qt::AlignTop | Qt::AlignLeft | Qt::TextWordWrap, + ev.title); + } else { + p.drawText(textRect, + Qt::AlignVCenter | Qt::AlignLeft | + Qt::TextSingleLine, + ev.title); + } +} + +void CalendarDayTimeGrid::paintEvent(QPaintEvent *) +{ + QPainter p(this); + p.setRenderHint(QPainter::Antialiasing); + + const int w = width(); + const int h = height(); + const int colW = ColWidth(); + const QDate today = QDate::currentDate(); + + p.fillRect(0, 0, w, h, palette().base()); + p.fillRect(0, 0, CalendarDayView::TIME_AXIS_WIDTH, h, + palette().button()); + + // Day column background + const int colX = CalendarDayView::TIME_AXIS_WIDTH; + if (_date == today) { + p.fillRect(colX, 0, colW, h, + palette().highlight().color().lighter(190)); + } else if (_date.dayOfWeek() >= 6) { + p.fillRect(colX, 0, colW, h, palette().alternateBase().color()); + } + + QFont labelFont = p.font(); + labelFont.setPixelSize(10); + p.setFont(labelFont); + + for (int hour = 0; hour < 24; ++hour) { + const int y = hour * CalendarDayView::PIXELS_PER_HOUR; + + p.setPen(palette().text().color()); + p.drawText(2, y, CalendarDayView::TIME_AXIS_WIDTH - 6, + CalendarDayView::PIXELS_PER_HOUR, + Qt::AlignTop | Qt::AlignRight, + QTime(hour, 0).toString("HH:mm")); + + p.setPen(QPen(palette().mid().color(), 1)); + p.drawLine(CalendarDayView::TIME_AXIS_WIDTH, y, w, y); + + QPen halfPen(palette().midlight().color(), 1, Qt::DotLine); + p.setPen(halfPen); + const int yHalf = y + CalendarDayView::PIXELS_PER_HOUR / 2; + p.drawLine(CalendarDayView::TIME_AXIS_WIDTH, yHalf, w, yHalf); + } + + p.setPen(QPen(palette().mid().color(), 1)); + p.drawLine(colX, 0, colX, h); + + for (const auto &layout : LayoutDay()) { + DrawEvent(p, layout, colW); + } + + // Current-time indicator + if (_date == today) { + const int y = TimeToY(QTime::currentTime()); + p.setPen(QPen(QColor(220, 30, 30), 2)); + p.drawLine(colX, y, w, y); + p.setBrush(QColor(220, 30, 30)); + p.setPen(Qt::NoPen); + p.drawEllipse(colX - 4, y - 4, 8, 8); + } +} + +void CalendarDayTimeGrid::mousePressEvent(QMouseEvent *e) +{ + if (e->button() != Qt::LeftButton) { + return; + } + const QString id = EventIdAtPoint(e->pos()); + if (!id.isEmpty()) { + emit EventClicked(id); + return; + } + const QDateTime slot = SlotAtPoint(e->pos()); + if (slot.isValid()) { + emit SlotClicked(slot); + } +} + +void CalendarDayTimeGrid::mouseDoubleClickEvent(QMouseEvent *e) +{ + if (e->button() != Qt::LeftButton) { + return; + } + const QString id = EventIdAtPoint(e->pos()); + if (!id.isEmpty()) { + emit EventDoubleClicked(id); + } +} + +// --------------------------------------------------------------------------- +// CalendarDayView +// --------------------------------------------------------------------------- + +CalendarDayView::CalendarDayView(QWidget *parent) + : CalendarView(parent), + _header(new CalendarDayHeader(this)), + _timeGrid(new CalendarDayTimeGrid(this)), + _scrollArea(new QScrollArea(this)) +{ + _timeGrid->setMinimumHeight(24 * CalendarDayView::PIXELS_PER_HOUR); + + _scrollArea->setWidget(_timeGrid); + _scrollArea->setWidgetResizable(true); + _scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + _scrollArea->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); + _scrollArea->setFrameShape(QFrame::NoFrame); + + auto layout = new QVBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(0); + layout->addWidget(_header); + layout->addWidget(_scrollArea); + setLayout(layout); + + connect(_timeGrid, &CalendarDayTimeGrid::SlotClicked, this, + &CalendarDayView::SlotClicked); + connect(_timeGrid, &CalendarDayTimeGrid::EventClicked, this, + &CalendarDayView::EventClicked); + connect(_timeGrid, &CalendarDayTimeGrid::EventDoubleClicked, this, + &CalendarDayView::EventDoubleClicked); + + SetDate(QDate::currentDate()); +} + +void CalendarDayView::SetDate(const QDate &date) +{ + _date = date; + UpdateViews(); + emit VisibleRangeChanged(RangeStart(), RangeEnd()); +} + +void CalendarDayView::SetEvents(const QList &events) +{ + _events = events; + _timeGrid->SetEvents(events); +} + +QDate CalendarDayView::CurrentDate() const +{ + return _date; +} + +QDate CalendarDayView::RangeStart() const +{ + return _date; +} + +QDate CalendarDayView::RangeEnd() const +{ + return _date; +} + +void CalendarDayView::UpdateViews() +{ + _header->SetDate(_date); + _timeGrid->SetDate(_date); + _timeGrid->ScrollToCurrentTime(_scrollArea); +} + +} // namespace advss + +// Required for CalendarDayTimeGrid defined in this file +#include "calendar-day-view.moc" diff --git a/plugins/schedule/calendar/calendar-day-view.hpp b/plugins/schedule/calendar/calendar-day-view.hpp new file mode 100644 index 00000000..4d3d0751 --- /dev/null +++ b/plugins/schedule/calendar/calendar-day-view.hpp @@ -0,0 +1,44 @@ +#pragma once +#include "calendar-view.hpp" + +#include + +namespace advss { + +class CalendarDayHeader; +class CalendarDayTimeGrid; + +// Displays a single day column with a vertical time axis (00:00 - 24:00). +// Events are rendered as colored blocks sized to their duration. +// Overlapping events are arranged in side-by-side sub-columns. +// A red indicator marks the current time. +class CalendarDayView : public CalendarView { + Q_OBJECT + +public: + explicit CalendarDayView(QWidget *parent = nullptr); + + void SetDate(const QDate &date) override; + void SetEvents(const QList &events) override; + + QDate CurrentDate() const override; + QDate RangeStart() const override; + QDate RangeEnd() const override; + + // Shared layout constants (used by DayHeader and TimeGrid) + static constexpr int TIME_AXIS_WIDTH = 56; + static constexpr int PIXELS_PER_HOUR = 64; + static constexpr int DAY_HEADER_HEIGHT = 36; + +private: + void UpdateViews(); + + CalendarDayHeader *_header; + CalendarDayTimeGrid *_timeGrid; + QScrollArea *_scrollArea; + + QDate _date; + QList _events; +}; + +} // namespace advss diff --git a/plugins/schedule/calendar/calendar-event.hpp b/plugins/schedule/calendar/calendar-event.hpp new file mode 100644 index 00000000..fcc20794 --- /dev/null +++ b/plugins/schedule/calendar/calendar-event.hpp @@ -0,0 +1,29 @@ +#pragma once + +#include +#include +#include +#include + +namespace advss { + +// Generic calendar event. All fields except id/start are optional. +struct CalendarEvent { + QString id; + QString title; + QDateTime start; + QDateTime end; + QColor color{70, 130, 180}; + QVariant userData; // Caller-defined payload, returned on click signals + + // Returns end if valid and > start, otherwise start + 30 minutes. + QDateTime EffectiveEnd() const + { + static constexpr qint64 defaultDuration = 1800; + return (end.isValid() && end > start) + ? end + : start.addSecs(defaultDuration); + } +}; + +} // namespace advss diff --git a/plugins/schedule/calendar/calendar-month-view.cpp b/plugins/schedule/calendar/calendar-month-view.cpp new file mode 100644 index 00000000..efda45bd --- /dev/null +++ b/plugins/schedule/calendar/calendar-month-view.cpp @@ -0,0 +1,284 @@ +#include "calendar-month-view.hpp" +#include "obs-module-helper.hpp" + +#include +#include +#include + +namespace advss { + +CalendarMonthView::CalendarMonthView(QWidget *parent) : CalendarView(parent) +{ + setMinimumSize(320, 240); + SetDate(QDate::currentDate()); +} + +// --------------------------------------------------------------------------- +// Public interface +// --------------------------------------------------------------------------- + +void CalendarMonthView::SetDate(const QDate &date) +{ + _currentDate = date; + + // Grid starts on the Monday of the week that contains the 1st of the month. + const QDate first(date.year(), date.month(), 1); + // Qt: dayOfWeek() -> 1=Mon … 7=Sun + _gridStart = first.addDays(-(first.dayOfWeek() - 1)); + + update(); + emit VisibleRangeChanged(RangeStart(), RangeEnd()); +} + +void CalendarMonthView::SetEvents(const QList &events) +{ + _events = events; + update(); +} + +// --------------------------------------------------------------------------- +// Geometry helpers +// --------------------------------------------------------------------------- + +QDate CalendarMonthView::CellDate(int row, int col) const +{ + return _gridStart.addDays(row * COLS + col); +} + +QRect CalendarMonthView::CellRect(int row, int col) const +{ + const int cellW = width() / COLS; + const int cellH = (height() - HEADER_HEIGHT) / ROWS; + return QRect(col * cellW, HEADER_HEIGHT + row * cellH, cellW, cellH); +} + +bool CalendarMonthView::CellFromPoint(const QPoint &pos, int &row, + int &col) const +{ + if (pos.y() < HEADER_HEIGHT) { + return false; + } + const int cellW = width() / COLS; + const int cellH = (height() - HEADER_HEIGHT) / ROWS; + col = pos.x() / cellW; + row = (pos.y() - HEADER_HEIGHT) / cellH; + return col >= 0 && col < COLS && row >= 0 && row < ROWS; +} + +// --------------------------------------------------------------------------- +// Event helpers +// --------------------------------------------------------------------------- + +QList CalendarMonthView::EventsForDate(const QDate &date) const +{ + QList result; + for (const auto &event : _events) { + if (!event.start.isValid()) { + continue; + } + const QDate evStart = event.start.date(); + const QDate evEnd = event.EffectiveEnd().date(); + if (date >= evStart && date <= evEnd) { + result.append(event); + } + } + return result; +} + +QString CalendarMonthView::EventIdAtPoint(const QPoint &pos) const +{ + int row, col; + if (!CellFromPoint(pos, row, col)) { + return {}; + } + + const QRect cell = CellRect(row, col); + const int evTop = cell.top() + DAY_NUM_HEIGHT + CELL_PAD; + const auto events = EventsForDate(CellDate(row, col)); + + for (int i = 0; i < qMin((int)events.size(), MAX_VISIBLE_EVENTS); ++i) { + const QRect evRect(cell.left() + CELL_PAD, + evTop + i * (EVENT_HEIGHT + EVENT_MARGIN), + cell.width() - CELL_PAD * 2, EVENT_HEIGHT); + if (evRect.contains(pos)) { + return events[i].id; + } + } + return {}; +} + +// --------------------------------------------------------------------------- +// Painting +// --------------------------------------------------------------------------- + +void CalendarMonthView::PaintDayNameHeader(QPainter &p) +{ + p.fillRect(0, 0, width(), HEADER_HEIGHT, palette().button()); + + const int cellW = width() / COLS; + QLocale locale; + + p.setPen(palette().buttonText().color()); + QFont f = p.font(); + f.setPixelSize(11); + p.setFont(f); + + for (int col = 0; col < COLS; ++col) { + // dayOfWeek: 1=Mon … 7=Sun (ISO 8601 order matches our columns) + const QString name = + locale.dayName(col + 1, QLocale::ShortFormat); + p.drawText(col * cellW, 0, cellW, HEADER_HEIGHT, + Qt::AlignCenter, name); + } + + // Bottom border + p.setPen(palette().mid().color()); + p.drawLine(0, HEADER_HEIGHT - 1, width(), HEADER_HEIGHT - 1); +} + +void CalendarMonthView::PaintCell(QPainter &p, int row, int col) +{ + const QRect rect = CellRect(row, col); + const QDate date = CellDate(row, col); + const QDate today = QDate::currentDate(); + const bool isToday = (date == today); + const bool inCurrentMonth = (date.month() == _currentDate.month()); + + // --- Background --- + QColor bg; + if (isToday) { + bg = palette().highlight().color().lighter(185); + } else if (!inCurrentMonth) { + bg = palette().alternateBase().color(); + } else { + bg = palette().base().color(); + } + // Weekend tint + if (col >= 5) { + bg = bg.darker(104); + } + p.fillRect(rect, bg); + + // --- Grid border --- + p.setBrush(Qt::NoBrush); + p.setPen(palette().mid().color()); + p.drawRect(rect.adjusted(0, 0, -1, -1)); + + // --- Day number --- + const QRect numArea(rect.left(), rect.top() + CELL_PAD, + rect.width() - CELL_PAD, DAY_NUM_HEIGHT); + + if (isToday) { + // Filled circle behind the number + const int dia = DAY_NUM_HEIGHT - 4; + const QRect circleRect(numArea.right() - dia - 2, + numArea.top() + 1, dia, dia); + p.setBrush(palette().highlight()); + p.setPen(Qt::NoPen); + p.drawEllipse(circleRect); + p.setPen(palette().highlightedText().color()); + } else { + p.setPen(inCurrentMonth ? palette().text().color() + : palette().placeholderText().color()); + } + + QFont f = p.font(); + f.setPixelSize(11); + f.setBold(isToday); + p.setFont(f); + p.drawText(numArea, Qt::AlignRight | Qt::AlignTop, + QString::number(date.day())); + + // --- Event bars --- + const auto events = EventsForDate(date); + const int evTop = rect.top() + DAY_NUM_HEIGHT + CELL_PAD; + const int maxFit = (rect.height() - DAY_NUM_HEIGHT - CELL_PAD * 2) / + (EVENT_HEIGHT + EVENT_MARGIN); + const int visible = + qMin(qMin((int)events.size(), maxFit), MAX_VISIBLE_EVENTS); + + QFont evFont = p.font(); + evFont.setPixelSize(10); + evFont.setBold(false); + p.setFont(evFont); + + for (int i = 0; i < visible; ++i) { + const auto &ev = events[i]; + const QRect evRect(rect.left() + CELL_PAD, + evTop + i * (EVENT_HEIGHT + EVENT_MARGIN), + rect.width() - CELL_PAD * 2, EVENT_HEIGHT); + + p.setBrush(ev.color); + p.setPen(Qt::NoPen); + p.drawRoundedRect(evRect, 2, 2); + + p.setPen(Qt::white); + p.drawText(evRect.adjusted(4, 0, -2, 0), + Qt::AlignVCenter | Qt::AlignLeft | + Qt::TextSingleLine, + ev.title); + } + + // "+N more" overflow label + if ((int)events.size() > visible) { + const int extra = events.size() - visible; + const QRect moreRect( + rect.left() + CELL_PAD, + evTop + visible * (EVENT_HEIGHT + EVENT_MARGIN), + rect.width() - CELL_PAD * 2, EVENT_HEIGHT); + p.setPen(palette().placeholderText().color()); + p.drawText( + moreRect, + Qt::AlignVCenter | Qt::AlignLeft | Qt::TextSingleLine, + QString(obs_module_text( + "AdvSceneSwitcher.calendar.moreEvents")) + .arg(extra)); + } +} + +void CalendarMonthView::paintEvent(QPaintEvent *) +{ + QPainter p(this); + p.setRenderHint(QPainter::Antialiasing); + + PaintDayNameHeader(p); + + for (int row = 0; row < ROWS; ++row) { + for (int col = 0; col < COLS; ++col) { + PaintCell(p, row, col); + } + } +} + +// --------------------------------------------------------------------------- +// Mouse events +// --------------------------------------------------------------------------- + +void CalendarMonthView::mousePressEvent(QMouseEvent *e) +{ + if (e->button() != Qt::LeftButton) { + return; + } + const QString id = EventIdAtPoint(e->pos()); + if (!id.isEmpty()) { + emit EventClicked(id); + return; + } + int row, col; + if (CellFromPoint(e->pos(), row, col)) { + emit SlotClicked(QDateTime(CellDate(row, col), QTime(0, 0))); + } +} + +void CalendarMonthView::mouseDoubleClickEvent(QMouseEvent *e) +{ + if (e->button() != Qt::LeftButton) { + return; + } + const QString id = EventIdAtPoint(e->pos()); + if (!id.isEmpty()) { + emit EventDoubleClicked(id); + } +} + +} // namespace advss diff --git a/plugins/schedule/calendar/calendar-month-view.hpp b/plugins/schedule/calendar/calendar-month-view.hpp new file mode 100644 index 00000000..6fe508f5 --- /dev/null +++ b/plugins/schedule/calendar/calendar-month-view.hpp @@ -0,0 +1,57 @@ +#pragma once +#include "calendar-view.hpp" + +namespace advss { + +// Displays a traditional month grid (6 weeks × 7 days). +// Events are rendered as small colored bars inside each day cell. +// Up to three events are shown per cell; additional events are +// indicated by a "+N more" label. +class CalendarMonthView : public CalendarView { + Q_OBJECT + +public: + explicit CalendarMonthView(QWidget *parent = nullptr); + + void SetDate(const QDate &date) override; + void SetEvents(const QList &events) override; + + QDate CurrentDate() const override { return _currentDate; } + QDate RangeStart() const override { return _gridStart; } + QDate RangeEnd() const override { return _gridStart.addDays(41); } + +protected: + void paintEvent(QPaintEvent *) override; + void mousePressEvent(QMouseEvent *) override; + void mouseDoubleClickEvent(QMouseEvent *) override; + +private: + // Geometry helpers + QDate CellDate(int row, int col) const; + QRect CellRect(int row, int col) const; + bool CellFromPoint(const QPoint &pos, int &row, int &col) const; + + // Event helpers + QList EventsForDate(const QDate &date) const; + QString EventIdAtPoint(const QPoint &pos) const; + + // Painting + void PaintDayNameHeader(QPainter &p); + void PaintCell(QPainter &p, int row, int col); + + static constexpr int ROWS = 6; + static constexpr int COLS = 7; + static constexpr int HEADER_HEIGHT = 28; // day-name row + static constexpr int DAY_NUM_HEIGHT = + 22; // space reserved for day number + static constexpr int EVENT_HEIGHT = 16; + static constexpr int EVENT_MARGIN = 2; + static constexpr int CELL_PAD = 3; + static constexpr int MAX_VISIBLE_EVENTS = 3; + + QDate _currentDate; + QDate _gridStart; // Monday of the week that contains the 1st of the month + QList _events; +}; + +} // namespace advss diff --git a/plugins/schedule/calendar/calendar-view.hpp b/plugins/schedule/calendar/calendar-view.hpp new file mode 100644 index 00000000..c3586790 --- /dev/null +++ b/plugins/schedule/calendar/calendar-view.hpp @@ -0,0 +1,49 @@ +#pragma once +#include "calendar-event.hpp" + +#include +#include +#include + +namespace advss { + +// Abstract base class for calendar view modes (month, week, …). +// Subclasses must implement the pure virtual interface and emit the +// signals defined here via Q_SIGNALS so that CalendarWidget can +// connect to them uniformly regardless of the active view. +class CalendarView : public QWidget { + Q_OBJECT + +public: + explicit CalendarView(QWidget *parent = nullptr) : QWidget(parent) {} + + // Navigate to show the given date. The visible range is determined + // by the concrete view (e.g. the whole month, or the week). + virtual void SetDate(const QDate &date) = 0; + + // Replace the full set of events shown in this view. + virtual void SetEvents(const QList &events) = 0; + + // A representative date for the current position (used for navigation). + virtual QDate CurrentDate() const = 0; + + // Inclusive range of dates currently rendered. + virtual QDate RangeStart() const = 0; + virtual QDate RangeEnd() const = 0; + +signals: + // User clicked an empty time slot. + void SlotClicked(const QDateTime &startTime); + + // User single-clicked an event (id = CalendarEvent::id). + void EventClicked(const QString &eventId); + + // User double-clicked an event. + void EventDoubleClicked(const QString &eventId); + + // The visible date range changed; the owner should refresh events. + void VisibleRangeChanged(const QDate &rangeStart, + const QDate &rangeEnd); +}; + +} // namespace advss diff --git a/plugins/schedule/calendar/calendar-week-view.cpp b/plugins/schedule/calendar/calendar-week-view.cpp new file mode 100644 index 00000000..c7154376 --- /dev/null +++ b/plugins/schedule/calendar/calendar-week-view.cpp @@ -0,0 +1,542 @@ +#include "calendar-week-view.hpp" + +#include +#include +#include +#include +#include +#include + +#include + +namespace advss { + +// --------------------------------------------------------------------------- +// CalendarWeekDayHeader – fixed day-name strip above the scroll area +// --------------------------------------------------------------------------- + +class CalendarWeekDayHeader : public QWidget { +public: + explicit CalendarWeekDayHeader(QWidget *parent = nullptr); + void SetStartOfWeek(const QDate &date); + +protected: + void paintEvent(QPaintEvent *) override; + +private: + QDate _startOfWeek; +}; + +CalendarWeekDayHeader::CalendarWeekDayHeader(QWidget *parent) : QWidget(parent) +{ + setFixedHeight(CalendarWeekView::DAY_HEADER_HEIGHT); +} + +void CalendarWeekDayHeader::SetStartOfWeek(const QDate &date) +{ + _startOfWeek = date; + update(); +} + +void CalendarWeekDayHeader::paintEvent(QPaintEvent *) +{ + QPainter p(this); + p.setRenderHint(QPainter::Antialiasing); + + const int dayW = + std::max(CalendarWeekView::MIN_DAY_WIDTH, + (width() - CalendarWeekView::TIME_AXIS_WIDTH) / 7); + const QDate today = QDate::currentDate(); + QLocale locale; + + // Time-axis placeholder + p.fillRect(0, 0, CalendarWeekView::TIME_AXIS_WIDTH, height(), + palette().button()); + + for (int d = 0; d < 7; ++d) { + const QDate date = _startOfWeek.addDays(d); + const int x = CalendarWeekView::TIME_AXIS_WIDTH + d * dayW; + const bool isToday = (date == today); + + QColor bg = palette().button().color(); + if (isToday) { + bg = palette().highlight().color().lighter(165); + } else if (date.dayOfWeek() >= 6) { + bg = bg.darker(106); + } + p.fillRect(x, 0, dayW, height(), bg); + + p.setPen(palette().mid().color()); + p.drawLine(x, 0, x, height()); + + QFont f = p.font(); + f.setPixelSize(12); + f.setBold(isToday); + p.setFont(f); + p.setPen(isToday ? palette().highlight().color() + : palette().buttonText().color()); + + const QString label = + locale.dayName(date.dayOfWeek(), QLocale::ShortFormat) + + " " + QString::number(date.day()); + p.drawText(x, 0, dayW, height(), Qt::AlignCenter, label); + } + + p.setPen(palette().mid().color()); + p.drawLine(0, height() - 1, width(), height() - 1); +} + +// --------------------------------------------------------------------------- +// CalendarWeekTimeGrid – scrollable painted time grid +// --------------------------------------------------------------------------- + +class CalendarWeekTimeGrid : public QWidget { + Q_OBJECT + +public: + explicit CalendarWeekTimeGrid(QWidget *parent = nullptr); + void SetStartOfWeek(const QDate &date); + void SetEvents(const QList &events); + void ScrollToCurrentTime(QScrollArea *sa); + + QSize sizeHint() const override; + +signals: + void SlotClicked(const QDateTime &startTime); + void EventClicked(const QString &id); + void EventDoubleClicked(const QString &id); + +protected: + void paintEvent(QPaintEvent *) override; + void mousePressEvent(QMouseEvent *) override; + void mouseDoubleClickEvent(QMouseEvent *) override; + void timerEvent(QTimerEvent *) override; + +private: + struct EventLayout { + CalendarEvent event; + QTime drawStart; // clipped to the day's [00:00, 23:59:59] + QTime drawEnd; + int col; + int numCols; + }; + + int TimeToY(const QTime &t) const; + QTime YToTime(int y) const; + int DayWidth() const; + int DayColumnX(int dayIndex) const; + + QList LayoutDay(int dayIndex) const; + QString EventIdAtPoint(const QPoint &pos) const; + QDateTime SlotAtPoint(const QPoint &pos) const; + + void DrawEvent(QPainter &p, const EventLayout &layout, int dayX, + int dayW); + + QDate _startOfWeek; + QList _events; + int _refreshTimerId = 0; +}; + +CalendarWeekTimeGrid::CalendarWeekTimeGrid(QWidget *parent) : QWidget(parent) +{ + _refreshTimerId = startTimer(60 * 1000); +} + +QSize CalendarWeekTimeGrid::sizeHint() const +{ + return QSize(CalendarWeekView::TIME_AXIS_WIDTH + + 7 * CalendarWeekView::MIN_DAY_WIDTH, + 24 * CalendarWeekView::PIXELS_PER_HOUR); +} + +void CalendarWeekTimeGrid::timerEvent(QTimerEvent *e) +{ + if (e->timerId() == _refreshTimerId) { + update(); + } +} + +int CalendarWeekTimeGrid::TimeToY(const QTime &t) const +{ + return (t.hour() * 60 + t.minute()) * + CalendarWeekView::PIXELS_PER_HOUR / 60; +} + +QTime CalendarWeekTimeGrid::YToTime(int y) const +{ + int totalMin = y * 60 / CalendarWeekView::PIXELS_PER_HOUR; + totalMin = qBound(0, totalMin, 24 * 60 - 1); + totalMin = (totalMin / 15) * 15; // snap to 15-min increments + return QTime(totalMin / 60, totalMin % 60); +} + +int CalendarWeekTimeGrid::DayWidth() const +{ + return std::max(CalendarWeekView::MIN_DAY_WIDTH, + (width() - CalendarWeekView::TIME_AXIS_WIDTH) / 7); +} + +int CalendarWeekTimeGrid::DayColumnX(int dayIndex) const +{ + return CalendarWeekView::TIME_AXIS_WIDTH + dayIndex * DayWidth(); +} + +void CalendarWeekTimeGrid::SetStartOfWeek(const QDate &date) +{ + _startOfWeek = date; + update(); +} + +void CalendarWeekTimeGrid::SetEvents(const QList &events) +{ + _events = events; + update(); +} + +void CalendarWeekTimeGrid::ScrollToCurrentTime(QScrollArea *sa) +{ + if (!sa) { + return; + } + const int y = qMax(0, TimeToY(QTime::currentTime().addSecs(-3600))); + sa->verticalScrollBar()->setValue(y); +} + +// Returns the portion of an event visible within [date 00:00, date+1 00:00). +// drawStart / drawEnd are times in that day's coordinate space. +static bool ClipEventToDay(const CalendarEvent &ev, const QDate &date, + QTime &drawStart, QTime &drawEnd) +{ + if (!ev.start.isValid()) { + return false; + } + const QDateTime dayStart(date, QTime(0, 0, 0)); + const QDateTime dayEnd(date.addDays(1), QTime(0, 0, 0)); + const QDateTime evEnd = ev.EffectiveEnd(); + + if (ev.start >= dayEnd || evEnd <= dayStart) { + return false; // no overlap + } + + drawStart = (ev.start < dayStart) ? QTime(0, 0, 0) : ev.start.time(); + drawEnd = (evEnd >= dayEnd) ? QTime(23, 59, 59) : evEnd.time(); + return true; +} + +QList +CalendarWeekTimeGrid::LayoutDay(int dayIndex) const +{ + const QDate date = _startOfWeek.addDays(dayIndex); + + // Collect events that overlap this day, sorted by their clipped start. + struct DayEvent { + CalendarEvent ev; + QTime drawStart; + QTime drawEnd; + }; + QList dayEvents; + for (const auto &ev : _events) { + QTime ds, de; + if (ClipEventToDay(ev, date, ds, de)) { + dayEvents.append({ev, ds, de}); + } + } + + std::sort(dayEvents.begin(), dayEvents.end(), + [](const DayEvent &a, const DayEvent &b) { + return a.drawStart < b.drawStart; + }); + + QList result; + QList columnEnds; + + for (const auto &de : dayEvents) { + int col = -1; + for (int i = 0; i < columnEnds.size(); ++i) { + if (columnEnds[i] <= de.drawStart) { + col = i; + columnEnds[i] = de.drawEnd; + break; + } + } + if (col == -1) { + col = columnEnds.size(); + columnEnds.append(de.drawEnd); + } + result.append({de.ev, de.drawStart, de.drawEnd, col, 0}); + } + + const int totalCols = qMax(1, (int)columnEnds.size()); + for (auto &layout : result) { + layout.numCols = totalCols; + } + return result; +} + +QString CalendarWeekTimeGrid::EventIdAtPoint(const QPoint &pos) const +{ + if (pos.x() < CalendarWeekView::TIME_AXIS_WIDTH) { + return {}; + } + const int dayW = DayWidth(); + const int dayIdx = (pos.x() - CalendarWeekView::TIME_AXIS_WIDTH) / dayW; + if (dayIdx < 0 || dayIdx >= 7) { + return {}; + } + + const int dayX = DayColumnX(dayIdx); + for (const auto &layout : LayoutDay(dayIdx)) { + const int colW = dayW / layout.numCols; + const int x = dayX + layout.col * colW + 2; + const int w = colW - 4; + const int y = TimeToY(layout.drawStart); + const int h = qMax(TimeToY(layout.drawEnd) - y, 20); + if (QRect(x, y, w, h).contains(pos)) { + return layout.event.id; + } + } + return {}; +} + +QDateTime CalendarWeekTimeGrid::SlotAtPoint(const QPoint &pos) const +{ + if (pos.x() < CalendarWeekView::TIME_AXIS_WIDTH) { + return {}; + } + const int dayW = DayWidth(); + const int dayIdx = (pos.x() - CalendarWeekView::TIME_AXIS_WIDTH) / dayW; + if (dayIdx < 0 || dayIdx >= 7) { + return {}; + } + return QDateTime(_startOfWeek.addDays(dayIdx), YToTime(pos.y())); +} + +void CalendarWeekTimeGrid::DrawEvent(QPainter &p, const EventLayout &layout, + int dayX, int dayW) +{ + const auto &ev = layout.event; + const int colW = dayW / layout.numCols; + const int x = dayX + layout.col * colW + 2; + const int w = colW - 4; + const int y = TimeToY(layout.drawStart); + const int endY = TimeToY(layout.drawEnd); + const int h = qMax(endY - y, 20); + const QRect rect(x, y, w, h); + + p.setBrush(ev.color); + p.setPen(ev.color.darker(130)); + p.drawRoundedRect(rect, 3, 3); + + p.fillRect(x, y + 1, 3, h - 2, ev.color.darker(160)); + + p.setPen(Qt::white); + + QFont f = p.font(); + f.setPixelSize(10); + f.setBold(true); + p.setFont(f); + + const QRect textRect = rect.adjusted(6, 2, -2, -2); + if (h >= 34) { + QFont tf = f; + tf.setBold(false); + p.setFont(tf); + p.drawText(textRect, Qt::AlignTop | Qt::AlignLeft, + ev.start.toString("HH:mm")); + p.setFont(f); + p.drawText(textRect.adjusted(0, 14, 0, 0), + Qt::AlignTop | Qt::AlignLeft | Qt::TextWordWrap, + ev.title); + } else { + p.drawText(textRect, + Qt::AlignVCenter | Qt::AlignLeft | + Qt::TextSingleLine, + ev.title); + } +} + +void CalendarWeekTimeGrid::paintEvent(QPaintEvent *) +{ + QPainter p(this); + p.setRenderHint(QPainter::Antialiasing); + + const int w = width(); + const int h = height(); + const int dayW = DayWidth(); + const QDate today = QDate::currentDate(); + + p.fillRect(0, 0, w, h, palette().base()); + p.fillRect(0, 0, CalendarWeekView::TIME_AXIS_WIDTH, h, + palette().button()); + + for (int d = 0; d < 7; ++d) { + const QDate date = _startOfWeek.addDays(d); + const int x = DayColumnX(d); + if (date == today) { + p.fillRect(x, 0, dayW, h, + palette().highlight().color().lighter(190)); + } else if (date.dayOfWeek() >= 6) { + p.fillRect(x, 0, dayW, h, + palette().alternateBase().color()); + } + } + + QFont labelFont = p.font(); + labelFont.setPixelSize(10); + p.setFont(labelFont); + + for (int hour = 0; hour < 24; ++hour) { + const int y = hour * CalendarWeekView::PIXELS_PER_HOUR; + + p.setPen(palette().text().color()); + p.drawText(2, y, CalendarWeekView::TIME_AXIS_WIDTH - 6, + CalendarWeekView::PIXELS_PER_HOUR, + Qt::AlignTop | Qt::AlignRight, + QTime(hour, 0).toString("HH:mm")); + + p.setPen(QPen(palette().mid().color(), 1)); + p.drawLine(CalendarWeekView::TIME_AXIS_WIDTH, y, w, y); + + QPen halfPen(palette().midlight().color(), 1, Qt::DotLine); + p.setPen(halfPen); + const int yHalf = y + CalendarWeekView::PIXELS_PER_HOUR / 2; + p.drawLine(CalendarWeekView::TIME_AXIS_WIDTH, yHalf, w, yHalf); + } + + p.setPen(QPen(palette().mid().color(), 1)); + for (int d = 0; d <= 7; ++d) { + const int x = DayColumnX(d); + p.drawLine(x, 0, x, h); + } + p.drawLine(CalendarWeekView::TIME_AXIS_WIDTH, 0, + CalendarWeekView::TIME_AXIS_WIDTH, h); + + for (int d = 0; d < 7; ++d) { + const int dayX = DayColumnX(d); + for (const auto &layout : LayoutDay(d)) { + DrawEvent(p, layout, dayX, dayW); + } + } + + // Current-time indicator + if (_startOfWeek.isValid() && _startOfWeek <= today && + today <= _startOfWeek.addDays(6)) { + const int dayIdx = _startOfWeek.daysTo(today); + const int dayX = DayColumnX(dayIdx); + const int y = TimeToY(QTime::currentTime()); + + p.setPen(QPen(QColor(220, 30, 30), 2)); + p.drawLine(dayX, y, dayX + dayW, y); + + p.setBrush(QColor(220, 30, 30)); + p.setPen(Qt::NoPen); + p.drawEllipse(dayX - 4, y - 4, 8, 8); + } +} + +void CalendarWeekTimeGrid::mousePressEvent(QMouseEvent *e) +{ + if (e->button() != Qt::LeftButton) { + return; + } + const QString id = EventIdAtPoint(e->pos()); + if (!id.isEmpty()) { + emit EventClicked(id); + return; + } + const QDateTime slot = SlotAtPoint(e->pos()); + if (slot.isValid()) { + emit SlotClicked(slot); + } +} + +void CalendarWeekTimeGrid::mouseDoubleClickEvent(QMouseEvent *e) +{ + if (e->button() != Qt::LeftButton) { + return; + } + const QString id = EventIdAtPoint(e->pos()); + if (!id.isEmpty()) { + emit EventDoubleClicked(id); + } +} + +// --------------------------------------------------------------------------- +// CalendarWeekView +// --------------------------------------------------------------------------- + +CalendarWeekView::CalendarWeekView(QWidget *parent) : CalendarView(parent) +{ + _header = new CalendarWeekDayHeader(this); + _timeGrid = new CalendarWeekTimeGrid(this); + + // Fix the minimum height so the scroll area cannot shrink the grid + // below the full 24-hour span — which would eliminate the scrollbar. + _timeGrid->setMinimumHeight(24 * CalendarWeekView::PIXELS_PER_HOUR); + + _scrollArea = new QScrollArea(this); + _scrollArea->setWidget(_timeGrid); + // widgetResizable(true) lets the grid expand horizontally to fill the + // viewport width while the fixed minimumHeight enforces vertical scrolling. + _scrollArea->setWidgetResizable(true); + _scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + _scrollArea->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); + _scrollArea->setFrameShape(QFrame::NoFrame); + + auto layout = new QVBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(0); + layout->addWidget(_header); + layout->addWidget(_scrollArea); + setLayout(layout); + + connect(_timeGrid, &CalendarWeekTimeGrid::SlotClicked, this, + &CalendarWeekView::SlotClicked); + connect(_timeGrid, &CalendarWeekTimeGrid::EventClicked, this, + &CalendarWeekView::EventClicked); + connect(_timeGrid, &CalendarWeekTimeGrid::EventDoubleClicked, this, + &CalendarWeekView::EventDoubleClicked); + + SetDate(QDate::currentDate()); +} + +void CalendarWeekView::SetDate(const QDate &date) +{ + _startOfWeek = date.addDays(-(date.dayOfWeek() - 1)); + UpdateViews(); + emit VisibleRangeChanged(RangeStart(), RangeEnd()); +} + +void CalendarWeekView::SetEvents(const QList &events) +{ + _events = events; + _timeGrid->SetEvents(events); +} + +QDate CalendarWeekView::CurrentDate() const +{ + return _startOfWeek.addDays(3); // Wednesday - stable mid-point +} + +QDate CalendarWeekView::RangeStart() const +{ + return _startOfWeek; +} + +QDate CalendarWeekView::RangeEnd() const +{ + return _startOfWeek.addDays(6); +} + +void CalendarWeekView::UpdateViews() +{ + _header->SetStartOfWeek(_startOfWeek); + _timeGrid->SetStartOfWeek(_startOfWeek); + _timeGrid->ScrollToCurrentTime(_scrollArea); +} + +} // namespace advss + +// Required for CalendarWeekTimeGrid defined in this file +#include "calendar-week-view.moc" diff --git a/plugins/schedule/calendar/calendar-week-view.hpp b/plugins/schedule/calendar/calendar-week-view.hpp new file mode 100644 index 00000000..cabc224b --- /dev/null +++ b/plugins/schedule/calendar/calendar-week-view.hpp @@ -0,0 +1,45 @@ +#pragma once +#include "calendar-view.hpp" + +#include + +namespace advss { + +class CalendarWeekDayHeader; +class CalendarWeekTimeGrid; + +// Displays 7 day columns with a vertical time axis (00:00 - 24:00). +// Events are rendered as colored blocks sized to their duration. +// Overlapping events within the same day are arranged in side-by-side +// sub-columns. A red indicator marks the current time. +class CalendarWeekView : public CalendarView { + Q_OBJECT + +public: + explicit CalendarWeekView(QWidget *parent = nullptr); + + void SetDate(const QDate &date) override; + void SetEvents(const QList &events) override; + + QDate CurrentDate() const override; + QDate RangeStart() const override; + QDate RangeEnd() const override; + + // Shared layout constants (used by DayHeader and TimeGrid) + static constexpr int TIME_AXIS_WIDTH = 56; + static constexpr int PIXELS_PER_HOUR = 64; + static constexpr int DAY_HEADER_HEIGHT = 36; + static constexpr int MIN_DAY_WIDTH = 80; + +private: + void UpdateViews(); + + CalendarWeekDayHeader *_header; + CalendarWeekTimeGrid *_timeGrid; + QScrollArea *_scrollArea; + + QDate _startOfWeek; + QList _events; +}; + +} // namespace advss diff --git a/plugins/schedule/calendar/calendar-widget.cpp b/plugins/schedule/calendar/calendar-widget.cpp new file mode 100644 index 00000000..4dc0f906 --- /dev/null +++ b/plugins/schedule/calendar/calendar-widget.cpp @@ -0,0 +1,308 @@ +#include "calendar-widget.hpp" + +#include "obs-module-helper.hpp" +#include "ui-helpers.hpp" + +#include +#include +#include + +namespace advss { + +CalendarWidget::CalendarWidget(QWidget *parent) + : QWidget(parent), + _prevBtn(new QPushButton(this)), + _nextBtn(new QPushButton(this)), + _todayBtn(new QPushButton( + obs_module_text("AdvSceneSwitcher.calendar.today"), this)), + _navLabel(new QLabel(this)), + _monthBtn(new QPushButton( + obs_module_text("AdvSceneSwitcher.calendar.month"), this)), + _weekBtn(new QPushButton( + obs_module_text("AdvSceneSwitcher.calendar.week"), this)), + _dayBtn(new QPushButton( + obs_module_text("AdvSceneSwitcher.calendar.day"), this)), + _viewStack(new QStackedWidget(this)), + _monthView(new CalendarMonthView(this)), + _weekView(new CalendarWeekView(this)), + _dayView(new CalendarDayView(this)) +{ + // --- Navigation bar --- + SetButtonIcon(_prevBtn, GetThemeTypeName() == "Light" + ? "theme:Light/left.svg" + : "theme:Dark/left.svg"); + SetButtonIcon(_nextBtn, GetThemeTypeName() == "Light" + ? "theme:Light/right.svg" + : "theme:Dark/right.svg"); + _navLabel->setAlignment(Qt::AlignCenter); + + _monthBtn->setCheckable(true); + _weekBtn->setCheckable(true); + _dayBtn->setCheckable(true); + _weekBtn->setChecked(true); + + auto navBar = new QHBoxLayout(); + navBar->addWidget(_prevBtn); + navBar->addWidget(_nextBtn); + navBar->addWidget(_todayBtn); + navBar->addStretch(); + navBar->addWidget(_navLabel, 1); + navBar->addStretch(); + navBar->addWidget(_monthBtn); + navBar->addWidget(_weekBtn); + navBar->addWidget(_dayBtn); + + // --- Views --- + _viewStack->addWidget(_monthView); + _viewStack->addWidget(_weekView); + _viewStack->addWidget(_dayView); + + // --- Main layout --- + auto mainLayout = new QVBoxLayout(this); + mainLayout->setContentsMargins(0, 0, 0, 0); + mainLayout->addLayout(navBar); + mainLayout->addWidget(_viewStack, 1); + setLayout(mainLayout); + + // --- Connections --- + connect(_prevBtn, &QPushButton::clicked, this, + &CalendarWidget::OnPrevClicked); + connect(_nextBtn, &QPushButton::clicked, this, + &CalendarWidget::OnNextClicked); + connect(_todayBtn, &QPushButton::clicked, this, + &CalendarWidget::OnTodayClicked); + connect(_monthBtn, &QPushButton::clicked, this, + &CalendarWidget::OnMonthModeClicked); + connect(_weekBtn, &QPushButton::clicked, this, + &CalendarWidget::OnWeekModeClicked); + connect(_dayBtn, &QPushButton::clicked, this, + &CalendarWidget::OnDayModeClicked); + + ConnectView(_monthView); + ConnectView(_weekView); + ConnectView(_dayView); + + // Start in week view + SwitchToView(_weekView); + UpdateNavLabel(); +} + +// --------------------------------------------------------------------------- +// Events +// --------------------------------------------------------------------------- + +void CalendarWidget::SetEvents(const QList &events) +{ + _events = events; + _monthView->SetEvents(events); + _weekView->SetEvents(events); + _dayView->SetEvents(events); +} + +void CalendarWidget::AddEvent(const CalendarEvent &event) +{ + _events.append(event); + _monthView->SetEvents(_events); + _weekView->SetEvents(_events); + _dayView->SetEvents(_events); +} + +void CalendarWidget::RemoveEvent(const QString &id) +{ + _events.erase(std::remove_if(_events.begin(), _events.end(), + [&id](const CalendarEvent &e) { + return e.id == id; + }), + _events.end()); + _monthView->SetEvents(_events); + _weekView->SetEvents(_events); + _dayView->SetEvents(_events); +} + +void CalendarWidget::ClearEvents() +{ + _events.clear(); + _monthView->SetEvents(_events); + _weekView->SetEvents(_events); + _dayView->SetEvents(_events); +} + +// --------------------------------------------------------------------------- +// Navigation +// --------------------------------------------------------------------------- + +void CalendarWidget::GoToDate(const QDate &date) +{ + _activeView->SetDate(date); + UpdateNavLabel(); +} + +void CalendarWidget::GoToToday() +{ + GoToDate(QDate::currentDate()); +} + +QDate CalendarWidget::VisibleRangeStart() const +{ + return _activeView ? _activeView->RangeStart() : QDate(); +} + +QDate CalendarWidget::VisibleRangeEnd() const +{ + return _activeView ? _activeView->RangeEnd() : QDate(); +} + +void CalendarWidget::OnPrevClicked() +{ + if (_viewMode == ViewMode::Month) { + _activeView->SetDate(_activeView->CurrentDate().addMonths(-1)); + } else if (_viewMode == ViewMode::Week) { + _activeView->SetDate(_activeView->RangeStart().addDays(-7)); + } else { + _activeView->SetDate(_activeView->CurrentDate().addDays(-1)); + } + UpdateNavLabel(); +} + +void CalendarWidget::OnNextClicked() +{ + if (_viewMode == ViewMode::Month) { + _activeView->SetDate(_activeView->CurrentDate().addMonths(1)); + } else if (_viewMode == ViewMode::Week) { + _activeView->SetDate(_activeView->RangeStart().addDays(7)); + } else { + _activeView->SetDate(_activeView->CurrentDate().addDays(1)); + } + UpdateNavLabel(); +} + +void CalendarWidget::OnTodayClicked() +{ + GoToToday(); +} + +void CalendarWidget::OnMonthModeClicked() +{ + SetViewMode(ViewMode::Month); +} + +void CalendarWidget::OnWeekModeClicked() +{ + SetViewMode(ViewMode::Week); +} + +void CalendarWidget::OnDayModeClicked() +{ + SetViewMode(ViewMode::Day); +} + +void CalendarWidget::OnViewRangeChanged(const QDate &start, const QDate &end) +{ + emit VisibleRangeChanged(start, end); + UpdateNavLabel(); +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +void CalendarWidget::ConnectView(CalendarView *view) +{ + connect(view, &CalendarView::SlotClicked, this, + &CalendarWidget::SlotClicked); + connect(view, &CalendarView::EventClicked, this, + &CalendarWidget::EventClicked); + connect(view, &CalendarView::EventDoubleClicked, this, + &CalendarWidget::EventDoubleClicked); + connect(view, &CalendarView::VisibleRangeChanged, this, + &CalendarWidget::OnViewRangeChanged); +} + +void CalendarWidget::SwitchToView(CalendarView *view) +{ + _activeView = view; + _viewStack->setCurrentWidget(view); + view->SetEvents(_events); + UpdateNavLabel(); +} + +void CalendarWidget::SetViewMode(ViewMode mode) +{ + if (_viewMode == mode) { + return; + } + _viewMode = mode; + + // Preserve the current date when switching views + const QDate current = _activeView->CurrentDate(); + + _monthBtn->setChecked(mode == ViewMode::Month); + _weekBtn->setChecked(mode == ViewMode::Week); + _dayBtn->setChecked(mode == ViewMode::Day); + + switch (mode) { + case ViewMode::Month: + SwitchToView(_monthView); + _monthView->SetDate(current); + break; + case ViewMode::Week: + SwitchToView(_weekView); + _weekView->SetDate(current); + break; + case ViewMode::Day: { + SwitchToView(_dayView); + const QDate today = QDate::currentDate(); + const QDate rangeStart = _activeView->RangeStart(); + const QDate rangeEnd = _activeView->RangeEnd(); + const QDate target = (today >= rangeStart && today <= rangeEnd) + ? today + : rangeStart; + _dayView->SetDate(target); + break; + } + } + UpdateNavLabel(); +} + +void CalendarWidget::UpdateNavLabel() +{ + if (!_activeView) { + return; + } + QLocale locale; + const QDate cur = _activeView->CurrentDate(); + + if (_viewMode == ViewMode::Month) { + _navLabel->setText(locale.monthName(cur.month()) + " " + + QString::number(cur.year())); + } else if (_viewMode == ViewMode::Day) { + // Day view: "Monday, April 13, 2026" + _navLabel->setText(locale.dayName(cur.dayOfWeek()) + ", " + + locale.monthName(cur.month()) + " " + + QString::number(cur.day()) + ", " + + QString::number(cur.year())); + } else { + // Week view: "Apr 7 – Apr 13, 2026" + const QDate s = _activeView->RangeStart(); + const QDate e = _activeView->RangeEnd(); + if (s.month() == e.month()) { + _navLabel->setText( + locale.monthName(s.month(), + QLocale::ShortFormat) + + " " + QString::number(s.day()) + " - " + + QString::number(e.day()) + ", " + + QString::number(s.year())); + } else { + _navLabel->setText( + locale.monthName(s.month(), + QLocale::ShortFormat) + + " " + QString::number(s.day()) + " - " + + locale.monthName(e.month(), + QLocale::ShortFormat) + + " " + QString::number(e.day()) + ", " + + QString::number(s.year())); + } + } +} + +} // namespace advss diff --git a/plugins/schedule/calendar/calendar-widget.hpp b/plugins/schedule/calendar/calendar-widget.hpp new file mode 100644 index 00000000..06ac8e41 --- /dev/null +++ b/plugins/schedule/calendar/calendar-widget.hpp @@ -0,0 +1,90 @@ +#pragma once +#include "calendar-day-view.hpp" +#include "calendar-event.hpp" +#include "calendar-month-view.hpp" +#include "calendar-week-view.hpp" + +#include +#include +#include +#include +#include + +namespace advss { + +class CalendarWidget : public QWidget { + Q_OBJECT + +public: + enum class ViewMode { Month, Week, Day }; + + explicit CalendarWidget(QWidget *parent = nullptr); + + // --- View --- + void SetViewMode(ViewMode mode); + ViewMode GetViewMode() const { return _viewMode; } + + // --- Events --- + void SetEvents(const QList &events); + void AddEvent(const CalendarEvent &event); + void RemoveEvent(const QString &id); + void ClearEvents(); + const QList &GetEvents() const { return _events; } + + // --- Navigation --- + void GoToDate(const QDate &date); + void GoToToday(); + + // --- Visible range --- + QDate VisibleRangeStart() const; + QDate VisibleRangeEnd() const; + +signals: + // User clicked an empty slot in the active view. + void SlotClicked(const QDateTime &startTime); + + // User single-clicked an event. + void EventClicked(const QString &eventId); + + // User double-clicked an event (typically: open edit dialog). + void EventDoubleClicked(const QString &eventId); + + // The visible date range changed; reload your events for [start, end]. + void VisibleRangeChanged(const QDate &rangeStart, + const QDate &rangeEnd); + +private slots: + void OnPrevClicked(); + void OnNextClicked(); + void OnTodayClicked(); + void OnMonthModeClicked(); + void OnWeekModeClicked(); + void OnDayModeClicked(); + void OnViewRangeChanged(const QDate &start, const QDate &end); + +private: + void ConnectView(CalendarView *view); + void SwitchToView(CalendarView *view); + void UpdateNavLabel(); + + // Navigation bar widgets + QPushButton *_prevBtn; + QPushButton *_nextBtn; + QPushButton *_todayBtn; + QLabel *_navLabel; + QPushButton *_monthBtn; + QPushButton *_weekBtn; + QPushButton *_dayBtn; + + // View stack + QStackedWidget *_viewStack; + CalendarMonthView *_monthView; + CalendarWeekView *_weekView; + CalendarDayView *_dayView; + CalendarView *_activeView = nullptr; + + ViewMode _viewMode = ViewMode::Week; + QList _events; +}; + +} // namespace advss From 76abda6e725956c1b7a325e0ff6cc36f6dd80017 Mon Sep 17 00:00:00 2001 From: WarmUpTill <19472752+WarmUpTill@users.noreply.github.com> Date: Sat, 11 Apr 2026 19:19:17 +0200 Subject: [PATCH 06/13] Add "Macro Schedule" tab --- data/locale/en-US.ini | 74 ++ plugins/CMakeLists.txt | 4 +- plugins/schedule/CMakeLists.txt | 27 + .../schedule/macro-schedule-entry-dialog.cpp | 438 +++++++++++ .../schedule/macro-schedule-entry-dialog.hpp | 75 ++ plugins/schedule/macro-schedule-tab.cpp | 696 ++++++++++++++++++ plugins/schedule/macro-schedule-tab.hpp | 70 ++ plugins/schedule/macro-schedule.cpp | 507 +++++++++++++ plugins/schedule/macro-schedule.hpp | 83 +++ 9 files changed, 1972 insertions(+), 2 deletions(-) create mode 100644 plugins/schedule/CMakeLists.txt create mode 100644 plugins/schedule/macro-schedule-entry-dialog.cpp create mode 100644 plugins/schedule/macro-schedule-entry-dialog.hpp create mode 100644 plugins/schedule/macro-schedule-tab.cpp create mode 100644 plugins/schedule/macro-schedule-tab.hpp create mode 100644 plugins/schedule/macro-schedule.cpp create mode 100644 plugins/schedule/macro-schedule.hpp diff --git a/data/locale/en-US.ini b/data/locale/en-US.ini index 34e6db35..dc15f51d 100644 --- a/data/locale/en-US.ini +++ b/data/locale/en-US.ini @@ -206,6 +206,7 @@ AdvSceneSwitcher.macroTab.export.additionalContent="Export Additional Content" AdvSceneSwitcher.macroTab.export.variables="Variables" AdvSceneSwitcher.macroTab.export.actionQueues="Action Queues" AdvSceneSwitcher.macroTab.export.httpServers="HTTP Servers" +AdvSceneSwitcher.macroTab.export.macroScheduleEntries="Macro Schedule" AdvSceneSwitcher.macroTab.export.twitchConnections="Twitch Connections" AdvSceneSwitcher.macroTab.export.websocketConnections="Websocket Connections" AdvSceneSwitcher.macroTab.import="Import" @@ -2718,6 +2719,79 @@ AdvSceneSwitcher.calendar.week="Week" AdvSceneSwitcher.calendar.day="Day" AdvSceneSwitcher.calendar.moreEvents="+%1 more" +# Macro Schedule tab +AdvSceneSwitcher.macroScheduleTab.title="Macro Schedule" +AdvSceneSwitcher.macroScheduleTab.help="No scheduled entries.\nClick '+' to schedule a macro." +AdvSceneSwitcher.macroScheduleTab.add="Add" +AdvSceneSwitcher.macroScheduleTab.add.tooltip="Add schedule entry" +AdvSceneSwitcher.macroScheduleTab.edit="Edit" +AdvSceneSwitcher.macroScheduleTab.remove="Remove" +AdvSceneSwitcher.macroScheduleTab.remove.tooltip="Remove schedule entry" +AdvSceneSwitcher.macroScheduleTab.enable="Enable" +AdvSceneSwitcher.macroScheduleTab.disable="Disable" +AdvSceneSwitcher.macroScheduleTab.showAll="Show All" +AdvSceneSwitcher.macroScheduleTab.column.name="Name" +AdvSceneSwitcher.macroScheduleTab.column.macro="Macro" +AdvSceneSwitcher.macroScheduleTab.column.schedule="Schedule" +AdvSceneSwitcher.macroScheduleTab.column.nextTrigger="Next Trigger" +AdvSceneSwitcher.macroScheduleTab.column.status="Status" +AdvSceneSwitcher.macroScheduleTab.remove.confirm="Are you sure you want to remove \"%1\"?" +AdvSceneSwitcher.macroScheduleTab.remove.confirmMultiple="Are you sure you want to remove %1 entries?" +AdvSceneSwitcher.macroScheduleTab.status.active="Active" +AdvSceneSwitcher.macroScheduleTab.status.disabled="Disabled" +AdvSceneSwitcher.macroScheduleTab.status.expired="Expired" + +# Macro Schedule entry dialog +AdvSceneSwitcher.macroScheduleEntry.dialog.title.add="Add Schedule Entry" +AdvSceneSwitcher.macroScheduleEntry.dialog.title.edit="Edit Schedule Entry" +AdvSceneSwitcher.macroScheduleEntry.name="Name (optional):" +AdvSceneSwitcher.macroScheduleEntry.name.placeholder="e.g. Daily stream start" +AdvSceneSwitcher.macroScheduleEntry.macro="Macro:" +AdvSceneSwitcher.macroScheduleEntry.startDateTime="Start:" +AdvSceneSwitcher.macroScheduleEntry.endDate="End date:" +AdvSceneSwitcher.macroScheduleEntry.endDate.help="Controls how far the entry spans visually in the calendar and optionally triggers an action when the date is reached." +AdvSceneSwitcher.macroScheduleEntry.endDate.action.none="No action" +AdvSceneSwitcher.macroScheduleEntry.endDate.action.disableEntry="Disable schedule entry" +AdvSceneSwitcher.macroScheduleEntry.endDate.action.pauseMacro="Pause macro" +AdvSceneSwitcher.macroScheduleEntry.endDate.action.stopMacro="Stop macro" +AdvSceneSwitcher.macroScheduleEntry.repeat="Repeat:" +AdvSceneSwitcher.macroScheduleEntry.repeat.none="None" +AdvSceneSwitcher.macroScheduleEntry.repeat.minutely="Every N minutes" +AdvSceneSwitcher.macroScheduleEntry.repeat.hourly="Hourly" +AdvSceneSwitcher.macroScheduleEntry.repeat.daily="Daily" +AdvSceneSwitcher.macroScheduleEntry.repeat.weekly="Weekly" +AdvSceneSwitcher.macroScheduleEntry.repeat.monthly="Monthly" +AdvSceneSwitcher.macroScheduleEntry.repeat.once="Once" +AdvSceneSwitcher.macroScheduleEntry.repeat.everyOne="Every %1" +AdvSceneSwitcher.macroScheduleEntry.repeat.everyN="Every %1 %2" +AdvSceneSwitcher.macroScheduleEntry.repeat.unit.minute="minute" +AdvSceneSwitcher.macroScheduleEntry.repeat.unit.minutes="minutes" +AdvSceneSwitcher.macroScheduleEntry.repeat.unit.hour="hour" +AdvSceneSwitcher.macroScheduleEntry.repeat.unit.hours="hours" +AdvSceneSwitcher.macroScheduleEntry.repeat.unit.day="day" +AdvSceneSwitcher.macroScheduleEntry.repeat.unit.days="days" +AdvSceneSwitcher.macroScheduleEntry.repeat.unit.week="week" +AdvSceneSwitcher.macroScheduleEntry.repeat.unit.weeks="weeks" +AdvSceneSwitcher.macroScheduleEntry.repeat.unit.month="month" +AdvSceneSwitcher.macroScheduleEntry.repeat.unit.months="months" +AdvSceneSwitcher.macroScheduleEntry.repeat.interval.prefix="Every" +AdvSceneSwitcher.macroScheduleEntry.repeat.interval.unit.minute="minute(s)" +AdvSceneSwitcher.macroScheduleEntry.repeat.interval.unit.hour="hour(s)" +AdvSceneSwitcher.macroScheduleEntry.repeat.interval.unit.day="day(s)" +AdvSceneSwitcher.macroScheduleEntry.repeat.interval.unit.week="week(s)" +AdvSceneSwitcher.macroScheduleEntry.repeat.interval.unit.month="month(s)" +AdvSceneSwitcher.macroScheduleEntry.noMacro="(no macro)" +AdvSceneSwitcher.macroScheduleEntry.repeatEnd="End repeat:" +AdvSceneSwitcher.macroScheduleEntry.repeatEnd.never="Never" +AdvSceneSwitcher.macroScheduleEntry.repeatEnd.afterNTimes="After" +AdvSceneSwitcher.macroScheduleEntry.repeatEnd.afterNTimes.suffix="occurrence(s)" +AdvSceneSwitcher.macroScheduleEntry.repeatEnd.untilDate="Until" +AdvSceneSwitcher.macroScheduleEntry.color="Color:" +AdvSceneSwitcher.macroScheduleEntry.color.dialog="Pick entry color" +AdvSceneSwitcher.macroScheduleEntry.checkConditions="Check macro conditions before running" +AdvSceneSwitcher.macroScheduleEntry.runElseActionsOnConditionFailure="Run else actions if conditions are not met" +AdvSceneSwitcher.macroScheduleEntry.enabled="Enabled" + # Legacy tabs below - please don't waste your time adding translations for these :) # Transition Tab AdvSceneSwitcher.transitionTab.title="Transition" diff --git a/plugins/CMakeLists.txt b/plugins/CMakeLists.txt index 9670718f..0d5eba32 100644 --- a/plugins/CMakeLists.txt +++ b/plugins/CMakeLists.txt @@ -7,8 +7,6 @@ endmacro() # ---------------------------------------------------------------------------- # -add_subdirectory(base) - # Add plugins below, which have dependencies to external libraries or other # components which might potentially not be fulfilled. @@ -28,10 +26,12 @@ install_advss_plugin_dependency(...) ... to install the plugin and its dependencies. #]] +add_plugin(base) add_plugin(http) add_plugin(midi) add_plugin(mqtt) add_plugin(openvr) +add_plugin(schedule) add_plugin(scripting) add_plugin(stream-deck) add_plugin(twitch) diff --git a/plugins/schedule/CMakeLists.txt b/plugins/schedule/CMakeLists.txt new file mode 100644 index 00000000..603ff3b3 --- /dev/null +++ b/plugins/schedule/CMakeLists.txt @@ -0,0 +1,27 @@ +cmake_minimum_required(VERSION 3.14) +project(advanced-scene-switcher-macro-schedule) + +add_library(${PROJECT_NAME} MODULE) + +target_sources( + ${PROJECT_NAME} + PRIVATE macro-schedule.cpp + macro-schedule.hpp + macro-schedule-entry-dialog.cpp + macro-schedule-entry-dialog.hpp + macro-schedule-tab.cpp + macro-schedule-tab.hpp + calendar/calendar-event.hpp + calendar/calendar-view.hpp + calendar/calendar-month-view.hpp + calendar/calendar-month-view.cpp + calendar/calendar-day-view.hpp + calendar/calendar-day-view.cpp + calendar/calendar-week-view.hpp + calendar/calendar-week-view.cpp + calendar/calendar-widget.hpp + calendar/calendar-widget.cpp) + +setup_advss_plugin(${PROJECT_NAME}) +set_target_properties(${PROJECT_NAME} PROPERTIES PREFIX "") +install_advss_plugin(${PROJECT_NAME}) diff --git a/plugins/schedule/macro-schedule-entry-dialog.cpp b/plugins/schedule/macro-schedule-entry-dialog.cpp new file mode 100644 index 00000000..41cc8612 --- /dev/null +++ b/plugins/schedule/macro-schedule-entry-dialog.cpp @@ -0,0 +1,438 @@ +#include "macro-schedule-entry-dialog.hpp" + +#include "help-icon.hpp" +#include "obs-module-helper.hpp" +#include "ui-helpers.hpp" + +#include +#include +#include +#include +#include + +namespace advss { + +bool MacroScheduleEntryDialog::AskForSettings(QWidget *parent, + MacroScheduleEntry &entry, + bool isNew) +{ + MacroScheduleEntryDialog dialog(parent, entry, isNew); + if (dialog.exec() != QDialog::Accepted) { + return false; + } + dialog.ApplyToEntry(entry); + return true; +} + +MacroScheduleEntryDialog::MacroScheduleEntryDialog( + QWidget *parent, const MacroScheduleEntry &entry, bool isNew) + : QDialog(parent), + _name(new QLineEdit(this)), + _macroSel(new MacroSelection(this)), + _startDateTime(new QDateTimeEdit(this)), + _hasEndDate(new QCheckBox( + obs_module_text("AdvSceneSwitcher.macroScheduleEntry.endDate"), + this)), + _endDate(new QDateTimeEdit(this)), + _endDateAction(new QComboBox(this)), + _repeatType(new QComboBox(this)), + _intervalRow(new QWidget(this)), + _repeatInterval(new QSpinBox(_intervalRow)), + _intervalUnitLabel(new QLabel("", _intervalRow)), + _repeatEndGroup(new QGroupBox( + obs_module_text( + "AdvSceneSwitcher.macroScheduleEntry.repeatEnd"), + this)), + _endNever(new QRadioButton( + obs_module_text( + "AdvSceneSwitcher.macroScheduleEntry.repeatEnd.never"), + _repeatEndGroup)), + _endAfterN(new QRadioButton( + obs_module_text( + "AdvSceneSwitcher.macroScheduleEntry.repeatEnd.afterNTimes"), + _repeatEndGroup)), + _endCount(new QSpinBox(_repeatEndGroup)), + _endUntil(new QRadioButton( + obs_module_text( + "AdvSceneSwitcher.macroScheduleEntry.repeatEnd.untilDate"), + _repeatEndGroup)), + _endUntilDate(new QDateEdit(_repeatEndGroup)), + _colorBtn(new QPushButton(this)), + _checkConditions(new QCheckBox( + obs_module_text( + "AdvSceneSwitcher.macroScheduleEntry.checkConditions"), + this)), + _runElseActionsOnConditionFailure(new QCheckBox( + obs_module_text( + "AdvSceneSwitcher.macroScheduleEntry.runElseActionsOnConditionFailure"), + this)), + _enabled(new QCheckBox( + obs_module_text("AdvSceneSwitcher.macroScheduleEntry.enabled"), + this)) +{ + setWindowTitle(obs_module_text( + isNew ? "AdvSceneSwitcher.macroScheduleEntry.dialog.title.add" + : "AdvSceneSwitcher.macroScheduleEntry.dialog.title.edit")); + setMinimumWidth(420); + + auto form = new QFormLayout(); + form->setFieldGrowthPolicy(QFormLayout::ExpandingFieldsGrow); + + // --- Name --- + _name->setPlaceholderText(obs_module_text( + "AdvSceneSwitcher.macroScheduleEntry.name.placeholder")); + form->addRow( + obs_module_text("AdvSceneSwitcher.macroScheduleEntry.name"), + _name); + + // --- Macro --- + form->addRow( + obs_module_text("AdvSceneSwitcher.macroScheduleEntry.macro"), + _macroSel); + + // --- Start date/time --- + _startDateTime->setDisplayFormat("yyyy-MM-dd HH:mm:ss"); + _startDateTime->setCalendarPopup(true); + _startDateTime->setDateTime(QDateTime::currentDateTime()); + form->addRow( + obs_module_text( + "AdvSceneSwitcher.macroScheduleEntry.startDateTime"), + _startDateTime); + + // --- Optional end date --- + + _endDate->setDisplayFormat("yyyy-MM-dd HH:mm:ss"); + _endDate->setCalendarPopup(true); + _endDate->setDateTime(QDateTime::currentDateTime().addSecs(60 * 60)); + _endDate->setEnabled(false); + auto endDateHelp = new HelpIcon( + obs_module_text( + "AdvSceneSwitcher.macroScheduleEntry.endDate.help"), + this); + _endDateAction->addItem( + obs_module_text( + "AdvSceneSwitcher.macroScheduleEntry.endDate.action.none"), + static_cast(MacroScheduleEntry::EndDateAction::NONE)); + _endDateAction->addItem( + obs_module_text( + "AdvSceneSwitcher.macroScheduleEntry.endDate.action.disableEntry"), + static_cast( + MacroScheduleEntry::EndDateAction::DISABLE_ENTRY)); + _endDateAction->addItem( + obs_module_text( + "AdvSceneSwitcher.macroScheduleEntry.endDate.action.pauseMacro"), + static_cast( + MacroScheduleEntry::EndDateAction::PAUSE_MACRO)); + _endDateAction->addItem( + obs_module_text( + "AdvSceneSwitcher.macroScheduleEntry.endDate.action.stopMacro"), + static_cast( + MacroScheduleEntry::EndDateAction::STOP_MACRO)); + _endDateAction->setEnabled(false); + + auto endDateRow = new QHBoxLayout(); + endDateRow->addWidget(_hasEndDate); + endDateRow->addWidget(_endDate); + endDateRow->addWidget(_endDateAction); + endDateRow->addWidget(endDateHelp); + endDateRow->addStretch(); + form->addRow(QString(), endDateRow); + + connect(_hasEndDate, &QCheckBox::toggled, _endDate, + &QDateTimeEdit::setEnabled); + connect(_hasEndDate, &QCheckBox::toggled, _endDateAction, + &QComboBox::setEnabled); + + // Keep end date minimum in sync with the start date. + _endDate->setMinimumDateTime(_startDateTime->dateTime()); + connect(_startDateTime, &QDateTimeEdit::dateTimeChanged, this, + [this](const QDateTime &dt) { + _endDate->setMinimumDateTime(dt); + if (_endDate->dateTime() < dt) { + _endDate->setDateTime(dt); + } + }); + + // --- Repeat type --- + _repeatType->addItem( + obs_module_text( + "AdvSceneSwitcher.macroScheduleEntry.repeat.none"), + static_cast(MacroScheduleEntry::RepeatType::NONE)); + _repeatType->addItem( + obs_module_text( + "AdvSceneSwitcher.macroScheduleEntry.repeat.minutely"), + static_cast(MacroScheduleEntry::RepeatType::MINUTELY)); + _repeatType->addItem( + obs_module_text( + "AdvSceneSwitcher.macroScheduleEntry.repeat.hourly"), + static_cast(MacroScheduleEntry::RepeatType::HOURLY)); + _repeatType->addItem( + obs_module_text( + "AdvSceneSwitcher.macroScheduleEntry.repeat.daily"), + static_cast(MacroScheduleEntry::RepeatType::DAILY)); + _repeatType->addItem( + obs_module_text( + "AdvSceneSwitcher.macroScheduleEntry.repeat.weekly"), + static_cast(MacroScheduleEntry::RepeatType::WEEKLY)); + _repeatType->addItem( + obs_module_text( + "AdvSceneSwitcher.macroScheduleEntry.repeat.monthly"), + static_cast(MacroScheduleEntry::RepeatType::MONTHLY)); + form->addRow( + obs_module_text("AdvSceneSwitcher.macroScheduleEntry.repeat"), + _repeatType); + + // --- Repeat interval row (hidden when NONE) --- + auto intervalLayout = new QHBoxLayout(_intervalRow); + intervalLayout->setContentsMargins(0, 0, 0, 0); + auto everyLabel = new QLabel( + obs_module_text( + "AdvSceneSwitcher.macroScheduleEntry.repeat.interval.prefix"), + _intervalRow); + _repeatInterval->setMinimum(1); + _repeatInterval->setMaximum(999); + _repeatInterval->setValue(1); + intervalLayout->addWidget(everyLabel); + intervalLayout->addWidget(_repeatInterval); + intervalLayout->addWidget(_intervalUnitLabel); + intervalLayout->addStretch(); + form->addRow(QString(), _intervalRow); + + // --- Repeat end group (hidden when NONE) --- + + auto repeatEndLayout = new QVBoxLayout(_repeatEndGroup); + + _endNever->setChecked(true); + repeatEndLayout->addWidget(_endNever); + + auto afterNRow = new QHBoxLayout(); + _endCount->setMinimum(1); + _endCount->setMaximum(99999); + _endCount->setValue(1); + _endCount->setEnabled(false); + auto afterNSuffix = new QLabel( + obs_module_text( + "AdvSceneSwitcher.macroScheduleEntry.repeatEnd.afterNTimes.suffix"), + _repeatEndGroup); + afterNRow->addWidget(_endAfterN); + afterNRow->addWidget(_endCount); + afterNRow->addWidget(afterNSuffix); + afterNRow->addStretch(); + repeatEndLayout->addLayout(afterNRow); + + auto untilRow = new QHBoxLayout(); + _endUntilDate->setCalendarPopup(true); + _endUntilDate->setDate(QDate::currentDate().addMonths(1)); + _endUntilDate->setEnabled(false); + untilRow->addWidget(_endUntil); + untilRow->addWidget(_endUntilDate); + untilRow->addStretch(); + repeatEndLayout->addLayout(untilRow); + + form->addRow(_repeatEndGroup); + + // Enable/disable child widgets based on radio selection + connect(_endAfterN, &QRadioButton::toggled, _endCount, + &QSpinBox::setEnabled); + connect(_endUntil, &QRadioButton::toggled, _endUntilDate, + &QDateEdit::setEnabled); + + // --- Color --- + _colorBtn->setFixedSize(48, 22); + _colorBtn->setFlat(false); + connect(_colorBtn, &QPushButton::clicked, this, [this]() { + const QColor picked = QColorDialog::getColor( + _color, this, + obs_module_text( + "AdvSceneSwitcher.macroScheduleEntry.color.dialog")); + if (picked.isValid()) { + _color = picked; + UpdateColorButton(); + } + }); + form->addRow( + obs_module_text("AdvSceneSwitcher.macroScheduleEntry.color"), + _colorBtn); + + // --- Options --- + form->addRow(QString(), _checkConditions); + + _runElseActionsOnConditionFailure->setVisible(false); + form->addRow(QString(), _runElseActionsOnConditionFailure); + connect(_checkConditions, &QCheckBox::toggled, + _runElseActionsOnConditionFailure, &QCheckBox::setVisible); + + _enabled->setChecked(true); + form->addRow(QString(), _enabled); + + // --- Buttons --- + auto buttons = new QDialogButtonBox( + QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this); + connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept); + connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject); + + // --- Main layout --- + auto mainLayout = new QVBoxLayout(this); + mainLayout->addLayout(form); + mainLayout->addWidget(buttons); + setLayout(mainLayout); + + // Connect repeat type change + connect(_repeatType, + QOverload::of(&QComboBox::currentIndexChanged), this, + [this](int) { UpdateRepeatVisibility(); }); + + PopulateFromEntry(entry); + UpdateRepeatVisibility(); +} + +void MacroScheduleEntryDialog::PopulateFromEntry(const MacroScheduleEntry &entry) +{ + _name->setText(QString::fromStdString(entry.name)); + + MacroRef ref = entry.macro; + _macroSel->SetCurrentMacro(ref); + + if (entry.startDateTime.isValid()) { + _startDateTime->setDateTime(entry.startDateTime); + } + + _hasEndDate->setChecked(entry.hasEndDate); + if (entry.hasEndDate && entry.endDate.isValid()) { + _endDate->setDateTime(entry.endDate); + } + { + const int idx = _endDateAction->findData( + static_cast(entry.endDateAction)); + if (idx >= 0) { + _endDateAction->setCurrentIndex(idx); + } + } + + const int repeatIdx = + _repeatType->findData(static_cast(entry.repeatType)); + if (repeatIdx >= 0) { + _repeatType->setCurrentIndex(repeatIdx); + } + _repeatInterval->setValue(entry.repeatInterval); + + switch (entry.repeatEndType) { + case MacroScheduleEntry::RepeatEndType::NEVER: + _endNever->setChecked(true); + break; + case MacroScheduleEntry::RepeatEndType::AFTER_N_TIMES: + _endAfterN->setChecked(true); + _endCount->setValue(entry.repeatMaxCount); + break; + case MacroScheduleEntry::RepeatEndType::UNTIL_DATE: + _endUntil->setChecked(true); + if (entry.repeatUntilDate.isValid()) { + _endUntilDate->setDate(entry.repeatUntilDate.date()); + } + break; + } + + _color = entry.color.isValid() ? entry.color : QColor(70, 130, 180); + UpdateColorButton(); + _checkConditions->setChecked(entry.checkConditions); + _runElseActionsOnConditionFailure->setChecked( + entry.runElseActionsOnConditionFailure); + _runElseActionsOnConditionFailure->setVisible(entry.checkConditions); + _enabled->setChecked(entry.enabled); +} + +void MacroScheduleEntryDialog::ApplyToEntry(MacroScheduleEntry &entry) const +{ + entry.name = _name->text().toStdString(); + + const QString macroName = _macroSel->currentText(); + entry.macro = macroName; + + entry.startDateTime = _startDateTime->dateTime(); + + entry.hasEndDate = _hasEndDate->isChecked(); + if (entry.hasEndDate) { + entry.endDate = _endDate->dateTime(); + entry.endDateAction = + static_cast( + _endDateAction->currentData().toInt()); + } else { + entry.endDate = QDateTime(); + entry.endDateAction = MacroScheduleEntry::EndDateAction::NONE; + } + entry.endDateActionApplied = false; + + const int repeatData = _repeatType->currentData().toInt(); + entry.repeatType = + static_cast(repeatData); + entry.repeatInterval = _repeatInterval->value(); + + if (_endNever->isChecked()) { + entry.repeatEndType = MacroScheduleEntry::RepeatEndType::NEVER; + entry.repeatUntilDate = QDateTime(); + } else if (_endAfterN->isChecked()) { + entry.repeatEndType = + MacroScheduleEntry::RepeatEndType::AFTER_N_TIMES; + entry.repeatMaxCount = _endCount->value(); + entry.repeatUntilDate = QDateTime(); + } else if (_endUntil->isChecked()) { + entry.repeatEndType = + MacroScheduleEntry::RepeatEndType::UNTIL_DATE; + entry.repeatUntilDate = + QDateTime(_endUntilDate->date(), QTime(23, 59, 59)); + } + + entry.color = _color; + entry.checkConditions = _checkConditions->isChecked(); + entry.runElseActionsOnConditionFailure = + _runElseActionsOnConditionFailure->isChecked(); + entry.enabled = _enabled->isChecked(); +} + +static QString repeatTypeUnitLabel(MacroScheduleEntry::RepeatType type) +{ + switch (type) { + case MacroScheduleEntry::RepeatType::MINUTELY: + return obs_module_text( + "AdvSceneSwitcher.macroScheduleEntry.repeat.interval.unit.minute"); + case MacroScheduleEntry::RepeatType::HOURLY: + return obs_module_text( + "AdvSceneSwitcher.macroScheduleEntry.repeat.interval.unit.hour"); + case MacroScheduleEntry::RepeatType::DAILY: + return obs_module_text( + "AdvSceneSwitcher.macroScheduleEntry.repeat.interval.unit.day"); + case MacroScheduleEntry::RepeatType::WEEKLY: + return obs_module_text( + "AdvSceneSwitcher.macroScheduleEntry.repeat.interval.unit.week"); + case MacroScheduleEntry::RepeatType::MONTHLY: + return obs_module_text( + "AdvSceneSwitcher.macroScheduleEntry.repeat.interval.unit.month"); + default: + return ""; + } +} + +void MacroScheduleEntryDialog::UpdateRepeatVisibility() +{ + const int data = _repeatType->currentData().toInt(); + const auto type = static_cast(data); + const bool repeating = (type != MacroScheduleEntry::RepeatType::NONE); + + _intervalRow->setVisible(repeating); + _repeatEndGroup->setVisible(repeating); + + if (repeating) { + _intervalUnitLabel->setText(repeatTypeUnitLabel(type)); + } +} + +void MacroScheduleEntryDialog::UpdateColorButton() +{ + // Fill the button with the chosen color so it acts as a swatch + const QString style = + QString("background-color: %1; border: 1px solid #888;") + .arg(_color.name()); + _colorBtn->setStyleSheet(style); +} + +} // namespace advss diff --git a/plugins/schedule/macro-schedule-entry-dialog.hpp b/plugins/schedule/macro-schedule-entry-dialog.hpp new file mode 100644 index 00000000..afbf61a6 --- /dev/null +++ b/plugins/schedule/macro-schedule-entry-dialog.hpp @@ -0,0 +1,75 @@ +#pragma once + +#include "macro-schedule.hpp" +#include "macro-selection.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace advss { + +// Dialog for creating or editing a MacroScheduleEntry. +class MacroScheduleEntryDialog : public QDialog { + Q_OBJECT + +public: + static bool AskForSettings(QWidget *parent, MacroScheduleEntry &entry, + bool isNew); + +private: + explicit MacroScheduleEntryDialog(QWidget *parent, + const MacroScheduleEntry &entry, + bool isNew); + + void PopulateFromEntry(const MacroScheduleEntry &entry); + void ApplyToEntry(MacroScheduleEntry &entry) const; + void UpdateRepeatVisibility(); + + // Basic fields + QLineEdit *_name; + MacroSelection *_macroSel; + QDateTimeEdit *_startDateTime; + + // Optional end date + QCheckBox *_hasEndDate; + QDateTimeEdit *_endDate; + QComboBox *_endDateAction; + + // Repeat type + QComboBox *_repeatType; + + // Repeat interval (hidden when type == NONE) + QWidget *_intervalRow; + QSpinBox *_repeatInterval; + QLabel *_intervalUnitLabel; + + // Repeat end (hidden when type == NONE) + QGroupBox *_repeatEndGroup; + QRadioButton *_endNever; + QRadioButton *_endAfterN; + QSpinBox *_endCount; + QRadioButton *_endUntil; + QDateEdit *_endUntilDate; + + // Color + QPushButton *_colorBtn; + QColor _color; + void UpdateColorButton(); + + // Options + QCheckBox *_checkConditions; + QCheckBox *_runElseActionsOnConditionFailure; + QCheckBox *_enabled; +}; + +} // namespace advss diff --git a/plugins/schedule/macro-schedule-tab.cpp b/plugins/schedule/macro-schedule-tab.cpp new file mode 100644 index 00000000..4bbabf9d --- /dev/null +++ b/plugins/schedule/macro-schedule-tab.cpp @@ -0,0 +1,696 @@ +#include "macro-schedule-tab.hpp" +#include "calendar/calendar-event.hpp" +#include "macro-schedule-entry-dialog.hpp" + +#include "macro-helpers.hpp" +#include "macro-signals.hpp" +#include "obs-module-helper.hpp" +#include "sync-helpers.hpp" +#include "tab-helpers.hpp" +#include "ui-helpers.hpp" + +#include +#include +#include +#include +#include + +namespace advss { + +// --------------------------------------------------------------------------- +// Tab registration +// --------------------------------------------------------------------------- + +static bool registerTab(); +static bool registerTabDone = registerTab(); + +static MacroScheduleTab *tabWidget = nullptr; + +static constexpr int MACRO_COUNT_THRESHOLD = 5; + +static void setTabVisible(QTabWidget *tab, bool visible) +{ + SetTabVisibleByName( + tab, visible, + obs_module_text("AdvSceneSwitcher.macroScheduleTab.title")); +} + +static bool enoughMacros() +{ + return (int)GetAllMacros().size() >= MACRO_COUNT_THRESHOLD; +} + +static void setupTab(QTabWidget *tab) +{ + if (!GetScheduleEntries().empty()) { + setTabVisible(tab, true); + return; + } + + if (!enoughMacros()) { + setTabVisible(tab, false); + } + + QWidget::connect(MacroSignalManager::Instance(), + &MacroSignalManager::Add, tab, [tab](const QString &) { + if (enoughMacros()) { + setTabVisible(tab, true); + } + }); +} + +static bool registerTab() +{ + AddSetupTabCallback("macroScheduleTab", MacroScheduleTab::Create, + setupTab); + return true; +} + +// --------------------------------------------------------------------------- +// Entry -> CalendarEvent conversion +// --------------------------------------------------------------------------- + +static QColor ColorForEntry(const MacroScheduleEntry &entry) +{ + if (!entry.enabled || entry.IsExpired()) { + return QColor(150, 150, 150); + } + return entry.color.isValid() ? entry.color : QColor(70, 130, 180); +} + +// Returns one CalendarEvent per occurrence of entry within [rangeStart, rangeEnd]. +// For non-repeating entries this is at most one event. +// A hard cap of 500 occurrences prevents infinite loops for misconfigured entries. +static QList +EntryToCalendarEvents(const MacroScheduleEntry &entry, const QDate &rangeStart, + const QDate &rangeEnd) +{ + QList result; + + const QDateTime windowStart(rangeStart, QTime(0, 0)); + const QDateTime windowEnd(rangeEnd, QTime(23, 59, 59)); + + // Walk occurrences starting from the entry's first trigger time. + // We use a copy to advance lastTriggered without modifying the real entry. + MacroScheduleEntry cursor = entry; + cursor.lastTriggered = + QDateTime(); // start from the very first occurrence + cursor.timesTriggered = 0; + + static constexpr int MAX_OCCURRENCES = 500; + + for (int i = 0; i < MAX_OCCURRENCES; ++i) { + const QDateTime next = cursor.NextTriggerTime(); + if (!next.isValid() || next > windowEnd) { + break; + } + + if (next >= windowStart) { + CalendarEvent ev; + // Use id + occurrence index to make each event unique + // while still allowing the tab to look up the parent entry. + ev.id = QString::fromStdString(entry.id); + ev.title = entry.GetSummary(); + ev.color = ColorForEntry(entry); + ev.userData = ev.id; + ev.start = next; + if (entry.hasEndDate && entry.endDate.isValid()) { + ev.end = entry.endDate; + } + result.append(ev); + } + + if (entry.repeatType == MacroScheduleEntry::RepeatType::NONE) { + break; // one-shot — no more occurrences + } + + // Advance cursor past this occurrence + cursor.lastTriggered = next; + ++cursor.timesTriggered; + + if (cursor.IsExpired()) { + break; + } + } + + return result; +} + +// --------------------------------------------------------------------------- +// Table columns +// --------------------------------------------------------------------------- + +static constexpr int COL_NAME = 0; +static constexpr int COL_MACRO = 1; +static constexpr int COL_SCHEDULE = 2; +static constexpr int COL_NEXT_TRIGGER = 3; +static constexpr int COL_STATUS = 4; +static constexpr int COL_COUNT = 5; + +// --------------------------------------------------------------------------- +// MacroScheduleTab +// --------------------------------------------------------------------------- + +MacroScheduleTab *MacroScheduleTab::Create() +{ + tabWidget = new MacroScheduleTab(); + return tabWidget; +} + +MacroScheduleTab::MacroScheduleTab(QWidget *parent) + : QWidget(parent), + _calendar(new CalendarWidget(this)), + _table(new QTableWidget(0, COL_COUNT, this)), + _helpLabel(new QLabel( + obs_module_text("AdvSceneSwitcher.macroScheduleTab.help"), + this)), + _addBtn(new QToolButton(this)), + _editBtn(new QPushButton( + obs_module_text("AdvSceneSwitcher.macroScheduleTab.edit"), + this)), + _removeBtn(new QToolButton(this)), + _toggleBtn(new QPushButton( + obs_module_text("AdvSceneSwitcher.macroScheduleTab.enable"), + this)), + _refreshTimer(new QTimer(this)) +{ + // --- Add / Remove: icon buttons (same style as ResourceTable) --- + _addBtn->setProperty("themeID", + QVariant(QString::fromUtf8("addIconSmall"))); + _addBtn->setProperty("class", QVariant(QString::fromUtf8("icon-plus"))); + _addBtn->setToolTip(obs_module_text( + "AdvSceneSwitcher.macroScheduleTab.add.tooltip")); + + _removeBtn->setProperty("themeID", + QVariant(QString::fromUtf8("removeIconSmall"))); + _removeBtn->setProperty("class", + QVariant(QString::fromUtf8("icon-trash"))); + _removeBtn->setToolTip(obs_module_text( + "AdvSceneSwitcher.macroScheduleTab.remove.tooltip")); + + if (GetScheduleEntries().empty()) { + _addButtonHighlight = + HighlightWidget(_addBtn, QColor(Qt::green)); + } + + // --- Table --- + _table->setSelectionBehavior(QAbstractItemView::SelectRows); + _table->setSelectionMode(QAbstractItemView::ExtendedSelection); + _table->setContextMenuPolicy(Qt::CustomContextMenu); + _table->installEventFilter(this); + _table->setEditTriggers(QAbstractItemView::NoEditTriggers); + _table->setAlternatingRowColors(true); + _table->horizontalHeader()->setSectionResizeMode( + QHeaderView::ResizeToContents); + _table->horizontalHeader()->setStretchLastSection(true); + _table->verticalHeader()->setVisible(false); + _table->setHorizontalHeaderLabels( + {obs_module_text( + "AdvSceneSwitcher.macroScheduleTab.column.name"), + obs_module_text( + "AdvSceneSwitcher.macroScheduleTab.column.macro"), + obs_module_text( + "AdvSceneSwitcher.macroScheduleTab.column.schedule"), + obs_module_text( + "AdvSceneSwitcher.macroScheduleTab.column.nextTrigger"), + obs_module_text( + "AdvSceneSwitcher.macroScheduleTab.column.status")}); + + // --- Help label --- + _helpLabel->setAlignment(Qt::AlignCenter); + _helpLabel->setWordWrap(true); + + // --- Button row below the table --- + auto buttonRow = new QHBoxLayout(); + buttonRow->setContentsMargins(0, 0, 0, 0); + buttonRow->addWidget(_addBtn); + buttonRow->addWidget(_removeBtn); + buttonRow->addStretch(); + buttonRow->addWidget(_editBtn); + buttonRow->addWidget(_toggleBtn); + + // --- Right panel: help + table + buttons --- + auto rightPanel = new QWidget(this); + auto rightLayout = new QVBoxLayout(rightPanel); + rightLayout->setContentsMargins(0, 0, 0, 0); + rightLayout->addWidget(_helpLabel); + rightLayout->addWidget(_table, 1); + rightLayout->addLayout(buttonRow); + rightPanel->setLayout(rightLayout); + + // --- Splitter --- + auto splitter = new QSplitter(Qt::Horizontal, this); + splitter->addWidget(_calendar); + splitter->addWidget(rightPanel); + splitter->setStretchFactor(0, 1); + splitter->setStretchFactor(1, 1); + + // --- Main layout --- + auto mainLayout = new QVBoxLayout(this); + mainLayout->setContentsMargins(4, 4, 4, 4); + mainLayout->addWidget(splitter, 1); + setLayout(mainLayout); + + // --- Connections: toolbar buttons --- + connect(_addBtn, &QPushButton::clicked, this, &MacroScheduleTab::Add); + connect(_editBtn, &QPushButton::clicked, this, &MacroScheduleTab::Edit); + connect(_removeBtn, &QPushButton::clicked, this, + &MacroScheduleTab::Remove); + connect(_toggleBtn, &QPushButton::clicked, this, + &MacroScheduleTab::ToggleEnabled); + connect(_table, &QWidget::customContextMenuRequested, this, + &MacroScheduleTab::ShowContextMenu); + + // --- Connections: table --- + connect(_table, &QTableWidget::doubleClicked, this, + [this]() { Edit(); }); + connect(_table->selectionModel(), + &QItemSelectionModel::selectionChanged, this, + &MacroScheduleTab::OnSelectionChanged); + + // --- Connections: calendar --- + connect(_calendar, &CalendarWidget::SlotClicked, this, + &MacroScheduleTab::AddAtTime); + connect(_calendar, &CalendarWidget::EventClicked, this, + &MacroScheduleTab::SelectTableRowById); + connect(_calendar, &CalendarWidget::EventDoubleClicked, this, + &MacroScheduleTab::EditById); + connect(_calendar, &CalendarWidget::VisibleRangeChanged, this, + [this](const QDate &, const QDate &) { + std::deque snapshot; + { + auto lock = LockContext(); + snapshot = GetScheduleEntries(); + } + RefreshCalendar(snapshot); + }); + + // --- Periodic refresh --- + _refreshTimer->setInterval(30000); // 30 seconds + connect(_refreshTimer, &QTimer::timeout, this, + &MacroScheduleTab::Refresh); + _refreshTimer->start(); + + // Defer the initial refresh: Create() is called while the context lock + // is already held, so we cannot call LockContext() here directly. + QTimer::singleShot(0, this, &MacroScheduleTab::Refresh); +} + +// --------------------------------------------------------------------------- +// Refresh +// --------------------------------------------------------------------------- + +void MacroScheduleTab::Refresh() +{ + std::deque snapshot; + { + auto lock = LockContext(); + snapshot = GetScheduleEntries(); + } + + PopulateTable(); + RefreshCalendar(snapshot); + UpdateButtonStates(); +} + +void MacroScheduleTab::RefreshCalendar( + const std::deque &entries) +{ + const QDate rangeStart = _calendar->VisibleRangeStart(); + const QDate rangeEnd = _calendar->VisibleRangeEnd(); + + QList events; + for (const auto &entry : entries) { + events.append( + EntryToCalendarEvents(entry, rangeStart, rangeEnd)); + } + _calendar->SetEvents(events); +} + +void MacroScheduleTab::PopulateTable() +{ + // Remember which entry was selected so we can restore it after repopulating + const int prevIdx = SelectedEntryIndex(); + QString prevId; + std::deque snapshot; + { + auto lock = LockContext(); + const auto &entries = GetScheduleEntries(); + if (prevIdx >= 0 && prevIdx < (int)entries.size()) { + prevId = QString::fromStdString(entries[prevIdx].id); + } + snapshot = entries; + } + + _table->setRowCount(0); + + for (const auto &entry : snapshot) { + const int row = _table->rowCount(); + _table->insertRow(row); + + auto makeItem = [](const QString &text) { + auto item = new QTableWidgetItem(text); + item->setFlags(item->flags() & ~Qt::ItemIsEditable); + return item; + }; + + _table->setItem(row, COL_NAME, makeItem(entry.GetSummary())); + _table->setItem( + row, COL_MACRO, + makeItem(QString::fromStdString(entry.macro.Name()))); + _table->setItem(row, COL_SCHEDULE, + makeItem(entry.GetRepeatDescription())); + _table->setItem(row, COL_NEXT_TRIGGER, + makeItem(entry.GetNextTriggerString())); + + QString status; + if (!entry.enabled) { + status = obs_module_text( + "AdvSceneSwitcher.macroScheduleTab.status.disabled"); + } else if (entry.IsExpired()) { + status = obs_module_text( + "AdvSceneSwitcher.macroScheduleTab.status.expired"); + } else { + status = obs_module_text( + "AdvSceneSwitcher.macroScheduleTab.status.active"); + } + _table->setItem(row, COL_STATUS, makeItem(status)); + + _table->item(row, COL_NAME) + ->setData(Qt::UserRole, + QString::fromStdString(entry.id)); + } + + SetHelpVisible(_table->rowCount() == 0); + + // Restore selection + if (!prevId.isEmpty()) { + SelectTableRowById(prevId); + } +} + +void MacroScheduleTab::SetHelpVisible(bool visible) +{ + _helpLabel->setVisible(visible); + _table->setVisible(!visible); +} + +// --------------------------------------------------------------------------- +// Selection helpers +// --------------------------------------------------------------------------- + +int MacroScheduleTab::SelectedEntryIndex() const +{ + const auto selected = _table->selectionModel()->selectedRows(); + if (selected.isEmpty()) { + return -1; + } + const QString id = _table->item(selected.first().row(), COL_NAME) + ->data(Qt::UserRole) + .toString(); + const auto &entries = GetScheduleEntries(); + for (int i = 0; i < (int)entries.size(); ++i) { + if (QString::fromStdString(entries[i].id) == id) { + return i; + } + } + return -1; +} + +void MacroScheduleTab::SelectTableRowById(const QString &entryId) +{ + for (int row = 0; row < _table->rowCount(); ++row) { + auto item = _table->item(row, COL_NAME); + if (item && item->data(Qt::UserRole).toString() == entryId) { + _table->selectRow(row); + _table->scrollToItem(item); + return; + } + } +} + +QStringList MacroScheduleTab::SelectedEntryIds() const +{ + QStringList ids; + for (const auto &index : _table->selectionModel()->selectedRows()) { + auto item = _table->item(index.row(), COL_NAME); + if (item) { + ids.append(item->data(Qt::UserRole).toString()); + } + } + return ids; +} + +bool MacroScheduleTab::eventFilter(QObject *obj, QEvent *event) +{ + if (obj == _table && event->type() == QEvent::KeyPress) { + const auto keyEvent = static_cast(event); + if (keyEvent->key() == Qt::Key_Delete) { + Remove(); + return true; + } + } + return QWidget::eventFilter(obj, event); +} + +void MacroScheduleTab::UpdateButtonStates() +{ + const QStringList ids = SelectedEntryIds(); + const bool hasSelection = !ids.isEmpty(); + const bool singleSelection = ids.size() == 1; + _editBtn->setEnabled(singleSelection); + _removeBtn->setEnabled(hasSelection); + _toggleBtn->setEnabled(hasSelection); + + if (hasSelection) { + // Label reflects whether the action will enable or disable: + // disable if all selected are enabled, otherwise enable. + bool allEnabled = true; + { + auto lock = LockContext(); + for (const auto &entry : GetScheduleEntries()) { + if (ids.contains( + QString::fromStdString(entry.id)) && + !entry.enabled) { + allEnabled = false; + break; + } + } + } + _toggleBtn->setText(obs_module_text( + allEnabled + ? "AdvSceneSwitcher.macroScheduleTab.disable" + : "AdvSceneSwitcher.macroScheduleTab.enable")); + } +} + +void MacroScheduleTab::OnSelectionChanged() +{ + UpdateButtonStates(); + + // Highlight the corresponding calendar event + const int idx = SelectedEntryIndex(); + if (idx >= 0) { + QDate triggerDate; + { + auto lock = LockContext(); + const auto &entries = GetScheduleEntries(); + if (idx < (int)entries.size()) { + const QDateTime next = + entries[idx].NextTriggerTime(); + if (next.isValid()) { + triggerDate = next.date(); + } + } + } + if (triggerDate.isValid()) { + _calendar->GoToDate(triggerDate); + } + } +} + +// --------------------------------------------------------------------------- +// CRUD actions +// --------------------------------------------------------------------------- + +void MacroScheduleTab::Add() +{ + AddAtTime(QDateTime::currentDateTime()); +} + +void MacroScheduleTab::AddAtTime(const QDateTime &startTime) +{ + MacroScheduleEntry entry; + entry.startDateTime = startTime; + + if (!MacroScheduleEntryDialog::AskForSettings(this, entry, true)) { + return; + } + const QString newId = QString::fromStdString(entry.id); + { + auto lock = LockContext(); + GetScheduleEntries().emplace_back(std::move(entry)); + } + Refresh(); + SelectTableRowById(newId); + + if (_addButtonHighlight) { + _addButtonHighlight->deleteLater(); + _addButtonHighlight = nullptr; + } +} + +void MacroScheduleTab::Edit() +{ + const int idx = SelectedEntryIndex(); + if (idx < 0) { + return; + } + MacroScheduleEntry copy; + { + auto lock = LockContext(); + copy = GetScheduleEntries()[idx]; + } + const QString id = QString::fromStdString(copy.id); + if (!MacroScheduleEntryDialog::AskForSettings(this, copy, false)) { + return; + } + + { + auto lock = LockContext(); + GetScheduleEntries()[idx] = std::move(copy); + } + Refresh(); + SelectTableRowById(id); +} + +void MacroScheduleTab::EditById(const QString &entryId) +{ + SelectTableRowById(entryId); + Edit(); +} + +void MacroScheduleTab::Remove() +{ + const QStringList ids = SelectedEntryIds(); + if (ids.isEmpty()) { + return; + } + + // Build a confirmation message + QString msg; + if (ids.size() == 1) { + QString name; + { + auto lock = LockContext(); + const int idx = SelectedEntryIndex(); + if (idx >= 0) { + name = GetScheduleEntries()[idx].GetSummary(); + } + } + msg = QString(obs_module_text( + "AdvSceneSwitcher.macroScheduleTab.remove.confirm")) + .arg(name); + } else { + msg = QString(obs_module_text( + "AdvSceneSwitcher.macroScheduleTab.remove.confirmMultiple")) + .arg(ids.size()); + } + + if (!DisplayMessage(msg, true)) { + return; + } + + { + auto lock = LockContext(); + auto &entries = GetScheduleEntries(); + entries.erase( + std::remove_if(entries.begin(), entries.end(), + [&ids](const MacroScheduleEntry &e) { + return ids.contains( + QString::fromStdString( + e.id)); + }), + entries.end()); + } + Refresh(); +} + +void MacroScheduleTab::ToggleEnabled() +{ + const QStringList ids = SelectedEntryIds(); + if (ids.isEmpty()) { + return; + } + + // If all selected entries are enabled, disable them; otherwise enable all. + auto lock = LockContext(); + bool allEnabled = true; + for (const auto &entry : GetScheduleEntries()) { + if (ids.contains(QString::fromStdString(entry.id)) && + !entry.enabled) { + allEnabled = false; + break; + } + } + const bool newState = !allEnabled; + for (auto &entry : GetScheduleEntries()) { + if (ids.contains(QString::fromStdString(entry.id))) { + entry.enabled = newState; + } + } + Refresh(); +} + +// --------------------------------------------------------------------------- +// Context menu +// --------------------------------------------------------------------------- + +void MacroScheduleTab::ShowContextMenu(const QPoint &pos) +{ + const QStringList ids = SelectedEntryIds(); + const bool hasSelection = !ids.isEmpty(); + const bool singleSelection = ids.size() == 1; + + QMenu menu(this); + + auto addAction = menu.addAction( + obs_module_text("AdvSceneSwitcher.macroScheduleTab.add"), this, + &MacroScheduleTab::Add); + (void)addAction; + + menu.addSeparator(); + + auto editAction = menu.addAction( + obs_module_text("AdvSceneSwitcher.macroScheduleTab.edit"), this, + &MacroScheduleTab::Edit); + editAction->setEnabled(singleSelection); + + auto removeAction = menu.addAction( + obs_module_text("AdvSceneSwitcher.macroScheduleTab.remove"), + this, &MacroScheduleTab::Remove); + removeAction->setEnabled(hasSelection); + + bool allEnabled = true; + if (hasSelection) { + auto lock = LockContext(); + for (const auto &entry : GetScheduleEntries()) { + if (ids.contains(QString::fromStdString(entry.id)) && + !entry.enabled) { + allEnabled = false; + break; + } + } + } + auto toggleAction = menu.addAction( + obs_module_text( + allEnabled + ? "AdvSceneSwitcher.macroScheduleTab.disable" + : "AdvSceneSwitcher.macroScheduleTab.enable"), + this, &MacroScheduleTab::ToggleEnabled); + toggleAction->setEnabled(hasSelection); + + menu.exec(_table->viewport()->mapToGlobal(pos)); +} + +} // namespace advss diff --git a/plugins/schedule/macro-schedule-tab.hpp b/plugins/schedule/macro-schedule-tab.hpp new file mode 100644 index 00000000..138393ea --- /dev/null +++ b/plugins/schedule/macro-schedule-tab.hpp @@ -0,0 +1,70 @@ +#pragma once + +#include "calendar/calendar-widget.hpp" +#include "macro-schedule.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +class QTabWidget; + +namespace advss { + +// The main tab widget registered with the settings window. +class MacroScheduleTab : public QWidget { + Q_OBJECT + +public: + static MacroScheduleTab *Create(); + + // Reload all data into the table and calendar + void Refresh(); + +private slots: + void Add(); + void AddAtTime(const QDateTime &startTime); + void Edit(); + void EditById(const QString &entryId); + void Remove(); + void ToggleEnabled(); + void ShowContextMenu(const QPoint &pos); + void OnSelectionChanged(); + +private: + explicit MacroScheduleTab(QWidget *parent = nullptr); + + bool eventFilter(QObject *obj, QEvent *event) override; + + void PopulateTable(); + void RefreshCalendar(const std::deque &entries); + void SelectTableRowById(const QString &entryId); + void UpdateButtonStates(); + void SetHelpVisible(bool visible); + // Returns the index in GetScheduleEntries() for the first selected row, + // or -1 if nothing is selected. + int SelectedEntryIndex() const; + // Returns the IDs of all selected rows. + QStringList SelectedEntryIds() const; + + CalendarWidget *_calendar; + QTableWidget *_table; + QLabel *_helpLabel; + + QToolButton *_addBtn; + QPushButton *_editBtn; + QToolButton *_removeBtn; + QPushButton *_toggleBtn; + + // Periodic refresh to update "Next Trigger" column + QTimer *_refreshTimer; + + QObject *_addButtonHighlight = nullptr; +}; + +} // namespace advss diff --git a/plugins/schedule/macro-schedule.cpp b/plugins/schedule/macro-schedule.cpp new file mode 100644 index 00000000..39dc9d5b --- /dev/null +++ b/plugins/schedule/macro-schedule.cpp @@ -0,0 +1,507 @@ +#include "macro-schedule.hpp" + +#include "log-helper.hpp" +#include "macro-export-extensions.hpp" +#include "macro-helpers.hpp" +#include "obs-module-helper.hpp" +#include "plugin-state-helpers.hpp" +#include "sync-helpers.hpp" + +#include +#include +#include +#include +#include +#include + +namespace advss { + +static std::deque scheduleEntries; + +static std::thread schedulerThread; +static std::atomic schedulerRunning{false}; +static std::mutex schedulerWaitMutex; +static std::condition_variable schedulerWaitCV; + +static void saveEntries(obs_data_t *obj); +static void loadEntries(obs_data_t *obj); +static bool setup(); +static bool setupDone = setup(); + +static bool setup() +{ + AddSaveStep(saveEntries); + AddLoadStep(loadEntries); + AddFinishedLoadingStep(InitScheduler); + AddPluginCleanupStep([]() { + CleanupScheduler(); + scheduleEntries.clear(); + }); + AddMacroExportExtension( + {"AdvSceneSwitcher.macroTab.export.macroScheduleEntries", + "macroScheduleEntries", + [](obs_data_t *data, const QStringList &selectedIds) { + OBSDataArrayAutoRelease array = + obs_data_array_create(); + auto lock = LockContext(); + for (const auto &entry : scheduleEntries) { + if (!selectedIds.isEmpty() && + !selectedIds.contains( + QString::fromStdString(entry.id))) + continue; + OBSDataAutoRelease item = obs_data_create(); + entry.Save(item); + obs_data_array_push_back(array, item); + } + obs_data_set_array(data, "macroScheduleEntries", + array); + }, + [](obs_data_t *data, const QStringList &) { + OBSDataArrayAutoRelease array = obs_data_get_array( + data, "macroScheduleEntries"); + if (!array) { + return; + } + const size_t count = obs_data_array_count(array); + auto lock = LockContext(); + for (size_t i = 0; i < count; ++i) { + OBSDataAutoRelease item = + obs_data_array_item(array, i); + MacroScheduleEntry entry; + entry.Load(item); + // Skip if an entry with this id already exists. + const auto &id = entry.id; + const bool exists = std::any_of( + scheduleEntries.begin(), + scheduleEntries.end(), + [&id](const MacroScheduleEntry &e) { + return e.id == id; + }); + if (exists) { + continue; + } + // Reset runtime state so the entry fires fresh. + entry.timesTriggered = 0; + entry.lastTriggered = QDateTime(); + entry.endDateActionApplied = false; + scheduleEntries.emplace_back(std::move(entry)); + } + }, + []() -> QList> { + QList> items; + auto lock = LockContext(); + for (const auto &entry : scheduleEntries) { + items.append({QString::fromStdString(entry.id), + entry.GetSummary()}); + } + return items; + }}); + return true; +} + +// --------------------------------------------------------------------------- +// Data model +// --------------------------------------------------------------------------- + +MacroScheduleEntry::MacroScheduleEntry() + : id(QUuid::createUuid().toString(QUuid::WithoutBraces).toStdString()), + startDateTime(QDateTime::currentDateTime()) +{ +} + +static void saveDateTime(obs_data_t *obj, const char *key, const QDateTime &dt) +{ + if (dt.isValid()) { + obs_data_set_string( + obj, key, + dt.toString(Qt::ISODate).toUtf8().constData()); + } +} + +static QDateTime loadDateTime(obs_data_t *obj, const char *key) +{ + const char *str = obs_data_get_string(obj, key); + if (!str || !*str) { + return QDateTime(); + } + return QDateTime::fromString(QString::fromUtf8(str), Qt::ISODate); +} + +void MacroScheduleEntry::Save(obs_data_t *obj) const +{ + obs_data_set_string(obj, "id", id.c_str()); + obs_data_set_string(obj, "name", name.c_str()); + macro.Save(obj); + obs_data_set_string(obj, "color", color.name().toUtf8().constData()); + obs_data_set_bool(obj, "checkConditions", checkConditions); + obs_data_set_bool(obj, "runElseActionsOnConditionFailure", + runElseActionsOnConditionFailure); + obs_data_set_bool(obj, "enabled", enabled); + + saveDateTime(obj, "startDateTime", startDateTime); + obs_data_set_bool(obj, "hasEndDate", hasEndDate); + if (hasEndDate) { + saveDateTime(obj, "endDate", endDate); + obs_data_set_int(obj, "endDateAction", + static_cast(endDateAction)); + } + + obs_data_set_int(obj, "repeatType", static_cast(repeatType)); + obs_data_set_int(obj, "repeatInterval", repeatInterval); + obs_data_set_int(obj, "repeatEndType", static_cast(repeatEndType)); + obs_data_set_int(obj, "repeatMaxCount", repeatMaxCount); + if (repeatEndType == RepeatEndType::UNTIL_DATE && + repeatUntilDate.isValid()) { + saveDateTime(obj, "repeatUntilDate", repeatUntilDate); + } + + obs_data_set_int(obj, "timesTriggered", timesTriggered); + saveDateTime(obj, "lastTriggered", lastTriggered); + obs_data_set_bool(obj, "endDateActionApplied", endDateActionApplied); +} + +void MacroScheduleEntry::Load(obs_data_t *obj) +{ + const char *idStr = obs_data_get_string(obj, "id"); + if (idStr && *idStr) { + id = idStr; + } + const char *nameStr = obs_data_get_string(obj, "name"); + if (nameStr) { + name = nameStr; + } + macro.Load(obj); + const char *colorStr = obs_data_get_string(obj, "color"); + if (colorStr && *colorStr) { + const QColor loaded(QString::fromUtf8(colorStr)); + if (loaded.isValid()) { + color = loaded; + } + } + checkConditions = obs_data_get_bool(obj, "checkConditions"); + runElseActionsOnConditionFailure = + obs_data_get_bool(obj, "runElseActionsOnConditionFailure"); + obs_data_set_default_bool(obj, "enabled", true); + enabled = obs_data_get_bool(obj, "enabled"); + + startDateTime = loadDateTime(obj, "startDateTime"); + hasEndDate = obs_data_get_bool(obj, "hasEndDate"); + if (hasEndDate) { + endDate = loadDateTime(obj, "endDate"); + endDateAction = static_cast( + obs_data_get_int(obj, "endDateAction")); + } + + repeatType = + static_cast(obs_data_get_int(obj, "repeatType")); + obs_data_set_default_int(obj, "repeatInterval", 1); + repeatInterval = (int)obs_data_get_int(obj, "repeatInterval"); + repeatEndType = static_cast( + obs_data_get_int(obj, "repeatEndType")); + obs_data_set_default_int(obj, "repeatMaxCount", 1); + repeatMaxCount = (int)obs_data_get_int(obj, "repeatMaxCount"); + repeatUntilDate = loadDateTime(obj, "repeatUntilDate"); + + timesTriggered = (int)obs_data_get_int(obj, "timesTriggered"); + lastTriggered = loadDateTime(obj, "lastTriggered"); + endDateActionApplied = obs_data_get_bool(obj, "endDateActionApplied"); +} + +QDateTime MacroScheduleEntry::advanceFrom(const QDateTime &base) const +{ + switch (repeatType) { + case RepeatType::MINUTELY: + return base.addSecs((qint64)repeatInterval * 60); + case RepeatType::HOURLY: + return base.addSecs((qint64)repeatInterval * 3600); + case RepeatType::DAILY: + return base.addDays(repeatInterval); + case RepeatType::WEEKLY: + return base.addDays((qint64)repeatInterval * 7); + case RepeatType::MONTHLY: + return base.addMonths(repeatInterval); + default: + return QDateTime(); + } +} + +QDateTime MacroScheduleEntry::NextTriggerTime() const +{ + if (!lastTriggered.isValid()) { + return startDateTime; + } + if (repeatType == RepeatType::NONE) { + // If the start was moved beyond the last trigger, treat as not yet fired. + if (startDateTime > lastTriggered) { + return startDateTime; + } + return QDateTime(); // one-shot already triggered + } + + QDateTime next = startDateTime; + while (next <= lastTriggered) { + QDateTime candidate = advanceFrom(next); + if (!candidate.isValid()) { + return QDateTime(); + } + next = candidate; + } + return next; +} + +bool MacroScheduleEntry::IsExpired() const +{ + if (repeatType == RepeatType::NONE && lastTriggered.isValid()) { + return lastTriggered >= startDateTime; + } + switch (repeatEndType) { + case RepeatEndType::NEVER: + return false; + case RepeatEndType::AFTER_N_TIMES: + return timesTriggered >= repeatMaxCount; + case RepeatEndType::UNTIL_DATE: + return repeatUntilDate.isValid() && + QDateTime::currentDateTime() > repeatUntilDate; + } + return false; +} + +bool MacroScheduleEntry::ShouldTrigger(const QDateTime &now) const +{ + if (!enabled) { + return false; + } + if (IsExpired()) { + return false; + } + if (!startDateTime.isValid()) { + return false; + } + const QDateTime next = NextTriggerTime(); + if (!next.isValid()) { + return false; + } + return next <= now; +} + +void MacroScheduleEntry::MarkTriggered(const QDateTime &now) +{ + ++timesTriggered; + lastTriggered = now; +} + +QString MacroScheduleEntry::GetRepeatDescription() const +{ + if (repeatType == RepeatType::NONE) { + return obs_module_text( + "AdvSceneSwitcher.macroScheduleEntry.repeat.once"); + } + const char *unitSingular = ""; + const char *unitPlural = ""; + switch (repeatType) { + case RepeatType::MINUTELY: + unitSingular = obs_module_text( + "AdvSceneSwitcher.macroScheduleEntry.repeat.unit.minute"); + unitPlural = obs_module_text( + "AdvSceneSwitcher.macroScheduleEntry.repeat.unit.minutes"); + break; + case RepeatType::HOURLY: + unitSingular = obs_module_text( + "AdvSceneSwitcher.macroScheduleEntry.repeat.unit.hour"); + unitPlural = obs_module_text( + "AdvSceneSwitcher.macroScheduleEntry.repeat.unit.hours"); + break; + case RepeatType::DAILY: + unitSingular = obs_module_text( + "AdvSceneSwitcher.macroScheduleEntry.repeat.unit.day"); + unitPlural = obs_module_text( + "AdvSceneSwitcher.macroScheduleEntry.repeat.unit.days"); + break; + case RepeatType::WEEKLY: + unitSingular = obs_module_text( + "AdvSceneSwitcher.macroScheduleEntry.repeat.unit.week"); + unitPlural = obs_module_text( + "AdvSceneSwitcher.macroScheduleEntry.repeat.unit.weeks"); + break; + case RepeatType::MONTHLY: + unitSingular = obs_module_text( + "AdvSceneSwitcher.macroScheduleEntry.repeat.unit.month"); + unitPlural = obs_module_text( + "AdvSceneSwitcher.macroScheduleEntry.repeat.unit.months"); + break; + default: + break; + } + if (repeatInterval == 1) { + return QString(obs_module_text( + "AdvSceneSwitcher.macroScheduleEntry.repeat.everyOne")) + .arg(unitSingular); + } + return QString(obs_module_text( + "AdvSceneSwitcher.macroScheduleEntry.repeat.everyN")) + .arg(repeatInterval) + .arg(unitPlural); +} + +QString MacroScheduleEntry::GetSummary() const +{ + if (!name.empty()) { + return QString::fromStdString(name); + } + const std::string macroName = macro.Name(); + return macroName.empty() + ? QString(obs_module_text( + "AdvSceneSwitcher.macroScheduleEntry.noMacro")) + : QString::fromStdString(macroName); +} + +QString MacroScheduleEntry::GetNextTriggerString() const +{ + if (!enabled) { + return obs_module_text( + "AdvSceneSwitcher.macroScheduleTab.status.disabled"); + } + if (IsExpired()) { + return obs_module_text( + "AdvSceneSwitcher.macroScheduleTab.status.expired"); + } + const QDateTime next = NextTriggerTime(); + if (!next.isValid()) { + return QString("-"); + } + return next.toString("yyyy-MM-dd HH:mm"); +} + +std::deque &GetScheduleEntries() +{ + return scheduleEntries; +} + +// --------------------------------------------------------------------------- +// Persistence +// --------------------------------------------------------------------------- + +static void saveEntries(obs_data_t *obj) +{ + OBSDataArrayAutoRelease array = obs_data_array_create(); + for (const auto &entry : scheduleEntries) { + OBSDataAutoRelease item = obs_data_create(); + entry.Save(item); + obs_data_array_push_back(array, item); + } + obs_data_set_array(obj, "macroScheduleEntries", array); +} + +static void loadEntries(obs_data_t *obj) +{ + scheduleEntries.clear(); + OBSDataArrayAutoRelease array = + obs_data_get_array(obj, "macroScheduleEntries"); + const size_t count = obs_data_array_count(array); + for (size_t i = 0; i < count; ++i) { + OBSDataAutoRelease item = obs_data_array_item(array, i); + scheduleEntries.emplace_back(); + scheduleEntries.back().Load(item); + } +} + +// --------------------------------------------------------------------------- +// Scheduler thread +// --------------------------------------------------------------------------- + +static void applyEndDateAction(MacroScheduleEntry &entry) +{ + if (!entry.hasEndDate || !entry.endDate.isValid()) { + return; + } + if (entry.endDateAction == MacroScheduleEntry::EndDateAction::NONE) { + return; + } + + switch (entry.endDateAction) { + case MacroScheduleEntry::EndDateAction::DISABLE_ENTRY: + entry.enabled = false; + break; + case MacroScheduleEntry::EndDateAction::PAUSE_MACRO: + StopMacro(entry.macro.GetMacro().get()); + SetMacroPaused(entry.macro.GetMacro().get(), true); + break; + case MacroScheduleEntry::EndDateAction::STOP_MACRO: + StopMacro(entry.macro.GetMacro().get()); + break; + default: + break; + } +} + +static void checkAndFireEntries() +{ + const QDateTime now = QDateTime::currentDateTime(); + auto lock = LockContext(); + + for (auto &entry : scheduleEntries) { + // Apply end-date action once when the entry transitions to expired. + // We detect the transition by checking whether the end date has + // just passed while the entry is still nominally enabled. + if (entry.hasEndDate && entry.endDate.isValid() && + entry.enabled && now >= entry.endDate && + entry.endDateAction != + MacroScheduleEntry::EndDateAction::NONE && + !entry.endDateActionApplied) { + applyEndDateAction(entry); + entry.endDateActionApplied = true; + } + + if (!entry.ShouldTrigger(now)) { + continue; + } + + auto macro = entry.macro.GetMacro(); + if (!macro) { + // Advance state so we don't spam-check a missing macro + entry.MarkTriggered(now); + blog(LOG_WARNING, + "[macro-schedule] Scheduled macro '%s' not found, skipping.", + entry.macro.Name().c_str()); + continue; + } + + if (entry.checkConditions) { + if (CheckMacroConditions(macro.get(), true)) { + RunMacroActions(macro.get(), true, true); + } else if (entry.runElseActionsOnConditionFailure) { + RunMacroElseActions(macro.get(), true, true); + } + } else { + RunMacroActions(macro.get(), true, true); + } + entry.MarkTriggered(now); + } +} + +void InitScheduler() +{ + if (schedulerRunning.exchange(true)) { + return; // already running + } + schedulerThread = std::thread([]() { + while (schedulerRunning) { + checkAndFireEntries(); + std::unique_lock lock(schedulerWaitMutex); + schedulerWaitCV.wait_for( + lock, std::chrono::seconds(10), + []() { return !schedulerRunning.load(); }); + } + }); +} + +void CleanupScheduler() +{ + schedulerRunning = false; + schedulerWaitCV.notify_all(); + if (schedulerThread.joinable()) { + schedulerThread.join(); + } +} + +} // namespace advss diff --git a/plugins/schedule/macro-schedule.hpp b/plugins/schedule/macro-schedule.hpp new file mode 100644 index 00000000..9c0763e1 --- /dev/null +++ b/plugins/schedule/macro-schedule.hpp @@ -0,0 +1,83 @@ +#pragma once +#include "macro-ref.hpp" + +#include +#include +#include +#include + +namespace advss { + +class MacroScheduleEntry { +public: + enum class RepeatType { + NONE, + MINUTELY, + HOURLY, + DAILY, + WEEKLY, + MONTHLY, + }; + + enum class RepeatEndType { + NEVER, + AFTER_N_TIMES, + UNTIL_DATE, + }; + + enum class EndDateAction { + NONE, + DISABLE_ENTRY, + PAUSE_MACRO, + STOP_MACRO, + }; + + MacroScheduleEntry(); + + void Save(obs_data_t *obj) const; + void Load(obs_data_t *obj); + + QDateTime NextTriggerTime() const; + bool ShouldTrigger(const QDateTime &now) const; + void MarkTriggered(const QDateTime &now); + bool IsExpired() const; + + QString GetSummary() const; + QString GetRepeatDescription() const; + QString GetNextTriggerString() const; + + std::string id; + std::string name; + MacroRef macro; + QColor color{70, 130, 180}; + bool checkConditions = false; + bool runElseActionsOnConditionFailure = false; + bool enabled = true; + + QDateTime startDateTime; + bool hasEndDate = false; + QDateTime endDate; + EndDateAction endDateAction = EndDateAction::NONE; + + RepeatType repeatType = RepeatType::NONE; + int repeatInterval = 1; + + RepeatEndType repeatEndType = RepeatEndType::NEVER; + int repeatMaxCount = 1; + QDateTime repeatUntilDate; + + // Persisted runtime state + int timesTriggered = 0; + QDateTime lastTriggered; + bool endDateActionApplied = false; + +private: + QDateTime advanceFrom(const QDateTime &base) const; +}; + +std::deque &GetScheduleEntries(); + +void InitScheduler(); +void CleanupScheduler(); + +} // namespace advss From 92144eaf939e096fb3f9cb779b476936680661de Mon Sep 17 00:00:00 2001 From: WarmUpTill <19472752+WarmUpTill@users.noreply.github.com> Date: Mon, 4 May 2026 20:47:17 +0200 Subject: [PATCH 07/13] Don't highlight action trigger mode when macro is paused --- lib/macro/macro-tab.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/macro/macro-tab.cpp b/lib/macro/macro-tab.cpp index ecc3ad43..8c43ebe8 100644 --- a/lib/macro/macro-tab.cpp +++ b/lib/macro/macro-tab.cpp @@ -550,6 +550,10 @@ void AdvSceneSwitcher::HighlightOnChange() const return; } + if (macro->Paused()) { + return; + } + if (macro->ActionTriggerModePreventedActionsSince( lastOnChangeHighlightCheckTime)) { HighlightWidget(ui->actionTriggerMode, Qt::yellow, From 2d699d82b826b6206eb4f5ac458e7bae51934be8 Mon Sep 17 00:00:00 2001 From: WarmUpTill <19472752+WarmUpTill@users.noreply.github.com> Date: Mon, 4 May 2026 21:00:54 +0200 Subject: [PATCH 08/13] fix crash on disable --- plugins/schedule/macro-schedule-tab.cpp | 26 +++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/plugins/schedule/macro-schedule-tab.cpp b/plugins/schedule/macro-schedule-tab.cpp index 4bbabf9d..9aedefe0 100644 --- a/plugins/schedule/macro-schedule-tab.cpp +++ b/plugins/schedule/macro-schedule-tab.cpp @@ -624,19 +624,21 @@ void MacroScheduleTab::ToggleEnabled() } // If all selected entries are enabled, disable them; otherwise enable all. - auto lock = LockContext(); - bool allEnabled = true; - for (const auto &entry : GetScheduleEntries()) { - if (ids.contains(QString::fromStdString(entry.id)) && - !entry.enabled) { - allEnabled = false; - break; + { + auto lock = LockContext(); + bool allEnabled = true; + for (const auto &entry : GetScheduleEntries()) { + if (ids.contains(QString::fromStdString(entry.id)) && + !entry.enabled) { + allEnabled = false; + break; + } } - } - const bool newState = !allEnabled; - for (auto &entry : GetScheduleEntries()) { - if (ids.contains(QString::fromStdString(entry.id))) { - entry.enabled = newState; + const bool newState = !allEnabled; + for (auto &entry : GetScheduleEntries()) { + if (ids.contains(QString::fromStdString(entry.id))) { + entry.enabled = newState; + } } } Refresh(); From a9ee8f21538b4aadd6d55b02199fd6c5094e1fbe Mon Sep 17 00:00:00 2001 From: WarmUpTill <19472752+WarmUpTill@users.noreply.github.com> Date: Mon, 4 May 2026 21:07:43 +0200 Subject: [PATCH 09/13] Don't log "on change" when macro is paused --- lib/macro/macro.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/macro/macro.cpp b/lib/macro/macro.cpp index 55e68c9b..acbc9757 100644 --- a/lib/macro/macro.cpp +++ b/lib/macro/macro.cpp @@ -380,7 +380,7 @@ bool Macro::ShouldRunActions() const !_paused && (_matched || _elseActions.size() > 0) && _actionModeMatch; - if (VerboseLoggingEnabled() && !_actionModeMatch) { + if (VerboseLoggingEnabled() && !_actionModeMatch && !_paused) { if (_matched && _actions.size() > 0) { blog(LOG_INFO, "skip actions for Macro %s (on change)", _name.c_str()); From be406ff3538e2cfe1e7724dd1c6f16e2cf18d668 Mon Sep 17 00:00:00 2001 From: WarmUpTill <19472752+WarmUpTill@users.noreply.github.com> Date: Mon, 4 May 2026 21:20:12 +0200 Subject: [PATCH 10/13] sched --- plugins/schedule/macro-schedule.cpp | 13 ++++++++----- plugins/schedule/macro-schedule.hpp | 3 --- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/plugins/schedule/macro-schedule.cpp b/plugins/schedule/macro-schedule.cpp index 39dc9d5b..fa4c6530 100644 --- a/plugins/schedule/macro-schedule.cpp +++ b/plugins/schedule/macro-schedule.cpp @@ -23,6 +23,8 @@ static std::atomic schedulerRunning{false}; static std::mutex schedulerWaitMutex; static std::condition_variable schedulerWaitCV; +static void initScheduler(); +static void cleanupScheduler(); static void saveEntries(obs_data_t *obj); static void loadEntries(obs_data_t *obj); static bool setup(); @@ -32,9 +34,10 @@ static bool setup() { AddSaveStep(saveEntries); AddLoadStep(loadEntries); - AddFinishedLoadingStep(InitScheduler); + AddStartStep(initScheduler); + AddStopStep(cleanupScheduler); AddPluginCleanupStep([]() { - CleanupScheduler(); + cleanupScheduler(); scheduleEntries.clear(); }); AddMacroExportExtension( @@ -479,7 +482,7 @@ static void checkAndFireEntries() } } -void InitScheduler() +static void initScheduler() { if (schedulerRunning.exchange(true)) { return; // already running @@ -489,13 +492,13 @@ void InitScheduler() checkAndFireEntries(); std::unique_lock lock(schedulerWaitMutex); schedulerWaitCV.wait_for( - lock, std::chrono::seconds(10), + lock, std::chrono::seconds(1), []() { return !schedulerRunning.load(); }); } }); } -void CleanupScheduler() +static void cleanupScheduler() { schedulerRunning = false; schedulerWaitCV.notify_all(); diff --git a/plugins/schedule/macro-schedule.hpp b/plugins/schedule/macro-schedule.hpp index 9c0763e1..d2aa2241 100644 --- a/plugins/schedule/macro-schedule.hpp +++ b/plugins/schedule/macro-schedule.hpp @@ -77,7 +77,4 @@ private: std::deque &GetScheduleEntries(); -void InitScheduler(); -void CleanupScheduler(); - } // namespace advss From 7f520b0f874cd0277e5d965a7bc1504c65cbfdc0 Mon Sep 17 00:00:00 2001 From: WarmUpTill <19472752+WarmUpTill@users.noreply.github.com> Date: Mon, 4 May 2026 21:50:23 +0200 Subject: [PATCH 11/13] fix repeat scheduling --- .../schedule/macro-schedule-entry-dialog.cpp | 7 +++- plugins/schedule/macro-schedule.cpp | 32 +++++++++++++++++++ plugins/schedule/macro-schedule.hpp | 1 + 3 files changed, 39 insertions(+), 1 deletion(-) diff --git a/plugins/schedule/macro-schedule-entry-dialog.cpp b/plugins/schedule/macro-schedule-entry-dialog.cpp index 41cc8612..ec4de87d 100644 --- a/plugins/schedule/macro-schedule-entry-dialog.cpp +++ b/plugins/schedule/macro-schedule-entry-dialog.cpp @@ -348,7 +348,12 @@ void MacroScheduleEntryDialog::ApplyToEntry(MacroScheduleEntry &entry) const const QString macroName = _macroSel->currentText(); entry.macro = macroName; - entry.startDateTime = _startDateTime->dateTime(); + const QDateTime newStartDateTime = _startDateTime->dateTime(); + const bool startChanged = (newStartDateTime != entry.startDateTime); + entry.startDateTime = newStartDateTime; + if (startChanged) { + entry.FastForwardTo(QDateTime::currentDateTime()); + } entry.hasEndDate = _hasEndDate->isChecked(); if (entry.hasEndDate) { diff --git a/plugins/schedule/macro-schedule.cpp b/plugins/schedule/macro-schedule.cpp index fa4c6530..9ada0979 100644 --- a/plugins/schedule/macro-schedule.cpp +++ b/plugins/schedule/macro-schedule.cpp @@ -293,6 +293,38 @@ void MacroScheduleEntry::MarkTriggered(const QDateTime &now) lastTriggered = now; } +void MacroScheduleEntry::FastForwardTo(const QDateTime &now) +{ + timesTriggered = 0; + lastTriggered = QDateTime(); + + if (!startDateTime.isValid()) { + return; + } + + if (repeatType == RepeatType::NONE) { + // One-shot: count it as triggered if the start lies in the past. + if (startDateTime <= now) { + timesTriggered = 1; + lastTriggered = startDateTime; + } + return; + } + + // Repeating: walk intervals from startDateTime and count each one + // that falls at or before 'now'. + QDateTime t = startDateTime; + while (t <= now) { + ++timesTriggered; + lastTriggered = t; + const QDateTime next = advanceFrom(t); + if (!next.isValid()) { + break; + } + t = next; + } +} + QString MacroScheduleEntry::GetRepeatDescription() const { if (repeatType == RepeatType::NONE) { diff --git a/plugins/schedule/macro-schedule.hpp b/plugins/schedule/macro-schedule.hpp index d2aa2241..22a97b3e 100644 --- a/plugins/schedule/macro-schedule.hpp +++ b/plugins/schedule/macro-schedule.hpp @@ -41,6 +41,7 @@ public: bool ShouldTrigger(const QDateTime &now) const; void MarkTriggered(const QDateTime &now); bool IsExpired() const; + void FastForwardTo(const QDateTime &now); QString GetSummary() const; QString GetRepeatDescription() const; From cd1a797ce0d7cbab5c351dad03501713c57f546d Mon Sep 17 00:00:00 2001 From: WarmUpTill <19472752+WarmUpTill@users.noreply.github.com> Date: Thu, 7 May 2026 21:46:02 +0200 Subject: [PATCH 12/13] Add support for units "days" --- lib/utils/duration-control.cpp | 3 +++ lib/utils/duration.cpp | 4 ++++ lib/utils/duration.hpp | 1 + 3 files changed, 8 insertions(+) diff --git a/lib/utils/duration-control.cpp b/lib/utils/duration-control.cpp index 3f9d1fd5..c29594e8 100644 --- a/lib/utils/duration-control.cpp +++ b/lib/utils/duration-control.cpp @@ -11,6 +11,7 @@ static void populateUnits(QComboBox *list) list->addItem(obs_module_text("AdvSceneSwitcher.unit.seconds")); list->addItem(obs_module_text("AdvSceneSwitcher.unit.minutes")); list->addItem(obs_module_text("AdvSceneSwitcher.unit.hours")); + list->addItem(obs_module_text("AdvSceneSwitcher.unit.days")); } DurationSelection::DurationSelection(QWidget *parent, bool showUnitSelection, @@ -66,6 +67,8 @@ static int durationUnitToMultiplier(Duration::Unit u) return 60; case Duration::Unit::HOURS: return 3600; + case Duration::Unit::DAYS: + return 86400; default: break; } diff --git a/lib/utils/duration.cpp b/lib/utils/duration.cpp index 137d07c3..0608fa8c 100644 --- a/lib/utils/duration.cpp +++ b/lib/utils/duration.cpp @@ -121,6 +121,8 @@ static std::string durationUnitToString(Duration::Unit u) return obs_module_text("AdvSceneSwitcher.unit.minutes"); case Duration::Unit::HOURS: return obs_module_text("AdvSceneSwitcher.unit.hours"); + case Duration::Unit::DAYS: + return obs_module_text("AdvSceneSwitcher.unit.days"); default: break; } @@ -151,6 +153,8 @@ static int durationUnitToMultiplier(Duration::Unit u) return 60; case Duration::Unit::HOURS: return 3600; + case Duration::Unit::DAYS: + return 86400; default: break; } diff --git a/lib/utils/duration.hpp b/lib/utils/duration.hpp index b4263f64..751b5d7e 100644 --- a/lib/utils/duration.hpp +++ b/lib/utils/duration.hpp @@ -27,6 +27,7 @@ public: SECONDS, MINUTES, HOURS, + DAYS, }; EXPORT Unit GetUnit() const { return _unit; } From 1ccea85d85da81945480fb45c7b1cf27f8bbb33e Mon Sep 17 00:00:00 2001 From: WarmUpTill <19472752+WarmUpTill@users.noreply.github.com> Date: Thu, 7 May 2026 21:46:32 +0200 Subject: [PATCH 13/13] rework --- data/locale/en-US.ini | 28 +--- .../schedule/macro-schedule-entry-dialog.cpp | 128 ++++-------------- .../schedule/macro-schedule-entry-dialog.hpp | 12 +- plugins/schedule/macro-schedule-tab.cpp | 2 +- plugins/schedule/macro-schedule.cpp | 79 ++--------- plugins/schedule/macro-schedule.hpp | 14 +- 6 files changed, 53 insertions(+), 210 deletions(-) diff --git a/data/locale/en-US.ini b/data/locale/en-US.ini index dc15f51d..6437331f 100644 --- a/data/locale/en-US.ini +++ b/data/locale/en-US.ini @@ -2754,32 +2754,10 @@ AdvSceneSwitcher.macroScheduleEntry.endDate.action.none="No action" AdvSceneSwitcher.macroScheduleEntry.endDate.action.disableEntry="Disable schedule entry" AdvSceneSwitcher.macroScheduleEntry.endDate.action.pauseMacro="Pause macro" AdvSceneSwitcher.macroScheduleEntry.endDate.action.stopMacro="Stop macro" -AdvSceneSwitcher.macroScheduleEntry.repeat="Repeat:" -AdvSceneSwitcher.macroScheduleEntry.repeat.none="None" -AdvSceneSwitcher.macroScheduleEntry.repeat.minutely="Every N minutes" -AdvSceneSwitcher.macroScheduleEntry.repeat.hourly="Hourly" -AdvSceneSwitcher.macroScheduleEntry.repeat.daily="Daily" -AdvSceneSwitcher.macroScheduleEntry.repeat.weekly="Weekly" -AdvSceneSwitcher.macroScheduleEntry.repeat.monthly="Monthly" +AdvSceneSwitcher.macroScheduleEntry.repeat="Repeat" AdvSceneSwitcher.macroScheduleEntry.repeat.once="Once" -AdvSceneSwitcher.macroScheduleEntry.repeat.everyOne="Every %1" -AdvSceneSwitcher.macroScheduleEntry.repeat.everyN="Every %1 %2" -AdvSceneSwitcher.macroScheduleEntry.repeat.unit.minute="minute" -AdvSceneSwitcher.macroScheduleEntry.repeat.unit.minutes="minutes" -AdvSceneSwitcher.macroScheduleEntry.repeat.unit.hour="hour" -AdvSceneSwitcher.macroScheduleEntry.repeat.unit.hours="hours" -AdvSceneSwitcher.macroScheduleEntry.repeat.unit.day="day" -AdvSceneSwitcher.macroScheduleEntry.repeat.unit.days="days" -AdvSceneSwitcher.macroScheduleEntry.repeat.unit.week="week" -AdvSceneSwitcher.macroScheduleEntry.repeat.unit.weeks="weeks" -AdvSceneSwitcher.macroScheduleEntry.repeat.unit.month="month" -AdvSceneSwitcher.macroScheduleEntry.repeat.unit.months="months" -AdvSceneSwitcher.macroScheduleEntry.repeat.interval.prefix="Every" -AdvSceneSwitcher.macroScheduleEntry.repeat.interval.unit.minute="minute(s)" -AdvSceneSwitcher.macroScheduleEntry.repeat.interval.unit.hour="hour(s)" -AdvSceneSwitcher.macroScheduleEntry.repeat.interval.unit.day="day(s)" -AdvSceneSwitcher.macroScheduleEntry.repeat.interval.unit.week="week(s)" -AdvSceneSwitcher.macroScheduleEntry.repeat.interval.unit.month="month(s)" +AdvSceneSwitcher.macroScheduleEntry.repeat.every="Every %1" +AdvSceneSwitcher.macroScheduleEntry.repeat.interval="Repeat interval:" AdvSceneSwitcher.macroScheduleEntry.noMacro="(no macro)" AdvSceneSwitcher.macroScheduleEntry.repeatEnd="End repeat:" AdvSceneSwitcher.macroScheduleEntry.repeatEnd.never="Never" diff --git a/plugins/schedule/macro-schedule-entry-dialog.cpp b/plugins/schedule/macro-schedule-entry-dialog.cpp index ec4de87d..63a419e0 100644 --- a/plugins/schedule/macro-schedule-entry-dialog.cpp +++ b/plugins/schedule/macro-schedule-entry-dialog.cpp @@ -35,10 +35,10 @@ MacroScheduleEntryDialog::MacroScheduleEntryDialog( this)), _endDate(new QDateTimeEdit(this)), _endDateAction(new QComboBox(this)), - _repeatType(new QComboBox(this)), - _intervalRow(new QWidget(this)), - _repeatInterval(new QSpinBox(_intervalRow)), - _intervalUnitLabel(new QLabel("", _intervalRow)), + _doesRepeat(new QCheckBox( + obs_module_text("AdvSceneSwitcher.macroScheduleEntry.repeat"), + this)), + _repeatInterval(new DurationSelection(this, true, 0.1)), _repeatEndGroup(new QGroupBox( obs_module_text( "AdvSceneSwitcher.macroScheduleEntry.repeatEnd"), @@ -153,50 +153,27 @@ MacroScheduleEntryDialog::MacroScheduleEntryDialog( } }); - // --- Repeat type --- - _repeatType->addItem( - obs_module_text( - "AdvSceneSwitcher.macroScheduleEntry.repeat.none"), - static_cast(MacroScheduleEntry::RepeatType::NONE)); - _repeatType->addItem( - obs_module_text( - "AdvSceneSwitcher.macroScheduleEntry.repeat.minutely"), - static_cast(MacroScheduleEntry::RepeatType::MINUTELY)); - _repeatType->addItem( - obs_module_text( - "AdvSceneSwitcher.macroScheduleEntry.repeat.hourly"), - static_cast(MacroScheduleEntry::RepeatType::HOURLY)); - _repeatType->addItem( - obs_module_text( - "AdvSceneSwitcher.macroScheduleEntry.repeat.daily"), - static_cast(MacroScheduleEntry::RepeatType::DAILY)); - _repeatType->addItem( - obs_module_text( - "AdvSceneSwitcher.macroScheduleEntry.repeat.weekly"), - static_cast(MacroScheduleEntry::RepeatType::WEEKLY)); - _repeatType->addItem( - obs_module_text( - "AdvSceneSwitcher.macroScheduleEntry.repeat.monthly"), - static_cast(MacroScheduleEntry::RepeatType::MONTHLY)); - form->addRow( - obs_module_text("AdvSceneSwitcher.macroScheduleEntry.repeat"), - _repeatType); + // --- Repeat --- + form->addRow(QString(), _doesRepeat); - // --- Repeat interval row (hidden when NONE) --- - auto intervalLayout = new QHBoxLayout(_intervalRow); - intervalLayout->setContentsMargins(0, 0, 0, 0); - auto everyLabel = new QLabel( + _repeatInterval->setVisible(false); + form->addRow( obs_module_text( - "AdvSceneSwitcher.macroScheduleEntry.repeat.interval.prefix"), - _intervalRow); - _repeatInterval->setMinimum(1); - _repeatInterval->setMaximum(999); - _repeatInterval->setValue(1); - intervalLayout->addWidget(everyLabel); - intervalLayout->addWidget(_repeatInterval); - intervalLayout->addWidget(_intervalUnitLabel); - intervalLayout->addStretch(); - form->addRow(QString(), _intervalRow); + "AdvSceneSwitcher.macroScheduleEntry.repeat.interval"), + _repeatInterval); + if (auto *label = form->labelForField(_repeatInterval)) { + label->setVisible(false); + } + + connect(_doesRepeat, &QCheckBox::toggled, this, + [form, this](bool checked) { + _repeatInterval->setVisible(checked); + if (auto *label = + form->labelForField(_repeatInterval)) { + label->setVisible(checked); + } + _repeatEndGroup->setVisible(checked); + }); // --- Repeat end group (hidden when NONE) --- @@ -229,6 +206,7 @@ MacroScheduleEntryDialog::MacroScheduleEntryDialog( untilRow->addStretch(); repeatEndLayout->addLayout(untilRow); + _repeatEndGroup->setVisible(false); form->addRow(_repeatEndGroup); // Enable/disable child widgets based on radio selection @@ -277,13 +255,7 @@ MacroScheduleEntryDialog::MacroScheduleEntryDialog( mainLayout->addWidget(buttons); setLayout(mainLayout); - // Connect repeat type change - connect(_repeatType, - QOverload::of(&QComboBox::currentIndexChanged), this, - [this](int) { UpdateRepeatVisibility(); }); - PopulateFromEntry(entry); - UpdateRepeatVisibility(); } void MacroScheduleEntryDialog::PopulateFromEntry(const MacroScheduleEntry &entry) @@ -309,12 +281,9 @@ void MacroScheduleEntryDialog::PopulateFromEntry(const MacroScheduleEntry &entry } } - const int repeatIdx = - _repeatType->findData(static_cast(entry.repeatType)); - if (repeatIdx >= 0) { - _repeatType->setCurrentIndex(repeatIdx); - } - _repeatInterval->setValue(entry.repeatInterval); + _doesRepeat->setChecked(entry.doesRepeat); + _repeatInterval->SetDuration(entry.repeatInterval); + _doesRepeat->toggled(entry.doesRepeat); switch (entry.repeatEndType) { case MacroScheduleEntry::RepeatEndType::NEVER: @@ -367,10 +336,8 @@ void MacroScheduleEntryDialog::ApplyToEntry(MacroScheduleEntry &entry) const } entry.endDateActionApplied = false; - const int repeatData = _repeatType->currentData().toInt(); - entry.repeatType = - static_cast(repeatData); - entry.repeatInterval = _repeatInterval->value(); + entry.doesRepeat = _doesRepeat->isChecked(); + entry.repeatInterval = _repeatInterval->GetDuration(); if (_endNever->isChecked()) { entry.repeatEndType = MacroScheduleEntry::RepeatEndType::NEVER; @@ -394,43 +361,6 @@ void MacroScheduleEntryDialog::ApplyToEntry(MacroScheduleEntry &entry) const entry.enabled = _enabled->isChecked(); } -static QString repeatTypeUnitLabel(MacroScheduleEntry::RepeatType type) -{ - switch (type) { - case MacroScheduleEntry::RepeatType::MINUTELY: - return obs_module_text( - "AdvSceneSwitcher.macroScheduleEntry.repeat.interval.unit.minute"); - case MacroScheduleEntry::RepeatType::HOURLY: - return obs_module_text( - "AdvSceneSwitcher.macroScheduleEntry.repeat.interval.unit.hour"); - case MacroScheduleEntry::RepeatType::DAILY: - return obs_module_text( - "AdvSceneSwitcher.macroScheduleEntry.repeat.interval.unit.day"); - case MacroScheduleEntry::RepeatType::WEEKLY: - return obs_module_text( - "AdvSceneSwitcher.macroScheduleEntry.repeat.interval.unit.week"); - case MacroScheduleEntry::RepeatType::MONTHLY: - return obs_module_text( - "AdvSceneSwitcher.macroScheduleEntry.repeat.interval.unit.month"); - default: - return ""; - } -} - -void MacroScheduleEntryDialog::UpdateRepeatVisibility() -{ - const int data = _repeatType->currentData().toInt(); - const auto type = static_cast(data); - const bool repeating = (type != MacroScheduleEntry::RepeatType::NONE); - - _intervalRow->setVisible(repeating); - _repeatEndGroup->setVisible(repeating); - - if (repeating) { - _intervalUnitLabel->setText(repeatTypeUnitLabel(type)); - } -} - void MacroScheduleEntryDialog::UpdateColorButton() { // Fill the button with the chosen color so it acts as a swatch diff --git a/plugins/schedule/macro-schedule-entry-dialog.hpp b/plugins/schedule/macro-schedule-entry-dialog.hpp index afbf61a6..85187d0b 100644 --- a/plugins/schedule/macro-schedule-entry-dialog.hpp +++ b/plugins/schedule/macro-schedule-entry-dialog.hpp @@ -1,5 +1,6 @@ #pragma once +#include "duration-control.hpp" #include "macro-schedule.hpp" #include "macro-selection.hpp" @@ -33,7 +34,6 @@ private: void PopulateFromEntry(const MacroScheduleEntry &entry); void ApplyToEntry(MacroScheduleEntry &entry) const; - void UpdateRepeatVisibility(); // Basic fields QLineEdit *_name; @@ -45,13 +45,9 @@ private: QDateTimeEdit *_endDate; QComboBox *_endDateAction; - // Repeat type - QComboBox *_repeatType; - - // Repeat interval (hidden when type == NONE) - QWidget *_intervalRow; - QSpinBox *_repeatInterval; - QLabel *_intervalUnitLabel; + // Repeat + QCheckBox *_doesRepeat; + DurationSelection *_repeatInterval; // Repeat end (hidden when type == NONE) QGroupBox *_repeatEndGroup; diff --git a/plugins/schedule/macro-schedule-tab.cpp b/plugins/schedule/macro-schedule-tab.cpp index 9aedefe0..b11e8b2f 100644 --- a/plugins/schedule/macro-schedule-tab.cpp +++ b/plugins/schedule/macro-schedule-tab.cpp @@ -120,7 +120,7 @@ EntryToCalendarEvents(const MacroScheduleEntry &entry, const QDate &rangeStart, result.append(ev); } - if (entry.repeatType == MacroScheduleEntry::RepeatType::NONE) { + if (!entry.doesRepeat) { break; // one-shot — no more occurrences } diff --git a/plugins/schedule/macro-schedule.cpp b/plugins/schedule/macro-schedule.cpp index 9ada0979..ac9fd014 100644 --- a/plugins/schedule/macro-schedule.cpp +++ b/plugins/schedule/macro-schedule.cpp @@ -149,8 +149,8 @@ void MacroScheduleEntry::Save(obs_data_t *obj) const static_cast(endDateAction)); } - obs_data_set_int(obj, "repeatType", static_cast(repeatType)); - obs_data_set_int(obj, "repeatInterval", repeatInterval); + obs_data_set_bool(obj, "doesRepeat", doesRepeat); + repeatInterval.Save(obj, "repeatInterval"); obs_data_set_int(obj, "repeatEndType", static_cast(repeatEndType)); obs_data_set_int(obj, "repeatMaxCount", repeatMaxCount); if (repeatEndType == RepeatEndType::UNTIL_DATE && @@ -195,10 +195,8 @@ void MacroScheduleEntry::Load(obs_data_t *obj) obs_data_get_int(obj, "endDateAction")); } - repeatType = - static_cast(obs_data_get_int(obj, "repeatType")); - obs_data_set_default_int(obj, "repeatInterval", 1); - repeatInterval = (int)obs_data_get_int(obj, "repeatInterval"); + doesRepeat = obs_data_get_bool(obj, "doesRepeat"); + repeatInterval.Load(obj, "repeatInterval"); repeatEndType = static_cast( obs_data_get_int(obj, "repeatEndType")); obs_data_set_default_int(obj, "repeatMaxCount", 1); @@ -212,20 +210,11 @@ void MacroScheduleEntry::Load(obs_data_t *obj) QDateTime MacroScheduleEntry::advanceFrom(const QDateTime &base) const { - switch (repeatType) { - case RepeatType::MINUTELY: - return base.addSecs((qint64)repeatInterval * 60); - case RepeatType::HOURLY: - return base.addSecs((qint64)repeatInterval * 3600); - case RepeatType::DAILY: - return base.addDays(repeatInterval); - case RepeatType::WEEKLY: - return base.addDays((qint64)repeatInterval * 7); - case RepeatType::MONTHLY: - return base.addMonths(repeatInterval); - default: + const qint64 ms = qRound64(repeatInterval.Seconds() * 1000.0); + if (ms <= 0) { return QDateTime(); } + return base.addMSecs(ms); } QDateTime MacroScheduleEntry::NextTriggerTime() const @@ -233,7 +222,7 @@ QDateTime MacroScheduleEntry::NextTriggerTime() const if (!lastTriggered.isValid()) { return startDateTime; } - if (repeatType == RepeatType::NONE) { + if (!doesRepeat) { // If the start was moved beyond the last trigger, treat as not yet fired. if (startDateTime > lastTriggered) { return startDateTime; @@ -254,7 +243,7 @@ QDateTime MacroScheduleEntry::NextTriggerTime() const bool MacroScheduleEntry::IsExpired() const { - if (repeatType == RepeatType::NONE && lastTriggered.isValid()) { + if (!doesRepeat && lastTriggered.isValid()) { return lastTriggered >= startDateTime; } switch (repeatEndType) { @@ -302,7 +291,7 @@ void MacroScheduleEntry::FastForwardTo(const QDateTime &now) return; } - if (repeatType == RepeatType::NONE) { + if (!doesRepeat) { // One-shot: count it as triggered if the start lies in the past. if (startDateTime <= now) { timesTriggered = 1; @@ -327,55 +316,13 @@ void MacroScheduleEntry::FastForwardTo(const QDateTime &now) QString MacroScheduleEntry::GetRepeatDescription() const { - if (repeatType == RepeatType::NONE) { + if (!doesRepeat) { return obs_module_text( "AdvSceneSwitcher.macroScheduleEntry.repeat.once"); } - const char *unitSingular = ""; - const char *unitPlural = ""; - switch (repeatType) { - case RepeatType::MINUTELY: - unitSingular = obs_module_text( - "AdvSceneSwitcher.macroScheduleEntry.repeat.unit.minute"); - unitPlural = obs_module_text( - "AdvSceneSwitcher.macroScheduleEntry.repeat.unit.minutes"); - break; - case RepeatType::HOURLY: - unitSingular = obs_module_text( - "AdvSceneSwitcher.macroScheduleEntry.repeat.unit.hour"); - unitPlural = obs_module_text( - "AdvSceneSwitcher.macroScheduleEntry.repeat.unit.hours"); - break; - case RepeatType::DAILY: - unitSingular = obs_module_text( - "AdvSceneSwitcher.macroScheduleEntry.repeat.unit.day"); - unitPlural = obs_module_text( - "AdvSceneSwitcher.macroScheduleEntry.repeat.unit.days"); - break; - case RepeatType::WEEKLY: - unitSingular = obs_module_text( - "AdvSceneSwitcher.macroScheduleEntry.repeat.unit.week"); - unitPlural = obs_module_text( - "AdvSceneSwitcher.macroScheduleEntry.repeat.unit.weeks"); - break; - case RepeatType::MONTHLY: - unitSingular = obs_module_text( - "AdvSceneSwitcher.macroScheduleEntry.repeat.unit.month"); - unitPlural = obs_module_text( - "AdvSceneSwitcher.macroScheduleEntry.repeat.unit.months"); - break; - default: - break; - } - if (repeatInterval == 1) { - return QString(obs_module_text( - "AdvSceneSwitcher.macroScheduleEntry.repeat.everyOne")) - .arg(unitSingular); - } return QString(obs_module_text( - "AdvSceneSwitcher.macroScheduleEntry.repeat.everyN")) - .arg(repeatInterval) - .arg(unitPlural); + "AdvSceneSwitcher.macroScheduleEntry.repeat.every")) + .arg(QString::fromStdString(repeatInterval.ToString())); } QString MacroScheduleEntry::GetSummary() const diff --git a/plugins/schedule/macro-schedule.hpp b/plugins/schedule/macro-schedule.hpp index 22a97b3e..e80d3f01 100644 --- a/plugins/schedule/macro-schedule.hpp +++ b/plugins/schedule/macro-schedule.hpp @@ -1,4 +1,5 @@ #pragma once +#include "duration.hpp" #include "macro-ref.hpp" #include @@ -10,15 +11,6 @@ namespace advss { class MacroScheduleEntry { public: - enum class RepeatType { - NONE, - MINUTELY, - HOURLY, - DAILY, - WEEKLY, - MONTHLY, - }; - enum class RepeatEndType { NEVER, AFTER_N_TIMES, @@ -60,8 +52,8 @@ public: QDateTime endDate; EndDateAction endDateAction = EndDateAction::NONE; - RepeatType repeatType = RepeatType::NONE; - int repeatInterval = 1; + bool doesRepeat = false; + Duration repeatInterval = 120; RepeatEndType repeatEndType = RepeatEndType::NEVER; int repeatMaxCount = 1;