From 36201cbfb46a84b75b00d6142b52b43b0dbabe88 Mon Sep 17 00:00:00 2001 From: WarmUpTill Date: Sat, 13 Jul 2024 20:38:41 +0200 Subject: [PATCH] Add API to register new macro condition and action types --- .gitignore | 1 + CMakeLists.txt | 11 + data/locale/en-US.ini | 94 +- lib/macro/macro-action-factory.cpp | 58 +- lib/macro/macro-action-factory.hpp | 5 +- lib/macro/macro-action-script.cpp | 92 + lib/macro/macro-action-script.hpp | 29 + lib/macro/macro-condition-factory.cpp | 50 +- lib/macro/macro-condition-factory.hpp | 6 +- lib/macro/macro-condition-script.cpp | 79 + lib/macro/macro-condition-script.hpp | 27 + lib/macro/macro-script-handler.cpp | 447 +++++ lib/macro/macro-script-handler.hpp | 59 + lib/macro/macro-segment-script.cpp | 219 +++ lib/macro/macro-segment-script.hpp | 78 + lib/utils/properties-view.cpp | 2540 +++++++++++++++++++++++++ lib/utils/properties-view.hpp | 264 +++ lib/utils/properties-view.moc.hpp | 63 + scripting/examples.lua | 283 +++ scripting/examples.py | 305 +++ 20 files changed, 4670 insertions(+), 40 deletions(-) create mode 100644 lib/macro/macro-action-script.cpp create mode 100644 lib/macro/macro-action-script.hpp create mode 100644 lib/macro/macro-condition-script.cpp create mode 100644 lib/macro/macro-condition-script.hpp create mode 100644 lib/macro/macro-script-handler.cpp create mode 100644 lib/macro/macro-script-handler.hpp create mode 100644 lib/macro/macro-segment-script.cpp create mode 100644 lib/macro/macro-segment-script.hpp create mode 100644 lib/utils/properties-view.cpp create mode 100644 lib/utils/properties-view.hpp create mode 100644 lib/utils/properties-view.moc.hpp create mode 100644 scripting/examples.lua create mode 100644 scripting/examples.py diff --git a/.gitignore b/.gitignore index 05d52837..5df5d612 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ !/cmake !/data !/deps +!/scripting !/forms !/lib !/module diff --git a/CMakeLists.txt b/CMakeLists.txt index dd7d2ddc..35c449b7 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -96,6 +96,8 @@ target_sources( lib/macro/macro-action-macro.hpp lib/macro/macro-action-queue.cpp lib/macro/macro-action-queue.hpp + lib/macro/macro-action-script.cpp + lib/macro/macro-action-script.hpp lib/macro/macro-action-variable.cpp lib/macro/macro-action-variable.hpp lib/macro/macro-action.cpp @@ -108,6 +110,8 @@ target_sources( lib/macro/macro-condition-macro.hpp lib/macro/macro-condition-queue.cpp lib/macro/macro-condition-queue.hpp + lib/macro/macro-condition-script.cpp + lib/macro/macro-condition-script.hpp lib/macro/macro-condition-tempvar.cpp lib/macro/macro-condition-tempvar.hpp lib/macro/macro-condition-variable.cpp @@ -128,10 +132,14 @@ target_sources( lib/macro/macro-ref.hpp lib/macro/macro-run-button.cpp lib/macro/macro-run-button.hpp + lib/macro/macro-script-handler.cpp + lib/macro/macro-script-handler.hpp lib/macro/macro-segment-copy-paste.cpp lib/macro/macro-segment-copy-paste.hpp lib/macro/macro-segment-list.cpp lib/macro/macro-segment-list.hpp + lib/macro/macro-segment-script.cpp + lib/macro/macro-segment-script.hpp lib/macro/macro-segment-selection.cpp lib/macro/macro-segment-selection.hpp lib/macro/macro-segment.cpp @@ -205,6 +213,9 @@ target_sources( lib/utils/plugin-state-helpers.hpp lib/utils/priority-helper.cpp lib/utils/priority-helper.hpp + lib/utils/properties-view.cpp + lib/utils/properties-view.hpp + lib/utils/properties-view.moc.hpp lib/utils/regex-config.cpp lib/utils/regex-config.hpp lib/utils/resizing-text-edit.cpp diff --git a/data/locale/en-US.ini b/data/locale/en-US.ini index 204c63ad..e57db64f 100644 --- a/data/locale/en-US.ini +++ b/data/locale/en-US.ini @@ -1,7 +1,7 @@ AdvSceneSwitcher.pluginName="Advanced Scene Switcher" AdvSceneSwitcher.windowTitle="Advanced Scene Switcher" -; General Tab +# General Tab AdvSceneSwitcher.generalTab.title="General" AdvSceneSwitcher.generalTab.status="Status" AdvSceneSwitcher.generalTab.status.hotkeytips="Hotkeys can be defined in the OBS settings" @@ -70,7 +70,7 @@ AdvSceneSwitcher.generalTab.transitionOverride="Set transition overrides" AdvSceneSwitcher.generalTab.adjustActiveTransitionType="Change active transition type" AdvSceneSwitcher.generalTab.transitionBehaviorSelectionError="At least one option must be enabled:\n\n - Use transition overrides\n\n - Change active transition type" -; Variables Tab +# Variables Tab AdvSceneSwitcher.variableTab.title="Variables" AdvSceneSwitcher.variableTab.help="Variables can be used in many places throughout the plugin.\n\nClick on the highlighted plus symbol to add a new variable." AdvSceneSwitcher.variableTab.variableAddButton.tooltip="Add new variable" @@ -89,7 +89,7 @@ AdvSceneSwitcher.variableTab.lastChanged.text="%1 seconds ago" AdvSceneSwitcher.variableTab.lastChanged.text.none="No change since launch" AdvSceneSwitcher.variableTab.lastChanged.tooltip="Times changed: %1\n\nPrevious value: %2" -; Action Queue Tab +# Action Queue Tab AdvSceneSwitcher.actionQueueTab.title="Action Queues" AdvSceneSwitcher.actionQueueTab.help="Action queues are executed sequentially but in parallel to the reset of the macro system.\nThe first action added to the queue will be the first one to be processed.\n\nClick on the highlighted plus symbol to add a new queue." AdvSceneSwitcher.actionQueueTab.queueAddButton.tooltip="Add new action queue" @@ -103,7 +103,7 @@ AdvSceneSwitcher.actionQueueTab.no="No" AdvSceneSwitcher.actionQueueTab.removeSingleQueuePopup.text="Are you sure you want to remove \"%1\"?" AdvSceneSwitcher.actionQueueTab.removeMultipleQueuesPopup.text="Are you sure you want to remove %1 action queues?" -; Websocket Connections Tab +# Websocket Connections Tab AdvSceneSwitcher.websocketConnectionTab.title="Websocket Connections" AdvSceneSwitcher.websocketConnectionTab.help="Websocket connections can be used to communicate with other OBS instances or programs.\n\nClick on the highlighted plus symbol to add a new connection." AdvSceneSwitcher.websocketConnectionTab.websocketConnectionAddButton.tooltip="Add new webscoket connection" @@ -117,7 +117,7 @@ AdvSceneSwitcher.websocketConnectionTab.protocol.no="No" AdvSceneSwitcher.websocketConnectionTab.removeSingleConnectionPopup.text="Are you sure you want to remove \"%1\"?" AdvSceneSwitcher.websocketConnectionTab.removeMultipleConnectionsPopup.text="Are you sure you want to remove %1 connections?" -; Twitch Connections Tab +# Twitch Connections Tab AdvSceneSwitcher.twitchConnectionTab.title="Twitch Connections" AdvSceneSwitcher.twitchConnectionTab.help="Twitch connections can be used to use Twitch events as triggers to execute actions or perform actions on the linked Twitch account.\n\nClick on the highlighted plus symbol to add a new connection." AdvSceneSwitcher.twitchConnectionTab.twitchConnectionAddButton.tooltip.tooltip="Add new Twitch connection" @@ -130,7 +130,7 @@ AdvSceneSwitcher.twitchConnectionTab.no="No" AdvSceneSwitcher.twitchConnectionTab.removeSingleConnectionPopup.text="Are you sure you want to remove \"%1\"?" AdvSceneSwitcher.twitchConnectionTab.removeMultipleConnectionsPopup.text="Are you sure you want to remove %1 Twitch connections?" -; Macro Tab +# Macro Tab AdvSceneSwitcher.macroTab.title="Macro" AdvSceneSwitcher.macroTab.macros="Macros" AdvSceneSwitcher.macroTab.priorityWarning="Note: It is recommended to configure macros to be the highest priority functionality.\nThis setting can be changed on the General tab." @@ -239,11 +239,11 @@ AdvSceneSwitcher.macroDock.run="Run" AdvSceneSwitcher.macroDock.statusLabel.true="Conditions are true." AdvSceneSwitcher.macroDock.statusLabel.false="Conditions are false." -; Macro List +# Macro List AdvSceneSwitcher.macroList.deleted="deleted" AdvSceneSwitcher.macroList.duplicate="\"%1\" is alreay selected!" -; Macro Logic +# Macro Logic AdvSceneSwitcher.logic.none="Ignore entry" AdvSceneSwitcher.logic.and="And" AdvSceneSwitcher.logic.or="Or" @@ -252,7 +252,8 @@ AdvSceneSwitcher.logic.orNot="Or not" AdvSceneSwitcher.logic.rootNone="If" AdvSceneSwitcher.logic.not="If not" -; Macro Conditions +# Macro Conditions +AdvSceneSwitcher.condition.unknown="Unknown condition" AdvSceneSwitcher.condition.audio="Audio" AdvSceneSwitcher.condition.audio.state.below="below" AdvSceneSwitcher.condition.audio.state.exact="exactly" @@ -724,7 +725,8 @@ AdvSceneSwitcher.condition.usb.productName="Product Name:" AdvSceneSwitcher.condition.usb.serialNumber="Serial Number:" AdvSceneSwitcher.condition.noDevicesFoundWarning="No USB devices detected!\nThe plugin might not have the required permissions to check for USB devices." -; Macro Actions +# Macro Actions +AdvSceneSwitcher.action.unknown="Unknown action" AdvSceneSwitcher.action.scene="Switch scene" AdvSceneSwitcher.action.scene.type.program="Program" AdvSceneSwitcher.action.scene.type.preview="Preview" @@ -1114,7 +1116,7 @@ AdvSceneSwitcher.action.log="Log" AdvSceneSwitcher.action.log.placeholder="My log message!" AdvSceneSwitcher.action.log.entry="Write to OBS log:{{logMessage}}" -; Hotkey +# Hotkey AdvSceneSwitcher.hotkey.startSwitcherHotkey="Start the Advanced Scene Switcher" AdvSceneSwitcher.hotkey.stopSwitcherHotkey="Stop the Advanced Scene Switcher" AdvSceneSwitcher.hotkey.startStopToggleSwitcherHotkey="Toggle Start/Stop for the Advanced Scene Switcher" @@ -1918,8 +1920,44 @@ AdvSceneSwitcher.noSettingsButtons="No buttons found!" AdvSceneSwitcher.clearBufferOnMatch="Clear message buffer when matching message was found" -; Legacy tabs below - please don't waste your time adding translations for these :) -; Transition Tab +AdvSceneSwitcher.script.settings="Settings" +AdvSceneSwitcher.script.timeout="Script timeout:{{timeout}}" + +# This secion is copied from the OBS locale files + +# commonly shared locale +Browse="Browse" +Show="Show" +Hide="Hide" + + +# properties window +Basic.PropertiesWindow="Properties for '%1'" +Basic.PropertiesWindow.AutoSelectFormat="%1 (autoselect: %2)" +Basic.PropertiesWindow.SelectColor="Select color" +Basic.PropertiesWindow.SelectFont="Select font" +Basic.PropertiesWindow.SelectFont.WindowTitle="Pick a Font" +Basic.PropertiesWindow.ConfirmTitle="Settings Changed" +Basic.PropertiesWindow.Confirm="There are unsaved changes. Do you want to keep them?" +Basic.PropertiesWindow.NoProperties="No properties available" +Basic.PropertiesWindow.AddFiles="Add Files" +Basic.PropertiesWindow.AddDir="Add Directory" +Basic.PropertiesWindow.AddURL="Add Path/URL" +Basic.PropertiesWindow.AddEditableListDir="Add directory to '%1'" +Basic.PropertiesWindow.AddEditableListFiles="Add files to '%1'" +Basic.PropertiesWindow.AddEditableListEntry="Add entry to '%1'" +Basic.PropertiesWindow.EditEditableListEntry="Edit entry from '%1'" + +# properties view +Basic.PropertiesView.FPS.Simple="Simple FPS Values" +Basic.PropertiesView.FPS.Rational="Rational FPS Values" +Basic.PropertiesView.FPS.ValidFPSRanges="Valid FPS Ranges:" +Basic.PropertiesView.UrlButton.Text="Open this link in your default web browser?" +Basic.PropertiesView.UrlButton.Text.Url="URL: %1" +Basic.PropertiesView.UrlButton.OpenUrl="Open URL" + +# Legacy tabs below - please don't waste your time adding translations for these :) +# Transition Tab AdvSceneSwitcher.transitionTab.title="Transition" AdvSceneSwitcher.transitionTab.transitionForAToB="Use transition for automated scene switch from scene A to scene B" AdvSceneSwitcher.transitionTab.transitionsHelp="

These settings only affect transitions caused by the scene switcher - Check out Transition Table if you want to configure this for manual scene changes.
Settings defined here take priority over transition settings configured elsewhere in the scene switcher.

Click the plus symbol below to add a new entry.

" @@ -1930,7 +1968,7 @@ AdvSceneSwitcher.transitionTab.defaultTransitionsHelp="Click on the plus symbol AdvSceneSwitcher.transitionTab.defaultTransition.delay="Switch transition{{defTransitionDelay}}after scene change." AdvSceneSwitcher.transitionTab.defaultTransition.delay.help="The delay is used to avoid cancelled scene switches, which can happen if the transition type is changed while a transition is still ongoing." -; Pause Scenes Tab +# Pause Scenes Tab AdvSceneSwitcher.pauseTab.title="Pause" AdvSceneSwitcher.pauseTab.pauseOnScene="Pause the Scene Switcher on scene" AdvSceneSwitcher.pauseTab.pauseInFocus1="Pause the Scene Switcher when " @@ -1941,7 +1979,7 @@ AdvSceneSwitcher.pauseTab.pauseTargetAll="all" AdvSceneSwitcher.pauseTab.pauseEntry="Pause{{pauseTargets}}checks when{{pauseTypes}}{{scenes}}{{windows}}" AdvSceneSwitcher.pauseTab.help="On this tab you can configure to pause individual switching methods if a scene is active or window is in focus.\n\nClick on the highlighted plus symbol to continue." -; Window Title Tab +# Window Title Tab AdvSceneSwitcher.windowTitleTab.title="Title" AdvSceneSwitcher.windowTitleTab.regexrDescription="

Enter either direct window titles or valid regex. You can check syntax and matches for regular expressions using RegExr

" AdvSceneSwitcher.windowTitleTab.stayInFocus1="Ignore this window name" @@ -1953,14 +1991,14 @@ AdvSceneSwitcher.windowTitleTab.entry="{{windows}}{{scenes}}{{transitions}}{{ful AdvSceneSwitcher.windowTitleTab.windowsHelp="Switch scenes based on the window title of running applications.\nThe following additional conditions can be selected:\nThe window is Fullscreen\nThe window is maximized\nThe window is focused\n\nClick on the highlighted plus symbol to continue." AdvSceneSwitcher.windowTitleTab.ignoreWindowsHelp="If a window title is ignored the scene switcher will act as if the previously selected window is still in focus.\nThis will allow you to avoid scene switches, if you frequently switch to a different window, which shall not trigger a scene change.\n\nChoose a window or enter a window title above and click on the plus symbol below to add it to the list." -; Executable Tab +# Executable Tab AdvSceneSwitcher.executableTab.title="Executable" AdvSceneSwitcher.executableTab.implemented="Implemented by dasOven" AdvSceneSwitcher.executableTab.requiresFocus="only if focused" AdvSceneSwitcher.executableTab.entry="When{{processes}}is running switch to{{scenes}}using{{transitions}}{{requiresFocus}}" AdvSceneSwitcher.executableTab.help="This tab will allow you to automatically switch scenes if a process is running.\nThis can be useful in situations where the window name could change or is not known.\n\nClick on the highlighted plus symbol to continue." -; Screen Region Tab +# Screen Region Tab AdvSceneSwitcher.screenRegionTab.title="Region" AdvSceneSwitcher.screenRegionTab.currentPosition="Cursor is currently at:" AdvSceneSwitcher.screenRegionTab.showGuideFrames="Show guide frames" @@ -1969,7 +2007,7 @@ AdvSceneSwitcher.screenRegionTab.excludeScenes.None="No selection" AdvSceneSwitcher.screenRegionTab.entry="If cursor is in{{minX}}{{minY}}x{{maxX}}{{maxY}}switch to{{scenes}}using{{transitions}}unless in{{excludeScenes}}" AdvSceneSwitcher.screenRegionTab.help="This tab will allow you to automatically switch scenes based on the current position of your mouse cursor.\n\nClick on the highlighted plus symbol to continue." -; Media Tab +# Media Tab AdvSceneSwitcher.mediaTab.title="Media" AdvSceneSwitcher.mediaTab.implemented="Implemented by Exeldro" AdvSceneSwitcher.mediaTab.states.none="None" @@ -1990,7 +2028,7 @@ AdvSceneSwitcher.mediaTab.timeRestriction.remainLonger="Time remaining longer" AdvSceneSwitcher.mediaTab.entry="When{{mediaSources}}state is{{states}}and{{timeRestrictions}}{{time}}switch to{{scenes}}using{{transitions}}" AdvSceneSwitcher.mediaTab.help="This tab will allow you to switch scenes based on the states of media sources.\nFor example, you can automatically switch back to the previous scene once the selected media sourced ended its playback.\n\nClick on the highlighted plus symbol to continue." -; File Tab +# File Tab AdvSceneSwitcher.fileTab.title="File" AdvSceneSwitcher.fileTab.readWriteSceneFile="Read / write scene from / to file" AdvSceneSwitcher.fileTab.currentSceneOutputFile="Write the name of the current scene to this file:" @@ -2015,13 +2053,13 @@ AdvSceneSwitcher.fileTab.entry2="{{matchText}}" AdvSceneSwitcher.fileTab.entry3="{{useRegex}}{{checkModificationDate}}{{checkFileContent}}" AdvSceneSwitcher.fileTab.help="This tab will allow you to automatically switch scenes based on the content of remote or local files.\n\nClick on the highlighted plus symbol to continue." -; Random Tab +# Random Tab AdvSceneSwitcher.randomTab.title="Random" AdvSceneSwitcher.randomTab.randomDisabledWarning="Functionality disabled - To activate select \"If no switch condition is met switch to any scene in Random tab\" on General tab" AdvSceneSwitcher.randomTab.entry="If no switch condition is met switch to{{scenes}}using{{transitions}}for{{delay}}" AdvSceneSwitcher.randomTab.help="The scene switcher will randomly choose an entry on this tab to switch to for the configured time.\nNote that the same entry will not be chosen twice in a row.\n\nClick on the highlighted plus symbol to continue." -; Time Tab +# Time Tab AdvSceneSwitcher.timeTab.title="Time" AdvSceneSwitcher.timeTab.anyDay="On any day" AdvSceneSwitcher.timeTab.mondays="Mondays" @@ -2036,14 +2074,14 @@ AdvSceneSwitcher.timeTab.afterstart.tip="The time relative to the start of strea AdvSceneSwitcher.timeTab.entry="{{triggers}}at{{time}}switch to{{scenes}}using{{transitions}}" AdvSceneSwitcher.timeTab.help="This tab will allow you to automatically switch to a different scene based on the current local time.\n\nNote that the scene switcher will only switch scenes at the exact time you specified.\nMake sure you have configured the priority settings on the General tab to your liking so the selected time point will not be missed due to other switching methods having a higher priority.\n\nClick on the highlighted plus symbol to continue." -; Idle Tab +# Idle Tab AdvSceneSwitcher.idleTab.title="Idle" AdvSceneSwitcher.idleTab.enable="Enable Idle Detection" AdvSceneSwitcher.idleTab.idleswitch="After{{duration}}of no keyboard or mouse inputs switch to scene{{scenes}}using the{{transitions}}" AdvSceneSwitcher.idleTab.dontSwitchIfFocus1="Do not switch if" AdvSceneSwitcher.idleTab.dontSwitchIfFocus2="is in focus" -; Scene Sequence Tab +# Scene Sequence Tab AdvSceneSwitcher.sceneSequenceTab.title="Sequence" AdvSceneSwitcher.sceneSequenceTab.description="A sequence of automatic scene switches can be cancelled by either pausing/stopping the scene switcher or manually switching to a different scene" AdvSceneSwitcher.sceneSequenceTab.save="Save scene sequences to file" @@ -2060,7 +2098,7 @@ AdvSceneSwitcher.sceneSequenceTab.extendEdit="Extend Sequence" AdvSceneSwitcher.sceneSequenceTab.extendEntry="After{{delay}}switch to{{scenes}}using{{transitions}}" AdvSceneSwitcher.sceneSequenceTab.help="This tab will allow you to automatically switch to a different scene if a scene was active for a configured period of time.\nFor example, you could automatically cycle back and forth between two scenes automatically.\n\nClick on the highlighted plus symbol to continue." -; Audio Tab +# Audio Tab AdvSceneSwitcher.audioTab.title="Audio" AdvSceneSwitcher.audioTab.condition.above="above" AdvSceneSwitcher.audioTab.condition.below="below" @@ -2070,7 +2108,7 @@ AdvSceneSwitcher.audioTab.multiMatchfallbackCondition="If multiple entries match AdvSceneSwitcher.audioTab.multiMatchfallback="... for{{duration}}seconds switch to{{scenes}}using{{transitions}}" AdvSceneSwitcher.audioTab.help="This tab will allow you to switch scenes based on the volume of sources.\nFor example, you could automatically switch to a different scene if the volume of your microphone reaches a certain threshold.\n\nClick on the highlighted plus symbol to continue." -; Video Tab +# Video Tab AdvSceneSwitcher.videoTab.title="Video" AdvSceneSwitcher.videoTab.getScreenshot="Get screenshot for selected entry" AdvSceneSwitcher.videoTab.getScreenshotHelp="Get Screenshot of the currently selected entry's video source and automatically set it as the target image" @@ -2083,7 +2121,7 @@ AdvSceneSwitcher.videoTab.ignoreInactiveSource="unless source is inactive" AdvSceneSwitcher.videoTab.entry="When{{videoSources}}{{condition}}{{filePath}}{{browseButton}}for{{duration}}switch to{{scenes}}using{{transitions}}{{ignoreInactiveSource}}" AdvSceneSwitcher.videoTab.help="

This tab will allow you to switch scenes based on the current video output of selected sources.
Make sure to check out Pixel Match Switcher for an even better implementation of this functionality.

Click on the highlighted plus symbol to continue.

" -; Network Tab +# Network Tab AdvSceneSwitcher.networkTab.title="Network" AdvSceneSwitcher.networkTab.description="This tab will allow you to remotely control the active scene of another OBS instance.\nPlease note that the scene names have to match exactly on all OBS instances." AdvSceneSwitcher.networkTab.warning="Running the server outside of a local network will allow third parties to read the active scene." @@ -2108,7 +2146,7 @@ AdvSceneSwitcher.networkTab.client.status.connecting="Connecting" AdvSceneSwitcher.networkTab.client.status.connected="Connected" AdvSceneSwitcher.networkTab.client.reconnect="Force reconnect" -; Scene Group Tab +# Scene Group Tab AdvSceneSwitcher.sceneGroupTab.title="Scene Group" AdvSceneSwitcher.sceneGroupTab.list="Scene Groups" AdvSceneSwitcher.sceneGroupTab.edit="Edit Scene Groups" @@ -2128,7 +2166,7 @@ AdvSceneSwitcher.sceneGroupTab.exists="Scene Group or Scene name exists already" AdvSceneSwitcher.sceneGroupTab.help="Scene Groups can be selected as a target just like a regular scene.\n\nAs the name suggests a scene group is a collection of multiple scenes.\nThe scene group will advance through the list of its assigned scenes depending on the configured settings, which can be found on the right side.\n\nYou can configure the scene group to advance to the next scene in the list:\nAfter a number of times the scene group is selected as a target.\nAfter a certain amount of time has passed.\nOr randomly.\n\nFor example, a scene group containing the scenes ...\nScene 1\nScene 2\nScene 3 \n... will activate \"Scene 1\" the first time it is selected as a target.\nThe second time it will activate \"Scene 2\".\nThe remaining times \"Scene 3\" will be activated.\n\nClick the highlighted plus symbol below to add a new scene group." AdvSceneSwitcher.sceneGroupTab.scenes.help="Select the scene group you want to modify on the left.\n\nSelect a scene to add to this scene group by selecting the scene above and clicking the plus symbol below.\n\nA scene can be added multiple times to the same scene group." -; Scene Trigger Tab +# Scene Trigger Tab AdvSceneSwitcher.sceneTriggerTab.title="Scene Triggers" AdvSceneSwitcher.sceneTriggerTab.sceneTriggerType.none="--select trigger--" AdvSceneSwitcher.sceneTriggerTab.sceneTriggerType.sceneActive="is active" diff --git a/lib/macro/macro-action-factory.cpp b/lib/macro/macro-action-factory.cpp index 57fe2ec4..25203e54 100644 --- a/lib/macro/macro-action-factory.cpp +++ b/lib/macro/macro-action-factory.cpp @@ -1,7 +1,28 @@ #include "macro-action-factory.hpp" +#include + namespace advss { +namespace { + +class MacroActionUnknown : public MacroAction { +public: + MacroActionUnknown(Macro *m) : MacroAction(m) {} + std::shared_ptr Copy() const + { + return std::make_shared(GetMacro()); + } + bool PerformAction() { return true; }; + bool Save(obs_data_t *obj) const { return MacroAction::Save(obj); }; + bool Load(obs_data_t *obj) { return MacroAction::Load(obj); }; + std::string GetId() const { return "unknown"; } +}; + +} // namespace + +static std::mutex mutex; + std::map &MacroActionFactory::GetMap() { static std::map _methods; @@ -10,6 +31,7 @@ std::map &MacroActionFactory::GetMap() bool MacroActionFactory::Register(const std::string &id, MacroActionInfo info) { + std::lock_guard lock(mutex); if (auto it = GetMap().find(id); it == GetMap().end()) { GetMap()[id] = info; return true; @@ -17,35 +39,61 @@ bool MacroActionFactory::Register(const std::string &id, MacroActionInfo info) return false; } +bool MacroActionFactory::Deregister(const std::string &id) +{ + std::lock_guard lock(mutex); + if (GetMap().count(id) == 0) { + return false; + } + GetMap().erase(id); + return true; +} + +static std::shared_ptr createUnknownAction(Macro *m) +{ + return std::make_shared(m); +} + std::shared_ptr MacroActionFactory::Create(const std::string &id, Macro *m) { - if (auto it = GetMap().find(id); it != GetMap().end()) + std::lock_guard lock(mutex); + if (auto it = GetMap().find(id); it != GetMap().end()) { return it->second._create(m); + } - return nullptr; + return createUnknownAction(m); +} + +static QWidget *createUnknownActionWidget() +{ + return new QLabel(obs_module_text("AdvSceneSwitcher.action.unknown")); } QWidget *MacroActionFactory::CreateWidget(const std::string &id, QWidget *parent, std::shared_ptr action) { - if (auto it = GetMap().find(id); it != GetMap().end()) + std::lock_guard lock(mutex); + if (auto it = GetMap().find(id); it != GetMap().end()) { return it->second._createWidget(parent, action); + } - return nullptr; + return createUnknownActionWidget(); } std::string MacroActionFactory::GetActionName(const std::string &id) { + std::lock_guard lock(mutex); if (auto it = GetMap().find(id); it != GetMap().end()) { return it->second._name; } - return "unknown action"; + return obs_module_text("AdvSceneSwitcher.action.unknown"); } std::string MacroActionFactory::GetIdByName(const QString &name) { + std::lock_guard lock(mutex); for (auto it : GetMap()) { if (name == obs_module_text(it.second._name.c_str())) { return it.first; diff --git a/lib/macro/macro-action-factory.hpp b/lib/macro/macro-action-factory.hpp index 38366867..cb703a25 100644 --- a/lib/macro/macro-action-factory.hpp +++ b/lib/macro/macro-action-factory.hpp @@ -6,10 +6,10 @@ namespace advss { struct MacroActionInfo { - using CreateAction = std::shared_ptr (*)(Macro *m); using CreateActionWidget = QWidget *(*)(QWidget *parent, std::shared_ptr); - CreateAction _create = nullptr; + + std::function(Macro *m)> _create = nullptr; CreateActionWidget _createWidget = nullptr; std::string _name; }; @@ -19,6 +19,7 @@ public: MacroActionFactory() = delete; EXPORT static bool Register(const std::string &id, MacroActionInfo); + static bool Deregister(const std::string &id); static std::shared_ptr Create(const std::string &id, Macro *m); static QWidget *CreateWidget(const std::string &id, QWidget *parent, diff --git a/lib/macro/macro-action-script.cpp b/lib/macro/macro-action-script.cpp new file mode 100644 index 00000000..0fbe385d --- /dev/null +++ b/lib/macro/macro-action-script.cpp @@ -0,0 +1,92 @@ +#include "macro-action-script.hpp" +#include "layout-helpers.hpp" +#include "macro-helpers.hpp" +#include "properties-view.hpp" +#include "sync-helpers.hpp" + +namespace advss { + +MacroActionScript::MacroActionScript(Macro *m, const std::string &id, + const OBSData &defaultSettings, + const std::string &propertiesSignalName, + const std::string &triggerSignal, + const std::string &completionSignal) + : MacroAction(m), + MacroSegmentScript(defaultSettings, propertiesSignalName, + triggerSignal, completionSignal), + _id(id) +{ +} + +MacroActionScript::MacroActionScript(const advss::MacroActionScript &other) + : MacroAction(other.GetMacro()), + MacroSegmentScript(other), + _id(other._id) +{ +} + +bool MacroActionScript::PerformAction() +{ + if (!ScriptHandler::ActionIdIsValid(_id)) { + blog(LOG_WARNING, "skipping unknown script action \"%s\"", + _id.c_str()); + return true; + } + + (void)SendTriggerSignal(); + return true; +} + +void MacroActionScript::LogAction() const +{ + ablog(LOG_INFO, "performing script action \"%s\"", GetId().c_str()); +} + +bool MacroActionScript::Save(obs_data_t *obj) const +{ + MacroAction::Save(obj); + MacroSegmentScript::Save(obj); + return true; +} + +bool MacroActionScript::Load(obs_data_t *obj) +{ + MacroAction::Load(obj); + MacroSegmentScript::Load(obj); + return true; +} + +std::shared_ptr MacroActionScript::Copy() const +{ + return std::make_shared(*this); +} + +void MacroActionScript::WaitForCompletion() const +{ + using namespace std::chrono_literals; + auto start = std::chrono::high_resolution_clock::now(); + auto timePassed = std::chrono::duration_cast( + start - start); + const auto timeoutMs = GetTimeoutSeconds() * 1000.0; + + std::unique_lock lock(*GetMutex()); + while (!TriggerIsCompleted()) { + if (MacroWaitShouldAbort() || MacroIsStopped(GetMacro())) { + break; + } + + if ((double)timePassed.count() > timeoutMs) { + blog(LOG_INFO, "script action timeout (%s)", + _id.c_str()); + break; + } + + GetMacroWaitCV().wait_for(lock, 10ms); + const auto now = std::chrono::high_resolution_clock::now(); + timePassed = + std::chrono::duration_cast( + now - start); + } +} + +} // namespace advss diff --git a/lib/macro/macro-action-script.hpp b/lib/macro/macro-action-script.hpp new file mode 100644 index 00000000..90fae0c2 --- /dev/null +++ b/lib/macro/macro-action-script.hpp @@ -0,0 +1,29 @@ +#pragma once +#include "macro-action-edit.hpp" +#include "macro-script-handler.hpp" +#include "macro-segment-script.hpp" + +namespace advss { + +class MacroActionScript : public MacroAction, public MacroSegmentScript { +public: + MacroActionScript(Macro *m, const std::string &id, + const OBSData &defaultSettings, + const std::string &propertiesSignalName, + const std::string &triggerSignal, + const std::string &signalComplete); + MacroActionScript(const advss::MacroActionScript &); + bool PerformAction(); + void LogAction() const; + bool Save(obs_data_t *obj) const; + bool Load(obs_data_t *obj); + std::string GetId() const { return _id; }; + std::shared_ptr Copy() const; + +private: + void WaitForCompletion() const; + + std::string _id = ""; +}; + +} // namespace advss diff --git a/lib/macro/macro-condition-factory.cpp b/lib/macro/macro-condition-factory.cpp index 8442eef3..0f654f97 100644 --- a/lib/macro/macro-condition-factory.cpp +++ b/lib/macro/macro-condition-factory.cpp @@ -1,7 +1,24 @@ #include "macro-condition-factory.hpp" +#include + namespace advss { +namespace { + +class MacroConditionUnknown : public MacroCondition { +public: + MacroConditionUnknown(Macro *m) : MacroCondition(m) {} + bool CheckCondition() { return false; } + bool Save(obs_data_t *obj) const { return MacroCondition::Save(obj); }; + bool Load(obs_data_t *obj) { return MacroCondition::Load(obj); }; + std::string GetId() const { return "unknown"; } +}; + +} // namespace + +static std::mutex mutex; + std::map &MacroConditionFactory::GetMap() { static std::map _methods; @@ -11,6 +28,7 @@ std::map &MacroConditionFactory::GetMap() bool MacroConditionFactory::Register(const std::string &id, MacroConditionInfo info) { + std::lock_guard lock(mutex); if (auto it = GetMap().find(id); it == GetMap().end()) { GetMap()[id] = info; return true; @@ -18,35 +36,60 @@ bool MacroConditionFactory::Register(const std::string &id, return false; } +bool MacroConditionFactory::Deregister(const std::string &id) +{ + std::lock_guard lock(mutex); + if (GetMap().count(id) == 0) { + return false; + } + GetMap().erase(id); + return true; +} + +static std::shared_ptr createUnknownCondition(Macro *m) +{ + return std::make_shared(m); +} + std::shared_ptr MacroConditionFactory::Create(const std::string &id, Macro *m) { + std::lock_guard lock(mutex); if (auto it = GetMap().find(id); it != GetMap().end()) { return it->second._create(m); } - return nullptr; + return createUnknownCondition(m); +} + +static QWidget *createUnknownConditionWidget() +{ + return new QLabel( + obs_module_text("AdvSceneSwitcher.condition.unknown")); } QWidget * MacroConditionFactory::CreateWidget(const std::string &id, QWidget *parent, std::shared_ptr cond) { + std::lock_guard lock(mutex); if (auto it = GetMap().find(id); it != GetMap().end()) { return it->second._createWidget(parent, cond); } - return nullptr; + return createUnknownConditionWidget(); } std::string MacroConditionFactory::GetConditionName(const std::string &id) { + std::lock_guard lock(mutex); if (auto it = GetMap().find(id); it != GetMap().end()) { return it->second._name; } - return "unknown condition"; + return obs_module_text("AdvSceneSwitcher.condition.unknown"); } std::string MacroConditionFactory::GetIdByName(const QString &name) { + std::lock_guard lock(mutex); for (auto it : GetMap()) { if (name == obs_module_text(it.second._name.c_str())) { return it.first; @@ -57,6 +100,7 @@ std::string MacroConditionFactory::GetIdByName(const QString &name) bool MacroConditionFactory::UsesDurationModifier(const std::string &id) { + std::lock_guard lock(mutex); if (auto it = GetMap().find(id); it != GetMap().end()) { return it->second._useDurationModifier; } diff --git a/lib/macro/macro-condition-factory.hpp b/lib/macro/macro-condition-factory.hpp index 32defbc9..0a6845b5 100644 --- a/lib/macro/macro-condition-factory.hpp +++ b/lib/macro/macro-condition-factory.hpp @@ -6,10 +6,11 @@ namespace advss { struct MacroConditionInfo { - using CreateCondition = std::shared_ptr (*)(Macro *m); using CreateConditionWidget = QWidget *(*)(QWidget *parent, std::shared_ptr); - CreateCondition _create = nullptr; + + std::function(Macro *m)> _create = + nullptr; CreateConditionWidget _createWidget = nullptr; std::string _name; bool _useDurationModifier = true; @@ -19,6 +20,7 @@ class MacroConditionFactory { public: MacroConditionFactory() = delete; EXPORT static bool Register(const std::string &, MacroConditionInfo); + static bool Deregister(const std::string &); static std::shared_ptr Create(const std::string &, Macro *m); static QWidget *CreateWidget(const std::string &id, QWidget *parent, diff --git a/lib/macro/macro-condition-script.cpp b/lib/macro/macro-condition-script.cpp new file mode 100644 index 00000000..bac1c9cd --- /dev/null +++ b/lib/macro/macro-condition-script.cpp @@ -0,0 +1,79 @@ +#include "macro-condition-script.hpp" +#include "layout-helpers.hpp" +#include "macro-helpers.hpp" +#include "sync-helpers.hpp" + +namespace advss { + +MacroConditionScript::MacroConditionScript( + Macro *m, const std::string &id, const OBSData &defaultSettings, + const std::string &propertiesSignalName, + const std::string &triggerSignal, const std::string &completionSignal) + : MacroCondition(m), + MacroSegmentScript(defaultSettings, propertiesSignalName, + triggerSignal, completionSignal), + _id(id) +{ +} + +MacroConditionScript::MacroConditionScript( + const advss::MacroConditionScript &other) + : MacroCondition(other.GetMacro()), + MacroSegmentScript(other), + _id(other._id) +{ +} + +bool MacroConditionScript::CheckCondition() +{ + if (!ScriptHandler::ConditionIdIsValid(_id)) { + blog(LOG_WARNING, "skipping unknown script condition \"%s\"", + _id.c_str()); + return false; + } + + return SendTriggerSignal(); +} + +bool MacroConditionScript::Save(obs_data_t *obj) const +{ + MacroCondition::Save(obj); + MacroSegmentScript::Save(obj); + return true; +} + +bool MacroConditionScript::Load(obs_data_t *obj) +{ + MacroCondition::Load(obj); + MacroSegmentScript::Load(obj); + return true; +} + +void MacroConditionScript::WaitForCompletion() const +{ + using namespace std::chrono_literals; + auto start = std::chrono::high_resolution_clock::now(); + auto timePassed = std::chrono::duration_cast( + start - start); + const auto timeoutMs = GetTimeoutSeconds() * 1000.0; + + while (!TriggerIsCompleted()) { + if (MacroWaitShouldAbort() || MacroIsStopped(GetMacro())) { + break; + } + + if ((double)timePassed.count() > timeoutMs) { + blog(LOG_INFO, "script condition timeout (%s)", + _id.c_str()); + break; + } + + std::this_thread::sleep_for(10ms); + const auto now = std::chrono::high_resolution_clock::now(); + timePassed = + std::chrono::duration_cast( + now - start); + } +} + +} // namespace advss diff --git a/lib/macro/macro-condition-script.hpp b/lib/macro/macro-condition-script.hpp new file mode 100644 index 00000000..33b01348 --- /dev/null +++ b/lib/macro/macro-condition-script.hpp @@ -0,0 +1,27 @@ +#pragma once +#include "macro-condition-edit.hpp" +#include "macro-script-handler.hpp" +#include "macro-segment-script.hpp" + +namespace advss { + +class MacroConditionScript : public MacroCondition, public MacroSegmentScript { +public: + MacroConditionScript(Macro *m, const std::string &id, + const OBSData &defaultSettings, + const std::string &propertiesSignalName, + const std::string &triggerSignal, + const std::string &signalComplete); + MacroConditionScript(const advss::MacroConditionScript &); + bool CheckCondition(); + bool Save(obs_data_t *obj) const; + bool Load(obs_data_t *obj); + std::string GetId() const { return _id; }; + +private: + void WaitForCompletion() const; + + std::string _id = ""; +}; + +} // namespace advss diff --git a/lib/macro/macro-script-handler.cpp b/lib/macro/macro-script-handler.cpp new file mode 100644 index 00000000..0b7354ec --- /dev/null +++ b/lib/macro/macro-script-handler.cpp @@ -0,0 +1,447 @@ +#include "macro-script-handler.hpp" +#include "macro-action-script.hpp" +#include "macro-condition-script.hpp" +#include "plugin-state-helpers.hpp" +#include "log-helper.hpp" +#include "variable.hpp" + +#include + +namespace advss { + +std::mutex ScriptHandler::_mutex = {}; +std::unordered_map ScriptHandler::_actions = {}; +std::unordered_map ScriptHandler::_conditions = + {}; + +/* Procedure handler helpers */ + +#define RETURN_STATUS(status) \ + { \ + calldata_set_bool(data, "success", status); \ + return; \ + } +#define RETURN_SUCCESS() RETURN_STATUS(true); +#define RETURN_FAILURE() RETURN_STATUS(false); + +static constexpr std::string_view nameParam = "name"; +static constexpr std::string_view defaultSettingsParam = "default_settings"; +static constexpr std::string_view propertiesSignalParam = + "properties_signal_name"; +static constexpr std::string_view triggerSignalParam = "trigger_signal_name"; + +static std::string getRegisterScriptSegmentDeclString(const char *funcName) +{ + return std::string("bool ") + funcName + "(in string " + + nameParam.data() + ", in ptr " + defaultSettingsParam.data() + + ", out string " + propertiesSignalParam.data() + + ", out string " + triggerSignalParam.data() + ")"; +} + +static std::string getDeregisterScriptSegmentDeclString(const char *funcName) +{ + return std::string("bool ") + funcName + "(in string " + + nameParam.data() + ")"; +} + +/* Script actions */ + +static constexpr std::string_view registerActionFuncName = + "advss_register_script_action"; +static constexpr std::string_view deregisterActionFuncName = + "advss_deregister_script_action"; + +static const std::string registerScriptActionDeclString = + getRegisterScriptSegmentDeclString(registerActionFuncName.data()); +static const std::string deregisterScriptActionDeclString = + getDeregisterScriptSegmentDeclString(deregisterActionFuncName.data()); + +/* Script conditions */ + +static constexpr std::string_view registerConditionFuncName = + "advss_register_script_condition"; +static constexpr std::string_view deregisterConditionFuncName = + "advss_deregister_script_condition"; + +static const std::string registerScriptConditionDeclString = + getRegisterScriptSegmentDeclString(registerConditionFuncName.data()); +static const std::string deregisterScriptConditionDeclString = + getDeregisterScriptSegmentDeclString( + deregisterConditionFuncName.data()); + +/* Script variables */ + +static constexpr std::string_view valueParam = "value"; +static constexpr std::string_view getVariableValueFuncName = + "advss_get_variable_value"; +static constexpr std::string_view setVariableValueFuncName = + "advss_set_variable_value"; +static const std::string getVariableValueDeclString = + std::string("bool ") + getVariableValueFuncName.data() + "(in string " + + nameParam.data() + ", out string " + valueParam.data() + ")"; +static const std::string setVariableValueDeclString = + std::string("bool ") + setVariableValueFuncName.data() + "(in string " + + nameParam.data() + ", in string " + valueParam.data() + ")"; + +static bool setup(); +static bool setupDone = setup(); + +static bool setup() +{ + proc_handler_t *ph = obs_get_proc_handler(); + assert(ph != NULL); + + proc_handler_add(ph, registerScriptActionDeclString.c_str(), + &ScriptHandler::RegisterScriptAction, nullptr); + proc_handler_add(ph, deregisterScriptActionDeclString.c_str(), + &ScriptHandler::DeregisterScriptAction, nullptr); + proc_handler_add(ph, registerScriptConditionDeclString.c_str(), + &ScriptHandler::RegisterScriptCondition, nullptr); + proc_handler_add(ph, deregisterScriptConditionDeclString.c_str(), + &ScriptHandler::DeregisterScriptCondition, nullptr); + proc_handler_add(ph, getVariableValueDeclString.c_str(), + &ScriptHandler::GetVariableValue, nullptr); + proc_handler_add(ph, setVariableValueDeclString.c_str(), + &ScriptHandler::SetVariableValue, nullptr); + return true; +} + +static void replaceWhitespace(std::string &string) +{ + std::transform(string.begin(), string.end(), string.begin(), [](char c) { + return std::isspace(static_cast(c)) ? '_' : c; + }); +} + +static std::string nameToScriptID(const std::string &name) +{ + return std::string("script_") + name; +} + +static std::string getTriggerSignal(const std::string &name, + const bool isAction) +{ + std::string signal = name; + replaceWhitespace(signal); + signal += "_run"; + signal += isAction ? "_action" : "_condition"; + return signal; +} + +static std::string getCompletionSignal(const std::string &name, + const bool isAction) +{ + auto signal = getTriggerSignal(name, isAction); + signal += "_complete"; + return signal; +} + +static std::string getPropertiesSignal(const std::string &name, + const bool isAction) +{ + std::string signal = name; + replaceWhitespace(signal); + signal += isAction ? "_action" : "_condition"; + signal += "_get_properties"; + return signal; +} + +void ScriptHandler::RegisterScriptAction(void *, calldata_t *data) +{ + const char *actionName; + if (!calldata_get_string(data, nameParam.data(), &actionName) || + strlen(actionName) == 0) { + blog(LOG_WARNING, "[%s] failed! \"%s\" parameter missing!", + registerScriptActionDeclString.data(), nameParam.data()); + RETURN_FAILURE(); + } + obs_data_t *defaultSettingsPtr = nullptr; + if (!calldata_get_ptr(data, defaultSettingsParam.data(), + &defaultSettingsPtr)) { + blog(LOG_WARNING, "[%s] failed! \"%s\" parameter missing!", + registerActionFuncName.data(), + defaultSettingsParam.data()); + RETURN_FAILURE(); + } + std::lock_guard lock(_mutex); + OBSData defaultSettings(defaultSettingsPtr); + obs_data_release(defaultSettingsPtr); + + if (_actions.count(actionName) > 0) { + blog(LOG_WARNING, "[%s] failed! Action \"%s\" already exists!", + registerActionFuncName.data(), actionName); + RETURN_FAILURE(); + } + + const std::string id = nameToScriptID(actionName); + auto triggerSignalName = getTriggerSignal(actionName, true); + auto completionSignalName = getCompletionSignal(actionName, true); + auto propertiesSignalName = getPropertiesSignal(actionName, true); + + const auto createScriptAction = + [id, defaultSettings, propertiesSignalName, triggerSignalName, + completionSignalName]( + Macro *m) -> std::shared_ptr { + return std::make_shared( + m, id, defaultSettings, propertiesSignalName, + triggerSignalName, completionSignalName); + }; + if (!MacroActionFactory::Register(id, {createScriptAction, + MacroSegmentScriptEdit::Create, + actionName})) { + blog(LOG_WARNING, + "[%s] failed! Action id \"%s\" already exists!", + registerActionFuncName.data(), id.c_str()); + RETURN_FAILURE(); + } + + blog(LOG_INFO, "[%s] successful for \"%s\"", + registerActionFuncName.data(), actionName); + + calldata_set_string(data, triggerSignalParam.data(), + triggerSignalName.c_str()); + calldata_set_string(data, propertiesSignalParam.data(), + propertiesSignalName.c_str()); + _actions.emplace(id, ScriptSegmentType(id, propertiesSignalName, + triggerSignalName, + completionSignalName)); + + RETURN_SUCCESS(); +} + +void ScriptHandler::DeregisterScriptAction(void *, calldata_t *data) +{ + const char *actionName; + if (!calldata_get_string(data, nameParam.data(), &actionName) || + strlen(actionName) == 0) { + blog(LOG_WARNING, "[%s] failed! \"%s\" parameter missing!", + deregisterActionFuncName.data(), nameParam.data()); + RETURN_FAILURE(); + } + + const std::string id = nameToScriptID(actionName); + std::lock_guard lock(_mutex); + + if (_actions.count(id) == 0) { + blog(LOG_WARNING, + "[%s] failed! Action \"%s\" was never registered!", + deregisterActionFuncName.data(), id.c_str()); + RETURN_FAILURE(); + } + + if (!MacroActionFactory::Deregister(id)) { + blog(LOG_WARNING, + "[%s] failed! Action id \"%s\" does not exist!", + deregisterActionFuncName.data(), id.c_str()); + RETURN_FAILURE(); + } + + auto it = _actions.find(id); + if (it != _actions.end()) { + _actions.erase(it); + } + + RETURN_SUCCESS(); +} + +void ScriptHandler::RegisterScriptCondition(void *, calldata_t *data) +{ + const char *conditionName; + if (!calldata_get_string(data, nameParam.data(), &conditionName) || + strlen(conditionName) == 0) { + blog(LOG_WARNING, "[%s] failed! \"%s\" parameter missing!", + registerScriptConditionDeclString.data(), + nameParam.data()); + RETURN_FAILURE(); + } + + obs_data_t *defaultSettingsPtr = nullptr; + if (!calldata_get_ptr(data, defaultSettingsParam.data(), + &defaultSettingsPtr)) { + blog(LOG_WARNING, "[%s] failed! \"%s\" parameter missing!", + registerScriptConditionDeclString.data(), + defaultSettingsParam.data()); + RETURN_FAILURE(); + } + + std::lock_guard lock(_mutex); + OBSData defaultSettings(defaultSettingsPtr); + obs_data_release(defaultSettingsPtr); + + if (_conditions.count(conditionName) > 0) { + blog(LOG_WARNING, + "[%s] failed! Condition \"%s\" already exists!", + registerConditionFuncName.data(), conditionName); + RETURN_FAILURE(); + } + + const std::string id = nameToScriptID(conditionName); + auto triggerSignalName = getTriggerSignal(conditionName, false); + auto completionSignalName = getCompletionSignal(conditionName, false); + auto propertiesSignalName = getPropertiesSignal(conditionName, false); + + const auto createScriptCondition = + [id, defaultSettings, propertiesSignalName, triggerSignalName, + completionSignalName]( + Macro *m) -> std::shared_ptr { + return std::make_shared( + m, id, defaultSettings, propertiesSignalName, + triggerSignalName, completionSignalName); + }; + if (!MacroConditionFactory::Register( + id, {createScriptCondition, MacroSegmentScriptEdit::Create, + conditionName})) { + blog(LOG_WARNING, + "[%s] failed! Condition id \"%s\" already exists!", + registerConditionFuncName.data(), id.c_str()); + RETURN_FAILURE(); + } + + blog(LOG_INFO, "[%s] successful for \"%s\"", + registerConditionFuncName.data(), conditionName); + + calldata_set_string(data, triggerSignalParam.data(), + triggerSignalName.c_str()); + calldata_set_string(data, propertiesSignalParam.data(), + propertiesSignalName.c_str()); + _conditions.emplace(id, ScriptSegmentType(id, propertiesSignalName, + triggerSignalName, + completionSignalName)); + + RETURN_SUCCESS(); +} + +void ScriptHandler::DeregisterScriptCondition(void *, calldata_t *data) +{ + const char *conditionName; + if (!calldata_get_string(data, nameParam.data(), &conditionName) || + strlen(conditionName) == 0) { + blog(LOG_WARNING, "[%s] failed! \"%s\" parameter missing!", + deregisterConditionFuncName.data(), nameParam.data()); + RETURN_FAILURE(); + } + + const std::string id = nameToScriptID(conditionName); + std::lock_guard lock(_mutex); + + if (_conditions.count(id) == 0) { + blog(LOG_WARNING, + "[%s] failed! Condition \"%s\" was never registered!", + deregisterConditionFuncName.data(), id.c_str()); + RETURN_FAILURE(); + } + + if (!MacroConditionFactory::Deregister(id)) { + blog(LOG_WARNING, + "[%s] failed! Condition id \"%s\" does not exist!", + deregisterConditionFuncName.data(), id.c_str()); + RETURN_FAILURE(); + } + + auto it = _conditions.find(id); + if (it != _conditions.end()) { + _conditions.erase(it); + } + + RETURN_SUCCESS(); +} + +void ScriptHandler::GetVariableValue(void *, calldata_t *data) +{ + const char *variableName; + if (!calldata_get_string(data, nameParam.data(), &variableName) || + strlen(variableName) == 0) { + blog(LOG_WARNING, "[%s] failed! \"%s\" parameter missing!", + getVariableValueFuncName.data(), nameParam.data()); + RETURN_FAILURE(); + } + + auto weakVariable = GetWeakVariableByName(variableName); + auto variable = weakVariable.lock(); + if (!variable) { + blog(LOG_WARNING, + "[%s] failed! \"%s\" variable does not exist!", + getVariableValueFuncName.data(), nameParam.data()); + RETURN_FAILURE(); + } + + calldata_set_string(data, valueParam.data(), variable->Value().c_str()); + RETURN_SUCCESS(); +} + +void ScriptHandler::SetVariableValue(void *, calldata_t *data) +{ + const char *variableName; + if (!calldata_get_string(data, nameParam.data(), &variableName) || + strlen(variableName) == 0) { + blog(LOG_WARNING, "[%s] failed! \"%s\" parameter missing!", + getVariableValueFuncName.data(), nameParam.data()); + RETURN_FAILURE(); + } + const char *variableValue; + if (!calldata_get_string(data, valueParam.data(), &variableValue) || + strlen(variableValue) == 0) { + blog(LOG_WARNING, "[%s] failed! \"%s\" parameter missing!", + getVariableValueFuncName.data(), valueParam.data()); + RETURN_FAILURE(); + } + + auto weakVariable = GetWeakVariableByName(variableName); + auto variable = weakVariable.lock(); + if (!variable) { + blog(LOG_WARNING, + "[%s] failed! \"%s\" variable does not exist!", + getVariableValueFuncName.data(), nameParam.data()); + RETURN_FAILURE(); + } + + variable->SetValue(variableValue); + RETURN_SUCCESS(); +} + +bool ScriptHandler::ActionIdIsValid(const std::string &id) +{ + std::lock_guard lock(_mutex); + return _actions.count(id) > 0; +} + +bool ScriptHandler::ConditionIdIsValid(const std::string &id) +{ + std::lock_guard lock(_mutex); + return _conditions.count(id) > 0; +} + +static std::string signalNameToTriggerSignalDecl(const std::string &name) +{ + return std::string("void ") + name + "()"; +} + +static std::string signalNameToPropertiesSignalDecl(const std::string &name) +{ + return std::string("void ") + name + "(in ptr " + + GetPropertiesSignalParamName().data() + ")"; +} +static std::string signalNameToCompletionSignalDecl(const std::string &name) +{ + return std::string("bool ") + name + "(in int " + + GeCompletionIdParamName().data() + ")"; +} + +ScriptSegmentType::ScriptSegmentType(const std::string &id, + const std::string &propertiesSignal, + const std::string &triggerSignal, + const std::string &completionSignal) + : _id(id) +{ + signal_handler_add( + obs_get_signal_handler(), + signalNameToPropertiesSignalDecl(propertiesSignal).c_str()); + signal_handler_add( + obs_get_signal_handler(), + signalNameToTriggerSignalDecl(triggerSignal).c_str()); + signal_handler_add( + obs_get_signal_handler(), + signalNameToCompletionSignalDecl(completionSignal).c_str()); +} + +} // namespace advss diff --git a/lib/macro/macro-script-handler.hpp b/lib/macro/macro-script-handler.hpp new file mode 100644 index 00000000..ca66cc50 --- /dev/null +++ b/lib/macro/macro-script-handler.hpp @@ -0,0 +1,59 @@ +#pragma once +#include +#include +#include +#include + +namespace advss { + +class ScriptSegmentType { +public: + ScriptSegmentType() = delete; + ScriptSegmentType(const std::string &id, + const std::string &propertiesSignal, + const std::string &triggerSignal, + const std::string &completionSignal); + +private: + std::string _id; +}; + +class ScriptHandler { +public: + ScriptHandler() = delete; + static void RegisterScriptAction(void *ctx, calldata_t *data); + static void DeregisterScriptAction(void *ctx, calldata_t *data); + static void RegisterScriptCondition(void *ctx, calldata_t *data); + static void DeregisterScriptCondition(void *ctx, calldata_t *data); + static void GetVariableValue(void *ctx, calldata_t *data); + static void SetVariableValue(void *ctx, calldata_t *data); + static bool ActionIdIsValid(const std::string &id); + static bool ConditionIdIsValid(const std::string &id); + +private: + static std::mutex _mutex; + static std::unordered_map _actions; + static std::unordered_map _conditions; +}; + +static constexpr std::string_view GetPropertiesSignalParamName() +{ + return "properties"; +} + +static constexpr std::string_view GetActionCompletionSignalParamName() +{ + return "completion_signal_name"; +} + +static constexpr std::string_view GeCompletionIdParamName() +{ + return "completion_id"; +} + +static constexpr std::string_view GeResultSignalParamName() +{ + return "result"; +} + +} // namespace advss diff --git a/lib/macro/macro-segment-script.cpp b/lib/macro/macro-segment-script.cpp new file mode 100644 index 00000000..d97ebb07 --- /dev/null +++ b/lib/macro/macro-segment-script.cpp @@ -0,0 +1,219 @@ +#include "macro-segment-script.hpp" +#include "layout-helpers.hpp" +#include "macro-action.hpp" +#include "macro-condition.hpp" +#include "macro-helpers.hpp" +#include "macro-script-handler.hpp" +#include "obs-module-helper.hpp" +#include "properties-view.hpp" +#include "sync-helpers.hpp" + +namespace advss { + +static std::atomic_int completionIdCounter = 0; + +MacroSegmentScript::MacroSegmentScript(obs_data_t *defaultSettings, + const std::string &propertiesSignalName, + const std::string &triggerSignal, + const std::string &completionSignal) + : _settings(obs_data_get_defaults(defaultSettings)), + _propertiesSignal(propertiesSignalName), + _triggerSignal(triggerSignal), + _completionSignal(completionSignal) +{ + signal_handler_connect(obs_get_signal_handler(), + completionSignal.c_str(), + &MacroSegmentScript::CompletionSignalReceived, + this); +} + +MacroSegmentScript::MacroSegmentScript(const MacroSegmentScript &other) + : _settings(obs_data_create()), + _propertiesSignal(other._propertiesSignal), + _triggerSignal(other._triggerSignal), + _completionSignal(other._completionSignal) +{ + signal_handler_connect(obs_get_signal_handler(), + _completionSignal.c_str(), + &MacroSegmentScript::CompletionSignalReceived, + this); + obs_data_apply(_settings.Get(), other._settings.Get()); +} + +bool MacroSegmentScript::Save(obs_data_t *obj) const +{ + obs_data_set_obj(obj, "settings", _settings.Get()); + _timeout.Save(obj); + return true; +} + +bool MacroSegmentScript::Load(obs_data_t *obj) +{ + OBSDataAutoRelease settings = obs_data_get_obj(obj, "settings"); + obs_data_apply(_settings.Get(), settings); + _timeout.Load(obj); + return true; +} + +obs_properties_t *MacroSegmentScript::GetProperties() const +{ + auto data = calldata_create(); + signal_handler_signal(obs_get_signal_handler(), + _propertiesSignal.c_str(), data); + obs_properties_t *properties = nullptr; + if (!calldata_get_ptr(data, GetPropertiesSignalParamName().data(), + &properties)) { + calldata_destroy(data); + return nullptr; + } + calldata_destroy(data); + return properties; +} + +void MacroSegmentScript::UpdateSettings(obs_data_t *newSettings) const +{ + obs_data_clear(_settings.Get()); + obs_data_apply(_settings.Get(), newSettings); +} + +bool MacroSegmentScript::SendTriggerSignal() +{ + _completionId = ++completionIdCounter; + _triggerIsComplete = false; + _triggerResult = false; + + auto data = calldata_create(); + calldata_set_string(data, GetActionCompletionSignalParamName().data(), + _completionSignal.c_str()); + calldata_set_int(data, GeCompletionIdParamName().data(), _completionId); + calldata_set_string(data, "settings", obs_data_get_json(GetSettings())); + signal_handler_signal(obs_get_signal_handler(), _triggerSignal.c_str(), + data); + calldata_destroy(data); + + SetMacroAbortWait(false); + WaitForCompletion(); + + return _triggerResult; +} + +void MacroSegmentScript::CompletionSignalReceived(void *param, calldata_t *data) +{ + auto segment = static_cast(param); + long long int id; + if (!calldata_get_int(data, GeCompletionIdParamName().data(), &id)) { + blog(LOG_WARNING, + "received completion signal without \"%s\" parameter", + GeCompletionIdParamName().data()); + return; + } + bool result; + if (!calldata_get_bool(data, GeResultSignalParamName().data(), + &result)) { + blog(LOG_WARNING, + "received completion signal without \"%s\" parameter", + GeResultSignalParamName().data()); + return; + } + if (id != segment->_completionId) { + return; + } + segment->_triggerIsComplete = true; + segment->_triggerResult = result; +} + +obs_properties_t *MacroSegmentScriptEdit::GetProperties(void *obj) +{ + auto segmentEdit = reinterpret_cast(obj); + if (!segmentEdit) { + return nullptr; + } + return segmentEdit->_entryData->GetProperties(); +} + +void MacroSegmentScriptEdit::UpdateSettings(void *obj, obs_data_t *settings) +{ + auto segmentEdit = reinterpret_cast(obj); + if (!segmentEdit || segmentEdit->_loading || !segmentEdit->_entryData) { + return; + } + auto lock = LockContext(); + segmentEdit->_entryData->UpdateSettings(settings); +} + +MacroSegmentScriptEdit::MacroSegmentScriptEdit( + QWidget *parent, std::shared_ptr entryData) + : QWidget(parent), + _timeout(new DurationSelection(this)) +{ + QWidget::connect(_timeout, &DurationSelection::DurationChanged, this, + &MacroSegmentScriptEdit::TimeoutChanged); + + auto timeoutLayout = new QHBoxLayout(); + PlaceWidgets(obs_module_text("AdvSceneSwitcher.script.timeout"), + timeoutLayout, {{"{{timeout}}", _timeout}}); + + auto layout = new QVBoxLayout(); + + auto properties = entryData->GetProperties(); + if (!!properties) { + obs_properties_destroy(properties); + + // We need a separate OBSData object here as we can't risk + // entryData->_settings being modified while it is currently used + OBSDataAutoRelease data = obs_data_create(); + obs_data_apply(data, entryData->GetSettings()); + +#if LIBOBS_API_VER >= MAKE_SEMANTIC_VERSION(30, 0, 0) + auto propertiesView = + new OBSPropertiesView(data.Get(), this, GetProperties, + nullptr, UpdateSettings); + layout->addWidget(propertiesView); + connect(propertiesView, &OBSPropertiesView::PropertiesResized, + this, [this]() { + adjustSize(); + updateGeometry(); + }); +#else + layout->addWidget(new QLabel( + "Displaying script properties not supported when compiled for OBS 29!")); +#endif + } + + layout->addLayout(timeoutLayout); + setLayout(layout); + + _entryData = entryData; + UpdateEntryData(); + _loading = false; + + adjustSize(); + updateGeometry(); +} + +void MacroSegmentScriptEdit::UpdateEntryData() +{ + _timeout->SetDuration(_entryData->_timeout); +} + +QWidget *MacroSegmentScriptEdit::Create(QWidget *parent, + std::shared_ptr segment) +{ + return new MacroSegmentScriptEdit( + parent, std::dynamic_pointer_cast(segment)); +} + +QWidget *MacroSegmentScriptEdit::Create(QWidget *parent, + std::shared_ptr segment) +{ + return new MacroSegmentScriptEdit( + parent, std::dynamic_pointer_cast(segment)); +} + +void MacroSegmentScriptEdit::TimeoutChanged(const Duration &timeout) +{ + GUARD_LOADING_AND_LOCK(); + _entryData->_timeout = timeout; +} + +} // namespace advss diff --git a/lib/macro/macro-segment-script.hpp b/lib/macro/macro-segment-script.hpp new file mode 100644 index 00000000..0bf9dda5 --- /dev/null +++ b/lib/macro/macro-segment-script.hpp @@ -0,0 +1,78 @@ +#pragma once +#include "duration-control.hpp" +#include "macro-script-handler.hpp" + +#include +#include + +namespace advss { + +class Macro; +class MacroAction; +class MacroCondition; + +class MacroSegmentScript { +public: + MacroSegmentScript(obs_data_t *defaultSettings, + const std::string &propertiesSignalName, + const std::string &triggerSignal, + const std::string &completionSignal); + MacroSegmentScript(const advss::MacroSegmentScript &); + +protected: + bool Save(obs_data_t *obj) const; + bool Load(obs_data_t *obj); + + obs_properties_t *GetProperties() const; + OBSData GetSettings() const { return _settings.Get(); } + void UpdateSettings(obs_data_t *newSettings) const; + + bool SendTriggerSignal(); + double GetTimeoutSeconds() const { return _timeout.Seconds(); }; + bool TriggerIsCompleted() const { return _triggerIsComplete; } + +private: + virtual void WaitForCompletion() const = 0; + static void CompletionSignalReceived(void *param, calldata_t *data); + + OBSDataAutoRelease _settings; + std::string _propertiesSignal = ""; + + std::string _triggerSignal = ""; + std::string _completionSignal = ""; + std::atomic_bool _triggerIsComplete = {false}; + bool _triggerResult = false; + int64_t _completionId = 0; + + Duration _timeout = Duration(10.0); + + friend class MacroSegmentScriptEdit; +}; + +class MacroSegmentScriptEdit : public QWidget { + Q_OBJECT + +public: + MacroSegmentScriptEdit( + QWidget *parent, + std::shared_ptr entryData = nullptr); + void UpdateEntryData(); + static QWidget *Create(QWidget *parent, + std::shared_ptr segment); + static QWidget *Create(QWidget *parent, + std::shared_ptr segment); + +private slots: + void TimeoutChanged(const Duration &); + +private: + static obs_properties_t *GetProperties(void *obj); + static void UpdateSettings(void *obj, obs_data_t *settings); + + DurationSelection *_timeout; + + std::shared_ptr _entryData; + bool _loading = true; +}; + +} // namespace advss diff --git a/lib/utils/properties-view.cpp b/lib/utils/properties-view.cpp new file mode 100644 index 00000000..de607523 --- /dev/null +++ b/lib/utils/properties-view.cpp @@ -0,0 +1,2540 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "double-slider.hpp" +#include "properties-view.hpp" +#include "properties-view.moc.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "obs-module-helper.hpp" +#include "ui-helpers.hpp" + +#if LIBOBS_API_VER >= MAKE_SEMANTIC_VERSION(30, 0, 0) + +#define QTStr(str) QString::fromUtf8(obs_module_text(str)) +#define QT_UTF8(str) QString::fromUtf8(str, -1) +#define QT_TO_UTF8(str) str.toUtf8().constData() + +using namespace std; + +namespace std { + +template<> struct default_delete { + void operator()(obs_data_t *data) { obs_data_release(data); } +}; + +template<> struct default_delete { + void operator()(obs_data_item_t *item) { obs_data_item_release(&item); } +}; + +} // namespace std + +namespace { + +struct frame_rate_tag { + enum tag_type { + SIMPLE, + RATIONAL, + USER, + } type = SIMPLE; + const char *val = nullptr; + + frame_rate_tag() = default; + + explicit frame_rate_tag(tag_type type) : type(type) {} + + explicit frame_rate_tag(const char *val) : type(USER), val(val) {} + + static frame_rate_tag simple() { return frame_rate_tag{SIMPLE}; } + static frame_rate_tag rational() { return frame_rate_tag{RATIONAL}; } +}; + +struct common_frame_rate { + const char *fps_name; + media_frames_per_second fps; +}; + +} // namespace + +Q_DECLARE_METATYPE(frame_rate_tag); +Q_DECLARE_METATYPE(media_frames_per_second); + +namespace advss { + +QWidget *GetSettingsWindow(); + +static QString SelectDirectory(QWidget *parent, QString title, QString path) +{ + QString dir = QFileDialog::getExistingDirectory( + parent, title, path, + QFileDialog::ShowDirsOnly | QFileDialog::DontResolveSymlinks); + + return dir; +} + +static QString SaveFile(QWidget *parent, QString title, QString path, + QString extensions) +{ + QString file = + QFileDialog::getSaveFileName(parent, title, path, extensions); + + return file; +} + +static QString OpenFile(QWidget *parent, QString title, QString path, + QString extensions) +{ + QString file = + QFileDialog::getOpenFileName(parent, title, path, extensions); + + return file; +} + +static QStringList OpenFiles(QWidget *parent, QString title, QString path, + QString extensions) +{ + QStringList files = + QFileDialog::getOpenFileNames(parent, title, path, extensions); + + return files; +} + +VScrollArea::VScrollArea(QWidget *parent) : QScrollArea(parent) +{ + setObjectName("VScrollArea"); + setStyleSheet("#VScrollArea {" + "background-color: transparent;" + "}"); + setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); +} + +void VScrollArea::resizeEvent(QResizeEvent *event) +{ + if (!!widget()) + widget()->setMaximumWidth(event->size().width()); + + QScrollArea::resizeEvent(event); +} + +SpinBoxIgnoreScroll::SpinBoxIgnoreScroll(QWidget *parent) : QSpinBox(parent) +{ + setFocusPolicy(Qt::StrongFocus); +} + +OBSPlainTextEdit::OBSPlainTextEdit(QWidget *parent, bool monospace) + : QPlainTextEdit(parent) +{ + // Fix display of tabs & multiple spaces + document()->setDefaultStyleSheet("font { white-space: pre; }"); + + if (monospace) { + const QFont fixedFont = + QFontDatabase::systemFont(QFontDatabase::FixedFont); + + setStyleSheet( + QString("font-family: %1; font-size: %2pt;") + .arg(fixedFont.family(), + QString::number(fixedFont.pointSize()))); + } +} + +void SpinBoxIgnoreScroll::wheelEvent(QWheelEvent *event) +{ + if (!hasFocus()) + event->ignore(); + else + QSpinBox::wheelEvent(event); +} + +static inline QColor color_from_int(long long val) +{ + return QColor(val & 0xff, (val >> 8) & 0xff, (val >> 16) & 0xff, + (val >> 24) & 0xff); +} + +static inline long long color_to_int(QColor color) +{ + auto shift = [&](unsigned val, int shift) { + return ((val & 0xff) << shift); + }; + + return shift(color.red(), 0) | shift(color.green(), 8) | + shift(color.blue(), 16) | shift(color.alpha(), 24); +} + +void OBSPropertiesView::ReloadProperties() +{ + if (weakObj || rawObj) { + OBSObject strongObj = GetObject(); + void *obj = strongObj ? strongObj.Get() : rawObj; + if (obj) + properties.reset(reloadCallback(obj)); + } else { + properties.reset(reloadCallback((void *)type.c_str())); + obs_properties_apply_settings(properties.get(), settings); + } + + uint32_t flags = obs_properties_get_flags(properties.get()); + deferUpdate = enableDefer && (flags & OBS_PROPERTIES_DEFER_UPDATE) != 0; + + RefreshProperties(); +} + +#define NO_PROPERTIES_STRING QTStr("Basic.PropertiesWindow.NoProperties") + +void OBSPropertiesView::RefreshProperties() +{ + int h, v, hend, vend; + GetScrollPos(h, v, hend, vend); + + children.clear(); + if (widget) + widget->deleteLater(); + + widget = new QWidget(); + widget->setObjectName("PropertiesContainer"); + widget->setStyleSheet("#PropertiesContainer {" + "background-color: transparent;" + "}"); + + QFormLayout *layout = new QFormLayout; + layout->setFieldGrowthPolicy(QFormLayout::AllNonFixedFieldsGrow); + widget->setLayout(layout); + + QSizePolicy mainPolicy(QSizePolicy::MinimumExpanding, + QSizePolicy::MinimumExpanding); + + layout->setLabelAlignment(Qt::AlignRight); + + obs_property_t *property = obs_properties_first(properties.get()); + bool hasNoProperties = !property; + + while (property) { + AddProperty(property, layout); + obs_property_next(&property); + } + + setWidgetResizable(true); + setWidget(widget); + setSizePolicy(mainPolicy); + adjustSize(); + SetScrollPos(h, v, hend, vend); + + lastFocused.clear(); + if (lastWidget) { + lastWidget->setFocus(Qt::OtherFocusReason); + lastWidget = nullptr; + } + + if (hasNoProperties) { + QLabel *noPropertiesLabel = new QLabel(NO_PROPERTIES_STRING); + layout->addWidget(noPropertiesLabel); + } + + emit PropertiesRefreshed(); +} + +void OBSPropertiesView::SetScrollPos(int h, int v, int old_hend, int old_vend) +{ + QScrollBar *scroll = horizontalScrollBar(); + if (scroll) { + int hend = scroll->maximum() + scroll->pageStep(); + scroll->setValue(h * hend / old_hend); + } + + scroll = verticalScrollBar(); + if (scroll) { + int vend = scroll->maximum() + scroll->pageStep(); + scroll->setValue(v * vend / old_vend); + } +} + +void OBSPropertiesView::GetScrollPos(int &h, int &v, int &hend, int &vend) +{ + h = v = 0; + + QScrollBar *scroll = horizontalScrollBar(); + if (scroll) { + h = scroll->value(); + hend = scroll->maximum() + scroll->pageStep(); + } + + scroll = verticalScrollBar(); + if (scroll) { + v = scroll->value(); + vend = scroll->maximum() + scroll->pageStep(); + } +} + +OBSPropertiesView::OBSPropertiesView(OBSData settings_, obs_object_t *obj, + PropertiesReloadCallback reloadCallback, + PropertiesUpdateCallback callback_, + PropertiesVisualUpdateCb visUpdateCb_, + int minSize_) + : VScrollArea(nullptr), + properties(nullptr, obs_properties_destroy), + settings(settings_), + weakObj(obs_object_get_weak_object(obj)), + reloadCallback(reloadCallback), + callback(callback_), + visUpdateCb(visUpdateCb_), + minSize(minSize_) +{ + setFrameShape(QFrame::NoFrame); + QMetaObject::invokeMethod(this, "ReloadProperties", + Qt::QueuedConnection); +} + +OBSPropertiesView::OBSPropertiesView(OBSData settings_, void *obj, + PropertiesReloadCallback reloadCallback, + PropertiesUpdateCallback callback_, + PropertiesVisualUpdateCb visUpdateCb_, + int minSize_) + : VScrollArea(nullptr), + properties(nullptr, obs_properties_destroy), + settings(settings_), + rawObj(obj), + reloadCallback(reloadCallback), + callback(callback_), + visUpdateCb(visUpdateCb_), + minSize(minSize_) +{ + setFrameShape(QFrame::NoFrame); + QMetaObject::invokeMethod(this, "ReloadProperties", + Qt::QueuedConnection); +} + +OBSPropertiesView::OBSPropertiesView(OBSData settings_, const char *type_, + PropertiesReloadCallback reloadCallback_, + int minSize_) + : VScrollArea(nullptr), + properties(nullptr, obs_properties_destroy), + settings(settings_), + type(type_), + reloadCallback(reloadCallback_), + minSize(minSize_) +{ + setFrameShape(QFrame::NoFrame); + QMetaObject::invokeMethod(this, "ReloadProperties", + Qt::QueuedConnection); +} + +void OBSPropertiesView::resizeEvent(QResizeEvent *event) +{ + emit PropertiesResized(); + VScrollArea::resizeEvent(event); +} + +template +QWidget *OBSPropertiesView::NewWidget(obs_property_t *prop, Sender *widget, + void (SenderParent::*signal)(Args...)) +{ + const char *long_desc = obs_property_long_description(prop); + + WidgetInfo *info = new WidgetInfo(this, prop, widget); + QObject::connect(widget, signal, info, &WidgetInfo::ControlChanged); + children.emplace_back(info); + + widget->setToolTip(QT_UTF8(long_desc)); + return widget; +} + +QWidget *OBSPropertiesView::AddCheckbox(obs_property_t *prop) +{ + const char *name = obs_property_name(prop); + const char *desc = obs_property_description(prop); + bool val = obs_data_get_bool(settings, name); + + QCheckBox *checkbox = new QCheckBox(QT_UTF8(desc)); + checkbox->setCheckState(val ? Qt::Checked : Qt::Unchecked); + return NewWidget(prop, checkbox, &QCheckBox::stateChanged); +} + +QWidget *OBSPropertiesView::AddText(obs_property_t *prop, QFormLayout *layout, + QLabel *&label) +{ + const char *name = obs_property_name(prop); + const char *val = obs_data_get_string(settings, name); + bool monospace = obs_property_text_monospace(prop); + obs_text_type type = obs_property_text_type(prop); + + if (type == OBS_TEXT_MULTILINE) { + OBSPlainTextEdit *edit = new OBSPlainTextEdit(this, monospace); + edit->setPlainText(QT_UTF8(val)); + edit->setTabStopDistance(40); + return NewWidget(prop, edit, &OBSPlainTextEdit::textChanged); + + } else if (type == OBS_TEXT_PASSWORD) { + QLayout *subLayout = new QHBoxLayout(); + QLineEdit *edit = new QLineEdit(); + QPushButton *show = new QPushButton(); + + show->setText(QTStr("Show")); + show->setCheckable(true); + edit->setText(QT_UTF8(val)); + edit->setEchoMode(QLineEdit::Password); + + subLayout->addWidget(edit); + subLayout->addWidget(show); + + WidgetInfo *info = new WidgetInfo(this, prop, edit); + connect(show, &QAbstractButton::toggled, info, + &WidgetInfo::TogglePasswordText); + connect(show, &QAbstractButton::toggled, [=](bool hide) { + show->setText(hide ? QTStr("Hide") : QTStr("Show")); + }); + children.emplace_back(info); + + label = new QLabel(QT_UTF8(obs_property_description(prop))); + layout->addRow(label, subLayout); + + edit->setToolTip(QT_UTF8(obs_property_long_description(prop))); + + connect(edit, &QLineEdit::textEdited, info, + &WidgetInfo::ControlChanged); + return nullptr; + } else if (type == OBS_TEXT_INFO) { + QString desc = QT_UTF8(obs_property_description(prop)); + const char *long_desc = obs_property_long_description(prop); + obs_text_info_type info_type = + obs_property_text_info_type(prop); + + QLabel *info_label = new QLabel(QT_UTF8(val)); + + if (info_label->text().isEmpty() && long_desc == NULL) { + label = nullptr; + info_label->setText(desc); + } else + label = new QLabel(desc); + + if (long_desc != NULL && !info_label->text().isEmpty()) { + QString file = !obs_frontend_is_theme_dark() + ? ":/res/images/help.svg" + : ":/res/images/help_light.svg"; + QString lStr = "%1 "; + + info_label->setText(lStr.arg(info_label->text(), file)); + info_label->setToolTip(QT_UTF8(long_desc)); + } else if (long_desc != NULL) { + info_label->setText(QT_UTF8(long_desc)); + } + + info_label->setOpenExternalLinks(true); + info_label->setWordWrap(obs_property_text_info_word_wrap(prop)); + + if (info_type == OBS_TEXT_INFO_WARNING) + info_label->setObjectName("warningLabel"); + else if (info_type == OBS_TEXT_INFO_ERROR) + info_label->setObjectName("errorLabel"); + + if (label) + label->setObjectName(info_label->objectName()); + + WidgetInfo *info = new WidgetInfo(this, prop, info_label); + children.emplace_back(info); + + layout->addRow(label, info_label); + + return nullptr; + } + + QLineEdit *edit = new QLineEdit(); + + edit->setText(QT_UTF8(val)); + edit->setToolTip(QT_UTF8(obs_property_long_description(prop))); + + return NewWidget(prop, edit, &QLineEdit::textEdited); +} + +void OBSPropertiesView::AddPath(obs_property_t *prop, QFormLayout *layout, + QLabel **label) +{ + const char *name = obs_property_name(prop); + const char *val = obs_data_get_string(settings, name); + QLayout *subLayout = new QHBoxLayout(); + QLineEdit *edit = new QLineEdit(); + QPushButton *button = new QPushButton(QTStr("Browse")); + + if (!obs_property_enabled(prop)) { + edit->setEnabled(false); + button->setEnabled(false); + } + + button->setProperty("themeID", "settingsButtons"); + edit->setText(QT_UTF8(val)); + edit->setReadOnly(true); + edit->setToolTip(QT_UTF8(obs_property_long_description(prop))); + + subLayout->addWidget(edit); + subLayout->addWidget(button); + + WidgetInfo *info = new WidgetInfo(this, prop, edit); + connect(button, &QPushButton::clicked, info, + &WidgetInfo::ControlChanged); + children.emplace_back(info); + + *label = new QLabel(QT_UTF8(obs_property_description(prop))); + layout->addRow(*label, subLayout); +} + +void OBSPropertiesView::AddInt(obs_property_t *prop, QFormLayout *layout, + QLabel **label) +{ + obs_number_type type = obs_property_int_type(prop); + QLayout *subLayout = new QHBoxLayout(); + + const char *name = obs_property_name(prop); + int val = (int)obs_data_get_int(settings, name); + QSpinBox *spin = new SpinBoxIgnoreScroll(); + + spin->setEnabled(obs_property_enabled(prop)); + + int minVal = obs_property_int_min(prop); + int maxVal = obs_property_int_max(prop); + int stepVal = obs_property_int_step(prop); + const char *suffix = obs_property_int_suffix(prop); + + spin->setMinimum(minVal); + spin->setMaximum(maxVal); + spin->setSingleStep(stepVal); + spin->setValue(val); + spin->setToolTip(QT_UTF8(obs_property_long_description(prop))); + spin->setSuffix(QT_UTF8(suffix)); + + WidgetInfo *info = new WidgetInfo(this, prop, spin); + children.emplace_back(info); + + if (type == OBS_NUMBER_SLIDER) { + QSlider *slider = new SliderIgnoreScroll(); + slider->setMinimum(minVal); + slider->setMaximum(maxVal); + slider->setPageStep(stepVal); + slider->setValue(val); + slider->setOrientation(Qt::Horizontal); + slider->setEnabled(obs_property_enabled(prop)); + subLayout->addWidget(slider); + + connect(slider, &QSlider::valueChanged, spin, + &QSpinBox::setValue); + connect(spin, &QSpinBox::valueChanged, slider, + &QSlider::setValue); + } + + connect(spin, &QSpinBox::valueChanged, info, + &WidgetInfo::ControlChanged); + + subLayout->addWidget(spin); + + *label = new QLabel(QT_UTF8(obs_property_description(prop))); + layout->addRow(*label, subLayout); +} + +void OBSPropertiesView::AddFloat(obs_property_t *prop, QFormLayout *layout, + QLabel **label) +{ + obs_number_type type = obs_property_float_type(prop); + QLayout *subLayout = new QHBoxLayout(); + + const char *name = obs_property_name(prop); + double val = obs_data_get_double(settings, name); + QDoubleSpinBox *spin = new QDoubleSpinBox(); + + if (!obs_property_enabled(prop)) + spin->setEnabled(false); + + double minVal = obs_property_float_min(prop); + double maxVal = obs_property_float_max(prop); + double stepVal = obs_property_float_step(prop); + const char *suffix = obs_property_float_suffix(prop); + + if (stepVal < 1.0) { + constexpr int sane_limit = 8; + const int decimals = ::std::min( + log10(1.0 / stepVal) + 0.99, sane_limit); + if (decimals > spin->decimals()) + spin->setDecimals(decimals); + } + + spin->setMinimum(minVal); + spin->setMaximum(maxVal); + spin->setSingleStep(stepVal); + spin->setValue(val); + spin->setToolTip(QT_UTF8(obs_property_long_description(prop))); + spin->setSuffix(QT_UTF8(suffix)); + + WidgetInfo *info = new WidgetInfo(this, prop, spin); + children.emplace_back(info); + + if (type == OBS_NUMBER_SLIDER) { + DoubleSlider *slider = new DoubleSlider(); + slider->SetDoubleConstraints(minVal, maxVal, stepVal, val); + slider->setOrientation(Qt::Horizontal); + subLayout->addWidget(slider); + + connect(slider, &DoubleSlider::DoubleValChanged, spin, + &QDoubleSpinBox::setValue); + connect(spin, &QDoubleSpinBox::valueChanged, slider, + &DoubleSlider::SetDoubleVal); + } + + connect(spin, &QDoubleSpinBox::valueChanged, info, + &WidgetInfo::ControlChanged); + + subLayout->addWidget(spin); + + *label = new QLabel(QT_UTF8(obs_property_description(prop))); + layout->addRow(*label, subLayout); +} + +static QVariant propertyListToQVariant(obs_property_t *prop, size_t idx) +{ + obs_combo_format format = obs_property_list_format(prop); + + QVariant var; + if (format == OBS_COMBO_FORMAT_INT) { + long long val = obs_property_list_item_int(prop, idx); + var = QVariant::fromValue(val); + } else if (format == OBS_COMBO_FORMAT_FLOAT) { + double val = obs_property_list_item_float(prop, idx); + var = QVariant::fromValue(val); + } else if (format == OBS_COMBO_FORMAT_STRING) { + var = QByteArray(obs_property_list_item_string(prop, idx)); + } else if (format == OBS_COMBO_FORMAT_BOOL) { + bool val = obs_property_list_item_bool(prop, idx); + var = QVariant::fromValue(val); + } + return var; +} + +static void AddComboItem(QComboBox *combo, obs_property_t *prop, size_t idx) +{ + const char *name = obs_property_list_item_name(prop, idx); + QVariant var = propertyListToQVariant(prop, idx); + + combo->addItem(QT_UTF8(name), var); + + if (!obs_property_list_item_disabled(prop, idx)) + return; + + int index = combo->findText(QT_UTF8(name)); + if (index < 0) + return; + + QStandardItemModel *model = + dynamic_cast(combo->model()); + if (!model) + return; + + QStandardItem *item = model->item(index); + item->setFlags(Qt::NoItemFlags); +} + +static void AddRadioItem(QButtonGroup *buttonGroup, QFormLayout *layout, + obs_property_t *prop, QVariant value, size_t idx) +{ + const char *name = obs_property_list_item_name(prop, idx); + + QVariant var = propertyListToQVariant(prop, idx); + QRadioButton *button = new QRadioButton(name); + button->setChecked(value == var); + button->setProperty("value", var); + buttonGroup->addButton(button); + layout->addRow(button); +} + +template +static QVariant from_obs_data(obs_data_t *data, const char *name, + obs_combo_format format) +{ + switch (format) { + case OBS_COMBO_FORMAT_INT: + return QVariant::fromValue(get_int(data, name)); + case OBS_COMBO_FORMAT_FLOAT: + return QVariant::fromValue(get_double(data, name)); + case OBS_COMBO_FORMAT_STRING: + return QByteArray(get_string(data, name)); + case OBS_COMBO_FORMAT_BOOL: + return QVariant::fromValue(get_bool(data, name)); + default: + return QVariant(); + } +} + +static QVariant from_obs_data(obs_data_t *data, const char *name, + obs_combo_format format) +{ + return from_obs_data(data, name, + format); +} + +static QVariant from_obs_data_autoselect(obs_data_t *data, const char *name, + obs_combo_format format) +{ + return from_obs_data< + obs_data_get_autoselect_int, obs_data_get_autoselect_double, + obs_data_get_autoselect_string, obs_data_get_autoselect_bool>( + data, name, format); +} + +QWidget *OBSPropertiesView::AddList(obs_property_t *prop, bool &warning) +{ + const char *name = obs_property_name(prop); + obs_combo_type type = obs_property_list_type(prop); + obs_combo_format format = obs_property_list_format(prop); + size_t count = obs_property_list_item_count(prop); + + QVariant value = from_obs_data(settings, name, format); + + if (type == OBS_COMBO_TYPE_RADIO) { + QButtonGroup *buttonGroup = new QButtonGroup(); + QFormLayout *subLayout = new QFormLayout(); + subLayout->setContentsMargins(0, 0, 0, 0); + + for (size_t idx = 0; idx < count; idx++) + AddRadioItem(buttonGroup, subLayout, prop, value, idx); + + if (count > 0) { + buttonGroup->setExclusive(true); + WidgetInfo *info = new WidgetInfo( + this, prop, buttonGroup->buttons()[0]); + children.emplace_back(info); + connect(buttonGroup, &QButtonGroup::buttonClicked, info, + &WidgetInfo::ControlChanged); + } + + QWidget *widget = new QWidget(); + widget->setLayout(subLayout); + return widget; + } + + int idx = -1; + + QComboBox *combo = new QComboBox(); + for (size_t i = 0; i < count; i++) + AddComboItem(combo, prop, i); + + if (type == OBS_COMBO_TYPE_EDITABLE) + combo->setEditable(true); + + combo->setMaxVisibleItems(40); + combo->setToolTip(QT_UTF8(obs_property_long_description(prop))); + + if (format == OBS_COMBO_FORMAT_STRING && + type == OBS_COMBO_TYPE_EDITABLE) { + combo->lineEdit()->setText(value.toString()); + } else { + idx = combo->findData(value); + } + + if (type == OBS_COMBO_TYPE_EDITABLE) + return NewWidget(prop, combo, &QComboBox::editTextChanged); + + if (idx != -1) + combo->setCurrentIndex(idx); + + if (obs_data_has_autoselect_value(settings, name)) { + QVariant autoselect = + from_obs_data_autoselect(settings, name, format); + int id = combo->findData(autoselect); + + if (id != -1 && id != idx) { + QString actual = combo->itemText(id); + QString selected = combo->itemText(idx); + QString combined = QTStr( + "Basic.PropertiesWindow.AutoSelectFormat"); + combo->setItemText(idx, + combined.arg(selected).arg(actual)); + } + } + + QAbstractItemModel *model = combo->model(); + warning = idx != -1 && + model->flags(model->index(idx, 0)) == Qt::NoItemFlags; + + WidgetInfo *info = new WidgetInfo(this, prop, combo); + connect(combo, &QComboBox::currentIndexChanged, info, + &WidgetInfo::ControlChanged); + children.emplace_back(info); + + /* trigger a settings update if the index was not found */ + if (count && idx == -1) + info->ControlChanged(); + + return combo; +} + +static void NewButton(QLayout *layout, WidgetInfo *info, const char *themeIcon, + void (WidgetInfo::*method)()) +{ + QPushButton *button = new QPushButton(); + button->setProperty("themeID", themeIcon); + button->setFlat(true); + button->setProperty("toolButton", true); + + QObject::connect(button, &QPushButton::clicked, info, method); + + layout->addWidget(button); +} + +void OBSPropertiesView::AddEditableList(obs_property_t *prop, + QFormLayout *layout, QLabel *&label) +{ + const char *name = obs_property_name(prop); + OBSDataArrayAutoRelease array = obs_data_get_array(settings, name); + QListWidget *list = new QListWidget(); + size_t count = obs_data_array_count(array); + + if (!obs_property_enabled(prop)) + list->setEnabled(false); + + list->setSortingEnabled(false); + list->setSelectionMode(QAbstractItemView::ExtendedSelection); + list->setToolTip(QT_UTF8(obs_property_long_description(prop))); + list->setSpacing(1); + + for (size_t i = 0; i < count; i++) { + OBSDataAutoRelease item = obs_data_array_item(array, i); + list->addItem(QT_UTF8(obs_data_get_string(item, "value"))); + QListWidgetItem *const list_item = list->item((int)i); + list_item->setSelected(obs_data_get_bool(item, "selected")); + list_item->setHidden(obs_data_get_bool(item, "hidden")); + } + + WidgetInfo *info = new WidgetInfo(this, prop, list); + + list->setDragDropMode(QAbstractItemView::InternalMove); + connect(list->model(), &QAbstractItemModel::rowsMoved, + [info]() { info->EditableListChanged(); }); + + QVBoxLayout *sideLayout = new QVBoxLayout(); + NewButton(sideLayout, info, "addIconSmall", &WidgetInfo::EditListAdd); + NewButton(sideLayout, info, "removeIconSmall", + &WidgetInfo::EditListRemove); + NewButton(sideLayout, info, "configIconSmall", + &WidgetInfo::EditListEdit); + NewButton(sideLayout, info, "upArrowIconSmall", + &WidgetInfo::EditListUp); + NewButton(sideLayout, info, "downArrowIconSmall", + &WidgetInfo::EditListDown); + sideLayout->addStretch(0); + + QHBoxLayout *subLayout = new QHBoxLayout(); + subLayout->addWidget(list); + subLayout->addLayout(sideLayout); + + children.emplace_back(info); + + label = new QLabel(QT_UTF8(obs_property_description(prop))); + layout->addRow(label, subLayout); +} + +QWidget *OBSPropertiesView::AddButton(obs_property_t *prop) +{ + const char *desc = obs_property_description(prop); + + QPushButton *button = new QPushButton(QT_UTF8(desc)); + button->setProperty("themeID", "settingsButtons"); + button->setSizePolicy(QSizePolicy::Maximum, QSizePolicy::Maximum); + return NewWidget(prop, button, &QPushButton::clicked); +} + +void OBSPropertiesView::AddColorInternal(obs_property_t *prop, + QFormLayout *layout, QLabel *&label, + bool supportAlpha) +{ + QPushButton *button = new QPushButton; + QLabel *colorLabel = new QLabel; + const char *name = obs_property_name(prop); + long long val = obs_data_get_int(settings, name); + QColor color = color_from_int(val); + QColor::NameFormat format; + + if (!obs_property_enabled(prop)) { + button->setEnabled(false); + colorLabel->setEnabled(false); + } + + button->setProperty("themeID", "settingsButtons"); + button->setText(QTStr("Basic.PropertiesWindow.SelectColor")); + button->setToolTip(QT_UTF8(obs_property_long_description(prop))); + + if (supportAlpha) { + format = QColor::HexArgb; + } else { + format = QColor::HexRgb; + color.setAlpha(255); + } + + QPalette palette = QPalette(color); + colorLabel->setFrameStyle(QFrame::Sunken | QFrame::Panel); + colorLabel->setText(color.name(format)); + colorLabel->setPalette(palette); + colorLabel->setStyleSheet( + QString("background-color :%1; color: %2;") + .arg(palette.color(QPalette::Window).name(format)) + .arg(palette.color(QPalette::WindowText).name(format))); + colorLabel->setAutoFillBackground(true); + colorLabel->setAlignment(Qt::AlignCenter); + colorLabel->setToolTip(QT_UTF8(obs_property_long_description(prop))); + + QHBoxLayout *subLayout = new QHBoxLayout; + subLayout->setContentsMargins(0, 0, 0, 0); + + subLayout->addWidget(colorLabel); + subLayout->addWidget(button); + + WidgetInfo *info = new WidgetInfo(this, prop, colorLabel); + connect(button, &QPushButton::clicked, info, + &WidgetInfo::ControlChanged); + children.emplace_back(info); + + label = new QLabel(QT_UTF8(obs_property_description(prop))); + layout->addRow(label, subLayout); +} + +void OBSPropertiesView::AddColor(obs_property_t *prop, QFormLayout *layout, + QLabel *&label) +{ + AddColorInternal(prop, layout, label, false); +} + +void OBSPropertiesView::AddColorAlpha(obs_property_t *prop, QFormLayout *layout, + QLabel *&label) +{ + AddColorInternal(prop, layout, label, true); +} + +void MakeQFont(obs_data_t *font_obj, QFont &font, bool limit = false) +{ + const char *face = obs_data_get_string(font_obj, "face"); + const char *style = obs_data_get_string(font_obj, "style"); + int size = (int)obs_data_get_int(font_obj, "size"); + uint32_t flags = (uint32_t)obs_data_get_int(font_obj, "flags"); + + if (face) { + font.setFamily(face); + font.setStyleName(style); + } + + if (size) { + if (limit) { + int max_size = font.pointSize(); + if (max_size < 28) + max_size = 28; + if (size > max_size) + size = max_size; + } + font.setPointSize(size); + } + + if (flags & OBS_FONT_BOLD) + font.setBold(true); + if (flags & OBS_FONT_ITALIC) + font.setItalic(true); + if (flags & OBS_FONT_UNDERLINE) + font.setUnderline(true); + if (flags & OBS_FONT_STRIKEOUT) + font.setStrikeOut(true); +} + +void OBSPropertiesView::AddFont(obs_property_t *prop, QFormLayout *layout, + QLabel *&label) +{ + const char *name = obs_property_name(prop); + OBSDataAutoRelease font_obj = obs_data_get_obj(settings, name); + const char *face = obs_data_get_string(font_obj, "face"); + const char *style = obs_data_get_string(font_obj, "style"); + QPushButton *button = new QPushButton; + QLabel *fontLabel = new QLabel; + QFont font; + + if (!obs_property_enabled(prop)) { + button->setEnabled(false); + fontLabel->setEnabled(false); + } + + font = fontLabel->font(); + MakeQFont(font_obj, font, true); + + button->setProperty("themeID", "settingsButtons"); + button->setText(QTStr("Basic.PropertiesWindow.SelectFont")); + button->setToolTip(QT_UTF8(obs_property_long_description(prop))); + + fontLabel->setFrameStyle(QFrame::Sunken | QFrame::Panel); + fontLabel->setFont(font); + fontLabel->setText(QString("%1 %2").arg(face, style)); + fontLabel->setAlignment(Qt::AlignCenter); + fontLabel->setToolTip(QT_UTF8(obs_property_long_description(prop))); + + QHBoxLayout *subLayout = new QHBoxLayout; + subLayout->setContentsMargins(0, 0, 0, 0); + + subLayout->addWidget(fontLabel); + subLayout->addWidget(button); + + WidgetInfo *info = new WidgetInfo(this, prop, fontLabel); + connect(button, &QPushButton::clicked, info, + &WidgetInfo::ControlChanged); + children.emplace_back(info); + + label = new QLabel(QT_UTF8(obs_property_description(prop))); + layout->addRow(label, subLayout); +} + +template static double make_epsilon(T val) +{ + return val * 0.00001; +} + +static bool matches_range(media_frames_per_second &match, + media_frames_per_second fps, + const frame_rate_range_t &pair) +{ + auto val = media_frames_per_second_to_frame_interval(fps); + auto max_ = media_frames_per_second_to_frame_interval(pair.first); + auto min_ = media_frames_per_second_to_frame_interval(pair.second); + + if (min_ <= val && val <= max_) { + match = fps; + return true; + } + + return false; +} + +static bool matches_ranges(media_frames_per_second &best_match, + media_frames_per_second fps, + const frame_rate_ranges_t &fps_ranges, + bool exact = false) +{ + auto convert_fn = media_frames_per_second_to_frame_interval; + auto val = convert_fn(fps); + auto epsilon = make_epsilon(val); + + bool match = false; + auto best_dist = numeric_limits::max(); + for (auto &pair : fps_ranges) { + auto max_ = convert_fn(pair.first); + auto min_ = convert_fn(pair.second); + /*blog(LOG_INFO, "%lg <= %lg <= %lg? %s %s %s", + min_, val, max_, + fabsl(min_ - val) < epsilon ? "true" : "false", + min_ <= val && val <= max_ ? "true" : "false", + fabsl(min_ - val) < epsilon ? "true" : + "false");*/ + + if (matches_range(best_match, fps, pair)) + return true; + + if (exact) + continue; + + auto min_dist = fabsl(min_ - val); + auto max_dist = fabsl(max_ - val); + if (min_dist < epsilon && min_dist < best_dist) { + best_match = pair.first; + match = true; + continue; + } + + if (max_dist < epsilon && max_dist < best_dist) { + best_match = pair.second; + match = true; + continue; + } + } + + return match; +} + +static media_frames_per_second make_fps(uint32_t num, uint32_t den) +{ + media_frames_per_second fps{}; + fps.numerator = num; + fps.denominator = den; + return fps; +} + +static const common_frame_rate common_fps[] = { + {"240", {240, 1}}, {"144", {144, 1}}, + {"120", {120, 1}}, {"119.88", {120000, 1001}}, + {"60", {60, 1}}, {"59.94", {60000, 1001}}, + {"50", {50, 1}}, {"48", {48, 1}}, + {"30", {30, 1}}, {"29.97", {30000, 1001}}, + {"25", {25, 1}}, {"24", {24, 1}}, + {"23.976", {24000, 1001}}, +}; + +static void UpdateSimpleFPSSelection(OBSFrameRatePropertyWidget *fpsProps, + const media_frames_per_second *current_fps) +{ + if (!current_fps || !media_frames_per_second_is_valid(*current_fps)) { + fpsProps->simpleFPS->setCurrentIndex(0); + return; + } + + auto combo = fpsProps->simpleFPS; + auto num = combo->count(); + for (int i = 0; i < num; i++) { + auto variant = combo->itemData(i); + if (!variant.canConvert()) + continue; + + auto fps = variant.value(); + if (fps != *current_fps) + continue; + + combo->setCurrentIndex(i); + return; + } + + combo->setCurrentIndex(0); +} + +static void AddFPSRanges(vector &items, + const frame_rate_ranges_t &ranges) +{ + auto InsertFPS = [&](media_frames_per_second fps) { + auto fps_val = media_frames_per_second_to_fps(fps); + + auto end_ = end(items); + auto i = begin(items); + for (; i != end_; i++) { + auto i_fps_val = media_frames_per_second_to_fps(i->fps); + if (fabsl(i_fps_val - fps_val) < 0.01) + return; + + if (i_fps_val > fps_val) + continue; + + break; + } + + items.insert(i, {nullptr, fps}); + }; + + for (auto &range : ranges) { + InsertFPS(range.first); + InsertFPS(range.second); + } +} + +static QWidget * +CreateSimpleFPSValues(OBSFrameRatePropertyWidget *fpsProps, bool &selected, + const media_frames_per_second *current_fps) +{ + auto widget = new QWidget{}; + widget->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); + + auto layout = new QVBoxLayout{}; + layout->setContentsMargins(0, 0, 0, 0); + + auto items = vector{}; + items.reserve(sizeof(common_fps) / sizeof(common_frame_rate)); + + auto combo = fpsProps->simpleFPS = new QComboBox(); + + combo->addItem("", QVariant::fromValue(make_fps(0, 0))); + for (const auto &fps : common_fps) { + media_frames_per_second best_match{}; + if (!matches_ranges(best_match, fps.fps, fpsProps->fps_ranges)) + continue; + + items.push_back({fps.fps_name, best_match}); + } + + AddFPSRanges(items, fpsProps->fps_ranges); + + for (const auto &item : items) { + auto var = QVariant::fromValue(item.fps); + auto name = item.fps_name + ? QString(item.fps_name) + : QString("%1").arg( + media_frames_per_second_to_fps( + item.fps)); + combo->addItem(name, var); + + bool select = current_fps && *current_fps == item.fps; + if (select) { + combo->setCurrentIndex(combo->count() - 1); + selected = true; + } + } + + layout->addWidget(combo, 0, Qt::AlignTop); + widget->setLayout(layout); + + return widget; +} + +static void UpdateRationalFPSWidgets(OBSFrameRatePropertyWidget *fpsProps, + const media_frames_per_second *current_fps) +{ + if (!current_fps || !media_frames_per_second_is_valid(*current_fps)) { + fpsProps->numEdit->setValue(0); + fpsProps->denEdit->setValue(0); + return; + } + + auto combo = fpsProps->fpsRange; + auto num = combo->count(); + for (int i = 0; i < num; i++) { + auto variant = combo->itemData(i); + if (!variant.canConvert()) + continue; + + auto idx = variant.value(); + if (fpsProps->fps_ranges.size() < idx) + continue; + + media_frames_per_second match{}; + if (!matches_range(match, *current_fps, + fpsProps->fps_ranges[idx])) + continue; + + combo->setCurrentIndex(i); + break; + } + + fpsProps->numEdit->setValue(current_fps->numerator); + fpsProps->denEdit->setValue(current_fps->denominator); +} + +static QWidget *CreateRationalFPS(OBSFrameRatePropertyWidget *fpsProps, + bool &selected, + const media_frames_per_second *current_fps) +{ + auto widget = new QWidget{}; + widget->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); + + auto layout = new QFormLayout{}; + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(4); + + auto str = QTStr("Basic.PropertiesView.FPS.ValidFPSRanges"); + auto rlabel = new QLabel{str}; + + auto combo = fpsProps->fpsRange = new QComboBox(); + auto convert_fps = media_frames_per_second_to_fps; + //auto convert_fi = media_frames_per_second_to_frame_interval; + + for (size_t i = 0; i < fpsProps->fps_ranges.size(); i++) { + auto &pair = fpsProps->fps_ranges[i]; + combo->addItem(QString{"%1 - %2"} + .arg(convert_fps(pair.first)) + .arg(convert_fps(pair.second)), + QVariant::fromValue(i)); + + media_frames_per_second match; + if (!current_fps || !matches_range(match, *current_fps, pair)) + continue; + + combo->setCurrentIndex(combo->count() - 1); + selected = true; + } + + layout->addRow(rlabel, combo); + + auto num_edit = fpsProps->numEdit = new SpinBoxIgnoreScroll{}; + auto den_edit = fpsProps->denEdit = new SpinBoxIgnoreScroll{}; + + num_edit->setRange(0, INT_MAX); + den_edit->setRange(0, INT_MAX); + + if (current_fps) { + num_edit->setValue(current_fps->numerator); + den_edit->setValue(current_fps->denominator); + } + + layout->addRow(QTStr("Basic.Settings.Video.Numerator"), num_edit); + layout->addRow(QTStr("Basic.Settings.Video.Denominator"), den_edit); + + widget->setLayout(layout); + + return widget; +} + +static OBSFrameRatePropertyWidget * +CreateFrameRateWidget(obs_property_t *prop, bool &warning, const char *option, + media_frames_per_second *current_fps, + frame_rate_ranges_t &fps_ranges) +{ + auto widget = new OBSFrameRatePropertyWidget{}; + auto hlayout = new QHBoxLayout{}; + hlayout->setContentsMargins(0, 0, 0, 0); + + swap(widget->fps_ranges, fps_ranges); + + auto combo = widget->modeSelect = new QComboBox(); + combo->addItem(QTStr("Basic.PropertiesView.FPS.Simple"), + QVariant::fromValue(frame_rate_tag::simple())); + combo->addItem(QTStr("Basic.PropertiesView.FPS.Rational"), + QVariant::fromValue(frame_rate_tag::rational())); + + combo->setToolTip(QT_UTF8(obs_property_long_description(prop))); + + auto num = obs_property_frame_rate_options_count(prop); + if (num) + combo->insertSeparator(combo->count()); + + bool option_found = false; + for (size_t i = 0; i < num; i++) { + auto name = obs_property_frame_rate_option_name(prop, i); + auto desc = obs_property_frame_rate_option_description(prop, i); + combo->addItem(desc, QVariant::fromValue(frame_rate_tag{name})); + + if (!name || !option || string(name) != option) + continue; + + option_found = true; + combo->setCurrentIndex(combo->count() - 1); + } + + hlayout->addWidget(combo, 0, Qt::AlignTop); + + auto stack = widget->modeDisplay = new QStackedWidget{}; + + bool match_found = option_found; + auto AddWidget = [&](decltype(CreateRationalFPS) func) { + bool selected = false; + stack->addWidget(func(widget, selected, current_fps)); + + if (match_found || !selected) + return; + + match_found = true; + + stack->setCurrentIndex(stack->count() - 1); + combo->setCurrentIndex(stack->count() - 1); + }; + + AddWidget(CreateSimpleFPSValues); + AddWidget(CreateRationalFPS); + stack->addWidget(new QWidget{}); + + if (option_found) + stack->setCurrentIndex(stack->count() - 1); + else if (!match_found) { + int idx = current_fps ? 1 : 0; // Rational for "unsupported" + // Simple as default + stack->setCurrentIndex(idx); + combo->setCurrentIndex(idx); + warning = true; + } + + hlayout->addWidget(stack, 0, Qt::AlignTop); + + auto label_area = widget->labels = new QWidget{}; + label_area->setSizePolicy(QSizePolicy::Expanding, + QSizePolicy::Expanding); + + auto vlayout = new QVBoxLayout{}; + vlayout->setContentsMargins(0, 0, 0, 0); + + auto fps_label = widget->currentFPS = new QLabel{"FPS: 22"}; + auto time_label = widget->timePerFrame = + new QLabel{"Frame Interval: 0.123 ms"}; + auto min_label = widget->minLabel = new QLabel{"Min FPS: 1/1"}; + auto max_label = widget->maxLabel = new QLabel{"Max FPS: 2/1"}; + + min_label->setHidden(true); + max_label->setHidden(true); + + auto flags = Qt::TextSelectableByMouse | Qt::TextSelectableByKeyboard; + min_label->setTextInteractionFlags(flags); + max_label->setTextInteractionFlags(flags); + + vlayout->addWidget(fps_label); + vlayout->addWidget(time_label); + vlayout->addWidget(min_label); + vlayout->addWidget(max_label); + label_area->setLayout(vlayout); + + hlayout->addWidget(label_area, 0, Qt::AlignTop); + + widget->setLayout(hlayout); + + return widget; +} + +static void UpdateMinMaxLabels(OBSFrameRatePropertyWidget *w) +{ + auto Hide = [&](bool hide) { + w->minLabel->setHidden(hide); + w->maxLabel->setHidden(hide); + }; + + auto variant = w->modeSelect->currentData(); + if (!variant.canConvert() || + variant.value().type != frame_rate_tag::RATIONAL) { + Hide(true); + return; + } + + variant = w->fpsRange->currentData(); + if (!variant.canConvert()) { + Hide(true); + return; + } + + auto idx = variant.value(); + if (idx >= w->fps_ranges.size()) { + Hide(true); + return; + } + + Hide(false); + + auto min = w->fps_ranges[idx].first; + auto max = w->fps_ranges[idx].second; + + w->minLabel->setText(QString("Min FPS: %1/%2") + .arg(min.numerator) + .arg(min.denominator)); + w->maxLabel->setText(QString("Max FPS: %1/%2") + .arg(max.numerator) + .arg(max.denominator)); +} + +static void UpdateFPSLabels(OBSFrameRatePropertyWidget *w) +{ + UpdateMinMaxLabels(w); + + unique_ptr obj{ + obs_data_item_byname(w->settings, w->name)}; + + media_frames_per_second fps{}; + media_frames_per_second *valid_fps = nullptr; + if (obs_data_item_get_autoselect_frames_per_second(obj.get(), &fps, + nullptr) || + obs_data_item_get_frames_per_second(obj.get(), &fps, nullptr)) + valid_fps = &fps; + + const char *option = nullptr; + obs_data_item_get_frames_per_second(obj.get(), nullptr, &option); + + if (!valid_fps) { + w->currentFPS->setHidden(true); + w->timePerFrame->setHidden(true); + if (!option) + w->warningLabel->setObjectName("errorLabel"); + + return; + } + + w->currentFPS->setHidden(false); + w->timePerFrame->setHidden(false); + + media_frames_per_second match{}; + if (!option && !matches_ranges(match, *valid_fps, w->fps_ranges, true)) + w->warningLabel->setObjectName("errorLabel"); + else + w->warningLabel->setObjectName(""); + + auto convert_to_fps = media_frames_per_second_to_fps; + auto convert_to_frame_interval = + media_frames_per_second_to_frame_interval; + + w->currentFPS->setText( + QString("FPS: %1").arg(convert_to_fps(*valid_fps))); + w->timePerFrame->setText( + QString("Frame Interval: %1 ms") + .arg(convert_to_frame_interval(*valid_fps) * 1000)); +} + +void OBSPropertiesView::AddFrameRate(obs_property_t *prop, bool &warning, + QFormLayout *layout, QLabel *&label) +{ + const char *name = obs_property_name(prop); + bool enabled = obs_property_enabled(prop); + unique_ptr obj{obs_data_item_byname(settings, name)}; + + const char *option = nullptr; + obs_data_item_get_frames_per_second(obj.get(), nullptr, &option); + + media_frames_per_second fps{}; + media_frames_per_second *valid_fps = nullptr; + if (obs_data_item_get_frames_per_second(obj.get(), &fps, nullptr)) + valid_fps = &fps; + + frame_rate_ranges_t fps_ranges; + size_t num = obs_property_frame_rate_fps_ranges_count(prop); + fps_ranges.reserve(num); + for (size_t i = 0; i < num; i++) + fps_ranges.emplace_back( + obs_property_frame_rate_fps_range_min(prop, i), + obs_property_frame_rate_fps_range_max(prop, i)); + + auto widget = CreateFrameRateWidget(prop, warning, option, valid_fps, + fps_ranges); + auto info = new WidgetInfo(this, prop, widget); + + widget->setToolTip(QT_UTF8(obs_property_long_description(prop))); + + widget->name = name; + widget->settings = settings; + + widget->modeSelect->setEnabled(enabled); + widget->simpleFPS->setEnabled(enabled); + widget->fpsRange->setEnabled(enabled); + widget->numEdit->setEnabled(enabled); + widget->denEdit->setEnabled(enabled); + + label = widget->warningLabel = + new QLabel{obs_property_description(prop)}; + + layout->addRow(label, widget); + + children.emplace_back(info); + + UpdateFPSLabels(widget); + + auto stack = widget->modeDisplay; + auto combo = widget->modeSelect; + + stack->setToolTip(QT_UTF8(obs_property_long_description(prop))); + combo->setToolTip(QT_UTF8(obs_property_long_description(prop))); + + auto comboIndexChanged = static_cast( + &QComboBox::currentIndexChanged); + connect(combo, comboIndexChanged, stack, [=](int index) { + bool out_of_bounds = index >= stack->count(); + auto idx = out_of_bounds ? stack->count() - 1 : index; + stack->setCurrentIndex(idx); + + if (widget->updating) + return; + + UpdateFPSLabels(widget); + emit info->ControlChanged(); + }); + + connect(widget->simpleFPS, comboIndexChanged, [=](int) { + if (widget->updating) + return; + + emit info->ControlChanged(); + }); + + connect(widget->fpsRange, comboIndexChanged, [=](int) { + if (widget->updating) + return; + + UpdateFPSLabels(widget); + }); + + auto sbValueChanged = + static_cast(&QSpinBox::valueChanged); + connect(widget->numEdit, sbValueChanged, [=](int) { + if (widget->updating) + return; + + emit info->ControlChanged(); + }); + + connect(widget->denEdit, sbValueChanged, [=](int) { + if (widget->updating) + return; + + emit info->ControlChanged(); + }); +} + +void OBSPropertiesView::AddGroup(obs_property_t *prop, QFormLayout *layout) +{ + const char *name = obs_property_name(prop); + bool val = obs_data_get_bool(settings, name); + const char *desc = obs_property_description(prop); + enum obs_group_type type = obs_property_group_type(prop); + + // Create GroupBox + QGroupBox *groupBox = new QGroupBox(QT_UTF8(desc)); + groupBox->setCheckable(type == OBS_GROUP_CHECKABLE); + groupBox->setChecked(groupBox->isCheckable() ? val : true); + groupBox->setAccessibleName("group"); + groupBox->setEnabled(obs_property_enabled(prop)); + + // Create Layout and build content + QFormLayout *subLayout = new QFormLayout(); + subLayout->setFieldGrowthPolicy(QFormLayout::AllNonFixedFieldsGrow); + groupBox->setLayout(subLayout); + + obs_properties_t *content = obs_property_group_content(prop); + obs_property_t *el = obs_properties_first(content); + while (el != nullptr) { + AddProperty(el, subLayout); + obs_property_next(&el); + } + + // Insert into UI + layout->setWidget(layout->rowCount(), + QFormLayout::ItemRole::SpanningRole, groupBox); + + // Register Group Widget + WidgetInfo *info = new WidgetInfo(this, prop, groupBox); + children.emplace_back(info); + + // Signals + connect(groupBox, &QGroupBox::toggled, info, + &WidgetInfo::ControlChanged); +} + +void OBSPropertiesView::AddProperty(obs_property_t *property, + QFormLayout *layout) +{ + const char *name = obs_property_name(property); + obs_property_type type = obs_property_get_type(property); + + if (!obs_property_visible(property)) + return; + + QLabel *label = nullptr; + QWidget *widget = nullptr; + bool warning = false; + + switch (type) { + case OBS_PROPERTY_INVALID: + return; + case OBS_PROPERTY_BOOL: + widget = AddCheckbox(property); + break; + case OBS_PROPERTY_INT: + AddInt(property, layout, &label); + break; + case OBS_PROPERTY_FLOAT: + AddFloat(property, layout, &label); + break; + case OBS_PROPERTY_TEXT: + widget = AddText(property, layout, label); + break; + case OBS_PROPERTY_PATH: + AddPath(property, layout, &label); + break; + case OBS_PROPERTY_LIST: + widget = AddList(property, warning); + break; + case OBS_PROPERTY_COLOR: + AddColor(property, layout, label); + break; + case OBS_PROPERTY_FONT: + AddFont(property, layout, label); + break; + case OBS_PROPERTY_BUTTON: + widget = AddButton(property); + break; + case OBS_PROPERTY_EDITABLE_LIST: + AddEditableList(property, layout, label); + break; + case OBS_PROPERTY_FRAME_RATE: + AddFrameRate(property, warning, layout, label); + break; + case OBS_PROPERTY_GROUP: + AddGroup(property, layout); + break; + case OBS_PROPERTY_COLOR_ALPHA: + AddColorAlpha(property, layout, label); + } + + if (!widget && !label) + return; + + if (!label && type != OBS_PROPERTY_BOOL && + type != OBS_PROPERTY_BUTTON && type != OBS_PROPERTY_GROUP) + label = new QLabel(QT_UTF8(obs_property_description(property))); + + if (label) { + if (warning) + label->setObjectName("errorLabel"); + + if (minSize) { + label->setMinimumWidth(minSize); + label->setAlignment(Qt::AlignRight | Qt::AlignVCenter); + } + + if (!obs_property_enabled(property)) + label->setEnabled(false); + } + + if (!widget) + return; + + if (!obs_property_enabled(property)) + widget->setEnabled(false); + + if (obs_property_long_description(property)) { + QString file = !obs_frontend_is_theme_dark() + ? ":/res/images/help.svg" + : ":/res/images/help_light.svg"; + if (label) { + QString lStr = "%1 "; + + label->setText(lStr.arg(label->text(), file)); + label->setToolTip( + obs_property_long_description(property)); + } else if (type == OBS_PROPERTY_BOOL) { + + QString bStr = " "; + + const char *desc = obs_property_description(property); + + QWidget *newWidget = new QWidget(); + + QHBoxLayout *boxLayout = new QHBoxLayout(newWidget); + boxLayout->setContentsMargins(0, 0, 0, 0); + boxLayout->setAlignment(Qt::AlignLeft); +#ifdef __APPLE__ + /* TODO: This fixes the issue of tooltip not aligning + * correcty on macOS, the root cause needs further + * investigation. */ + boxLayout->setSpacing(10); +#else + boxLayout->setSpacing(0); +#endif + QCheckBox *check = qobject_cast(widget); + check->setText(desc); + check->setToolTip( + obs_property_long_description(property)); + + QLabel *help = new QLabel(check); + help->setText(bStr.arg(file)); + help->setToolTip( + obs_property_long_description(property)); + + boxLayout->addWidget(check); + boxLayout->addWidget(help); + widget = newWidget; + } + } + + layout->addRow(label, widget); + + if (!lastFocused.empty()) + if (lastFocused.compare(name) == 0) + lastWidget = widget; +} + +void OBSPropertiesView::SignalChanged() +{ + emit Changed(); +} + +static bool FrameRateChangedVariant(const QVariant &variant, + media_frames_per_second &fps, + obs_data_item_t *&obj, + const media_frames_per_second *valid_fps) +{ + if (!variant.canConvert()) + return false; + + fps = variant.value(); + if (valid_fps && fps == *valid_fps) + return false; + + obs_data_item_set_frames_per_second(&obj, fps, nullptr); + return true; +} + +static bool FrameRateChangedCommon(OBSFrameRatePropertyWidget *w, + obs_data_item_t *&obj, + const media_frames_per_second *valid_fps) +{ + media_frames_per_second fps{}; + if (!FrameRateChangedVariant(w->simpleFPS->currentData(), fps, obj, + valid_fps)) + return false; + + UpdateRationalFPSWidgets(w, &fps); + return true; +} + +static bool FrameRateChangedRational(OBSFrameRatePropertyWidget *w, + obs_data_item_t *&obj, + const media_frames_per_second *valid_fps) +{ + auto num = w->numEdit->value(); + auto den = w->denEdit->value(); + + auto fps = make_fps(num, den); + if (valid_fps && media_frames_per_second_is_valid(fps) && + fps == *valid_fps) + return false; + + obs_data_item_set_frames_per_second(&obj, fps, nullptr); + UpdateSimpleFPSSelection(w, &fps); + return true; +} + +static bool FrameRateChanged(QWidget *widget, const char *name, + OBSData &settings) +{ + auto w = qobject_cast(widget); + if (!w) + return false; + + auto variant = w->modeSelect->currentData(); + if (!variant.canConvert()) + return false; + + auto StopUpdating = [&](void *) { + w->updating = false; + }; + unique_ptr signalGuard( + static_cast(w), StopUpdating); + w->updating = true; + + if (!obs_data_has_user_value(settings, name)) + obs_data_set_obj(settings, name, nullptr); + + unique_ptr obj{obs_data_item_byname(settings, name)}; + auto obj_ptr = obj.get(); + auto CheckObj = [&]() { + if (!obj_ptr) + obj.release(); + }; + + const char *option = nullptr; + obs_data_item_get_frames_per_second(obj.get(), nullptr, &option); + + media_frames_per_second fps{}; + media_frames_per_second *valid_fps = nullptr; + if (obs_data_item_get_frames_per_second(obj.get(), &fps, nullptr)) + valid_fps = &fps; + + auto tag = variant.value(); + switch (tag.type) { + case frame_rate_tag::SIMPLE: + if (!FrameRateChangedCommon(w, obj_ptr, valid_fps)) + return false; + break; + + case frame_rate_tag::RATIONAL: + if (!FrameRateChangedRational(w, obj_ptr, valid_fps)) + return false; + break; + + case frame_rate_tag::USER: + if (tag.val && option && strcmp(tag.val, option) == 0) + return false; + + obs_data_item_set_frames_per_second(&obj_ptr, {}, tag.val); + break; + } + + UpdateFPSLabels(w); + CheckObj(); + return true; +} + +void WidgetInfo::BoolChanged(const char *setting) +{ + QCheckBox *checkbox = static_cast(widget); + obs_data_set_bool(view->settings, setting, + checkbox->checkState() == Qt::Checked); +} + +void WidgetInfo::IntChanged(const char *setting) +{ + QSpinBox *spin = static_cast(widget); + obs_data_set_int(view->settings, setting, spin->value()); +} + +void WidgetInfo::FloatChanged(const char *setting) +{ + QDoubleSpinBox *spin = static_cast(widget); + obs_data_set_double(view->settings, setting, spin->value()); +} + +void WidgetInfo::TextChanged(const char *setting) +{ + obs_text_type type = obs_property_text_type(property); + + if (type == OBS_TEXT_MULTILINE) { + OBSPlainTextEdit *edit = + static_cast(widget); + obs_data_set_string(view->settings, setting, + QT_TO_UTF8(edit->toPlainText())); + return; + } + + QLineEdit *edit = static_cast(widget); + obs_data_set_string(view->settings, setting, QT_TO_UTF8(edit->text())); +} + +bool WidgetInfo::PathChanged(const char *setting) +{ + const char *desc = obs_property_description(property); + obs_path_type type = obs_property_path_type(property); + const char *filter = obs_property_path_filter(property); + const char *default_path = obs_property_path_default_path(property); + QString path; + + if (type == OBS_PATH_DIRECTORY) + path = SelectDirectory(view, QT_UTF8(desc), + QT_UTF8(default_path)); + else if (type == OBS_PATH_FILE) + path = OpenFile(view, QT_UTF8(desc), QT_UTF8(default_path), + QT_UTF8(filter)); + else if (type == OBS_PATH_FILE_SAVE) + path = SaveFile(view, QT_UTF8(desc), QT_UTF8(default_path), + QT_UTF8(filter)); + +#ifdef __APPLE__ + // TODO: Revisit when QTBUG-42661 is fixed + widget->window()->raise(); +#endif + + if (path.isEmpty()) + return false; + + QLineEdit *edit = static_cast(widget); + edit->setText(path); + obs_data_set_string(view->settings, setting, QT_TO_UTF8(path)); + return true; +} + +void WidgetInfo::ListChanged(const char *setting) +{ + obs_combo_format format = obs_property_list_format(property); + obs_combo_type type = obs_property_list_type(property); + QVariant data; + + if (type == OBS_COMBO_TYPE_RADIO) { + QButtonGroup *group = + static_cast(widget)->group(); + QAbstractButton *button = group->checkedButton(); + data = button->property("value"); + } else if (type == OBS_COMBO_TYPE_EDITABLE) { + data = static_cast(widget)->currentText().toUtf8(); + } else { + QComboBox *combo = static_cast(widget); + int index = combo->currentIndex(); + if (index != -1) + data = combo->itemData(index); + else + return; + } + + switch (format) { + case OBS_COMBO_FORMAT_INVALID: + return; + case OBS_COMBO_FORMAT_INT: + obs_data_set_int(view->settings, setting, + data.value()); + break; + case OBS_COMBO_FORMAT_FLOAT: + obs_data_set_double(view->settings, setting, + data.value()); + break; + case OBS_COMBO_FORMAT_STRING: + obs_data_set_string(view->settings, setting, + data.toByteArray().constData()); + break; + case OBS_COMBO_FORMAT_BOOL: + obs_data_set_bool(view->settings, setting, + data.value()); + break; + } +} + +bool WidgetInfo::ColorChangedInternal(const char *setting, bool supportAlpha) +{ + const char *desc = obs_property_description(property); + long long val = obs_data_get_int(view->settings, setting); + QColor color = color_from_int(val); + QColor::NameFormat format; + + QColorDialog::ColorDialogOptions options; + + if (supportAlpha) { + options |= QColorDialog::ShowAlphaChannel; + } + +#ifdef __linux__ + // TODO: Revisit hang on Ubuntu with native dialog + options |= QColorDialog::DontUseNativeDialog; +#endif + + color = QColorDialog::getColor(color, view, QT_UTF8(desc), options); + +#ifdef __APPLE__ + // TODO: Revisit when QTBUG-42661 is fixed + widget->window()->raise(); +#endif + + if (!color.isValid()) + return false; + + if (supportAlpha) { + format = QColor::HexArgb; + } else { + color.setAlpha(255); + format = QColor::HexRgb; + } + + QLabel *label = static_cast(widget); + label->setText(color.name(format)); + QPalette palette = QPalette(color); + label->setPalette(palette); + label->setStyleSheet( + QString("background-color :%1; color: %2;") + .arg(palette.color(QPalette::Window).name(format)) + .arg(palette.color(QPalette::WindowText).name(format))); + + obs_data_set_int(view->settings, setting, color_to_int(color)); + + return true; +} + +bool WidgetInfo::ColorChanged(const char *setting) +{ + return ColorChangedInternal(setting, false); +} + +bool WidgetInfo::ColorAlphaChanged(const char *setting) +{ + return ColorChangedInternal(setting, true); +} + +bool WidgetInfo::FontChanged(const char *setting) +{ + OBSDataAutoRelease font_obj = obs_data_get_obj(view->settings, setting); + bool success; + uint32_t flags; + QFont font; + + QFontDialog::FontDialogOptions options; + +#ifndef _WIN32 + options = QFontDialog::DontUseNativeDialog; +#endif + + if (!font_obj) { + QFont initial; + font = QFontDialog::getFont( + &success, initial, view, + QTStr("Basic.PropertiesWindow.SelectFont.WindowTitle"), + options); + } else { + MakeQFont(font_obj, font); + font = QFontDialog::getFont( + &success, font, view, + QTStr("Basic.PropertiesWindow.SelectFont.WindowTitle"), + options); + } + + if (!success) + return false; + + font_obj = obs_data_create(); + + obs_data_set_string(font_obj, "face", QT_TO_UTF8(font.family())); + obs_data_set_string(font_obj, "style", QT_TO_UTF8(font.styleName())); + obs_data_set_int(font_obj, "size", font.pointSize()); + flags = font.bold() ? OBS_FONT_BOLD : 0; + flags |= font.italic() ? OBS_FONT_ITALIC : 0; + flags |= font.underline() ? OBS_FONT_UNDERLINE : 0; + flags |= font.strikeOut() ? OBS_FONT_STRIKEOUT : 0; + obs_data_set_int(font_obj, "flags", flags); + + QLabel *label = static_cast(widget); + QFont labelFont; + MakeQFont(font_obj, labelFont, true); + label->setFont(labelFont); + label->setText(QString("%1 %2").arg(font.family(), font.styleName())); + + obs_data_set_obj(view->settings, setting, font_obj); + return true; +} + +void WidgetInfo::GroupChanged(const char *setting) +{ + QGroupBox *groupbox = static_cast(widget); + obs_data_set_bool(view->settings, setting, + groupbox->isCheckable() ? groupbox->isChecked() + : true); +} + +void WidgetInfo::EditableListChanged() +{ + const char *setting = obs_property_name(property); + QListWidget *list = reinterpret_cast(widget); + OBSDataArrayAutoRelease array = obs_data_array_create(); + + for (int i = 0; i < list->count(); i++) { + QListWidgetItem *item = list->item(i); + OBSDataAutoRelease arrayItem = obs_data_create(); + obs_data_set_string(arrayItem, "value", + QT_TO_UTF8(item->text())); + obs_data_set_bool(arrayItem, "selected", item->isSelected()); + obs_data_set_bool(arrayItem, "hidden", item->isHidden()); + obs_data_array_push_back(array, arrayItem); + } + + obs_data_set_array(view->settings, setting, array); + + ControlChanged(); +} + +void WidgetInfo::ButtonClicked() +{ + obs_button_type type = obs_property_button_type(property); + const char *savedUrl = obs_property_button_url(property); + + if (type == OBS_BUTTON_URL && strcmp(savedUrl, "") != 0) { + QUrl url(savedUrl, QUrl::StrictMode); + if (url.isValid() && (url.scheme().compare("http") == 0 || + url.scheme().compare("https") == 0)) { + QString msg( + QTStr("Basic.PropertiesView.UrlButton.Text")); + msg += "\n\n"; + msg += QString(QTStr("Basic.PropertiesView.UrlButton.Text.Url")) + .arg(savedUrl); + + if (DisplayMessage(msg, true, true)) + QDesktopServices::openUrl(url); + } + return; + } + + OBSObject strongObj = view->GetObject(); + void *obj = strongObj ? strongObj.Get() : view->rawObj; + if (obs_property_button_clicked(property, obj)) { + QMetaObject::invokeMethod(view, "RefreshProperties", + Qt::QueuedConnection); + } +} + +void WidgetInfo::TogglePasswordText(bool show) +{ + reinterpret_cast(widget)->setEchoMode( + show ? QLineEdit::Normal : QLineEdit::Password); +} + +void WidgetInfo::ControlChanged() +{ + const char *setting = obs_property_name(property); + obs_property_type type = obs_property_get_type(property); + + if (!recently_updated) { + old_settings_cache = obs_data_create(); + obs_data_apply(old_settings_cache, view->settings); + obs_data_release(old_settings_cache); + } + + switch (type) { + case OBS_PROPERTY_INVALID: + return; + case OBS_PROPERTY_BOOL: + BoolChanged(setting); + break; + case OBS_PROPERTY_INT: + IntChanged(setting); + break; + case OBS_PROPERTY_FLOAT: + FloatChanged(setting); + break; + case OBS_PROPERTY_TEXT: + TextChanged(setting); + break; + case OBS_PROPERTY_LIST: + ListChanged(setting); + break; + case OBS_PROPERTY_BUTTON: + ButtonClicked(); + return; + case OBS_PROPERTY_COLOR: + if (!ColorChanged(setting)) + return; + break; + case OBS_PROPERTY_FONT: + if (!FontChanged(setting)) + return; + break; + case OBS_PROPERTY_PATH: + if (!PathChanged(setting)) + return; + break; + case OBS_PROPERTY_EDITABLE_LIST: + break; + case OBS_PROPERTY_FRAME_RATE: + if (!FrameRateChanged(widget, setting, view->settings)) + return; + break; + case OBS_PROPERTY_GROUP: + GroupChanged(setting); + break; + case OBS_PROPERTY_COLOR_ALPHA: + if (!ColorAlphaChanged(setting)) + return; + break; + } + + if (!recently_updated) { + recently_updated = true; + update_timer = new QTimer; + connect(update_timer, &QTimer::timeout, + [this, &ru = recently_updated]() { + OBSObject strongObj = view->GetObject(); + void *obj = strongObj ? strongObj.Get() + : view->rawObj; + if (obj && view->callback && + !view->deferUpdate) { + view->callback(obj, old_settings_cache, + view->settings); + } + + ru = false; + }); + connect(update_timer, &QTimer::timeout, &QTimer::deleteLater); + update_timer->setSingleShot(true); + } + + if (update_timer) { + update_timer->stop(); + update_timer->start(500); + } else { + blog(LOG_DEBUG, "No update timer or no callback!"); + } + + if (view->visUpdateCb && !view->deferUpdate) { + OBSObject strongObj = view->GetObject(); + void *obj = strongObj ? strongObj.Get() : view->rawObj; + if (obj) + view->visUpdateCb(obj, view->settings); + } + + view->SignalChanged(); + + if (obs_property_modified(property, view->settings)) { + view->lastFocused = setting; + QMetaObject::invokeMethod(view, "RefreshProperties", + Qt::QueuedConnection); + } +} + +class EditableItemDialog : public QDialog { + QLineEdit *edit; + QString filter; + QString default_path; + + void BrowseClicked() + { + QString curPath = QFileInfo(edit->text()).absoluteDir().path(); + + if (curPath.isEmpty()) + curPath = default_path; + + QString path = OpenFile(GetSettingsWindow(), QTStr("Browse"), + curPath, filter); + if (path.isEmpty()) + return; + + edit->setText(path); + } + +public: + EditableItemDialog(QWidget *parent, const QString &text, bool browse, + const char *filter_ = nullptr, + const char *default_path_ = nullptr) + : QDialog(parent), + filter(QT_UTF8(filter_)), + default_path(QT_UTF8(default_path_)) + { + QHBoxLayout *topLayout = new QHBoxLayout(); + QVBoxLayout *mainLayout = new QVBoxLayout(); + + edit = new QLineEdit(); + edit->setText(text); + topLayout->addWidget(edit); + topLayout->setAlignment(edit, Qt::AlignVCenter); + + if (browse) { + QPushButton *browseButton = + new QPushButton(QTStr("Browse")); + browseButton->setProperty("themeID", "settingsButtons"); + topLayout->addWidget(browseButton); + topLayout->setAlignment(browseButton, Qt::AlignVCenter); + + connect(browseButton, &QPushButton::clicked, this, + &EditableItemDialog::BrowseClicked); + } + + QDialogButtonBox::StandardButtons buttons = + QDialogButtonBox::Ok | QDialogButtonBox::Cancel; + + QDialogButtonBox *buttonBox = new QDialogButtonBox(buttons); + buttonBox->setCenterButtons(true); + + mainLayout->addLayout(topLayout); + mainLayout->addWidget(buttonBox); + + setLayout(mainLayout); + resize(QSize(400, 80)); + + connect(buttonBox, &QDialogButtonBox::accepted, this, + &EditableItemDialog::accept); + connect(buttonBox, &QDialogButtonBox::rejected, this, + &EditableItemDialog::reject); + } + + inline QString GetText() const { return edit->text(); } +}; + +void WidgetInfo::EditListAdd() +{ + enum obs_editable_list_type type = + obs_property_editable_list_type(property); + + if (type == OBS_EDITABLE_LIST_TYPE_STRINGS) { + EditListAddText(); + return; + } + + /* Files and URLs */ + QMenu popup(view->window()); + + QAction *action; + + action = new QAction(QTStr("Basic.PropertiesWindow.AddFiles"), this); + connect(action, &QAction::triggered, this, + &WidgetInfo::EditListAddFiles); + popup.addAction(action); + + action = new QAction(QTStr("Basic.PropertiesWindow.AddDir"), this); + connect(action, &QAction::triggered, this, &WidgetInfo::EditListAddDir); + popup.addAction(action); + + if (type == OBS_EDITABLE_LIST_TYPE_FILES_AND_URLS) { + action = new QAction(QTStr("Basic.PropertiesWindow.AddURL"), + this); + connect(action, &QAction::triggered, this, + &WidgetInfo::EditListAddText); + popup.addAction(action); + } + + popup.exec(QCursor::pos()); +} + +void WidgetInfo::EditListAddText() +{ + QListWidget *list = reinterpret_cast(widget); + const char *desc = obs_property_description(property); + + EditableItemDialog dialog(widget->window(), QString(), false); + auto title = QTStr("Basic.PropertiesWindow.AddEditableListEntry") + .arg(QT_UTF8(desc)); + dialog.setWindowTitle(title); + if (dialog.exec() == QDialog::Rejected) + return; + + QString text = dialog.GetText(); + if (text.isEmpty()) + return; + + list->addItem(text); + EditableListChanged(); +} + +void WidgetInfo::EditListAddFiles() +{ + QListWidget *list = reinterpret_cast(widget); + const char *desc = obs_property_description(property); + const char *filter = obs_property_editable_list_filter(property); + const char *default_path = + obs_property_editable_list_default_path(property); + + QString title = QTStr("Basic.PropertiesWindow.AddEditableListFiles") + .arg(QT_UTF8(desc)); + + QStringList files = OpenFiles(GetSettingsWindow(), title, + QT_UTF8(default_path), QT_UTF8(filter)); +#ifdef __APPLE__ + // TODO: Revisit when QTBUG-42661 is fixed + widget->window()->raise(); +#endif + + if (files.count() == 0) + return; + + list->addItems(files); + EditableListChanged(); +} + +void WidgetInfo::EditListAddDir() +{ + QListWidget *list = reinterpret_cast(widget); + const char *desc = obs_property_description(property); + const char *default_path = + obs_property_editable_list_default_path(property); + + QString title = QTStr("Basic.PropertiesWindow.AddEditableListDir") + .arg(QT_UTF8(desc)); + + QString dir = SelectDirectory(GetSettingsWindow(), title, + QT_UTF8(default_path)); +#ifdef __APPLE__ + // TODO: Revisit when QTBUG-42661 is fixed + widget->window()->raise(); +#endif + + if (dir.isEmpty()) + return; + + list->addItem(dir); + EditableListChanged(); +} + +void WidgetInfo::EditListRemove() +{ + QListWidget *list = reinterpret_cast(widget); + QList items = list->selectedItems(); + + for (QListWidgetItem *item : items) + delete item; + EditableListChanged(); +} + +void WidgetInfo::EditListEdit() +{ + QListWidget *list = reinterpret_cast(widget); + enum obs_editable_list_type type = + obs_property_editable_list_type(property); + const char *desc = obs_property_description(property); + const char *filter = obs_property_editable_list_filter(property); + QList selectedItems = list->selectedItems(); + + if (!selectedItems.count()) + return; + + QListWidgetItem *item = selectedItems[0]; + + if (type == OBS_EDITABLE_LIST_TYPE_FILES) { + QDir pathDir(item->text()); + QString path; + + if (pathDir.exists()) + path = SelectDirectory(GetSettingsWindow(), + QTStr("Browse"), item->text()); + else + path = OpenFile(GetSettingsWindow(), QTStr("Browse"), + item->text(), QT_UTF8(filter)); + + if (path.isEmpty()) + return; + + item->setText(path); + EditableListChanged(); + return; + } + + EditableItemDialog dialog(widget->window(), item->text(), + type != OBS_EDITABLE_LIST_TYPE_STRINGS, + filter); + auto title = QTStr("Basic.PropertiesWindow.EditEditableListEntry") + .arg(QT_UTF8(desc)); + dialog.setWindowTitle(title); + if (dialog.exec() == QDialog::Rejected) + return; + + QString text = dialog.GetText(); + if (text.isEmpty()) + return; + + item->setText(text); + EditableListChanged(); +} + +void WidgetInfo::EditListUp() +{ + QListWidget *list = reinterpret_cast(widget); + int lastItemRow = -1; + + for (int i = 0; i < list->count(); i++) { + QListWidgetItem *item = list->item(i); + if (!item->isSelected()) + continue; + + int row = list->row(item); + + if ((row - 1) != lastItemRow) { + lastItemRow = row - 1; + list->takeItem(row); + list->insertItem(lastItemRow, item); + item->setSelected(true); + } else { + lastItemRow = row; + } + } + + EditableListChanged(); +} + +void WidgetInfo::EditListDown() +{ + QListWidget *list = reinterpret_cast(widget); + int lastItemRow = list->count(); + + for (int i = list->count() - 1; i >= 0; i--) { + QListWidgetItem *item = list->item(i); + if (!item->isSelected()) + continue; + + int row = list->row(item); + + if ((row + 1) != lastItemRow) { + lastItemRow = row + 1; + list->takeItem(row); + list->insertItem(lastItemRow, item); + item->setSelected(true); + } else { + lastItemRow = row; + } + } + + EditableListChanged(); +} + +} // namespace advss + +#endif diff --git a/lib/utils/properties-view.hpp b/lib/utils/properties-view.hpp new file mode 100644 index 00000000..614db8d0 --- /dev/null +++ b/lib/utils/properties-view.hpp @@ -0,0 +1,264 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +#if LIBOBS_API_VER >= MAKE_SEMANTIC_VERSION(30, 0, 0) + +class QFormLayout; +class QLabel; +class QResizeEvent; + +typedef obs_properties_t *(*PropertiesReloadCallback)(void *obj); +typedef void (*PropertiesUpdateCallback)(void *obj, obs_data_t *old_settings, + obs_data_t *new_settings); +typedef void (*PropertiesVisualUpdateCb)(void *obj, obs_data_t *settings); + +namespace advss { + +class OBSPropertiesView; + +/* ------------------------------------------------------------------------- */ + +class VScrollArea : public QScrollArea { + Q_OBJECT + +public: + VScrollArea(QWidget *parent = nullptr); + +protected: + virtual void resizeEvent(QResizeEvent *event) override; +}; + +/* ------------------------------------------------------------------------- */ + +class SpinBoxIgnoreScroll : public QSpinBox { + Q_OBJECT + +public: + SpinBoxIgnoreScroll(QWidget *parent = nullptr); + +protected: + virtual void wheelEvent(QWheelEvent *event) override; +}; + +/* ------------------------------------------------------------------------- */ + +class OBSPlainTextEdit : public QPlainTextEdit { + Q_OBJECT + +public: + explicit OBSPlainTextEdit(QWidget *parent = nullptr, + bool monospace = true); +}; + +/* ------------------------------------------------------------------------- */ + +class WidgetInfo : public QObject { + Q_OBJECT + + friend class OBSPropertiesView; + +private: + OBSPropertiesView *view; + obs_property_t *property; + QWidget *widget; + QPointer update_timer; + bool recently_updated = false; + OBSData old_settings_cache; + + void BoolChanged(const char *setting); + void IntChanged(const char *setting); + void FloatChanged(const char *setting); + void TextChanged(const char *setting); + bool PathChanged(const char *setting); + void ListChanged(const char *setting); + bool ColorChangedInternal(const char *setting, bool supportAlpha); + bool ColorChanged(const char *setting); + bool ColorAlphaChanged(const char *setting); + bool FontChanged(const char *setting); + void GroupChanged(const char *setting); + void EditableListChanged(); + void ButtonClicked(); + + void TogglePasswordText(bool checked); + +public: + inline WidgetInfo(OBSPropertiesView *view_, obs_property_t *prop, + QWidget *widget_) + : view(view_), + property(prop), + widget(widget_) + { + } + + ~WidgetInfo() + { + if (update_timer) { + update_timer->stop(); + QMetaObject::invokeMethod(update_timer, "timeout"); + update_timer->deleteLater(); + } + } + +public slots: + + void ControlChanged(); + + /* editable list */ + void EditListAdd(); + void EditListAddText(); + void EditListAddFiles(); + void EditListAddDir(); + void EditListRemove(); + void EditListEdit(); + void EditListUp(); + void EditListDown(); +}; + +/* ------------------------------------------------------------------------- */ + +class OBSPropertiesView : public VScrollArea { + Q_OBJECT + + friend class WidgetInfo; + + using properties_delete_t = decltype(&obs_properties_destroy); + using properties_t = + std::unique_ptr; + +private: + QWidget *widget = nullptr; + properties_t properties; + OBSData settings; + OBSWeakObjectAutoRelease weakObj; + void *rawObj = nullptr; + std::string type; + PropertiesReloadCallback reloadCallback; + PropertiesUpdateCallback callback = nullptr; + PropertiesVisualUpdateCb visUpdateCb = nullptr; + int minSize; + std::vector> children; + std::string lastFocused; + QWidget *lastWidget = nullptr; + bool deferUpdate; + bool enableDefer = true; + + template + QWidget *NewWidget(obs_property_t *prop, Sender *widget, + void (SenderParent::*signal)(Args...)); + + QWidget *AddCheckbox(obs_property_t *prop); + QWidget *AddText(obs_property_t *prop, QFormLayout *layout, + QLabel *&label); + void AddPath(obs_property_t *prop, QFormLayout *layout, QLabel **label); + void AddInt(obs_property_t *prop, QFormLayout *layout, QLabel **label); + void AddFloat(obs_property_t *prop, QFormLayout *layout, + QLabel **label); + QWidget *AddList(obs_property_t *prop, bool &warning); + void AddEditableList(obs_property_t *prop, QFormLayout *layout, + QLabel *&label); + QWidget *AddButton(obs_property_t *prop); + void AddColorInternal(obs_property_t *prop, QFormLayout *layout, + QLabel *&label, bool supportAlpha); + void AddColor(obs_property_t *prop, QFormLayout *layout, + QLabel *&label); + void AddColorAlpha(obs_property_t *prop, QFormLayout *layout, + QLabel *&label); + void AddFont(obs_property_t *prop, QFormLayout *layout, QLabel *&label); + void AddFrameRate(obs_property_t *prop, bool &warning, + QFormLayout *layout, QLabel *&label); + + void AddGroup(obs_property_t *prop, QFormLayout *layout); + + void AddProperty(obs_property_t *property, QFormLayout *layout); + + void resizeEvent(QResizeEvent *event) override; + + void GetScrollPos(int &h, int &v, int &hend, int &vend); + void SetScrollPos(int h, int v, int old_hend, int old_vend); + +private slots: + void RefreshProperties(); + +public slots: + void ReloadProperties(); + void SignalChanged(); + +signals: + void PropertiesResized(); + void Changed(); + void PropertiesRefreshed(); + +public: + OBSPropertiesView(OBSData settings, obs_object_t *obj, + PropertiesReloadCallback reloadCallback, + PropertiesUpdateCallback callback, + PropertiesVisualUpdateCb cb = nullptr, + int minSize = 0); + OBSPropertiesView(OBSData settings, void *obj, + PropertiesReloadCallback reloadCallback, + PropertiesUpdateCallback callback, + PropertiesVisualUpdateCb cb = nullptr, + int minSize = 0); + OBSPropertiesView(OBSData settings, const char *type, + PropertiesReloadCallback reloadCallback, + int minSize = 0); + +#define obj_constructor(type) \ + inline OBSPropertiesView(OBSData settings, obs_##type##_t *type, \ + PropertiesReloadCallback reloadCallback, \ + PropertiesUpdateCallback callback, \ + PropertiesVisualUpdateCb cb = nullptr, \ + int minSize = 0) \ + : OBSPropertiesView(settings, (obs_object_t *)type, \ + reloadCallback, callback, cb, minSize) \ + { \ + } + + obj_constructor(source); + obj_constructor(output); + obj_constructor(encoder); + obj_constructor(service); +#undef obj_constructor + + inline obs_data_t *GetSettings() const { return settings; } + + inline void UpdateSettings() + { + if (callback) + callback(OBSGetStrongRef(weakObj), nullptr, settings); + else if (visUpdateCb) + visUpdateCb(OBSGetStrongRef(weakObj), settings); + } + inline bool DeferUpdate() const { return deferUpdate; } + inline void SetDeferrable(bool deferrable) { enableDefer = deferrable; } + + inline OBSObject GetObject() const { return OBSGetStrongRef(weakObj); } + +#define Def_IsObject(type) \ + inline bool IsObject(obs_##type##_t *type) const \ + { \ + OBSObject obj = OBSGetStrongRef(weakObj); \ + return obj.Get() == (obs_object_t *)type; \ + } + + /* clang-format off */ + Def_IsObject(source) + Def_IsObject(output) + Def_IsObject(encoder) + Def_IsObject(service) + /* clang-format on */ + +#undef Def_IsObject +}; + +} // namespace advss + +#endif diff --git a/lib/utils/properties-view.moc.hpp b/lib/utils/properties-view.moc.hpp new file mode 100644 index 00000000..39f32a77 --- /dev/null +++ b/lib/utils/properties-view.moc.hpp @@ -0,0 +1,63 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include +#include + +#include + +#ifdef _MSC_VER +#pragma warning(disable : 4505) +#endif + +static bool operator!=(const media_frames_per_second &a, + const media_frames_per_second &b) +{ + return a.numerator != b.numerator || a.denominator != b.denominator; +} + +static bool operator==(const media_frames_per_second &a, + const media_frames_per_second &b) +{ + return !(a != b); +} + +using frame_rate_range_t = + std::pair; +using frame_rate_ranges_t = std::vector; + +class OBSFrameRatePropertyWidget : public QWidget { + Q_OBJECT + +public: + frame_rate_ranges_t fps_ranges; + + QComboBox *modeSelect = nullptr; + QStackedWidget *modeDisplay = nullptr; + + QWidget *labels = nullptr; + QLabel *currentFPS = nullptr; + QLabel *timePerFrame = nullptr; + QLabel *minLabel = nullptr; + QLabel *maxLabel = nullptr; + + QComboBox *simpleFPS = nullptr; + + QComboBox *fpsRange = nullptr; + QSpinBox *numEdit = nullptr; + QSpinBox *denEdit = nullptr; + + bool updating = false; + + const char *name = nullptr; + obs_data_t *settings = nullptr; + + QLabel *warningLabel = nullptr; + + OBSFrameRatePropertyWidget() = default; +}; diff --git a/scripting/examples.lua b/scripting/examples.lua new file mode 100644 index 00000000..32fea131 --- /dev/null +++ b/scripting/examples.lua @@ -0,0 +1,283 @@ +------------------------------------------------------------------------------- +-- Since lua does not support threads natively, you will have to be careful to +-- to not block the main OBS script thread with long running actions or +-- conditions. +-- +-- Also note that due to this limitation only one instance of the actions and +-- conditions defined in this script can be executed at a time. +-- If multiple actions or conditions would have been executed at the same time, +-- they will be executed sequentially instead. +-- +-- Consider switching to python instead, if those limitations are a problem. +------------------------------------------------------------------------------- +obs = obslua + +------------------------------------------------------------------------------- + +-- Simple action callback example + +function my_lua_action(data) + obs.script_log(obs.LOG_WARNING, "hello from lua!") +end + +------------------------------------------------------------------------------- + +-- Action showcasing how to provide configurable settings + +function get_action_properties() + local props = obs.obs_properties_create() + obs.obs_properties_add_text(props, "name", "Name", obs.OBS_TEXT_DEFAULT) + return props +end + +function get_action_defaults() + local default_settings = obs.obs_data_create() + obs.obs_data_set_default_string(default_settings, "name", "John") + return default_settings +end + +function my_lua_settings_action(data) + local name = obs.obs_data_get_string(data, "name") + obs.script_log(obs.LOG_WARNING, string.format("hello %s from lua", name)) +end + +------------------------------------------------------------------------------- + +-- Action callback demonstrating the interacting with variables + +counter = 0 + +function variable_lua_action(data) + local value = advss_get_variable_value("variable") + if value ~= nil then + obs.script_log(obs.LOG_WARNING, string.format("variable has value: %s", value)) + end + + counter = counter + 1 + advss_set_variable_value("variable", counter) +end + +------------------------------------------------------------------------------- + +-- Example condition randomly returning true or false based on user configured +-- probability value + +math.randomseed(os.time()) + +function get_condition_properties() + local props = obs.obs_properties_create() + obs.obs_properties_add_float(props, "probability", "Probability of returning true", 0, 100, 0.1) + return props +end + +function get_condition_defaults() + local default_settings = obs.obs_data_create() + obs.obs_data_set_default_double(default_settings, "probability", 33.3) + return default_settings +end + +function my_lua_condition(data) + local target = obs.obs_data_get_double(data, "probability") + local value = math.random(0, 100) + return value <= target +end + +------------------------------------------------------------------------------- + +function script_load(settings) + -- Register an example action + advss_register_action("My simple Lua action", my_lua_action, nil, nil) + + -- Register an example action with settings + advss_register_action("My Lua action with settings", my_lua_settings_action, get_action_properties, + get_action_defaults()) + + -- This example action demonstrates how to interact with variables + advss_register_action("My variable Lua action", variable_lua_action, nil, nil) + + -- Register an example condition + advss_register_condition("My Lua condition", my_lua_condition, get_condition_properties, get_condition_defaults()) +end + +function script_unload() + -- Deregistering is useful if you plan on reloading the script files + advss_deregister_action("My simple Lua action") + advss_deregister_action("My Lua action with settings") + advss_deregister_action("My variable Lua action") + + advss_deregister_condition("My Lua condition") +end + +------------------------------------------------------------------------------- + +-- Advanced Scene Switcher helper functions below: +-- Usually you should not have to modify this code. +-- Simply copy paste it into your scripts. + +------------------------------------------------------------------------------- +-- Actions +------------------------------------------------------------------------------- + +-- The advss_register_action() function is used to register custom actions +-- It takes the following arguments: +-- 1. The name of the new action type. +-- 2. The function callback which should run when the action is executed. +-- 3. The optional function callback which return the properties to display the +-- settings of this action type. +-- 4. The optional default_settings pointer used to set the default settings of +-- newly created actions. +-- The pointer must not be freed within this script. +function advss_register_action(name, callback, get_properties, default_settings) + advss_register_segment_type(true, name, callback, get_properties, default_settings) +end + +function advss_deregister_action(name) + advss_deregister_segment(true, name) +end + +------------------------------------------------------------------------------- +-- Conditions +------------------------------------------------------------------------------- + +-- The advss_register_condition() function is used to register custom conditions +-- It takes the following arguments: +-- 1. The name of the new condition type. +-- 2. The function callback which should run when the condition is executed. +-- 3. The optional function callback which return the properties to display the +-- settings of this condition type. +-- 4. The optional default_settings pointer used to set the default settings of +-- newly created condition. +-- The pointer must not be freed within this script. +function advss_register_condition(name, callback, get_properties, default_settings) + advss_register_segment_type(false, name, callback, get_properties, default_settings) +end + +function advss_deregister_condition(name) + advss_deregister_segment(false, name) +end + +------------------------------------------------------------------------------- +-- (De)register helpers +------------------------------------------------------------------------------- + +function advss_register_segment_type(is_action, name, callback, get_properties, default_settings) + local proc_handler = obs.obs_get_proc_handler() + local data = obs.calldata_create() + + obs.calldata_set_string(data, "name", name) + obs.calldata_set_ptr(data, "default_settings", default_settings) + + local register_proc = is_action and "advss_register_script_action" or "advss_register_script_condition" + obs.proc_handler_call(proc_handler, register_proc, data) + + local success = obs.calldata_bool(data, "success") + if success == false then + local segment_type = is_action and "action" or "condition" + obs.script_log(obs.LOG_WARNING, string.format("failed to register custom %s \"%s\"", segment_type, name)) + obs.calldata_destroy(data) + return + end + + -- Lua does not support threads natively. + -- So, we will call the provided callback function directly. + local run_helper = (function(data) + local completion_signal_name = obs.calldata_string(data, "completion_signal_name") + local id = obs.calldata_int(data, "completion_id") + local settings = obs.obs_data_create_from_json(obs.calldata_string(data, "settings")) + + local callback_result = callback(settings) + if is_action then + callback_result = true + end + + obs.obs_data_release(settings) + + local reply_data = obs.calldata_create() + obs.calldata_set_int(reply_data, "completion_id", id) + obs.calldata_set_bool(reply_data, "result", callback_result) + local signal_handler = obs.obs_get_signal_handler() + obs.signal_handler_signal(signal_handler, completion_signal_name, reply_data) + obs.calldata_destroy(reply_data) + end) + + local properties_helper = (function(data) + if get_properties ~= nil then + obs.calldata_set_ptr(data, "properties", get_properties()) + else + obs.calldata_set_ptr(data, "properties", nil) + end + end) + + local signal_name = obs.calldata_string(data, "trigger_signal_name") + local property_signal_name = obs.calldata_string(data, "properties_signal_name") + local signal_handler = obs.obs_get_signal_handler() + obs.signal_handler_connect(signal_handler, signal_name, run_helper) + obs.signal_handler_connect(signal_handler, property_signal_name, properties_helper) + + obs.calldata_destroy(data) +end + +function advss_deregister_segment(is_action, name) + local proc_handler = obs.obs_get_proc_handler() + local data = obs.calldata_create() + + obs.calldata_set_string(data, "name", name) + + local deregister_proc = is_action and "advss_deregister_script_action" or "advss_deregister_script_condition" + obs.proc_handler_call(proc_handler, deregister_proc, data) + + local success = obs.calldata_bool(data, "success") + if success == false then + local segment_type = is_action and "action" or "condition" + obs.script_log(obs.LOG_WARNING, string.format("failed to deregister custom %s \"%s\"", segment_type, name)) + end + + obs.calldata_destroy(data) +end + +------------------------------------------------------------------------------- +-- Variables +------------------------------------------------------------------------------- + +-- The advss_get_variable_value() function can be used to query the value of a +-- variable with a given name. +-- nil is returned in case the variable does not exist. +function advss_get_variable_value(name) + local proc_handler = obs.obs_get_proc_handler() + local data = obs.calldata_create() + + obs.calldata_set_string(data, "name", name) + obs.proc_handler_call(proc_handler, "advss_get_variable_value", data) + + local success = obs.calldata_bool(data, "success") + if success == false then + obs.script_log(obs.LOG_WARNING, string.format("failed to get value for variable \"%s\"", name)) + obs.calldata_destroy(data) + return nil + end + + local value = obs.calldata_string(data, "value") + + obs.calldata_destroy(data) + return value +end + +-- The advss_set_variable_value() function can be used to set the value of a +-- variable with a given name. +-- True is returned if the operation was successful. +function advss_set_variable_value(name, value) + local proc_handler = obs.obs_get_proc_handler() + local data = obs.calldata_create() + + obs.calldata_set_string(data, "name", name) + obs.calldata_set_string(data, "value", value) + obs.proc_handler_call(proc_handler, "advss_set_variable_value", data) + + local success = obs.calldata_bool(data, "success") + if success == false then + obs.script_log(obs.LOG_WARNING, string.format("failed to set value for variable \"%s\"", name)) + end + + obs.calldata_destroy(data) + return success +end diff --git a/scripting/examples.py b/scripting/examples.py new file mode 100644 index 00000000..6b63192f --- /dev/null +++ b/scripting/examples.py @@ -0,0 +1,305 @@ +import obspython as obs +import threading # Required by advss helpers +import random # Required for example condition + +############################################################################### + +# Simple action callback example + + +def my_python_action(data): + obs.script_log(obs.LOG_WARNING, "hello from python!") + + +############################################################################### + +# Action showcasing how to provide configurable settings + + +def get_action_properties(): + props = obs.obs_properties_create() + obs.obs_properties_add_text(props, "name", "Name", obs.OBS_TEXT_DEFAULT) + return props + + +def get_action_defaults(): + default_settings = obs.obs_data_create() + obs.obs_data_set_default_string(default_settings, "name", "John") + return default_settings + + +def my_python_settings_action(data): + name = obs.obs_data_get_string(data, "name") + obs.script_log(obs.LOG_WARNING, f"hello {name} from python!") + + +############################################################################### + +# Action callback demonstrating the interacting with variables + +counter = 0 + + +def variable_python_action(data): + value = advss_get_variable_value("variable") + if value is not None: + obs.script_log(obs.LOG_WARNING, f"variable has value: {value}") + + global counter + counter += 1 + advss_set_variable_value("variable", str(counter)) + + +############################################################################### + +# Example condition randomly returning true or false based on user configured +# probability value + + +def get_condition_properties(): + props = obs.obs_properties_create() + obs.obs_properties_add_float( + props, "probability", "Probability of returning true", 0, 100, 0.1 + ) + return props + + +def get_condition_defaults(): + default_settings = obs.obs_data_create() + obs.obs_data_set_default_double(default_settings, "probability", 33.3) + return default_settings + + +def my_python_condition(data): + target = obs.obs_data_get_double(data, "probability") + value = random.uniform(0, 100) + return value <= target + + +############################################################################### + + +def script_load(settings): + # Register an example action + advss_register_action("My simple Python action", my_python_action) + + # Register an example action with settings + advss_register_action( + "My Python action with settings", + my_python_settings_action, + get_action_properties, + get_action_defaults(), + ) + + # This example action demonstrates how to interact with variables + advss_register_action("My variable Python action", variable_python_action) + + # Register an example condition + advss_register_condition( + "My Python condition", + my_python_condition, + get_condition_properties, + get_condition_defaults(), + ) + + +def script_unload(): + # Deregistering is useful if you plan on reloading the script files + advss_deregister_action("My simple Python action") + advss_deregister_action("My Python action with settings") + advss_deregister_action("My variable Python action") + + advss_deregister_condition("My Python condition") + + +############################################################################### + +# Advanced Scene Switcher helper functions below: +# Usually you should not have to modify this code. +# Simply copy paste it into your scripts. + +############################################################################### +# Actions +############################################################################### + + +# The advss_register_action() function is used to register custom actions +# It takes the following arguments: +# 1. The name of the new action type. +# 2. The function callback which should run when the action is executed. +# 3. The optional function callback which return the properties to display the +# settings of this action type. +# 4. The optional default_settings pointer used to set the default settings of +# newly created actions. +# The pointer must not be freed within this script. +def advss_register_action(name, callback, get_properties=None, default_settings=None): + advss_register_segment_type(True, name, callback, get_properties, default_settings) + + +def advss_deregister_action(name): + advss_deregister_segment(True, name) + + +############################################################################### +# Conditions +############################################################################### + + +# The advss_register_condition() function is used to register custom conditions +# It takes the following arguments: +# 1. The name of the new condition type. +# 2. The function callback which should run when the condition is executed. +# 3. The optional function callback which return the properties to display the +# settings of this condition type. +# 4. The optional default_settings pointer used to set the default settings of +# newly created condition. +# The pointer must not be freed within this script. +def advss_register_condition( + name, callback, get_properties=None, default_settings=None +): + advss_register_segment_type(False, name, callback, get_properties, default_settings) + + +def advss_deregister_condition(name): + advss_deregister_segment(False, name) + + +############################################################################### +# (De)register helpers +############################################################################### + + +def advss_register_segment_type( + is_action, name, callback, get_properties, default_settings +): + proc_handler = obs.obs_get_proc_handler() + data = obs.calldata_create() + + obs.calldata_set_string(data, "name", name) + obs.calldata_set_ptr(data, "default_settings", default_settings) + + register_proc = ( + "advss_register_script_action" + if is_action + else "advss_register_script_condition" + ) + obs.proc_handler_call(proc_handler, register_proc, data) + + success = obs.calldata_bool(data, "success") + if success == False: + segment_type = "action" if is_action else "condition" + log_msg = f'failed to register custom {segment_type} "{name}"' + obs.script_log(obs.LOG_WARNING, log_msg) + obs.calldata_destroy(data) + return + + # Run in separate thread to avoid blocking main OBS signal handler. + # Operation completion will be indicated via signal completion_signal_name. + def run_helper(data): + completion_signal_name = obs.calldata_string(data, "completion_signal_name") + id = obs.calldata_int(data, "completion_id") + + def thread_func(settings): + settings = obs.obs_data_create_from_json( + obs.calldata_string(data, "settings") + ) + callback_result = callback(settings) + if is_action: + callback_result = True + + reply_data = obs.calldata_create() + obs.calldata_set_int(reply_data, "completion_id", id) + obs.calldata_set_bool(reply_data, "result", callback_result) + signal_handler = obs.obs_get_signal_handler() + obs.signal_handler_signal( + signal_handler, completion_signal_name, reply_data + ) + obs.obs_data_release(settings) + obs.calldata_destroy(reply_data) + + threading.Thread(target=thread_func, args={data}).start() + + def properties_helper(data): + if get_properties is not None: + properties = get_properties() + else: + properties = None + obs.calldata_set_ptr(data, "properties", properties) + + trigger_signal_name = obs.calldata_string(data, "trigger_signal_name") + property_signal_name = obs.calldata_string(data, "properties_signal_name") + + signal_handler = obs.obs_get_signal_handler() + obs.signal_handler_connect(signal_handler, trigger_signal_name, run_helper) + obs.signal_handler_connect(signal_handler, property_signal_name, properties_helper) + + obs.calldata_destroy(data) + + +def advss_deregister_segment(is_action, name): + proc_handler = obs.obs_get_proc_handler() + data = obs.calldata_create() + + obs.calldata_set_string(data, "name", name) + + deregister_proc = ( + "advss_deregister_script_action" + if is_action + else "advss_deregister_script_condition" + ) + + obs.proc_handler_call(proc_handler, deregister_proc, data) + + success = obs.calldata_bool(data, "success") + if success == False: + segment_type = "action" if is_action else "condition" + log_msg = f'failed to deregister custom {segment_type} "{name}"' + obs.script_log(obs.LOG_WARNING, log_msg) + + obs.calldata_destroy(data) + + +############################################################################### +# Variables +############################################################################### + + +# The advss_get_variable_value() function can be used to query the value of a +# variable with a given name. +# None is returned in case the variable does not exist. +def advss_get_variable_value(name): + proc_handler = obs.obs_get_proc_handler() + data = obs.calldata_create() + + obs.calldata_set_string(data, "name", name) + obs.proc_handler_call(proc_handler, "advss_get_variable_value", data) + + success = obs.calldata_bool(data, "success") + if success == False: + obs.script_log(obs.LOG_WARNING, f'failed to get value for variable "{name}"') + obs.calldata_destroy(data) + return None + + value = obs.calldata_string(data, "value") + + obs.calldata_destroy(data) + return value + + +# The advss_set_variable_value() function can be used to set the value of a +# variable with a given name. +# True is returned if the operation was successful. +def advss_set_variable_value(name, value): + proc_handler = obs.obs_get_proc_handler() + data = obs.calldata_create() + + obs.calldata_set_string(data, "name", name) + obs.calldata_set_string(data, "value", value) + obs.proc_handler_call(proc_handler, "advss_set_variable_value", data) + + success = obs.calldata_bool(data, "success") + if success == False: + obs.script_log(obs.LOG_WARNING, f'failed to set value for variable "{name}"') + + obs.calldata_destroy(data) + return success