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] 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