Compare commits

...

40 Commits

Author SHA1 Message Date
WarmUpTill
b4a1593dc9 Fix crash on shutdown caused by "Profile" condition
Some checks failed
debian-build / build (push) Has been cancelled
Push to master / Check Formatting 🔍 (push) Has been cancelled
Push to master / Build Project 🧱 (push) Has been cancelled
Push to master / Create Release 🛫 (push) Has been cancelled
2026-07-07 20:19:51 +02:00
WarmUpTill
577cfc6ba9 Add message_text temp var for subscription message event
Some checks failed
debian-build / build (push) Waiting to run
Push to master / Check Formatting 🔍 (push) Waiting to run
Push to master / Build Project 🧱 (push) Waiting to run
Push to master / Create Release 🛫 (push) Blocked by required conditions
Check locale / ubuntu64 (push) Has been cancelled
The existing "message" temp var for the subscription message event
contained a raw JSON object (including emote metadata) rather than
plain text, making it difficult to use.
2026-07-06 17:19:32 +02:00
WarmUpTill
f7a29dfa69 Add option to start and stop polls 2026-07-06 17:19:32 +02:00
WarmUpTill
e3475cf8ee Add option to wait for scene item transition
Some checks failed
debian-build / build (push) Has been cancelled
Check locale / ubuntu64 (push) Has been cancelled
Push to master / Check Formatting 🔍 (push) Has been cancelled
Push to master / Build Project 🧱 (push) Has been cancelled
Push to master / Create Release 🛫 (push) Has been cancelled
2026-07-05 20:41:12 +02:00
WarmUpTill
b344f75a30 Add variable setter / getter to inline script generated code 2026-07-05 20:26:36 +02:00
WarmUpTill
338a478ead Cleanup Twitch timestamp verification
Some checks failed
debian-build / build (push) Has been cancelled
Push to master / Check Formatting 🔍 (push) Has been cancelled
Push to master / Build Project 🧱 (push) Has been cancelled
Push to master / Create Release 🛫 (push) Has been cancelled
2026-07-04 12:09:31 +02:00
WarmUpTill
2658ced18e Add "Audio" option to the first run wizard
Some checks failed
debian-build / build (push) Waiting to run
Push to master / Check Formatting 🔍 (push) Waiting to run
Push to master / Build Project 🧱 (push) Waiting to run
Push to master / Create Release 🛫 (push) Blocked by required conditions
Check locale / ubuntu64 (push) Has been cancelled
2026-07-03 18:57:32 +02:00
WarmUpTill
cd6ff4d52b Add "Sequence" option to the first run wizard 2026-07-03 18:57:32 +02:00
WarmUpTill
5438a6a364 Split first run wizard into multiple files 2026-07-03 18:57:32 +02:00
WarmUpTill
ea1dea8621 Add option to wait for variable changes to "Wait" action type
Some checks are pending
debian-build / build (push) Waiting to run
Check locale / ubuntu64 (push) Waiting to run
Push to master / Check Formatting 🔍 (push) Waiting to run
Push to master / Build Project 🧱 (push) Waiting to run
Push to master / Create Release 🛫 (push) Blocked by required conditions
2026-07-03 15:59:23 +02:00
WarmUpTill
3a556113aa Expose API to notify about variable value change 2026-07-03 15:59:23 +02:00
WarmUpTill
eded0367db Add option to get commercial info 2026-07-03 15:59:23 +02:00
WarmUpTill
6c90f517e4 Fix freeze when stopping waiting macro via "Run macro" button 2026-07-03 15:59:23 +02:00
WarmUpTill
c07c00325f Switch to VariableColorButton for OCR and color match
Some checks failed
debian-build / build (push) Has been cancelled
Check locale / ubuntu64 (push) Has been cancelled
Push to master / Check Formatting 🔍 (push) Has been cancelled
Push to master / Build Project 🧱 (push) Has been cancelled
Push to master / Create Release 🛫 (push) Has been cancelled
2026-06-30 20:30:50 +02:00
WarmUpTill
0a2e2cd771 Add VariableColorButton
Used for color selection which also support variables
2026-06-30 20:30:50 +02:00
WarmUpTill
8f3b5f9dfc CI: Rename "legacy" Windows artifacts to "portable"
This is consistent with the naming used by other plugin developers
2026-06-30 19:54:52 +02:00
WarmUpTill
78499bcf5f Improve layout spacing for "Recording" action
Some checks are pending
debian-build / build (push) Waiting to run
Check locale / ubuntu64 (push) Waiting to run
Push to master / Check Formatting 🔍 (push) Waiting to run
Push to master / Build Project 🧱 (push) Waiting to run
Push to master / Create Release 🛫 (push) Blocked by required conditions
2026-06-29 17:48:42 +02:00
WarmUpTill
78fa776ce5 Move recording tips to HelpIcons 2026-06-29 17:48:42 +02:00
WarmUpTill
93c6415547 Add restart hint to recording folder/format actions 2026-06-29 17:48:42 +02:00
WarmUpTill
12f26c29e6 Clarify COPY_VAR layout to show source and destination variables
Some checks failed
debian-build / build (push) Has been cancelled
Check locale / ubuntu64 (push) Has been cancelled
Push to master / Check Formatting 🔍 (push) Has been cancelled
Push to master / Build Project 🧱 (push) Has been cancelled
Push to master / Create Release 🛫 (push) Has been cancelled
2026-06-26 22:29:06 +02:00
WarmUpTill
e4f6d94247 Add option to automatically extract value from JSON query result 2026-06-26 22:29:06 +02:00
WarmUpTill
a04c51397d Fix variables of nested macros' actions not being resolved
Some checks failed
debian-build / build (push) Has been cancelled
Push to master / Check Formatting 🔍 (push) Has been cancelled
Push to master / Build Project 🧱 (push) Has been cancelled
Push to master / Create Release 🛫 (push) Has been cancelled
2026-06-17 21:12:48 +02:00
WarmUpTill
2073ef6ce9 Fix nested macro actions being executed twice when added to a queue 2026-06-17 21:12:48 +02:00
WarmUpTill
7dfd9ddcb5 Print plugin enabled/disabled summary during CMake configure
Some checks are pending
debian-build / build (push) Waiting to run
Push to master / Check Formatting 🔍 (push) Waiting to run
Push to master / Build Project 🧱 (push) Waiting to run
Push to master / Create Release 🛫 (push) Blocked by required conditions
2026-06-16 17:40:22 +02:00
WarmUpTill
6215c39024 Don't fail cmake configure step if openvr deps are not met 2026-06-16 17:40:22 +02:00
WarmUpTill
1c856911e8 Set default value for CMAKE_GENERATOR_PLATFORM on Windows 2026-06-16 17:40:22 +02:00
WarmUpTill
ed17c2ac6d Disconnect Twitch chat and event connection on plugin stop
Some checks failed
debian-build / build (push) Has been cancelled
Push to master / Check Formatting 🔍 (push) Has been cancelled
Push to master / Build Project 🧱 (push) Has been cancelled
Push to master / Create Release 🛫 (push) Has been cancelled
2026-06-13 22:05:32 +02:00
WarmUpTill
1ce332a91d Track all kWin wayland windows 2026-06-13 22:01:52 +02:00
WarmUpTill
2aa27fc051 Fix window conditions not triggering for native Wayland windows
When KWin compat is active, GetWindows() only returned XWayland windows
since it relied on X11's _NET_CLIENT_LIST. Native Wayland windows were
invisible to conditions even though GetCurrentWindowTitle() reported them
correctly via the KWin DBus notifier.
2026-06-13 22:01:52 +02:00
WarmUpTill
31b239d5dc Focus segment type selection when new segment is added
Some checks failed
debian-build / build (push) Has been cancelled
Push to master / Check Formatting 🔍 (push) Has been cancelled
Push to master / Build Project 🧱 (push) Has been cancelled
Push to master / Create Release 🛫 (push) Has been cancelled
2026-06-11 18:25:29 +02:00
WarmUpTill
7c3779e287 Sort macro export dialog resources alphabetically and add scrollbars 2026-06-11 18:25:29 +02:00
WarmUpTill
3f13c33b10 Fix crash on transition start in studio mode 2026-06-11 18:25:29 +02:00
WarmUpTill
8727a65513 Support both recommended and legacy layouts in Windows installer
Some checks failed
debian-build / build (push) Has been cancelled
Push to master / Check Formatting 🔍 (push) Has been cancelled
Push to master / Build Project 🧱 (push) Has been cancelled
Push to master / Create Release 🛫 (push) Has been cancelled
Default to %ProgramData%\obs-studio\plugins\ on fresh installs.
Restore the previously recorded install path on upgrades, so users
who installed to a custom location are not silently moved.
2026-05-30 16:01:11 +02:00
WarmUpTill
baf3d0e63c Fix race condition in Twitch event sub reconnection logic 2026-05-30 16:01:11 +02:00
WarmUpTill
ea2d1e7e9d Fix crash when importing macros is slow
Probably not a real issue.
Noticed during debug of another problem.
2026-05-30 16:01:11 +02:00
WarmUpTill
f9f26ad50b Fix crash on shutdown
StopAllMacros was called before th->wait(), allowing Macro::Stop()
to run concurrently with the other threads still inside
CheckConditions. This caused races on _conditionCheckFuture (called
from both threads) and _helperThreads.

This could happen if conditions / actions were marked to run in parallel
to the main loop.
2026-05-30 16:01:11 +02:00
WarmUpTill
9911c41a35 Split advanced-scene-switcher-*-windows-x64.zip
One zip archive for the ProgramData path.
Another zip archive for the legacy Program Files path.

Requested in:
https://github.com/WarmUpTill/SceneSwitcher/discussions/1628
2026-05-30 16:01:11 +02:00
WarmUpTill
d590bc11f8 Fix freeze when choosing invalid OCR model 2026-05-30 16:01:11 +02:00
WarmUpTill
31c4c1f5d7 Convert "Program Files/" paths to "ProgramData/" paths on Windows
Affects OCR and object detection functionality of the video condition
2026-05-30 16:01:11 +02:00
WarmUpTill
42ff48d637 Add option to lock settings dialog with simple password
Some checks failed
debian-build / build (push) Has been cancelled
Check locale / ubuntu64 (push) Has been cancelled
Push to master / Check Formatting 🔍 (push) Has been cancelled
Push to master / Build Project 🧱 (push) Has been cancelled
Push to master / Create Release 🛫 (push) Has been cancelled
This is meant as a simple tampering protection.
The settings are not encrypted and the password is stored in plaintext.
2026-05-28 21:50:00 +02:00
91 changed files with 4148 additions and 909 deletions

View File

