Add "Macro Schedule" tab

This commit is contained in:
WarmUpTill 2026-04-11 19:19:17 +02:00
parent e9b4bbfd42
commit 76abda6e72
9 changed files with 1972 additions and 2 deletions

View File

@ -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"

View File

@ -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)

View File

@ -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})

View File

@ -0,0 +1,438 @@
#include "macro-schedule-entry-dialog.hpp"
#include "help-icon.hpp"
#include "obs-module-helper.hpp"
#include "ui-helpers.hpp"
#include <QColorDialog>
#include <QDialogButtonBox>
#include <QFormLayout>
#include <QHBoxLayout>
#include <QVBoxLayout>
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<int>(MacroScheduleEntry::EndDateAction::NONE));
_endDateAction->addItem(
obs_module_text(
"AdvSceneSwitcher.macroScheduleEntry.endDate.action.disableEntry"),
static_cast<int>(
MacroScheduleEntry::EndDateAction::DISABLE_ENTRY));
_endDateAction->addItem(
obs_module_text(
"AdvSceneSwitcher.macroScheduleEntry.endDate.action.pauseMacro"),
static_cast<int>(
MacroScheduleEntry::EndDateAction::PAUSE_MACRO));
_endDateAction->addItem(
obs_module_text(
"AdvSceneSwitcher.macroScheduleEntry.endDate.action.stopMacro"),
static_cast<int>(
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<int>(MacroScheduleEntry::RepeatType::NONE));
_repeatType->addItem(
obs_module_text(
"AdvSceneSwitcher.macroScheduleEntry.repeat.minutely"),
static_cast<int>(MacroScheduleEntry::RepeatType::MINUTELY));
_repeatType->addItem(
obs_module_text(
"AdvSceneSwitcher.macroScheduleEntry.repeat.hourly"),
static_cast<int>(MacroScheduleEntry::RepeatType::HOURLY));
_repeatType->addItem(
obs_module_text(
"AdvSceneSwitcher.macroScheduleEntry.repeat.daily"),
static_cast<int>(MacroScheduleEntry::RepeatType::DAILY));
_repeatType->addItem(
obs_module_text(
"AdvSceneSwitcher.macroScheduleEntry.repeat.weekly"),
static_cast<int>(MacroScheduleEntry::RepeatType::WEEKLY));
_repeatType->addItem(
obs_module_text(
"AdvSceneSwitcher.macroScheduleEntry.repeat.monthly"),
static_cast<int>(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<int>::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<int>(entry.endDateAction));
if (idx >= 0) {
_endDateAction->setCurrentIndex(idx);
}
}
const int repeatIdx =
_repeatType->findData(static_cast<int>(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<MacroScheduleEntry::EndDateAction>(
_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<MacroScheduleEntry::RepeatType>(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<MacroScheduleEntry::RepeatType>(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

View File

@ -0,0 +1,75 @@
#pragma once
#include "macro-schedule.hpp"
#include "macro-selection.hpp"
#include <QCheckBox>
#include <QColor>
#include <QDateEdit>
#include <QDateTimeEdit>
#include <QDialog>
#include <QGroupBox>
#include <QLabel>
#include <QLineEdit>
#include <QPushButton>
#include <QRadioButton>
#include <QSpinBox>
#include <QComboBox>
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

View File

@ -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 <QKeyEvent>
#include <QHBoxLayout>
#include <QHeaderView>
#include <QMenu>
#include <QVBoxLayout>
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<CalendarEvent>
EntryToCalendarEvents(const MacroScheduleEntry &entry, const QDate &rangeStart,
const QDate &rangeEnd)
{
QList<CalendarEvent> 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<MacroScheduleEntry> 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<MacroScheduleEntry> snapshot;
{
auto lock = LockContext();
snapshot = GetScheduleEntries();
}
PopulateTable();
RefreshCalendar(snapshot);
UpdateButtonStates();
}
void MacroScheduleTab::RefreshCalendar(
const std::deque<MacroScheduleEntry> &entries)
{
const QDate rangeStart = _calendar->VisibleRangeStart();
const QDate rangeEnd = _calendar->VisibleRangeEnd();
QList<CalendarEvent> 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<MacroScheduleEntry> 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<QKeyEvent *>(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

View File

@ -0,0 +1,70 @@
#pragma once
#include "calendar/calendar-widget.hpp"
#include "macro-schedule.hpp"
#include <QEvent>
#include <QLabel>
#include <QPushButton>
#include <QSplitter>
#include <QTableWidget>
#include <QTimer>
#include <QToolButton>
#include <QWidget>
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<MacroScheduleEntry> &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

View File

@ -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 <obs-data.h>
#include <QUuid>
#include <atomic>
#include <condition_variable>
#include <mutex>
#include <thread>
namespace advss {
static std::deque<MacroScheduleEntry> scheduleEntries;
static std::thread schedulerThread;
static std::atomic<bool> 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<QPair<QString, QString>> {
QList<QPair<QString, QString>> 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<int>(endDateAction));
}
obs_data_set_int(obj, "repeatType", static_cast<int>(repeatType));
obs_data_set_int(obj, "repeatInterval", repeatInterval);
obs_data_set_int(obj, "repeatEndType", static_cast<int>(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<EndDateAction>(
obs_data_get_int(obj, "endDateAction"));
}
repeatType =
static_cast<RepeatType>(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<RepeatEndType>(
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<MacroScheduleEntry> &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<std::mutex> 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

View File

@ -0,0 +1,83 @@
#pragma once
#include "macro-ref.hpp"
#include <QColor>
#include <QDateTime>
#include <QString>
#include <deque>
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<MacroScheduleEntry> &GetScheduleEntries();
void InitScheduler();
void CleanupScheduler();
} // namespace advss