@@ -68,44 +68,53 @@ function Package {
Remove-Item @RemoveArgs
# Build a staging directory with two subfolders:
# recommended/ - new layout, extract to %ProgramData%\obs-studio\
# legacy/ - old layout, extract to the OBS install directory
$ReleasePath = "${ProjectRoot}/release/${Configuration}"
$StagingPath = "${ProjectRoot}/release/zip-staging"
$NewBinPath = "${ReleasePath}/${ProductName}/bin/64bit"
$NewDataPath = "${ReleasePath}/${ProductName}/data"
Remove-Item -Path $StagingPath -Recurse -Force -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Force -Path $StagingPath | Out-Null
Copy-Item -Path "${ProjectRoot}/build-aux/CI/windows/README.txt" -Destination "${StagingPath}/README.txt"
$ReleasePath = "${ProjectRoot}/release/${Configuration}"
$NewBinPath = "${ReleasePath}/${ProductName}/bin/64bit"
$NewDataPath = "${ReleasePath}/${ProductName}/data"
$CIWindowsDir = "${ProjectRoot}/build-aux/CI/windows"
# --- Recommended zip (new layout, extract to %ProgramData%\obs-studio\) ---
Log-Group "Archiving ${ProductName} (recommended)..."
$RecStaging = "${ProjectRoot}/release/zip-staging-rec"
Remove-Item -Path $RecStaging -Recurse -Force -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Force -Path $RecStaging | Out-Null
Copy-Item -Path "${CIWindowsDir}/README.txt" -Destination "${RecStaging}/README.txt"
if ( Test-Path -Path $NewBinPath ) {
$RecBinPath = "${StagingPath}/recommended/${ProductName}/bin/64bit"
$LegacyBinPath = "${StagingPath}/legacy/obs-plugins/64bit"
New-Item -ItemType Directory -Force -Path $RecBinPath | Out-Null
New-Item -ItemType Directory -Force -Path $LegacyBinPath | Out-Null
Copy-Item -Path "${NewBinPath}/*" -Destination $RecBinPath -Recurse -Force
Copy-Item -Path "${NewBinPath}/*" -Destination $LegacyBinPath -Recurse -Force
$RecBinPath = "${RecStaging}/${ProductName}/bin/64bit"
New-Item -ItemType Directory -Force -Path $RecBinPath | Out-Null
Copy-Item -Path "${NewBinPath}/*" -Destination $RecBinPath -Recurse -Force
}
if ( Test-Path -Path $NewDataPath ) {
$RecDataPath = "${StagingPath}/recommended/${ProductName}/data"
$LegacyDataPath = "${StagingPath}/legacy/data/obs-plugins/${ProductName}"
New-Item -ItemType Directory -Force -Path $RecDataPath | Out-Null
New-Item -ItemType Directory -Force -Path $LegacyDataPath | Out-Null
Copy-Item -Path "${NewDataPath}/*" -Destination $RecDataPath -Recurse -Force
Copy-Item -Path "${NewDataPath}/*" -Destination $LegacyDataPath -Recurse -Force
$RecDataPath = "${RecStaging}/${ProductName}/data"
New-Item -ItemType Directory -Force -Path $RecDataPath | Out-Null
Copy-Item -Path "${NewDataPath}/*" -Destination $RecDataPath -Recurse -Force
}
Compress-Archive -Force -Path (Get-ChildItem -Path $RecStaging) `
-CompressionLevel Optimal `
-DestinationPath "${ProjectRoot}/release/${OutputName}.zip"
Remove-Item -Path $RecStaging -Recurse -Force
Log-Group
Log-Group "Archiving ${ProductName}..."
$CompressArgs = @{
Path = (Get-ChildItem -Path $StagingPath)
CompressionLevel = 'Optimal'
DestinationPath = "${ProjectRoot}/release/${OutputName}.zip"
Verbose = ($null -ne $Env:CI)
# --- Legacy zip (old layout, extract to OBS install directory) ---
Log-Group "Archiving ${ProductName} (portable)..."
$PortableStaging = "${ProjectRoot}/release/zip-staging-portable"
Remove-Item -Path $PortableStaging -Recurse -Force -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Force -Path $PortableStaging | Out-Null
Copy-Item -Path "${CIWindowsDir}/README-portable.txt" -Destination "${PortableStaging}/README.txt"
if ( Test-Path -Path $NewBinPath ) {
$PortableBinPath = "${PortableStaging}/obs-plugins/64bit"
New-Item -ItemType Directory -Force -Path $PortableBinPath | Out-Null
Copy-Item -Path "${NewBinPath}/*" -Destination $PortableBinPath -Recurse -Force
}
Compress-Archive -Force @CompressArgs
Remove-Item -Path $StagingPath -Recurse -Force
if ( Test-Path -Path $NewDataPath ) {
$PortableDataPath = "${PortableStaging}/data/obs-plugins/${ProductName}"
New-Item -ItemType Directory -Force -Path $PortableDataPath | Out-Null
Copy-Item -Path "${NewDataPath}/*" -Destination $PortableDataPath -Recurse -Force
}
Compress-Archive -Force -Path (Get-ChildItem -Path $PortableStaging) `
-CompressionLevel Optimal `
-DestinationPath "${ProjectRoot}/release/${OutputName}-portable.zip"
Remove-Item -Path $PortableStaging -Recurse -Force
Log-Group
if ( ( $BuildInstaller ) ) {
@@ -120,7 +129,24 @@ function Package {
Push-Location -Stack BuildTemp
Ensure-Location -Path "${ProjectRoot}/release"
Remove-Item -Path Package -Recurse -Force -ErrorAction SilentlyContinue
Copy-Item -Path ${Configuration} -Destination Package -Recurse
# Recommended layout (for %ProgramData%\obs-studio\plugins\)
$PkgRec = "Package/recommended"
New-Item -ItemType Directory -Force -Path $PkgRec | Out-Null
Copy-Item -Path "${Configuration}/*" -Destination $PkgRec -Recurse -Force
# Legacy layout (for OBS installation directory)
if ( Test-Path "${Configuration}/${ProductName}/bin/64bit" ) {
$PkgLegBin = "Package/portable/obs-plugins/64bit"
New-Item -ItemType Directory -Force -Path $PkgLegBin | Out-Null
Copy-Item -Path "${Configuration}/${ProductName}/bin/64bit/*" -Destination $PkgLegBin -Recurse -Force
}
if ( Test-Path "${Configuration}/${ProductName}/data" ) {
$PkgLegData = "Package/portable/data/obs-plugins/${ProductName}"
New-Item -ItemType Directory -Force -Path $PkgLegData | Out-Null
Copy-Item -Path "${Configuration}/${ProductName}/data/*" -Destination $PkgLegData -Recurse -Force
}
Invoke-External iscc ${IsccFile} /O"${ProjectRoot}/release" /F"${OutputName}-Installer"
Remove-Item -Path Package -Recurse
Pop-Location -Stack BuildTemp

3
.gitmodules vendored
View File

@@ -28,9 +28,6 @@
[submodule "deps/libusb"]
path = deps/libusb
url = https://github.com/libusb/libusb.git
[submodule "deps/date"]
path = deps/date
url = https://github.com/HowardHinnant/date.git
[submodule "deps/jsoncons"]
path = deps/jsoncons
url = https://github.com/danielaparker/jsoncons.git

View File

@@ -209,6 +209,13 @@ target_sources(
lib/utils/filter-combo-box.hpp
lib/utils/first-run-wizard.cpp
lib/utils/first-run-wizard.hpp
lib/utils/first-run-wizard-audio.cpp
lib/utils/first-run-wizard-audio.hpp
lib/utils/first-run-wizard-helpers.hpp
lib/utils/first-run-wizard-sequence.cpp
lib/utils/first-run-wizard-sequence.hpp
lib/utils/first-run-wizard-window.cpp
lib/utils/first-run-wizard-window.hpp
lib/utils/help-icon.hpp
lib/utils/help-icon.cpp
lib/utils/item-selection-helpers.cpp
@@ -291,6 +298,10 @@ target_sources(
lib/utils/volume-control.hpp
lib/utils/websocket-api.cpp
lib/utils/websocket-api.hpp
lib/variables/variable-color-button.cpp
lib/variables/variable-color-button.hpp
lib/variables/variable-color.cpp
lib/variables/variable-color.hpp
lib/variables/variable-line-edit.cpp
lib/variables/variable-line-edit.hpp
lib/variables/variable-number.hpp

View File

@@ -0,0 +1,23 @@
Advanced Scene Switcher - Windows Installation (Portable)
==========================================================
For full installation instructions visit:
https://github.com/WarmUpTill/SceneSwitcher/wiki/Installation
This archive uses the portable plugin layout for older OBS versions,
portable OBS installs, and Steam.
Extract the CONTENTS of this archive into your OBS installation directory,
typically:
C:\Program Files\obs-studio\
Merge the data\ and obs-plugins\ folders with the existing ones when prompted.
Steam users: right-click OBS in your library -> Manage -> Browse local files,
then extract the contents of this archive into that directory.
For OBS 28 and newer the recommended archive is preferred:
advanced-scene-switcher-<version>-windows-x64.zip

View File

@@ -1,22 +1,15 @@
Advanced Scene Switcher - Windows Installation
==============================================
===============================================
For full installation instructions visit:
https://github.com/WarmUpTill/SceneSwitcher/wiki/Installation
Quick guide
-----------
This archive uses the recommended plugin layout for OBS 28 and newer.
This archive contains two installation methods.
Choose ONE depending on your OBS version.
Extract the CONTENTS of this archive into:
recommended\ (preferred)
---------------------------------------------
Extract the CONTENTS of this folder into:
C:\ProgramData\obs-studio\
C:\ProgramData\obs-studio\plugins\
Tip: press Win+R and type %ProgramData%\obs-studio\plugins to open that folder.
@@ -24,14 +17,5 @@ After extracting, the path should look like:
C:\ProgramData\obs-studio\plugins\advanced-scene-switcher\bin\64bit\advanced-scene-switcher.dll
legacy\ (older OBS versions, portable OBS installs, or if the recommended method does not work)
---------------------------------------------------------------------------
Extract the CONTENTS of this folder into your OBS installation directory,
typically:
C:\Program Files\obs-studio\
Merge the data\ and obs-plugins\ folders with the existing ones when prompted.
Steam users: right-click OBS in your library -> Manage -> Browse local files,
then extract the contents of legacy\ into that directory.
If the plugin does not appear in OBS, try the portable archive instead:
advanced-scene-switcher-<version>-windows-x64-portable.zip

View File

@@ -157,7 +157,9 @@ else()
set_target_properties(${target} PROPERTIES INSTALL_RPATH
"$ORIGIN:$ORIGIN/..")
endif()
message(STATUS "ADVSS: ENABLED PLUGIN ${target}")
get_filename_component(_plugin_dir_name "${CMAKE_CURRENT_SOURCE_DIR}" NAME)
set_property(GLOBAL APPEND PROPERTY ADVSS_PLUGINS_ENABLED
${_plugin_dir_name})
endfunction()
function(install_advss_plugin_dependency_target target dep)

View File

@@ -6,7 +6,11 @@ include(buildspec_common)
# _check_dependencies_windows: Set up Windows slice for _check_dependencies
function(_check_dependencies_windows)
set(arch ${CMAKE_GENERATOR_PLATFORM})
if(CMAKE_GENERATOR_PLATFORM)
set(arch ${CMAKE_GENERATOR_PLATFORM})
else()
set(arch x64)
endif()
set(platform windows-${arch})
set(dependencies_dir "${CMAKE_CURRENT_SOURCE_DIR}/.deps")

View File

@@ -23,14 +23,17 @@ SolidCompression=yes
DirExistsWarning=no
UsePreviousAppDir=no
SetupIconFile=installer.ico
UninstallDisplayIcon={app}\advanced-scene-switcher\data\res\images\logo.ico
UninstallDisplayIcon={code:GetUninstallIconPath}
DisableDirPage=no
[Languages]
Name: "english"; MessagesFile: "compiler:Default.isl"
[Files]
Source: "..\release\Package\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
; Recommended layout - used when installing to %ProgramData%\obs-studio\plugins\
Source: "..\release\Package\recommended\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs; Check: IsRecommendedLayout
; Legacy layout - used when installing to the OBS installation directory
Source: "..\release\Package\portable\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs; Check: IsLegacyLayout
Source: "..\LICENSE"; Flags: dontcopy
; NOTE: Don't use "Flags: ignoreversion" on any shared system files
@@ -53,23 +56,54 @@ begin
);
end;
function GetDirName(Value: string): string;
// Returns true when the selected install directory is under %ProgramData%,
// meaning the recommended plugin layout should be used.
function IsRecommendedLayout(): Boolean;
begin
Result := ExpandConstant('{commonappdata}\obs-studio\plugins');
Result := Pos(LowerCase(ExpandConstant('{commonappdata}')),
LowerCase(WizardDirValue())) = 1;
end;
// Remove files placed by the legacy installer (obs-plugins/64bit and
function IsLegacyLayout(): Boolean;
begin
Result := not IsRecommendedLayout();
end;
function GetUninstallIconPath(Param: string): string;
begin
if IsRecommendedLayout() then
Result := ExpandConstant('{app}\advanced-scene-switcher\data\res\images\logo.ico')
else
Result := ExpandConstant('{app}\data\obs-plugins\advanced-scene-switcher\res\images\logo.ico');
end;
function GetDirName(Value: string): string;
var
PrevPath: string;
UninstallKey: string;
begin
Result := ExpandConstant('{commonappdata}\obs-studio\plugins');
UninstallKey := 'SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\' + '{' + '{#MyAppId}' + '}_is1';
if RegQueryStringValue(HKLM, UninstallKey, 'InstallLocation', PrevPath) and (PrevPath <> '') then
Result := PrevPath;
end;
// Remove files placed by the portable installer (obs-plugins/64bit and
// data/obs-plugins layout) so the plugin is not loaded twice by OBS.
// We read InstallLocation from our own previous uninstall entry, which is
// the exact directory the old installer used regardless of OBS install path.
// Only runs when upgrading from a portable location to the recommended one.
procedure RemoveLegacyFiles();
var
OldInstallPath: string;
UninstallKey: string;
begin
if not IsRecommendedLayout() then
Exit;
UninstallKey := 'SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\' + '{' + '{#MyAppId}' + '}_is1';
if not RegQueryStringValue(HKLM, UninstallKey, 'InstallLocation', OldInstallPath) then
Exit;
// Skip if the previous install was already in the recommended location
if Pos(LowerCase(ExpandConstant('{commonappdata}')), LowerCase(OldInstallPath)) = 1 then
Exit;
DeleteFile(OldInstallPath + '\obs-plugins\64bit\advanced-scene-switcher.dll');
DeleteFile(OldInstallPath + '\obs-plugins\64bit\advanced-scene-switcher.pdb');
DeleteFile(OldInstallPath + '\obs-plugins\64bit\advanced-scene-switcher-lib.dll');
@@ -83,4 +117,3 @@ begin
if CurStep = ssInstall then
RemoveLegacyFiles();
end;

View File

@@ -382,8 +382,8 @@ AdvSceneSwitcher.action.scene.blockUntilTransitionDone="Warten, bis der Übergan
AdvSceneSwitcher.action.wait="Warten"
AdvSceneSwitcher.action.wait.type.fixed="fixe"
AdvSceneSwitcher.action.wait.type.random="zufällig"
AdvSceneSwitcher.action.wait.entry.fixed="Warte für {{waitType}} Dauer von {{duration}}"
AdvSceneSwitcher.action.wait.entry.random="Warte für {{waitType}} Dauer von {{duration}} bis {{duration2}}"
AdvSceneSwitcher.action.wait.layout.fixed="Warte für {{waitType}} Dauer von {{duration}}"
AdvSceneSwitcher.action.wait.layout.random="Warte für {{waitType}} Dauer von {{duration}} bis {{duration2}}"
AdvSceneSwitcher.action.audio="Audio"
AdvSceneSwitcher.action.audio.type.mute="Stummgeschaltet"
AdvSceneSwitcher.action.audio.type.unmute="Nicht stummgeschaltet"

View File

@@ -47,6 +47,13 @@ AdvSceneSwitcher.generalTab.generalBehavior.hideLegacyTabs="Hide tabs which can
AdvSceneSwitcher.generalTab.generalBehavior.alwaysShowMacroSearch="Always show macro search"
AdvSceneSwitcher.generalTab.generalBehavior.alwaysShowFeatureTabs="Always show all feature tabs"
AdvSceneSwitcher.generalTab.generalBehavior.alwaysShowFeatureTabs.tooltip="Show tabs that are normally hidden until first used in a macro (e.g. Action Queues, Variables, Twitch, Websockets)."
AdvSceneSwitcher.generalTab.generalBehavior.settingsLock="Lock settings dialog with password"
AdvSceneSwitcher.generalTab.generalBehavior.settingsLock.title="Settings Locked"
AdvSceneSwitcher.generalTab.generalBehavior.settingsLock.passwordLabel="Enter password:"
AdvSceneSwitcher.generalTab.generalBehavior.settingsLock.wrongPassword="Wrong password!"
AdvSceneSwitcher.generalTab.generalBehavior.settingsLock.newPasswordLabel="New password:"
AdvSceneSwitcher.generalTab.generalBehavior.settingsLock.confirmPasswordLabel="Confirm password:"
AdvSceneSwitcher.generalTab.generalBehavior.settingsLock.passwordMismatch="Passwords do not match!"
AdvSceneSwitcher.generalTab.matchBehavior="Match behavior"
AdvSceneSwitcher.generalTab.priority="Priority"
AdvSceneSwitcher.generalTab.priority.description="Switching methods priority (Highest priority is at the top)"
@@ -437,6 +444,7 @@ AdvSceneSwitcher.condition.video.ocrConfigReload="Reload configuration file"
AdvSceneSwitcher.condition.video.ocrConfigHint="Tesseract config files consist of lines with parameter-value pairs (space separated).\nFor example:\n\ntessedit_char_blacklist\t\t\t\t\"abc\"\nlanguage_model_penalty_non_dict_word\t0"
AdvSceneSwitcher.condition.video.modelLoadFail="Model data could not be loaded!"
AdvSceneSwitcher.condition.video.selectColor="Select Color"
AdvSceneSwitcher.condition.video.colorVariableTooltip="Expected format: #RRGGBB or #AARRGGBB\nInvalid values will default to black."
AdvSceneSwitcher.condition.video.ocrMode.singleColumn="Single column of text of variable sizes"
AdvSceneSwitcher.condition.video.ocrMode.singleBlockVertText="Single uniform block of vertically aligned text"
AdvSceneSwitcher.condition.video.ocrMode.singleBlock="Single uniform block of text"
@@ -460,12 +468,12 @@ AdvSceneSwitcher.condition.video.layout.minNeighbor="Minimum neighbors:{{minNeig
AdvSceneSwitcher.condition.video.layout.throttle="{{throttleEnable}}Reduce CPU load by performing check only every{{throttleCount}}milliseconds"
AdvSceneSwitcher.condition.video.layout.checkAreaEnable="Perform check only in area"
AdvSceneSwitcher.condition.video.layout.checkArea="{{checkAreaEnable}}{{checkArea}}{{selectArea}}"
AdvSceneSwitcher.condition.video.layout.ocrColorPick="Check for text color:{{textColor}}{{selectColor}}"
AdvSceneSwitcher.condition.video.layout.ocrColorPick="Check for text color:{{color}}"
AdvSceneSwitcher.condition.video.layout.ocrTextType="Check for text type:{{textType}}"
AdvSceneSwitcher.condition.video.layout.ocrBaseDir="Tesseract base directory:{{tesseractBaseDir}}"
AdvSceneSwitcher.condition.video.layout.ocrLanguage="Check for language:{{languageCode}}"
AdvSceneSwitcher.condition.video.layout.ocrConfig="Config file:{{configFile}}{{openConfigFile}}{{reloadConfig}}{{configFileHint}}"
AdvSceneSwitcher.condition.video.layout.color="Check for color:{{color}}{{selectColor}}"
AdvSceneSwitcher.condition.video.layout.color="Check for color:{{color}}"
AdvSceneSwitcher.condition.video.minSize="Minimum size:"
AdvSceneSwitcher.condition.video.maxSize="Maximum size:"
AdvSceneSwitcher.condition.video.selectArea="Select area"
@@ -891,8 +899,18 @@ AdvSceneSwitcher.action.scene.canvasNotSupported="Not supported by the currently
AdvSceneSwitcher.action.wait="Wait"
AdvSceneSwitcher.action.wait.type.fixed="fixed"
AdvSceneSwitcher.action.wait.type.random="random"
AdvSceneSwitcher.action.wait.entry.fixed="Wait for{{waitType}}duration of{{duration}}"
AdvSceneSwitcher.action.wait.entry.random="Wait for{{waitType}}duration from{{duration}}to{{duration2}}"
AdvSceneSwitcher.action.wait.type.variableCondition="variable"
AdvSceneSwitcher.action.wait.layout.fixed="Wait for{{waitType}}duration of{{duration}}"
AdvSceneSwitcher.action.wait.layout.random="Wait for{{waitType}}duration from{{duration}}to{{duration2}}"
AdvSceneSwitcher.action.wait.condition.equals="equals"
AdvSceneSwitcher.action.wait.condition.doesNotEqual="does not equal"
AdvSceneSwitcher.action.wait.condition.isEmpty="is empty"
AdvSceneSwitcher.action.wait.condition.lessThan="is less than"
AdvSceneSwitcher.action.wait.condition.greaterThan="is greater than"
AdvSceneSwitcher.action.wait.layout.variableCondition="Wait until{{waitType}}{{variable}}{{condition}}{{strValue}}{{numValue}}{{regex}}"
AdvSceneSwitcher.action.wait.layout.timeout="Timeout:{{useTimeout}}{{conditionTimeout}}"
AdvSceneSwitcher.tempVar.wait.timedOut="Timed out"
AdvSceneSwitcher.tempVar.wait.timedOut.description="True if the wait action timed out before the condition was met, false if the condition was met in time"
AdvSceneSwitcher.action.audio="Audio"
AdvSceneSwitcher.action.audio.type.mute="Mute"
AdvSceneSwitcher.action.audio.type.unmute="Unmute"
@@ -932,7 +950,8 @@ AdvSceneSwitcher.action.recording.type.changeOutputFileFormat="Change filename f
AdvSceneSwitcher.action.recording.type.addChapter="Add chapter"
AdvSceneSwitcher.action.recording.pause.hint="Note that depending on your recording settings you might not be able to pause recording"
AdvSceneSwitcher.action.recording.split.hint="Make sure to enable automatic file splitting in the OBS settings first!"
AdvSceneSwitcher.action.recording.entry="{{actions}}{{recordFolder}}{{recordFileFormat}}{{chapterName}}{{pauseHint}}{{splitHint}}"
AdvSceneSwitcher.action.recording.outputNotActive.hint="This change will only take effect when the Recording, Replay Buffer, ... output is not active.\nThe output needs to be restarted for the change to apply."
AdvSceneSwitcher.action.recording.layout="{{actions}}{{recordFolder}}{{recordFileFormat}}{{outputNotActiveHelp}}{{chapterName}}{{pauseHint}}{{splitHint}}"
AdvSceneSwitcher.action.replay="Replay buffer"
AdvSceneSwitcher.action.replay.saveWarn="Warning: Saving too frequently might result in the replay buffer not actually being saved!"
AdvSceneSwitcher.action.replay.durationWarn="Warning: Changing the maximum replay time will only apply the next time the replay buffer is started!"
@@ -963,6 +982,7 @@ AdvSceneSwitcher.action.sceneVisibility.type.sourceGroup="Any"
AdvSceneSwitcher.action.sceneVisibility.layout="On{{scenes}}{{actions}}{{sources}}"
AdvSceneSwitcher.action.sceneVisibility.layout.transition="{{updateTransition}}Set transition to{{transitions}}"
AdvSceneSwitcher.action.sceneVisibility.layout.duration="{{updateDuration}}Set transition duration to{{duration}}seconds"
AdvSceneSwitcher.action.sceneVisibility.blockUntilTransitionDone="Wait until visibility transition is complete"
AdvSceneSwitcher.action.filter="Filter"
AdvSceneSwitcher.action.filter.type.enable="Enable"
AdvSceneSwitcher.action.filter.type.disable="Disable"
@@ -1281,6 +1301,8 @@ AdvSceneSwitcher.action.variable.type.stringLength="Set to length of string"
AdvSceneSwitcher.action.variable.type.extractJsonField="Extract JSON field with name"
AdvSceneSwitcher.action.variable.type.queryJson="Query JSON"
AdvSceneSwitcher.action.variable.type.queryJson.info="You can use \"JSONPath\" (RFC 9535) syntax here.\nSo, for example:\n\n • $['books'][0]['category']\n • $.books[0].category\n\nIf the query or the JSON should be invalid, the variable value will not be changed.\nIf the input is valid, the result of the query will always be a JSON array."
AdvSceneSwitcher.action.variable.type.queryJson.extractSingle="Extract value if single result"
AdvSceneSwitcher.action.variable.type.queryJson.extractSingle.info="When enabled and the query returns exactly one result, the value is extracted from the result array and stored directly.\nIf the query matches zero or more than one element, the full JSON array is stored as usual."
AdvSceneSwitcher.action.variable.type.accessJsonArray="Access JSON array at index"
AdvSceneSwitcher.action.variable.type.setToTempvar="Set to macro property"
AdvSceneSwitcher.action.variable.type.setToTempvar.help="This action type will allow you to extract values out of segments of the current macro and assign those values to the selected variable.\nFor example, you can get the current scene name from a scene condition and assign this name to a variable."
@@ -1313,6 +1335,7 @@ AdvSceneSwitcher.action.variable.actionNoVariableSupport="Getting variable value
AdvSceneSwitcher.action.variable.conditionNoVariableSupport="Getting variable values from %1 conditions is not supported!"
AdvSceneSwitcher.action.variable.currentSegmentValue="Current value:"
AdvSceneSwitcher.action.variable.layout.other="{{actions}}{{variables}}{{variables2}}{{strValue}}{{numValue}}{{segmentIndex}}{{mathExpression}}{{envVariableName}}{{scenes}}{{tempVars}}{{tempVarsHelp}}{{sceneItemIndex}}{{direction}}{{stringLength}}{{paddingCharSelection}}{{caseType}}{{jsonQuery}}{{jsonQueryHelp}}{{jsonIndex}}"
AdvSceneSwitcher.action.variable.layout.copy="{{actions}}{{variables2}}to{{variables}}"
AdvSceneSwitcher.action.variable.layout.pad="{{actions}}of{{variables}}to length{{stringLength}}by adding{{paddingCharSelection}}to the{{direction}}"
AdvSceneSwitcher.action.variable.layout.truncate="{{actions}}of{{variables}}to length{{stringLength}}by removing characters from the{{direction}}"
AdvSceneSwitcher.action.variable.layout.substringIndex="Substring start:{{subStringStart}}Substring size:{{subStringSize}}{{subStringRegex}}"
@@ -1352,6 +1375,8 @@ AdvSceneSwitcher.action.twitch.type.channel.info.language.set="Set stream langua
AdvSceneSwitcher.action.twitch.type.raid.start="Start raid"
AdvSceneSwitcher.action.twitch.type.raid.end="Cancel raid"
AdvSceneSwitcher.action.twitch.type.shoutout.send="Send shoutout"
AdvSceneSwitcher.action.twitch.type.poll.start="Start poll"
AdvSceneSwitcher.action.twitch.type.poll.end="End poll"
AdvSceneSwitcher.action.twitch.type.shieldMode.start="Enable shield mode"
AdvSceneSwitcher.action.twitch.type.shieldMode.end="Disable shield mode"
AdvSceneSwitcher.action.twitch.type.commercial.start="Start commercial"
@@ -1383,6 +1408,7 @@ AdvSceneSwitcher.action.twitch.type.user.vip.add="Add user as VIP"
AdvSceneSwitcher.action.twitch.type.user.vip.delete="Remove user as VIP"
AdvSceneSwitcher.action.twitch.type.reward.getInfo="Get channel points reward information"
AdvSceneSwitcher.action.twitch.type.channel.getInfo="Get channel information"
AdvSceneSwitcher.action.twitch.type.channel.ads.getInfo="Get channel ad schedule"
AdvSceneSwitcher.action.twitch.reward.toggleControl="Toggle reward name / variable selection control"
AdvSceneSwitcher.action.twitch.categorySelectionDisabled="Cannot select category without selecting a Twitch account first!"
AdvSceneSwitcher.action.twitch.layout.default="On{{account}}{{actions}}{{streamTitle}}{{category}}{{markerDescription}}{{clipHasDelay}}{{duration}}{{announcementColor}}{{nonModDelayDuration}}{{channel}}{{pointsReward}}"
@@ -1396,6 +1422,10 @@ AdvSceneSwitcher.action.twitch.layout.user.ban.row1="Using account{{account}}{{a
AdvSceneSwitcher.action.twitch.layout.user.ban.row2="on channel{{channel}}for{{userInfoQueryType}}{{userLogin}}{{userId}}reason{{banReason}}"
AdvSceneSwitcher.action.twitch.layout.reward.getInfo.row1="Using account{{account}}{{actions}}for channel{{channel}}"
AdvSceneSwitcher.action.twitch.layout.reward.getInfo.row2="{{pointsReward}}{{rewardVariable}}{{toggleRewardSelection}}"
AdvSceneSwitcher.action.twitch.layout.poll.start.row1="Using account{{account}}{{actions}}"
AdvSceneSwitcher.action.twitch.layout.poll.start.row2="on{{channel}}for{{duration}}"
AdvSceneSwitcher.action.twitch.layout.poll.end.row1="Using account{{account}}{{actions}}"
AdvSceneSwitcher.action.twitch.layout.poll.end.row2="on{{channel}}{{pollEndStatus}}"
AdvSceneSwitcher.action.twitch.title.title="Enter title"
AdvSceneSwitcher.action.twitch.marker.description="Describe marker"
AdvSceneSwitcher.action.twitch.clip.hasDelay="Add a slight delay before capturing the clip"
@@ -1405,6 +1435,9 @@ AdvSceneSwitcher.action.twitch.announcement.blue="Blue"
AdvSceneSwitcher.action.twitch.announcement.green="Green"
AdvSceneSwitcher.action.twitch.announcement.orange="Orange"
AdvSceneSwitcher.action.twitch.announcement.purple="Purple"
AdvSceneSwitcher.action.twitch.poll.choices="Enter one choice per line (2-10 choices, max 25 characters each)"
AdvSceneSwitcher.action.twitch.poll.end.terminated="Terminate (show results)"
AdvSceneSwitcher.action.twitch.poll.end.archived="Archive (hide results)"
AdvSceneSwitcher.action.twitch.user.getInfo.queryType.id="User Id"
AdvSceneSwitcher.action.twitch.user.getInfo.queryType.login="User login"
AdvSceneSwitcher.action.twitch.tags.add="Add Channel Tag"
@@ -1876,8 +1909,10 @@ AdvSceneSwitcher.tempVar.twitch.user_login.subscribe.message="Subscriber Twitch
AdvSceneSwitcher.tempVar.twitch.user_login.subscribe.message.description="The user login of the user who sent a resubscription chat message."
AdvSceneSwitcher.tempVar.twitch.user_name.subscribe.message="Subscriber Twitch user name"
AdvSceneSwitcher.tempVar.twitch.user_name.subscribe.message.description="The user display name of the user who a resubscription chat message."
AdvSceneSwitcher.tempVar.twitch.message.subscribe="Subscription message"
AdvSceneSwitcher.tempVar.twitch.message.subscribe.description="An object that contains the resubscription message and emote information needed to recreate the message."
AdvSceneSwitcher.tempVar.twitch.message.subscribe="Subscription message (JSON)"
AdvSceneSwitcher.tempVar.twitch.message.subscribe.description="A JSON object containing the resubscription message text and emote information. Use 'Subscription message text' to get the plain text."
AdvSceneSwitcher.tempVar.twitch.message_text.subscribe="Subscription message text"
AdvSceneSwitcher.tempVar.twitch.message_text.subscribe.description="The plain text of the resubscription message, with emotes replaced by their names."
AdvSceneSwitcher.tempVar.twitch.cumulative_months.subscribe="Number of months subscribed"
AdvSceneSwitcher.tempVar.twitch.cumulative_months.subscribe.description="The total number of months the user has been subscribed to the channel."
AdvSceneSwitcher.tempVar.twitch.streak_months.subscribe="Subscription streak"
@@ -2266,6 +2301,19 @@ AdvSceneSwitcher.tempVar.twitch.requester_user_login.commercial.description="The
AdvSceneSwitcher.tempVar.twitch.requester_user_name.commercial="Requester user name"
AdvSceneSwitcher.tempVar.twitch.requester_user_name.commercial.description="The display name of the user that requested the ad."
AdvSceneSwitcher.tempVar.twitch.snooze_count.ads="Snooze count"
AdvSceneSwitcher.tempVar.twitch.snooze_count.ads.description="The number of snoozes available for the broadcaster."
AdvSceneSwitcher.tempVar.twitch.snooze_refresh_at.ads="Snooze refresh time"
AdvSceneSwitcher.tempVar.twitch.snooze_refresh_at.ads.description="The UTC timestamp of when the broadcaster will have an additional snooze available, in RFC3339 format."
AdvSceneSwitcher.tempVar.twitch.next_ad_at.ads="Next ad time"
AdvSceneSwitcher.tempVar.twitch.next_ad_at.ads.description="The UTC timestamp of when the channel's next scheduled ad break will begin, in RFC3339 format."
AdvSceneSwitcher.tempVar.twitch.duration.ads="Ad duration"
AdvSceneSwitcher.tempVar.twitch.duration.ads.description="The length in seconds of the scheduled ad break."
AdvSceneSwitcher.tempVar.twitch.last_ad_at.ads="Last ad time"
AdvSceneSwitcher.tempVar.twitch.last_ad_at.ads.description="The UTC timestamp of the channel's last ad break, in RFC3339 format."
AdvSceneSwitcher.tempVar.twitch.preroll_free_time.ads="Pre-roll free time"
AdvSceneSwitcher.tempVar.twitch.preroll_free_time.ads.description="The amount of time in seconds the broadcaster must wait before running another commercial."
AdvSceneSwitcher.tempVar.audio.output_volume="Output volume"
AdvSceneSwitcher.tempVar.audio.output_volume.description="The volume the audio source is outputting."
AdvSceneSwitcher.tempVar.audio.configured_volume="Configured volume"
@@ -2668,7 +2716,28 @@ FirstRunWizard.windowTitle="Advanced Scene Switcher - Setup Wizard"
FirstRunWizard.welcome.title="Welcome to Advanced Scene Switcher"
FirstRunWizard.welcome.subtitle="Let's create your first automation in a few easy steps."
FirstRunWizard.welcome.body="<p>Advanced Scene Switcher lets you build <b>Macros</b> - rules of the form:</p><blockquote><i>When [condition] -> perform [action]</i></blockquote><p>This wizard creates a macro that <b>switches to a chosen OBS scene whenever a specific window comes into focus</b>.</p><p>You can skip at any time. Re-open this wizard later from the <b>General</b> tab inside the Advanced Scene Switcher dialog.</p>"
FirstRunWizard.welcome.body="<p>Advanced Scene Switcher lets you build <b>Macros</b> - rules of the form:</p><blockquote><i>When [condition] -> perform [action]</i></blockquote><p>This wizard will guide you through creating your first automation. On the next page, choose the type of macro you want to set up.</p><p>You can skip at any time. Re-open this wizard later from the <b>General</b> tab inside the Advanced Scene Switcher dialog.</p>"
FirstRunWizard.template.title="Choose an Automation Type"
FirstRunWizard.template.subtitle="Select the kind of macro you would like to create."
FirstRunWizard.template.window="Switch to a scene when a window comes into focus"
FirstRunWizard.template.sequence="Run a timed sequence of scene switches"
FirstRunWizard.template.audio="Show or hide a source based on audio activity"
FirstRunWizard.audio.source.title="Choose an Audio Source"
FirstRunWizard.audio.source.subtitle="Select the audio source to monitor and configure when it should be considered active."
FirstRunWizard.audio.source.sourceRow="Audio source:{{source}}"
FirstRunWizard.audio.source.thresholdRow="Show when volume is above{{threshold}}for{{duration}}"
FirstRunWizard.audio.target.title="Choose a Target Source"
FirstRunWizard.audio.target.subtitle="Select the source to show when audio is active and hide when it is not."
FirstRunWizard.audio.target.sourceRow="Source to show/hide:{{source}}"
FirstRunWizard.audio.review.title="Review Your Macro"
FirstRunWizard.audio.review.subtitle="Click Back to make changes, or Finish to create the macro."
FirstRunWizard.audio.review.summary="<b>When</b> <i>%1</i> output volume is above <b>%2 dB</b> for <b>%3</b>:<br><br>&nbsp;&nbsp;<b>Show</b> <i>%4</i> on the current scene<br><br><b>Otherwise:</b><br><br>&nbsp;&nbsp;<b>Hide</b> <i>%4</i> on the current scene"
FirstRunWizard.audio.review.errorTitle="Macro Creation Failed"
FirstRunWizard.audio.review.errorBody="The audio macro could not be created. Please check the OBS log for details."
FirstRunWizard.scene.title="Choose a Target Scene"
FirstRunWizard.scene.subtitle="Which OBS scene should become active when your chosen window comes into focus?"
@@ -2689,6 +2758,26 @@ FirstRunWizard.review.summary="<b>Macro name:</b> Window -> %1<br><br><b>Conditi
FirstRunWizard.review.errorTitle="Macro Creation Failed"
FirstRunWizard.review.errorBody="The macro could not be created automatically.\n\nYou can create it manually on the Macros tab:\n Condition: Window -> contains \"%1\"\n Action: Switch scene -> \"%2\""
FirstRunWizard.seqTrigger.title="Choose a Trigger Scene"
FirstRunWizard.seqTrigger.subtitle="The sequence will start when you have been on this scene for the specified duration."
FirstRunWizard.seqTrigger.scene="Trigger scene:{{scene}}"
FirstRunWizard.seqTrigger.delay="Start sequence after{{duration}}"
FirstRunWizard.seqScenes.title="Build Your Sequence"
FirstRunWizard.seqScenes.subtitle="Add at least two scenes. The plugin will switch through them in order, waiting the configured time between each switch."
FirstRunWizard.seqScenes.triggerInfo="When on \"%1\" for %2, switch to:"
FirstRunWizard.seqScenes.addScene="Add scene"
FirstRunWizard.seqScenes.removeTooltip="Remove this scene from the sequence"
FirstRunWizard.seqScenes.switchTo="Switch to{{scene}}"
FirstRunWizard.seqScenes.thenWait="then wait{{duration}}"
FirstRunWizard.seqReview.title="Review Your Sequence"
FirstRunWizard.seqReview.subtitle="Click Back to make changes, or Finish to create the macro."
FirstRunWizard.seqReview.trigger="When on <b>%1</b> for at least <b>%2</b>:"
FirstRunWizard.seqReview.step="After %1, switch to %2"
FirstRunWizard.seqReview.errorTitle="Macro Creation Failed"
FirstRunWizard.seqReview.errorBody="The sequence macro could not be created. Please check the OBS log for details."
FirstRunWizard.done.title="You're all set!"
FirstRunWizard.done.subtitle="Your first macro has been created and is now active."
FirstRunWizard.done.body="<p>You can view and edit it on the <b>Macros</b> tab of the Advanced Scene Switcher dialog.</p><p>To learn more:</p><ul><li><a href=\"https://github.com/WarmUpTill/SceneSwitcher/wiki\">Plugin wiki</a></li><li><a href=\"https://obsproject.com/forum/resources/automatic-scene-switching.395/\">OBS forum thread</a></li></ul>"

View File

@@ -312,8 +312,8 @@ AdvSceneSwitcher.action.scene.blockUntilTransitionDone="Espere hasta que se comp
AdvSceneSwitcher.action.wait="Esperar"
AdvSceneSwitcher.action.wait.type.fixed="fijo"
AdvSceneSwitcher.action.wait.type.random="aleatorio"
AdvSceneSwitcher.action.wait.entry.fixed="Espere {{waitType}} de {{duration}}"
AdvSceneSwitcher.action.wait.entry.random="Espere {{waitType}} de {{duration}} a {{duration2}}"
AdvSceneSwitcher.action.wait.layout.fixed="Espere {{waitType}} de {{duration}}"
AdvSceneSwitcher.action.wait.layout.random="Espere {{waitType}} de {{duration}} a {{duration2}}"
AdvSceneSwitcher.action.audio="Audio"
AdvSceneSwitcher.action.audio.type.mute="Silencio"
AdvSceneSwitcher.action.audio.type.unmute="Activar silencio"

View File

@@ -257,10 +257,10 @@ AdvSceneSwitcher.condition.video.layout.modelPath="Données du modèle (classifi
AdvSceneSwitcher.condition.video.layout.minNeighbor="Nombre minimum de voisins :{{minNeighbors}}"
AdvSceneSwitcher.condition.video.layout.throttle="{{throttleEnable}}Réduire la charge CPU en effectuant la vérification uniquement toutes les{{throttleCount}}millisecondes"
AdvSceneSwitcher.condition.video.layout.checkAreaEnable="Effectuer la vérification uniquement dans la zone"
AdvSceneSwitcher.condition.video.layout.ocrColorPick="Vérifier la couleur du texte :{{textColor}}{{selectColor}}"
AdvSceneSwitcher.condition.video.layout.ocrColorPick="Vérifier la couleur du texte :{{color}}"
AdvSceneSwitcher.condition.video.layout.ocrTextType="Vérifier le type de texte :{{textType}}"
AdvSceneSwitcher.condition.video.layout.ocrLanguage="Vérifier la langue :{{languageCode}}"
AdvSceneSwitcher.condition.video.layout.color="Vérifier la couleur :{{color}}{{selectColor}}"
AdvSceneSwitcher.condition.video.layout.color="Vérifier la couleur :{{color}}"
AdvSceneSwitcher.condition.video.minSize="Taille minimale :"
AdvSceneSwitcher.condition.video.maxSize="Taille maximale :"
AdvSceneSwitcher.condition.video.selectArea="Sélectionner la zone"
@@ -460,8 +460,8 @@ AdvSceneSwitcher.action.scene.blockUntilTransitionDone="Attendre que la transiti
AdvSceneSwitcher.action.wait="Attendre"
AdvSceneSwitcher.action.wait.type.fixed="fixe"
AdvSceneSwitcher.action.wait.type.random="aléatoire"
AdvSceneSwitcher.action.wait.entry.fixed="Attendre pour{{waitType}}une durée de{{duration}}"
AdvSceneSwitcher.action.wait.entry.random="Attendre pour{{waitType}}une durée de{{duration}}à{{duration2}}"
AdvSceneSwitcher.action.wait.layout.fixed="Attendre pour{{waitType}}une durée de{{duration}}"
AdvSceneSwitcher.action.wait.layout.random="Attendre pour{{waitType}}une durée de{{duration}}à{{duration2}}"
AdvSceneSwitcher.action.audio="Audio"
AdvSceneSwitcher.action.audio.type.mute="Muet"
AdvSceneSwitcher.action.audio.type.unmute="Activer le son"

View File

@@ -414,12 +414,12 @@ AdvSceneSwitcher.condition.video.layout.minNeighbor="最小近傍数:{{minNeighb
AdvSceneSwitcher.condition.video.layout.throttle="{{throttleEnable}}:{{throttleCount}}ミリ秒ごとにのみパフォーマンスチェックを実行することでCPU負荷を軽減します"
AdvSceneSwitcher.condition.video.layout.checkAreaEnable="エリア内のみチェックを実施"
; AdvSceneSwitcher.condition.video.layout.checkArea="{{checkAreaEnable}}{{checkArea}}{{selectArea}}"
AdvSceneSwitcher.condition.video.layout.ocrColorPick="テキストカラーの確認:{{textColor}}{{selectColor}}"
AdvSceneSwitcher.condition.video.layout.ocrColorPick="テキストカラーの確認:{{color}}"
AdvSceneSwitcher.condition.video.layout.ocrTextType="テキストタイプの確認:{{textType}}"
AdvSceneSwitcher.condition.video.layout.ocrBaseDir="Tesseractベースディレクトリ:{{tesseractBaseDir}}"
AdvSceneSwitcher.condition.video.layout.ocrLanguage="言語の確認:{{languageCode}}"
AdvSceneSwitcher.condition.video.layout.ocrConfig="設定ファイル:{{configFile}}{{openConfigFile}}{{reloadConfig}}{{configFileHint}}"
AdvSceneSwitcher.condition.video.layout.color="カラーを確認してください:{{color}}{{selectColor}}"
AdvSceneSwitcher.condition.video.layout.color="カラーを確認してください:{{color}}"
AdvSceneSwitcher.condition.video.minSize="最小サイズ:"
AdvSceneSwitcher.condition.video.maxSize="最大サイズ:"
AdvSceneSwitcher.condition.video.selectArea="範囲指定"
@@ -800,8 +800,8 @@ AdvSceneSwitcher.action.scene.blockUntilTransitionDone="目的のシーンへの
; AdvSceneSwitcher.action.wait="Wait"
AdvSceneSwitcher.action.wait.type.fixed="指定"
AdvSceneSwitcher.action.wait.type.random="ランダム"
AdvSceneSwitcher.action.wait.entry.fixed="{{waitType}}期間{{duration}}待機します"
AdvSceneSwitcher.action.wait.entry.random="{{duration}}から{{duration2}}までの{{waitType}}期間待ちます"
AdvSceneSwitcher.action.wait.layout.fixed="{{waitType}}期間{{duration}}待機します"
AdvSceneSwitcher.action.wait.layout.random="{{duration}}から{{duration2}}までの{{waitType}}期間待ちます"
AdvSceneSwitcher.action.audio="音声"
AdvSceneSwitcher.action.audio.type.mute="ミュート"
AdvSceneSwitcher.action.audio.type.unmute="ミュート解除"
@@ -830,7 +830,6 @@ AdvSceneSwitcher.action.recording.type.changeOutputFolder="出力フォルダの
AdvSceneSwitcher.action.recording.type.changeOutputFileFormat="ファイル名の書式変更"
AdvSceneSwitcher.action.recording.pause.hint="録画設定によっては録画を一時停止できない場合がありますのでご注意ください"
AdvSceneSwitcher.action.recording.split.hint="まずOBS設定で自動ファイル分割を有効にしてください"
; AdvSceneSwitcher.action.recording.entry="{{actions}}{{recordFolder}}{{recordFileFormat}}{{pauseHint}}{{splitHint}}"
AdvSceneSwitcher.action.replay="リプレイバッファー"
AdvSceneSwitcher.action.replay.saveWarn="警告: 頻繁に保存しすぎると、リプレイ バッファーが実際には保存されなくなる可能性があります。"
AdvSceneSwitcher.action.replay.durationWarn="注意: 最大リプレイ時間の変更は、次回再生バッファを開始したときから適用されます!"

View File

@@ -371,10 +371,10 @@ AdvSceneSwitcher.condition.video.layout.minNeighbor="Vizinhos mínimos:{{minNeig
AdvSceneSwitcher.condition.video.layout.throttle="{{throttleEnable}}Reduzir carga de CPU realizando a verificação apenas a cada {{throttleCount}} milissegundos"
AdvSceneSwitcher.condition.video.layout.checkAreaEnable="Realizar verificação apenas na área"
AdvSceneSwitcher.condition.video.layout.checkArea="{{checkAreaEnable}}{{checkArea}}{{selectArea}}"
AdvSceneSwitcher.condition.video.layout.ocrColorPick="Verificar cor do texto:{{textColor}}{{selectColor}}"
AdvSceneSwitcher.condition.video.layout.ocrColorPick="Verificar cor do texto:{{color}}"
AdvSceneSwitcher.condition.video.layout.ocrTextType="Verificar tipo de texto:{{textType}}"
AdvSceneSwitcher.condition.video.layout.ocrLanguage="Verificar idioma:{{languageCode}}"
AdvSceneSwitcher.condition.video.layout.color="Verificar cor:{{color}}{{selectColor}}"
AdvSceneSwitcher.condition.video.layout.color="Verificar cor:{{color}}"
AdvSceneSwitcher.condition.video.minSize="Tamanho mínimo:"
AdvSceneSwitcher.condition.video.maxSize="Tamanho máximo:"
AdvSceneSwitcher.condition.video.selectArea="Selecionar área"
@@ -718,8 +718,8 @@ AdvSceneSwitcher.action.scene.blockUntilTransitionDone="Aguardar até que a tran
AdvSceneSwitcher.action.wait="Aguardar"
AdvSceneSwitcher.action.wait.type.fixed="fixo"
AdvSceneSwitcher.action.wait.type.random="aleatório"
AdvSceneSwitcher.action.wait.entry.fixed="Aguardando por{{waitType}}duração de{{duration}}"
AdvSceneSwitcher.action.wait.entry.random="Aguardando por{{waitType}}duração de{{duration}}para{{duration2}}"
AdvSceneSwitcher.action.wait.layout.fixed="Aguardando por{{waitType}}duração de{{duration}}"
AdvSceneSwitcher.action.wait.layout.random="Aguardando por{{waitType}}duração de{{duration}}para{{duration2}}"
AdvSceneSwitcher.action.audio="Áudio"
AdvSceneSwitcher.action.audio.type.mute="Silenciar"
AdvSceneSwitcher.action.audio.type.unmute="Ativar som"

View File

@@ -114,8 +114,8 @@ AdvSceneSwitcher.action.scene="Переключить сцену"
AdvSceneSwitcher.action.wait="Подождать"
AdvSceneSwitcher.action.wait.type.fixed="фиксированный"
AdvSceneSwitcher.action.wait.type.random="случайный"
AdvSceneSwitcher.action.wait.entry.fixed="Ожидать {{waitType}} в течении {{duration}}"
AdvSceneSwitcher.action.wait.entry.random="Ожидание {{waitType}} длительностью от {{duration}} до {{duration2}}"
AdvSceneSwitcher.action.wait.layout.fixed="Ожидать {{waitType}} в течении {{duration}}"
AdvSceneSwitcher.action.wait.layout.random="Ожидание {{waitType}} длительностью от {{duration}} до {{duration2}}"
AdvSceneSwitcher.action.audio="Аудио"
AdvSceneSwitcher.action.audio.type.mute="Замьютить"
AdvSceneSwitcher.action.audio.type.unmute="Размьютить"

View File

@@ -249,8 +249,8 @@ AdvSceneSwitcher.action.scene.blockUntilTransitionDone="Hedef sahneye geçiş ta
AdvSceneSwitcher.action.wait="Bekle"
AdvSceneSwitcher.action.wait.type.fixed="sabit"
AdvSceneSwitcher.action.wait.type.random="rastgele"
AdvSceneSwitcher.action.wait.entry.fixed="Şunu bekle {{waitType}} süre {{duration}}"
AdvSceneSwitcher.action.wait.entry.random="Şunu bekle {{waitType}} bu aralıktan {{duration}} buna {{duration2}}"
AdvSceneSwitcher.action.wait.layout.fixed="Şunu bekle {{waitType}} süre {{duration}}"
AdvSceneSwitcher.action.wait.layout.random="Şunu bekle {{waitType}} bu aralıktan {{duration}} buna {{duration2}}"
AdvSceneSwitcher.action.audio="Ses"
AdvSceneSwitcher.action.audio.type.mute="Sessiz"
AdvSceneSwitcher.action.audio.type.unmute="Ses açmak"

View File

@@ -395,10 +395,10 @@ AdvSceneSwitcher.condition.video.layout.minNeighbor="最小区域: {{minNeighbor
AdvSceneSwitcher.condition.video.layout.throttle="{{throttleEnable}}每隔{{throttleCount}} 毫秒,才执行一次检查,从而减少 CPU 负载."
AdvSceneSwitcher.condition.video.layout.checkAreaEnable="仅在区域内执行检查"
AdvSceneSwitcher.condition.video.layout.checkArea="{{checkAreaEnable}}{{checkArea}}{{selectArea}}"
AdvSceneSwitcher.condition.video.layout.ocrColorPick="文本检查颜色:{{textColor}}{{selectColor}}"
AdvSceneSwitcher.condition.video.layout.ocrColorPick="文本检查颜色:{{color}}"
AdvSceneSwitcher.condition.video.layout.ocrTextType="文本检查类型:{{textType}}"
AdvSceneSwitcher.condition.video.layout.ocrLanguage="语言检查:{{languageCode}}"
AdvSceneSwitcher.condition.video.layout.color="颜色检查:{{color}}{{selectColor}}"
AdvSceneSwitcher.condition.video.layout.color="颜色检查:{{color}}"
AdvSceneSwitcher.condition.video.minSize="最小尺寸:"
AdvSceneSwitcher.condition.video.maxSize="最大尺寸:"
AdvSceneSwitcher.condition.video.selectArea="选择区域"
@@ -778,8 +778,8 @@ AdvSceneSwitcher.action.scene.blockUntilTransitionDone="等待转场到目标场
AdvSceneSwitcher.action.wait="等待"
AdvSceneSwitcher.action.wait.type.fixed="固定"
AdvSceneSwitcher.action.wait.type.random="随机"
AdvSceneSwitcher.action.wait.entry.fixed="等待时间{{waitType}}{{duration}}"
AdvSceneSwitcher.action.wait.entry.random="等待时间{{waitType}}{{duration}} 到 {{duration2}}"
AdvSceneSwitcher.action.wait.layout.fixed="等待时间{{waitType}}{{duration}}"
AdvSceneSwitcher.action.wait.layout.random="等待时间{{waitType}}{{duration}} 到 {{duration2}}"
AdvSceneSwitcher.action.audio="音频"
AdvSceneSwitcher.action.audio.type.mute="静音"
AdvSceneSwitcher.action.audio.type.unmute="取消静音"

1
deps/date vendored

Submodule deps/date deleted from 5bdb7e6f31

View File

@@ -258,6 +258,13 @@
</property>
</widget>
</item>
<item>
<widget class="QCheckBox" name="settingsLock">
<property name="text">
<string>AdvSceneSwitcher.generalTab.generalBehavior.settingsLock</string>
</property>
</widget>
</item>
</layout>
</widget>
</item>

View File

@@ -19,8 +19,10 @@
#include <obs-frontend-api.h>
#include <QAction>
#include <QDirIterator>
#include <QInputDialog>
#include <QLibrary>
#include <QMainWindow>
#include <QMessageBox>
#include <QTextStream>
#include <regex>
@@ -459,12 +461,13 @@ void SwitcherData::Stop()
SetMacroAbortWait(true);
GetMacroWaitCV().notify_all();
GetMacroTransitionCV().notify_all();
StopAllMacros();
SignalStopAllMacros();
StopAndClearAllActionQueues();
CloseAllInputDialogs();
th->wait();
delete th;
th = nullptr;
WaitForAllMacros();
RunStopSteps();
}
@@ -718,19 +721,54 @@ static void LoadPlugins()
}
}
static bool settingsPasswordIsCorrect(QWidget *parent)
{
bool ok;
QString input = QInputDialog::getText(
parent,
obs_module_text(
"AdvSceneSwitcher.generalTab.generalBehavior.settingsLock.title"),
obs_module_text(
"AdvSceneSwitcher.generalTab.generalBehavior.settingsLock.passwordLabel"),
QLineEdit::Password, QString(), &ok);
if (!ok) {
return false;
}
if (input.toStdString() == switcher->settingsLockPassword) {
return true;
}
QMessageBox::warning(
parent,
obs_module_text(
"AdvSceneSwitcher.generalTab.generalBehavior.settingsLock.title"),
obs_module_text(
"AdvSceneSwitcher.generalTab.generalBehavior.settingsLock.wrongPassword"));
return false;
}
void OpenSettingsWindow()
{
if (switcher->settingsWindowOpened) {
AdvSceneSwitcher::window->show();
AdvSceneSwitcher::window->raise();
AdvSceneSwitcher::window->activateWindow();
} else {
AdvSceneSwitcher::window =
new AdvSceneSwitcher(static_cast<QMainWindow *>(
obs_frontend_get_main_window()));
AdvSceneSwitcher::window->setAttribute(Qt::WA_DeleteOnClose);
AdvSceneSwitcher::window->show();
return;
}
auto parent =
static_cast<QMainWindow *>(obs_frontend_get_main_window());
if (switcher->settingsLockEnabled &&
!switcher->settingsLockPassword.empty() &&
!settingsPasswordIsCorrect(parent)) {
return;
}
AdvSceneSwitcher::window = new AdvSceneSwitcher(parent);
AdvSceneSwitcher::window->setAttribute(Qt::WA_DeleteOnClose);
AdvSceneSwitcher::window->show();
}
void AdvSceneSwitcher::HighlightMacroSettingsButton(bool enable)

View File

@@ -68,6 +68,7 @@ public slots:
void on_warnPluginLoadFailure_stateChanged(int state);
void on_suppressCrashRecoveryDialog_stateChanged(int state);
void on_hideLegacyTabs_stateChanged(int state);
void on_settingsLock_stateChanged(int state);
void on_priorityUp_clicked();
void on_priorityDown_clicked();
void on_threadPriority_currentTextChanged(const QString &text);

View File

@@ -19,6 +19,8 @@
#include <obs-frontend-api.h>
#include <QFileDialog>
#include <QInputDialog>
#include <QMessageBox>
namespace advss {
@@ -243,6 +245,64 @@ void AdvSceneSwitcher::on_hideLegacyTabs_stateChanged(int state)
ui->priorityBox->setVisible(!switcher->hideLegacyTabs);
}
void AdvSceneSwitcher::on_settingsLock_stateChanged(int state)
{
if (loading) {
return;
}
if (!state) {
switcher->settingsLockEnabled = false;
switcher->settingsLockPassword = "";
return;
}
bool ok;
QString newPassword = QInputDialog::getText(
this,
obs_module_text(
"AdvSceneSwitcher.generalTab.generalBehavior.settingsLock.title"),
obs_module_text(
"AdvSceneSwitcher.generalTab.generalBehavior.settingsLock.newPasswordLabel"),
QLineEdit::Password, QString(), &ok);
const auto disablePassword = [this]() {
QSignalBlocker blocker(ui->settingsLock);
ui->settingsLock->setChecked(false);
ui->settingsLock->setCheckState(Qt::Unchecked);
switcher->settingsLockEnabled = false;
switcher->settingsLockPassword = "";
};
if (!ok || newPassword.isEmpty()) {
disablePassword();
return;
}
QString confirm = QInputDialog::getText(
this,
obs_module_text(
"AdvSceneSwitcher.generalTab.generalBehavior.settingsLock.title"),
obs_module_text(
"AdvSceneSwitcher.generalTab.generalBehavior.settingsLock.confirmPasswordLabel"),
QLineEdit::Password, QString(), &ok);
if (!ok || newPassword != confirm) {
if (ok) {
QMessageBox::warning(
this,
obs_module_text(
"AdvSceneSwitcher.generalTab.generalBehavior.settingsLock.title"),
obs_module_text(
"AdvSceneSwitcher.generalTab.generalBehavior.settingsLock.passwordMismatch"));
}
disablePassword();
return;
}
switcher->settingsLockEnabled = true;
switcher->settingsLockPassword = newPassword.toStdString();
}
void AdvSceneSwitcher::SetDeprecationWarnings()
{
QString toolTip =
@@ -392,7 +452,7 @@ void AdvSceneSwitcher::CheckFirstTimeSetup()
}
bool wasSkipped = false;
auto macro = FirstRunWizard::ShowWizard(this, &wasSkipped);
auto macro = wiz::FirstRunWizard::ShowWizard(this, &wasSkipped);
if (macro) {
renameMacroIfNecessary(macro);
QTimer::singleShot(0, this,
@@ -406,7 +466,7 @@ void AdvSceneSwitcher::CheckFirstTimeSetup()
void AdvSceneSwitcher::on_openSetupWizard_clicked()
{
auto macro = FirstRunWizard::ShowWizard(this);
auto macro = wiz::FirstRunWizard::ShowWizard(this);
if (!macro) {
return;
}
@@ -567,6 +627,9 @@ void SwitcherData::SaveGeneralSettings(obs_data_t *obj)
disableFilterComboboxFilter);
obs_data_set_bool(obj, "warnPluginLoadFailure", warnPluginLoadFailure);
obs_data_set_bool(obj, "hideLegacyTabs", hideLegacyTabs);
obs_data_set_bool(obj, "settingsLockEnabled", settingsLockEnabled);
obs_data_set_string(obj, "settingsLockPassword",
settingsLockPassword.c_str());
SaveFunctionPriorities(obj, functionNamesByPriority);
@@ -638,6 +701,8 @@ void SwitcherData::LoadGeneralSettings(obs_data_t *obj)
warnPluginLoadFailure = obs_data_get_bool(obj, "warnPluginLoadFailure");
obs_data_set_default_bool(obj, "hideLegacyTabs", true);
hideLegacyTabs = obs_data_get_bool(obj, "hideLegacyTabs");
settingsLockEnabled = obs_data_get_bool(obj, "settingsLockEnabled");
settingsLockPassword = obs_data_get_string(obj, "settingsLockPassword");
SetDefaultFunctionPriorities(obj);
LoadFunctionPriorities(obj, functionNamesByPriority);
@@ -960,6 +1025,7 @@ void AdvSceneSwitcher::SetupGeneralTab()
ui->warnPluginLoadFailure->setChecked(switcher->warnPluginLoadFailure);
ui->suppressCrashRecoveryDialog->setChecked(GetSuppressCrashDialog());
ui->hideLegacyTabs->setChecked(switcher->hideLegacyTabs);
ui->settingsLock->setChecked(switcher->settingsLockEnabled);
populatePriorityFunctionList(ui->priorityList);
populateThreadPriorityList(ui->threadPriority);

View File

@@ -294,6 +294,30 @@ std::vector<WindowInfo> GetWindows(const WindowQueryOptions &options)
result.emplace_back(std::move(info));
}
// When KWin compat is active, native Wayland windows are not tracked by
// X11 and therefore do not appear in the list above. Merge in any window
// reported by KWin that is not already present (matched by title) so that
// title and focus conditions work for native Wayland windows.
if (KWin) {
for (const auto &[pid, title] :
FocusNotifier::getWindowList()) {
bool found = false;
for (const auto &info : result) {
if (info.title == title) {
found = true;
break;
}
}
if (!found) {
WindowInfo waylandWindow;
waylandWindow.title = title;
waylandWindow.focused =
(title == foregroundTitle);
result.emplace_back(std::move(waylandWindow));
}
}
}
return result;
}

View File

@@ -12,35 +12,82 @@
namespace advss {
std::mutex FocusNotifier::_mutex;
int FocusNotifier::activePID = -1;
std::string FocusNotifier::activeTitle = {};
std::map<std::string, std::pair<int, std::string>> FocusNotifier::_windows;
int FocusNotifier::getActiveWindowPID()
{
std::lock_guard<std::mutex> lock(_mutex);
return activePID;
}
std::string FocusNotifier::getActiveWindowTitle()
{
std::lock_guard<std::mutex> lock(_mutex);
return activeTitle;
}
std::vector<std::pair<int, std::string>> FocusNotifier::getWindowList()
{
std::lock_guard<std::mutex> lock(_mutex);
std::vector<std::pair<int, std::string>> result;
result.reserve(_windows.size());
for (const auto &entry : _windows) {
result.emplace_back(entry.second);
}
return result;
}
void FocusNotifier::focusChanged(const int pid)
{
std::lock_guard<std::mutex> lock(_mutex);
activePID = pid;
}
void FocusNotifier::focusTitle(const QString &title)
{
std::lock_guard<std::mutex> lock(_mutex);
activeTitle = title.toStdString();
}
void FocusNotifier::focusUpdate(const int pid, const QString &title)
{
std::lock_guard<std::mutex> lock(_mutex);
activePID = pid;
activeTitle = title.toStdString();
}
void FocusNotifier::windowAdded(const QString &id, const int pid,
const QString &title)
{
std::lock_guard<std::mutex> lock(_mutex);
_windows[id.toStdString()] = {pid, title.toStdString()};
}
void FocusNotifier::windowRemoved(const QString &id)
{
std::lock_guard<std::mutex> lock(_mutex);
_windows.erase(id.toStdString());
}
void FocusNotifier::windowTitleChanged(const QString &id, const QString &title)
{
std::lock_guard<std::mutex> lock(_mutex);
auto it = _windows.find(id.toStdString());
if (it != _windows.end()) {
it->second.second = title.toStdString();
}
}
bool isKWinAvailable()
{
const QDBusConnectionInterface *interface =
QDBusConnection::sessionBus().interface();
if (!interface)
if (!interface) {
return false;
}
const QStringList services =
interface->registeredServiceNames().value();
@@ -53,25 +100,32 @@ bool startKWinScript(QString &scriptObjectPath)
"/tmp/AdvancedSceneSwitcher/KWinFocusNotifier.js";
const QString script =
R"(workspace.windowActivated.connect(function(client) {
if (!client) return;
if (!client.pid) return;
if (!client.caption) return;
R"(var adss = "com.github.AdvancedSceneSwitcher";
var adssPath = "/com/github/AdvancedSceneSwitcher";
callDBus(
"com.github.AdvancedSceneSwitcher",
"/com/github/AdvancedSceneSwitcher",
"com.github.AdvancedSceneSwitcher",
"focusChanged",
client.pid
);
callDBus(
"com.github.AdvancedSceneSwitcher",
"/com/github/AdvancedSceneSwitcher",
"com.github.AdvancedSceneSwitcher",
"focusTitle",
client.caption
);
function trackWindow(window) {
var id = window.internalId.toString();
callDBus(adss, adssPath, adss, "windowAdded", id, window.pid, window.caption);
window.captionChanged.connect(function() {
callDBus(adss, adssPath, adss, "windowTitleChanged", id, window.caption);
});
}
var windows = workspace.windowList();
for (var i = 0; i < windows.length; i++) {
trackWindow(windows[i]);
}
workspace.windowAdded.connect(trackWindow);
workspace.windowRemoved.connect(function(window) {
callDBus(adss, adssPath, adss, "windowRemoved", window.internalId.toString());
});
workspace.windowActivated.connect(function(client) {
callDBus(adss, adssPath, adss, "focusUpdate",
client ? client.pid : 0,
client ? client.caption : "");
}))";
if (const QDir dir; !dir.mkpath(QFileInfo(scriptPath).absolutePath())) {

View File

@@ -2,7 +2,11 @@
#include <QObject>
#include <QString>
#include <map>
#include <mutex>
#include <string>
#include <utility>
#include <vector>
namespace advss {
@@ -10,18 +14,27 @@ class FocusNotifier final : public QObject {
Q_OBJECT
Q_CLASSINFO("D-Bus Interface", "com.github.AdvancedSceneSwitcher")
static std::mutex _mutex;
static int activePID;
static std::string activeTitle;
// Maps KWin internal window ID to {pid, title}
static std::map<std::string, std::pair<int, std::string>> _windows;
public:
using QObject::QObject;
static int getActiveWindowPID();
static std::string getActiveWindowTitle();
static std::vector<std::pair<int, std::string>> getWindowList();
public slots:
void focusChanged(const int pid);
void focusTitle(const QString &title);
void focusUpdate(const int pid, const QString &title);
void windowAdded(const QString &id, const int pid,
const QString &title);
void windowRemoved(const QString &id);
void windowTitleChanged(const QString &id, const QString &title);
};
bool isKWinAvailable();

View File

@@ -164,4 +164,9 @@ std::shared_ptr<MacroSegment> MacroActionEdit::Data() const
return *_entryData;
}
void MacroActionEdit::FocusTypeSelection()
{
_actionSelection->setFocus();
}
} // namespace advss

View File

@@ -17,6 +17,7 @@ public:
std::shared_ptr<MacroAction> * = nullptr);
void SetupWidgets(bool basicSetup = false);
void SetEntryData(std::shared_ptr<MacroAction> *);
void FocusTypeSelection() override;
private slots:
void ActionSelectionChanged(const QString &text);

View File

@@ -263,6 +263,7 @@ bool MacroActionMacro::Load(obs_data_t *obj)
if (obs_data_has_user_value(obj, "nestedMacro")) {
OBSDataAutoRelease nestedMacroData =
obs_data_get_obj(obj, "nestedMacro");
_nestedMacro = std::make_shared<Macro>();
_nestedMacro->Load(nestedMacroData);
}
@@ -309,6 +310,15 @@ void MacroActionMacro::ResolveVariablesToFixedValues()
{
_actionIndex.ResolveVariables();
_label.ResolveVariables();
if (_action == Action::NESTED_MACRO) {
for (auto &action : _nestedMacro->Actions()) {
action->ResolveVariablesToFixedValues();
}
for (auto &action : _nestedMacro->ElseActions()) {
action->ResolveVariablesToFixedValues();
}
}
}
static void runActionsHelper(Macro *macro, bool runElseActions, bool setInputs,

View File

@@ -447,7 +447,19 @@ bool MacroActionVariable::PerformAction()
if (!value.has_value()) {
return true;
}
var->SetValue(*value);
if (!_jsonQueryExtractSingle) {
var->SetValue(*value);
return true;
}
auto extracted = ExtractSingleJsonArrayElement(*value);
if (extracted.has_value()) {
var->SetValue(*extracted);
} else {
var->SetValue(*value);
}
return true;
}
case Action::ARRAY_JSON: {
@@ -551,6 +563,8 @@ bool MacroActionVariable::Save(obs_data_t *obj) const
obs_data_set_bool(obj, "allowRepeatValues", _allowRepeatValues);
_jsonQuery.Save(obj, "jsonQuery");
_jsonIndex.Save(obj, "jsonIndex");
obs_data_set_bool(obj, "jsonQueryExtractSingle",
_jsonQueryExtractSingle);
obs_data_set_int(obj, "version", 1);
@@ -611,6 +625,8 @@ bool MacroActionVariable::Load(obs_data_t *obj)
_allowRepeatValues = obs_data_get_bool(obj, "allowRepeatValues");
_jsonQuery.Load(obj, "jsonQuery");
_jsonIndex.Load(obj, "jsonIndex");
_jsonQueryExtractSingle =
obs_data_get_bool(obj, "jsonQueryExtractSingle");
return true;
}
@@ -904,6 +920,15 @@ MacroActionVariableEdit::MacroActionVariableEdit(
"AdvSceneSwitcher.action.variable.type.queryJson.info"),
this)),
_jsonIndex(new VariableSpinBox(this)),
_jsonExtractSingle(new QCheckBox(
obs_module_text(
"AdvSceneSwitcher.action.variable.type.queryJson.extractSingle"),
this)),
_jsonExtractSingleHelp(new HelpIcon(
obs_module_text(
"AdvSceneSwitcher.action.variable.type.queryJson.extractSingle.info"),
this)),
_jsonQueryLayout(new QVBoxLayout()),
_entryLayout(new QHBoxLayout())
{
_numValue->setMinimum(-9999999999);
@@ -935,6 +960,11 @@ MacroActionVariableEdit::MacroActionVariableEdit(
_randomNumberEnd->setMaximum(9999999999);
_randomValues->SetMaxStringSize(99999999);
_jsonIndex->setMaximum(999);
auto jsonExtractSingleLayout = new QHBoxLayout();
jsonExtractSingleLayout->addWidget(_jsonExtractSingle);
jsonExtractSingleLayout->addWidget(_jsonExtractSingleHelp);
jsonExtractSingleLayout->addStretch();
_jsonQueryLayout->addLayout(jsonExtractSingleLayout);
QWidget::connect(_variables, SIGNAL(SelectionChanged(const QString &)),
this, SLOT(VariableChanged(const QString &)));
@@ -1033,6 +1063,8 @@ MacroActionVariableEdit::MacroActionVariableEdit(
_jsonIndex,
SIGNAL(NumberVariableChanged(const NumberVariable<int> &)),
this, SLOT(JsonIndexChanged(const NumberVariable<int> &)));
QWidget::connect(_jsonExtractSingle, SIGNAL(stateChanged(int)), this,
SLOT(JsonQueryExtractSingleChanged(int)));
const std::unordered_map<std::string, QWidget *> widgetPlaceholders = {
{"{{variables}}", _variables},
@@ -1109,6 +1141,7 @@ MacroActionVariableEdit::MacroActionVariableEdit(
auto layout = new QVBoxLayout;
layout->addLayout(_entryLayout);
layout->addLayout(_jsonQueryLayout);
layout->addLayout(_substringLayout);
layout->addWidget(_segmentValueStatus);
layout->addWidget(_segmentValue);
@@ -1180,6 +1213,7 @@ void MacroActionVariableEdit::UpdateEntryData()
_randomValues->SetStringList(_entryData->_randomValues);
_jsonQuery->setText(_entryData->_jsonQuery);
_jsonIndex->SetValue(_entryData->_jsonIndex);
_jsonExtractSingle->setChecked(_entryData->_jsonQueryExtractSingle);
SetWidgetVisibility();
}
@@ -1545,6 +1579,12 @@ void MacroActionVariableEdit::JsonIndexChanged(const NumberVariable<int> &value)
_entryData->_jsonIndex = value;
}
void MacroActionVariableEdit::JsonQueryExtractSingleChanged(int value)
{
GUARD_LOADING_AND_LOCK();
_entryData->_jsonQueryExtractSingle = value;
}
void MacroActionVariableEdit::SetWidgetVisibility()
{
if (!_entryData) {
@@ -1593,6 +1633,10 @@ void MacroActionVariableEdit::SetWidgetVisibility()
MacroActionVariable::Action::TRUNCATE) {
layoutString = obs_module_text(
"AdvSceneSwitcher.action.variable.layout.truncate");
} else if (_entryData->_action ==
MacroActionVariable::Action::COPY_VAR) {
layoutString = obs_module_text(
"AdvSceneSwitcher.action.variable.layout.copy");
} else {
layoutString = obs_module_text(
"AdvSceneSwitcher.action.variable.layout.other");
@@ -1740,6 +1784,9 @@ void MacroActionVariableEdit::SetWidgetVisibility()
MacroActionVariable::Action::QUERY_JSON);
_jsonIndex->setVisible(_entryData->_action ==
MacroActionVariable::Action::ARRAY_JSON);
SetLayoutVisible(_jsonQueryLayout,
_entryData->_action ==
MacroActionVariable::Action::QUERY_JSON);
adjustSize();
updateGeometry();

View File

@@ -116,6 +116,7 @@ public:
StringVariable _jsonQuery = "$.some.nested.value";
IntVariable _jsonIndex = 0;
bool _jsonQueryExtractSingle = true;
private:
void DecrementCurrentSegmentVariableRef();
@@ -187,6 +188,7 @@ private slots:
void AllowRepeatValuesChanged(int);
void JsonQueryChanged();
void JsonIndexChanged(const NumberVariable<int> &);
void JsonQueryExtractSingleChanged(int);
signals:
void HeaderInfoChanged(const QString &);
@@ -239,8 +241,11 @@ private:
QCheckBox *_allowRepeatValues;
QVBoxLayout *_randomValueLayout;
VariableLineEdit *_jsonQuery;
QLabel *_jsonQueryHelp;
HelpIcon *_jsonQueryHelp;
VariableSpinBox *_jsonIndex;
QCheckBox *_jsonExtractSingle;
HelpIcon *_jsonExtractSingleHelp;
QVBoxLayout *_jsonQueryLayout;
QHBoxLayout *_entryLayout;
std::shared_ptr<MacroActionVariable> _entryData;

View File

@@ -305,4 +305,9 @@ std::shared_ptr<MacroSegment> MacroConditionEdit::Data() const
return *_entryData;
}
void MacroConditionEdit::FocusTypeSelection()
{
_conditionSelection->setFocus();
}
} // namespace advss

View File

@@ -43,6 +43,7 @@ public:
void SetRootNode(bool);
void SetupWidgets(bool basicSetup = false);
void SetEntryData(std::shared_ptr<MacroCondition> *);
void FocusTypeSelection() override;
private slots:
void LogicSelectionChanged(int idx);

View File

@@ -650,6 +650,18 @@ void MacroEdit::HighlightCondition(int idx, QColor color) const
ui->conditionsList->Highlight(idx, color);
}
void MacroEdit::ScrollAndFocusNewSegment(MacroSegmentList *list, int newIdx,
int *currentIdx)
{
QTimer::singleShot(0, this, [this, list, newIdx, currentIdx]() {
list->ensureWidgetVisible(list->WidgetAt(*currentIdx));
auto *widget = list->WidgetAt(newIdx);
if (widget) {
widget->FocusTypeSelection();
}
});
}
static void resetSegmentHighlights(MacroSegmentList *list)
{
MacroSegmentEdit *widget = nullptr;
@@ -1087,10 +1099,7 @@ void MacroEdit::AddMacroAction(Macro *macro, int idx, const std::string &id,
ui->actionsList->SetHelpMsgVisible(false);
emit(MacroSegmentOrderChanged());
QTimer::singleShot(0, this, [this]() {
ui->actionsList->ensureWidgetVisible(
ui->actionsList->WidgetAt(currentActionIdx));
});
ScrollAndFocusNewSegment(ui->actionsList, idx, &currentActionIdx);
}
void MacroEdit::AddMacroAction(int idx)
@@ -1398,10 +1407,8 @@ void MacroEdit::AddMacroElseAction(Macro *macro, int idx, const std::string &id,
ui->elseActionsList->SetHelpMsgVisible(false);
emit(MacroSegmentOrderChanged());
QTimer::singleShot(0, this, [this]() {
ui->elseActionsList->ensureWidgetVisible(
ui->elseActionsList->WidgetAt(currentElseActionIdx));
});
ScrollAndFocusNewSegment(ui->elseActionsList, idx,
&currentElseActionIdx);
}
void MacroEdit::AddMacroElseAction(int idx)
@@ -1611,10 +1618,7 @@ void MacroEdit::AddMacroCondition(Macro *macro, int idx, const std::string &id,
ui->conditionsList->SetHelpMsgVisible(false);
emit(MacroSegmentOrderChanged());
QTimer::singleShot(0, this, [this]() {
ui->conditionsList->ensureWidgetVisible(
ui->conditionsList->WidgetAt(currentConditionIdx));
});
ScrollAndFocusNewSegment(ui->conditionsList, idx, &currentConditionIdx);
}
void MacroEdit::on_conditionAdd_clicked()

View File

@@ -116,6 +116,8 @@ private:
void PopulateMacroActions(Macro &m, uint32_t afterIdx = 0);
void PopulateMacroElseActions(Macro &m, uint32_t afterIdx = 0);
void PopulateMacroConditions(Macro &m, uint32_t afterIdx = 0);
void ScrollAndFocusNewSegment(MacroSegmentList *list, int newIdx,
int *currentIdx);
void SetupMacroSegmentSelection(MacroSection type, int idx);
void
SetupContextMenu(const QPoint &pos,

View File

@@ -1,11 +1,14 @@
#include "macro-export-import-dialog.hpp"
#include "macro-export-extensions.hpp"
#include "obs-module-helper.hpp"
#include "section.hpp"
#include <obs.hpp>
#include <QDialogButtonBox>
#include <QLabel>
#include <QLayout>
#include <QScrollArea>
#include <QSplitter>
#include <algorithm>
#include <numeric>
@@ -44,10 +47,14 @@ static bool isValidData(const QString &json)
// Extension section
// ---------------------------------------------------------------------------
Section *MacroExportImportDialog::BuildExtensionSection()
QWidget *MacroExportImportDialog::BuildExtensionWidget()
{
auto outerSection = new Section(300, this);
outerSection->AddHeaderWidget(new QLabel(obs_module_text(
auto wrapper = new QWidget(this);
auto wrapperLayout = new QVBoxLayout(wrapper);
wrapperLayout->setContentsMargins(0, 0, 0, 0);
wrapperLayout->setSpacing(4);
wrapperLayout->addWidget(new QLabel(obs_module_text(
"AdvSceneSwitcher.macroTab.export.additionalContent")));
auto outerContent = new QWidget();
@@ -57,7 +64,6 @@ Section *MacroExportImportDialog::BuildExtensionSection()
const auto &extensions = GetMacroExportExtensions();
// Build a sorted index list (alphabetical by translated display name).
std::vector<int> order(extensions.size());
std::iota(order.begin(), order.end(), 0);
std::sort(order.begin(), order.end(), [&](int a, int b) {
@@ -81,6 +87,7 @@ Section *MacroExportImportDialog::BuildExtensionSection()
&MacroExportImportDialog::UpdateExportString);
outerLayout->addWidget(ui.mainCheck);
_extensionUIs.append(std::move(ui));
_extensionOrder.push_back(extIdx);
continue;
}
@@ -88,11 +95,18 @@ Section *MacroExportImportDialog::BuildExtensionSection()
// Section: the main checkbox sits in the header (next to the
// toggle arrow) and the per-item checkboxes live in the
// collapsible content area.
const auto items = ext.getExportItems();
auto items = ext.getExportItems();
if (items.isEmpty()) {
continue;
}
std::sort(items.begin(), items.end(),
[](const QPair<QString, QString> &a,
const QPair<QString, QString> &b) {
return QString::localeAwareCompare(
a.second, b.second) < 0;
});
auto innerSection = new Section(200, outerContent);
ui.mainCheck =
@@ -132,13 +146,22 @@ Section *MacroExportImportDialog::BuildExtensionSection()
UpdateExportString();
});
innerSection->SetContent(subWidget, false);
innerSection->SetContent(subWidget, true);
outerLayout->addWidget(innerSection);
_extensionUIs.append(std::move(ui));
_extensionOrder.push_back(extIdx);
}
outerSection->SetContent(outerContent, true);
return outerSection;
outerLayout->addStretch();
auto scrollArea = new QScrollArea(wrapper);
scrollArea->setWidget(outerContent);
scrollArea->setWidgetResizable(true);
scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
scrollArea->setFrameShape(QFrame::NoFrame);
wrapperLayout->addWidget(scrollArea);
return wrapper;
}
// ---------------------------------------------------------------------------
@@ -180,12 +203,25 @@ MacroExportImportDialog::MacroExportImportDialog(Type type,
auto layout = new QVBoxLayout(this);
if (type == Type::EXPORT_MACRO && !GetMacroExportExtensions().empty()) {
layout->addWidget(BuildExtensionSection());
auto splitter = new QSplitter(Qt::Vertical, this);
splitter->setChildrenCollapsible(false);
splitter->addWidget(BuildExtensionWidget());
auto textWidget = new QWidget(splitter);
auto textLayout = new QVBoxLayout(textWidget);
textLayout->setContentsMargins(0, 0, 0, 0);
textLayout->addWidget(label);
textLayout->addWidget(_importExportString);
textLayout->addWidget(_usePlainText);
splitter->addWidget(textWidget);
layout->addWidget(splitter);
} else {
layout->addWidget(label);
layout->addWidget(_importExportString);
layout->addWidget(_usePlainText);
}
layout->addWidget(label);
layout->addWidget(_importExportString);
layout->addWidget(_usePlainText);
layout->addWidget(buttons);
setLayout(layout);
@@ -209,19 +245,26 @@ QString MacroExportImportDialog::BuildExportJson() const
}
const auto &extensions = GetMacroExportExtensions();
for (int i = 0;
i < (int)extensions.size() && i < (int)_extensionUIs.size(); ++i) {
if (!_extensionUIs[i].mainCheck->isChecked()) {
continue;
}
for (int i = 0; i < (int)_extensionUIs.size(); ++i) {
const auto &ui = _extensionUIs[i];
QStringList selectedIds;
for (const auto &[id, cb] : _extensionUIs[i].itemChecks) {
if (cb->isChecked()) {
selectedIds << id;
if (ui.itemChecks.isEmpty()) {
if (!ui.mainCheck->isChecked()) {
continue;
}
} else {
for (const auto &[id, cb] : ui.itemChecks) {
if (cb->isChecked()) {
selectedIds << id;
}
}
if (selectedIds.isEmpty()) {
continue;
}
}
extensions[i].save(data, selectedIds);
extensions[_extensionOrder[i]].save(data, selectedIds);
}
const char *json = obs_data_get_json(data);

View File

@@ -1,13 +1,12 @@
#pragma once
#include "section.hpp"
#include <QCheckBox>
#include <QDialog>
#include <QList>
#include <QPlainTextEdit>
#include <obs-data.h>
#include <vector>
namespace advss {
@@ -34,7 +33,7 @@ private:
QList<QPair<QString, QCheckBox *>> itemChecks;
};
Section *BuildExtensionSection();
QWidget *BuildExtensionWidget();
QString BuildExportJson() const;
void RefreshExportText();
@@ -42,6 +41,7 @@ private:
QPlainTextEdit *_importExportString;
QCheckBox *_usePlainText;
QList<ExtensionUI> _extensionUIs;
std::vector<int> _extensionOrder;
};
} // namespace advss

View File

@@ -76,7 +76,8 @@ EXPORT bool RunMacroElseActions(Macro *, bool forceParallel = false,
bool ignorePause = false);
EXPORT bool RunMacros();
void StopAllMacros();
void SignalStopAllMacros();
void WaitForAllMacros();
EXPORT void LoadMacros(obs_data_t *obj);
EXPORT void SaveMacros(obs_data_t *obj);

View File

@@ -108,7 +108,7 @@ void MacroRunButton::Pressed()
const bool abortMacro = DisplayMessage(
err.arg(QString::fromStdString(macro->Name())), true);
if (abortMacro) {
macro->Stop();
macro->SignalStop();
}
}

View File

@@ -134,6 +134,7 @@ public:
void SetSelected(bool);
virtual std::shared_ptr<MacroSegment> Data() const = 0;
virtual void SetupWidgets(bool basicSetup = false) = 0;
virtual void FocusTypeSelection() {}
void SetupVarMappings(MacroSegment *segment);

View File

@@ -395,7 +395,7 @@ void AdvSceneSwitcher::ImportMacros()
std::vector<std::shared_ptr<Macro>> importedMacros;
auto &tempMacros = GetTemporaryMacros();
auto lock = LockContext();
std::unique_lock<std::mutex> lock(*GetMutex());
for (size_t i = 0; i < count; i++) {
tempMacros.clear();
@@ -405,10 +405,14 @@ void AdvSceneSwitcher::ImportMacros()
macro->Load(array_obj);
RunAndClearPostLoadSteps();
if (macroNameExists(macro->Name()) &&
!ResolveMacroImportNameConflict(macro)) {
groupSize--;
continue;
if (macroNameExists(macro->Name())) {
lock.unlock();
bool resolved = ResolveMacroImportNameConflict(macro);
lock.lock();
if (!resolved) {
groupSize--;
continue;
}
}
importedMacros.emplace_back(macro);

View File

@@ -552,6 +552,12 @@ Macro::PauseStateSaveBehavior Macro::GetPauseStateSaveBehavior() const
return _pauseSaveBehavior;
}
void Macro::SignalStop()
{
_stop = true;
GetMacroWaitCV().notify_all();
}
void Macro::Stop()
{
_stop = true;
@@ -1316,7 +1322,14 @@ bool RunMacros()
return true;
}
void StopAllMacros()
void SignalStopAllMacros()
{
for (const auto &m : GetAllMacros()) {
m->SignalStop();
}
}
void WaitForAllMacros()
{
for (const auto &m : GetAllMacros()) {
m->Stop();

View File

@@ -61,6 +61,7 @@ public:
PauseStateSaveBehavior GetPauseStateSaveBehavior() const;
void Stop();
void SignalStop();
bool GetStop() const { return _stop; }
void ResetTimers();
@@ -228,7 +229,7 @@ void LoadMacros(obs_data_t *obj);
void SaveMacros(obs_data_t *obj);
bool CheckMacros();
bool RunMacros();
void StopAllMacros();
void WaitForAllMacros();
void InvalidateMacroTempVarValues();
std::shared_ptr<Macro> GetMacroWithInvalidConditionInterval();

View File

@@ -163,6 +163,8 @@ public:
bool disableHints = false;
bool disableFilterComboboxFilter = false;
bool hideLegacyTabs = true;
bool settingsLockEnabled = false;
std::string settingsLockPassword;
bool saveWindowGeo = false;
QPoint windowPos = {};
QSize windowSize = {};

View File

@@ -0,0 +1,425 @@
#include "first-run-wizard-audio.hpp"
#include "first-run-wizard-helpers.hpp"
#include "layout-helpers.hpp"
#include "log-helper.hpp"
#include "macro-settings.hpp"
#include "selection-helpers.hpp"
#include <obs-data.h>
#include <obs.hpp>
#include <QHBoxLayout>
#include <QMessageBox>
#include <QVBoxLayout>
#include <QWidget>
namespace advss {
namespace wiz {
// Builds the obs_data blob for an audio volume condition,
// matching MacroConditionAudio::Save() output.
//
// Checks output volume (checkType 0) ABOVE (outputCondition 0) the given
// threshold in dB, held for at least `duration`.
//
// {
// "segmentSettings": { "enabled": true, "version": 2 },
// "id": "audio",
// "logic": 0,
// "durationModifier": {
// "time_constraint": 1, // AT_LEAST
// "seconds": <Duration::Save output>
// },
// "audioSource": { "type": 0, "name": "<source>" },
// "monitor": 0,
// "volume": { "value": 0.0, "type": 0 },
// "syncOffset": { "value": 0, "type": 0 },
// "balance": { "value": 0.5, "type": 0 },
// "checkType": 0, // OUTPUT_VOLUME
// "outputCondition": 0, // ABOVE
// "volumeCondition": 0,
// "useDb": true,
// "volumeDB": { "value": <thresholdDb>, "type": 0 },
// "version": 3
// }
static OBSDataAutoRelease buildAudioConditionData(const QString &sourceName,
double thresholdDb,
const Duration &duration)
{
OBSDataAutoRelease seg = obs_data_create();
obs_data_set_bool(seg, "enabled", true);
obs_data_set_int(seg, "version", 2);
OBSDataAutoRelease durMod = obs_data_create();
obs_data_set_int(durMod, "time_constraint", 1);
duration.Save(durMod, "seconds");
OBSDataAutoRelease audioSrc = obs_data_create();
obs_data_set_int(audioSrc, "type", 0);
obs_data_set_string(audioSrc, "name", sourceName.toUtf8().constData());
OBSDataAutoRelease volume = obs_data_create();
obs_data_set_double(volume, "value", 0.0);
obs_data_set_int(volume, "type", 0);
OBSDataAutoRelease syncOffset = obs_data_create();
obs_data_set_int(syncOffset, "value", 0);
obs_data_set_int(syncOffset, "type", 0);
OBSDataAutoRelease balance = obs_data_create();
obs_data_set_double(balance, "value", 0.5);
obs_data_set_int(balance, "type", 0);
OBSDataAutoRelease volumeDB = obs_data_create();
obs_data_set_double(volumeDB, "value", thresholdDb);
obs_data_set_int(volumeDB, "type", 0);
OBSDataAutoRelease data = obs_data_create();
obs_data_set_obj(data, "segmentSettings", seg);
obs_data_set_string(data, "id", "audio");
obs_data_set_int(data, "logic", 0);
obs_data_set_obj(data, "durationModifier", durMod);
obs_data_set_obj(data, "audioSource", audioSrc);
obs_data_set_int(data, "monitor", 0);
obs_data_set_obj(data, "volume", volume);
obs_data_set_obj(data, "syncOffset", syncOffset);
obs_data_set_obj(data, "balance", balance);
obs_data_set_int(data, "checkType", 0);
obs_data_set_int(data, "outputCondition", 0);
obs_data_set_int(data, "volumeCondition", 0);
obs_data_set_bool(data, "useDb", true);
obs_data_set_obj(data, "volumeDB", volumeDB);
obs_data_set_int(data, "version", 3);
return data;
}
// Builds the obs_data blob for a scene-visibility action on the current scene,
// matching MacroActionSceneVisibility::Save() output.
//
// sceneSelection type 3 = current scene.
// action 0 = SHOW, action 1 = HIDE.
static OBSDataAutoRelease buildVisibilityActionData(const QString &sourceName,
int action)
{
OBSDataAutoRelease seg = obs_data_create();
obs_data_set_bool(seg, "enabled", true);
obs_data_set_int(seg, "version", 2);
OBSDataAutoRelease sceneSel = obs_data_create();
obs_data_set_int(sceneSel, "type", 3); // current scene
obs_data_set_string(sceneSel, "canvasSelection", "Main");
OBSDataAutoRelease itemSel = obs_data_create();
obs_data_set_int(itemSel, "type", 0);
obs_data_set_int(itemSel, "idxType", 0);
obs_data_set_int(itemSel, "idx", 0);
obs_data_set_string(itemSel, "item", sourceName.toUtf8().constData());
OBSDataAutoRelease data = obs_data_create();
obs_data_set_obj(data, "segmentSettings", seg);
obs_data_set_string(data, "id", "scene_visibility");
obs_data_set_obj(data, "sceneSelection", sceneSel);
obs_data_set_obj(data, "sceneItemSelection", itemSel);
obs_data_set_bool(data, "updateTransition", false);
obs_data_set_int(data, "transitionType", 0);
obs_data_set_string(data, "transition", "");
obs_data_set_bool(data, "updateDuration", false);
Duration().Save(data, "duration");
obs_data_set_int(data, "action", action);
return data;
}
// ===========================================================================
// AudioSourcePage
// ===========================================================================
AudioSourcePage::AudioSourcePage(QWidget *parent)
: QWizardPage(parent),
_sourceCombo(new QComboBox(this)),
_thresholdSpinbox(new QDoubleSpinBox(this)),
_durationSelection(new DurationSelection(this, true, 0.0)),
_volmeterLayout(new QVBoxLayout)
{
setTitle(obs_module_text("FirstRunWizard.audio.source.title"));
setSubTitle(obs_module_text("FirstRunWizard.audio.source.subtitle"));
_sourceCombo->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
_thresholdSpinbox->setRange(-100.0, 0.0);
_thresholdSpinbox->setValue(-24.0);
_thresholdSpinbox->setDecimals(1);
_thresholdSpinbox->setSingleStep(1.0);
_thresholdSpinbox->setSuffix(" dB");
_durationSelection->SetDuration(Duration(1.0));
registerField("audioSourceName*", _sourceCombo, "currentText",
SIGNAL(currentTextChanged(QString)));
connect(_sourceCombo, &QComboBox::currentTextChanged, this,
&QWizardPage::completeChanged);
connect(_sourceCombo, &QComboBox::currentTextChanged, this,
&AudioSourcePage::UpdateVolmeter);
connect(_thresholdSpinbox,
QOverload<double>::of(&QDoubleSpinBox::valueChanged), this,
&AudioSourcePage::SyncSliderFromSpinbox);
auto *sourceRow = new QHBoxLayout;
PlaceWidgets(obs_module_text("FirstRunWizard.audio.source.sourceRow"),
sourceRow, {{"{{source}}", _sourceCombo}}, false);
auto *thresholdRow = new QHBoxLayout;
PlaceWidgets(
obs_module_text("FirstRunWizard.audio.source.thresholdRow"),
thresholdRow,
{{"{{threshold}}", _thresholdSpinbox},
{"{{duration}}", _durationSelection}},
false);
auto *layout = new QVBoxLayout(this);
layout->addLayout(sourceRow);
layout->addLayout(thresholdRow);
layout->addLayout(_volmeterLayout);
layout->addStretch();
}
void AudioSourcePage::initializePage()
{
_sourceCombo->clear();
PopulateAudioSelection(_sourceCombo, false);
}
void AudioSourcePage::UpdateVolmeter()
{
delete _volControl;
_volControl = nullptr;
OBSSourceAutoRelease source = obs_get_source_by_name(
_sourceCombo->currentText().toUtf8().constData());
if (!source) {
return;
}
_volControl = new VolControl(source.Get());
_volmeterLayout->addWidget(_volControl);
connect(_volControl->GetSlider(), &DoubleSlider::DoubleValChanged, this,
&AudioSourcePage::SyncSpinboxFromSlider);
SyncSliderFromSpinbox();
}
void AudioSourcePage::SyncSpinboxFromSlider()
{
if (!_volControl) {
return;
}
const QSignalBlocker blocker(_thresholdSpinbox);
_thresholdSpinbox->setValue(_volControl->GetSlider()->DoubleValue() -
100.0);
}
void AudioSourcePage::SyncSliderFromSpinbox()
{
if (!_volControl) {
return;
}
const QSignalBlocker blocker(_volControl->GetSlider());
_volControl->GetSlider()->SetDoubleVal(_thresholdSpinbox->value() +
100.0);
}
bool AudioSourcePage::isComplete() const
{
return _sourceCombo->count() > 0 &&
!_sourceCombo->currentText().isEmpty();
}
Duration AudioSourcePage::GetDuration() const
{
return _durationSelection->GetDuration();
}
// ===========================================================================
// AudioTargetPage
// ===========================================================================
AudioTargetPage::AudioTargetPage(QWidget *parent)
: QWizardPage(parent),
_sourceCombo(new QComboBox(this))
{
setTitle(obs_module_text("FirstRunWizard.audio.target.title"));
setSubTitle(obs_module_text("FirstRunWizard.audio.target.subtitle"));
_sourceCombo->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
registerField("audioTargetSource*", _sourceCombo, "currentText",
SIGNAL(currentTextChanged(QString)));
connect(_sourceCombo, &QComboBox::currentTextChanged, this,
&QWizardPage::completeChanged);
auto *row = new QHBoxLayout;
PlaceWidgets(obs_module_text("FirstRunWizard.audio.target.sourceRow"),
row, {{"{{source}}", _sourceCombo}}, false);
auto *layout = new QVBoxLayout(this);
layout->addLayout(row);
layout->addStretch();
}
void AudioTargetPage::initializePage()
{
QStringList names;
auto enumScenes = [](void *param, obs_source_t *src) -> bool {
auto *list = reinterpret_cast<QStringList *>(param);
obs_scene_t *scene = obs_scene_from_source(src);
obs_scene_enum_items(
scene,
[](obs_scene_t *, obs_sceneitem_t *item,
void *p) -> bool {
auto *l = reinterpret_cast<QStringList *>(p);
OBSSource s = obs_sceneitem_get_source(item);
if (s) {
*l << obs_source_get_name(s);
}
return true;
},
list);
return true;
};
obs_enum_scenes(enumScenes, &names);
names.sort(Qt::CaseInsensitive);
names.removeDuplicates();
const QString prev = _sourceCombo->currentText();
_sourceCombo->clear();
_sourceCombo->addItems(names);
const int idx = _sourceCombo->findText(prev);
if (idx >= 0) {
_sourceCombo->setCurrentIndex(idx);
}
}
bool AudioTargetPage::isComplete() const
{
return _sourceCombo->count() > 0 &&
!_sourceCombo->currentText().isEmpty();
}
// ===========================================================================
// AudioReviewPage
// ===========================================================================
AudioReviewPage::AudioReviewPage(QWidget *parent, std::shared_ptr<Macro> &macro)
: QWizardPage(parent),
_summary(new QLabel(this)),
_macro(macro)
{
setTitle(obs_module_text("FirstRunWizard.audio.review.title"));
setSubTitle(obs_module_text("FirstRunWizard.audio.review.subtitle"));
detail::setupSummaryLabel(_summary);
auto *layout = new QVBoxLayout(this);
layout->addWidget(_summary);
layout->addStretch();
}
void AudioReviewPage::initializePage()
{
const QString audioSource = field("audioSourceName").toString();
const QString targetSource = field("audioTargetSource").toString();
auto *sourcePage = qobject_cast<AudioSourcePage *>(
wizard()->page(PAGE_AUDIO_SOURCE));
const double thresholdDb = sourcePage ? sourcePage->GetThresholdDb()
: -24.0;
const Duration duration = sourcePage ? sourcePage->GetDuration()
: Duration(1.0);
_summary->setText(
QString(obs_module_text("FirstRunWizard.audio.review.summary"))
.arg(audioSource.toHtmlEscaped())
.arg(thresholdDb, 0, 'f', 1)
.arg(QString::fromStdString(duration.ToString()))
.arg(targetSource.toHtmlEscaped()));
}
bool AudioReviewPage::validatePage()
{
const QString audioSource = field("audioSourceName").toString();
const QString targetSource = field("audioTargetSource").toString();
const std::string name =
("Audio: " + audioSource + " -> " + targetSource).toStdString();
auto *sourcePage = qobject_cast<AudioSourcePage *>(
wizard()->page(PAGE_AUDIO_SOURCE));
const double thresholdDb = sourcePage ? sourcePage->GetThresholdDb()
: -24.0;
const Duration duration = sourcePage ? sourcePage->GetDuration()
: Duration(1.0);
_macro = std::make_shared<Macro>(name, GetGlobalMacroSettings());
if (!_macro) {
blog(LOG_WARNING,
"FirstRunWizard: audio macro allocation failed");
return true;
}
_macro->SetActionConditionSplitterPosition(
{QWIDGETSIZE_MAX / 2, QWIDGETSIZE_MAX / 2});
_macro->SetElseActionSplitterPosition(
{QWIDGETSIZE_MAX / 2, QWIDGETSIZE_MAX / 2});
OBSDataAutoRelease condData =
buildAudioConditionData(audioSource, thresholdDb, duration);
if (!detail::addCondition(_macro.get(), "audio", condData)) {
_macro.reset();
QMessageBox::warning(
this,
obs_module_text(
"FirstRunWizard.audio.review.errorTitle"),
obs_module_text(
"FirstRunWizard.audio.review.errorBody"));
return true;
}
OBSDataAutoRelease showData =
buildVisibilityActionData(targetSource, 0);
if (!detail::addAction(_macro.get(), "scene_visibility", showData)) {
_macro.reset();
QMessageBox::warning(
this,
obs_module_text(
"FirstRunWizard.audio.review.errorTitle"),
obs_module_text(
"FirstRunWizard.audio.review.errorBody"));
return true;
}
OBSDataAutoRelease hideData =
buildVisibilityActionData(targetSource, 1);
if (!detail::addElseAction(_macro.get(), "scene_visibility",
hideData)) {
_macro.reset();
QMessageBox::warning(
this,
obs_module_text(
"FirstRunWizard.audio.review.errorTitle"),
obs_module_text(
"FirstRunWizard.audio.review.errorBody"));
return true;
}
blog(LOG_INFO, "FirstRunWizard: created audio macro '%s'",
name.c_str());
return true;
}
} // namespace wiz
} // namespace advss

View File

@@ -0,0 +1,83 @@
#pragma once
#include "duration-control.hpp"
#include "duration.hpp"
#include "first-run-wizard.hpp"
#include "volume-control.hpp"
#include <QComboBox>
#include <QDoubleSpinBox>
#include <QLabel>
#include <QVBoxLayout>
#include <memory>
namespace advss {
namespace wiz {
// ---------------------------------------------------------------------------
// AudioSourcePage
// Registers wizard field "audioSourceName" (QString).
// Volume threshold and duration are read back via getters.
// ---------------------------------------------------------------------------
class AudioSourcePage : public QWizardPage {
Q_OBJECT
public:
explicit AudioSourcePage(QWidget *parent = nullptr);
void initializePage() override;
bool isComplete() const override;
int nextId() const override { return PAGE_AUDIO_TARGET; }
double GetThresholdDb() const { return _thresholdSpinbox->value(); }
Duration GetDuration() const;
private slots:
void UpdateVolmeter();
void SyncSpinboxFromSlider();
void SyncSliderFromSpinbox();
private:
QComboBox *_sourceCombo;
QDoubleSpinBox *_thresholdSpinbox;
DurationSelection *_durationSelection;
QVBoxLayout *_volmeterLayout;
VolControl *_volControl = nullptr;
};
// ---------------------------------------------------------------------------
// AudioTargetPage
// Registers wizard field "audioTargetSource" (QString).
// ---------------------------------------------------------------------------
class AudioTargetPage : public QWizardPage {
Q_OBJECT
public:
explicit AudioTargetPage(QWidget *parent = nullptr);
void initializePage() override;
bool isComplete() const override;
int nextId() const override { return PAGE_AUDIO_REVIEW; }
private:
QComboBox *_sourceCombo;
};
// ---------------------------------------------------------------------------
// AudioReviewPage
// Displays a summary and builds the macro from wizard fields on Finish.
// ---------------------------------------------------------------------------
class AudioReviewPage : public QWizardPage {
Q_OBJECT
public:
explicit AudioReviewPage(QWidget *parent,
std::shared_ptr<advss::Macro> &macro);
void initializePage() override;
bool validatePage() override;
int nextId() const override { return PAGE_DONE; }
private:
QLabel *_summary;
std::shared_ptr<advss::Macro> &_macro;
};
} // namespace wiz
} // namespace advss

View File

@@ -0,0 +1,85 @@
#pragma once
#include "log-helper.hpp"
#include "macro.hpp"
#include "macro-action-factory.hpp"
#include "macro-condition-factory.hpp"
#include <obs-data.h>
#include <QFrame>
#include <QLabel>
#include <string>
namespace advss::wiz::detail {
static bool addCondition(advss::Macro *macro, const std::string &id,
obs_data_t *data)
{
auto cond = MacroConditionFactory::Create(id, macro);
if (!cond) {
blog(LOG_WARNING,
"FirstRunWizard: condition factory returned null for '%s'",
id.c_str());
return false;
}
if (!cond->Load(data)) {
blog(LOG_WARNING,
"FirstRunWizard: condition Load() failed for '%s'",
id.c_str());
return false;
}
macro->Conditions().emplace_back(cond);
return true;
}
static bool addAction(advss::Macro *macro, const std::string &id,
obs_data_t *data)
{
auto action = MacroActionFactory::Create(id, macro);
if (!action) {
blog(LOG_WARNING,
"FirstRunWizard: action factory returned null for '%s'",
id.c_str());
return false;
}
if (!action->Load(data)) {
blog(LOG_WARNING,
"FirstRunWizard: action Load() failed for '%s'",
id.c_str());
return false;
}
macro->Actions().emplace_back(action);
return true;
}
static bool addElseAction(advss::Macro *macro, const std::string &id,
obs_data_t *data)
{
auto action = MacroActionFactory::Create(id, macro);
if (!action) {
blog(LOG_WARNING,
"FirstRunWizard: else-action factory returned null for '%s'",
id.c_str());
return false;
}
if (!action->Load(data)) {
blog(LOG_WARNING,
"FirstRunWizard: else-action Load() failed for '%s'",
id.c_str());
return false;
}
macro->ElseActions().emplace_back(action);
return true;
}
static void setupSummaryLabel(QLabel *label)
{
label->setWordWrap(true);
label->setTextFormat(Qt::RichText);
label->setFrameShape(QFrame::StyledPanel);
label->setContentsMargins(12, 12, 12, 12);
}
} // namespace advss::wiz::detail

View File

@@ -0,0 +1,499 @@
#include "first-run-wizard-sequence.hpp"
#include "first-run-wizard-helpers.hpp"
#include "layout-helpers.hpp"
#include "log-helper.hpp"
#include "macro-settings.hpp"
#include "selection-helpers.hpp"
#include <obs-data.h>
#include <QFrame>
#include <QHBoxLayout>
#include <QMessageBox>
#include <QScrollArea>
#include <QVBoxLayout>
namespace advss {
namespace wiz {
// Builds the obs_data blob for a "current scene == sceneName for at least
// triggerDuration" condition, matching MacroConditionScene::Save().
//
// {
// "segmentSettings": { "enabled": true, "version": 2 },
// "id": "scene",
// "logic": 0,
// "durationModifier": {
// "time_constraint": 1, // AT_LEAST
// "seconds": <Duration::Save output>
// },
// "sceneSelection": { "type": 0, "name": "<scene>", "canvasSelection": "Main" },
// "type": 10, // CURRENT_SCENE
// "version": 1
// }
static OBSDataAutoRelease
buildSceneConditionData(const QString &scene, const Duration &triggerDuration)
{
OBSDataAutoRelease seg = obs_data_create();
obs_data_set_bool(seg, "enabled", true);
obs_data_set_int(seg, "version", 2);
OBSDataAutoRelease durMod = obs_data_create();
obs_data_set_int(durMod, "time_constraint", 1);
triggerDuration.Save(durMod, "seconds");
OBSDataAutoRelease sceneSel = obs_data_create();
obs_data_set_int(sceneSel, "type", 0);
obs_data_set_string(sceneSel, "name", scene.toUtf8().constData());
obs_data_set_string(sceneSel, "canvasSelection", "Main");
OBSDataAutoRelease data = obs_data_create();
obs_data_set_obj(data, "segmentSettings", seg);
obs_data_set_string(data, "id", "scene");
obs_data_set_int(data, "logic", 0);
obs_data_set_obj(data, "durationModifier", durMod);
obs_data_set_obj(data, "sceneSelection", sceneSel);
obs_data_set_int(data, "type", 10);
obs_data_set_int(data, "version", 1);
return data;
}
// Builds the obs_data blob for a scene-switch action,
// matching MacroActionSwitchScene::Save().
//
// {
// "segmentSettings": { "enabled": true, "version": 2 },
// "id": "scene_switch",
// "action": 0,
// "sceneSelection": { "type": 0, "name": "<scene>", "canvasSelection": "Main" },
// "transitionType": 1, // scene's default transition
// "blockUntilTransitionDone": true,
// "sceneType": 0
// }
static OBSDataAutoRelease buildSceneSwitchData(const QString &scene)
{
OBSDataAutoRelease seg = obs_data_create();
obs_data_set_bool(seg, "enabled", true);
obs_data_set_int(seg, "version", 2);
OBSDataAutoRelease sceneSel = obs_data_create();
obs_data_set_int(sceneSel, "type", 0);
obs_data_set_string(sceneSel, "name", scene.toUtf8().constData());
obs_data_set_string(sceneSel, "canvasSelection", "Main");
OBSDataAutoRelease data = obs_data_create();
obs_data_set_obj(data, "segmentSettings", seg);
obs_data_set_string(data, "id", "scene_switch");
obs_data_set_int(data, "action", 0);
obs_data_set_obj(data, "sceneSelection", sceneSel);
obs_data_set_int(data, "transitionType", 1);
obs_data_set_bool(data, "blockUntilTransitionDone", true);
obs_data_set_int(data, "sceneType", 0);
return data;
}
// Builds the obs_data blob for a wait action, matching MacroActionWait::Save().
//
// {
// "segmentSettings": { "enabled": true, "version": 2 },
// "id": "wait",
// "duration": <Duration::Save output>,
// "waitType": 0,
// "version": 1
// }
static OBSDataAutoRelease buildWaitData(const Duration &duration)
{
OBSDataAutoRelease seg = obs_data_create();
obs_data_set_bool(seg, "enabled", true);
obs_data_set_int(seg, "version", 2);
OBSDataAutoRelease data = obs_data_create();
obs_data_set_obj(data, "segmentSettings", seg);
obs_data_set_string(data, "id", "wait");
duration.Save(data, "duration");
obs_data_set_int(data, "waitType", 0);
obs_data_set_int(data, "version", 1);
return data;
}
// ===========================================================================
// SequenceTriggerPage
// ===========================================================================
SequenceTriggerPage::SequenceTriggerPage(QWidget *parent)
: QWizardPage(parent),
_sceneCombo(new QComboBox(this)),
_delaySelection(new DurationSelection(this, true, 0.0))
{
setTitle(obs_module_text("FirstRunWizard.seqTrigger.title"));
setSubTitle(obs_module_text("FirstRunWizard.seqTrigger.subtitle"));
_sceneCombo->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
_delaySelection->SetDuration(Duration(5.0));
registerField("seqTriggerScene*", _sceneCombo, "currentText",
SIGNAL(currentTextChanged(QString)));
connect(_sceneCombo, &QComboBox::currentTextChanged, this,
&QWizardPage::completeChanged);
auto *sceneRow = new QHBoxLayout;
PlaceWidgets(obs_module_text("FirstRunWizard.seqTrigger.scene"),
sceneRow, {{"{{scene}}", _sceneCombo}}, false);
auto *delayRow = new QHBoxLayout;
PlaceWidgets(obs_module_text("FirstRunWizard.seqTrigger.delay"),
delayRow, {{"{{duration}}", _delaySelection}}, false);
auto *layout = new QVBoxLayout(this);
layout->addLayout(sceneRow);
layout->addLayout(delayRow);
layout->addStretch();
}
void SequenceTriggerPage::initializePage()
{
_sceneCombo->clear();
for (const QString &name : GetSceneNames()) {
_sceneCombo->addItem(name);
}
}
bool SequenceTriggerPage::isComplete() const
{
return _sceneCombo->count() > 0 &&
!_sceneCombo->currentText().isEmpty();
}
Duration SequenceTriggerPage::GetTriggerDuration() const
{
return _delaySelection->GetDuration();
}
// ===========================================================================
// SequenceScenesPage
// ===========================================================================
SequenceScenesPage::SequenceScenesPage(QWidget *parent)
: QWizardPage(parent),
_triggerInfoLabel(new QLabel(this)),
_stepsContainer(new QWidget(this)),
_stepsLayout(new QVBoxLayout(_stepsContainer))
{
setTitle(obs_module_text("FirstRunWizard.seqScenes.title"));
setSubTitle(obs_module_text("FirstRunWizard.seqScenes.subtitle"));
_triggerInfoLabel->setWordWrap(true);
_triggerInfoLabel->setFrameShape(QFrame::StyledPanel);
_triggerInfoLabel->setContentsMargins(8, 4, 8, 4);
_stepsLayout->setContentsMargins(0, 0, 0, 0);
_stepsLayout->setSpacing(4);
auto *scrollArea = new QScrollArea(this);
scrollArea->setWidget(_stepsContainer);
scrollArea->setWidgetResizable(true);
scrollArea->setFrameShape(QFrame::NoFrame);
auto *addBtn = new QPushButton(
obs_module_text("FirstRunWizard.seqScenes.addScene"), this);
connect(addBtn, &QPushButton::clicked, this,
&SequenceScenesPage::onAddStepClicked);
auto *layout = new QVBoxLayout(this);
layout->addWidget(_triggerInfoLabel);
layout->addWidget(scrollArea, 1);
layout->addWidget(addBtn, 0, Qt::AlignLeft);
}
void SequenceScenesPage::initializePage()
{
const QString triggerScene = field("seqTriggerScene").toString();
auto *triggerPage = qobject_cast<SequenceTriggerPage *>(
wizard()->page(PAGE_SEQ_TRIGGER));
const Duration triggerDuration =
triggerPage ? triggerPage->GetTriggerDuration() : Duration(5.0);
_triggerInfoLabel->setText(
QString(obs_module_text("FirstRunWizard.seqScenes.triggerInfo"))
.arg(triggerScene)
.arg(QString::fromStdString(
triggerDuration.ToString())));
if (_initialized) {
return;
}
_initialized = true;
// Pre-select the scenes after the trigger scene so the user doesn't
// have to start by deselecting it manually.
const QStringList scenes = GetSceneNames();
const int count = static_cast<int>(scenes.size());
const int triggerIdx = scenes.indexOf(triggerScene);
const int firstIdx = count > 0 ? (triggerIdx + 1) % count : 0;
const int secondIdx = count > 0 ? (triggerIdx + 2) % count : 0;
AddStep(firstIdx);
AddStep(secondIdx);
}
bool SequenceScenesPage::isComplete() const
{
return _rows.size() >= 2;
}
QVector<QPair<QString, Duration>> SequenceScenesPage::GetSteps() const
{
QVector<QPair<QString, Duration>> steps;
steps.reserve(_rows.size());
for (int i = 0; i < _rows.size(); ++i) {
const Duration delay = (i < _rows.size() - 1)
? _rows[i].delay->GetDuration()
: Duration();
steps.append({_rows[i].scene->currentText(), delay});
}
return steps;
}
void SequenceScenesPage::onAddStepClicked()
{
const QStringList scenes = GetSceneNames();
const int count = static_cast<int>(scenes.size());
int nextIdx = 0;
if (!_rows.isEmpty() && count > 0) {
const int lastIdx = _rows.last().scene->currentIndex();
nextIdx = (lastIdx + 1) % count;
}
AddStep(nextIdx);
emit completeChanged();
}
void SequenceScenesPage::AddStep(int defaultSceneIndex)
{
auto *row = new QWidget(_stepsContainer);
auto *rowLayout = new QHBoxLayout(row);
rowLayout->setContentsMargins(0, 0, 0, 0);
auto *sceneCombo = new QComboBox(row);
sceneCombo->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
const QStringList scenes = GetSceneNames();
for (const QString &name : scenes) {
sceneCombo->addItem(name);
}
if (defaultSceneIndex >= 0 && defaultSceneIndex < scenes.size()) {
sceneCombo->setCurrentIndex(defaultSceneIndex);
}
PlaceWidgets(obs_module_text("FirstRunWizard.seqScenes.switchTo"),
rowLayout, {{"{{scene}}", sceneCombo}}, false);
auto *delayWidget = new QWidget(row);
auto *delayLayout = new QHBoxLayout(delayWidget);
delayLayout->setContentsMargins(0, 0, 0, 0);
auto *durationSel = new DurationSelection(delayWidget, true, 0.1);
durationSel->SetDuration(Duration(5.0));
PlaceWidgets(obs_module_text("FirstRunWizard.seqScenes.thenWait"),
delayLayout, {{"{{duration}}", durationSel}}, false);
rowLayout->addWidget(delayWidget);
auto *removeBtn = new QPushButton(row);
removeBtn->setProperty("themeID",
QVariant(QString::fromUtf8("removeIconSmall")));
removeBtn->setProperty("class",
QVariant(QString::fromUtf8("icon-trash")));
removeBtn->setToolTip(
obs_module_text("FirstRunWizard.seqScenes.removeTooltip"));
removeBtn->setMaximumSize(22, 22);
rowLayout->addWidget(removeBtn);
_rows.append({row, sceneCombo, delayWidget, durationSel, removeBtn});
_stepsLayout->addWidget(row);
UpdateDelayVisibility();
UpdateRemoveButtons();
connect(removeBtn, &QPushButton::clicked, this,
[this, row]() { RemoveStep(row); });
}
void SequenceScenesPage::RemoveStep(QWidget *rowWidget)
{
int idx = -1;
for (int i = 0; i < _rows.size(); ++i) {
if (_rows[i].row == rowWidget) {
idx = i;
break;
}
}
if (idx < 0) {
return;
}
_stepsLayout->removeWidget(_rows[idx].row);
_rows[idx].row->deleteLater();
_rows.removeAt(idx);
UpdateDelayVisibility();
UpdateRemoveButtons();
emit completeChanged();
}
void SequenceScenesPage::UpdateDelayVisibility()
{
for (int i = 0; i < _rows.size(); ++i) {
_rows[i].delayWidget->setVisible(i < _rows.size() - 1);
}
}
void SequenceScenesPage::UpdateRemoveButtons()
{
const bool canRemove = _rows.size() > 1;
for (auto &step : _rows) {
step.remove->setEnabled(canRemove);
}
}
// ===========================================================================
// SequenceReviewPage
// ===========================================================================
SequenceReviewPage::SequenceReviewPage(QWidget *parent,
std::shared_ptr<Macro> &macro)
: QWizardPage(parent),
_summary(new QLabel(this)),
_macro(macro)
{
setTitle(obs_module_text("FirstRunWizard.seqReview.title"));
setSubTitle(obs_module_text("FirstRunWizard.seqReview.subtitle"));
detail::setupSummaryLabel(_summary);
auto *layout = new QVBoxLayout(this);
layout->addWidget(_summary);
layout->addStretch();
}
void SequenceReviewPage::initializePage()
{
const QString triggerScene = field("seqTriggerScene").toString();
auto *triggerPage = qobject_cast<SequenceTriggerPage *>(
wizard()->page(PAGE_SEQ_TRIGGER));
const Duration triggerDuration =
triggerPage ? triggerPage->GetTriggerDuration() : Duration(5.0);
auto *seqPage = qobject_cast<SequenceScenesPage *>(
wizard()->page(PAGE_SEQ_SCENES));
const auto steps = seqPage ? seqPage->GetSteps()
: QVector<QPair<QString, Duration>>{};
QString html =
QString("<p>%1</p>")
.arg(QString(obs_module_text(
"FirstRunWizard.seqReview.trigger"))
.arg(triggerScene.toHtmlEscaped())
.arg(QString::fromStdString(
triggerDuration.ToString())));
html += "<ol>";
for (int i = 0; i < steps.size(); ++i) {
const QString scene = steps[i].first.toHtmlEscaped();
if (i == 0) {
html += "<li>" + scene + "</li>";
} else {
const QString waitStr = QString::fromStdString(
steps[i - 1].second.ToString());
html += "<li>" +
QString(obs_module_text(
"FirstRunWizard.seqReview.step"))
.arg(waitStr)
.arg(scene) +
"</li>";
}
}
html += "</ol>";
_summary->setText(html);
}
bool SequenceReviewPage::validatePage()
{
const QString triggerScene = field("seqTriggerScene").toString();
const std::string name = ("Sequence: " + triggerScene).toStdString();
auto *triggerPage = qobject_cast<SequenceTriggerPage *>(
wizard()->page(PAGE_SEQ_TRIGGER));
const Duration triggerDuration =
triggerPage ? triggerPage->GetTriggerDuration() : Duration(5.0);
auto *seqPage = qobject_cast<SequenceScenesPage *>(
wizard()->page(PAGE_SEQ_SCENES));
if (!seqPage) {
return true;
}
const auto steps = seqPage->GetSteps();
_macro = std::make_shared<Macro>(name, GetGlobalMacroSettings());
if (!_macro) {
blog(LOG_WARNING,
"FirstRunWizard: sequence macro allocation failed");
return true;
}
_macro->SetRunInParallel(true);
// --- Condition ---
OBSDataAutoRelease condData =
buildSceneConditionData(triggerScene, triggerDuration);
if (!detail::addCondition(_macro.get(), "scene", condData)) {
_macro.reset();
QMessageBox::warning(
this,
obs_module_text("FirstRunWizard.seqReview.errorTitle"),
obs_module_text("FirstRunWizard.seqReview.errorBody"));
return true;
}
// --- Actions: scene_switch interleaved with wait ---
for (int i = 0; i < steps.size(); ++i) {
OBSDataAutoRelease switchData =
buildSceneSwitchData(steps[i].first);
if (!detail::addAction(_macro.get(), "scene_switch",
switchData)) {
_macro.reset();
QMessageBox::warning(
this,
obs_module_text(
"FirstRunWizard.seqReview.errorTitle"),
obs_module_text(
"FirstRunWizard.seqReview.errorBody"));
return true;
}
const bool isLastStep = (i == steps.size() - 1);
if (!isLastStep) {
OBSDataAutoRelease waitData =
buildWaitData(steps[i].second);
if (!detail::addAction(_macro.get(), "wait",
waitData)) {
_macro.reset();
QMessageBox::warning(
this,
obs_module_text(
"FirstRunWizard.seqReview.errorTitle"),
obs_module_text(
"FirstRunWizard.seqReview.errorBody"));
return true;
}
}
}
blog(LOG_INFO, "FirstRunWizard: created sequence macro '%s'",
name.c_str());
return true;
}
} // namespace wiz
} // namespace advss

View File

@@ -0,0 +1,101 @@
#pragma once
#include "duration-control.hpp"
#include "duration.hpp"
#include "first-run-wizard.hpp"
#include <QComboBox>
#include <QLabel>
#include <QPushButton>
#include <QVector>
#include <QWidget>
#include <memory>
class QVBoxLayout;
namespace advss {
namespace wiz {
// ---------------------------------------------------------------------------
// SequenceTriggerPage
// Registers wizard field "seqTriggerScene" (QString).
// Trigger duration is read back via GetTriggerDuration().
// ---------------------------------------------------------------------------
class SequenceTriggerPage : public QWizardPage {
Q_OBJECT
public:
explicit SequenceTriggerPage(QWidget *parent = nullptr);
void initializePage() override;
bool isComplete() const override;
int nextId() const override { return PAGE_SEQ_SCENES; }
Duration GetTriggerDuration() const;
private:
QComboBox *_sceneCombo;
DurationSelection *_delaySelection;
};
// ---------------------------------------------------------------------------
// SequenceScenesPage
// Lets the user build an ordered list of scenes with delays between them.
// The delay after the last scene is ignored when building the macro.
// ---------------------------------------------------------------------------
struct SequenceStep {
QWidget *row;
QComboBox *scene;
QWidget *delayWidget; // hidden for the last row
DurationSelection *delay;
QPushButton *remove;
};
class SequenceScenesPage : public QWizardPage {
Q_OBJECT
public:
explicit SequenceScenesPage(QWidget *parent = nullptr);
void initializePage() override;
bool isComplete() const override;
int nextId() const override { return PAGE_SEQ_REVIEW; }
// Returns (sceneName, delayAfter) pairs. The last entry's delayAfter
// is always a default-constructed Duration and must be ignored by the caller.
QVector<QPair<QString, Duration>> GetSteps() const;
private slots:
void onAddStepClicked();
private:
void AddStep(int defaultSceneIndex = 0);
void RemoveStep(QWidget *rowWidget);
void UpdateDelayVisibility();
void UpdateRemoveButtons();
QLabel *_triggerInfoLabel;
QWidget *_stepsContainer;
QVBoxLayout *_stepsLayout;
QVector<SequenceStep> _rows;
bool _initialized = false;
};
// ---------------------------------------------------------------------------
// SequenceReviewPage
// Displays a summary and builds the macro from wizard fields on Finish.
// ---------------------------------------------------------------------------
class SequenceReviewPage : public QWizardPage {
Q_OBJECT
public:
explicit SequenceReviewPage(QWidget *parent,
std::shared_ptr<advss::Macro> &macro);
void initializePage() override;
bool validatePage() override;
int nextId() const override { return PAGE_DONE; }
private:
QLabel *_summary;
std::shared_ptr<advss::Macro> &_macro;
};
} // namespace wiz
} // namespace advss

View File

@@ -0,0 +1,308 @@
#include "first-run-wizard-window.hpp"
#include "first-run-wizard-helpers.hpp"
#include "macro-settings.hpp"
#include "obs-module-helper.hpp"
#include "platform-funcs.hpp"
#include "selection-helpers.hpp"
#include <obs.hpp>
#include <QHBoxLayout>
#include <QMessageBox>
#include <QRegularExpression>
#include <QVBoxLayout>
namespace advss {
namespace wiz {
static constexpr char kConditionIdWindow[] = "window";
static constexpr char kActionIdSceneSwitch[] = "scene_switch";
static QString detectFocusedWindow()
{
return QString::fromStdString(GetCurrentWindowTitle());
}
static QString escapeForRegex(const QString &input)
{
return QRegularExpression::escape(input);
}
// ===========================================================================
// WindowSceneSelectionPage
// ===========================================================================
WindowSceneSelectionPage::WindowSceneSelectionPage(QWidget *parent)
: QWizardPage(parent)
{
setTitle(obs_module_text("FirstRunWizard.scene.title"));
setSubTitle(obs_module_text("FirstRunWizard.scene.subtitle"));
auto label =
new QLabel(obs_module_text("FirstRunWizard.scene.label"), this);
_sceneCombo = new QComboBox(this);
_sceneCombo->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
// registerField with * suffix means the field is mandatory for Next
registerField("targetScene*", _sceneCombo, "currentText",
SIGNAL(currentTextChanged(QString)));
connect(_sceneCombo, &QComboBox::currentTextChanged, this,
&QWizardPage::completeChanged);
auto row = new QHBoxLayout;
row->addWidget(label);
row->addWidget(_sceneCombo, 1);
auto layout = new QVBoxLayout(this);
layout->addLayout(row);
layout->addStretch();
}
void WindowSceneSelectionPage::initializePage()
{
_sceneCombo->clear();
for (const QString &name : GetSceneNames()) {
_sceneCombo->addItem(name);
}
}
bool WindowSceneSelectionPage::isComplete() const
{
return _sceneCombo->count() > 0 &&
!_sceneCombo->currentText().isEmpty();
}
// ===========================================================================
// WindowConditionPage
// ===========================================================================
WindowConditionPage::WindowConditionPage(QWidget *parent)
: QWizardPage(parent),
_detectTimer(new QTimer(this))
{
setTitle(obs_module_text("FirstRunWizard.window.title"));
setSubTitle(obs_module_text("FirstRunWizard.window.subtitle"));
auto label = new QLabel(obs_module_text("FirstRunWizard.window.label"),
this);
_windowEdit = new QLineEdit(this);
_windowEdit->setPlaceholderText(
obs_module_text("FirstRunWizard.window.placeholder"));
_autoDetect = new QPushButton(
obs_module_text("FirstRunWizard.window.autoDetect"), this);
_autoDetect->setToolTip(
obs_module_text("FirstRunWizard.window.autoDetectTooltip"));
registerField("windowTitle*", _windowEdit);
connect(_windowEdit, &QLineEdit::textChanged, this,
&QWizardPage::completeChanged);
connect(_autoDetect, &QPushButton::clicked, this,
&WindowConditionPage::onAutoDetectClicked);
connect(_detectTimer, &QTimer::timeout, this,
&WindowConditionPage::onCountdownTick);
auto row = new QHBoxLayout;
row->addWidget(label);
row->addWidget(_windowEdit, 1);
auto hint =
new QLabel(obs_module_text("FirstRunWizard.window.hint"), this);
hint->setTextFormat(Qt::RichText);
hint->setWordWrap(true);
auto layout = new QVBoxLayout(this);
layout->addLayout(row);
layout->addWidget(_autoDetect, 0, Qt::AlignLeft);
layout->addWidget(hint);
layout->addStretch();
}
void WindowConditionPage::initializePage()
{
if (_windowEdit->text().isEmpty()) {
QString detected = detectFocusedWindow();
if (!detected.isEmpty()) {
_windowEdit->setText(detected);
}
}
}
bool WindowConditionPage::isComplete() const
{
return !_windowEdit->text().trimmed().isEmpty();
}
void WindowConditionPage::onAutoDetectClicked()
{
_countdown = 3;
_autoDetect->setEnabled(false);
_autoDetect->setText(
QString(obs_module_text(
"FirstRunWizard.window.autoDetectCountdown"))
.arg(_countdown));
_detectTimer->start(1000);
}
void WindowConditionPage::onCountdownTick()
{
--_countdown;
if (_countdown > 0) {
_autoDetect->setText(
QString(obs_module_text(
"FirstRunWizard.window.autoDetectCountdown"))
.arg(_countdown));
return;
}
_detectTimer->stop();
QString title = detectFocusedWindow();
if (!title.isEmpty()) {
_windowEdit->setText(title);
}
_autoDetect->setEnabled(true);
_autoDetect->setText(
obs_module_text("FirstRunWizard.window.autoDetect"));
}
// ===========================================================================
// WindowReviewPage
// ===========================================================================
WindowReviewPage::WindowReviewPage(QWidget *parent,
std::shared_ptr<Macro> &macro)
: QWizardPage(parent),
_macro(macro)
{
setTitle(obs_module_text("FirstRunWizard.review.title"));
setSubTitle(obs_module_text("FirstRunWizard.review.subtitle"));
_summary = new QLabel(this);
detail::setupSummaryLabel(_summary);
auto layout = new QVBoxLayout(this);
layout->addWidget(_summary);
layout->addStretch();
}
void WindowReviewPage::initializePage()
{
const QString scene = field("targetScene").toString();
const QString window = field("windowTitle").toString();
_summary->setText(
QString(obs_module_text("FirstRunWizard.review.summary"))
.arg(scene.toHtmlEscaped(), window.toHtmlEscaped()));
}
bool WindowReviewPage::validatePage()
{
const QString scene = field("targetScene").toString();
const QString window = escapeForRegex(field("windowTitle").toString());
const std::string name = ("Window -> " + scene).toStdString();
// Build condition data blob
// ---------------------------------------------------------------
// Condition blob — mirrors MacroConditionWindow::Save() output:
//
// {
// "segmentSettings": { "enabled": true, "version": 1 },
// "id": "window",
// "checkTitle": true,
// "window": "<user input>",
// "windowRegexConfig": {
// "enable": true, // use regex-style partial matching
// "partial": true, // match anywhere in the title
// "options": 3 // case-insensitive (QRegularExpression flags)
// },
// "focus": true, // only trigger when window is focused
// "version": 1
// }
// ---------------------------------------------------------------
OBSDataAutoRelease condSegment = obs_data_create();
obs_data_set_bool(condSegment, "enabled", true);
obs_data_set_int(condSegment, "version", 1);
OBSDataAutoRelease condRegex = obs_data_create();
obs_data_set_bool(condRegex, "enable", true);
obs_data_set_bool(condRegex, "partial", true);
obs_data_set_int(condRegex, "options", 3); // CaseInsensitiveOption
OBSDataAutoRelease condData = obs_data_create();
obs_data_set_obj(condData, "segmentSettings", condSegment);
obs_data_set_string(condData, "id", "window");
obs_data_set_bool(condData, "checkTitle", true);
obs_data_set_string(condData, "window", window.toUtf8().constData());
obs_data_set_obj(condData, "windowRegexConfig", condRegex);
obs_data_set_bool(condData, "focus", true);
obs_data_set_int(condData, "version", 1);
// Build action data blob
// ---------------------------------------------------------------
// Action blob — mirrors MacroActionSwitchScene::Save() output:
//
// {
// "segmentSettings": { "enabled": true, "version": 1 },
// "id": "scene_switch",
// "action": 0, // 0 = switch scene
// "sceneSelection": {
// "type": 0, // 0 = scene by name
// "name": "<scene>",
// "canvasSelection": "Main"
// },
// "transitionType": 1, // 1 = use scene's default transition
// "blockUntilTransitionDone": false,
// "sceneType": 0
// }
// ---------------------------------------------------------------
OBSDataAutoRelease actionSegment = obs_data_create();
obs_data_set_bool(actionSegment, "enabled", true);
obs_data_set_int(actionSegment, "version", 1);
OBSDataAutoRelease sceneSelection = obs_data_create();
obs_data_set_int(sceneSelection, "type", 0);
obs_data_set_string(sceneSelection, "name", scene.toUtf8().constData());
obs_data_set_string(sceneSelection, "canvasSelection", "Main");
OBSDataAutoRelease actionData = obs_data_create();
obs_data_set_obj(actionData, "segmentSettings", actionSegment);
obs_data_set_string(actionData, "id", "scene_switch");
obs_data_set_int(actionData, "action", 0);
obs_data_set_obj(actionData, "sceneSelection", sceneSelection);
obs_data_set_int(actionData, "transitionType", 1);
obs_data_set_bool(actionData, "blockUntilTransitionDone", false);
obs_data_set_int(actionData, "sceneType", 0);
_macro = std::make_shared<Macro>(name, GetGlobalMacroSettings());
if (!_macro) {
blog(LOG_WARNING,
"FirstRunWizard: window macro allocation failed");
return true;
}
if (!detail::addCondition(_macro.get(), kConditionIdWindow, condData) ||
!detail::addAction(_macro.get(), kActionIdSceneSwitch,
actionData)) {
QMessageBox::warning(
this,
obs_module_text("FirstRunWizard.review.errorTitle"),
QString(obs_module_text(
"FirstRunWizard.review.errorBody"))
.arg(window, scene));
_macro.reset();
// Still advance so the user is not stuck.
return true;
}
blog(LOG_INFO, "FirstRunWizard: created macro '%s'", name.c_str());
return true;
}
} // namespace wiz
} // namespace advss

View File

@@ -0,0 +1,76 @@
#pragma once
#include "first-run-wizard.hpp"
#include <QComboBox>
#include <QLabel>
#include <QLineEdit>
#include <QPushButton>
#include <QTimer>
#include <memory>
namespace advss {
namespace wiz {
// ---------------------------------------------------------------------------
// WindowSceneSelectionPage
// Registers wizard field "targetScene" (QString).
// ---------------------------------------------------------------------------
class WindowSceneSelectionPage : public QWizardPage {
Q_OBJECT
public:
explicit WindowSceneSelectionPage(QWidget *parent = nullptr);
void initializePage() override;
bool isComplete() const override;
int nextId() const override { return PAGE_WINDOW_CONDITION; }
private:
QComboBox *_sceneCombo;
};
// ---------------------------------------------------------------------------
// WindowConditionPage
// Registers wizard field "windowTitle" (QString).
// Auto-detect button samples the focused window after a countdown.
// ---------------------------------------------------------------------------
class WindowConditionPage : public QWizardPage {
Q_OBJECT
public:
explicit WindowConditionPage(QWidget *parent = nullptr);
void initializePage() override;
bool isComplete() const override;
int nextId() const override { return PAGE_WINDOW_REVIEW; }
private slots:
void onAutoDetectClicked();
void onCountdownTick();
private:
QLineEdit *_windowEdit;
QPushButton *_autoDetect;
QTimer *_detectTimer;
int _countdown = 3;
};
// ---------------------------------------------------------------------------
// WindowReviewPage
// Displays a summary and builds the macro from wizard fields on Finish.
// ---------------------------------------------------------------------------
class WindowReviewPage : public QWizardPage {
Q_OBJECT
public:
explicit WindowReviewPage(QWidget *parent,
std::shared_ptr<advss::Macro> &macro);
void initializePage() override;
bool validatePage() override;
int nextId() const override { return PAGE_DONE; }
private:
QLabel *_summary;
std::shared_ptr<advss::Macro> &_macro;
};
} // namespace wiz
} // namespace advss

View File

@@ -1,28 +1,17 @@
#include "first-run-wizard.hpp"
#include "first-run-wizard-audio.hpp"
#include "first-run-wizard-sequence.hpp"
#include "first-run-wizard-window.hpp"
#include "log-helper.hpp"
#include "macro.hpp"
#include "macro-action-factory.hpp"
#include "macro-condition-factory.hpp"
#include "macro-settings.hpp"
#include "platform-funcs.hpp"
#include "selection-helpers.hpp"
#include <obs-frontend-api.h>
#include <obs-data.h>
#include <util/config-file.h>
#include <QFrame>
#include <QHBoxLayout>
#include <QMessageBox>
#include <QRegularExpression>
#include <QVBoxLayout>
namespace advss {
static constexpr char kConditionIdWindow[] = "window";
static constexpr char kActionIdSceneSwitch[] = "scene_switch";
// ---------------------------------------------------------------------------
// OBS global config helpers
// ---------------------------------------------------------------------------
@@ -42,7 +31,7 @@ bool IsFirstRun()
return config_get_bool(cfg, kConfigSection, kFirstRunKey);
}
static void WriteFirstRun(bool value)
static void writeFirstRun(bool value)
{
#if LIBOBS_API_VER >= MAKE_SEMANTIC_VERSION(31, 0, 0)
config_t *cfg = obs_frontend_get_user_config();
@@ -53,10 +42,10 @@ static void WriteFirstRun(bool value)
config_save_safe(cfg, "tmp", nullptr);
}
static QString DetectFocusedWindow()
{
return QString::fromStdString(GetCurrentWindowTitle());
}
} // namespace advss
namespace advss {
namespace wiz {
// ===========================================================================
// WelcomePage
@@ -78,271 +67,39 @@ WelcomePage::WelcomePage(QWidget *parent) : QWizardPage(parent)
}
// ===========================================================================
// SceneSelectionPage
// TemplatePage
// ===========================================================================
SceneSelectionPage::SceneSelectionPage(QWidget *parent) : QWizardPage(parent)
{
setTitle(obs_module_text("FirstRunWizard.scene.title"));
setSubTitle(obs_module_text("FirstRunWizard.scene.subtitle"));
auto label =
new QLabel(obs_module_text("FirstRunWizard.scene.label"), this);
_sceneCombo = new QComboBox(this);
_sceneCombo->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
// registerField with * suffix means the field is mandatory for Next
registerField("targetScene*", _sceneCombo, "currentText",
SIGNAL(currentTextChanged(QString)));
connect(_sceneCombo, &QComboBox::currentTextChanged, this,
&QWizardPage::completeChanged);
auto row = new QHBoxLayout;
row->addWidget(label);
row->addWidget(_sceneCombo, 1);
auto layout = new QVBoxLayout(this);
layout->addLayout(row);
layout->addStretch();
}
void SceneSelectionPage::initializePage()
{
_sceneCombo->clear();
for (const QString &name : GetSceneNames())
_sceneCombo->addItem(name);
}
bool SceneSelectionPage::isComplete() const
{
return _sceneCombo->count() > 0 &&
!_sceneCombo->currentText().isEmpty();
}
// ===========================================================================
// WindowConditionPage
// ===========================================================================
WindowConditionPage::WindowConditionPage(QWidget *parent)
TemplatePage::TemplatePage(QWidget *parent)
: QWizardPage(parent),
_detectTimer(new QTimer(this))
_windowRadio(new QRadioButton(
obs_module_text("FirstRunWizard.template.window"), this)),
_sequenceRadio(new QRadioButton(
obs_module_text("FirstRunWizard.template.sequence"), this)),
_audioRadio(new QRadioButton(
obs_module_text("FirstRunWizard.template.audio"), this))
{
setTitle(obs_module_text("FirstRunWizard.window.title"));
setSubTitle(obs_module_text("FirstRunWizard.window.subtitle"));
setTitle(obs_module_text("FirstRunWizard.template.title"));
setSubTitle(obs_module_text("FirstRunWizard.template.subtitle"));
auto label = new QLabel(obs_module_text("FirstRunWizard.window.label"),
this);
_windowEdit = new QLineEdit(this);
_windowEdit->setPlaceholderText(
obs_module_text("FirstRunWizard.window.placeholder"));
_windowRadio->setChecked(true);
_autoDetect = new QPushButton(
obs_module_text("FirstRunWizard.window.autoDetect"), this);
_autoDetect->setToolTip(
obs_module_text("FirstRunWizard.window.autoDetectTooltip"));
registerField("windowTitle*", _windowEdit);
connect(_windowEdit, &QLineEdit::textChanged, this,
&QWizardPage::completeChanged);
connect(_autoDetect, &QPushButton::clicked, this,
&WindowConditionPage::onAutoDetectClicked);
connect(_detectTimer, &QTimer::timeout, this,
&WindowConditionPage::onCountdownTick);
auto row = new QHBoxLayout;
row->addWidget(label);
row->addWidget(_windowEdit, 1);
auto hint =
new QLabel(obs_module_text("FirstRunWizard.window.hint"), this);
hint->setTextFormat(Qt::RichText);
hint->setWordWrap(true);
auto layout = new QVBoxLayout(this);
layout->addLayout(row);
layout->addWidget(_autoDetect, 0, Qt::AlignLeft);
layout->addWidget(hint);
auto *layout = new QVBoxLayout(this);
layout->addWidget(_windowRadio);
layout->addWidget(_sequenceRadio);
layout->addWidget(_audioRadio);
layout->addStretch();
}
void WindowConditionPage::initializePage()
int TemplatePage::nextId() const
{
if (_windowEdit->text().isEmpty()) {
QString detected = DetectFocusedWindow();
if (!detected.isEmpty()) {
_windowEdit->setText(detected);
}
if (_sequenceRadio->isChecked()) {
return PAGE_SEQ_TRIGGER;
}
}
bool WindowConditionPage::isComplete() const
{
return !_windowEdit->text().trimmed().isEmpty();
}
void WindowConditionPage::onAutoDetectClicked()
{
_countdown = 3;
_autoDetect->setEnabled(false);
_autoDetect->setText(
QString(obs_module_text(
"FirstRunWizard.window.autoDetectCountdown"))
.arg(_countdown));
_detectTimer->start(1000);
}
void WindowConditionPage::onCountdownTick()
{
--_countdown;
if (_countdown > 0) {
_autoDetect->setText(
QString(obs_module_text(
"FirstRunWizard.window.autoDetectCountdown"))
.arg(_countdown));
return;
if (_audioRadio->isChecked()) {
return PAGE_AUDIO_SOURCE;
}
_detectTimer->stop();
QString title = DetectFocusedWindow();
if (!title.isEmpty()) {
_windowEdit->setText(title);
}
_autoDetect->setEnabled(true);
_autoDetect->setText(
obs_module_text("FirstRunWizard.window.autoDetect"));
}
// ===========================================================================
// ReviewPage
// ===========================================================================
ReviewPage::ReviewPage(QWidget *parent, std::shared_ptr<Macro> &macro)
: QWizardPage(parent),
_macro(macro)
{
setTitle(obs_module_text("FirstRunWizard.review.title"));
setSubTitle(obs_module_text("FirstRunWizard.review.subtitle"));
_summary = new QLabel(this);
_summary->setWordWrap(true);
_summary->setTextFormat(Qt::RichText);
_summary->setFrameShape(QFrame::StyledPanel);
_summary->setContentsMargins(12, 12, 12, 12);
auto layout = new QVBoxLayout(this);
layout->addWidget(_summary);
layout->addStretch();
}
void ReviewPage::initializePage()
{
const QString scene = field("targetScene").toString();
const QString window = field("windowTitle").toString();
_summary->setText(
QString(obs_module_text("FirstRunWizard.review.summary"))
.arg(scene.toHtmlEscaped(), window.toHtmlEscaped()));
}
static QString escapeForRegex(const QString &input)
{
return QRegularExpression::escape(input);
}
bool ReviewPage::validatePage()
{
const QString scene = field("targetScene").toString();
const QString window = escapeForRegex(field("windowTitle").toString());
const std::string name = ("Window -> " + scene).toStdString();
// Build condition data blob
// ---------------------------------------------------------------
// Condition blob — mirrors MacroConditionWindow::Save() output:
//
// {
// "segmentSettings": { "enabled": true, "version": 1 },
// "id": "window",
// "checkTitle": true,
// "window": "<user input>",
// "windowRegexConfig": {
// "enable": true, // use regex-style partial matching
// "partial": true, // match anywhere in the title
// "options": 3 // case-insensitive (QRegularExpression flags)
// },
// "focus": true, // only trigger when window is focused
// "version": 1
// }
// ---------------------------------------------------------------
OBSDataAutoRelease condSegment = obs_data_create();
obs_data_set_bool(condSegment, "enabled", true);
obs_data_set_int(condSegment, "version", 1);
OBSDataAutoRelease condRegex = obs_data_create();
obs_data_set_bool(condRegex, "enable", true);
obs_data_set_bool(condRegex, "partial", true);
obs_data_set_int(condRegex, "options", 3); // CaseInsensitiveOption
OBSDataAutoRelease condData = obs_data_create();
obs_data_set_obj(condData, "segmentSettings", condSegment);
obs_data_set_string(condData, "id", "window");
obs_data_set_bool(condData, "checkTitle", true);
obs_data_set_string(condData, "window", window.toUtf8().constData());
obs_data_set_obj(condData, "windowRegexConfig", condRegex);
obs_data_set_bool(condData, "focus", true);
obs_data_set_int(condData, "version", 1);
// Build action data blob
// ---------------------------------------------------------------
// Action blob — mirrors MacroActionSwitchScene::Save() output:
//
// {
// "segmentSettings": { "enabled": true, "version": 1 },
// "id": "scene_switch",
// "action": 0, // 0 = switch scene
// "sceneSelection": {
// "type": 0, // 0 = scene by name
// "name": "<scene>",
// "canvasSelection": "Main"
// },
// "transitionType": 1, // 1 = use scene's default transition
// "blockUntilTransitionDone": false,
// "sceneType": 0
// }
// ---------------------------------------------------------------
OBSDataAutoRelease actionSegment = obs_data_create();
obs_data_set_bool(actionSegment, "enabled", true);
obs_data_set_int(actionSegment, "version", 1);
OBSDataAutoRelease sceneSelection = obs_data_create();
obs_data_set_int(sceneSelection, "type", 0);
obs_data_set_string(sceneSelection, "name", scene.toUtf8().constData());
obs_data_set_string(sceneSelection, "canvasSelection", "Main");
OBSDataAutoRelease actionData = obs_data_create();
obs_data_set_obj(actionData, "segmentSettings", actionSegment);
obs_data_set_string(actionData, "id", "scene_switch");
obs_data_set_int(actionData, "action", 0);
obs_data_set_obj(actionData, "sceneSelection", sceneSelection);
obs_data_set_int(actionData, "transitionType", 1);
obs_data_set_bool(actionData, "blockUntilTransitionDone", false);
obs_data_set_int(actionData, "sceneType", 0);
if (!FirstRunWizard::CreateMacro(_macro, name, kConditionIdWindow,
condData, kActionIdSceneSwitch,
actionData)) {
QMessageBox::warning(
this,
obs_module_text("FirstRunWizard.review.errorTitle"),
QString(obs_module_text(
"FirstRunWizard.review.errorBody"))
.arg(window, scene));
_macro.reset();
// Still advance so the user is not stuck.
}
return true;
return PAGE_WINDOW_SCENE;
}
// ===========================================================================
@@ -373,12 +130,19 @@ FirstRunWizard::FirstRunWizard(QWidget *parent) : QWizard(parent)
{
setWindowTitle(obs_module_text("FirstRunWizard.windowTitle"));
setWizardStyle(QWizard::ModernStyle);
setMinimumSize(540, 420);
setMinimumSize(600, 420);
setPage(PAGE_WELCOME, new WelcomePage(this));
setPage(PAGE_SCENE, new SceneSelectionPage(this));
setPage(PAGE_WINDOW, new WindowConditionPage(this));
setPage(PAGE_REVIEW, new ReviewPage(this, _macro));
setPage(PAGE_TEMPLATE, new TemplatePage(this));
setPage(PAGE_WINDOW_SCENE, new WindowSceneSelectionPage(this));
setPage(PAGE_WINDOW_CONDITION, new WindowConditionPage(this));
setPage(PAGE_WINDOW_REVIEW, new WindowReviewPage(this, _macro));
setPage(PAGE_SEQ_TRIGGER, new SequenceTriggerPage(this));
setPage(PAGE_SEQ_SCENES, new SequenceScenesPage(this));
setPage(PAGE_SEQ_REVIEW, new SequenceReviewPage(this, _macro));
setPage(PAGE_AUDIO_SOURCE, new AudioSourcePage(this));
setPage(PAGE_AUDIO_TARGET, new AudioTargetPage(this));
setPage(PAGE_AUDIO_REVIEW, new AudioReviewPage(this, _macro));
setPage(PAGE_DONE, new DonePage(this));
setStartId(PAGE_WELCOME);
@@ -394,7 +158,7 @@ FirstRunWizard::FirstRunWizard(QWidget *parent) : QWizard(parent)
void FirstRunWizard::markFirstRunComplete()
{
WriteFirstRun(false);
writeFirstRun(false);
}
// static
@@ -410,58 +174,5 @@ std::shared_ptr<Macro> FirstRunWizard::ShowWizard(QWidget *parent,
return wizard->_macro;
}
// static
bool FirstRunWizard::CreateMacro(std::shared_ptr<Macro> &macro,
const std::string &macroName,
const std::string &conditionId,
obs_data_t *conditionData,
const std::string &actionId,
obs_data_t *actionData)
{
// 1. Create and register the Macro
macro = std::make_shared<Macro>(macroName, GetGlobalMacroSettings());
if (!macro) {
blog(LOG_WARNING, "FirstRunWizard: Macro allocation failed");
return false;
}
// 2. Instantiate condition via factory, then hydrate via Load()
auto condition =
MacroConditionFactory::Create(conditionId, macro.get());
if (!condition) {
blog(LOG_WARNING,
"FirstRunWizard: condition factory returned null "
"for id '%s' — is the base plugin loaded?",
conditionId.c_str());
return false;
}
if (!condition->Load(conditionData)) {
blog(LOG_WARNING,
"FirstRunWizard: condition Load() failed for id '%s'",
conditionId.c_str());
return false;
}
macro->Conditions().emplace_back(condition);
// 3. Instantiate action via factory, then hydrate via Load()
auto action = MacroActionFactory::Create(actionId, macro.get());
if (!action) {
blog(LOG_WARNING,
"FirstRunWizard: action factory returned null "
"for id '%s' — is the base plugin loaded?",
actionId.c_str());
return false;
}
if (!action->Load(actionData)) {
blog(LOG_WARNING,
"FirstRunWizard: action Load() failed for id '%s'",
actionId.c_str());
return false;
}
macro->Actions().emplace_back(action);
blog(LOG_INFO, "FirstRunWizard: created macro '%s'", macroName.c_str());
return true;
}
} // namespace wiz
} // namespace advss

View File

@@ -1,31 +1,34 @@
#pragma once
#include <obs-data.h>
#include <QComboBox>
#include <QLabel>
#include <QLineEdit>
#include <QPushButton>
#include <QTimer>
#include <QRadioButton>
#include <QWizard>
#include <QWizardPage>
#include <string>
#include <memory>
namespace advss {
bool IsFirstRun();
class Macro;
bool IsFirstRun();
namespace wiz {
// ---------------------------------------------------------------------------
// Page IDs
// ---------------------------------------------------------------------------
enum WizardPageId {
PAGE_WELCOME = 0,
PAGE_SCENE,
PAGE_WINDOW,
PAGE_REVIEW,
PAGE_TEMPLATE,
PAGE_WINDOW_SCENE,
PAGE_WINDOW_CONDITION,
PAGE_WINDOW_REVIEW,
PAGE_SEQ_TRIGGER,
PAGE_SEQ_SCENES,
PAGE_SEQ_REVIEW,
PAGE_AUDIO_SOURCE,
PAGE_AUDIO_TARGET,
PAGE_AUDIO_REVIEW,
PAGE_DONE,
};
@@ -36,64 +39,23 @@ class WelcomePage : public QWizardPage {
Q_OBJECT
public:
explicit WelcomePage(QWidget *parent = nullptr);
int nextId() const override { return PAGE_SCENE; }
int nextId() const override { return PAGE_TEMPLATE; }
};
// ---------------------------------------------------------------------------
// SceneSelectionPage
// Registers wizard field "targetScene" (QString).
// TemplatePage
// Lets the user choose which kind of automation to create.
// ---------------------------------------------------------------------------
class SceneSelectionPage : public QWizardPage {
class TemplatePage : public QWizardPage {
Q_OBJECT
public:
explicit SceneSelectionPage(QWidget *parent = nullptr);
void initializePage() override;
bool isComplete() const override;
int nextId() const override { return PAGE_WINDOW; }
explicit TemplatePage(QWidget *parent = nullptr);
int nextId() const override;
private:
QComboBox *_sceneCombo;
};
// ---------------------------------------------------------------------------
// WindowConditionPage
// Registers wizard field "windowTitle" (QString).
// Auto-detect button samples the focused window after a countdown.
// ---------------------------------------------------------------------------
class WindowConditionPage : public QWizardPage {
Q_OBJECT
public:
explicit WindowConditionPage(QWidget *parent = nullptr);
void initializePage() override;
bool isComplete() const override;
int nextId() const override { return PAGE_REVIEW; }
private slots:
void onAutoDetectClicked();
void onCountdownTick();
private:
QLineEdit *_windowEdit;
QPushButton *_autoDetect;
QTimer *_detectTimer;
int _countdown = 3;
};
// ---------------------------------------------------------------------------
// ReviewPage
// Displays a summary and calls FirstRunWizard::CreateMacro() on Finish.
// ---------------------------------------------------------------------------
class ReviewPage : public QWizardPage {
Q_OBJECT
public:
explicit ReviewPage(QWidget *parent, std::shared_ptr<Macro> &macro);
void initializePage() override;
bool validatePage() override;
int nextId() const override { return PAGE_DONE; }
private:
QLabel *_summary;
std::shared_ptr<Macro> &_macro;
QRadioButton *_windowRadio;
QRadioButton *_sequenceRadio;
QRadioButton *_audioRadio;
};
// ---------------------------------------------------------------------------
@@ -113,17 +75,14 @@ class FirstRunWizard : public QWizard {
Q_OBJECT
public:
explicit FirstRunWizard(QWidget *parent = nullptr);
static std::shared_ptr<Macro> ShowWizard(QWidget *parent,
bool *wasSkipped = nullptr);
static bool
CreateMacro(std::shared_ptr<Macro> &macro, const std::string &macroName,
const std::string &conditionId, obs_data_t *conditionData,
const std::string &actionId, obs_data_t *actionData);
static std::shared_ptr<advss::Macro>
ShowWizard(QWidget *parent, bool *wasSkipped = nullptr);
private:
void markFirstRunComplete();
std::shared_ptr<Macro> _macro;
std::shared_ptr<advss::Macro> _macro;
};
} // namespace wiz
} // namespace advss

View File

@@ -99,4 +99,23 @@ std::optional<std::string> AccessJsonArrayIndex(const std::string &jsonStr,
return {};
}
std::optional<std::string>
ExtractSingleJsonArrayElement(const std::string &jsonStr)
{
try {
nlohmann::json json = nlohmann::json::parse(jsonStr);
if (!json.is_array() || json.size() != 1) {
return {};
}
auto result = json.at(0);
if (result.is_string()) {
return result.get<std::string>();
}
return result.dump();
} catch (const nlohmann::json::exception &) {
return {};
}
return {};
}
} // namespace advss

View File

@@ -17,5 +17,7 @@ EXPORT std::optional<std::string> QueryJson(const std::string &json,
const std::string &query);
EXPORT std::optional<std::string> AccessJsonArrayIndex(const std::string &json,
const int index);
EXPORT std::optional<std::string>
ExtractSingleJsonArrayElement(const std::string &json);
} // namespace advss

View File

@@ -0,0 +1,106 @@
#include "variable-color-button.hpp"
#include "obs-module-helper.hpp"
#include "ui-helpers.hpp"
#include <QColorDialog>
#include <QHBoxLayout>
namespace advss {
VariableColorButton::VariableColorButton(QWidget *parent,
const QString &selectText)
: QWidget(parent),
_colorSwatch(new QLabel()),
_selectColor(new QPushButton(selectText)),
_variable(new VariableSelection(this)),
_toggleType(new QPushButton())
{
_toggleType->setCheckable(true);
_toggleType->setMaximumWidth(11);
SetButtonIcon(_toggleType, GetThemeTypeName() == "Light"
? ":/res/images/dots-vert.svg"
: "theme:Dark/dots-vert.svg");
QWidget::connect(_selectColor, SIGNAL(clicked()), this,
SLOT(SelectColorClicked()));
QWidget::connect(_toggleType, SIGNAL(toggled(bool)), this,
SLOT(ToggleTypeClicked(bool)));
QWidget::connect(_variable, SIGNAL(SelectionChanged(const QString &)),
this, SLOT(VariableChanged(const QString &)));
auto layout = new QHBoxLayout();
layout->setContentsMargins(0, 0, 0, 0);
layout->addWidget(_colorSwatch);
layout->addWidget(_selectColor);
layout->addWidget(_variable);
layout->addWidget(_toggleType);
setLayout(layout);
SetVisibility();
}
void VariableColorButton::SetValue(const ColorVariable &color)
{
_color = color;
const QSignalBlocker b1(_toggleType);
const QSignalBlocker b2(_variable);
_toggleType->setChecked(!color.IsFixedType());
SetupColorLabel(color.GetFixedValue());
_variable->SetVariable(color.GetVariable());
SetVisibility();
}
void VariableColorButton::SelectColorClicked()
{
const QColor color = QColorDialog::getColor(
_color.GetFixedValue(), this, _selectColor->text(),
QColorDialog::ColorDialogOption());
if (!color.isValid()) {
return;
}
SetupColorLabel(color);
_color._value = color;
emit ColorVariableChanged(_color);
}
void VariableColorButton::VariableChanged(const QString &name)
{
_color._variable = GetWeakVariableByQString(name);
emit ColorVariableChanged(_color);
}
void VariableColorButton::ToggleTypeClicked(bool useVariable)
{
_color._type = useVariable ? ColorVariable::Type::VARIABLE
: ColorVariable::Type::FIXED_VALUE;
SetVisibility();
emit ColorVariableChanged(_color);
}
void VariableColorButton::SetupColorLabel(const QColor &color)
{
_colorSwatch->setText(color.name());
_colorSwatch->setStyleSheet(
QString("background-color: %1;").arg(color.name()));
}
void VariableColorButton::SetVisibility()
{
if (_color.IsFixedType()) {
SetupColorLabel(_color.GetFixedValue());
_colorSwatch->show();
_selectColor->show();
_variable->hide();
_toggleType->setVisible(!GetVariables().empty());
} else {
_colorSwatch->hide();
_selectColor->hide();
_variable->show();
_variable->setToolTip(obs_module_text(
"AdvSceneSwitcher.condition.video.colorVariableTooltip"));
_toggleType->show();
}
adjustSize();
updateGeometry();
}
} // namespace advss

View File

@@ -0,0 +1,37 @@
#pragma once
#include "export-symbol-helper.hpp"
#include "variable-color.hpp"
#include <QLabel>
#include <QPushButton>
#include <QWidget>
namespace advss {
class ADVSS_EXPORT VariableColorButton : public QWidget {
Q_OBJECT
public:
VariableColorButton(QWidget *parent, const QString &selectText);
void SetValue(const ColorVariable &);
ColorVariable Value() const { return _color; }
public slots:
void SelectColorClicked();
void VariableChanged(const QString &);
void ToggleTypeClicked(bool useVariable);
signals:
void ColorVariableChanged(const ColorVariable &);
private:
void SetupColorLabel(const QColor &);
void SetVisibility();
ColorVariable _color;
QLabel *_colorSwatch;
QPushButton *_selectColor;
VariableSelection *_variable;
QPushButton *_toggleType;
};
} // namespace advss

View File

@@ -0,0 +1,53 @@
#include "variable-color.hpp"
#include <obs.hpp>
namespace advss {
ColorVariable::ColorVariable(const QColor &value) : _value(value) {}
void ColorVariable::Save(obs_data_t *obj, const char *name) const
{
OBSDataAutoRelease data = obs_data_create();
obs_data_set_int(data, "version", 1);
obs_data_set_int(data, "type", static_cast<int>(_type));
auto var = _variable.lock();
if (var) {
obs_data_set_string(data, "variable", var->Name().c_str());
}
obs_data_set_int(data, "red", _value.red());
obs_data_set_int(data, "green", _value.green());
obs_data_set_int(data, "blue", _value.blue());
obs_data_set_obj(obj, name, data);
}
void ColorVariable::Load(obs_data_t *obj, const char *name)
{
OBSDataAutoRelease data = obs_data_get_obj(obj, name);
_value.setRed(obs_data_get_int(data, "red"));
_value.setGreen(obs_data_get_int(data, "green"));
_value.setBlue(obs_data_get_int(data, "blue"));
if (!obs_data_has_user_value(data, "version")) {
// Old format: no variable support, just R/G/B
_type = Type::FIXED_VALUE;
return;
}
auto variableName = obs_data_get_string(data, "variable");
_variable = GetWeakVariableByName(variableName);
_type = static_cast<Type>(obs_data_get_int(data, "type"));
}
QColor ColorVariable::GetValue() const
{
if (_type == Type::FIXED_VALUE) {
return _value;
}
auto var = _variable.lock();
if (!var) {
return Qt::black;
}
const QColor color(QString::fromStdString(var->Value()));
return color.isValid() ? color : Qt::black;
}
} // namespace advss

View File

@@ -0,0 +1,35 @@
#pragma once
#include "variable.hpp"
#include <obs-data.h>
#include <QColor>
namespace advss {
class VariableColorButton;
class ADVSS_EXPORT ColorVariable {
public:
enum class Type { FIXED_VALUE, VARIABLE };
ColorVariable() = default;
ColorVariable(const QColor &value);
void Save(obs_data_t *obj, const char *name) const;
void Load(obs_data_t *obj, const char *name);
QColor GetValue() const;
QColor GetFixedValue() const { return _value; }
bool IsFixedType() const { return _type == Type::FIXED_VALUE; }
Type GetType() const { return _type; }
std::weak_ptr<Variable> GetVariable() const { return _variable; }
private:
Type _type = Type::FIXED_VALUE;
QColor _value = Qt::black;
std::weak_ptr<Variable> _variable;
friend class VariableColorButton;
};
} // namespace advss

View File

@@ -108,16 +108,20 @@ std::optional<int> Variable::IntValue() const
void Variable::SetValue(const std::string &value)
{
std::lock_guard<std::mutex> lock(_mutex);
_previousValue = _value;
_value = value;
{
std::lock_guard<std::mutex> lock(_mutex);
_previousValue = _value;
_value = value;
UpdateLastUsed();
if (_previousValue != _value) {
_lastChanged = std::chrono::high_resolution_clock::now();
++_valueChangeCount;
UpdateLastUsed();
if (_previousValue != _value) {
_lastChanged =
std::chrono::high_resolution_clock::now();
++_valueChangeCount;
}
setLastVariableChangeTime();
}
setLastVariableChangeTime();
_cv.notify_all();
}
void Variable::SetValue(double value)

View File

@@ -3,6 +3,7 @@
#include "item-selection-helpers.hpp"
#include "resizing-text-edit.hpp"
#include <condition_variable>
#include <mutex>
#include <obs-data.h>
#include <optional>
@@ -43,6 +44,11 @@ public:
std::optional<uint64_t> GetSecondsSinceLastUse() const;
std::optional<uint64_t> GetSecondsSinceLastChange() const;
void MarkAsUsed() const;
EXPORT std::condition_variable &GetCV() { return _cv; }
EXPORT std::unique_lock<std::mutex> GetCVLock()
{
return std::unique_lock<std::mutex>(_cvMutex);
}
private:
SaveAction _saveAction = SaveAction::DONT_SAVE;
@@ -53,6 +59,8 @@ private:
mutable std::chrono::high_resolution_clock::time_point _lastUsed;
mutable std::chrono::high_resolution_clock::time_point _lastChanged;
mutable std::mutex _mutex;
std::mutex _cvMutex;
std::condition_variable _cv;
void UpdateLastUsed() const;

View File

@@ -1,7 +1,11 @@
macro(add_plugin plugin)
option(ENABLE_${plugin}_PLUGIN "Enable the ${plugin} plugin" ON)
if(ENABLE_${plugin}_PLUGIN)
set_property(GLOBAL APPEND PROPERTY ADVSS_PLUGINS_PENDING ${plugin})
add_subdirectory(${plugin})
else()
set_property(GLOBAL APPEND PROPERTY ADVSS_PLUGINS_DISABLED_BY_OPTION
${plugin})
endif()
endmacro()
@@ -37,3 +41,32 @@ add_plugin(stream-deck)
add_plugin(twitch)
add_plugin(usb)
add_plugin(video)
# ---------------------------------------------------------------------------- #
# Print plugin summary
get_property(_advss_enabled GLOBAL PROPERTY ADVSS_PLUGINS_ENABLED)
get_property(_advss_pending GLOBAL PROPERTY ADVSS_PLUGINS_PENDING)
get_property(_advss_disabled_opt GLOBAL
PROPERTY ADVSS_PLUGINS_DISABLED_BY_OPTION)
set(_advss_disabled_deps)
foreach(_p ${_advss_pending})
if(NOT _p IN_LIST _advss_enabled)
list(APPEND _advss_disabled_deps ${_p})
endif()
endforeach()
message(STATUS "")
message(STATUS "ADVSS Plugin Summary")
message(STATUS "====================")
foreach(_p ${_advss_enabled})
message(STATUS " ENABLED: ${_p}")
endforeach()
foreach(_p ${_advss_disabled_deps})
message(STATUS " DISABLED: ${_p} (missing dependencies)")
endforeach()
foreach(_p ${_advss_disabled_opt})
message(STATUS " DISABLED: ${_p} (ENABLE_${_p}_PLUGIN=OFF)")
endforeach()
message(STATUS "")

View File

@@ -165,12 +165,20 @@ MacroActionRecordEdit::MacroActionRecordEdit(
QWidget *parent, std::shared_ptr<MacroActionRecord> entryData)
: QWidget(parent),
_actions(new QComboBox()),
_pauseHint(new QLabel(obs_module_text(
"AdvSceneSwitcher.action.recording.pause.hint"))),
_splitHint(new QLabel(obs_module_text(
"AdvSceneSwitcher.action.recording.split.hint"))),
_pauseHint(new HelpIcon(
obs_module_text(
"AdvSceneSwitcher.action.recording.pause.hint"),
this)),
_splitHint(new HelpIcon(
obs_module_text(
"AdvSceneSwitcher.action.recording.split.hint"),
this)),
_recordFolder(new FileSelection(FileSelection::Type::FOLDER, this)),
_recordFileFormat(new VariableLineEdit(this)),
_outputNotActiveHelp(new HelpIcon(
obs_module_text(
"AdvSceneSwitcher.action.recording.outputNotActive.hint"),
this)),
_chapterName(new VariableLineEdit(this))
{
populateActionSelection(_actions);
@@ -184,16 +192,18 @@ MacroActionRecordEdit::MacroActionRecordEdit(
QWidget::connect(_chapterName, SIGNAL(editingFinished()), this,
SLOT(ChapterNameChanged()));
auto mainLayout = new QHBoxLayout;
PlaceWidgets(obs_module_text("AdvSceneSwitcher.action.recording.entry"),
mainLayout,
{{"{{actions}}", _actions},
{"{{pauseHint}}", _pauseHint},
{"{{splitHint}}", _splitHint},
{"{{recordFolder}}", _recordFolder},
{"{{recordFileFormat}}", _recordFileFormat},
{"{{chapterName}}", _chapterName}});
setLayout(mainLayout);
_mainLayout = new QHBoxLayout;
PlaceWidgets(
obs_module_text("AdvSceneSwitcher.action.recording.layout"),
_mainLayout,
{{"{{actions}}", _actions},
{"{{pauseHint}}", _pauseHint},
{"{{splitHint}}", _splitHint},
{"{{recordFolder}}", _recordFolder},
{"{{recordFileFormat}}", _recordFileFormat},
{"{{outputNotActiveHelp}}", _outputNotActiveHelp},
{"{{chapterName}}", _chapterName}});
setLayout(_mainLayout);
_entryData = entryData;
UpdateEntryData();
@@ -245,8 +255,20 @@ void MacroActionRecordEdit::SetWidgetVisibility()
MacroActionRecord::Action::FOLDER);
_recordFileFormat->setVisible(_entryData->_action ==
MacroActionRecord::Action::FILE_FORMAT);
_outputNotActiveHelp->setVisible(
_entryData->_action == MacroActionRecord::Action::FOLDER ||
_entryData->_action == MacroActionRecord::Action::FILE_FORMAT);
_chapterName->setVisible(_entryData->_action ==
MacroActionRecord::Action::ADD_CHAPTER);
const bool hasExtraWidget =
_entryData->_action == MacroActionRecord::Action::FOLDER ||
_entryData->_action == MacroActionRecord::Action::FILE_FORMAT ||
_entryData->_action == MacroActionRecord::Action::ADD_CHAPTER;
if (hasExtraWidget) {
RemoveStretchIfPresent(_mainLayout);
} else {
AddStretchIfNecessary(_mainLayout);
}
}
void MacroActionRecordEdit::ActionChanged(int value)

View File

@@ -1,6 +1,7 @@
#pragma once
#include "macro-action-edit.hpp"
#include "file-selection.hpp"
#include "help-icon.hpp"
#include "variable-line-edit.hpp"
#include <QDir>
@@ -64,19 +65,19 @@ private slots:
void FormatStringChanged();
void ChapterNameChanged();
protected:
QComboBox *_actions;
QLabel *_pauseHint;
QLabel *_splitHint;
FileSelection *_recordFolder;
VariableLineEdit *_recordFileFormat;
VariableLineEdit *_chapterName;
std::shared_ptr<MacroActionRecord> _entryData;
private:
void SetWidgetVisibility();
QComboBox *_actions;
HelpIcon *_pauseHint;
HelpIcon *_splitHint;
FileSelection *_recordFolder;
VariableLineEdit *_recordFileFormat;
HelpIcon *_outputNotActiveHelp;
VariableLineEdit *_chapterName;
std::shared_ptr<MacroActionRecord> _entryData;
QHBoxLayout *_mainLayout;
bool _loading = true;
};

View File

@@ -1,6 +1,8 @@
#include "macro-action-scene-visibility.hpp"
#include "layout-helpers.hpp"
#include "macro-helpers.hpp"
#include "plugin-state-helpers.hpp"
#include "sync-helpers.hpp"
#include "transition-helpers.hpp"
#include <obs-frontend-api.h>
@@ -158,12 +160,11 @@ static void attachRestoreContext(obs_sceneitem_t *item,
signal_handler_connect(sh, "destroy", handleSourceDestroyed, ctx);
}
static void setSceneItemVisibility(obs_sceneitem_t *item,
const bool setTransition,
const OBSWeakSource &transitionWeak,
const bool setDuration,
const Duration &duration,
MacroActionSceneVisibility::Action action)
static obs_source_t *
setSceneItemVisibility(obs_sceneitem_t *item, const bool setTransition,
const OBSWeakSource &transitionWeak,
const bool setDuration, const Duration &duration,
MacroActionSceneVisibility::Action action)
{
const OBSSourceAutoRelease transition = OBSGetStrongRef(transitionWeak);
const bool itemIsVisible = obs_sceneitem_visible(item);
@@ -200,7 +201,7 @@ static void setSceneItemVisibility(obs_sceneitem_t *item,
}
if (!setTransition && !setDuration) {
return;
return privateTransitionSource;
}
if (!privateTransitionSource) {
@@ -213,27 +214,151 @@ static void setSceneItemVisibility(obs_sceneitem_t *item,
item, !itemIsVisible,
currentTransitionDuration);
}
return;
return nullptr;
}
auto sh = obs_source_get_signal_handler(privateTransitionSource);
if (!sh) {
return;
return nullptr;
}
attachRestoreContext(item, privateTransitionSource, itemIsVisible,
currentTransition, currentTransitionDuration);
return privateTransitionSource;
}
namespace {
struct TransitionWaitItem {
obs_sceneitem_t *sceneItem;
obs_source_t *expectedSource;
bool show;
std::atomic<int> *pendingCount;
std::atomic<bool> done{false};
void markDone()
{
bool expected = false;
if (done.compare_exchange_strong(expected, true)) {
(*pendingCount)--;
GetMacroTransitionCV().notify_all();
}
}
};
} // namespace
static void onTransitionDone(void *data, calldata_t *)
{
static_cast<TransitionWaitItem *>(data)->markDone();
}
static void waitForSceneItemTransitions(
std::atomic<int> &pendingCount,
const std::vector<std::unique_ptr<TransitionWaitItem>> &waitItems,
Macro *macro)
{
using namespace std::chrono_literals;
SetMacroAbortWait(false);
std::unique_lock<std::mutex> lock(*GetMutex());
while (pendingCount > 0 && !MacroWaitShouldAbort() &&
!MacroIsStopped(macro) && !OBSIsShuttingDown()) {
GetMacroTransitionCV().wait_for(lock, 100ms);
// Detect transitions replaced mid-wait (e.g. user sets
// transition to "None" via OBS context menu). In that
// case transition_stop never fires, so we poll.
for (const auto &wi : waitItems) {
if (wi->done) {
continue;
}
obs_source_t *current = obs_sceneitem_get_transition(
wi->sceneItem, wi->show);
if (current != wi->expectedSource) {
wi->markDone();
}
}
}
}
bool MacroActionSceneVisibility::PerformAction()
{
auto items = _source.GetSceneItems(_scene);
for (const auto &item : items) {
setSceneItemVisibility(item, _updateTransition,
_transition.GetTransition(),
_updateDuration, _duration, _action);
if (!_blockUntilTransitionDone) {
for (const auto &item : items) {
setSceneItemVisibility(item, _updateTransition,
_transition.GetTransition(),
_updateDuration, _duration,
_action);
}
return true;
}
return true;
std::atomic<int> pendingCount(0);
std::vector<OBSSourceAutoRelease> transitionSources;
std::vector<std::unique_ptr<TransitionWaitItem>> waitItems;
std::vector<OBSSignal> signalConnections;
for (const auto &item : items) {
const bool itemWasVisible = obs_sceneitem_visible(item);
bool targetVisible;
switch (_action) {
case Action::SHOW:
targetVisible = true;
break;
case Action::HIDE:
targetVisible = false;
break;
case Action::TOGGLE:
targetVisible = !itemWasVisible;
break;
default:
continue;
}
if (itemWasVisible == targetVisible) {
// No-op: visibility won't change, no transition will play
setSceneItemVisibility(item, _updateTransition,
_transition.GetTransition(),
_updateDuration, _duration,
_action);
continue;
}
auto *source = setSceneItemVisibility(
item, _updateTransition, _transition.GetTransition(),
_updateDuration, _duration, _action);
if (!source) {
continue;
}
auto *sh = obs_source_get_signal_handler(source);
if (!sh) {
continue;
}
transitionSources.emplace_back(obs_source_get_ref(source));
++pendingCount;
auto wi = std::make_unique<TransitionWaitItem>();
wi->sceneItem = item;
wi->expectedSource = source;
wi->show = !itemWasVisible;
wi->pendingCount = &pendingCount;
signalConnections.emplace_back(sh, "transition_stop",
onTransitionDone, wi.get());
waitItems.push_back(std::move(wi));
}
if (pendingCount == 0) {
return true;
}
waitForSceneItemTransitions(pendingCount, waitItems, GetMacro());
return !MacroWaitShouldAbort();
}
void MacroActionSceneVisibility::LogAction() const
@@ -261,6 +386,8 @@ bool MacroActionSceneVisibility::Save(obs_data_t *obj) const
obs_data_set_bool(obj, "updateDuration", _updateDuration);
_duration.Save(obj);
obs_data_set_int(obj, "action", static_cast<int>(_action));
obs_data_set_bool(obj, "blockUntilTransitionDone",
_blockUntilTransitionDone);
return true;
}
@@ -282,6 +409,8 @@ bool MacroActionSceneVisibility::Load(obs_data_t *obj)
_duration.Load(obj);
_action = static_cast<MacroActionSceneVisibility::Action>(
obs_data_get_int(obj, "action"));
_blockUntilTransitionDone =
obs_data_get_bool(obj, "blockUntilTransitionDone");
// TODO: Remove in future version
if (obs_data_get_int(obj, "sourceType") != 0) {
@@ -344,6 +473,10 @@ MacroActionSceneVisibilityEdit::MacroActionSceneVisibilityEdit(
_updateDuration(new QCheckBox(this)),
_duration(new DurationSelection(this, false)),
_durationLayout(new QHBoxLayout),
_blockUntilTransitionDone(new QCheckBox(
obs_module_text(
"AdvSceneSwitcher.action.sceneVisibility.blockUntilTransitionDone"),
this)),
_actions(new QComboBox())
{
populateActionSelection(_actions);
@@ -369,6 +502,8 @@ MacroActionSceneVisibilityEdit::MacroActionSceneVisibilityEdit(
SLOT(UpdateDurationChanged(int)));
QWidget::connect(_duration, SIGNAL(DurationChanged(const Duration &)),
this, SLOT(DurationChanged(const Duration &)));
QWidget::connect(_blockUntilTransitionDone, SIGNAL(stateChanged(int)),
this, SLOT(BlockUntilTransitionDoneChanged(int)));
auto sceneItemLayout = new QHBoxLayout;
PlaceWidgets(obs_module_text(
@@ -397,6 +532,7 @@ MacroActionSceneVisibilityEdit::MacroActionSceneVisibilityEdit(
layout->addLayout(sceneItemLayout);
layout->addLayout(transitionLayout);
layout->addLayout(_durationLayout);
layout->addWidget(_blockUntilTransitionDone);
setLayout(layout);
_entryData = entryData;
@@ -418,6 +554,8 @@ void MacroActionSceneVisibilityEdit::UpdateEntryData()
_transitions->SetTransition(_entryData->_transition);
_updateDuration->setChecked(_entryData->_updateDuration);
_duration->SetDuration(_entryData->_duration);
_blockUntilTransitionDone->setChecked(
_entryData->_blockUntilTransitionDone);
}
void MacroActionSceneVisibilityEdit::SceneChanged(const SceneSelection &s)
@@ -472,6 +610,12 @@ void MacroActionSceneVisibilityEdit::ActionChanged(int value)
static_cast<MacroActionSceneVisibility::Action>(value);
}
void MacroActionSceneVisibilityEdit::BlockUntilTransitionDoneChanged(int state)
{
GUARD_LOADING_AND_LOCK();
_entryData->_blockUntilTransitionDone = state;
}
void MacroActionSceneVisibilityEdit::SetWidgetVisibility()
{
const bool hideDurationSelection =

View File

@@ -26,6 +26,7 @@ public:
TransitionSelection _transition;
bool _updateDuration = false;
Duration _duration;
bool _blockUntilTransitionDone = true;
enum class Action {
SHOW,
@@ -64,6 +65,7 @@ private slots:
void UpdateDurationChanged(int);
void DurationChanged(const Duration &seconds);
void ActionChanged(int value);
void BlockUntilTransitionDoneChanged(int state);
signals:
void HeaderInfoChanged(const QString &);
@@ -77,6 +79,7 @@ private:
QCheckBox *_updateDuration;
DurationSelection *_duration;
QHBoxLayout *_durationLayout;
QCheckBox *_blockUntilTransitionDone;
QComboBox *_actions;
std::shared_ptr<MacroActionSceneVisibility> _entryData;

View File

@@ -19,6 +19,21 @@ static const std::map<MacroActionWait::Type, std::string> waitTypes = {
"AdvSceneSwitcher.action.wait.type.fixed"},
{MacroActionWait::Type::RANDOM,
"AdvSceneSwitcher.action.wait.type.random"},
{MacroActionWait::Type::VARIABLE_WAIT,
"AdvSceneSwitcher.action.wait.type.variableCondition"},
};
static const std::map<MacroActionWait::Condition, std::string> conditionTypes = {
{MacroActionWait::Condition::EQUALS,
"AdvSceneSwitcher.action.wait.condition.equals"},
{MacroActionWait::Condition::DOES_NOT_EQUAL,
"AdvSceneSwitcher.action.wait.condition.doesNotEqual"},
{MacroActionWait::Condition::IS_EMPTY,
"AdvSceneSwitcher.action.wait.condition.isEmpty"},
{MacroActionWait::Condition::LESS_THAN,
"AdvSceneSwitcher.action.wait.condition.lessThan"},
{MacroActionWait::Condition::GREATER_THAN,
"AdvSceneSwitcher.action.wait.condition.greaterThan"},
};
static std::random_device rd;
@@ -35,7 +50,52 @@ static void waitHelper(std::unique_lock<std::mutex> *lock, Macro *macro,
}
}
bool MacroActionWait::PerformAction()
void MacroActionWait::SetupTempVars()
{
MacroAction::SetupTempVars();
if (_waitType != Type::VARIABLE_WAIT) {
return;
}
AddTempvar(
"timedOut",
obs_module_text("AdvSceneSwitcher.tempVar.wait.timedOut"),
obs_module_text(
"AdvSceneSwitcher.tempVar.wait.timedOut.description"));
}
bool MacroActionWait::ConditionIsMet() const
{
auto var = _variable.lock();
if (!var) {
return false;
}
switch (_condition) {
case Condition::EQUALS:
if (_regex.Enabled()) {
return _regex.Matches(var->Value(), _strValue);
}
return var->Value() == std::string(_strValue);
case Condition::DOES_NOT_EQUAL:
if (_regex.Enabled()) {
return !_regex.Matches(var->Value(), _strValue);
}
return var->Value() != std::string(_strValue);
case Condition::IS_EMPTY:
return var->Value().empty();
case Condition::LESS_THAN: {
auto val = var->DoubleValue();
return val.has_value() && *val < (double)_numValue;
}
case Condition::GREATER_THAN: {
auto val = var->DoubleValue();
return val.has_value() && *val > (double)_numValue;
}
}
return false;
}
bool MacroActionWait::PerformDurationWait()
{
double sleepDuration;
if (_waitType == Type::FIXED) {
@@ -66,20 +126,71 @@ bool MacroActionWait::PerformAction()
return !MacroWaitShouldAbort();
}
bool MacroActionWait::PerformVariableWait()
{
auto var = _variable.lock();
if (!var) {
return true;
}
auto deadline =
_useTimeout
? std::chrono::high_resolution_clock::now() +
std::chrono::milliseconds((
int)(_conditionTimeout.Milliseconds()))
: std::chrono::high_resolution_clock::time_point::max();
SetMacroAbortWait(false);
SuspendLock suspendLock(*this);
auto cvLock = var->GetCVLock();
while (!MacroWaitShouldAbort() && !MacroIsStopped(GetMacro())) {
if (ConditionIsMet()) {
SetTempVarValue("timedOut", false);
return true;
}
auto now = std::chrono::high_resolution_clock::now();
if (now >= deadline) {
SetTempVarValue("timedOut", true);
return true;
}
auto wakeAt =
std::min(now + std::chrono::milliseconds(50), deadline);
var->GetCV().wait_until(cvLock, wakeAt);
}
return !MacroWaitShouldAbort();
}
bool MacroActionWait::PerformAction()
{
if (_waitType == Type::VARIABLE_WAIT) {
return PerformVariableWait();
}
return PerformDurationWait();
}
bool MacroActionWait::Save(obs_data_t *obj) const
{
MacroAction::Save(obj);
_duration.Save(obj);
_duration2.Save(obj, "duration2");
obs_data_set_int(obj, "waitType", static_cast<int>(_waitType));
obs_data_set_int(obj, "version", 1);
_duration.Save(obj);
_duration2.Save(obj, "duration2");
obs_data_set_string(obj, "variableName",
GetWeakVariableName(_variable).c_str());
obs_data_set_int(obj, "condition", static_cast<int>(_condition));
_strValue.Save(obj, "strValue");
_numValue.Save(obj, "numValue");
_regex.Save(obj);
obs_data_set_bool(obj, "useTimeout", _useTimeout);
_conditionTimeout.Save(obj, "conditionTimeout");
return true;
}
bool MacroActionWait::Load(obs_data_t *obj)
{
MacroAction::Load(obj);
_duration.Load(obj);
_waitType = static_cast<Type>(obs_data_get_int(obj, "waitType"));
// TODO: remove this fallback
if (obs_data_get_int(obj, "version") == 1) {
_duration2.Load(obj, "duration2");
@@ -88,12 +199,23 @@ bool MacroActionWait::Load(obs_data_t *obj)
_duration2.SetUnit(static_cast<Duration::Unit>(
obs_data_get_int(obj, "displayUnit2")));
}
_waitType = static_cast<Type>(obs_data_get_int(obj, "waitType"));
_duration.Load(obj);
_variable =
GetWeakVariableByName(obs_data_get_string(obj, "variableName"));
_condition = static_cast<Condition>(obs_data_get_int(obj, "condition"));
_strValue.Load(obj, "strValue");
_numValue.Load(obj, "numValue");
_regex.Load(obj);
_useTimeout = obs_data_get_bool(obj, "useTimeout");
_conditionTimeout.Load(obj, "conditionTimeout");
return true;
}
std::string MacroActionWait::GetShortDesc() const
{
if (_waitType == Type::VARIABLE_WAIT) {
return GetWeakVariableName(_variable);
}
if (_waitType == Type::FIXED) {
return _duration.ToString();
}
@@ -114,12 +236,18 @@ void MacroActionWait::ResolveVariablesToFixedValues()
{
_duration.ResolveVariables();
_duration2.ResolveVariables();
_strValue.ResolveVariables();
_numValue.ResolveVariables();
_conditionTimeout.ResolveVariables();
}
static inline void populateTypeSelection(QComboBox *list)
template<typename T>
static inline void populateSelection(QComboBox *list,
const std::map<T, std::string> &options)
{
for (const auto &[_, name] : waitTypes) {
list->addItem(obs_module_text(name.c_str()));
for (const auto &[value, name] : options) {
list->addItem(obs_module_text(name.c_str()),
static_cast<int>(value));
}
}
@@ -128,10 +256,21 @@ MacroActionWaitEdit::MacroActionWaitEdit(
: QWidget(parent),
_duration(new DurationSelection()),
_duration2(new DurationSelection()),
_variable(new VariableSelection(this)),
_variableCondition(new QComboBox()),
_strValue(new VariableTextEdit(this, 5, 1, 1)),
_numValue(new VariableDoubleSpinBox()),
_regex(new RegexConfigWidget(parent)),
_useTimeout(new QCheckBox()),
_conditionTimeout(new DurationSelection()),
_waitType(new QComboBox()),
_mainLayout(new QHBoxLayout())
_mainLayout(new QHBoxLayout()),
_timeoutLayout(new QHBoxLayout())
{
populateTypeSelection(_waitType);
_numValue->setMinimum(-9999999999);
_numValue->setMaximum(9999999999);
populateSelection(_waitType, waitTypes);
populateSelection(_variableCondition, conditionTypes);
QWidget::connect(_duration, SIGNAL(DurationChanged(const Duration &)),
this, SLOT(DurationChanged(const Duration &)));
@@ -139,8 +278,37 @@ MacroActionWaitEdit::MacroActionWaitEdit(
this, SLOT(Duration2Changed(const Duration &)));
QWidget::connect(_waitType, SIGNAL(currentIndexChanged(int)), this,
SLOT(TypeChanged(int)));
QWidget::connect(_variable, SIGNAL(SelectionChanged(const QString &)),
this, SLOT(VariableChanged(const QString &)));
QWidget::connect(_variableCondition, SIGNAL(currentIndexChanged(int)),
this, SLOT(ConditionChanged(int)));
QWidget::connect(_strValue, SIGNAL(textChanged()), this,
SLOT(StrValueChanged()));
QWidget::connect(
_numValue,
SIGNAL(NumberVariableChanged(const NumberVariable<double> &)),
this, SLOT(NumValueChanged(const NumberVariable<double> &)));
QWidget::connect(_regex,
SIGNAL(RegexConfigChanged(const RegexConfig &)), this,
SLOT(RegexChanged(const RegexConfig &)));
QWidget::connect(_useTimeout, SIGNAL(stateChanged(int)), this,
SLOT(UseTimeoutChanged(int)));
QWidget::connect(_conditionTimeout,
SIGNAL(DurationChanged(const Duration &)), this,
SLOT(ConditionTimeoutChanged(const Duration &)));
setLayout(_mainLayout);
PlaceWidgets(
obs_module_text("AdvSceneSwitcher.action.wait.layout.timeout"),
_timeoutLayout,
{
{"{{useTimeout}}", _useTimeout},
{"{{conditionTimeout}}", _conditionTimeout},
});
auto *outerLayout = new QVBoxLayout();
outerLayout->addLayout(_mainLayout);
outerLayout->addLayout(_timeoutLayout);
setLayout(outerLayout);
_entryData = entryData;
UpdateEntryData();
@@ -153,15 +321,30 @@ void MacroActionWaitEdit::UpdateEntryData()
return;
}
if (_entryData->_waitType == MacroActionWait::Type::FIXED) {
_waitType->setCurrentIndex(static_cast<int>(_entryData->_waitType));
switch (_entryData->_waitType) {
case MacroActionWait::Type::FIXED:
SetupFixedDurationEdit();
} else {
break;
case MacroActionWait::Type::RANDOM:
SetupRandomDurationEdit();
break;
case MacroActionWait::Type::VARIABLE_WAIT:
SetupVariableWaitEdit();
break;
}
_duration->SetDuration(_entryData->_duration);
_duration2->SetDuration(_entryData->_duration2);
_waitType->setCurrentIndex(static_cast<int>(_entryData->_waitType));
_variable->SetVariable(_entryData->_variable);
_variableCondition->setCurrentIndex(
static_cast<int>(_entryData->_condition));
_strValue->setPlainText(_entryData->_strValue);
_numValue->SetValue(_entryData->_numValue);
_regex->SetRegexConfig(_entryData->_regex);
_useTimeout->setChecked(_entryData->_useTimeout);
_conditionTimeout->SetDuration(_entryData->_conditionTimeout);
}
void MacroActionWaitEdit::SetupFixedDurationEdit()
@@ -169,15 +352,24 @@ void MacroActionWaitEdit::SetupFixedDurationEdit()
_mainLayout->removeWidget(_duration);
_mainLayout->removeWidget(_duration2);
_mainLayout->removeWidget(_waitType);
_mainLayout->removeWidget(_variable);
_mainLayout->removeWidget(_variableCondition);
_mainLayout->removeWidget(_strValue);
_mainLayout->removeWidget(_numValue);
_mainLayout->removeWidget(_regex);
ClearLayout(_mainLayout);
std::unordered_map<std::string, QWidget *> widgetPlaceholders = {
{"{{duration}}", _duration},
{"{{waitType}}", _waitType},
};
PlaceWidgets(
obs_module_text("AdvSceneSwitcher.action.wait.entry.fixed"),
_mainLayout, widgetPlaceholders);
obs_module_text("AdvSceneSwitcher.action.wait.layout.fixed"),
_mainLayout,
{{"{{duration}}", _duration}, {"{{waitType}}", _waitType}});
_duration->show();
_duration2->hide();
_variable->hide();
_variableCondition->hide();
_strValue->hide();
_numValue->hide();
_regex->hide();
SetLayoutVisible(_timeoutLayout, false);
}
void MacroActionWaitEdit::SetupRandomDurationEdit()
@@ -185,30 +377,99 @@ void MacroActionWaitEdit::SetupRandomDurationEdit()
_mainLayout->removeWidget(_duration);
_mainLayout->removeWidget(_duration2);
_mainLayout->removeWidget(_waitType);
_mainLayout->removeWidget(_variable);
_mainLayout->removeWidget(_variableCondition);
_mainLayout->removeWidget(_strValue);
_mainLayout->removeWidget(_numValue);
_mainLayout->removeWidget(_regex);
ClearLayout(_mainLayout);
std::unordered_map<std::string, QWidget *> widgetPlaceholders = {
{"{{duration}}", _duration},
{"{{duration2}}", _duration2},
{"{{waitType}}", _waitType},
};
PlaceWidgets(
obs_module_text("AdvSceneSwitcher.action.wait.entry.random"),
_mainLayout, widgetPlaceholders);
obs_module_text("AdvSceneSwitcher.action.wait.layout.random"),
_mainLayout,
{{"{{duration}}", _duration},
{"{{duration2}}", _duration2},
{"{{waitType}}", _waitType}});
_duration->show();
_duration2->show();
_variable->hide();
_variableCondition->hide();
_strValue->hide();
_numValue->hide();
_regex->hide();
SetLayoutVisible(_timeoutLayout, false);
}
void MacroActionWaitEdit::TypeChanged(int value)
void MacroActionWaitEdit::SetupVariableWaitEdit()
{
_mainLayout->removeWidget(_duration);
_mainLayout->removeWidget(_duration2);
_mainLayout->removeWidget(_waitType);
_mainLayout->removeWidget(_variable);
_mainLayout->removeWidget(_variableCondition);
_mainLayout->removeWidget(_strValue);
_mainLayout->removeWidget(_numValue);
_mainLayout->removeWidget(_regex);
ClearLayout(_mainLayout);
std::unordered_map<std::string, QWidget *> widgetPlaceholders = {
{"{{waitType}}", _waitType},
{"{{variable}}", _variable},
{"{{condition}}", _variableCondition},
{"{{strValue}}", _strValue},
{"{{numValue}}", _numValue},
{"{{regex}}", _regex},
};
PlaceWidgets(
obs_module_text(
"AdvSceneSwitcher.action.wait.layout.variableCondition"),
_mainLayout, widgetPlaceholders);
_duration->hide();
_duration2->hide();
_variable->show();
_variableCondition->show();
SetLayoutVisible(_timeoutLayout, true);
SetVariableWaitWidgetVisibility();
}
void MacroActionWaitEdit::SetVariableWaitWidgetVisibility()
{
if (!_entryData) {
return;
}
const auto cond = _entryData->_condition;
const bool isStringComparison =
cond == MacroActionWait::Condition::EQUALS ||
cond == MacroActionWait::Condition::DOES_NOT_EQUAL;
_strValue->setVisible(isStringComparison);
_regex->setVisible(isStringComparison);
_numValue->setVisible(cond == MacroActionWait::Condition::LESS_THAN ||
cond == MacroActionWait::Condition::GREATER_THAN);
_conditionTimeout->setVisible(_entryData->_useTimeout);
adjustSize();
updateGeometry();
}
void MacroActionWaitEdit::TypeChanged(int idx)
{
GUARD_LOADING_AND_LOCK();
auto type = static_cast<MacroActionWait::Type>(value);
auto type = static_cast<MacroActionWait::Type>(
_waitType->itemData(idx).toInt());
if (type == MacroActionWait::Type::FIXED) {
switch (type) {
case MacroActionWait::Type::FIXED:
SetupFixedDurationEdit();
} else {
break;
case MacroActionWait::Type::RANDOM:
SetupRandomDurationEdit();
break;
case MacroActionWait::Type::VARIABLE_WAIT:
SetupVariableWaitEdit();
break;
}
_entryData->_waitType = type;
_entryData->SetupTempVars();
emit HeaderInfoChanged(
QString::fromStdString(_entryData->GetShortDesc()));
}
@@ -229,4 +490,55 @@ void MacroActionWaitEdit::Duration2Changed(const Duration &dur)
QString::fromStdString(_entryData->GetShortDesc()));
}
void MacroActionWaitEdit::VariableChanged(const QString &text)
{
GUARD_LOADING_AND_LOCK();
_entryData->_variable = GetWeakVariableByQString(text);
emit HeaderInfoChanged(
QString::fromStdString(_entryData->GetShortDesc()));
}
void MacroActionWaitEdit::ConditionChanged(int idx)
{
GUARD_LOADING_AND_LOCK();
_entryData->_condition = static_cast<MacroActionWait::Condition>(
_variableCondition->itemData(idx).toInt());
SetVariableWaitWidgetVisibility();
}
void MacroActionWaitEdit::StrValueChanged()
{
GUARD_LOADING_AND_LOCK();
_entryData->_strValue = _strValue->toPlainText().toStdString();
adjustSize();
updateGeometry();
}
void MacroActionWaitEdit::NumValueChanged(const NumberVariable<double> &val)
{
GUARD_LOADING_AND_LOCK();
_entryData->_numValue = val;
}
void MacroActionWaitEdit::RegexChanged(const RegexConfig &conf)
{
GUARD_LOADING_AND_LOCK();
_entryData->_regex = conf;
adjustSize();
updateGeometry();
}
void MacroActionWaitEdit::UseTimeoutChanged(int state)
{
GUARD_LOADING_AND_LOCK();
_entryData->_useTimeout = state != Qt::Unchecked;
SetVariableWaitWidgetVisibility();
}
void MacroActionWaitEdit::ConditionTimeoutChanged(const Duration &dur)
{
GUARD_LOADING_AND_LOCK();
_entryData->_conditionTimeout = dur;
}
} // namespace advss

View File

@@ -1,8 +1,14 @@
#pragma once
#include "macro-action-edit.hpp"
#include "duration-control.hpp"
#include "regex-config.hpp"
#include "variable-spinbox.hpp"
#include "variable-text-edit.hpp"
#include "variable.hpp"
#include <QCheckBox>
#include <QHBoxLayout>
#include <QVBoxLayout>
namespace advss {
@@ -17,17 +23,40 @@ public:
static std::shared_ptr<MacroAction> Create(Macro *m);
std::shared_ptr<MacroAction> Copy() const;
void ResolveVariablesToFixedValues();
Duration _duration;
Duration _duration2;
void SetupTempVars();
enum class Type {
FIXED,
RANDOM,
VARIABLE_WAIT,
};
Type _waitType = Type::FIXED;
// FIXED / RANDOM
Duration _duration;
Duration _duration2;
// VARIABLE_WAIT
enum class Condition {
EQUALS,
DOES_NOT_EQUAL,
IS_EMPTY,
LESS_THAN,
GREATER_THAN,
};
std::weak_ptr<Variable> _variable;
Condition _condition = Condition::EQUALS;
StringVariable _strValue = "";
DoubleVariable _numValue = 0.0;
RegexConfig _regex;
bool _useTimeout = false;
Duration _conditionTimeout;
private:
bool PerformDurationWait();
bool PerformVariableWait();
bool ConditionIsMet() const;
static bool _registered;
static const std::string id;
};
@@ -42,6 +71,7 @@ public:
void UpdateEntryData();
void SetupFixedDurationEdit();
void SetupRandomDurationEdit();
void SetupVariableWaitEdit();
static QWidget *Create(QWidget *parent,
std::shared_ptr<MacroAction> action)
{
@@ -54,15 +84,36 @@ private slots:
void DurationChanged(const Duration &value);
void Duration2Changed(const Duration &value);
void TypeChanged(int value);
void VariableChanged(const QString &);
void ConditionChanged(int);
void StrValueChanged();
void NumValueChanged(const NumberVariable<double> &);
void RegexChanged(const RegexConfig &);
void UseTimeoutChanged(int);
void ConditionTimeoutChanged(const Duration &);
signals:
void HeaderInfoChanged(const QString &);
private:
void SetVariableWaitWidgetVisibility();
// Duration widgets (FIXED / RANDOM)
DurationSelection *_duration;
DurationSelection *_duration2;
// Variable condition widgets (VARIABLE_WAIT)
VariableSelection *_variable;
QComboBox *_variableCondition;
VariableTextEdit *_strValue;
VariableDoubleSpinBox *_numValue;
RegexConfigWidget *_regex;
QCheckBox *_useTimeout;
DurationSelection *_conditionTimeout;
QComboBox *_waitType;
QHBoxLayout *_mainLayout;
QHBoxLayout *_timeoutLayout;
std::shared_ptr<MacroActionWait> _entryData;
bool _loading = true;

View File

@@ -15,6 +15,9 @@ bool MacroConditionProfile::_registered = MacroConditionFactory::Register(
bool MacroConditionProfile::CheckCondition()
{
auto currentProfile = obs_frontend_get_current_profile();
if (!currentProfile) {
return false;
}
const bool match = _profile == currentProfile;
bfree(currentProfile);
return match;

View File

@@ -163,7 +163,7 @@ void MacroConditionTransition::TransitionStarted(void *data, calldata_t *cd)
// Look up the original by name to get a comparable reference.
const bool studioModeActive =
obs_frontend_preview_program_mode_active();
if (studioModeActive) {
if (studioModeActive && startSource) {
OBSSourceAutoRelease startSourceByName = obs_get_source_by_name(
obs_source_get_name(startSource));
if (startSourceByName) {

View File

@@ -26,7 +26,7 @@ if(NOT OpenVR_DIR)
set(OpenVR_DIR ${ADVSS_SOURCE_DIR}/deps/openvr)
endif()
if(EXISTS ${OpenVR_DIR})
if(EXISTS "${OpenVR_DIR}/CMakeLists.txt")
set(SIZEOF_VOIDP ${CMAKE_SIZEOF_VOID_P})
if((NOT APPLE) AND (CMAKE_SIZEOF_VOID_P EQUAL 8))
set(PROCESSOR_ARCH "64")

View File

@@ -255,39 +255,100 @@ static std::string preprocessScriptText(const std::string &text,
const std::string &id)
{
const std::string footerPython =
std::string("\n\n"
"## AUTO GENERATED ##\n"
"def script_load(settings):\n"
" def run_wrapper(data):\n"
" id = obs.calldata_string(data, \"id\")\n"
" if id == \"") +
R"(
## AUTO GENERATED ##
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 is 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
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 is False:
obs.script_log(obs.LOG_WARNING, f'failed to set value for variable "{name}"')
obs.calldata_destroy(data)
return success
def script_load(settings):
def run_wrapper(data):
id = obs.calldata_string(data, "id")
if id == ")" +
id +
"\":\n"
" ret = run()\n"
" obs.calldata_set_bool(data, \"result\", ret)\n"
" sh = obs.obs_get_signal_handler()\n"
" obs.signal_handler_connect(sh, \"" +
signalName.data() + "\", run_wrapper)\n\n";
R"(":
ret = run()
obs.calldata_set_bool(data, "result", ret)
sh = obs.obs_get_signal_handler()
obs.signal_handler_connect(sh, ")" +
std::string(signalName) +
R"(", run_wrapper)
)";
const std::string footerLUA =
std::string(
"\n\n"
"-- AUTO GENERATED --\n"
"function script_load(settings)\n"
" local run_wrapper = (function(data)\n"
" local id = obs.calldata_string(data, \"id\")\n"
" if id == \"") +
R"(
-- AUTO GENERATED --
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
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
function script_load(settings)
local run_wrapper = (function(data)
local id = obs.calldata_string(data, "id")
if id == ")" +
id +
"\" then\n"
" local ret = run()\n"
" obs.calldata_set_bool(data, \"result\", ret)\n"
" end\n"
" end)\n"
" local sh = obs.obs_get_signal_handler()\n"
" obs.signal_handler_connect(sh, \"" +
signalName.data() +
"\" , run_wrapper)\n"
"end\n";
R"(" then
local ret = run()
obs.calldata_set_bool(data, "result", ret)
end
end)
local sh = obs.obs_get_signal_handler()
obs.signal_handler_connect(sh, ")" +
std::string(signalName) +
R"(" , run_wrapper)
end
)";
std::string scriptText =
language == OBS_SCRIPT_LANG_PYTHON ? footerPython : footerLUA;

View File

@@ -32,32 +32,6 @@ if(NOT ZLIB_FOUND)
return()
endif()
set(DATE_LIB_DIR "${ADVSS_SOURCE_DIR}/deps/date")
if(EXISTS "${DATE_LIB_DIR}/CMakeLists.txt"
AND NOT DISABLE_TWITCH_TIMESTAMP_VERIFICATION)
set(BUILD_TZ_LIB ON)
if(OS_WINDOWS)
if(CURL_FOUND AND TARGET CURL::libcurl)
get_target_property(CURL_INCLUDE_DIR CURL::libcurl
INTERFACE_INCLUDE_DIRECTORIES)
add_subdirectory("${DATE_LIB_DIR}" "${DATE_LIB_DIR}/build"
EXCLUDE_FROM_ALL)
target_include_directories(date-tz PRIVATE "${CURL_INCLUDE_DIR}")
set(VERIFY_TWITCH_TIMESTAMPS ON)
else()
message(WARNING "CURL not found - not verifying Twitch timestamps")
endif()
else()
add_subdirectory("${DATE_LIB_DIR}" "${DATE_LIB_DIR}/build" EXCLUDE_FROM_ALL)
target_compile_options(date-tz PUBLIC -Wno-error=conversion
-Wno-error=shadow)
set(VERIFY_TWITCH_TIMESTAMPS ON)
endif()
else()
message(WARNING "date lib not found in \"${DATE_LIB_DIR}\"!\n"
"Twitch timestamps will not be checked!")
endif()
# --- End of section ---
add_library(${PROJECT_NAME} MODULE)
@@ -92,6 +66,8 @@ target_sources(
content-classification.hpp
event-sub.cpp
event-sub.hpp
twitch-timestamp.cpp
twitch-timestamp.hpp
language-selection.cpp
language-selection.hpp
macro-action-twitch.cpp
@@ -114,13 +90,6 @@ set_target_properties(${PROJECT_NAME} PROPERTIES PREFIX "")
target_include_directories(${PROJECT_NAME} PRIVATE "${CPP_HTTPLIB_DIR}/"
"${OPENSSL_INCLUDE_DIR}")
target_link_libraries(${PROJECT_NAME} PRIVATE ${OPENSSL_LIBRARIES} ZLIB::ZLIB)
if(DEFINED VERIFY_TWITCH_TIMESTAMPS)
target_compile_definitions(${PROJECT_NAME} PRIVATE VERIFY_TIMESTAMPS=1)
target_link_libraries(${PROJECT_NAME} PRIVATE date::date-tz)
if(OS_WINDOWS)
target_link_libraries(${PROJECT_NAME} PRIVATE CURL::libcurl)
endif()
endif()
install_advss_plugin(${PROJECT_NAME})
if(OS_WINDOWS)

View File

@@ -3,6 +3,7 @@
#include "twitch-helpers.hpp"
#include <log-helper.hpp>
#include <plugin-state-helpers.hpp>
#undef DispatchMessage
@@ -339,6 +340,24 @@ static constexpr std::string_view defaultURL =
std::map<TwitchChatConnection::ChatMapKey, std::weak_ptr<TwitchChatConnection>>
TwitchChatConnection::_chatMap = {};
void TwitchChatConnection::DisconnectAll()
{
for (auto &[key, weakConn] : _chatMap) {
auto conn = weakConn.lock();
if (conn) {
conn->Disconnect();
}
}
}
static bool setupChatConnectionSupport()
{
AddStopStep(TwitchChatConnection::DisconnectAll);
return true;
}
static bool _chatSetup = setupChatConnectionSupport();
TwitchChatConnection::TwitchChatConnection(const TwitchToken &token,
const TwitchChannel &channel)
: QObject(nullptr),

View File

@@ -84,6 +84,7 @@ public:
static std::shared_ptr<TwitchChatConnection>
GetChatConnection(const TwitchToken &token,
const TwitchChannel &channel);
static void DisconnectAll();
[[nodiscard]] ChatMessageBuffer RegisterForMessages();
[[nodiscard]] ChatMessageBuffer RegisterForWhispers();
void SendChatMessage(const std::string &message);

View File

@@ -3,10 +3,8 @@
#include "twitch-helpers.hpp"
#include <log-helper.hpp>
#ifdef VERIFY_TIMESTAMPS
#include "date/tz.h"
#endif
#include <plugin-state-helpers.hpp>
#include "twitch-timestamp.hpp"
using namespace std::chrono_literals;
@@ -71,6 +69,22 @@ void EventSub::UnregisterInstance()
_instances.erase(it, _instances.end());
}
void EventSub::DisconnectAll()
{
std::lock_guard<std::mutex> lock(_instancesMtx);
for (auto *instance : _instances) {
instance->Disconnect();
}
}
static bool setupEventSubSupport()
{
AddStopStep(EventSub::DisconnectAll);
return true;
}
static bool _eventSubSetup = setupEventSubSupport();
void EventSub::ConnectThread()
{
_client->reset();
@@ -262,12 +276,21 @@ std::string EventSub::AddEventSubscription(std::shared_ptr<TwitchToken> token,
return "";
}
// Capture the session ID and release the lock before making the HTTP
// request. Holding the mutex during a network call would block
// SubscriptionIsActive() on the condition-check thread for the entire
// duration of the request, causing visibly slow macro evaluations.
const std::string sessionID = eventSub->_sessionID;
lock.unlock();
OBSDataAutoRelease postData = copyData(subscription.data);
setTransportData(postData.Get(), eventSub->_sessionID);
setTransportData(postData.Get(), sessionID);
auto result = SendPostRequest(*token, registerSubscriptionURL.data(),
registerSubscriptionPath.data(), {},
postData.Get());
lock.lock();
if (result.status != 202) {
const char *error = obs_data_get_string(result.data, "error");
const char *message =
@@ -280,6 +303,24 @@ std::string EventSub::AddEventSubscription(std::shared_ptr<TwitchToken> token,
return "";
}
// Verify the session has not been replaced while the HTTP request was
// in flight (e.g. a reconnect occurred). If it was, the subscription
// we just created belongs to a dead session and must be discarded so
// the caller will retry with the new session ID.
if (eventSub->_sessionID != sessionID) {
blog(LOG_INFO,
"discarding Twitch EventSub registration - session ID "
"changed during HTTP request");
return "";
}
// Another thread may have registered the same subscription while the
// lock was released.
if (isAlreadySubscribed(eventSub->_activeSubscriptions, subscription)) {
eventSub->LogActiveSubscriptions();
return eventSub->_activeSubscriptions.find(subscription)->id;
}
OBSDataArrayAutoRelease replyArray =
obs_data_get_array(result.data, "data");
OBSDataAutoRelease replyData = obs_data_array_item(replyArray, 0);
@@ -313,46 +354,6 @@ void EventSub::OnOpen(connection_hdl)
_connected = true;
}
static bool isValidTimestamp(const std::string &timestamp)
{
#ifdef VERIFY_TIMESTAMPS
// Example input: 2023-07-19T14:56:51.634234626Z
try {
// Discard the nanosecond part
static constexpr size_t dotPos = 19;
std::string trimmed = timestamp.substr(0, dotPos);
auto tzStart = timestamp.find_first_of("Z+-", dotPos);
trimmed = timestamp.substr(0, dotPos);
if (tzStart != std::string::npos) {
trimmed += timestamp.substr(tzStart);
}
std::istringstream in(trimmed);
date::sys_time<std::chrono::seconds> parsedTime;
in >> date::parse("%FT%TZ", parsedTime);
if (in.fail()) {
blog(LOG_WARNING, "failed to parse timestamp %s",
timestamp.c_str());
return false;
}
auto now = date::zoned_time{date::current_zone(),
std::chrono::system_clock::now()}
.get_sys_time();
auto duration = now - parsedTime;
// Clocks might be off by a bit, so allow negative values also
return duration <= 10min && duration >= -1min;
} catch (const std::exception &e) {
blog(LOG_WARNING, "%s: %s", __func__, e.what());
return false;
}
#else
// Just assume timestamps are always valid
return true;
#endif
}
bool EventSub::IsValidMessageID(const std::string &id)
{
auto it = std::find(_messageIDs.begin(), _messageIDs.end(), id);
@@ -392,7 +393,7 @@ EventSub::ParseWebSocketMessage(const EventSubWSClient::message_ptr &message)
OBSDataAutoRelease metadata = obs_data_get_obj(json, "metadata");
std::string timestamp =
obs_data_get_string(metadata, "message_timestamp");
if (_validateTimestamps && !isValidTimestamp(timestamp)) {
if (_validateTimestamps && !IsValidEventSubTimestamp(timestamp)) {
blog(LOG_WARNING,
"discarding Twitch EventSub with invalid timestamp %s",
timestamp.c_str());
@@ -439,9 +440,10 @@ void EventSub::OnMessage(connection_hdl, EventSubWSClient::message_ptr message)
void EventSub::HandleWelcome(obs_data_t *data)
{
std::lock_guard<std::mutex> lock(_subscriptionMtx);
OBSDataAutoRelease session = obs_data_get_obj(data, "session");
_sessionID = obs_data_get_string(session, "id");
ClearActiveSubscriptions();
_activeSubscriptions.clear();
blog(LOG_INFO, "Twitch EventSub connected");
vblog(LOG_INFO, "Twitch EventSub session id: %s", _sessionID.c_str());
}

View File

@@ -53,6 +53,7 @@ public:
void Connect();
void Disconnect();
static void DisconnectAll();
[[nodiscard]] EventSubMessageBuffer RegisterForEvents();
bool SubscriptionIsActive(const std::string &id);
static std::string AddEventSubscription(std::shared_ptr<TwitchToken>,

View File

@@ -26,6 +26,8 @@ void MacroActionTwitch::ResolveVariablesToFixedValues()
_userLogin.ResolveVariables();
_userId.ResolveVariables();
_banReason.ResolveVariables();
_pollTitle.ResolveVariables();
_pollChoices.ResolveVariables();
_useVariableForRewardSelection = false;
auto token = _token.lock();
if (token) {
@@ -74,6 +76,10 @@ const static std::map<MacroActionTwitch::Action, std::string> actionTypes = {
"AdvSceneSwitcher.action.twitch.type.raid.end"},
{MacroActionTwitch::Action::SHOUTOUT_SEND,
"AdvSceneSwitcher.action.twitch.type.shoutout.send"},
{MacroActionTwitch::Action::POLL_START,
"AdvSceneSwitcher.action.twitch.type.poll.start"},
{MacroActionTwitch::Action::POLL_END,
"AdvSceneSwitcher.action.twitch.type.poll.end"},
{MacroActionTwitch::Action::SHIELD_MODE_START,
"AdvSceneSwitcher.action.twitch.type.shieldMode.start"},
{MacroActionTwitch::Action::SHIELD_MODE_END,
@@ -136,6 +142,8 @@ const static std::map<MacroActionTwitch::Action, std::string> actionTypes = {
"AdvSceneSwitcher.action.twitch.type.reward.getInfo"},
{MacroActionTwitch::Action::CHANNEL_GET_INFO,
"AdvSceneSwitcher.action.twitch.type.channel.getInfo"},
{MacroActionTwitch::Action::CHANNEL_ADS_GET_INFO,
"AdvSceneSwitcher.action.twitch.type.channel.ads.getInfo"},
};
const static std::map<MacroActionTwitch::AnnouncementColor, std::string>
@@ -349,6 +357,131 @@ static void setChatSetting(const TwitchToken &token, const char *field,
}
}
static std::vector<std::string> splitLines(const std::string &str)
{
std::vector<std::string> lines;
size_t start = 0;
while (start < str.size()) {
size_t end = str.find('\n', start);
if (end == std::string::npos) {
end = str.size();
}
size_t lineEnd = end;
if (lineEnd > start && str[lineEnd - 1] == '\r') {
--lineEnd;
}
if (lineEnd > start) {
lines.push_back(str.substr(start, lineEnd - start));
}
start = end + 1;
}
return lines;
}
void MacroActionTwitch::StartPoll(
const std::shared_ptr<TwitchToken> &token) const
{
const auto id = token->GetUserID();
if (!id) {
vblog(LOG_INFO, "%s skip - invalid user id", __func__);
return;
}
const auto pollTitle = std::string(_pollTitle);
if (pollTitle.empty()) {
blog(LOG_WARNING, "Poll title is empty!");
return;
}
auto choices = splitLines(std::string(_pollChoices));
if (choices.size() < 2) {
blog(LOG_WARNING, "Poll requires at least 2 choices!");
return;
}
if (choices.size() > 10) {
choices.resize(10);
}
const auto duration = _duration.Seconds();
if (duration < 15 || duration > 1800) {
blog(LOG_WARNING,
"Poll duration must be between 15 and 1800 seconds! (%d)",
(int)duration);
return;
}
const auto channelId = _channel.GetUserID(*token);
const auto &broadcasterId = channelId.empty() ? *id : channelId;
OBSDataAutoRelease data = obs_data_create();
obs_data_set_string(data, "broadcaster_id", broadcasterId.c_str());
obs_data_set_string(data, "title", pollTitle.c_str());
obs_data_set_int(data, "duration", duration);
OBSDataArrayAutoRelease choicesArray = obs_data_array_create();
for (const auto &choice : choices) {
OBSDataAutoRelease choiceData = obs_data_create();
obs_data_set_string(choiceData, "title", choice.c_str());
obs_data_array_push_back(choicesArray, choiceData);
}
obs_data_set_array(data, "choices", choicesArray);
auto result = SendPostRequest(*token, "https://api.twitch.tv",
"/helix/polls", {}, data.Get());
if (result.status != 200) {
blog(LOG_INFO, "Failed to start poll! (%d)", result.status);
}
}
const static std::map<MacroActionTwitch::PollEndStatus, std::string>
pollEndStatusesTwitch = {
{MacroActionTwitch::PollEndStatus::TERMINATED, "TERMINATED"},
{MacroActionTwitch::PollEndStatus::ARCHIVED, "ARCHIVED"},
};
void MacroActionTwitch::EndPoll(const std::shared_ptr<TwitchToken> &token) const
{
const auto id = token->GetUserID();
if (!id) {
vblog(LOG_INFO, "%s skip - invalid user id", __func__);
return;
}
const auto channelId = _channel.GetUserID(*token);
const auto &broadcasterId = channelId.empty() ? *id : channelId;
auto getResult = SendGetRequest(
*token, "https://api.twitch.tv", "/helix/polls",
{{"broadcaster_id", broadcasterId}, {"status", "ACTIVE"}});
if (getResult.status != 200) {
blog(LOG_INFO, "Failed to get active poll! (%d)",
getResult.status);
return;
}
OBSDataArrayAutoRelease array =
obs_data_get_array(getResult.data, "data");
if (obs_data_array_count(array) == 0) {
blog(LOG_INFO, "No active poll found!");
return;
}
OBSDataAutoRelease pollData = obs_data_array_item(array, 0);
const auto pollId = obs_data_get_string(pollData, "id");
OBSDataAutoRelease patchData = obs_data_create();
obs_data_set_string(patchData, "broadcaster_id", broadcasterId.c_str());
obs_data_set_string(patchData, "id", pollId);
obs_data_set_string(patchData, "status",
pollEndStatusesTwitch.at(_pollEndStatus).c_str());
auto result = SendPatchRequest(*token, "https://api.twitch.tv",
"/helix/polls", {}, patchData.Get());
if (result.status != 200) {
blog(LOG_INFO, "Failed to end poll! (%d)", result.status);
}
}
void MacroActionTwitch::StartRaid(const std::shared_ptr<TwitchToken> &token)
{
const auto id = token->GetUserID();
@@ -450,6 +583,43 @@ void MacroActionTwitch::GetChannelInfo(const std::shared_ptr<TwitchToken> &token
info->is_branded_content ? "true" : "false");
}
void MacroActionTwitch::GetAdsInfo(const std::shared_ptr<TwitchToken> &token)
{
const auto id = token->GetUserID();
if (!id) {
vblog(LOG_INFO, "%s skip - invalid user id", __func__);
return;
}
auto result = SendGetRequest(*token, "https://api.twitch.tv",
"/helix/channels/ads",
{{"broadcaster_id", *id}});
if (result.status != 200) {
blog(LOG_INFO, "Failed to get ads info! (%d)", result.status);
return;
}
OBSDataArrayAutoRelease array = obs_data_get_array(result.data, "data");
if (obs_data_array_count(array) == 0) {
blog(LOG_WARNING, "%s did not return any data!", __func__);
return;
}
OBSDataAutoRelease data = obs_data_array_item(array, 0);
SetTempVarValue("snooze_count",
std::to_string(obs_data_get_int(data, "snooze_count")));
SetTempVarValue("snooze_refresh_at",
obs_data_get_string(data, "snooze_refresh_at"));
SetTempVarValue("next_ad_at", obs_data_get_string(data, "next_ad_at"));
SetTempVarValue("duration",
std::to_string(obs_data_get_int(data, "duration")));
SetTempVarValue("last_ad_at", obs_data_get_string(data, "last_ad_at"));
SetTempVarValue(
"preroll_free_time",
std::to_string(obs_data_get_int(data, "preroll_free_time")));
}
std::optional<std::string> MacroActionTwitch::GetTargetUserID(
const std::shared_ptr<TwitchToken> &token) const
{
@@ -688,6 +858,14 @@ void MacroActionTwitch::SetupTempVars()
setupTempVarHelper("content_classification_labels");
setupTempVarHelper("is_branded_content");
break;
case Action::CHANNEL_ADS_GET_INFO:
setupTempVarHelper("snooze_count", ".ads");
setupTempVarHelper("snooze_refresh_at", ".ads");
setupTempVarHelper("next_ad_at", ".ads");
setupTempVarHelper("duration", ".ads");
setupTempVarHelper("last_ad_at", ".ads");
setupTempVarHelper("preroll_free_time", ".ads");
break;
default:
break;
}
@@ -774,6 +952,12 @@ bool MacroActionTwitch::PerformAction()
}
break;
}
case Action::POLL_START:
StartPoll(token);
break;
case Action::POLL_END:
EndPoll(token);
break;
case Action::SHIELD_MODE_START:
case Action::SHIELD_MODE_END: {
const auto id = token->GetUserID();
@@ -1054,6 +1238,9 @@ bool MacroActionTwitch::PerformAction()
case MacroActionTwitch::Action::CHANNEL_GET_INFO:
GetChannelInfo(token);
break;
case MacroActionTwitch::Action::CHANNEL_ADS_GET_INFO:
GetAdsInfo(token);
break;
default:
break;
}
@@ -1100,6 +1287,10 @@ bool MacroActionTwitch::Save(obs_data_t *obj) const
_userLogin.Save(obj, "userLogin");
_userId.Save(obj, "userId");
_banReason.Save(obj, "banReason");
_pollTitle.Save(obj, "pollTitle");
_pollChoices.Save(obj, "pollChoices");
obs_data_set_int(obj, "pollEndStatus",
static_cast<int>(_pollEndStatus));
_pointsReward.Save(obj);
obs_data_set_string(obj, "rewardVariable",
GetWeakVariableName(_rewardVariable).c_str());
@@ -1138,6 +1329,10 @@ bool MacroActionTwitch::Load(obs_data_t *obj)
_userLogin.Load(obj, "userLogin");
_userId.Load(obj, "userId");
_banReason.Load(obj, "banReason");
_pollTitle.Load(obj, "pollTitle");
_pollChoices.Load(obj, "pollChoices");
_pollEndStatus = static_cast<PollEndStatus>(
obs_data_get_int(obj, "pollEndStatus"));
_pointsReward.Load(obj);
_rewardVariable = GetWeakVariableByName(
obs_data_get_string(obj, "rewardVariable"));
@@ -1180,6 +1375,7 @@ bool MacroActionTwitch::ActionIsSupportedByToken()
{Action::RAID_END, {{"channel:manage:raids"}}},
{Action::SHOUTOUT_SEND,
{{"moderator:manage:shoutouts"}}},
{Action::POLL_START, {{"channel:manage:polls"}}},
{Action::POLL_END, {{"channel:manage:polls"}}},
{Action::PREDICTION_END,
{{"channel:manage:predictions"}}},
@@ -1273,6 +1469,7 @@ bool MacroActionTwitch::ActionIsSupportedByToken()
{Action::SEND_CHAT_MESSAGE, {{"chat:edit"}}},
{Action::USER_GET_INFO, {}},
{Action::CHANNEL_GET_INFO, {}},
{Action::CHANNEL_ADS_GET_INFO, {{"channel:read:ads"}}},
{Action::POINTS_REWARD_GET_INFO,
{{"channel:read:redemptions"},
{"channel:manage:redemptions"}}}};
@@ -1361,7 +1558,10 @@ MacroActionTwitchEdit::MacroActionTwitchEdit(
_layout2(new QHBoxLayout()),
_pointsReward(new TwitchPointsRewardWidget(this, false)),
_rewardVariable(new VariableSelection(this)),
_toggleRewardSelection(new QPushButton())
_toggleRewardSelection(new QPushButton()),
_pollTitle(new VariableLineEdit(this)),
_pollChoices(new VariableTextEdit(this)),
_pollEndStatus(new QComboBox(this))
{
_layout2->setContentsMargins(0, 0, 0, 0);
_userModerationRow->setLayout(_layout2);
@@ -1377,6 +1577,8 @@ MacroActionTwitchEdit::MacroActionTwitchEdit(
mainLayout->addWidget(_userModerationRow);
mainLayout->addWidget(_announcementMessage);
mainLayout->addWidget(_chatMessage);
mainLayout->addWidget(_pollTitle);
mainLayout->addWidget(_pollChoices);
mainLayout->addWidget(_tags);
mainLayout->addWidget(_language);
mainLayout->addWidget(_contentClassification);
@@ -1546,9 +1748,24 @@ void MacroActionTwitchEdit::SetWidgetProperties()
_announcementMessage->setMaxLength(500);
auto durationSpinBox = _duration->SpinBox();
durationSpinBox->setMaximum(180);
durationSpinBox->setMaximum(1800);
durationSpinBox->setSuffix("s");
_pollTitle->setSizePolicy(QSizePolicy::MinimumExpanding,
QSizePolicy::Preferred);
_pollTitle->setMaxLength(60);
_pollChoices->setToolTip(
obs_module_text("AdvSceneSwitcher.action.twitch.poll.choices"));
_pollEndStatus->addItem(
obs_module_text(
"AdvSceneSwitcher.action.twitch.poll.end.terminated"),
static_cast<int>(MacroActionTwitch::PollEndStatus::TERMINATED));
_pollEndStatus->addItem(
obs_module_text(
"AdvSceneSwitcher.action.twitch.poll.end.archived"),
static_cast<int>(MacroActionTwitch::PollEndStatus::ARCHIVED));
populateActionSelection(_actions);
populateAnnouncementColorSelection(_announcementColor);
populateUserQueryInfoTypeSelection(_userInfoQueryType);
@@ -1626,6 +1843,12 @@ void MacroActionTwitchEdit::SetWidgetSignalConnections()
this, SLOT(UserIdChanged(const NumberVariable<double> &)));
QWidget::connect(_banReason, SIGNAL(editingFinished()), this,
SLOT(BanReasonChanged()));
QWidget::connect(_pollTitle, SIGNAL(editingFinished()), this,
SLOT(PollTitleChanged()));
QWidget::connect(_pollChoices, SIGNAL(textChanged()), this,
SLOT(PollChoicesChanged()));
QWidget::connect(_pollEndStatus, SIGNAL(currentIndexChanged(int)), this,
SLOT(PollEndStatusChanged(int)));
QWidget::connect(
_pointsReward,
SIGNAL(PointsRewardChanged(const TwitchPointsReward &)), this,
@@ -1660,22 +1883,33 @@ void MacroActionTwitchEdit::SetWidgetVisibility()
action == MacroActionTwitch::Action::USER_VIP_DELETE;
_channel->setVisible(
action == MacroActionTwitch::Action::CHANNEL_GET_INFO ||
action == MacroActionTwitch::Action::CHANNEL_ADS_GET_INFO ||
action == MacroActionTwitch::Action::RAID_START ||
action == MacroActionTwitch::Action::RAID_END ||
action == MacroActionTwitch::Action::SHOUTOUT_SEND ||
action == MacroActionTwitch::Action::SEND_CHAT_MESSAGE ||
action == MacroActionTwitch::Action::POINTS_REWARD_GET_INFO ||
action == MacroActionTwitch::Action::POLL_START ||
action == MacroActionTwitch::Action::POLL_END ||
isChannelModAction);
_duration->setVisible(
action == MacroActionTwitch::Action::COMMERCIAL_START ||
action == MacroActionTwitch::Action::USER_BAN);
action == MacroActionTwitch::Action::USER_BAN ||
action == MacroActionTwitch::Action::POLL_START);
_pollTitle->setVisible(action == MacroActionTwitch::Action::POLL_START);
_pollChoices->setVisible(action ==
MacroActionTwitch::Action::POLL_START);
_pollEndStatus->setVisible(action ==
MacroActionTwitch::Action::POLL_END);
_banReason->setVisible(action == MacroActionTwitch::Action::USER_BAN);
_userModerationRow->setVisible(
isChannelModAction ||
action == MacroActionTwitch::Action::USER_GET_INFO ||
action == MacroActionTwitch::Action::USER_BLOCK ||
action == MacroActionTwitch::Action::USER_UNBLOCK ||
action == MacroActionTwitch::Action::POINTS_REWARD_GET_INFO);
action == MacroActionTwitch::Action::POINTS_REWARD_GET_INFO ||
action == MacroActionTwitch::Action::POLL_START ||
action == MacroActionTwitch::Action::POLL_END);
_markerDescription->setVisible(
action == MacroActionTwitch::Action::MARKER_CREATE);
_clipHasDelay->setVisible(action ==
@@ -1787,6 +2021,28 @@ void MacroActionTwitchEdit::BanReasonChanged()
_entryData->_banReason = _banReason->text().toStdString();
}
void MacroActionTwitchEdit::PollTitleChanged()
{
GUARD_LOADING_AND_LOCK();
_entryData->_pollTitle = _pollTitle->text().toStdString();
}
void MacroActionTwitchEdit::PollChoicesChanged()
{
GUARD_LOADING_AND_LOCK();
_entryData->_pollChoices = _pollChoices->toPlainText().toStdString();
adjustSize();
updateGeometry();
}
void MacroActionTwitchEdit::PollEndStatusChanged(int idx)
{
GUARD_LOADING_AND_LOCK();
_entryData->_pollEndStatus =
static_cast<MacroActionTwitch::PollEndStatus>(
_pollEndStatus->itemData(idx).toInt());
}
void MacroActionTwitchEdit::PointsRewardChanged(const TwitchPointsReward &reward)
{
GUARD_LOADING_AND_LOCK();
@@ -1811,7 +2067,8 @@ void MacroActionTwitchEdit::SetWidgetLayout()
_banReason,
_pointsReward,
_rewardVariable,
_toggleRewardSelection};
_toggleRewardSelection,
_pollEndStatus};
for (auto widget : widgets) {
_layout->removeWidget(widget);
_layout2->removeWidget(widget);
@@ -1836,7 +2093,8 @@ void MacroActionTwitchEdit::SetWidgetLayout()
{"{{banReason}}", _banReason},
{"{{pointsReward}}", _pointsReward},
{"{{rewardVariable}}", _rewardVariable},
{"{{toggleRewardSelection}}", _toggleRewardSelection}};
{"{{toggleRewardSelection}}", _toggleRewardSelection},
{"{{pollEndStatus}}", _pollEndStatus}};
const char *layoutText;
const char *layout2Text = nullptr;
@@ -1879,9 +2137,22 @@ void MacroActionTwitchEdit::SetWidgetLayout()
"AdvSceneSwitcher.action.twitch.layout.reward.getInfo.row2");
break;
case MacroActionTwitch::Action::CHANNEL_GET_INFO:
case MacroActionTwitch::Action::CHANNEL_ADS_GET_INFO:
layoutText = obs_module_text(
"AdvSceneSwitcher.action.twitch.layout.channel.getInfo");
break;
case MacroActionTwitch::Action::POLL_START:
layoutText = obs_module_text(
"AdvSceneSwitcher.action.twitch.layout.poll.start.row1");
layout2Text = obs_module_text(
"AdvSceneSwitcher.action.twitch.layout.poll.start.row2");
break;
case MacroActionTwitch::Action::POLL_END:
layoutText = obs_module_text(
"AdvSceneSwitcher.action.twitch.layout.poll.end.row1");
layout2Text = obs_module_text(
"AdvSceneSwitcher.action.twitch.layout.poll.end.row2");
break;
default:
layoutText = obs_module_text(
"AdvSceneSwitcher.action.twitch.layout.default");
@@ -1898,6 +2169,7 @@ void MacroActionTwitchEdit::SetWidgetLayout()
const bool showVariableMapping =
action == MacroActionTwitch::Action::CHANNEL_GET_INFO ||
action == MacroActionTwitch::Action::CHANNEL_ADS_GET_INFO ||
action == MacroActionTwitch::Action::USER_GET_INFO ||
action == MacroActionTwitch::Action::POINTS_REWARD_GET_INFO;
emit ShowVariableMappings(showVariableMapping);
@@ -1938,6 +2210,10 @@ void MacroActionTwitchEdit::UpdateEntryData()
_userLogin->setText(_entryData->_userLogin);
_userId->SetValue(_entryData->_userId);
_banReason->setText(_entryData->_banReason);
_pollTitle->setText(_entryData->_pollTitle);
_pollChoices->setPlainText(_entryData->_pollChoices);
_pollEndStatus->setCurrentIndex(_pollEndStatus->findData(
static_cast<int>(_entryData->_pollEndStatus)));
_pointsReward->SetToken(_entryData->_token);
_pointsReward->SetChannel(_entryData->_channel);
_pointsReward->SetPointsReward(_entryData->_pointsReward);

View File

@@ -44,7 +44,7 @@ public:
SHOUTOUT_SEND = 200,
// Poll
POLL_START = 300, // TODO
POLL_START = 300,
POLL_END = 310,
// Prediction
@@ -142,9 +142,15 @@ public:
SEND_CHAT_MESSAGE = 5000,
// Get user info
// Get info
USER_GET_INFO = 6000,
CHANNEL_GET_INFO = 6100,
CHANNEL_ADS_GET_INFO = 6200,
};
enum class PollEndStatus {
TERMINATED,
ARCHIVED,
};
enum class AnnouncementColor {
@@ -193,6 +199,9 @@ public:
TwitchPointsReward _pointsReward;
std::weak_ptr<Variable> _rewardVariable;
bool _useVariableForRewardSelection = false;
StringVariable _pollTitle = "Poll question";
StringVariable _pollChoices = "Choice 1\nChoice 2";
PollEndStatus _pollEndStatus = PollEndStatus::TERMINATED;
private:
void SetStreamTitle(const std::shared_ptr<TwitchToken> &) const;
@@ -202,10 +211,13 @@ private:
void StartCommercial(const std::shared_ptr<TwitchToken> &) const;
void SendChatAnnouncement(const std::shared_ptr<TwitchToken> &) const;
void StartRaid(const std::shared_ptr<TwitchToken> &);
void StartPoll(const std::shared_ptr<TwitchToken> &) const;
void EndPoll(const std::shared_ptr<TwitchToken> &) const;
void SendChatMessage(const std::shared_ptr<TwitchToken> &);
void GetUserInfo(const std::shared_ptr<TwitchToken> &);
void GetRewardInfo(const std::shared_ptr<TwitchToken> &);
void GetChannelInfo(const std::shared_ptr<TwitchToken> &token);
void GetChannelInfo(const std::shared_ptr<TwitchToken> &);
void GetAdsInfo(const std::shared_ptr<TwitchToken> &);
std::optional<std::string>
GetTargetUserID(const std::shared_ptr<TwitchToken> &) const;
@@ -261,6 +273,9 @@ private slots:
void UserLoginChanged();
void UserIdChanged(const NumberVariable<double> &);
void BanReasonChanged();
void PollTitleChanged();
void PollChoicesChanged();
void PollEndStatusChanged(int);
void PointsRewardChanged(const TwitchPointsReward &);
void RewardVariableChanged(const QString &);
void ToggleRewardSelection(bool);
@@ -305,6 +320,9 @@ private:
// we use QDoubleSpinBox instead
VariableDoubleSpinBox *_userId;
VariableLineEdit *_banReason;
VariableLineEdit *_pollTitle;
VariableTextEdit *_pollChoices;
QComboBox *_pollEndStatus;
TwitchPointsRewardWidget *_pointsReward;
VariableSelection *_rewardVariable;
QPushButton *_toggleRewardSelection;

View File

@@ -317,6 +317,21 @@ bool MacroConditionTwitch::CheckChannelLiveEvents()
return false;
}
bool MacroConditionTwitch::CheckChannelSubscriptionMessageEvent()
{
return HandleMatchingSubscriptionEvents([this](const Event &event) {
SetVariableValue(event.ToString());
SetJsonTempVars(event.data,
[this](const char *id, const char *value) {
SetTempVarValue(id, value);
});
OBSDataAutoRelease message =
obs_data_get_obj(event.data, "message");
SetTempVarValue("message_text",
obs_data_get_string(message, "text"));
});
}
bool MacroConditionTwitch::CheckChannelRewardChangeEvents()
{
return HandleMatchingSubscriptionEvents([this](const Event &event) {
@@ -547,16 +562,18 @@ bool MacroConditionTwitch::CheckChatMessageRemove(TwitchToken &token)
bool MacroConditionTwitch::ChatConnectionIsSetup(TwitchToken &token)
{
if (_chatConnection) {
_chatConnection->ConnectToChat();
return true;
}
_chatConnection =
TwitchChatConnection::GetChatConnection(token, _channel);
if (!_chatConnection) {
_chatConnection = TwitchChatConnection::GetChatConnection(
token, _channel);
if (!_chatConnection) {
return false;
}
_chatBuffer = _chatConnection->RegisterForMessages();
return false;
}
return true;
_chatBuffer = _chatConnection->RegisterForMessages();
return false;
}
bool MacroConditionTwitch::HandleChatEvents(
@@ -687,7 +704,6 @@ bool MacroConditionTwitch::CheckCondition()
case Condition::SUBSCRIPTION_START_EVENT:
case Condition::SUBSCRIPTION_END_EVENT:
case Condition::SUBSCRIPTION_GIFT_EVENT:
case Condition::SUBSCRIPTION_MESSAGE_EVENT:
case Condition::CHEER_EVENT:
case Condition::RAID_OUTBOUND_EVENT:
case Condition::RAID_INBOUND_EVENT:
@@ -718,6 +734,8 @@ bool MacroConditionTwitch::CheckCondition()
case Condition::USER_MODERATOR_ADDITION_EVENT:
case Condition::USER_MODERATOR_DELETION_EVENT:
return CheckChannelGenericEvents();
case Condition::SUBSCRIPTION_MESSAGE_EVENT:
return CheckChannelSubscriptionMessageEvent();
case Condition::POINTS_REWARD_ADDITION_EVENT:
case Condition::POINTS_REWARD_UPDATE_EVENT:
case Condition::POINTS_REWARD_DELETION_EVENT:
@@ -1205,6 +1223,7 @@ void MacroConditionTwitch::SetupTempVars()
setupTempVarHelper("user_name", ".subscribe.message");
setupTempVarHelper("tier", ".subscribe");
setupTempVarHelper("message", ".subscribe");
setupTempVarHelper("message_text", ".subscribe");
setupTempVarHelper("cumulative_months", ".subscribe");
setupTempVarHelper("streak_months", ".subscribe");
setupTempVarHelper("duration_months", ".subscribe");

View File

@@ -114,6 +114,7 @@ public:
private:
bool CheckChannelGenericEvents();
bool CheckChannelLiveEvents();
bool CheckChannelSubscriptionMessageEvent();
bool CheckChannelRewardChangeEvents();
bool CheckChannelRewardRedemptionEvents();
bool HandleMatchingSubscriptionEvents(

View File

@@ -652,9 +652,6 @@ TwitchTokenSettingsDialog::TwitchTokenSettingsDialog(
}
_validateTimestamps->setChecked(settings._validateEventSubTimestamps);
#ifndef VERIFY_TIMESTAMPS
_validateTimestamps->hide();
#endif
_warnIfInvalid->setChecked(settings._warnIfInvalid);
_currentToken = settings;

View File

@@ -0,0 +1,86 @@
#include "twitch-timestamp.hpp"
#include <log-helper.hpp>
#include <chrono>
#include <ctime>
#include <regex>
#include <string>
using namespace std::chrono_literals;
namespace advss {
bool IsValidEventSubTimestamp(const std::string &timestamp)
{
// Example input: 2023-07-19T14:56:51.634234626Z
// or: 2023-07-19T14:56:51+05:30
static const std::regex pattern(
R"(^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(Z|[+-]\d{2}:\d{2})$)");
std::smatch m;
if (!std::regex_match(timestamp, m, pattern)) {
blog(LOG_WARNING, "failed to parse timestamp %s",
timestamp.c_str());
return false;
}
std::tm tm = {};
tm.tm_year = std::stoi(m[1]) - 1900;
tm.tm_mon = std::stoi(m[2]) - 1;
tm.tm_mday = std::stoi(m[3]);
tm.tm_hour = std::stoi(m[4]);
tm.tm_min = std::stoi(m[5]);
tm.tm_sec = std::stoi(m[6]);
// Range checks; timegm/_mkgmtime silently normalize out-of-range
// fields instead of failing, so we need to catch that ourselves.
if (tm.tm_mon < 0 || tm.tm_mon > 11 || tm.tm_mday < 1 ||
tm.tm_mday > 31 || tm.tm_hour > 23 || tm.tm_min > 59 ||
tm.tm_sec > 60 /* allow leap second */) {
blog(LOG_WARNING, "timestamp field out of range %s",
timestamp.c_str());
return false;
}
#ifdef _WIN32
time_t t = _mkgmtime(&tm);
#else
time_t t = timegm(&tm);
#endif
if (t == -1) {
blog(LOG_WARNING, "failed to convert timestamp %s",
timestamp.c_str());
return false;
}
// Reject dates that normalized to something else (e.g. Feb 30 -> Mar 2)
if (tm.tm_mday != std::stoi(m[3]) || tm.tm_mon != std::stoi(m[2]) - 1) {
blog(LOG_WARNING,
"timestamp date invalid after normalization %s",
timestamp.c_str());
return false;
}
const std::string &tz = m[7];
if (tz != "Z") {
int offSign = (tz[0] == '+') ? 1 : -1;
int offH = std::stoi(tz.substr(1, 2));
int offM = std::stoi(tz.substr(4, 2));
if (offH > 23 || offM > 59) {
blog(LOG_WARNING, "invalid timestamp offset %s",
timestamp.c_str());
return false;
}
time_t offsetSecs = (offH * 60 + offM) * 60;
t += (offSign > 0) ? -offsetSecs : offsetSecs;
}
auto parsedTime = std::chrono::system_clock::from_time_t(t);
auto now = std::chrono::system_clock::now();
auto duration = now - parsedTime;
// Clocks might be off by a bit, so allow negative values also
return duration <= 10min && duration >= -1min;
}
} // namespace advss

View File

@@ -0,0 +1,8 @@
#pragma once
#include <string>
namespace advss {
bool IsValidEventSubTimestamp(const std::string &timestamp);
} // namespace advss

View File

@@ -5,7 +5,6 @@
#include <macro-condition-edit.hpp>
#include <plugin-state-helpers.hpp>
#include <QBuffer>
#include <QColorDialog>
#include <QDesktopServices>
#include <QFileDialog>
#include <QMessageBox>
@@ -403,12 +402,14 @@ bool MacroConditionVideo::CheckBrightnessThreshold()
bool MacroConditionVideo::CheckOCR()
{
if (!_ocrParameters.Initialized()) {
auto *ocr = _ocrParameters.GetOCR();
if (!ocr) {
return false;
}
auto text = RunOCR(_ocrParameters.GetOCR(), _screenshotData.GetImage(),
_ocrParameters.color, _ocrParameters.colorThreshold);
auto text = RunOCR(ocr, _screenshotData.GetImage(),
_ocrParameters.color.GetValue(),
_ocrParameters.colorThreshold);
if (!text) {
return false;
}
@@ -424,7 +425,7 @@ bool MacroConditionVideo::CheckOCR()
bool MacroConditionVideo::CheckColor()
{
const bool ret = ContainsPixelsInColorRange(
_screenshotData.GetImage(), _colorParameters.color,
_screenshotData.GetImage(), _colorParameters.color.GetValue(),
_colorParameters.colorThreshold,
_colorParameters.matchThreshold);
@@ -676,9 +677,10 @@ OCREdit::OCREdit(QWidget *parent, PreviewDialog *previewDialog,
: QWidget(parent),
_matchText(new VariableTextEdit(this)),
_regex(new RegexConfigWidget(this)),
_textColor(new QLabel),
_selectColor(new QPushButton(obs_module_text(
"AdvSceneSwitcher.condition.video.selectColor"))),
_colorButton(new VariableColorButton(
this,
obs_module_text(
"AdvSceneSwitcher.condition.video.selectColor"))),
_colorThreshold(new SliderSpinBox(
0., 1.,
obs_module_text(
@@ -708,8 +710,9 @@ OCREdit::OCREdit(QWidget *parent, PreviewDialog *previewDialog,
_reloadConfig->setToolTip(obs_module_text(
"AdvSceneSwitcher.condition.video.ocrConfigReload"));
QWidget::connect(_selectColor, SIGNAL(clicked()), this,
SLOT(SelectColorClicked()));
QWidget::connect(_colorButton,
SIGNAL(ColorVariableChanged(const ColorVariable &)),
this, SLOT(ColorChanged(const ColorVariable &)));
QWidget::connect(
_colorThreshold,
SIGNAL(DoubleValueChanged(const NumberVariable<double> &)),
@@ -753,8 +756,7 @@ OCREdit::OCREdit(QWidget *parent, PreviewDialog *previewDialog,
"AdvSceneSwitcher.condition.video.ocrConfigHint"));
const std::unordered_map<std::string, QWidget *> widgetPlaceholders = {
{"{{textColor}}", _textColor},
{"{{selectColor}}", _selectColor},
{"{{color}}", _colorButton},
{"{{textType}}", _pageSegMode},
{"{{tesseractBaseDir}}", _tesseractBaseDir},
{"{{languageCode}}", _languageCode},
@@ -806,7 +808,7 @@ OCREdit::OCREdit(QWidget *parent, PreviewDialog *previewDialog,
_matchText->setPlainText(_entryData->_ocrParameters.text);
_regex->SetRegexConfig(_entryData->_ocrParameters.regex);
SetupColorLabel(_entryData->_ocrParameters.color);
_colorButton->SetValue(_entryData->_ocrParameters.color);
_colorThreshold->SetDoubleValue(
_entryData->_ocrParameters.colorThreshold);
_pageSegMode->setCurrentIndex(_pageSegMode->findData(
@@ -822,31 +824,10 @@ OCREdit::OCREdit(QWidget *parent, PreviewDialog *previewDialog,
_loading = false;
}
void OCREdit::SetupColorLabel(const QColor &color)
void OCREdit::ColorChanged(const ColorVariable &value)
{
_textColor->setText(color.name());
_textColor->setPalette(QPalette(color));
_textColor->setAutoFillBackground(true);
}
void OCREdit::SelectColorClicked()
{
if (_loading || !_entryData) {
return;
}
const QColor color = QColorDialog::getColor(
_entryData->_ocrParameters.color, this,
obs_module_text("AdvSceneSwitcher.condition.video.selectColor"),
QColorDialog::ColorDialogOption());
if (!color.isValid()) {
return;
}
SetupColorLabel(color);
auto lock = LockContext();
_entryData->_ocrParameters.color = color;
GUARD_LOADING_AND_LOCK();
_entryData->_ocrParameters.color = value;
_previewDialog->OCRParametersChanged(_entryData->_ocrParameters);
}
@@ -1105,13 +1086,15 @@ ColorEdit::ColorEdit(QWidget *parent,
obs_module_text(
"AdvSceneSwitcher.condition.video.colorDeviationThresholdDescription"),
true)),
_color(new QLabel),
_selectColor(new QPushButton(obs_module_text(
"AdvSceneSwitcher.condition.video.selectColor"))),
_colorButton(new VariableColorButton(
this,
obs_module_text(
"AdvSceneSwitcher.condition.video.selectColor"))),
_entryData(data)
{
QWidget::connect(_selectColor, SIGNAL(clicked()), this,
SLOT(SelectColorClicked()));
QWidget::connect(_colorButton,
SIGNAL(ColorVariableChanged(const ColorVariable &)),
this, SLOT(ColorChanged(const ColorVariable &)));
QWidget::connect(
_matchThreshold,
SIGNAL(DoubleValueChanged(const NumberVariable<double> &)),
@@ -1124,8 +1107,7 @@ ColorEdit::ColorEdit(QWidget *parent,
SLOT(ColorThresholdChanged(const NumberVariable<double> &)));
std::unordered_map<std::string, QWidget *> widgetPlaceholders = {
{"{{color}}", _color},
{"{{selectColor}}", _selectColor},
{"{{color}}", _colorButton},
};
auto colorLayout = new QHBoxLayout;
@@ -1144,15 +1126,14 @@ ColorEdit::ColorEdit(QWidget *parent,
_entryData->_colorParameters.matchThreshold);
_colorThreshold->SetDoubleValue(
_entryData->_colorParameters.colorThreshold);
SetupColorLabel(_entryData->_colorParameters.color);
_colorButton->SetValue(_entryData->_colorParameters.color);
_loading = false;
}
void ColorEdit::SetupColorLabel(const QColor &color)
void ColorEdit::ColorChanged(const ColorVariable &value)
{
_color->setText(color.name());
_color->setPalette(QPalette(color));
_color->setAutoFillBackground(true);
GUARD_LOADING_AND_LOCK();
_entryData->_colorParameters.color = value;
}
void ColorEdit::MatchThresholdChanged(const DoubleVariable &value)
@@ -1167,26 +1148,6 @@ void ColorEdit::ColorThresholdChanged(const DoubleVariable &value)
_entryData->_colorParameters.colorThreshold = value;
}
void ColorEdit::SelectColorClicked()
{
if (_loading || !_entryData) {
return;
}
const QColor color = QColorDialog::getColor(
_entryData->_colorParameters.color, this,
obs_module_text("AdvSceneSwitcher.condition.video.selectColor"),
QColorDialog::ColorDialogOption());
if (!color.isValid()) {
return;
}
SetupColorLabel(color);
auto lock = LockContext();
_entryData->_colorParameters.color = color;
}
AreaEdit::AreaEdit(QWidget *parent, PreviewDialog *previewDialog,
const std::shared_ptr<MacroConditionVideo> &data)
: QWidget(parent),

View File

@@ -10,6 +10,7 @@
#include <screenshot-helper.hpp>
#include <slider-spinbox.hpp>
#include <source-helpers.hpp>
#include <variable-color-button.hpp>
#include <variable-line-edit.hpp>
#include <variable-text-edit.hpp>
@@ -143,7 +144,7 @@ public:
const std::shared_ptr<MacroConditionVideo> &);
private slots:
void SelectColorClicked();
void ColorChanged(const ColorVariable &);
void ColorThresholdChanged(const NumberVariable<double> &);
void MatchTextChanged();
void RegexChanged(const RegexConfig &conf);
@@ -154,12 +155,9 @@ private slots:
void ConfigFileChanged(const QString &);
private:
void SetupColorLabel(const QColor &);
VariableTextEdit *_matchText;
RegexConfigWidget *_regex;
QLabel *_textColor;
QPushButton *_selectColor;
VariableColorButton *_colorButton;
SliderSpinBox *_colorThreshold;
QComboBox *_pageSegMode;
FileSelection *_tesseractBaseDir;
@@ -212,17 +210,14 @@ public:
const std::shared_ptr<MacroConditionVideo> &);
private slots:
void SelectColorClicked();
void ColorChanged(const ColorVariable &);
void MatchThresholdChanged(const NumberVariable<double> &);
void ColorThresholdChanged(const NumberVariable<double> &);
private:
void SetupColorLabel(const QColor &);
SliderSpinBox *_matchThreshold;
SliderSpinBox *_colorThreshold;
QLabel *_color;
QPushButton *_selectColor;
VariableColorButton *_colorButton;
std::shared_ptr<MacroConditionVideo> _entryData;
bool _loading = true;

View File

@@ -82,7 +82,7 @@ bool ObjDetectParameters::Save(obs_data_t *obj) const
minSize.Save(data, "minSize");
maxSize.Save(data, "maxSize");
obs_data_set_obj(obj, "objectMatchData", data);
obs_data_set_int(data, "version", 1);
obs_data_set_int(data, "version", 2);
obs_data_release(data);
return true;
}
@@ -121,6 +121,21 @@ bool ObjDetectParameters::Load(obs_data_t *obj)
// TODO: Remove this fallback in a future version
if (!obs_data_has_user_value(data, "version")) {
scaleFactor = obs_data_get_double(data, "scaleFactor");
} else if (obs_data_get_int(data, "version") == 1) {
#ifdef _WIN32
// On Windows the plugin directory moved from
// "Program Files/.../data/obs-plugins/advanced-scene-switcher"
// to "ProgramData/.../plugins/advanced-scene-switcher/data",
// which invalidates previously saved default model paths.
const std::string oldPrefix =
"../../data/obs-plugins/advanced-scene-switcher/res/cascadeClassifiers/";
if (modelPath.substr(0, oldPrefix.size()) == oldPrefix) {
modelPath = std::string(obs_get_module_data_path(
obs_current_module())) +
"/res/cascadeClassifiers/" +
modelPath.substr(oldPrefix.size());
}
#endif
}
if (scaleFactor.IsFixedType() && !isScaleFactorValid(scaleFactor)) {
scaleFactor = 1.1;
@@ -258,27 +273,6 @@ OBSWeakSource VideoInput::GetVideo() const
return nullptr;
}
static void SaveColor(obs_data_t *obj, const char *name, const QColor &color)
{
auto data = obs_data_create();
obs_data_set_int(data, "red", color.red());
obs_data_set_int(data, "green", color.green());
obs_data_set_int(data, "blue", color.blue());
obs_data_set_obj(obj, name, data);
obs_data_release(data);
}
static QColor LoadColor(obs_data_t *obj, const char *name)
{
QColor color = Qt::black;
auto data = obs_data_get_obj(obj, name);
color.setRed(obs_data_get_int(data, "red"));
color.setGreen(obs_data_get_int(data, "green"));
color.setBlue(obs_data_get_int(data, "blue"));
obs_data_release(data);
return color;
}
OCRParameters::OCRParameters()
{
Setup();
@@ -303,9 +297,6 @@ OCRParameters::OCRParameters(const OCRParameters &other)
useConfig(other.useConfig),
configFile(other.configFile)
{
if (!initDone) {
Setup();
}
}
OCRParameters &OCRParameters::operator=(const OCRParameters &other)
@@ -319,9 +310,7 @@ OCRParameters &OCRParameters::operator=(const OCRParameters &other)
languageCode = other.languageCode;
useConfig = other.useConfig;
configFile = other.configFile;
if (!initDone) {
Setup();
}
initDone = false;
return *this;
}
@@ -334,10 +323,10 @@ bool OCRParameters::Save(obs_data_t *obj) const
languageCode.Save(data, "language");
obs_data_set_bool(data, "useConfig", useConfig);
obs_data_set_string(data, "configFile", configFile.c_str());
SaveColor(data, "textColor", color);
color.Save(data, "textColor");
colorThreshold.Save(data, "colorThreshold");
obs_data_set_int(data, "pageSegMode", static_cast<int>(pageSegMode));
obs_data_set_int(data, "version", 2);
obs_data_set_int(data, "version", 3);
obs_data_set_obj(obj, "ocrData", data);
obs_data_release(data);
return true;
@@ -356,11 +345,28 @@ bool OCRParameters::Load(obs_data_t *obj)
tesseractBasePath = std::string(obs_get_module_data_path(
obs_current_module())) +
"/res/ocr/";
} else if (obs_data_get_int(data, "version") == 2) {
#ifdef _WIN32
// On Windows the plugin directory moved from
// "Program Files/.../data/obs-plugins/advanced-scene-switcher"
// to "ProgramData/.../plugins/advanced-scene-switcher/data",
// which invalidates the previously saved default OCR path.
const std::string oldPath = tesseractBasePath;
if (oldPath ==
"../../data/obs-plugins/advanced-scene-switcher/res/ocr/" ||
oldPath ==
"../../data/obs-plugins/advanced-scene-switcher/res/ocr") {
tesseractBasePath =
std::string(obs_get_module_data_path(
obs_current_module())) +
"/res/ocr/";
}
#endif
}
useConfig = obs_data_get_bool(data, "useConfig");
configFile = obs_data_get_string(data, "configFile");
color = LoadColor(data, "textColor");
color.Load(data, "textColor");
if (obs_data_has_user_value(data, "version")) {
colorThreshold.Load(data, "colorThreshold");
}
@@ -380,6 +386,14 @@ void OCRParameters::SetPageMode(tesseract::PageSegMode mode)
}
}
tesseract::TessBaseAPI *OCRParameters::GetOCR()
{
if (!initDone) {
Setup();
}
return initDone ? ocr.get() : nullptr;
}
bool OCRParameters::SetLanguageCode(const std::string &value)
{
const auto dataPath = QString::fromStdString(tesseractBasePath) + "/" +
@@ -389,7 +403,7 @@ bool OCRParameters::SetLanguageCode(const std::string &value)
return false;
}
languageCode = value;
Setup();
initDone = false;
return true;
}
@@ -408,7 +422,7 @@ bool OCRParameters::SetTesseractBasePath(const std::string &value)
return false;
}
tesseractBasePath = value;
Setup();
initDone = false;
return true;
}
@@ -420,13 +434,13 @@ std::string OCRParameters::GetTesseractBasePath() const
void OCRParameters::EnableCustomConfig(bool enable)
{
useConfig = enable;
Setup();
initDone = false;
}
void OCRParameters::SetCustomConfigFile(const std::string &filename)
{
configFile = filename;
Setup();
initDone = false;
}
void OCRParameters::Setup()
@@ -481,7 +495,7 @@ void OCRParameters::Setup()
bool ColorParameters::Save(obs_data_t *obj) const
{
auto data = obs_data_create();
SaveColor(data, "color", color);
color.Save(data, "color");
colorThreshold.Save(data, "colorThreshold");
matchThreshold.Save(data, "matchThreshold");
obs_data_set_obj(obj, "colorData", data);
@@ -492,7 +506,7 @@ bool ColorParameters::Save(obs_data_t *obj) const
bool ColorParameters::Load(obs_data_t *obj)
{
auto data = obs_data_get_obj(obj, "colorData");
color = LoadColor(data, "color");
color.Load(data, "color");
colorThreshold.Load(data, "colorThreshold");
matchThreshold.Load(data, "matchThreshold");
obs_data_release(data);

View File

@@ -6,6 +6,7 @@
#include <source-selection.hpp>
#include <scene-selection.hpp>
#include <regex-config.hpp>
#include <variable-color.hpp>
#include <variable-string.hpp>
#include <variable-number.hpp>
#include <obs.hpp>
@@ -108,14 +109,11 @@ public:
void SetCustomConfigFile(const std::string &);
std::string GetCustomConfigFile() const { return configFile; }
tesseract::PageSegMode GetPageMode() const { return pageSegMode; }
tesseract::TessBaseAPI *GetOCR() const
{
return initDone ? ocr.get() : nullptr;
}
tesseract::TessBaseAPI *GetOCR();
StringVariable text = obs_module_text("AdvSceneSwitcher.enterText");
RegexConfig regex = RegexConfig::PartialMatchRegexConfig();
QColor color = Qt::black;
ColorVariable color;
DoubleVariable colorThreshold = 0.3;
private:
@@ -137,7 +135,7 @@ public:
bool Save(obs_data_t *obj) const;
bool Load(obs_data_t *obj);
QColor color = Qt::black;
ColorVariable color;
DoubleVariable colorThreshold = 0.1;
DoubleVariable matchThreshold = 0.8;
};

View File

@@ -385,7 +385,8 @@ void PreviewImage::MarkOCRMatch(QImage &screenshot,
"AdvSceneSwitcher.condition.video.ocrMatchFail"));
return;
}
auto text = RunOCR(ocrParams->GetOCR(), screenshot, ocrParams->color,
auto text = RunOCR(ocrParams->GetOCR(), screenshot,
ocrParams->color.GetValue(),
ocrParams->colorThreshold);
if (!text) {

View File

@@ -202,6 +202,16 @@ target_sources(
${ADVSS_SOURCE_DIR}/plugins/base/macro-condition-window.cpp
${ADVSS_SOURCE_DIR}/plugins/base/utils/window-selection.cpp)
# --- twitch timestamp --- #
set(TWITCH_PLUGIN_DIR
"${ADVSS_SOURCE_DIR}/plugins/twitch"
CACHE INTERNAL "")
target_include_directories(${PROJECT_NAME} PRIVATE "${TWITCH_PLUGIN_DIR}")
target_sources(
${PROJECT_NAME} PRIVATE test-twitch-timestamp.cpp
"${TWITCH_PLUGIN_DIR}/twitch-timestamp.cpp")
# --- Testing --- #
enable_testing()

View File

@@ -103,3 +103,27 @@ TEST_CASE("AccessJsonArrayIndex", "[json-helpers]")
result = advss::AccessJsonArrayIndex("[\"1\", \"2\"]", 1);
REQUIRE(*result == "2");
}
TEST_CASE("ExtractSingleJsonArrayElement", "[json-helpers]")
{
auto result = advss::ExtractSingleJsonArrayElement("invalid json");
REQUIRE_FALSE(result);
result = advss::ExtractSingleJsonArrayElement("{}");
REQUIRE_FALSE(result);
result = advss::ExtractSingleJsonArrayElement("[]");
REQUIRE_FALSE(result);
result = advss::ExtractSingleJsonArrayElement("[1, 2]");
REQUIRE_FALSE(result);
result = advss::ExtractSingleJsonArrayElement("[42]");
REQUIRE(*result == "42");
result = advss::ExtractSingleJsonArrayElement("[\"hello\"]");
REQUIRE(*result == "hello");
result = advss::ExtractSingleJsonArrayElement("[{\"key\": 1}]");
REQUIRE(*result == "{\"key\":1}");
}

View File

@@ -0,0 +1,183 @@
#include "catch.hpp"
#include <twitch-timestamp.hpp>
#include <chrono>
#include <cstdio>
#include <ctime>
#include <string>
// Build an RFC3339 UTC timestamp offset by 'offsetSeconds' from now.
// A positive value means that many seconds in the past.
static std::string makeTimestamp(int offsetSeconds = 0, bool withNanos = false,
const char *tzSuffix = "Z")
{
auto now = std::chrono::system_clock::now() -
std::chrono::seconds(offsetSeconds);
time_t t = std::chrono::system_clock::to_time_t(now);
std::tm tm = {};
#ifdef _WIN32
gmtime_s(&tm, &t);
#else
gmtime_r(&t, &tm);
#endif
char buf[64];
if (withNanos) {
snprintf(buf, sizeof(buf),
"%04d-%02d-%02dT%02d:%02d:%02d.123456789%s",
tm.tm_year + 1900, tm.tm_mon + 1, tm.tm_mday,
tm.tm_hour, tm.tm_min, tm.tm_sec, tzSuffix);
} else {
snprintf(buf, sizeof(buf), "%04d-%02d-%02dT%02d:%02d:%02d%s",
tm.tm_year + 1900, tm.tm_mon + 1, tm.tm_mday,
tm.tm_hour, tm.tm_min, tm.tm_sec, tzSuffix);
}
return buf;
}
TEST_CASE("Valid timestamps are accepted", "[twitch][timestamp]")
{
SECTION("Current time with Z suffix")
{
REQUIRE(advss::IsValidEventSubTimestamp(makeTimestamp(0)));
}
SECTION("5 seconds in the past")
{
REQUIRE(advss::IsValidEventSubTimestamp(makeTimestamp(5)));
}
SECTION("9 minutes in the past (within 10-minute window)")
{
REQUIRE(advss::IsValidEventSubTimestamp(makeTimestamp(9 * 60)));
}
SECTION("Timestamp with nanoseconds and Z suffix")
{
REQUIRE(advss::IsValidEventSubTimestamp(
makeTimestamp(0, true, "Z")));
}
SECTION("Lowercase z suffix is rejected (RFC 3339 requires uppercase Z)")
{
REQUIRE_FALSE(advss::IsValidEventSubTimestamp(
makeTimestamp(0, false, "z")));
}
SECTION("+00:00 offset (UTC expressed as positive offset)")
{
REQUIRE(advss::IsValidEventSubTimestamp(
makeTimestamp(0, false, "+00:00")));
}
SECTION("-00:00 offset")
{
REQUIRE(advss::IsValidEventSubTimestamp(
makeTimestamp(0, false, "-00:00")));
}
SECTION("+05:30 offset (IST) with nanoseconds")
{
// Build a timestamp whose wall-clock value is now, expressed in
// IST (+05:30). The UTC equivalent must still be within the window.
auto now = std::chrono::system_clock::now();
time_t t = std::chrono::system_clock::to_time_t(now);
// Advance the displayed time by 5h30m to represent +05:30
t += (5 * 60 + 30) * 60;
std::tm tm = {};
#ifdef _WIN32
gmtime_s(&tm, &t);
#else
gmtime_r(&t, &tm);
#endif
char buf[64];
snprintf(buf, sizeof(buf),
"%04d-%02d-%02dT%02d:%02d:%02d.000000000+05:30",
tm.tm_year + 1900, tm.tm_mon + 1, tm.tm_mday,
tm.tm_hour, tm.tm_min, tm.tm_sec);
REQUIRE(advss::IsValidEventSubTimestamp(buf));
}
SECTION("-08:00 offset (PST)")
{
auto now = std::chrono::system_clock::now();
time_t t = std::chrono::system_clock::to_time_t(now);
t -= 8 * 3600;
std::tm tm = {};
#ifdef _WIN32
gmtime_s(&tm, &t);
#else
gmtime_r(&t, &tm);
#endif
char buf[64];
snprintf(buf, sizeof(buf),
"%04d-%02d-%02dT%02d:%02d:%02d-08:00",
tm.tm_year + 1900, tm.tm_mon + 1, tm.tm_mday,
tm.tm_hour, tm.tm_min, tm.tm_sec);
REQUIRE(advss::IsValidEventSubTimestamp(buf));
}
}
TEST_CASE("Expired timestamps are rejected", "[twitch][timestamp]")
{
SECTION("11 minutes in the past (outside 10-minute window)")
{
REQUIRE_FALSE(advss::IsValidEventSubTimestamp(
makeTimestamp(11 * 60)));
}
SECTION("1 hour in the past")
{
REQUIRE_FALSE(
advss::IsValidEventSubTimestamp(makeTimestamp(3600)));
}
SECTION("2 minutes in the future (outside -1min allowance)")
{
REQUIRE_FALSE(advss::IsValidEventSubTimestamp(
makeTimestamp(-2 * 60)));
}
}
TEST_CASE("Invalid timestamp strings are rejected", "[twitch][timestamp]")
{
SECTION("Empty string")
{
REQUIRE_FALSE(advss::IsValidEventSubTimestamp(""));
}
SECTION("Completely wrong format")
{
REQUIRE_FALSE(
advss::IsValidEventSubTimestamp("not-a-timestamp"));
}
SECTION("Missing time component")
{
REQUIRE_FALSE(advss::IsValidEventSubTimestamp("2023-07-19Z"));
}
SECTION("Missing UTC offset")
{
REQUIRE_FALSE(advss::IsValidEventSubTimestamp(
"2023-07-19T14:56:51.634234626"));
}
SECTION("Malformed offset - missing minutes")
{
REQUIRE_FALSE(advss::IsValidEventSubTimestamp(
"2023-07-19T14:56:51+05"));
}
SECTION("Malformed offset - non-numeric")
{
REQUIRE_FALSE(advss::IsValidEventSubTimestamp(
"2023-07-19T14:56:51+XX:XX"));
}
SECTION("Truncated after seconds")
{
REQUIRE_FALSE(
advss::IsValidEventSubTimestamp("2023-07-19T14:56:"));
}
}