Compare commits

..

72 Commits

Author SHA1 Message Date
WarmUpTill
ba7886c8c1 Fix variables of nested macros' actions not being resolved 2026-06-16 20:43:09 +02:00
WarmUpTill
801268d08b Fix nested macro actions being executed twice when added to a queue 2026-06-16 20:29:11 +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
WarmUpTill
d44376e869 deps: Update cpp-httplib to v0.46.0
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-05-27 11:38:26 +02:00
WarmUpTill
4b1fd9e700 Fix crash on startup
Resize of vector could invalidate the this pointers passed to the
PostLoad step lambdas
2026-05-27 11:32:30 +02:00
WarmUpTill
d61099f9c0 Fix freeze when showing setup wizzard
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-05-25 12:04:57 +02:00
WarmUpTill
c2b73a02ca Prevent plugin from being loaded twice 2026-05-25 11:49:37 +02:00
WarmUpTill
49a681bcfe Show "Macro Schedule" tab when date / timer condition is created 2026-05-25 11:49:37 +02:00
WarmUpTill
4cedb29123 Fix issues with Windows installer
Don't reuse the old installation dir since it might not be compatible
with the new layout.
Ensure previous legacy install is cleaned up to avoid plugin being
installed twice.
2026-05-25 11:49:37 +02:00
WarmUpTill
d80b3aaf69 CI: Ensure ccache is used on macOS 2026-05-25 11:49:37 +02:00
WarmUpTill
b1cc15386f Fix warnings 2026-05-25 11:49:37 +02:00
WarmUpTill
748744d1b7 Fix packaging issue on Ubuntu 2026-05-25 11:49:37 +02:00
WarmUpTill
912ff98e27 Ignore warnings generated by libremidi 2026-05-25 11:49:37 +02:00
WarmUpTill
a9614c5306 CI: Fix warnings 2026-05-25 11:49:34 +02:00
WarmUpTill
cb2f97f11d Adapt macOS fullscreen window detection to be more reliable 2026-05-25 00:22:59 +02:00
WarmUpTill
017ea233ec Fix macOS fullscreen window detection across "Spaces" 2026-05-25 00:22:59 +02:00
WarmUpTill
e8206173ff Tests: Add test-macro-condition-window.cpp 2026-05-25 00:22:59 +02:00
WarmUpTill
680f72e4f9 Rework temp var mapping handling on copy of macro segments 2026-05-25 00:22:59 +02:00
WarmUpTill
930e8b38d4 Refactor window platform helper functions 2026-05-25 00:22:59 +02:00
WarmUpTill
ee1d0136d9 Add temp vars for window position and size 2026-05-25 00:22:59 +02:00
WarmUpTill
20c276f4ea CI: Fix occasional formatting check failures 2026-05-25 00:22:59 +02:00
WarmUpTill
bea4f66eb1 Fix crash when accessing files in profile dir 2026-05-25 00:22:59 +02:00
WarmUpTill
c5185765ee CI: Pin openssl version on Windows
Must stick with OpenSSL 3 for now to stay compatbile with the asio
version supported by websocketpp
2026-05-25 00:22:59 +02:00
WarmUpTill
90130c119e Fix crash due to "keep source active" setting 2026-05-25 00:22:59 +02:00
WarmUpTill
a7ca06d25c Prevent crash when OCR traineddata file is missing 2026-05-25 00:22:59 +02:00
WarmUpTill
5a5d590c51 CI: Trigger rebuild of dependencies 2026-05-25 00:22:58 +02:00
WarmUpTill
336b398bc3 deps: Update libusb submodule to v1.0.30-rc2
Required for v145 toolset support on Windows
2026-05-25 00:22:58 +02:00
WarmUpTill
56c8f335db deps: Update leptonica submodule to 1.87.0
Required to support cmake 4+
2026-05-25 00:22:58 +02:00
WarmUpTill
b6a4d48948 Fix temp var mapping not working for new macro segments 2026-05-25 00:22:58 +02:00
WarmUpTill
774ccb7fbd Fix temp var mapping not working after copy-paste of segment 2026-05-25 00:22:58 +02:00
WarmUpTill
eda9dde23d Add temp vars indicating OCR match location 2026-05-25 00:22:58 +02:00
WarmUpTill
bbf91efd0d Add "Macro Schedule" tab 2026-05-25 00:22:54 +02:00
WarmUpTill
b022d59391 Add calendar widget 2026-05-15 19:15:19 +02:00
WarmUpTill
cf46a010a7 Add support for units "days" 2026-05-15 19:15:19 +02:00
WarmUpTill
595bab0d83 Add option to export Websocket, Twitch, HTTP connections 2026-05-15 19:15:19 +02:00
WarmUpTill
6fc819d72b Add API to expand export / import dialog 2026-05-15 19:15:19 +02:00
WarmUpTill
0553a9b2ee Call MacroRef::PostLoad() automatically 2026-05-15 19:15:19 +02:00
WarmUpTill
3caa3a4735 Add helpers to check, run, stop, and pause macros 2026-05-15 19:15:19 +02:00
WarmUpTill
2469057ad6 Fix double free
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-05-11 17:30:18 +02:00
WarmUpTill
b230ec5bc5 locale: Clarify "reevaluate condition state" option for run macro action 2026-05-11 17:30:18 +02:00
WarmUpTill
1669b4cb72 Fix "Run Macro" help being visible when option is not selected 2026-05-11 17:30:18 +02:00
WarmUpTill
2841fcaadb Fix variable dock not listing variables on startup 2026-05-11 17:30:11 +02:00
WarmUpTill
91810d9c40 Enable resolving plugin symbols in OBS crash reports 2026-05-11 17:24:35 +02:00
WarmUpTill
ccb5c8ba2c Don't highlight paused macro in macro list 2026-05-11 17:24:35 +02:00
WarmUpTill
265daebdf3 Don't log "on change" when macro is paused 2026-05-11 17:24:35 +02:00
WarmUpTill
a149a11c57 Don't highlight action trigger mode when macro is paused 2026-05-11 17:24:35 +02:00
WarmUpTill
d7cc7a4c7b Ensure plugin is stopped before running cleanup steps 2026-05-11 17:24:35 +02:00
WarmUpTill
41866bcb24 Replace "Ignore Entry" condition logic with SwitchButton toggle 2026-05-11 17:24:35 +02:00
WarmUpTill
c73a5866d7 Add option to keep source of video condition active
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-05-10 12:47:55 +02:00
WarmUpTill
18aaf1dd31 Add option to keep selected game capture sources active 2026-05-10 12:47:55 +02:00
WarmUpTill
d523a2e674 Add helper to keep sources active
To be used for e.g. the video condition / game capture condition, which
can only get the relevant information while a given source is "active".

A source can become active if it is not part of the currently selected
scene.
2026-05-10 12:47:55 +02:00
WarmUpTill
0f7eed6257 Fix "transitioning from" check not working in studio mode
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-05-09 13:05:50 +02:00
WarmUpTill
1f5a346190 Fix transition signals not being connected on scene selection change
This would be the case if a new transition condition was created, which
immediately is changed to "transitioning from ..." or "transitioning to
..." type.
2026-05-09 13:05:50 +02:00
WarmUpTill
7fd2979e86 Support legacy and new Windows install paths in zip archive
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
See https://obsproject.com/kb/plugins-guide for more details
2026-05-08 22:15:44 +02:00
WarmUpTill
2077e1971d Adapt to new plugin folder structure / location for Windows 2026-05-08 22:15:44 +02:00
WarmUpTill
873e073eb6 Show all feature tabs when setup wizard is skipped
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-05-05 19:36:41 +02:00
117 changed files with 6478 additions and 683 deletions

View File

@@ -19,14 +19,9 @@ inputs:
runs:
using: 'composite'
steps:
- name: Setup cmake
uses: jwlawson/actions-setup-cmake@v1.13
with:
cmake-version: '3.x.x'
- name: Restore cached dependencies
id: restore-cache
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: ${{ env.DEP_DIR }}
key: ${{ env.DEP_DIR }}-${{ runner.os }}-${{ inputs.target }}

View File

@@ -29,6 +29,7 @@ runs:
eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)"
echo "/home/linuxbrew/.linuxbrew/bin:/home/linuxbrew/.linuxbrew/sbin" >> $GITHUB_PATH
echo "/home/linuxbrew/.linuxbrew/opt/clang-format@16/bin" >> $GITHUB_PATH
brew update
brew install --quiet zsh
echo ::endgroup::
@@ -52,6 +53,7 @@ runs:
if (( ${changes[(I)(*.c|*.h|*.cpp|*.hpp|*.m|*.mm)]} )) {
echo ::group::Install clang-format-16
brew update
brew install --quiet obsproject/tools/clang-format@16
echo ::endgroup::

View File

@@ -1,4 +1,4 @@
package 'cmake', path: 'Cmake\bin', bin: 'cmake'
package 'innosetup', path: 'Inno Setup 6', bin: 'iscc'
package 'OpenSSL', path: 'OpenSSL', bin: 'openssl'
package 'OpenSSL', path: 'OpenSSL', bin: 'openssl', version: '3.6.2'
package 'Microsoft.VisualStudio.Locator', path: 'vswhere', bin: 'vswhere'

View File

@@ -68,14 +68,53 @@ function Package {
Remove-Item @RemoveArgs
Log-Group "Archiving ${ProductName}..."
$CompressArgs = @{
Path = (Get-ChildItem -Path "${ProjectRoot}/release/${Configuration}" -Exclude "${OutputName}*.*")
CompressionLevel = 'Optimal'
DestinationPath = "${ProjectRoot}/release/${OutputName}.zip"
Verbose = ($Env:CI -ne $null)
$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 = "${RecStaging}/${ProductName}/bin/64bit"
New-Item -ItemType Directory -Force -Path $RecBinPath | Out-Null
Copy-Item -Path "${NewBinPath}/*" -Destination $RecBinPath -Recurse -Force
}
Compress-Archive -Force @CompressArgs
if ( Test-Path -Path $NewDataPath ) {
$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
# --- Legacy zip (old layout, extract to OBS install directory) ---
Log-Group "Archiving ${ProductName} (legacy)..."
$LegStaging = "${ProjectRoot}/release/zip-staging-leg"
Remove-Item -Path $LegStaging -Recurse -Force -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Force -Path $LegStaging | Out-Null
Copy-Item -Path "${CIWindowsDir}/README-legacy.txt" -Destination "${LegStaging}/README.txt"
if ( Test-Path -Path $NewBinPath ) {
$LegBinPath = "${LegStaging}/obs-plugins/64bit"
New-Item -ItemType Directory -Force -Path $LegBinPath | Out-Null
Copy-Item -Path "${NewBinPath}/*" -Destination $LegBinPath -Recurse -Force
}
if ( Test-Path -Path $NewDataPath ) {
$LegDataPath = "${LegStaging}/data/obs-plugins/${ProductName}"
New-Item -ItemType Directory -Force -Path $LegDataPath | Out-Null
Copy-Item -Path "${NewDataPath}/*" -Destination $LegDataPath -Recurse -Force
}
Compress-Archive -Force -Path (Get-ChildItem -Path $LegStaging) `
-CompressionLevel Optimal `
-DestinationPath "${ProjectRoot}/release/${OutputName}-legacy.zip"
Remove-Item -Path $LegStaging -Recurse -Force
Log-Group
if ( ( $BuildInstaller ) ) {
@@ -89,7 +128,25 @@ function Package {
Log-Information 'Creating InnoSetup installer...'
Push-Location -Stack BuildTemp
Ensure-Location -Path "${ProjectRoot}/release"
Copy-Item -Path ${Configuration} -Destination Package -Recurse
Remove-Item -Path Package -Recurse -Force -ErrorAction SilentlyContinue
# 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/legacy/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/legacy/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

View File

@@ -18,4 +18,5 @@ if (( ! ${+commands[brew]} )) {
}
brew bundle --file ${SCRIPT_HOME}/.Brewfile
rehash
log_group

View File

@@ -21,7 +21,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
submodules: "recursive"
- name: check_libobs_revision
@@ -50,7 +50,7 @@ jobs:
mv ../*.deb .
- name: Publish
if: success()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: "obs-scene-switcher.deb"
path: ${{ github.workspace }}/*.deb

View File

@@ -6,7 +6,7 @@ on:
description: "Project name detected by parsing build spec file"
value: ${{ jobs.check-event.outputs.pluginName }}
env:
DEP_DIR: .deps/advss-build-dependencies-4
DEP_DIR: .deps/advss-build-dependencies-5
jobs:
check-event:
name: Check GitHub Event Data 🔎
@@ -22,7 +22,7 @@ jobs:
commitHash: ${{ steps.setup.outputs.commitHash }}
pluginName: ${{ steps.setup.outputs.pluginName }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Check Event Data ☑️
@@ -81,7 +81,7 @@ jobs:
run:
shell: zsh --no-rcs --errexit --pipefail {0}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
submodules: recursive
fetch-depth: 0
@@ -111,7 +111,7 @@ jobs:
sudo xcode-select --switch /Applications/Xcode_16.1.0.app/Contents/Developer
print '::endgroup::'
- uses: actions/cache@v4
- uses: actions/cache@v5
id: ccache-cache
with:
path: ${{ github.workspace }}/.ccache
@@ -163,13 +163,13 @@ jobs:
codesignPass: ${{ secrets.MACOS_NOTARIZATION_PASSWORD }}
- name: Upload Artifacts 📡
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: ${{ steps.setup.outputs.pluginName }}-${{ steps.setup.outputs.pluginVersion }}-macos-universal-${{ needs.check-event.outputs.commitHash }}
path: ${{ github.workspace }}/release/${{ steps.setup.outputs.pluginName }}-*-macos-universal.*
- name: Upload Debug Symbol Artifacts 🪲
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
if: ${{ needs.check-event.outputs.config == 'Release' }}
with:
name: ${{ steps.setup.outputs.pluginName }}-${{ steps.setup.outputs.pluginVersion }}-macos-universal-${{ needs.check-event.outputs.commitHash }}-dSYMs
@@ -183,7 +183,7 @@ jobs:
run:
shell: bash
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
submodules: recursive
fetch-depth: 0
@@ -202,7 +202,7 @@ jobs:
echo "pluginName=${product_name}" >> $GITHUB_OUTPUT
echo "pluginVersion=${git_tag}" >> $GITHUB_OUTPUT
- uses: actions/cache@v4
- uses: actions/cache@v5
id: ccache-cache
with:
path: ${{ github.workspace }}/.ccache
@@ -210,13 +210,8 @@ jobs:
restore-keys: |
${{ runner.os }}-ccache-x86_64-
- name: Set up CMake 🏗️
uses: jwlawson/actions-setup-cmake@v1.13
with:
cmake-version: '3.x.x'
- name: Set up Homebrew 🍺
uses: Homebrew/actions/setup-homebrew@master
uses: Homebrew/actions/setup-homebrew@main
- name: Build Plugin 🧱
uses: ./.github/actions/build-plugin
@@ -238,23 +233,24 @@ jobs:
config: ${{ needs.check-event.outputs.config }}
- name: Upload Source Tarball 🗜️
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: ${{ steps.setup.outputs.pluginName }}-${{ steps.setup.outputs.pluginVersion }}-sources-${{ needs.check-event.outputs.commitHash }}
path: ${{ github.workspace }}/release/${{ steps.setup.outputs.pluginName }}-*-source.*
- name: Upload Artifacts 📡
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: ${{ steps.setup.outputs.pluginName }}-${{ steps.setup.outputs.pluginVersion }}-ubuntu-24.04-x86_64-${{ needs.check-event.outputs.commitHash }}
path: ${{ github.workspace }}/release/${{ steps.setup.outputs.pluginName }}-*-x86_64*.*
- name: Upload debug symbol artifacts 🪲
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
if: ${{ fromJSON(needs.check-event.outputs.package) }}
with:
name: ${{ steps.setup.outputs.pluginName }}-${{ steps.setup.outputs.pluginVersion }}-ubuntu-24.04-x86_64-${{ needs.check-event.outputs.commitHash }}-dbgsym
path: ${{ github.workspace }}/release/${{ steps.setup.outputs.pluginName }}-*-x86_64*-dbgsym.ddeb
if-no-files-found: ignore
windows-build:
name: Build for Windows 🪟
@@ -264,7 +260,7 @@ jobs:
run:
shell: pwsh
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
submodules: recursive
fetch-depth: 0
@@ -311,7 +307,7 @@ jobs:
package: ${{ fromJSON(needs.check-event.outputs.package) }}
- name: Upload Artifacts 📡
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: ${{ steps.setup.outputs.pluginName }}-${{ steps.setup.outputs.pluginVersion }}-windows-x64-${{ needs.check-event.outputs.commitHash }}
path: ${{ github.workspace }}/release/${{ steps.setup.outputs.pluginName }}-*-windows-x64*.*

View File

@@ -5,7 +5,7 @@ jobs:
clang-format:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: clang-format check 🐉
@@ -17,7 +17,7 @@ jobs:
cmake-format:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: cmake-format check 🎛️

View File

@@ -19,7 +19,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Check locale files
run: |

View File

@@ -59,7 +59,7 @@ jobs:
esac
- name: Download Build Artifacts 📥
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
if: fromJSON(steps.check.outputs.validTag)
id: download
@@ -106,7 +106,7 @@ jobs:
- name: Create Release 🛫
if: fromJSON(steps.check.outputs.validTag)
id: create_release
uses: softprops/action-gh-release@d4e8205d7e959a9107da6396278b2f1f07af0f9b
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda
with:
draft: true
prerelease: ${{ fromJSON(steps.check.outputs.prerelease) }}

View File

@@ -134,6 +134,9 @@ target_sources(
lib/macro/macro-dock-window.hpp
lib/macro/macro-edit.cpp
lib/macro/macro-edit.hpp
lib/macro/macro-export-builtin-extensions.cpp
lib/macro/macro-export-extensions.cpp
lib/macro/macro-export-extensions.hpp
lib/macro/macro-export-import-dialog.cpp
lib/macro/macro-export-import-dialog.hpp
lib/macro/macro-helpers.cpp

View File

@@ -0,0 +1,23 @@
Advanced Scene Switcher - Windows Installation (Legacy)
========================================================
For full installation instructions visit:
https://github.com/WarmUpTill/SceneSwitcher/wiki/Installation
This archive uses the legacy 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

@@ -0,0 +1,21 @@
Advanced Scene Switcher - Windows Installation
===============================================
For full installation instructions visit:
https://github.com/WarmUpTill/SceneSwitcher/wiki/Installation
This archive uses the recommended plugin layout for OBS 28 and newer.
Extract the CONTENTS of this archive into:
C:\ProgramData\obs-studio\plugins\
Tip: press Win+R and type %ProgramData%\obs-studio\plugins to open that folder.
After extracting, the path should look like:
C:\ProgramData\obs-studio\plugins\advanced-scene-switcher\bin\64bit\advanced-scene-switcher.dll
If the plugin does not appear in OBS, try the legacy archive instead:
advanced-scene-switcher-<version>-windows-x64-legacy.zip

View File

@@ -2,7 +2,7 @@
if(BUILD_OUT_OF_TREE)
if(OS_WINDOWS)
set(OBS_PLUGIN_DESTINATION "obs-plugins/64bit")
set(OBS_PLUGIN_DESTINATION "${CMAKE_PROJECT_NAME}/bin/64bit")
else()
set(OBS_PLUGIN_DESTINATION "${CMAKE_INSTALL_LIBDIR}/obs-plugins")
endif()
@@ -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

@@ -18,7 +18,9 @@ set(CPACK_PACKAGE_FILE_NAME
"${CPACK_PACKAGE_NAME}-${CPACK_PACKAGE_VERSION}-${CMAKE_C_LIBRARY_ARCHITECTURE}"
)
set(CPACK_GENERATOR "DEB")
set(CPACK_GENERATOR
"DEB"
CACHE STRING "CPack generator to use")
set(CPACK_DEBIAN_PACKAGE_SHLIBDEPS ON)
set(CPACK_DEBIAN_PACKAGE_MAINTAINER "${PLUGIN_EMAIL}")
set(CPACK_SET_DESTDIR ON)
@@ -46,6 +48,19 @@ set(CPACK_SOURCE_PACKAGE_FILE_NAME
set(CPACK_ARCHIVE_THREADS 0)
include(CPack)
# CPack.cmake has a side effect that leaves binary packaging variables in the
# state intended for source packaging when it finishes. Specifically it sets
# CPACK_GENERATOR to CPACK_SOURCE_GENERATOR ("TXZ"), sets
# CPACK_INSTALLED_DIRECTORIES to the source root (which would cause CPackDeb to
# scan the entire source tree, including ccache object files that lack a Build
# ID), and clears CPACK_INSTALL_CMAKE_PROJECTS (preventing the cmake install
# mechanism from being used). In some cmake versions these overrides happen
# before CPackConfig.cmake is written. Reset all three so that binary DEB
# packaging works correctly regardless of cmake version.
set(CPACK_GENERATOR "DEB")
set(CPACK_INSTALLED_DIRECTORIES "")
set(CPACK_INSTALL_CMAKE_PROJECTS
"${CMAKE_BINARY_DIR};${CMAKE_PROJECT_NAME};ALL;/")
find_package(libobs QUIET)

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

@@ -6,3 +6,9 @@ include_guard(GLOBAL)
set(CMAKE_FIND_PACKAGE_TARGETS_GLOBAL TRUE)
include(buildspec)
if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT)
set(CMAKE_INSTALL_PREFIX
"$ENV{ALLUSERSPROFILE}/obs-studio/plugins"
CACHE STRING "Default plugin installation directory" FORCE)
endif()

View File

@@ -30,13 +30,13 @@ function(set_target_properties_plugin target)
install(
TARGETS ${target}
RUNTIME DESTINATION bin/64bit
LIBRARY DESTINATION obs-plugins/64bit)
RUNTIME DESTINATION "${target}/bin/64bit"
LIBRARY DESTINATION "${target}/bin/64bit")
install(
FILES "$<TARGET_PDB_FILE:${target}>"
CONFIGURATIONS RelWithDebInfo Debug Release
DESTINATION obs-plugins/64bit
DESTINATION "${target}/bin/64bit"
OPTIONAL)
if(OBS_BUILD_DIR)
@@ -44,11 +44,11 @@ function(set_target_properties_plugin target)
TARGET ${target}
POST_BUILD
COMMAND "${CMAKE_COMMAND}" -E make_directory
"${OBS_BUILD_DIR}/obs-plugins/64bit"
"${OBS_BUILD_DIR}/plugins/${target}/bin/64bit"
COMMAND
"${CMAKE_COMMAND}" -E copy_if_different "$<TARGET_FILE:${target}>"
"$<$<CONFIG:Debug,RelWithDebInfo,Release>:$<TARGET_PDB_FILE:${target}>>"
"${OBS_BUILD_DIR}/obs-plugins/64bit"
"${OBS_BUILD_DIR}/plugins/${target}/bin/64bit"
COMMENT "Copy ${target} to obs-studio directory ${OBS_BUILD_DIR}"
VERBATIM)
endif()
@@ -105,7 +105,7 @@ function(target_install_resources target)
install(
DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}/data/"
DESTINATION data/obs-plugins/${target}
DESTINATION "${target}/data"
USE_SOURCE_PERMISSIONS)
if(OBS_BUILD_DIR)
@@ -113,11 +113,11 @@ function(target_install_resources target)
TARGET ${target}
POST_BUILD
COMMAND "${CMAKE_COMMAND}" -E make_directory
"${OBS_BUILD_DIR}/data/obs-plugins/${target}"
"${OBS_BUILD_DIR}/plugins/${target}/data"
COMMAND
"${CMAKE_COMMAND}" -E copy_directory
"${CMAKE_CURRENT_SOURCE_DIR}/data"
"${OBS_BUILD_DIR}/data/obs-plugins/${target}"
"${OBS_BUILD_DIR}/plugins/${target}/data"
COMMENT "Copy ${target} resources to data directory"
VERBATIM)
endif()
@@ -133,7 +133,7 @@ function(target_add_resource target resource)
install(
FILES "${resource}"
DESTINATION data/obs-plugins/${target}
DESTINATION "${target}/data"
COMPONENT Runtime)
if(OBS_BUILD_DIR)
@@ -141,9 +141,9 @@ function(target_add_resource target resource)
TARGET ${target}
POST_BUILD
COMMAND "${CMAKE_COMMAND}" -E make_directory
"${OBS_BUILD_DIR}/data/obs-plugins/${target}"
"${OBS_BUILD_DIR}/plugins/${target}/data"
COMMAND "${CMAKE_COMMAND}" -E copy "${resource}"
"${OBS_BUILD_DIR}/data/obs-plugins/${target}"
"${OBS_BUILD_DIR}/plugins/${target}/data"
COMMENT "Copy ${target} resource ${resource} to library directory"
VERBATIM)
endif()

View File

@@ -2,12 +2,13 @@
#define MyAppVersion "@CMAKE_PROJECT_VERSION@"
#define MyAppPublisher "@PLUGIN_AUTHOR@"
#define MyAppURL "@PLUGIN_WEBSITE@"
#define MyAppId "@UUID_APP@"
[Setup]
; NOTE: The value of AppId uniquely identifies this application.
; Do not use the same AppId value in installers for other applications.
; (To generate a new GUID, click Tools | Generate GUID inside the IDE.)
AppId={{@UUID_APP@}
AppId={{{#MyAppId}}
AppName={#MyAppName}
AppVersion={#MyAppVersion}
AppPublisher={#MyAppPublisher}
@@ -20,15 +21,19 @@ OutputBaseFilename={#MyAppName}-{#MyAppVersion}-Windows-Installer
Compression=lzma
SolidCompression=yes
DirExistsWarning=no
UsePreviousAppDir=no
SetupIconFile=installer.ico
UninstallDisplayIcon={app}\data\obs-plugins\advanced-scene-switcher\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\legacy\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs; Check: IsLegacyLayout
Source: "..\LICENSE"; Flags: dontcopy
; NOTE: Don't use "Flags: ignoreversion" on any shared system files
@@ -51,17 +56,64 @@ begin
);
end;
// credit where it's due :
// following function come from https://github.com/Xaymar/obs-studio_amf-encoder-plugin/blob/master/%23Resources/Installer.in.iss#L45
function GetDirName(Value: string): string;
var
InstallPath: string;
// Returns true when the selected install directory is under %ProgramData%,
// meaning the recommended plugin layout should be used.
function IsRecommendedLayout(): Boolean;
begin
// initialize default path, which will be returned when the following registry
// key queries fail due to missing keys or for some different reason
Result := '{autopf}\obs-studio';
// query the first registry value; if this succeeds, return the obtained value
if RegQueryStringValue(HKLM32, 'SOFTWARE\OBS Studio', '', InstallPath) then
Result := InstallPath
Result := Pos(LowerCase(ExpandConstant('{commonappdata}')),
LowerCase(WizardDirValue())) = 1;
end;
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 legacy installer (obs-plugins/64bit and
// data/obs-plugins layout) so the plugin is not loaded twice by OBS.
// Only runs when upgrading from a legacy 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');
DeleteFile(OldInstallPath + '\obs-plugins\64bit\advanced-scene-switcher-lib.pdb');
DelTree(OldInstallPath + '\obs-plugins\64bit\advanced-scene-switcher-plugins', True, True, True);
DelTree(OldInstallPath + '\data\obs-plugins\advanced-scene-switcher', True, True, True);
end;
procedure CurStepChanged(CurStep: TSetupStep);
begin
if CurStep = ssInstall then
RemoveLegacyFiles();
end;

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)"
@@ -202,6 +209,13 @@ AdvSceneSwitcher.macroTab.remove="Remove"
AdvSceneSwitcher.macroTab.export="Export"
AdvSceneSwitcher.macroTab.export.info="Paste the string below into the import dialog to import the selected macros:"
AdvSceneSwitcher.macroTab.export.usePlainText="Use plain text"
AdvSceneSwitcher.macroTab.export.additionalContent="Export Additional Content"
AdvSceneSwitcher.macroTab.export.variables="Variables"
AdvSceneSwitcher.macroTab.export.actionQueues="Action Queues"
AdvSceneSwitcher.macroTab.export.httpServers="HTTP Servers"
AdvSceneSwitcher.macroTab.export.macroScheduleEntries="Macro Schedule"
AdvSceneSwitcher.macroTab.export.twitchConnections="Twitch Connections"
AdvSceneSwitcher.macroTab.export.websocketConnections="Websocket Connections"
AdvSceneSwitcher.macroTab.import="Import"
AdvSceneSwitcher.macroTab.import.info="Paste the export string into the below text box to import macros:"
AdvSceneSwitcher.macroTab.import.invalid="Invalid import data provided!"
@@ -855,6 +869,7 @@ AdvSceneSwitcher.condition.streamDeck.stopListen="Stop listening"
AdvSceneSwitcher.condition.streamDeck.pluginDownload="<html><head/><body><p>The Stream Deck plugin can be found <a href=\"https://github.com/WarmUpTill/advanced-scene-switcher-streamdeck-plugin/releases\"><span style=\" text-decoration: underline; color:#268bd2;\">here on GitHub</span></a>.</p></body></html>"
AdvSceneSwitcher.condition.gameCapture="Game capture"
AdvSceneSwitcher.condition.gameCapture.entry="{{sources}}hooked a game."
AdvSceneSwitcher.condition.screenshot="Screenshot"
AdvSceneSwitcher.condition.screenshot.entry="A screenshot was taken"
AdvSceneSwitcher.condition.mqtt="MQTT"
@@ -2279,6 +2294,14 @@ AdvSceneSwitcher.tempVar.scene.position="Position in scenes list"
AdvSceneSwitcher.tempVar.window.window="Window title"
AdvSceneSwitcher.tempVar.window.window.description="The window title of the matching window."
AdvSceneSwitcher.tempVar.window.windowX="Window X position"
AdvSceneSwitcher.tempVar.window.windowX.description="The X position (left edge) of the matching window in screen coordinates."
AdvSceneSwitcher.tempVar.window.windowY="Window Y position"
AdvSceneSwitcher.tempVar.window.windowY.description="The Y position (top edge) of the matching window in screen coordinates."
AdvSceneSwitcher.tempVar.window.windowWidth="Window width"
AdvSceneSwitcher.tempVar.window.windowWidth.description="The width of the matching window in pixels."
AdvSceneSwitcher.tempVar.window.windowHeight="Window height"
AdvSceneSwitcher.tempVar.window.windowHeight.description="The height of the matching window in pixels."
AdvSceneSwitcher.tempVar.window.windowClass="Window class"
AdvSceneSwitcher.tempVar.window.windowClass.description="The window class of the matching window."
AdvSceneSwitcher.tempVar.window.windowText="Window text"
@@ -2344,6 +2367,14 @@ AdvSceneSwitcher.tempVar.video.similarity="Similarity Rating"
AdvSceneSwitcher.tempVar.video.similarity.description="Values range from 0 to 1, where 0 means dissimilar and 1 means highly similar."
AdvSceneSwitcher.tempVar.video.patternCount="Pattern count"
AdvSceneSwitcher.tempVar.video.patternCount.description="The number of times the given pattern has been found in a given video input frame."
AdvSceneSwitcher.tempVar.video.matchX="Match X coordinate"
AdvSceneSwitcher.tempVar.video.matchX.description="The X coordinate of the top-left corner of the best pattern match.\nIf the 'check area' option is enabled, the coordinate is relative to the video source, not the area.\nSet to -1 if no match was found."
AdvSceneSwitcher.tempVar.video.matchY="Match Y coordinate"
AdvSceneSwitcher.tempVar.video.matchY.description="The Y coordinate of the top-left corner of the best pattern match.\nIf the 'check area' option is enabled, the coordinate is relative to the video source, not the area.\nSet to -1 if no match was found."
AdvSceneSwitcher.tempVar.video.matchWidth="Match width"
AdvSceneSwitcher.tempVar.video.matchWidth.description="The width of the matched pattern in pixels."
AdvSceneSwitcher.tempVar.video.matchHeight="Match height"
AdvSceneSwitcher.tempVar.video.matchHeight.description="The height of the matched pattern in pixels."
AdvSceneSwitcher.tempVar.video.objectCount="Object count"
AdvSceneSwitcher.tempVar.video.objectCount.description="The number of objects the given model has identified in a given video input frame."
AdvSceneSwitcher.tempVar.video.brightness="Average brightness"
@@ -2537,6 +2568,9 @@ AdvSceneSwitcher.selectDisplay="--select display--"
AdvSceneSwitcher.invaildEntriesWillNotBeSaved="invalid entries will not be saved"
AdvSceneSwitcher.selectWindowTip="Use \"OBS\" to specify OBS window\nUse \"Task Switching\"to specify ALT + TAB"
AdvSceneSwitcher.keepSourceActive="Keep source active"
AdvSceneSwitcher.keepSourceActive.help="When a source is not visible in the active scene it becomes inactive, which can cause it to stop producing output (e.g. video frames or game capture hooks).\nEnabling this option forces the source to remain active at all times, ensuring it continues to produce output even when not displayed.\n\nNote: Keeping a source permanently active may increase CPU and GPU usage, as the source continues to run even when not shown."
AdvSceneSwitcher.settings.suffix.type.invalid=" (Invalid)"
AdvSceneSwitcher.settings.suffix.type.bool=" (Bool)"
AdvSceneSwitcher.settings.suffix.type.int=" (Int)"
@@ -2704,6 +2738,64 @@ Basic.Settings.Video.FPSCommon="Common FPS Values"
Basic.Settings.Video.FPSInteger="Integer FPS Value"
Basic.Settings.Video.FPSFraction="Fractional FPS Value"
# Calendar widget
AdvSceneSwitcher.calendar.today="Today"
AdvSceneSwitcher.calendar.month="Month"
AdvSceneSwitcher.calendar.week="Week"
AdvSceneSwitcher.calendar.day="Day"
AdvSceneSwitcher.calendar.moreEvents="+%1 more"
# Macro Schedule tab
AdvSceneSwitcher.macroScheduleTab.title="Macro Schedule"
AdvSceneSwitcher.macroScheduleTab.help="No scheduled entries.\nClick '+' to schedule a macro."
AdvSceneSwitcher.macroScheduleTab.add="Add"
AdvSceneSwitcher.macroScheduleTab.add.tooltip="Add schedule entry"
AdvSceneSwitcher.macroScheduleTab.edit="Edit"
AdvSceneSwitcher.macroScheduleTab.remove="Remove"
AdvSceneSwitcher.macroScheduleTab.remove.tooltip="Remove schedule entry"
AdvSceneSwitcher.macroScheduleTab.enable="Enable"
AdvSceneSwitcher.macroScheduleTab.disable="Disable"
AdvSceneSwitcher.macroScheduleTab.showAll="Show All"
AdvSceneSwitcher.macroScheduleTab.column.name="Name"
AdvSceneSwitcher.macroScheduleTab.column.macro="Macro"
AdvSceneSwitcher.macroScheduleTab.column.schedule="Schedule"
AdvSceneSwitcher.macroScheduleTab.column.nextTrigger="Next Trigger"
AdvSceneSwitcher.macroScheduleTab.column.status="Status"
AdvSceneSwitcher.macroScheduleTab.remove.confirm="Are you sure you want to remove \"%1\"?"
AdvSceneSwitcher.macroScheduleTab.remove.confirmMultiple="Are you sure you want to remove %1 entries?"
AdvSceneSwitcher.macroScheduleTab.status.active="Active"
AdvSceneSwitcher.macroScheduleTab.status.disabled="Disabled"
AdvSceneSwitcher.macroScheduleTab.status.expired="Expired"
# Macro Schedule entry dialog
AdvSceneSwitcher.macroScheduleEntry.dialog.title.add="Add Schedule Entry"
AdvSceneSwitcher.macroScheduleEntry.dialog.title.edit="Edit Schedule Entry"
AdvSceneSwitcher.macroScheduleEntry.name="Name (optional):"
AdvSceneSwitcher.macroScheduleEntry.name.placeholder="e.g. Daily stream start"
AdvSceneSwitcher.macroScheduleEntry.macro="Macro:"
AdvSceneSwitcher.macroScheduleEntry.startDateTime="Start:"
AdvSceneSwitcher.macroScheduleEntry.endDate="End date:"
AdvSceneSwitcher.macroScheduleEntry.endDate.help="Controls how far the entry spans visually in the calendar and optionally triggers an action when the date is reached."
AdvSceneSwitcher.macroScheduleEntry.endDate.action.none="No action"
AdvSceneSwitcher.macroScheduleEntry.endDate.action.disableEntry="Disable schedule entry"
AdvSceneSwitcher.macroScheduleEntry.endDate.action.pauseMacro="Pause macro"
AdvSceneSwitcher.macroScheduleEntry.endDate.action.stopMacro="Stop macro"
AdvSceneSwitcher.macroScheduleEntry.repeat="Repeat"
AdvSceneSwitcher.macroScheduleEntry.repeat.once="Once"
AdvSceneSwitcher.macroScheduleEntry.repeat.every="Every %1"
AdvSceneSwitcher.macroScheduleEntry.repeat.interval="Repeat interval:"
AdvSceneSwitcher.macroScheduleEntry.noMacro="(no macro)"
AdvSceneSwitcher.macroScheduleEntry.repeatEnd="End repeat:"
AdvSceneSwitcher.macroScheduleEntry.repeatEnd.never="Never"
AdvSceneSwitcher.macroScheduleEntry.repeatEnd.afterNTimes="After"
AdvSceneSwitcher.macroScheduleEntry.repeatEnd.afterNTimes.suffix="occurrence(s)"
AdvSceneSwitcher.macroScheduleEntry.repeatEnd.untilDate="Until"
AdvSceneSwitcher.macroScheduleEntry.color="Color:"
AdvSceneSwitcher.macroScheduleEntry.color.dialog="Pick entry color"
AdvSceneSwitcher.macroScheduleEntry.checkConditions="Check macro conditions before running"
AdvSceneSwitcher.macroScheduleEntry.runElseActionsOnConditionFailure="Run else actions if conditions are not met"
AdvSceneSwitcher.macroScheduleEntry.enabled="Enabled"
# Legacy tabs below - please don't waste your time adding translations for these :)
# Transition Tab
AdvSceneSwitcher.transitionTab.title="Transition"

2
deps/leptonica vendored

2
deps/libusb vendored

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>
@@ -41,9 +43,12 @@ AdvSceneSwitcher::AdvSceneSwitcher(QWidget *parent)
{
switcher->settingsWindowOpened = true;
ui->setupUi(this);
std::lock_guard<std::mutex> lock(switcher->m);
switcher->Prune();
LoadUI();
{
std::lock_guard<std::mutex> lock(switcher->m);
switcher->Prune();
LoadUI();
}
CheckFirstTimeSetup();
}
AdvSceneSwitcher::~AdvSceneSwitcher()
@@ -139,7 +144,6 @@ void AdvSceneSwitcher::LoadUI()
SetTabOrder(ui->tabWidget);
SetCurrentTab(ui->tabWidget);
RestoreWindowGeo();
CheckFirstTimeSetup();
loading = false;
}
@@ -457,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();
}
@@ -716,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 =
@@ -391,12 +451,16 @@ void AdvSceneSwitcher::CheckFirstTimeSetup()
return;
}
auto macro = FirstRunWizard::ShowWizard(this);
bool wasSkipped = false;
auto macro = FirstRunWizard::ShowWizard(this, &wasSkipped);
if (macro) {
renameMacroIfNecessary(macro);
QTimer::singleShot(0, this,
[this, macro]() { ui->macros->Add(macro); });
}
if (wasSkipped) {
ui->alwaysShowFeatureTabs->setChecked(true);
}
switcher->Start();
}
@@ -563,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);
@@ -634,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);
@@ -956,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

@@ -178,47 +178,19 @@ void AdvSceneSwitcher::on_ignoreWindows_currentRowChanged(int idx)
}
}
void checkWindowTitleSwitchDirect(WindowSwitch &s,
std::string &currentWindowTitle, bool &match,
OBSWeakSource &scene,
OBSWeakSource &transition)
static bool windowInfoMatchesSwitch(const WindowInfo &info,
const WindowSwitch &s)
{
bool focus = (!s.focus || s.window == currentWindowTitle);
bool fullscreen = (!s.fullscreen || IsFullscreen(s.window));
bool max = (!s.maximized || IsMaximized(s.window));
if (focus && fullscreen && max) {
match = true;
scene = s.getScene();
transition = s.transition;
if (s.focus && !info.focused) {
return false;
}
}
void checkWindowTitleSwitchRegex(WindowSwitch &s,
std::string &currentWindowTitle,
std::vector<std::string> windowList,
bool &match, OBSWeakSource &scene,
OBSWeakSource &transition)
{
for (auto &window : windowList) {
try {
std::regex expr(s.window);
if (!std::regex_match(window, expr)) {
continue;
}
} catch (const std::regex_error &) {
}
bool focus = (!s.focus || window == currentWindowTitle);
bool fullscreen = (!s.fullscreen || IsFullscreen(window));
bool max = (!s.maximized || IsMaximized(window));
if (focus && fullscreen && max) {
match = true;
scene = s.getScene();
transition = s.transition;
}
if (s.fullscreen && !info.fullscreen) {
return false;
}
if (s.maximized && !info.maximized) {
return false;
}
return true;
}
bool SwitcherData::checkWindowTitleSwitch(OBSWeakSource &scene,
@@ -228,23 +200,37 @@ bool SwitcherData::checkWindowTitleSwitch(OBSWeakSource &scene,
return false;
}
std::string currentWindowTitle = switcher->currentTitle;
bool match = false;
const auto windowList = GetWindowList();
WindowQueryOptions options;
options.focus = true;
options.fullscreen = true;
options.maximized = true;
const auto windows = GetWindows(options);
for (WindowSwitch &s : windowSwitches) {
if (!s.initialized()) {
continue;
}
if (std::find(windowList.begin(), windowList.end(), s.window) !=
windowList.end()) {
checkWindowTitleSwitchDirect(s, currentWindowTitle,
match, scene, transition);
} else {
checkWindowTitleSwitchRegex(s, currentWindowTitle,
windowList, match, scene,
transition);
for (const auto &info : windows) {
bool titleMatch = false;
try {
std::regex expr(s.window);
titleMatch = info.title == s.window ||
std::regex_match(info.title, expr);
} catch (const std::regex_error &) {
titleMatch = info.title == s.window;
}
if (!titleMatch || !windowInfoMatchesSwitch(info, s)) {
continue;
}
match = true;
scene = s.getScene();
transition = s.transition;
break;
}
if (match) {

View File

@@ -236,17 +236,89 @@ std::string getWindowName(Window window)
return windowTitle;
}
std::vector<std::string> GetWindowList()
std::vector<WindowInfo> GetWindows(const WindowQueryOptions &options)
{
std::vector<std::string> windows;
for (auto window : getTopLevelWindows()) {
const std::string foregroundTitle = GetCurrentWindowTitle();
const auto topLevel = getTopLevelWindows();
auto display = disp();
std::vector<WindowInfo> result;
result.reserve(topLevel.size());
for (auto window : topLevel) {
auto name = getWindowName(window);
if (name.empty()) {
continue;
}
windows.emplace_back(name);
WindowInfo info;
info.title = name;
if (options.focus) {
info.focused = (name == foregroundTitle);
}
if (options.fullscreen || options.maximized) {
QStringList states = getStates(window);
if (options.fullscreen) {
info.fullscreen = states.contains(
"_NET_WM_STATE_FULLSCREEN");
}
if (options.maximized) {
info.maximized =
states.contains(
"_NET_WM_STATE_MAXIMIZED_VERT") &&
states.contains(
"_NET_WM_STATE_MAXIMIZED_HORZ");
}
}
if (options.geometry && display) {
XWindowAttributes attrs;
if (XGetWindowAttributes(display, window, &attrs)) {
int x = 0;
int y = 0;
Window child;
XTranslateCoordinates(
display, window,
DefaultRootWindow(display), 0, 0, &x,
&y, &child);
info.x = x;
info.y = y;
info.width = attrs.width;
info.height = attrs.height;
}
}
// windowClass and text not implemented on Linux
result.emplace_back(std::move(info));
}
return windows;
// 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;
}
int getActiveWindow(Window *&window)
@@ -290,68 +362,6 @@ std::string GetCurrentWindowTitle()
return name;
}
bool windowStatesAreSet(const std::string &windowTitle,
std::vector<QString> &expectedStates)
{
if (!ewmhIsSupported()) {
return false;
}
std::vector<Window> windows = getTopLevelWindows();
for (auto &window : windows) {
auto name = getWindowName(window);
if (name.empty()) {
continue;
}
bool equals = windowTitle == name;
bool matches = QString::fromStdString(name).contains(
QRegularExpression(
QString::fromStdString(windowTitle)));
if (!(equals || matches)) {
continue;
}
QStringList states = getStates(window);
if (states.isEmpty()) {
if (expectedStates.empty()) {
return true;
}
return false;
}
for (const auto &state : expectedStates) {
if (!states.contains(state)) {
return false;
}
}
return true;
}
return false;
}
bool IsMaximized(const std::string &title)
{
std::vector<QString> states;
states.emplace_back("_NET_WM_STATE_MAXIMIZED_VERT");
states.emplace_back("_NET_WM_STATE_MAXIMIZED_HORZ");
return windowStatesAreSet(title, states);
}
bool IsFullscreen(const std::string &title)
{
std::vector<QString> states;
states.emplace_back("_NET_WM_STATE_FULLSCREEN");
return windowStatesAreSet(title, states);
}
std::optional<std::string> GetTextInWindow(const std::string &)
{
// Not implemented
return {};
}
static void getProcessListProcps(QStringList &processes)
{
#ifdef PROCPS_AVAILABLE

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

@@ -1,5 +1,6 @@
#include "macro-condition-edit.hpp"
#include "advanced-scene-switcher.hpp"
#include "macro-signals.hpp"
#include "macro-settings.hpp"
#include "macro.hpp"
#include "path-helpers.hpp"
@@ -274,6 +275,9 @@ void MacroConditionEdit::ConditionSelectionChanged(const QString &text)
SetupVarMappings((*_entryData).get());
_dur->setVisible(MacroConditionFactory::UsesDurationModifier(id));
SetFocusPolicyOfWidgets();
emit MacroSignalManager::Instance()->ConditionTypeCreated(
QString::fromStdString(id));
}
void MacroConditionEdit::DurationChanged(const Duration &seconds)
@@ -301,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)
@@ -1118,6 +1127,9 @@ void MacroEdit::AddMacroAction(int idx)
macro->Actions().at(idx - 1)->Save(data);
}
AddMacroAction(macro.get(), idx, id, data);
if (idx < (int)macro->Actions().size()) {
macro->Actions().at(idx)->FixupVarMappingRefs();
}
}
void MacroEdit::on_actionAdd_clicked()
@@ -1395,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)
@@ -1426,6 +1436,9 @@ void MacroEdit::AddMacroElseAction(int idx)
macro->ElseActions().at(idx - 1)->Save(data);
}
AddMacroElseAction(macro.get(), idx, id, data);
if (idx < (int)macro->ElseActions().size()) {
macro->ElseActions().at(idx)->FixupVarMappingRefs();
}
}
void MacroEdit::RemoveMacroElseAction(int idx)
@@ -1570,6 +1583,9 @@ void MacroEdit::AddMacroCondition(int idx)
macro->Conditions().at(idx - 1)->Save(data);
}
AddMacroCondition(macro.get(), idx, id, data.Get(), logic);
if (idx < (int)macro->Conditions().size()) {
macro->Conditions().at(idx)->FixupVarMappingRefs();
}
}
void MacroEdit::AddMacroCondition(Macro *macro, int idx, const std::string &id,
@@ -1602,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

@@ -0,0 +1,89 @@
#include "macro-export-extensions.hpp"
#include "action-queue.hpp"
#include "variable.hpp"
#include <obs.hpp>
namespace advss {
static bool setup();
static bool setupDone = setup();
static bool setup()
{
// --- Variables ---
AddMacroExportExtension(
{"AdvSceneSwitcher.macroTab.export.variables", "variables",
[](obs_data_t *data, const QStringList &selectedIds) {
if (selectedIds.isEmpty()) {
SaveVariables(data);
return;
}
OBSDataArrayAutoRelease array =
obs_data_array_create();
for (const auto &v : GetVariables()) {
const QString name =
QString::fromStdString(v->Name());
if (!selectedIds.contains(name)) {
continue;
}
OBSDataAutoRelease item = obs_data_create();
v->Save(item);
obs_data_array_push_back(array, item);
}
obs_data_set_array(data, "variables", array);
},
[](obs_data_t *data, const QStringList &) {
ImportVariables(data);
},
[]() -> QList<QPair<QString, QString>> {
QList<QPair<QString, QString>> items;
for (const auto &v : GetVariables()) {
const QString name =
QString::fromStdString(v->Name());
items.append({name, name});
}
return items;
}});
// --- Action Queues ---
AddMacroExportExtension(
{"AdvSceneSwitcher.macroTab.export.actionQueues",
"actionQueues",
[](obs_data_t *data, const QStringList &selectedIds) {
if (selectedIds.isEmpty()) {
SaveActionQueues(data);
return;
}
OBSDataArrayAutoRelease array =
obs_data_array_create();
for (const auto &q : GetActionQueues()) {
const QString name =
QString::fromStdString(q->Name());
if (!selectedIds.contains(name)) {
continue;
}
OBSDataAutoRelease item = obs_data_create();
q->Save(item);
obs_data_array_push_back(array, item);
}
obs_data_set_array(data, "actionQueues", array);
},
[](obs_data_t *data, const QStringList &) {
ImportQueues(data);
},
[]() -> QList<QPair<QString, QString>> {
QList<QPair<QString, QString>> items;
for (const auto &q : GetActionQueues()) {
const QString name =
QString::fromStdString(q->Name());
items.append({name, name});
}
return items;
}});
return true;
}
} // namespace advss

View File

@@ -0,0 +1,17 @@
#include "macro-export-extensions.hpp"
namespace advss {
static std::vector<MacroExportExtension> extensions;
void AddMacroExportExtension(const MacroExportExtension &ext)
{
extensions.emplace_back(ext);
}
const std::vector<MacroExportExtension> &GetMacroExportExtensions()
{
return extensions;
}
} // namespace advss

View File

@@ -0,0 +1,41 @@
#pragma once
#include "export-symbol-helper.hpp"
#include <functional>
#include <obs-data.h>
#include <QList>
#include <QPair>
#include <QString>
#include <vector>
namespace advss {
struct MacroExportExtension {
const char *displayNameKey;
const char *jsonKey;
// Export callback.
// Write content into 'data'. 'selectedIds' contains the item IDs the
// user explicitly checked; if empty (no per-item widget provided) save
// everything.
std::function<void(obs_data_t *data, const QStringList &selectedIds)>
save;
// Import callback.
// Read content from 'data'. 'selectedIds' contains the item IDs the
// user explicitly checked; if empty import every item found in 'data'.
std::function<void(obs_data_t *data, const QStringList &selectedIds)>
load;
// Optional: enumerate items available for export as (id, displayName)
// pairs. When nullptr the extension appears as a single all-or-nothing
// checkbox in the export dialog.
std::function<QList<QPair<QString, QString>>()> getExportItems =
nullptr;
};
EXPORT void AddMacroExportExtension(const MacroExportExtension &ext);
const std::vector<MacroExportExtension> &GetMacroExportExtensions();
} // namespace advss

View File

@@ -1,22 +1,183 @@
#include "macro-export-import-dialog.hpp"
#include "macro-export-extensions.hpp"
#include "obs-module-helper.hpp"
#include "section.hpp"
#include <obs.hpp>
#include <QLayout>
#include <QLabel>
#include <QDialogButtonBox>
#include <QLabel>
#include <QLayout>
#include <QScrollArea>
#include <QSplitter>
#include <algorithm>
#include <numeric>
namespace advss {
static bool usePlainText = false;
MacroExportImportDialog::MacroExportImportDialog(Type type)
// ---------------------------------------------------------------------------
// Compress / decompress helpers
// ---------------------------------------------------------------------------
static QString compressMacroString(const QString &input)
{
QByteArray inputData = input.toUtf8();
auto compressedData = qCompress(inputData);
QByteArray encodedData = compressedData.toBase64();
return QString::fromUtf8(encodedData);
}
static QString decompressMacroString(const QString &input)
{
QByteArray encodedData = input.toUtf8();
QByteArray compressedData = QByteArray::fromBase64(encodedData);
auto outputData = qUncompress(compressedData);
return QString::fromUtf8(outputData);
}
static bool isValidData(const QString &json)
{
OBSDataAutoRelease data =
obs_data_create_from_json(json.toStdString().c_str());
return !!data;
}
// ---------------------------------------------------------------------------
// Extension section
// ---------------------------------------------------------------------------
QWidget *MacroExportImportDialog::BuildExtensionWidget()
{
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();
auto outerLayout = new QVBoxLayout(outerContent);
outerLayout->setContentsMargins(4, 4, 4, 4);
outerLayout->setSpacing(4);
const auto &extensions = GetMacroExportExtensions();
std::vector<int> order(extensions.size());
std::iota(order.begin(), order.end(), 0);
std::sort(order.begin(), order.end(), [&](int a, int b) {
return QString::localeAwareCompare(
obs_module_text(extensions[a].displayNameKey),
obs_module_text(extensions[b].displayNameKey)) <
0;
});
for (int extIdx : order) {
const auto &ext = extensions[extIdx];
ExtensionUI ui;
// Simple all-or-nothing checkbox (no sub-items).
if (!ext.getExportItems) {
ui.mainCheck = new QCheckBox(
obs_module_text(ext.displayNameKey),
outerContent);
ui.mainCheck->setChecked(false);
connect(ui.mainCheck, &QCheckBox::stateChanged, this,
&MacroExportImportDialog::UpdateExportString);
outerLayout->addWidget(ui.mainCheck);
_extensionUIs.append(std::move(ui));
_extensionOrder.push_back(extIdx);
continue;
}
// Extensions with per-item selection get their own inner
// Section: the main checkbox sits in the header (next to the
// toggle arrow) and the per-item checkboxes live in the
// collapsible content area.
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 =
new QCheckBox(obs_module_text(ext.displayNameKey));
ui.mainCheck->setChecked(false);
innerSection->AddHeaderWidget(ui.mainCheck);
auto subWidget = new QWidget();
auto subLayout = new QVBoxLayout(subWidget);
subLayout->setContentsMargins(4, 2, 4, 2);
subLayout->setSpacing(2);
for (const auto &[id, displayName] : items) {
auto cb = new QCheckBox(displayName, subWidget);
cb->setChecked(false);
subLayout->addWidget(cb);
ui.itemChecks.append({id, cb});
connect(cb, &QCheckBox::stateChanged, this,
&MacroExportImportDialog::UpdateExportString);
}
// Main checkbox toggles all sub-items.
// Capture uiIdx (position in _extensionUIs after append).
const int uiIdx = _extensionUIs.size();
connect(ui.mainCheck, &QCheckBox::stateChanged, this,
[this, uiIdx](int state) {
if (state == Qt::PartiallyChecked)
return;
const bool checked = (state == Qt::Checked);
if (uiIdx < _extensionUIs.size()) {
for (auto &[id, cb] :
_extensionUIs[uiIdx].itemChecks) {
QSignalBlocker b(cb);
cb->setChecked(checked);
}
}
UpdateExportString();
});
innerSection->SetContent(subWidget, true);
outerLayout->addWidget(innerSection);
_extensionUIs.append(std::move(ui));
_extensionOrder.push_back(extIdx);
}
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;
}
// ---------------------------------------------------------------------------
// Constructor
// ---------------------------------------------------------------------------
MacroExportImportDialog::MacroExportImportDialog(Type type,
const QString &baseJson)
: QDialog(nullptr),
_baseJson(baseJson),
_importExportString(new QPlainTextEdit(this)),
_usePlainText(new QCheckBox(obs_module_text(
"AdvSceneSwitcher.macroTab.export.usePlainText")))
{
_importExportString->setReadOnly(type == Type::EXPORT_MACRO);
auto label = new QLabel(obs_module_text(
type == Type::EXPORT_MACRO
? "AdvSceneSwitcher.macroTab.export.info"
@@ -39,40 +200,90 @@ MacroExportImportDialog::MacroExportImportDialog(Type type)
connect(_usePlainText, &QCheckBox::stateChanged, this,
&MacroExportImportDialog::UsePlainTextChanged);
auto layout = new QVBoxLayout;
layout->addWidget(label);
layout->addWidget(_importExportString);
layout->addWidget(_usePlainText);
auto layout = new QVBoxLayout(this);
if (type == Type::EXPORT_MACRO && !GetMacroExportExtensions().empty()) {
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(buttons);
setLayout(layout);
setWindowTitle(obs_module_text("AdvSceneSwitcher.windowTitle"));
if (type == Type::EXPORT_MACRO) {
RefreshExportText();
}
}
QString compressMacroString(const QString &input)
// ---------------------------------------------------------------------------
// Export string helpers
// ---------------------------------------------------------------------------
QString MacroExportImportDialog::BuildExportJson() const
{
QByteArray inputData = input.toUtf8();
auto compressedData = qCompress(inputData);
QByteArray encodedData = compressedData.toBase64();
return QString::fromUtf8(encodedData);
OBSDataAutoRelease data =
obs_data_create_from_json(_baseJson.toStdString().c_str());
if (!data) {
return _baseJson;
}
const auto &extensions = GetMacroExportExtensions();
for (int i = 0; i < (int)_extensionUIs.size(); ++i) {
const auto &ui = _extensionUIs[i];
QStringList selectedIds;
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[_extensionOrder[i]].save(data, selectedIds);
}
const char *json = obs_data_get_json(data);
return json ? QString::fromUtf8(json) : QString{};
}
QString decompressMacroString(const QString &input)
void MacroExportImportDialog::RefreshExportText()
{
QByteArray encodedData = input.toUtf8();
QByteArray compressedData = QByteArray::fromBase64(encodedData);
auto outputData = qUncompress(compressedData);
return QString::fromUtf8(outputData);
const QString json = BuildExportJson();
if (usePlainText) {
_importExportString->setPlainText(json);
} else {
_importExportString->setPlainText(compressMacroString(json));
}
}
void MacroExportImportDialog::ExportMacros(const QString &json)
void MacroExportImportDialog::UpdateExportString()
{
MacroExportImportDialog dialog(
MacroExportImportDialog::Type::EXPORT_MACRO);
dialog._importExportString->setPlainText(compressMacroString(json));
dialog.adjustSize();
dialog.updateGeometry();
dialog.exec();
RefreshExportText();
}
void MacroExportImportDialog::UsePlainTextChanged(int value)
@@ -90,26 +301,45 @@ void MacroExportImportDialog::UsePlainTextChanged(int value)
usePlainText = value;
}
static bool isValidData(const QString &json)
// ---------------------------------------------------------------------------
// Static entry points
// ---------------------------------------------------------------------------
void MacroExportImportDialog::ExportMacros(obs_data_t *macroData)
{
OBSDataAutoRelease data =
obs_data_create_from_json(json.toStdString().c_str());
return !!data;
const char *rawJson = obs_data_get_json(macroData);
const QString baseJson = rawJson ? QString::fromUtf8(rawJson)
: QString{};
MacroExportImportDialog dialog(Type::EXPORT_MACRO, baseJson);
dialog.adjustSize();
dialog.updateGeometry();
dialog.exec();
}
bool MacroExportImportDialog::ImportMacros(QString &json)
{
MacroExportImportDialog dialog(
MacroExportImportDialog::Type::IMPORT_MACRO);
if (dialog.exec() == QDialog::Accepted) {
json = decompressMacroString(
dialog._importExportString->toPlainText());
if (!isValidData(json)) { // Fallback to support raw json format
json = dialog._importExportString->toPlainText();
}
return true;
MacroExportImportDialog dialog(Type::IMPORT_MACRO);
if (dialog.exec() != QDialog::Accepted) {
return false;
}
return false;
json = decompressMacroString(dialog._importExportString->toPlainText());
if (!isValidData(json)) {
// Fallback: support raw (uncompressed) JSON pasted directly.
json = dialog._importExportString->toPlainText();
}
// Invoke all extension load callbacks.
OBSDataAutoRelease data =
obs_data_create_from_json(json.toStdString().c_str());
if (data) {
for (const auto &ext : GetMacroExportExtensions()) {
ext.load(data, {});
}
}
return true;
}
} // namespace advss

View File

@@ -1,25 +1,47 @@
#pragma once
#include <QCheckBox>
#include <QDialog>
#include <QList>
#include <QPlainTextEdit>
#include <obs-data.h>
#include <vector>
namespace advss {
class MacroExportImportDialog : public QDialog {
Q_OBJECT
public:
enum class Type { EXPORT_MACRO, IMPORT_MACRO };
MacroExportImportDialog(Type type);
static void ExportMacros(const QString &json);
static void ExportMacros(obs_data_t *macroData);
static bool ImportMacros(QString &json);
private slots:
void UsePlainTextChanged(int);
void UpdateExportString();
private:
explicit MacroExportImportDialog(Type type,
const QString &baseJson = {});
// Per-extension widgets
struct ExtensionUI {
QCheckBox *mainCheck = nullptr;
// (itemId, checkbox) pairs - empty when no per-item selection.
QList<QPair<QString, QCheckBox *>> itemChecks;
};
QWidget *BuildExtensionWidget();
QString BuildExportJson() const;
void RefreshExportText();
QString _baseJson;
QPlainTextEdit *_importExportString;
QCheckBox *_usePlainText;
QList<ExtensionUI> _extensionUIs;
std::vector<int> _extensionOrder;
};
} // namespace advss

View File

@@ -214,6 +214,20 @@ bool MacroIsPaused(const Macro *macro)
return macro ? macro->Paused() : true;
}
void SetMacroPaused(Macro *macro, bool paused)
{
if (macro) {
macro->SetPaused(paused);
}
}
void StopMacro(Macro *macro)
{
if (macro) {
macro->Stop();
}
}
bool MacroWasPausedSince(
const Macro *macro,
const std::chrono::high_resolution_clock::time_point &time)
@@ -237,14 +251,20 @@ void AddMacroHelperThread(Macro *macro, std::thread &&newThread)
macro->AddHelperThread(std::move(newThread));
}
bool RunMacroActions(Macro *macro)
bool RunMacroActions(Macro *macro, bool forceParallel, bool ignorePause)
{
return macro && macro->PerformActions(true);
return macro && macro->PerformActions(true, forceParallel, ignorePause);
}
bool RunMacroElseActions(Macro *macro)
bool RunMacroElseActions(Macro *macro, bool forceParallel, bool ignorePause)
{
return macro && macro->PerformActions(false);
return macro &&
macro->PerformActions(false, forceParallel, ignorePause);
}
bool CheckMacroConditions(Macro *macro, bool ignorePause)
{
return macro && macro->CheckConditions(ignorePause);
}
void ResetMacroConditionTimers(Macro *macro)

View File

@@ -58,6 +58,8 @@ LastMacroConditionCheckTime(const Macro *);
EXPORT bool MacroIsStopped(const Macro *);
EXPORT bool MacroIsPaused(const Macro *);
EXPORT void SetMacroPaused(Macro *, bool paused);
EXPORT void StopMacro(Macro *);
EXPORT bool
MacroWasPausedSince(const Macro *,
const std::chrono::high_resolution_clock::time_point &);
@@ -66,11 +68,16 @@ EXPORT bool MacroWasCheckedSinceLastStart(const Macro *);
EXPORT void AddMacroHelperThread(Macro *, std::thread &&);
EXPORT bool CheckMacros();
EXPORT bool CheckMacroConditions(Macro *, bool ignorePause = false);
EXPORT bool RunMacroActions(Macro *);
bool RunMacroElseActions(Macro *);
EXPORT bool RunMacroActions(Macro *, bool forceParallel = false,
bool ignorePause = false);
EXPORT bool RunMacroElseActions(Macro *, bool forceParallel = false,
bool ignorePause = false);
EXPORT bool RunMacros();
void StopAllMacros();
void SignalStopAllMacros();
void WaitForAllMacros();
EXPORT void LoadMacros(obs_data_t *obj);
EXPORT void SaveMacros(obs_data_t *obj);

View File

@@ -1,6 +1,8 @@
#include "macro-ref.hpp"
#include "macro.hpp"
#include "plugin-state-helpers.hpp"
namespace advss {
MacroRef::MacroRef(std::string name)
@@ -19,6 +21,7 @@ void MacroRef::Load(obs_data_t *obj)
auto name = obs_data_get_string(obj, "macro");
_postLoadName = name;
_macro = GetWeakMacroByName(name);
AddPostLoadStep([this]() { MacroRef::PostLoad(); });
}
void MacroRef::PostLoad()
@@ -71,11 +74,11 @@ void LoadMacroList(obs_data_t *obj, std::vector<MacroRef> &macros,
{
obs_data_array_t *array = obs_data_get_array(obj, name.c_str());
size_t count = obs_data_array_count(array);
macros.reserve(macros.size() + count);
for (size_t i = 0; i < count; i++) {
obs_data_t *array_obj = obs_data_array_item(array, i);
MacroRef ref;
ref.Load(array_obj);
macros.push_back(ref);
macros.emplace_back();
macros.back().Load(array_obj);
obs_data_release(array_obj);
}
obs_data_array_release(array);

View File

@@ -72,15 +72,24 @@ void MacroEdit::PasteMacroSegment()
}
AddMacroCondition(macro.get(), macro->Conditions().size(),
copyInfo.segment->GetId(), data.Get(), logic);
if (!macro->Conditions().empty()) {
macro->Conditions().back()->FixupVarMappingRefs();
}
break;
}
case MacroSegmentCopyInfo::Type::ACTION:
AddMacroAction(macro.get(), macro->Actions().size(),
copyInfo.segment->GetId(), data.Get());
if (!macro->Actions().empty()) {
macro->Actions().back()->FixupVarMappingRefs();
}
break;
case MacroSegmentCopyInfo::Type::ELSE:
AddMacroElseAction(macro.get(), macro->ElseActions().size(),
copyInfo.segment->GetId(), data.Get());
if (!macro->ElseActions().empty()) {
macro->ElseActions().back()->FixupVarMappingRefs();
}
break;
default:
break;

View File

@@ -54,6 +54,23 @@ const std::vector<VarMapping> &MacroSegment::GetVarMappings() const
void MacroSegment::SetVarMappings(const std::vector<VarMapping> &mappings)
{
_varMappings = mappings;
IncrementTempVarInUseGeneration();
}
void MacroSegment::FixupVarMappingRefs()
{
for (auto &mapping : _varMappings) {
if (!mapping.tempVar.HasValidID()) {
continue;
}
for (const auto &var : _tempVariables) {
if (var.ID() == mapping.tempVar.GetID()) {
mapping.tempVar = var.GetRef();
break;
}
}
}
NotifyUIAboutTempVarChange(this);
}
std::vector<TempVariable> MacroSegment::GetOwnTempVars() const

View File

@@ -57,6 +57,7 @@ public:
const std::vector<VarMapping> &GetVarMappings() const;
void SetVarMappings(const std::vector<VarMapping> &mappings);
std::vector<TempVariable> GetOwnTempVars() const;
void FixupVarMappingRefs();
protected:
friend bool SupportsVariableValue(MacroSegment *);
@@ -133,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

@@ -18,6 +18,7 @@ signals:
void Remove(const QString &);
void SegmentOrderChanged();
void HighlightChanged(bool value);
void ConditionTypeCreated(const QString &typeId);
private:
MacroSignalManager(QObject *parent = nullptr);

View File

@@ -1,5 +1,4 @@
#include "advanced-scene-switcher.hpp"
#include "action-queue.hpp"
#include "macro-action-edit.hpp"
#include "macro-condition-edit.hpp"
#include "macro-export-import-dialog.hpp"
@@ -316,13 +315,9 @@ void AdvSceneSwitcher::ExportMacros() const
obs_data_array_push_back(macroArray, obj);
}
obs_data_set_array(data, "macros", macroArray);
SaveVariables(data);
SaveActionQueues(data);
obs_data_set_string(data, "version", g_GIT_TAG);
auto json = obs_data_get_json(data);
QString exportString(json);
MacroExportImportDialog::ExportMacros(exportString);
MacroExportImportDialog::ExportMacros(data);
}
bool AdvSceneSwitcher::ResolveMacroImportNameConflict(
@@ -384,8 +379,6 @@ void AdvSceneSwitcher::ImportMacros()
ImportMacros();
return;
}
ImportVariables(data);
ImportQueues(data);
auto version = obs_data_get_string(data, "version");
if (strcmp(version, g_GIT_TAG) != 0) {
@@ -402,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();
@@ -412,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

@@ -129,12 +129,11 @@ static bool checkCondition(const std::shared_ptr<MacroCondition> &condition)
const auto timeSpent = endTime - startTime;
if (timeSpent >= perfLogThreshold) {
const long int ms =
std::chrono::duration_cast<std::chrono::milliseconds>(
timeSpent)
.count();
blog(LOG_WARNING,
"spent %ld ms in %s condition check of macro '%s'!", ms,
"spent %lld ms in %s condition check of macro '%s'!",
(long long)std::chrono::duration_cast<
std::chrono::milliseconds>(timeSpent)
.count(),
condition->GetId().c_str(),
condition->GetMacro()->Name().c_str());
}
@@ -1317,7 +1316,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() { _stop = true; }
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

@@ -6,6 +6,7 @@
#import <Carbon/Carbon.h>
#include <Carbon/Carbon.h>
#include <util/platform.h>
#include <set>
#include <vector>
#include <QStringList>
#include <QRegularExpression>
@@ -14,41 +15,13 @@
namespace advss {
std::vector<std::string> GetWindowList()
static std::string nsStringToStdString(NSString *str)
{
std::vector<std::string> windows;
@autoreleasepool {
CFArrayRef cfApps = CGWindowListCopyWindowInfo(
kCGWindowListOptionOnScreenOnly, kCGNullWindowID);
NSMutableArray *apps = (__bridge NSMutableArray *)cfApps;
for (NSDictionary *app in apps) {
std::string name([[app objectForKey:@"kCGWindowName"]
UTF8String],
[[app objectForKey:@"kCGWindowName"]
lengthOfBytesUsingEncoding:
NSUTF8StringEncoding]);
std::string owner(
[[app objectForKey:@"kCGWindowOwnerName"]
UTF8String],
[[app objectForKey:@"kCGWindowOwnerName"]
lengthOfBytesUsingEncoding:
NSUTF8StringEncoding]);
if (!name.empty() &&
find(windows.begin(), windows.end(), name) ==
windows.end()) {
windows.emplace_back(name);
}
if (!owner.empty() &&
find(windows.begin(), windows.end(), owner) ==
windows.end()) {
windows.emplace_back(owner);
}
}
apps = nil;
CFRelease(cfApps);
if (!str) {
return "";
}
return windows;
const char *utf8 = [str UTF8String];
return utf8 ? utf8 : "";
}
std::string GetCurrentWindowTitle()
@@ -100,100 +73,49 @@ std::string GetCurrentWindowTitle()
return title;
}
bool isWindowOriginOnScreen(NSDictionary *app, NSScreen *screen,
bool fullscreen = false)
{
NSArray *screens = [NSScreen screens];
NSRect mainScreenFrame = [screens[0] frame];
NSRect screenFrame;
if (fullscreen) {
screenFrame = [screen frame];
} else {
screenFrame = [screen visibleFrame];
}
NSRect windowBounds;
CGRectMakeWithDictionaryRepresentation(
(CFDictionaryRef)[app objectForKey:@"kCGWindowBounds"],
&windowBounds);
return (windowBounds.origin.x == screenFrame.origin.x &&
(mainScreenFrame.size.height - screenFrame.size.height -
windowBounds.origin.y ==
screenFrame.origin.y));
}
bool isWindowMaximizedOnScreen(NSDictionary *app, NSScreen *screen)
{
double maximizedTolerance = 0.99;
NSRect screenFrame = [screen frame];
NSRect windowBounds;
CGRectMakeWithDictionaryRepresentation(
(CFDictionaryRef)[app objectForKey:@"kCGWindowBounds"],
&windowBounds);
int sumX = windowBounds.origin.x + windowBounds.size.width;
int sumY = windowBounds.origin.y + windowBounds.size.height;
// CGWindowList: flipped coords, origin at top-left of main display.
// NSScreen: Cocoa coords, origin at bottom-left of main display.
NSRect mainFrame = [[NSScreen screens][0] frame];
NSRect visFrame = [screen visibleFrame];
// Return false if window spans over multiple screens
if (sumX > screenFrame.size.width) {
// Convert visible area to CGWindow coordinates.
// screenTop is just below the menubar; screenBottom is just above the
// dock.
CGFloat screenLeft = visFrame.origin.x;
CGFloat screenTop =
mainFrame.size.height - visFrame.origin.y - visFrame.size.height;
CGFloat screenRight = screenLeft + visFrame.size.width;
CGFloat screenBottom = screenTop + visFrame.size.height;
const double tolerance = 4.0;
// Width must span the full visible width.
if (fabs(windowBounds.origin.x - screenLeft) > tolerance) {
return false;
}
if (sumY > screenFrame.size.height) {
if (fabs((windowBounds.origin.x + windowBounds.size.width) -
screenRight) > tolerance) {
return false;
}
return ((double)sumX / (double)screenFrame.size.width) >
maximizedTolerance &&
((double)sumY / (double)screenFrame.size.height) >
maximizedTolerance;
}
bool nameMachesPattern(std::string windowName, std::string pattern)
{
return QString::fromStdString(windowName)
.contains(QRegularExpression(QString::fromStdString(pattern)));
}
bool IsMaximized(const std::string &title)
{
@autoreleasepool {
NSArray *screens = [NSScreen screens];
CFArrayRef cfApps = CGWindowListCopyWindowInfo(
kCGWindowListOptionOnScreenOnly, kCGNullWindowID);
NSMutableArray *apps = (__bridge NSMutableArray *)cfApps;
for (NSDictionary *app in apps) {
std::string name([[app objectForKey:@"kCGWindowName"]
UTF8String],
[[app objectForKey:@"kCGWindowName"]
lengthOfBytesUsingEncoding:
NSUTF8StringEncoding]);
std::string owner(
[[app objectForKey:@"kCGWindowOwnerName"]
UTF8String],
[[app objectForKey:@"kCGWindowOwnerName"]
lengthOfBytesUsingEncoding:
NSUTF8StringEncoding]);
bool equals = (title == name || title == owner);
bool matches = nameMachesPattern(name, title) ||
nameMachesPattern(owner, title);
if (equals || matches) {
for (NSScreen *screen in screens) {
if (isWindowOriginOnScreen(app,
screen) &&
isWindowMaximizedOnScreen(app,
screen)) {
apps = nil;
CFRelease(cfApps);
return true;
}
}
}
}
apps = nil;
CFRelease(cfApps);
// Bottom edge must align with the visible bottom (above dock).
if (fabs((windowBounds.origin.y + windowBounds.size.height) -
screenBottom) > tolerance) {
return false;
}
return false;
// Top edge must be at or above the menubar line (some apps extend
// their window frame behind the menubar).
if (windowBounds.origin.y > screenTop + tolerance) {
return false;
}
return true;
}
bool isWindowFullscreenOnScreen(NSDictionary *app, NSScreen *screen)
@@ -207,52 +129,176 @@ bool isWindowFullscreenOnScreen(NSDictionary *app, NSScreen *screen)
return NSEqualSizes(windowBounds.size, screenFrame.size);
}
bool IsFullscreen(const std::string &title)
std::vector<WindowInfo> GetWindows(const WindowQueryOptions &options)
{
std::vector<WindowInfo> result;
const std::string foregroundTitle = GetCurrentWindowTitle();
@autoreleasepool {
NSArray *screens = [NSScreen screens];
CFArrayRef cfApps = CGWindowListCopyWindowInfo(
kCGWindowListOptionOnScreenOnly, kCGNullWindowID);
NSMutableArray *apps = (__bridge NSMutableArray *)cfApps;
for (NSDictionary *app in apps) {
std::string name([[app objectForKey:@"kCGWindowName"]
UTF8String],
[[app objectForKey:@"kCGWindowName"]
lengthOfBytesUsingEncoding:
NSUTF8StringEncoding]);
std::string owner(
[[app objectForKey:@"kCGWindowOwnerName"]
UTF8String],
[[app objectForKey:@"kCGWindowOwnerName"]
lengthOfBytesUsingEncoding:
NSUTF8StringEncoding]);
bool equals = (title == name || title == owner);
bool matches = nameMachesPattern(name, title) ||
nameMachesPattern(owner, title);
if (equals || matches) {
CFArrayRef cfApps = CGWindowListCopyWindowInfo(
kCGWindowListExcludeDesktopElements, kCGNullWindowID);
NSMutableArray *apps = (__bridge NSMutableArray *)cfApps;
// Pre-compute which PIDs own at least one fullscreen-sized window.
// A fullscreen app reports a small chrome/title bar window first and
// its full-size content window second; checking only the first
// window's bounds gives a false negative.
std::set<int> fullscreenPIDs;
if (options.fullscreen) {
for (NSDictionary *app in apps) {
int layer = [[app objectForKey:@"kCGWindowLayer"]
intValue];
if (layer != 0) {
continue;
}
for (NSScreen *screen in screens) {
if (isWindowOriginOnScreen(app, screen,
true) &&
isWindowFullscreenOnScreen(
app, screen)) {
apps = nil;
CFRelease(cfApps);
return true;
if (isWindowFullscreenOnScreen(app,
screen)) {
int pid = [[app objectForKey:
@"kCGWindowOwnerPID"]
intValue];
fullscreenPIDs.insert(pid);
break;
}
}
}
}
std::set<int> maximizedPIDs;
if (options.maximized) {
for (NSDictionary *app in apps) {
int layer = [[app objectForKey:@"kCGWindowLayer"]
intValue];
if (layer != 0) {
continue;
}
for (NSScreen *screen in screens) {
if (isWindowMaximizedOnScreen(app,
screen)) {
int pid = [[app objectForKey:
@"kCGWindowOwnerPID"]
intValue];
maximizedPIDs.insert(pid);
break;
}
}
}
}
// Pre-compute the largest-area window bounds per PID.
// CGWindowList often lists small auxiliary windows (toolbars,
// chrome) before the main content window; using the largest area
// avoids reporting those wrong bounds for geometry.
std::map<int, NSRect> largestBoundsPerPID;
if (options.geometry || options.maximized) {
for (NSDictionary *app in apps) {
int layer = [[app objectForKey:@"kCGWindowLayer"]
intValue];
if (layer != 0) {
continue;
}
NSRect b = NSZeroRect;
CGRectMakeWithDictionaryRepresentation(
(CFDictionaryRef)[app
objectForKey:@"kCGWindowBounds"],
&b);
int pid = [[app objectForKey:@"kCGWindowOwnerPID"]
intValue];
auto it = largestBoundsPerPID.find(pid);
if (it == largestBoundsPerPID.end() ||
b.size.width * b.size.height >
it->second.size.width *
it->second.size.height) {
largestBoundsPerPID[pid] = b;
}
}
}
// Track titles already added to avoid duplicates (name + owner)
std::vector<std::string> seen;
for (NSDictionary *app in apps) {
int layer =
[[app objectForKey:@"kCGWindowLayer"] intValue];
if (layer != 0) {
continue;
}
std::string name = nsStringToStdString(
[app objectForKey:@"kCGWindowName"]);
std::string owner = nsStringToStdString(
[app objectForKey:@"kCGWindowOwnerName"]);
// Collect geometry once (used for multiple fields)
NSRect bounds = NSZeroRect;
if (options.geometry || options.fullscreen ||
options.maximized) {
CGRectMakeWithDictionaryRepresentation(
(CFDictionaryRef)[app
objectForKey:@"kCGWindowBounds"],
&bounds);
}
// Process both the window name and the owner name as
// separate entries, matching the old GetWindowList() behaviour
for (int pass = 0; pass < 2; ++pass) {
const std::string &title = (pass == 0) ? name
: owner;
if (title.empty()) {
continue;
}
if (std::find(seen.begin(), seen.end(),
title) != seen.end()) {
continue;
}
seen.emplace_back(title);
WindowInfo info;
info.title = title;
if (options.focus) {
info.focused =
(title == foregroundTitle);
}
if (options.geometry || options.fullscreen ||
options.maximized) {
int pid = [[app objectForKey:
@"kCGWindowOwnerPID"]
intValue];
auto it = largestBoundsPerPID.find(pid);
const NSRect &geoBounds =
(it != largestBoundsPerPID.end())
? it->second
: bounds;
info.x = (int)geoBounds.origin.x;
info.y = (int)geoBounds.origin.y;
info.width = (int)geoBounds.size.width;
info.height = (int)geoBounds.size.height;
if (options.fullscreen) {
info.fullscreen =
fullscreenPIDs.count(pid) >
0;
}
if (options.maximized) {
info.maximized =
maximizedPIDs.count(pid) >
0;
}
}
// windowClass and text not implemented on macOS
result.emplace_back(std::move(info));
}
}
apps = nil;
CFRelease(cfApps);
}
return false;
}
std::optional<std::string> GetTextInWindow(const std::string &)
{
// Not implemented
return {};
return result;
}
int SecondsSinceLastInput()
@@ -341,8 +387,7 @@ QStringList GetProcessPathsFromName(const QString &name)
continue;
}
const char *nameStr = appName.UTF8String;
if (!nameStr ||
name != QString::fromUtf8(nameStr)) {
if (!nameStr || name != QString::fromUtf8(nameStr)) {
continue;
}
NSURL *url = app.executableURL;

View File

@@ -11,11 +11,31 @@ namespace advss {
enum class HotkeyType;
EXPORT std::vector<std::string> GetWindowList();
struct WindowQueryOptions {
bool geometry = false;
bool focus = false;
bool fullscreen = false;
bool maximized = false;
bool windowClass = false; // Windows only, no-op on other platforms
bool text = false; // Windows only, expensive, no-op on other platforms
};
struct WindowInfo {
std::string title;
bool focused = false;
bool fullscreen = false;
bool maximized = false;
int x = 0;
int y = 0;
int width = 0;
int height = 0;
std::string windowClass;
std::optional<std::string> text;
};
EXPORT std::vector<WindowInfo>
GetWindows(const WindowQueryOptions &options = {});
EXPORT std::string GetCurrentWindowTitle();
EXPORT bool IsFullscreen(const std::string &title);
EXPORT bool IsMaximized(const std::string &title);
EXPORT std::optional<std::string> GetTextInWindow(const std::string &window);
EXPORT int SecondsSinceLastInput();
EXPORT QStringList GetProcessList();
EXPORT std::string GetForegroundProcessName();

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

@@ -11,6 +11,7 @@ static void populateUnits(QComboBox *list)
list->addItem(obs_module_text("AdvSceneSwitcher.unit.seconds"));
list->addItem(obs_module_text("AdvSceneSwitcher.unit.minutes"));
list->addItem(obs_module_text("AdvSceneSwitcher.unit.hours"));
list->addItem(obs_module_text("AdvSceneSwitcher.unit.days"));
}
DurationSelection::DurationSelection(QWidget *parent, bool showUnitSelection,
@@ -66,6 +67,8 @@ static int durationUnitToMultiplier(Duration::Unit u)
return 60;
case Duration::Unit::HOURS:
return 3600;
case Duration::Unit::DAYS:
return 86400;
default:
break;
}

View File

@@ -121,6 +121,8 @@ static std::string durationUnitToString(Duration::Unit u)
return obs_module_text("AdvSceneSwitcher.unit.minutes");
case Duration::Unit::HOURS:
return obs_module_text("AdvSceneSwitcher.unit.hours");
case Duration::Unit::DAYS:
return obs_module_text("AdvSceneSwitcher.unit.days");
default:
break;
}
@@ -151,6 +153,8 @@ static int durationUnitToMultiplier(Duration::Unit u)
return 60;
case Duration::Unit::HOURS:
return 3600;
case Duration::Unit::DAYS:
return 86400;
default:
break;
}

View File

@@ -27,6 +27,7 @@ public:
SECONDS,
MINUTES,
HOURS,
DAYS,
};
EXPORT Unit GetUnit() const { return _unit; }

View File

@@ -398,10 +398,14 @@ void FirstRunWizard::markFirstRunComplete()
}
// static
std::shared_ptr<Macro> FirstRunWizard::ShowWizard(QWidget *parent)
std::shared_ptr<Macro> FirstRunWizard::ShowWizard(QWidget *parent,
bool *wasSkipped)
{
auto wizard = new FirstRunWizard(parent);
wizard->exec();
if (wasSkipped) {
*wasSkipped = (wizard->result() == QDialog::Rejected);
}
wizard->deleteLater();
return wizard->_macro;
}

View File

@@ -113,7 +113,8 @@ class FirstRunWizard : public QWizard {
Q_OBJECT
public:
explicit FirstRunWizard(QWidget *parent = nullptr);
static std::shared_ptr<Macro> ShowWizard(QWidget *parent);
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,

View File

@@ -173,10 +173,10 @@ void PopulateTransitionSelection(QComboBox *sel, bool addCurrent, bool addAny,
void PopulateWindowSelection(QComboBox *sel, bool addSelect)
{
const auto windows = GetWindowList();
const auto windows = GetWindows();
for (const std::string &window : windows) {
sel->addItem(window.c_str());
for (const auto &info : windows) {
sel->addItem(info.title.c_str());
}
sel->model()->sort(0);

View File

@@ -162,4 +162,48 @@ bool IsMediaSource(obs_source_t *source)
return (flags & OBS_SOURCE_CONTROLLABLE_MEDIA) != 0;
}
SourceActiveKeeper::~SourceActiveKeeper()
{
ReleaseRef();
}
void SourceActiveKeeper::SetActive(bool active)
{
if (_active == active) {
return;
}
if (active) {
_active = true;
AcquireRef();
} else {
ReleaseRef();
_active = false;
}
}
void SourceActiveKeeper::SetSource(obs_source_t *source)
{
if (_source == source) {
return;
}
ReleaseRef();
_source = source;
AcquireRef();
}
void SourceActiveKeeper::AcquireRef()
{
if (_active && _source) {
obs_source_inc_active(_source);
}
}
void SourceActiveKeeper::ReleaseRef()
{
if (_active && _source) {
obs_source_dec_active(_source);
_source = nullptr;
}
}
} // namespace advss

View File

@@ -20,4 +20,25 @@ EXPORT OBSWeakSource GetWeakFilterByQString(OBSWeakSource source,
EXPORT int GetSceneItemCount(const OBSWeakSource &);
EXPORT bool IsMediaSource(obs_source_t *source);
// RAII helper that keeps an OBS source active (via obs_source_inc_active /
// obs_source_dec_active) for as long as the keeper is enabled.
class EXPORT SourceActiveKeeper {
public:
SourceActiveKeeper() = default;
~SourceActiveKeeper();
SourceActiveKeeper(const SourceActiveKeeper &) = delete;
SourceActiveKeeper &operator=(const SourceActiveKeeper &) = delete;
void SetActive(bool active);
void SetSource(obs_source_t *source);
private:
void AcquireRef();
void ReleaseRef();
OBSSource _source;
bool _active = false;
};
} // namespace advss

View File

@@ -74,6 +74,7 @@ public:
EXPORT std::optional<const TempVariable> GetTempVariable(Macro *) const;
EXPORT bool operator==(const TempVariableRef &other) const;
bool HasValidID() const { return !_id.empty(); }
std::string GetID() const { return _id; }
private:
enum class SegmentType { NONE, CONDITION, ACTION, ELSEACTION };

View File

@@ -29,6 +29,7 @@ static void save(obs_data_t *data)
{
tabSettings.Save(data, "tabSettings");
dockSettings.Save(data, "dockSettings");
obs_data_set_bool(data, "addVariablesDock", addDock);
}

View File

@@ -138,7 +138,7 @@ const std::vector<std::string> getOBSWindows()
return lastDoneHelper->windows;
}
std::vector<std::string> GetWindowList()
static std::vector<std::string> getAllWindowTitles()
{
std::vector<std::string> windows;
EnumWindowsWithMetro(GetTitleCB, reinterpret_cast<LPARAM>(&windows));
@@ -155,6 +155,129 @@ std::vector<std::string> GetWindowList()
return windows;
}
static bool isWindowMaximized(HWND hwnd)
{
if (!hwnd || hwnd == GetDesktopWindow() || hwnd == GetShellWindow()) {
return false;
}
if (IsZoomed(hwnd)) {
return true;
}
RECT appBounds;
MONITORINFO monitorInfo = {0};
monitorInfo.cbSize = sizeof(MONITORINFO);
GetMonitorInfo(MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST),
&monitorInfo);
GetWindowRect(hwnd, &appBounds);
return monitorInfo.rcMonitor.bottom == appBounds.bottom &&
monitorInfo.rcMonitor.top == appBounds.top &&
monitorInfo.rcMonitor.left == appBounds.left &&
monitorInfo.rcMonitor.right == appBounds.right;
}
static bool isWindowFullscreen(HWND hwnd)
{
if (!hwnd || hwnd == GetDesktopWindow() || hwnd == GetShellWindow()) {
return false;
}
RECT appBounds;
MONITORINFO monitorInfo = {0};
monitorInfo.cbSize = sizeof(MONITORINFO);
GetMonitorInfo(MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST),
&monitorInfo);
GetWindowRect(hwnd, &appBounds);
return monitorInfo.rcMonitor.bottom == appBounds.bottom &&
monitorInfo.rcMonitor.top == appBounds.top &&
monitorInfo.rcMonitor.left == appBounds.left &&
monitorInfo.rcMonitor.right == appBounds.right;
}
static std::string getWindowClass(HWND hwnd)
{
std::wstring wClass;
wClass.resize(256);
int len = GetClassNameW(hwnd, &wClass[0], (int)wClass.size());
if (len <= 0) {
return "";
}
wClass.resize(len);
size_t utf8len = os_wcs_to_utf8(wClass.c_str(), 0, nullptr, 0);
std::string result;
result.resize(utf8len);
os_wcs_to_utf8(wClass.c_str(), 0, &result[0], utf8len + 1);
return result;
}
static HWND getHWNDfromTitle(const std::string &title);
static std::optional<std::string> GetTextInWindow(const std::string &window);
std::vector<WindowInfo> GetWindows(const WindowQueryOptions &options)
{
const std::string foregroundTitle = GetCurrentWindowTitle();
const auto titles = getAllWindowTitles();
std::vector<WindowInfo> result;
result.reserve(titles.size());
for (const auto &title : titles) {
WindowInfo info;
info.title = title;
const bool needHwnd = options.geometry || options.fullscreen ||
options.maximized ||
options.windowClass || options.text ||
options.focus;
if (options.focus) {
info.focused = (title == foregroundTitle);
}
if (!needHwnd || (!options.geometry && !options.fullscreen &&
!options.maximized && !options.windowClass &&
!options.text)) {
result.emplace_back(std::move(info));
continue;
}
HWND hwnd = getHWNDfromTitle(title);
if (!hwnd) {
result.emplace_back(std::move(info));
continue;
}
if (options.geometry || options.fullscreen ||
options.maximized) {
RECT rect;
if (GetWindowRect(hwnd, &rect)) {
info.x = rect.left;
info.y = rect.top;
info.width = rect.right - rect.left;
info.height = rect.bottom - rect.top;
}
}
if (options.maximized) {
info.maximized = isWindowMaximized(hwnd);
}
if (options.fullscreen) {
info.fullscreen = isWindowFullscreen(hwnd);
}
if (options.windowClass) {
info.windowClass = getWindowClass(hwnd);
}
if (options.text) {
info.text = GetTextInWindow(title);
}
result.emplace_back(std::move(info));
}
return result;
}
std::string GetCurrentWindowTitle()
{
HWND window = GetForegroundWindow();
@@ -193,36 +316,6 @@ static HWND getHWNDfromTitle(const std::string &title)
return hwnd;
}
bool IsMaximized(const std::string &title)
{
RECT appBounds;
MONITORINFO monitorInfo = {0};
HWND hwnd = NULL;
hwnd = getHWNDfromTitle(title);
if (!hwnd) {
return false;
}
monitorInfo.cbSize = sizeof(MONITORINFO);
GetMonitorInfo(MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST),
&monitorInfo);
if (hwnd && hwnd != GetDesktopWindow() && hwnd != GetShellWindow()) {
if (IsZoomed(hwnd)) {
return true;
}
GetWindowRect(hwnd, &appBounds);
if (monitorInfo.rcMonitor.bottom == appBounds.bottom &&
monitorInfo.rcMonitor.top == appBounds.top &&
monitorInfo.rcMonitor.left == appBounds.left &&
monitorInfo.rcMonitor.right == appBounds.right) {
return true;
}
}
return false;
}
static std::wstring GetControlText(HWND hwnd, IUIAutomationElement *element)
{
VARIANT var;
@@ -249,7 +342,7 @@ static std::wstring GetControlText(HWND hwnd, IUIAutomationElement *element)
return text;
}
std::optional<std::string> GetTextInWindow(const std::string &window)
static std::optional<std::string> GetTextInWindow(const std::string &window)
{
HWND hwnd = getHWNDfromTitle(window);
if (!hwnd) {
@@ -316,35 +409,6 @@ std::optional<std::string> GetTextInWindow(const std::string &window)
return tmp;
}
bool IsFullscreen(const std::string &title)
{
RECT appBounds;
MONITORINFO monitorInfo = {0};
HWND hwnd = NULL;
hwnd = getHWNDfromTitle(title);
if (!hwnd) {
return false;
}
monitorInfo.cbSize = sizeof(MONITORINFO);
GetMonitorInfo(MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST),
&monitorInfo);
if (hwnd && hwnd != GetDesktopWindow() && hwnd != GetShellWindow()) {
GetWindowRect(hwnd, &appBounds);
if (monitorInfo.rcMonitor.bottom == appBounds.bottom &&
monitorInfo.rcMonitor.top == appBounds.top &&
monitorInfo.rcMonitor.left == appBounds.left &&
monitorInfo.rcMonitor.right == appBounds.right) {
return true;
}
}
return false;
}
QStringList GetProcessList()
{
QStringList processes;

View File

@@ -1,5 +1,16 @@
#include <obs-module.h>
#include <obs-frontend-api.h>
#include <stdlib.h>
#ifdef _WIN32
#define ADVSS_SETENV(k, v) _putenv_s(k, v)
#define ADVSS_UNSETENV(k) _putenv_s(k, "")
#else
#define ADVSS_SETENV(k, v) setenv(k, v, 1)
#define ADVSS_UNSETENV(k) unsetenv(k)
#endif
#define ADVSS_INSTANCE_ENV "ADVSS_INSTANCE_LOADED"
OBS_DECLARE_MODULE()
OBS_MODULE_USE_DEFAULT_LOCALE("advanced-scene-switcher", "en-US")
@@ -17,6 +28,12 @@ void obs_module_post_load(void)
bool obs_module_load(void)
{
if (getenv(ADVSS_INSTANCE_ENV)) {
blog(LOG_WARNING,
"[adv-ss] Another instance is already loaded - skipping.");
return false;
}
ADVSS_SETENV(ADVSS_INSTANCE_ENV, "1");
obs_frontend_push_ui_translation(obs_module_get_string);
InitSceneSwitcher(obs_current_module(), obs_module_text);
return true;
@@ -25,4 +42,5 @@ bool obs_module_load(void)
void obs_module_unload(void)
{
FreeSceneSwitcher();
ADVSS_UNSETENV(ADVSS_INSTANCE_ENV);
}

View File

@@ -1,14 +1,16 @@
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()
# ---------------------------------------------------------------------------- #
add_subdirectory(base)
# Add plugins below, which have dependencies to external libraries or other
# components which might potentially not be fulfilled.
@@ -28,12 +30,43 @@ install_advss_plugin_dependency(...)
... to install the plugin and its dependencies.
#]]
add_plugin(base)
add_plugin(http)
add_plugin(midi)
add_plugin(mqtt)
add_plugin(openvr)
add_plugin(schedule)
add_plugin(scripting)
add_plugin(stream-deck)
add_plugin(twitch)
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

@@ -46,7 +46,7 @@ public:
} // namespace
static void handleShutdown(enum obs_frontend_event event, void *private_data)
static void handleShutdown(enum obs_frontend_event event, void *)
{
if (event != OBS_FRONTEND_EVENT_EXIT) {
return;

View File

@@ -259,7 +259,7 @@ void MacroActionSourceInteractionEdit::AcceptRecorded(
_stepList->SetSteps(_entryData->_steps);
}
void MacroActionSourceInteractionEdit::showEvent(QShowEvent *event)
void MacroActionSourceInteractionEdit::showEvent(QShowEvent *)
{
const QSignalBlocker b(this);
UpdateEntryData();

View File

@@ -42,19 +42,20 @@ void CloseWindow(const std::string &) {}
std::optional<std::string> MacroActionWindow::GetMatchingWindow() const
{
const auto windowList = GetWindowList();
const auto windows = GetWindows();
if (!_regex.Enabled()) {
if (std::find(windowList.begin(), windowList.end(),
std::string(_window)) == windowList.end()) {
return {};
for (const auto &info : windows) {
if (info.title == std::string(_window)) {
return info.title;
}
}
return _window;
return {};
}
for (const auto &window : windowList) {
if (_regex.Matches(window, _window)) {
return window;
for (const auto &info : windows) {
if (_regex.Matches(info.title, _window)) {
return info.title;
}
}

View File

@@ -710,7 +710,7 @@ void MacroConditionDateEdit::UpdateEntryData()
SetWidgetStatus();
}
void MacroConditionDateEdit::showEvent(QShowEvent *event)
void MacroConditionDateEdit::showEvent(QShowEvent *)
{
const QSignalBlocker b(this);
UpdateEntryData();

View File

@@ -24,6 +24,8 @@ bool MacroConditionGameCapture::CheckCondition()
return false;
}
_activeKeeper.SetActive(_keepActive);
std::lock_guard<std::mutex> lock(_mtx);
if (_hooked) {
SetTempVarValue("title", _title);
@@ -38,6 +40,7 @@ bool MacroConditionGameCapture::Save(obs_data_t *obj) const
{
MacroCondition::Save(obj);
_source.Save(obj);
obs_data_set_bool(obj, "keepActive", _keepActive);
return true;
}
@@ -45,6 +48,7 @@ bool MacroConditionGameCapture::Load(obs_data_t *obj)
{
MacroCondition::Load(obj);
_source.Load(obj);
_keepActive = obs_data_get_bool(obj, "keepActive");
SetupSignalHandler(OBSGetStrongRef(_source.GetSource()));
return true;
}
@@ -111,6 +115,9 @@ void MacroConditionGameCapture::SetupSignalHandler(obs_source_t *source)
_unhookSignal = OBSSignal(sh, "unhooked", UnhookedSignalReceived, this);
_lastSource = source;
_activeKeeper.SetActive(_keepActive);
_activeKeeper.SetSource(source);
SetupInitialState(source);
}
@@ -164,17 +171,33 @@ MacroConditionGameCaptureEdit::MacroConditionGameCaptureEdit(
QWidget *parent, std::shared_ptr<MacroConditionGameCapture> entryData)
: QWidget(parent),
_sources(new SourceSelectionWidget(this, getGameCaptureSourcesList,
true))
true)),
_keepActive(new QCheckBox(
obs_module_text("AdvSceneSwitcher.keepSourceActive"), this)),
_keepActiveHelp(new HelpIcon(
obs_module_text("AdvSceneSwitcher.keepSourceActive.help"),
this))
{
QWidget::connect(_sources,
SIGNAL(SourceChanged(const SourceSelection &)), this,
SLOT(SourceChanged(const SourceSelection &)));
QWidget::connect(_keepActive, SIGNAL(stateChanged(int)), this,
SLOT(KeepActiveChanged(int)));
auto layout = new QHBoxLayout;
auto entryLayout = new QHBoxLayout;
PlaceWidgets(
obs_module_text("AdvSceneSwitcher.condition.gameCapture.entry"),
layout, {{"{{sources}}", _sources}});
setLayout(layout);
entryLayout, {{"{{sources}}", _sources}});
auto keepActiveLayout = new QHBoxLayout;
keepActiveLayout->addWidget(_keepActive);
keepActiveLayout->addWidget(_keepActiveHelp);
keepActiveLayout->addStretch();
auto mainLayout = new QVBoxLayout;
mainLayout->addLayout(entryLayout);
mainLayout->addLayout(keepActiveLayout);
setLayout(mainLayout);
_entryData = entryData;
UpdateEntryData();
@@ -187,9 +210,16 @@ void MacroConditionGameCaptureEdit::SourceChanged(const SourceSelection &source)
_entryData->_source = source;
}
void MacroConditionGameCaptureEdit::KeepActiveChanged(int state)
{
GUARD_LOADING_AND_LOCK();
_entryData->_keepActive = state;
}
void MacroConditionGameCaptureEdit::UpdateEntryData()
{
_sources->SetSource(_entryData->_source);
_keepActive->setChecked(_entryData->_keepActive);
}
} // namespace advss

View File

@@ -1,9 +1,12 @@
#pragma once
#include "help-icon.hpp"
#include "macro-condition-edit.hpp"
#include "source-helpers.hpp"
#include "source-selection.hpp"
#include <QWidget>
#include <QComboBox>
#include <QCheckBox>
namespace advss {
@@ -20,6 +23,7 @@ public:
}
SourceSelection _source;
bool _keepActive = false;
private:
static void HookedSignalReceived(void *data, calldata_t *);
@@ -31,6 +35,7 @@ private:
void GetCalldataInfo(calldata_t *cd);
obs_source_t *_lastSource = nullptr;
SourceActiveKeeper _activeKeeper;
OBSSignal _hookSignal;
OBSSignal _unhookSignal;
@@ -63,9 +68,12 @@ public:
private slots:
void SourceChanged(const SourceSelection &);
void KeepActiveChanged(int);
private:
SourceSelectionWidget *_sources;
QCheckBox *_keepActive;
HelpIcon *_keepActiveHelp;
std::shared_ptr<MacroConditionGameCapture> _entryData;
bool _loading = true;

View File

@@ -158,8 +158,21 @@ void MacroConditionTransition::TransitionStarted(void *data, calldata_t *cd)
OBSSourceAutoRelease startSource = obs_transition_get_source(
transitionSource, OBS_TRANSITION_SOURCE_A);
// In studio mode, source A may be a private copy of the scene.
// Look up the original by name to get a comparable reference.
const bool studioModeActive =
obs_frontend_preview_program_mode_active();
if (studioModeActive && startSource) {
OBSSourceAutoRelease startSourceByName = obs_get_source_by_name(
obs_source_get_name(startSource));
if (startSourceByName) {
startSource = std::move(startSourceByName);
}
}
OBSSourceAutoRelease endSource =
obs_frontend_preview_program_mode_active()
studioModeActive
? obs_frontend_get_current_scene()
: obs_transition_get_source(transitionSource,
OBS_TRANSITION_SOURCE_B);
@@ -357,6 +370,7 @@ void MacroConditionTransitionEdit::SceneChanged(const SceneSelection &s)
{
GUARD_LOADING_AND_LOCK();
_entryData->_scene = s;
_entryData->ConnectToTransitionSignals();
}
void MacroConditionTransitionEdit::DurationChanged(const Duration &dur)

View File

@@ -14,21 +14,6 @@ bool MacroConditionWindow::_registered = MacroConditionFactory::Register(
{MacroConditionWindow::Create, MacroConditionWindowEdit::Create,
"AdvSceneSwitcher.condition.window"});
static bool windowContainsText(const std::string &window,
const std::string &matchText,
const RegexConfig &regex)
{
auto text = GetTextInWindow(window);
if (!text.has_value()) {
return false;
}
if (regex.Enabled()) {
return regex.Matches(*text, matchText);
}
return text == matchText;
}
void MacroConditionWindow::SetCheckText(bool value)
{
#ifdef _WIN32
@@ -46,83 +31,76 @@ bool MacroConditionWindow::GetCheckText()
}
bool MacroConditionWindow::WindowMatchesRequirements(
const std::string &window) const
const WindowInfo &info) const
{
const bool focusCheckOK =
(!_focus || window == ForegroundWindowTitle());
if (!focusCheckOK) {
if (_focus && !info.focused) {
return false;
}
const bool fullscreenCheckOK = (!_fullscreen || IsFullscreen(window));
if (!fullscreenCheckOK) {
if (_fullscreen && !info.fullscreen) {
return false;
}
const bool maxCheckOK = (!_maximized || IsMaximized(window));
if (!maxCheckOK) {
if (_maximized && !info.maximized) {
return false;
}
const bool textCheckOK =
(!_checkText || windowContainsText(window, _text, _textRegex));
if (!textCheckOK) {
return false;
if (_checkText) {
if (!info.text.has_value()) {
return false;
}
if (_textRegex.Enabled()) {
if (!_textRegex.Matches(*info.text, _text)) {
return false;
}
} else {
if (*info.text != std::string(_text)) {
return false;
}
}
}
return true;
}
bool MacroConditionWindow::WindowMatches(
const std::vector<std::string> &windowList)
bool MacroConditionWindow::FindMatch(const std::vector<WindowInfo> &windows)
{
bool match = !_checkTitle ||
std::find(windowList.begin(), windowList.end(),
std::string(_window)) != windowList.end();
match = match && WindowMatchesRequirements(_window);
SetVariableValueBasedOnMatch(_window);
return match;
}
#ifdef _WIN32
std::string GetWindowClassByWindowTitle(const std::string &window);
#endif
bool MacroConditionWindow::WindowRegexMatches(
const std::vector<std::string> &windowList)
{
// No need to test if checking for window title is required as if the
// user has disabled window title matching the option will always be
// enabled in the backend and use the regular expression ".*".
for (const auto &window : windowList) {
if (_windowRegex.Matches(window, _window) &&
WindowMatchesRequirements(window)) {
SetVariableValueBasedOnMatch(window);
return true;
// When regex is enabled the title check is always active (the backend
// uses ".*" when the user disables the title check), so we can use a
// single predicate for both modes.
for (const auto &info : windows) {
const bool titleOK =
_windowRegex.Enabled()
? _windowRegex.Matches(info.title, _window)
: (!_checkTitle ||
info.title == std::string(_window));
if (!titleOK || !WindowMatchesRequirements(info)) {
continue;
}
SetVariableValueBasedOnMatch(&info);
return true;
}
SetVariableValueBasedOnMatch("");
SetVariableValueBasedOnMatch(nullptr);
return false;
}
void MacroConditionWindow::SetVariableValueBasedOnMatch(
const std::string &matchWindow)
void MacroConditionWindow::SetVariableValueBasedOnMatch(const WindowInfo *info)
{
SetTempVarValue("window", matchWindow);
const std::string title = info ? info->title : "";
SetTempVarValue("window", title);
SetTempVarValue("windowX", info ? std::to_string(info->x) : "");
SetTempVarValue("windowY", info ? std::to_string(info->y) : "");
SetTempVarValue("windowWidth", info ? std::to_string(info->width) : "");
SetTempVarValue("windowHeight",
info ? std::to_string(info->height) : "");
#ifdef _WIN32
SetTempVarValue("windowClass",
GetWindowClassByWindowTitle(matchWindow));
SetTempVarValue("windowClass", info ? info->windowClass : "");
if (_checkText) {
const auto text = GetTextInWindow(matchWindow);
if (text) {
SetTempVarValue("windowText", *text);
}
SetTempVarValue("windowText",
(info && info->text) ? *info->text : "");
}
#endif
if (!IsReferencedInVars()) {
return;
}
if (_checkText) {
const auto text = GetTextInWindow(matchWindow);
SetVariableValue(text.value_or(""));
SetVariableValue((info && info->text) ? *info->text : "");
} else {
SetVariableValue(ForegroundWindowTitle());
}
@@ -135,13 +113,18 @@ static bool foregroundWindowChanged()
bool MacroConditionWindow::CheckCondition()
{
const auto windowList = GetWindowList();
bool match = false;
if (_windowRegex.Enabled()) {
match = WindowRegexMatches(windowList);
} else {
match = WindowMatches(windowList);
}
WindowQueryOptions options;
options.focus = _focus;
options.fullscreen = _fullscreen;
options.maximized = _maximized;
options.geometry = true;
#ifdef _WIN32
options.windowClass = true;
options.text = _checkText;
#endif
const auto windows = GetWindows(options);
bool match = FindMatch(windows);
match = match && (!_windowFocusChanged || foregroundWindowChanged());
return match;
}
@@ -201,6 +184,26 @@ void MacroConditionWindow::SetupTempVars()
obs_module_text("AdvSceneSwitcher.tempVar.window.window"),
obs_module_text(
"AdvSceneSwitcher.tempVar.window.window.description"));
AddTempvar(
"windowX",
obs_module_text("AdvSceneSwitcher.tempVar.window.windowX"),
obs_module_text(
"AdvSceneSwitcher.tempVar.window.windowX.description"));
AddTempvar(
"windowY",
obs_module_text("AdvSceneSwitcher.tempVar.window.windowY"),
obs_module_text(
"AdvSceneSwitcher.tempVar.window.windowY.description"));
AddTempvar(
"windowWidth",
obs_module_text("AdvSceneSwitcher.tempVar.window.windowWidth"),
obs_module_text(
"AdvSceneSwitcher.tempVar.window.windowWidth.description"));
AddTempvar(
"windowHeight",
obs_module_text("AdvSceneSwitcher.tempVar.window.windowHeight"),
obs_module_text(
"AdvSceneSwitcher.tempVar.window.windowHeight.description"));
#ifdef _WIN32
AddTempvar(
"windowClass",

View File

@@ -1,5 +1,6 @@
#pragma once
#include "macro-condition-edit.hpp"
#include "platform-funcs.hpp"
#include "variable-text-edit.hpp"
#include "regex-config.hpp"
#include "window-selection.hpp"
@@ -37,10 +38,9 @@ public:
RegexConfig _textRegex = RegexConfig::PartialMatchRegexConfig();
private:
bool WindowMatchesRequirements(const std::string &window) const;
bool WindowMatches(const std::vector<std::string> &windowList);
bool WindowRegexMatches(const std::vector<std::string> &windowList);
void SetVariableValueBasedOnMatch(const std::string &matchWindow);
bool WindowMatchesRequirements(const WindowInfo &info) const;
bool FindMatch(const std::vector<WindowInfo> &windows);
void SetVariableValueBasedOnMatch(const WindowInfo *info);
void SetupTempVars();
// For now only supported on Windows

View File

@@ -1,5 +1,6 @@
#include "connection-manager.hpp"
#include "layout-helpers.hpp"
#include "macro-export-extensions.hpp"
#include "name-dialog.hpp"
#include "obs-module-helper.hpp"
#include "plugin-state-helpers.hpp"
@@ -24,6 +25,51 @@ bool setup()
AddSaveStep(saveConnections);
AddLoadStep(loadConnections);
AddPluginCleanupStep([]() { connections.clear(); });
AddMacroExportExtension(
{"AdvSceneSwitcher.macroTab.export.websocketConnections",
"websocketConnections",
[](obs_data_t *data, const QStringList &selectedIds) {
OBSDataArrayAutoRelease array =
obs_data_array_create();
for (const auto &c : connections) {
if (!selectedIds.isEmpty() &&
!selectedIds.contains(
QString::fromStdString(c->Name())))
continue;
OBSDataAutoRelease item = obs_data_create();
c->Save(item);
obs_data_array_push_back(array, item);
}
obs_data_set_array(data, "websocketConnections",
array);
},
[](obs_data_t *data, const QStringList &) {
OBSDataArrayAutoRelease array = obs_data_get_array(
data, "websocketConnections");
const size_t count = obs_data_array_count(array);
for (size_t i = 0; i < count; ++i) {
OBSDataAutoRelease item =
obs_data_array_item(array, i);
auto con = WSConnection::Create();
con->Load(item);
if (!GetWeakConnectionByName(con->Name())
.expired())
continue;
connections.emplace_back(con);
ConnectionSelectionSignalManager::Instance()
->Add(QString::fromStdString(
con->Name()));
}
},
[]() -> QList<QPair<QString, QString>> {
QList<QPair<QString, QString>> items;
for (const auto &c : connections) {
const QString name =
QString::fromStdString(c->Name());
items.append({name, name});
}
return items;
}});
return true;
}

View File

@@ -25,6 +25,9 @@ void PopulateProfileSelection(QComboBox *box)
std::string GetPathInProfileDir(const char *filePath)
{
auto path = obs_frontend_get_current_profile_path();
if (!path) {
return "";
}
std::string result(path);
bfree(path);
return result + "/" + filePath;

View File

@@ -205,26 +205,6 @@ static std::vector<HWND> getHWNDfromTitle(const std::string &title)
return hwnds;
}
std::string GetWindowClassByWindowTitle(const std::string &window)
{
auto hwnds = getHWNDfromTitle(window);
if (hwnds.empty()) {
return "";
}
auto hwnd = hwnds.at(0);
std::wstring wClass;
wClass.resize(1024);
if (!GetClassNameW(hwnd, &wClass[0], wClass.capacity())) {
return "";
}
size_t len = os_wcs_to_utf8(wClass.c_str(), 0, nullptr, 0);
std::string className;
className.resize(len);
os_wcs_to_utf8(wClass.c_str(), 0, &className[0], len + 1);
return className;
}
void SetFocusWindow(const std::string &title)
{
auto hwnds = getHWNDfromTitle(title);

View File

@@ -1,6 +1,7 @@
#include "http-server.hpp"
#include "layout-helpers.hpp"
#include "log-helper.hpp"
#include "macro-export-extensions.hpp"
#include "obs-module-helper.hpp"
#include "plugin-state-helpers.hpp"
@@ -32,6 +33,49 @@ bool setup()
}
httpServers.clear();
});
AddMacroExportExtension(
{"AdvSceneSwitcher.macroTab.export.httpServers", "httpServers",
[](obs_data_t *data, const QStringList &selectedIds) {
OBSDataArrayAutoRelease array =
obs_data_array_create();
for (const auto &s : httpServers) {
if (!selectedIds.isEmpty() &&
!selectedIds.contains(
QString::fromStdString(s->Name())))
continue;
OBSDataAutoRelease item = obs_data_create();
s->Save(item);
obs_data_array_push_back(array, item);
}
obs_data_set_array(data, "httpServers", array);
},
[](obs_data_t *data, const QStringList &) {
OBSDataArrayAutoRelease array =
obs_data_get_array(data, "httpServers");
const size_t count = obs_data_array_count(array);
for (size_t i = 0; i < count; ++i) {
OBSDataAutoRelease item =
obs_data_array_item(array, i);
auto server = HttpServer::Create();
server->Load(item);
if (!GetWeakHttpServerByName(server->Name())
.expired())
continue;
httpServers.emplace_back(server);
HttpServerSignalManager::Instance()->Add(
QString::fromStdString(
server->Name()));
}
},
[]() -> QList<QPair<QString, QString>> {
QList<QPair<QString, QString>> items;
for (const auto &s : httpServers) {
const QString name =
QString::fromStdString(s->Name());
items.append({name, name});
}
return items;
}});
return true;
}

View File

@@ -39,7 +39,9 @@ else()
-Wno-error=unused-parameter -Wno-error=unused-variable
-Wno-error=sign-compare)
if(OS_MACOS)
target_compile_options(libremidi PUBLIC -Wno-error=vla-extension)
target_compile_options(
libremidi PUBLIC -Wno-error=vla-extension -Wno-vla-extension
-Wno-shorten-64-to-32)
endif()
endif()

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

@@ -0,0 +1,27 @@
cmake_minimum_required(VERSION 3.14)
project(advanced-scene-switcher-macro-schedule)
add_library(${PROJECT_NAME} MODULE)
target_sources(
${PROJECT_NAME}
PRIVATE macro-schedule.cpp
macro-schedule.hpp
macro-schedule-entry-dialog.cpp
macro-schedule-entry-dialog.hpp
macro-schedule-tab.cpp
macro-schedule-tab.hpp
calendar/calendar-event.hpp
calendar/calendar-view.hpp
calendar/calendar-month-view.hpp
calendar/calendar-month-view.cpp
calendar/calendar-day-view.hpp
calendar/calendar-day-view.cpp
calendar/calendar-week-view.hpp
calendar/calendar-week-view.cpp
calendar/calendar-widget.hpp
calendar/calendar-widget.cpp)
setup_advss_plugin(${PROJECT_NAME})
set_target_properties(${PROJECT_NAME} PROPERTIES PREFIX "")
install_advss_plugin(${PROJECT_NAME})

View File

@@ -0,0 +1,493 @@
#include "calendar-day-view.hpp"
#include <QLocale>
#include <QMouseEvent>
#include <QPainter>
#include <QScrollBar>
#include <QTimerEvent>
#include <QVBoxLayout>
#include <algorithm>
namespace advss {
// ---------------------------------------------------------------------------
// CalendarDayHeader fixed date strip above the scroll area
// ---------------------------------------------------------------------------
class CalendarDayHeader : public QWidget {
public:
explicit CalendarDayHeader(QWidget *parent = nullptr);
void SetDate(const QDate &date);
protected:
void paintEvent(QPaintEvent *) override;
private:
QDate _date;
};
CalendarDayHeader::CalendarDayHeader(QWidget *parent) : QWidget(parent)
{
setFixedHeight(CalendarDayView::DAY_HEADER_HEIGHT);
}
void CalendarDayHeader::SetDate(const QDate &date)
{
_date = date;
update();
}
void CalendarDayHeader::paintEvent(QPaintEvent *)
{
QPainter p(this);
p.setRenderHint(QPainter::Antialiasing);
const QDate today = QDate::currentDate();
const bool isToday = (_date == today);
QLocale locale;
// Time-axis placeholder
p.fillRect(0, 0, CalendarDayView::TIME_AXIS_WIDTH, height(),
palette().button());
// Day column
const int colX = CalendarDayView::TIME_AXIS_WIDTH;
const int colW = width() - colX;
QColor bg = palette().button().color();
if (isToday) {
bg = palette().highlight().color().lighter(165);
}
p.fillRect(colX, 0, colW, height(), bg);
p.setPen(palette().mid().color());
p.drawLine(colX, 0, colX, height());
QFont f = p.font();
f.setPixelSize(13);
f.setBold(isToday);
p.setFont(f);
p.setPen(isToday ? palette().highlight().color()
: palette().buttonText().color());
const QString label =
locale.dayName(_date.dayOfWeek(), QLocale::LongFormat) + " " +
QString::number(_date.day()) + " " +
locale.monthName(_date.month(), QLocale::ShortFormat) + " " +
QString::number(_date.year());
p.drawText(colX, 0, colW, height(), Qt::AlignCenter, label);
p.setPen(palette().mid().color());
p.drawLine(0, height() - 1, width(), height() - 1);
}
// ---------------------------------------------------------------------------
// CalendarDayTimeGrid scrollable single-day painted time grid
// ---------------------------------------------------------------------------
class CalendarDayTimeGrid : public QWidget {
Q_OBJECT
public:
explicit CalendarDayTimeGrid(QWidget *parent = nullptr);
void SetDate(const QDate &date);
void SetEvents(const QList<CalendarEvent> &events);
void ScrollToCurrentTime(QScrollArea *sa);
QSize sizeHint() const override;
signals:
void SlotClicked(const QDateTime &startTime);
void EventClicked(const QString &id);
void EventDoubleClicked(const QString &id);
protected:
void paintEvent(QPaintEvent *) override;
void mousePressEvent(QMouseEvent *) override;
void mouseDoubleClickEvent(QMouseEvent *) override;
void timerEvent(QTimerEvent *) override;
private:
struct EventLayout {
CalendarEvent event;
QTime drawStart; // clipped to the day's [00:00, 23:59:59]
QTime drawEnd;
int col;
int numCols;
};
int TimeToY(const QTime &t) const;
QTime YToTime(int y) const;
int ColWidth() const;
QList<EventLayout> LayoutDay() const;
QString EventIdAtPoint(const QPoint &pos) const;
QDateTime SlotAtPoint(const QPoint &pos) const;
void DrawEvent(QPainter &p, const EventLayout &layout, int colW);
QDate _date;
QList<CalendarEvent> _events;
int _refreshTimerId = 0;
};
CalendarDayTimeGrid::CalendarDayTimeGrid(QWidget *parent) : QWidget(parent)
{
_refreshTimerId = startTimer(60 * 1000);
}
QSize CalendarDayTimeGrid::sizeHint() const
{
return QSize(CalendarDayView::TIME_AXIS_WIDTH + 200,
24 * CalendarDayView::PIXELS_PER_HOUR);
}
void CalendarDayTimeGrid::timerEvent(QTimerEvent *e)
{
if (e->timerId() == _refreshTimerId) {
update();
}
}
int CalendarDayTimeGrid::TimeToY(const QTime &t) const
{
return (t.hour() * 60 + t.minute()) * CalendarDayView::PIXELS_PER_HOUR /
60;
}
QTime CalendarDayTimeGrid::YToTime(int y) const
{
int totalMin = y * 60 / CalendarDayView::PIXELS_PER_HOUR;
totalMin = qBound(0, totalMin, 24 * 60 - 1);
totalMin = (totalMin / 15) * 15; // snap to 15-min increments
return QTime(totalMin / 60, totalMin % 60);
}
int CalendarDayTimeGrid::ColWidth() const
{
return qMax(0, width() - CalendarDayView::TIME_AXIS_WIDTH);
}
void CalendarDayTimeGrid::SetDate(const QDate &date)
{
_date = date;
update();
}
void CalendarDayTimeGrid::SetEvents(const QList<CalendarEvent> &events)
{
_events = events;
update();
}
void CalendarDayTimeGrid::ScrollToCurrentTime(QScrollArea *sa)
{
if (!sa) {
return;
}
const int y = qMax(0, TimeToY(QTime::currentTime().addSecs(-3600)));
sa->verticalScrollBar()->setValue(y);
}
static bool ClipEventToDayD(const CalendarEvent &ev, const QDate &date,
QTime &drawStart, QTime &drawEnd)
{
if (!ev.start.isValid()) {
return false;
}
const QDateTime dayStart(date, QTime(0, 0, 0));
const QDateTime dayEnd(date.addDays(1), QTime(0, 0, 0));
const QDateTime evEnd = ev.EffectiveEnd();
if (ev.start >= dayEnd || evEnd <= dayStart) {
return false;
}
drawStart = (ev.start < dayStart) ? QTime(0, 0, 0) : ev.start.time();
drawEnd = (evEnd >= dayEnd) ? QTime(23, 59, 59) : evEnd.time();
return true;
}
QList<CalendarDayTimeGrid::EventLayout> CalendarDayTimeGrid::LayoutDay() const
{
struct DayEvent {
CalendarEvent ev;
QTime drawStart;
QTime drawEnd;
};
QList<DayEvent> dayEvents;
for (const auto &ev : _events) {
QTime ds, de;
if (ClipEventToDayD(ev, _date, ds, de)) {
dayEvents.append({ev, ds, de});
}
}
std::sort(dayEvents.begin(), dayEvents.end(),
[](const DayEvent &a, const DayEvent &b) {
return a.drawStart < b.drawStart;
});
QList<EventLayout> result;
QList<QTime> columnEnds;
for (const auto &de : dayEvents) {
int col = -1;
for (int i = 0; i < columnEnds.size(); ++i) {
if (columnEnds[i] <= de.drawStart) {
col = i;
columnEnds[i] = de.drawEnd;
break;
}
}
if (col == -1) {
col = columnEnds.size();
columnEnds.append(de.drawEnd);
}
result.append({de.ev, de.drawStart, de.drawEnd, col, 0});
}
const int totalCols = qMax(1, (int)columnEnds.size());
for (auto &layout : result) {
layout.numCols = totalCols;
}
return result;
}
QString CalendarDayTimeGrid::EventIdAtPoint(const QPoint &pos) const
{
if (pos.x() < CalendarDayView::TIME_AXIS_WIDTH) {
return {};
}
const int colW = ColWidth();
for (const auto &layout : LayoutDay()) {
const int subW = colW / layout.numCols;
const int x = CalendarDayView::TIME_AXIS_WIDTH +
layout.col * subW + 2;
const int w = subW - 4;
const int y = TimeToY(layout.drawStart);
const int h = qMax(TimeToY(layout.drawEnd) - y, 20);
if (QRect(x, y, w, h).contains(pos)) {
return layout.event.id;
}
}
return {};
}
QDateTime CalendarDayTimeGrid::SlotAtPoint(const QPoint &pos) const
{
if (pos.x() < CalendarDayView::TIME_AXIS_WIDTH) {
return {};
}
return QDateTime(_date, YToTime(pos.y()));
}
void CalendarDayTimeGrid::DrawEvent(QPainter &p, const EventLayout &layout,
int colW)
{
const auto &ev = layout.event;
const int subW = colW / layout.numCols;
const int x = CalendarDayView::TIME_AXIS_WIDTH + layout.col * subW + 2;
const int w = subW - 4;
const int y = TimeToY(layout.drawStart);
const int endY = TimeToY(layout.drawEnd);
const int h = qMax(endY - y, 20);
const QRect rect(x, y, w, h);
p.setBrush(ev.color);
p.setPen(ev.color.darker(130));
p.drawRoundedRect(rect, 3, 3);
p.fillRect(x, y + 1, 3, h - 2, ev.color.darker(160));
p.setPen(Qt::white);
QFont f = p.font();
f.setPixelSize(10);
f.setBold(true);
p.setFont(f);
const QRect textRect = rect.adjusted(6, 2, -2, -2);
if (h >= 34) {
QFont tf = f;
tf.setBold(false);
p.setFont(tf);
p.drawText(textRect, Qt::AlignTop | Qt::AlignLeft,
ev.start.toString("HH:mm"));
p.setFont(f);
p.drawText(textRect.adjusted(0, 14, 0, 0),
Qt::AlignTop | Qt::AlignLeft | Qt::TextWordWrap,
ev.title);
} else {
p.drawText(textRect,
Qt::AlignVCenter | Qt::AlignLeft |
Qt::TextSingleLine,
ev.title);
}
}
void CalendarDayTimeGrid::paintEvent(QPaintEvent *)
{
QPainter p(this);
p.setRenderHint(QPainter::Antialiasing);
const int w = width();
const int h = height();
const int colW = ColWidth();
const QDate today = QDate::currentDate();
p.fillRect(0, 0, w, h, palette().base());
p.fillRect(0, 0, CalendarDayView::TIME_AXIS_WIDTH, h,
palette().button());
// Day column background
const int colX = CalendarDayView::TIME_AXIS_WIDTH;
if (_date == today) {
p.fillRect(colX, 0, colW, h,
palette().highlight().color().lighter(190));
} else if (_date.dayOfWeek() >= 6) {
p.fillRect(colX, 0, colW, h, palette().alternateBase().color());
}
QFont labelFont = p.font();
labelFont.setPixelSize(10);
p.setFont(labelFont);
for (int hour = 0; hour < 24; ++hour) {
const int y = hour * CalendarDayView::PIXELS_PER_HOUR;
p.setPen(palette().text().color());
p.drawText(2, y, CalendarDayView::TIME_AXIS_WIDTH - 6,
CalendarDayView::PIXELS_PER_HOUR,
Qt::AlignTop | Qt::AlignRight,
QTime(hour, 0).toString("HH:mm"));
p.setPen(QPen(palette().mid().color(), 1));
p.drawLine(CalendarDayView::TIME_AXIS_WIDTH, y, w, y);
QPen halfPen(palette().midlight().color(), 1, Qt::DotLine);
p.setPen(halfPen);
const int yHalf = y + CalendarDayView::PIXELS_PER_HOUR / 2;
p.drawLine(CalendarDayView::TIME_AXIS_WIDTH, yHalf, w, yHalf);
}
p.setPen(QPen(palette().mid().color(), 1));
p.drawLine(colX, 0, colX, h);
for (const auto &layout : LayoutDay()) {
DrawEvent(p, layout, colW);
}
// Current-time indicator
if (_date == today) {
const int y = TimeToY(QTime::currentTime());
p.setPen(QPen(QColor(220, 30, 30), 2));
p.drawLine(colX, y, w, y);
p.setBrush(QColor(220, 30, 30));
p.setPen(Qt::NoPen);
p.drawEllipse(colX - 4, y - 4, 8, 8);
}
}
void CalendarDayTimeGrid::mousePressEvent(QMouseEvent *e)
{
if (e->button() != Qt::LeftButton) {
return;
}
const QString id = EventIdAtPoint(e->pos());
if (!id.isEmpty()) {
emit EventClicked(id);
return;
}
const QDateTime slot = SlotAtPoint(e->pos());
if (slot.isValid()) {
emit SlotClicked(slot);
}
}
void CalendarDayTimeGrid::mouseDoubleClickEvent(QMouseEvent *e)
{
if (e->button() != Qt::LeftButton) {
return;
}
const QString id = EventIdAtPoint(e->pos());
if (!id.isEmpty()) {
emit EventDoubleClicked(id);
}
}
// ---------------------------------------------------------------------------
// CalendarDayView
// ---------------------------------------------------------------------------
CalendarDayView::CalendarDayView(QWidget *parent)
: CalendarView(parent),
_header(new CalendarDayHeader(this)),
_timeGrid(new CalendarDayTimeGrid(this)),
_scrollArea(new QScrollArea(this))
{
_timeGrid->setMinimumHeight(24 * CalendarDayView::PIXELS_PER_HOUR);
_scrollArea->setWidget(_timeGrid);
_scrollArea->setWidgetResizable(true);
_scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
_scrollArea->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded);
_scrollArea->setFrameShape(QFrame::NoFrame);
auto layout = new QVBoxLayout(this);
layout->setContentsMargins(0, 0, 0, 0);
layout->setSpacing(0);
layout->addWidget(_header);
layout->addWidget(_scrollArea);
setLayout(layout);
connect(_timeGrid, &CalendarDayTimeGrid::SlotClicked, this,
&CalendarDayView::SlotClicked);
connect(_timeGrid, &CalendarDayTimeGrid::EventClicked, this,
&CalendarDayView::EventClicked);
connect(_timeGrid, &CalendarDayTimeGrid::EventDoubleClicked, this,
&CalendarDayView::EventDoubleClicked);
SetDate(QDate::currentDate());
}
void CalendarDayView::SetDate(const QDate &date)
{
_date = date;
UpdateViews();
emit VisibleRangeChanged(RangeStart(), RangeEnd());
}
void CalendarDayView::SetEvents(const QList<CalendarEvent> &events)
{
_events = events;
_timeGrid->SetEvents(events);
}
QDate CalendarDayView::CurrentDate() const
{
return _date;
}
QDate CalendarDayView::RangeStart() const
{
return _date;
}
QDate CalendarDayView::RangeEnd() const
{
return _date;
}
void CalendarDayView::UpdateViews()
{
_header->SetDate(_date);
_timeGrid->SetDate(_date);
_timeGrid->ScrollToCurrentTime(_scrollArea);
}
} // namespace advss
// Required for CalendarDayTimeGrid defined in this file
#include "calendar-day-view.moc"

View File

@@ -0,0 +1,44 @@
#pragma once
#include "calendar-view.hpp"
#include <QScrollArea>
namespace advss {
class CalendarDayHeader;
class CalendarDayTimeGrid;
// Displays a single day column with a vertical time axis (00:00 - 24:00).
// Events are rendered as colored blocks sized to their duration.
// Overlapping events are arranged in side-by-side sub-columns.
// A red indicator marks the current time.
class CalendarDayView : public CalendarView {
Q_OBJECT
public:
explicit CalendarDayView(QWidget *parent = nullptr);
void SetDate(const QDate &date) override;
void SetEvents(const QList<CalendarEvent> &events) override;
QDate CurrentDate() const override;
QDate RangeStart() const override;
QDate RangeEnd() const override;
// Shared layout constants (used by DayHeader and TimeGrid)
static constexpr int TIME_AXIS_WIDTH = 56;
static constexpr int PIXELS_PER_HOUR = 64;
static constexpr int DAY_HEADER_HEIGHT = 36;
private:
void UpdateViews();
CalendarDayHeader *_header;
CalendarDayTimeGrid *_timeGrid;
QScrollArea *_scrollArea;
QDate _date;
QList<CalendarEvent> _events;
};
} // namespace advss

View File

@@ -0,0 +1,29 @@
#pragma once
#include <QColor>
#include <QDateTime>
#include <QString>
#include <QVariant>
namespace advss {
// Generic calendar event. All fields except id/start are optional.
struct CalendarEvent {
QString id;
QString title;
QDateTime start;
QDateTime end;
QColor color{70, 130, 180};
QVariant userData; // Caller-defined payload, returned on click signals
// Returns end if valid and > start, otherwise start + 30 minutes.
QDateTime EffectiveEnd() const
{
static constexpr qint64 defaultDuration = 1800;
return (end.isValid() && end > start)
? end
: start.addSecs(defaultDuration);
}
};
} // namespace advss

View File

@@ -0,0 +1,284 @@
#include "calendar-month-view.hpp"
#include "obs-module-helper.hpp"
#include <QLocale>
#include <QMouseEvent>
#include <QPainter>
namespace advss {
CalendarMonthView::CalendarMonthView(QWidget *parent) : CalendarView(parent)
{
setMinimumSize(320, 240);
SetDate(QDate::currentDate());
}
// ---------------------------------------------------------------------------
// Public interface
// ---------------------------------------------------------------------------
void CalendarMonthView::SetDate(const QDate &date)
{
_currentDate = date;
// Grid starts on the Monday of the week that contains the 1st of the month.
const QDate first(date.year(), date.month(), 1);
// Qt: dayOfWeek() -> 1=Mon … 7=Sun
_gridStart = first.addDays(-(first.dayOfWeek() - 1));
update();
emit VisibleRangeChanged(RangeStart(), RangeEnd());
}
void CalendarMonthView::SetEvents(const QList<CalendarEvent> &events)
{
_events = events;
update();
}
// ---------------------------------------------------------------------------
// Geometry helpers
// ---------------------------------------------------------------------------
QDate CalendarMonthView::CellDate(int row, int col) const
{
return _gridStart.addDays(row * COLS + col);
}
QRect CalendarMonthView::CellRect(int row, int col) const
{
const int cellW = width() / COLS;
const int cellH = (height() - HEADER_HEIGHT) / ROWS;
return QRect(col * cellW, HEADER_HEIGHT + row * cellH, cellW, cellH);
}
bool CalendarMonthView::CellFromPoint(const QPoint &pos, int &row,
int &col) const
{
if (pos.y() < HEADER_HEIGHT) {
return false;
}
const int cellW = width() / COLS;
const int cellH = (height() - HEADER_HEIGHT) / ROWS;
col = pos.x() / cellW;
row = (pos.y() - HEADER_HEIGHT) / cellH;
return col >= 0 && col < COLS && row >= 0 && row < ROWS;
}
// ---------------------------------------------------------------------------
// Event helpers
// ---------------------------------------------------------------------------
QList<CalendarEvent> CalendarMonthView::EventsForDate(const QDate &date) const
{
QList<CalendarEvent> result;
for (const auto &event : _events) {
if (!event.start.isValid()) {
continue;
}
const QDate evStart = event.start.date();
const QDate evEnd = event.EffectiveEnd().date();
if (date >= evStart && date <= evEnd) {
result.append(event);
}
}
return result;
}
QString CalendarMonthView::EventIdAtPoint(const QPoint &pos) const
{
int row, col;
if (!CellFromPoint(pos, row, col)) {
return {};
}
const QRect cell = CellRect(row, col);
const int evTop = cell.top() + DAY_NUM_HEIGHT + CELL_PAD;
const auto events = EventsForDate(CellDate(row, col));
for (int i = 0; i < qMin((int)events.size(), MAX_VISIBLE_EVENTS); ++i) {
const QRect evRect(cell.left() + CELL_PAD,
evTop + i * (EVENT_HEIGHT + EVENT_MARGIN),
cell.width() - CELL_PAD * 2, EVENT_HEIGHT);
if (evRect.contains(pos)) {
return events[i].id;
}
}
return {};
}
// ---------------------------------------------------------------------------
// Painting
// ---------------------------------------------------------------------------
void CalendarMonthView::PaintDayNameHeader(QPainter &p)
{
p.fillRect(0, 0, width(), HEADER_HEIGHT, palette().button());
const int cellW = width() / COLS;
QLocale locale;
p.setPen(palette().buttonText().color());
QFont f = p.font();
f.setPixelSize(11);
p.setFont(f);
for (int col = 0; col < COLS; ++col) {
// dayOfWeek: 1=Mon … 7=Sun (ISO 8601 order matches our columns)
const QString name =
locale.dayName(col + 1, QLocale::ShortFormat);
p.drawText(col * cellW, 0, cellW, HEADER_HEIGHT,
Qt::AlignCenter, name);
}
// Bottom border
p.setPen(palette().mid().color());
p.drawLine(0, HEADER_HEIGHT - 1, width(), HEADER_HEIGHT - 1);
}
void CalendarMonthView::PaintCell(QPainter &p, int row, int col)
{
const QRect rect = CellRect(row, col);
const QDate date = CellDate(row, col);
const QDate today = QDate::currentDate();
const bool isToday = (date == today);
const bool inCurrentMonth = (date.month() == _currentDate.month());
// --- Background ---
QColor bg;
if (isToday) {
bg = palette().highlight().color().lighter(185);
} else if (!inCurrentMonth) {
bg = palette().alternateBase().color();
} else {
bg = palette().base().color();
}
// Weekend tint
if (col >= 5) {
bg = bg.darker(104);
}
p.fillRect(rect, bg);
// --- Grid border ---
p.setBrush(Qt::NoBrush);
p.setPen(palette().mid().color());
p.drawRect(rect.adjusted(0, 0, -1, -1));
// --- Day number ---
const QRect numArea(rect.left(), rect.top() + CELL_PAD,
rect.width() - CELL_PAD, DAY_NUM_HEIGHT);
if (isToday) {
// Filled circle behind the number
const int dia = DAY_NUM_HEIGHT - 4;
const QRect circleRect(numArea.right() - dia - 2,
numArea.top() + 1, dia, dia);
p.setBrush(palette().highlight());
p.setPen(Qt::NoPen);
p.drawEllipse(circleRect);
p.setPen(palette().highlightedText().color());
} else {
p.setPen(inCurrentMonth ? palette().text().color()
: palette().placeholderText().color());
}
QFont f = p.font();
f.setPixelSize(11);
f.setBold(isToday);
p.setFont(f);
p.drawText(numArea, Qt::AlignRight | Qt::AlignTop,
QString::number(date.day()));
// --- Event bars ---
const auto events = EventsForDate(date);
const int evTop = rect.top() + DAY_NUM_HEIGHT + CELL_PAD;
const int maxFit = (rect.height() - DAY_NUM_HEIGHT - CELL_PAD * 2) /
(EVENT_HEIGHT + EVENT_MARGIN);
const int visible =
qMin(qMin((int)events.size(), maxFit), MAX_VISIBLE_EVENTS);
QFont evFont = p.font();
evFont.setPixelSize(10);
evFont.setBold(false);
p.setFont(evFont);
for (int i = 0; i < visible; ++i) {
const auto &ev = events[i];
const QRect evRect(rect.left() + CELL_PAD,
evTop + i * (EVENT_HEIGHT + EVENT_MARGIN),
rect.width() - CELL_PAD * 2, EVENT_HEIGHT);
p.setBrush(ev.color);
p.setPen(Qt::NoPen);
p.drawRoundedRect(evRect, 2, 2);
p.setPen(Qt::white);
p.drawText(evRect.adjusted(4, 0, -2, 0),
Qt::AlignVCenter | Qt::AlignLeft |
Qt::TextSingleLine,
ev.title);
}
// "+N more" overflow label
if ((int)events.size() > visible) {
const int extra = events.size() - visible;
const QRect moreRect(
rect.left() + CELL_PAD,
evTop + visible * (EVENT_HEIGHT + EVENT_MARGIN),
rect.width() - CELL_PAD * 2, EVENT_HEIGHT);
p.setPen(palette().placeholderText().color());
p.drawText(
moreRect,
Qt::AlignVCenter | Qt::AlignLeft | Qt::TextSingleLine,
QString(obs_module_text(
"AdvSceneSwitcher.calendar.moreEvents"))
.arg(extra));
}
}
void CalendarMonthView::paintEvent(QPaintEvent *)
{
QPainter p(this);
p.setRenderHint(QPainter::Antialiasing);
PaintDayNameHeader(p);
for (int row = 0; row < ROWS; ++row) {
for (int col = 0; col < COLS; ++col) {
PaintCell(p, row, col);
}
}
}
// ---------------------------------------------------------------------------
// Mouse events
// ---------------------------------------------------------------------------
void CalendarMonthView::mousePressEvent(QMouseEvent *e)
{
if (e->button() != Qt::LeftButton) {
return;
}
const QString id = EventIdAtPoint(e->pos());
if (!id.isEmpty()) {
emit EventClicked(id);
return;
}
int row, col;
if (CellFromPoint(e->pos(), row, col)) {
emit SlotClicked(QDateTime(CellDate(row, col), QTime(0, 0)));
}
}
void CalendarMonthView::mouseDoubleClickEvent(QMouseEvent *e)
{
if (e->button() != Qt::LeftButton) {
return;
}
const QString id = EventIdAtPoint(e->pos());
if (!id.isEmpty()) {
emit EventDoubleClicked(id);
}
}
} // namespace advss

View File

@@ -0,0 +1,57 @@
#pragma once
#include "calendar-view.hpp"
namespace advss {
// Displays a traditional month grid (6 weeks × 7 days).
// Events are rendered as small colored bars inside each day cell.
// Up to three events are shown per cell; additional events are
// indicated by a "+N more" label.
class CalendarMonthView : public CalendarView {
Q_OBJECT
public:
explicit CalendarMonthView(QWidget *parent = nullptr);
void SetDate(const QDate &date) override;
void SetEvents(const QList<CalendarEvent> &events) override;
QDate CurrentDate() const override { return _currentDate; }
QDate RangeStart() const override { return _gridStart; }
QDate RangeEnd() const override { return _gridStart.addDays(41); }
protected:
void paintEvent(QPaintEvent *) override;
void mousePressEvent(QMouseEvent *) override;
void mouseDoubleClickEvent(QMouseEvent *) override;
private:
// Geometry helpers
QDate CellDate(int row, int col) const;
QRect CellRect(int row, int col) const;
bool CellFromPoint(const QPoint &pos, int &row, int &col) const;
// Event helpers
QList<CalendarEvent> EventsForDate(const QDate &date) const;
QString EventIdAtPoint(const QPoint &pos) const;
// Painting
void PaintDayNameHeader(QPainter &p);
void PaintCell(QPainter &p, int row, int col);
static constexpr int ROWS = 6;
static constexpr int COLS = 7;
static constexpr int HEADER_HEIGHT = 28; // day-name row
static constexpr int DAY_NUM_HEIGHT =
22; // space reserved for day number
static constexpr int EVENT_HEIGHT = 16;
static constexpr int EVENT_MARGIN = 2;
static constexpr int CELL_PAD = 3;
static constexpr int MAX_VISIBLE_EVENTS = 3;
QDate _currentDate;
QDate _gridStart; // Monday of the week that contains the 1st of the month
QList<CalendarEvent> _events;
};
} // namespace advss

View File

@@ -0,0 +1,49 @@
#pragma once
#include "calendar-event.hpp"
#include <QDate>
#include <QList>
#include <QWidget>
namespace advss {
// Abstract base class for calendar view modes (month, week, …).
// Subclasses must implement the pure virtual interface and emit the
// signals defined here via Q_SIGNALS so that CalendarWidget can
// connect to them uniformly regardless of the active view.
class CalendarView : public QWidget {
Q_OBJECT
public:
explicit CalendarView(QWidget *parent = nullptr) : QWidget(parent) {}
// Navigate to show the given date. The visible range is determined
// by the concrete view (e.g. the whole month, or the week).
virtual void SetDate(const QDate &date) = 0;
// Replace the full set of events shown in this view.
virtual void SetEvents(const QList<CalendarEvent> &events) = 0;
// A representative date for the current position (used for navigation).
virtual QDate CurrentDate() const = 0;
// Inclusive range of dates currently rendered.
virtual QDate RangeStart() const = 0;
virtual QDate RangeEnd() const = 0;
signals:
// User clicked an empty time slot.
void SlotClicked(const QDateTime &startTime);
// User single-clicked an event (id = CalendarEvent::id).
void EventClicked(const QString &eventId);
// User double-clicked an event.
void EventDoubleClicked(const QString &eventId);
// The visible date range changed; the owner should refresh events.
void VisibleRangeChanged(const QDate &rangeStart,
const QDate &rangeEnd);
};
} // namespace advss

View File

@@ -0,0 +1,542 @@
#include "calendar-week-view.hpp"
#include <QLocale>
#include <QMouseEvent>
#include <QPainter>
#include <QScrollBar>
#include <QTimerEvent>
#include <QVBoxLayout>
#include <algorithm>
namespace advss {
// ---------------------------------------------------------------------------
// CalendarWeekDayHeader fixed day-name strip above the scroll area
// ---------------------------------------------------------------------------
class CalendarWeekDayHeader : public QWidget {
public:
explicit CalendarWeekDayHeader(QWidget *parent = nullptr);
void SetStartOfWeek(const QDate &date);
protected:
void paintEvent(QPaintEvent *) override;
private:
QDate _startOfWeek;
};
CalendarWeekDayHeader::CalendarWeekDayHeader(QWidget *parent) : QWidget(parent)
{
setFixedHeight(CalendarWeekView::DAY_HEADER_HEIGHT);
}
void CalendarWeekDayHeader::SetStartOfWeek(const QDate &date)
{
_startOfWeek = date;
update();
}
void CalendarWeekDayHeader::paintEvent(QPaintEvent *)
{
QPainter p(this);
p.setRenderHint(QPainter::Antialiasing);
const int dayW =
std::max(CalendarWeekView::MIN_DAY_WIDTH,
(width() - CalendarWeekView::TIME_AXIS_WIDTH) / 7);
const QDate today = QDate::currentDate();
QLocale locale;
// Time-axis placeholder
p.fillRect(0, 0, CalendarWeekView::TIME_AXIS_WIDTH, height(),
palette().button());
for (int d = 0; d < 7; ++d) {
const QDate date = _startOfWeek.addDays(d);
const int x = CalendarWeekView::TIME_AXIS_WIDTH + d * dayW;
const bool isToday = (date == today);
QColor bg = palette().button().color();
if (isToday) {
bg = palette().highlight().color().lighter(165);
} else if (date.dayOfWeek() >= 6) {
bg = bg.darker(106);
}
p.fillRect(x, 0, dayW, height(), bg);
p.setPen(palette().mid().color());
p.drawLine(x, 0, x, height());
QFont f = p.font();
f.setPixelSize(12);
f.setBold(isToday);
p.setFont(f);
p.setPen(isToday ? palette().highlight().color()
: palette().buttonText().color());
const QString label =
locale.dayName(date.dayOfWeek(), QLocale::ShortFormat) +
" " + QString::number(date.day());
p.drawText(x, 0, dayW, height(), Qt::AlignCenter, label);
}
p.setPen(palette().mid().color());
p.drawLine(0, height() - 1, width(), height() - 1);
}
// ---------------------------------------------------------------------------
// CalendarWeekTimeGrid scrollable painted time grid
// ---------------------------------------------------------------------------
class CalendarWeekTimeGrid : public QWidget {
Q_OBJECT
public:
explicit CalendarWeekTimeGrid(QWidget *parent = nullptr);
void SetStartOfWeek(const QDate &date);
void SetEvents(const QList<CalendarEvent> &events);
void ScrollToCurrentTime(QScrollArea *sa);
QSize sizeHint() const override;
signals:
void SlotClicked(const QDateTime &startTime);
void EventClicked(const QString &id);
void EventDoubleClicked(const QString &id);
protected:
void paintEvent(QPaintEvent *) override;
void mousePressEvent(QMouseEvent *) override;
void mouseDoubleClickEvent(QMouseEvent *) override;
void timerEvent(QTimerEvent *) override;
private:
struct EventLayout {
CalendarEvent event;
QTime drawStart; // clipped to the day's [00:00, 23:59:59]
QTime drawEnd;
int col;
int numCols;
};
int TimeToY(const QTime &t) const;
QTime YToTime(int y) const;
int DayWidth() const;
int DayColumnX(int dayIndex) const;
QList<EventLayout> LayoutDay(int dayIndex) const;
QString EventIdAtPoint(const QPoint &pos) const;
QDateTime SlotAtPoint(const QPoint &pos) const;
void DrawEvent(QPainter &p, const EventLayout &layout, int dayX,
int dayW);
QDate _startOfWeek;
QList<CalendarEvent> _events;
int _refreshTimerId = 0;
};
CalendarWeekTimeGrid::CalendarWeekTimeGrid(QWidget *parent) : QWidget(parent)
{
_refreshTimerId = startTimer(60 * 1000);
}
QSize CalendarWeekTimeGrid::sizeHint() const
{
return QSize(CalendarWeekView::TIME_AXIS_WIDTH +
7 * CalendarWeekView::MIN_DAY_WIDTH,
24 * CalendarWeekView::PIXELS_PER_HOUR);
}
void CalendarWeekTimeGrid::timerEvent(QTimerEvent *e)
{
if (e->timerId() == _refreshTimerId) {
update();
}
}
int CalendarWeekTimeGrid::TimeToY(const QTime &t) const
{
return (t.hour() * 60 + t.minute()) *
CalendarWeekView::PIXELS_PER_HOUR / 60;
}
QTime CalendarWeekTimeGrid::YToTime(int y) const
{
int totalMin = y * 60 / CalendarWeekView::PIXELS_PER_HOUR;
totalMin = qBound(0, totalMin, 24 * 60 - 1);
totalMin = (totalMin / 15) * 15; // snap to 15-min increments
return QTime(totalMin / 60, totalMin % 60);
}
int CalendarWeekTimeGrid::DayWidth() const
{
return std::max(CalendarWeekView::MIN_DAY_WIDTH,
(width() - CalendarWeekView::TIME_AXIS_WIDTH) / 7);
}
int CalendarWeekTimeGrid::DayColumnX(int dayIndex) const
{
return CalendarWeekView::TIME_AXIS_WIDTH + dayIndex * DayWidth();
}
void CalendarWeekTimeGrid::SetStartOfWeek(const QDate &date)
{
_startOfWeek = date;
update();
}
void CalendarWeekTimeGrid::SetEvents(const QList<CalendarEvent> &events)
{
_events = events;
update();
}
void CalendarWeekTimeGrid::ScrollToCurrentTime(QScrollArea *sa)
{
if (!sa) {
return;
}
const int y = qMax(0, TimeToY(QTime::currentTime().addSecs(-3600)));
sa->verticalScrollBar()->setValue(y);
}
// Returns the portion of an event visible within [date 00:00, date+1 00:00).
// drawStart / drawEnd are times in that day's coordinate space.
static bool ClipEventToDay(const CalendarEvent &ev, const QDate &date,
QTime &drawStart, QTime &drawEnd)
{
if (!ev.start.isValid()) {
return false;
}
const QDateTime dayStart(date, QTime(0, 0, 0));
const QDateTime dayEnd(date.addDays(1), QTime(0, 0, 0));
const QDateTime evEnd = ev.EffectiveEnd();
if (ev.start >= dayEnd || evEnd <= dayStart) {
return false; // no overlap
}
drawStart = (ev.start < dayStart) ? QTime(0, 0, 0) : ev.start.time();
drawEnd = (evEnd >= dayEnd) ? QTime(23, 59, 59) : evEnd.time();
return true;
}
QList<CalendarWeekTimeGrid::EventLayout>
CalendarWeekTimeGrid::LayoutDay(int dayIndex) const
{
const QDate date = _startOfWeek.addDays(dayIndex);
// Collect events that overlap this day, sorted by their clipped start.
struct DayEvent {
CalendarEvent ev;
QTime drawStart;
QTime drawEnd;
};
QList<DayEvent> dayEvents;
for (const auto &ev : _events) {
QTime ds, de;
if (ClipEventToDay(ev, date, ds, de)) {
dayEvents.append({ev, ds, de});
}
}
std::sort(dayEvents.begin(), dayEvents.end(),
[](const DayEvent &a, const DayEvent &b) {
return a.drawStart < b.drawStart;
});
QList<EventLayout> result;
QList<QTime> columnEnds;
for (const auto &de : dayEvents) {
int col = -1;
for (int i = 0; i < columnEnds.size(); ++i) {
if (columnEnds[i] <= de.drawStart) {
col = i;
columnEnds[i] = de.drawEnd;
break;
}
}
if (col == -1) {
col = columnEnds.size();
columnEnds.append(de.drawEnd);
}
result.append({de.ev, de.drawStart, de.drawEnd, col, 0});
}
const int totalCols = qMax(1, (int)columnEnds.size());
for (auto &layout : result) {
layout.numCols = totalCols;
}
return result;
}
QString CalendarWeekTimeGrid::EventIdAtPoint(const QPoint &pos) const
{
if (pos.x() < CalendarWeekView::TIME_AXIS_WIDTH) {
return {};
}
const int dayW = DayWidth();
const int dayIdx = (pos.x() - CalendarWeekView::TIME_AXIS_WIDTH) / dayW;
if (dayIdx < 0 || dayIdx >= 7) {
return {};
}
const int dayX = DayColumnX(dayIdx);
for (const auto &layout : LayoutDay(dayIdx)) {
const int colW = dayW / layout.numCols;
const int x = dayX + layout.col * colW + 2;
const int w = colW - 4;
const int y = TimeToY(layout.drawStart);
const int h = qMax(TimeToY(layout.drawEnd) - y, 20);
if (QRect(x, y, w, h).contains(pos)) {
return layout.event.id;
}
}
return {};
}
QDateTime CalendarWeekTimeGrid::SlotAtPoint(const QPoint &pos) const
{
if (pos.x() < CalendarWeekView::TIME_AXIS_WIDTH) {
return {};
}
const int dayW = DayWidth();
const int dayIdx = (pos.x() - CalendarWeekView::TIME_AXIS_WIDTH) / dayW;
if (dayIdx < 0 || dayIdx >= 7) {
return {};
}
return QDateTime(_startOfWeek.addDays(dayIdx), YToTime(pos.y()));
}
void CalendarWeekTimeGrid::DrawEvent(QPainter &p, const EventLayout &layout,
int dayX, int dayW)
{
const auto &ev = layout.event;
const int colW = dayW / layout.numCols;
const int x = dayX + layout.col * colW + 2;
const int w = colW - 4;
const int y = TimeToY(layout.drawStart);
const int endY = TimeToY(layout.drawEnd);
const int h = qMax(endY - y, 20);
const QRect rect(x, y, w, h);
p.setBrush(ev.color);
p.setPen(ev.color.darker(130));
p.drawRoundedRect(rect, 3, 3);
p.fillRect(x, y + 1, 3, h - 2, ev.color.darker(160));
p.setPen(Qt::white);
QFont f = p.font();
f.setPixelSize(10);
f.setBold(true);
p.setFont(f);
const QRect textRect = rect.adjusted(6, 2, -2, -2);
if (h >= 34) {
QFont tf = f;
tf.setBold(false);
p.setFont(tf);
p.drawText(textRect, Qt::AlignTop | Qt::AlignLeft,
ev.start.toString("HH:mm"));
p.setFont(f);
p.drawText(textRect.adjusted(0, 14, 0, 0),
Qt::AlignTop | Qt::AlignLeft | Qt::TextWordWrap,
ev.title);
} else {
p.drawText(textRect,
Qt::AlignVCenter | Qt::AlignLeft |
Qt::TextSingleLine,
ev.title);
}
}
void CalendarWeekTimeGrid::paintEvent(QPaintEvent *)
{
QPainter p(this);
p.setRenderHint(QPainter::Antialiasing);
const int w = width();
const int h = height();
const int dayW = DayWidth();
const QDate today = QDate::currentDate();
p.fillRect(0, 0, w, h, palette().base());
p.fillRect(0, 0, CalendarWeekView::TIME_AXIS_WIDTH, h,
palette().button());
for (int d = 0; d < 7; ++d) {
const QDate date = _startOfWeek.addDays(d);
const int x = DayColumnX(d);
if (date == today) {
p.fillRect(x, 0, dayW, h,
palette().highlight().color().lighter(190));
} else if (date.dayOfWeek() >= 6) {
p.fillRect(x, 0, dayW, h,
palette().alternateBase().color());
}
}
QFont labelFont = p.font();
labelFont.setPixelSize(10);
p.setFont(labelFont);
for (int hour = 0; hour < 24; ++hour) {
const int y = hour * CalendarWeekView::PIXELS_PER_HOUR;
p.setPen(palette().text().color());
p.drawText(2, y, CalendarWeekView::TIME_AXIS_WIDTH - 6,
CalendarWeekView::PIXELS_PER_HOUR,
Qt::AlignTop | Qt::AlignRight,
QTime(hour, 0).toString("HH:mm"));
p.setPen(QPen(palette().mid().color(), 1));
p.drawLine(CalendarWeekView::TIME_AXIS_WIDTH, y, w, y);
QPen halfPen(palette().midlight().color(), 1, Qt::DotLine);
p.setPen(halfPen);
const int yHalf = y + CalendarWeekView::PIXELS_PER_HOUR / 2;
p.drawLine(CalendarWeekView::TIME_AXIS_WIDTH, yHalf, w, yHalf);
}
p.setPen(QPen(palette().mid().color(), 1));
for (int d = 0; d <= 7; ++d) {
const int x = DayColumnX(d);
p.drawLine(x, 0, x, h);
}
p.drawLine(CalendarWeekView::TIME_AXIS_WIDTH, 0,
CalendarWeekView::TIME_AXIS_WIDTH, h);
for (int d = 0; d < 7; ++d) {
const int dayX = DayColumnX(d);
for (const auto &layout : LayoutDay(d)) {
DrawEvent(p, layout, dayX, dayW);
}
}
// Current-time indicator
if (_startOfWeek.isValid() && _startOfWeek <= today &&
today <= _startOfWeek.addDays(6)) {
const int dayIdx = _startOfWeek.daysTo(today);
const int dayX = DayColumnX(dayIdx);
const int y = TimeToY(QTime::currentTime());
p.setPen(QPen(QColor(220, 30, 30), 2));
p.drawLine(dayX, y, dayX + dayW, y);
p.setBrush(QColor(220, 30, 30));
p.setPen(Qt::NoPen);
p.drawEllipse(dayX - 4, y - 4, 8, 8);
}
}
void CalendarWeekTimeGrid::mousePressEvent(QMouseEvent *e)
{
if (e->button() != Qt::LeftButton) {
return;
}
const QString id = EventIdAtPoint(e->pos());
if (!id.isEmpty()) {
emit EventClicked(id);
return;
}
const QDateTime slot = SlotAtPoint(e->pos());
if (slot.isValid()) {
emit SlotClicked(slot);
}
}
void CalendarWeekTimeGrid::mouseDoubleClickEvent(QMouseEvent *e)
{
if (e->button() != Qt::LeftButton) {
return;
}
const QString id = EventIdAtPoint(e->pos());
if (!id.isEmpty()) {
emit EventDoubleClicked(id);
}
}
// ---------------------------------------------------------------------------
// CalendarWeekView
// ---------------------------------------------------------------------------
CalendarWeekView::CalendarWeekView(QWidget *parent) : CalendarView(parent)
{
_header = new CalendarWeekDayHeader(this);
_timeGrid = new CalendarWeekTimeGrid(this);
// Fix the minimum height so the scroll area cannot shrink the grid
// below the full 24-hour span — which would eliminate the scrollbar.
_timeGrid->setMinimumHeight(24 * CalendarWeekView::PIXELS_PER_HOUR);
_scrollArea = new QScrollArea(this);
_scrollArea->setWidget(_timeGrid);
// widgetResizable(true) lets the grid expand horizontally to fill the
// viewport width while the fixed minimumHeight enforces vertical scrolling.
_scrollArea->setWidgetResizable(true);
_scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
_scrollArea->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded);
_scrollArea->setFrameShape(QFrame::NoFrame);
auto layout = new QVBoxLayout(this);
layout->setContentsMargins(0, 0, 0, 0);
layout->setSpacing(0);
layout->addWidget(_header);
layout->addWidget(_scrollArea);
setLayout(layout);
connect(_timeGrid, &CalendarWeekTimeGrid::SlotClicked, this,
&CalendarWeekView::SlotClicked);
connect(_timeGrid, &CalendarWeekTimeGrid::EventClicked, this,
&CalendarWeekView::EventClicked);
connect(_timeGrid, &CalendarWeekTimeGrid::EventDoubleClicked, this,
&CalendarWeekView::EventDoubleClicked);
SetDate(QDate::currentDate());
}
void CalendarWeekView::SetDate(const QDate &date)
{
_startOfWeek = date.addDays(-(date.dayOfWeek() - 1));
UpdateViews();
emit VisibleRangeChanged(RangeStart(), RangeEnd());
}
void CalendarWeekView::SetEvents(const QList<CalendarEvent> &events)
{
_events = events;
_timeGrid->SetEvents(events);
}
QDate CalendarWeekView::CurrentDate() const
{
return _startOfWeek.addDays(3); // Wednesday - stable mid-point
}
QDate CalendarWeekView::RangeStart() const
{
return _startOfWeek;
}
QDate CalendarWeekView::RangeEnd() const
{
return _startOfWeek.addDays(6);
}
void CalendarWeekView::UpdateViews()
{
_header->SetStartOfWeek(_startOfWeek);
_timeGrid->SetStartOfWeek(_startOfWeek);
_timeGrid->ScrollToCurrentTime(_scrollArea);
}
} // namespace advss
// Required for CalendarWeekTimeGrid defined in this file
#include "calendar-week-view.moc"

View File

@@ -0,0 +1,45 @@
#pragma once
#include "calendar-view.hpp"
#include <QScrollArea>
namespace advss {
class CalendarWeekDayHeader;
class CalendarWeekTimeGrid;
// Displays 7 day columns with a vertical time axis (00:00 - 24:00).
// Events are rendered as colored blocks sized to their duration.
// Overlapping events within the same day are arranged in side-by-side
// sub-columns. A red indicator marks the current time.
class CalendarWeekView : public CalendarView {
Q_OBJECT
public:
explicit CalendarWeekView(QWidget *parent = nullptr);
void SetDate(const QDate &date) override;
void SetEvents(const QList<CalendarEvent> &events) override;
QDate CurrentDate() const override;
QDate RangeStart() const override;
QDate RangeEnd() const override;
// Shared layout constants (used by DayHeader and TimeGrid)
static constexpr int TIME_AXIS_WIDTH = 56;
static constexpr int PIXELS_PER_HOUR = 64;
static constexpr int DAY_HEADER_HEIGHT = 36;
static constexpr int MIN_DAY_WIDTH = 80;
private:
void UpdateViews();
CalendarWeekDayHeader *_header;
CalendarWeekTimeGrid *_timeGrid;
QScrollArea *_scrollArea;
QDate _startOfWeek;
QList<CalendarEvent> _events;
};
} // namespace advss

View File

@@ -0,0 +1,308 @@
#include "calendar-widget.hpp"
#include "obs-module-helper.hpp"
#include "ui-helpers.hpp"
#include <QHBoxLayout>
#include <QLocale>
#include <QVBoxLayout>
namespace advss {
CalendarWidget::CalendarWidget(QWidget *parent)
: QWidget(parent),
_prevBtn(new QPushButton(this)),
_nextBtn(new QPushButton(this)),
_todayBtn(new QPushButton(
obs_module_text("AdvSceneSwitcher.calendar.today"), this)),
_navLabel(new QLabel(this)),
_monthBtn(new QPushButton(
obs_module_text("AdvSceneSwitcher.calendar.month"), this)),
_weekBtn(new QPushButton(
obs_module_text("AdvSceneSwitcher.calendar.week"), this)),
_dayBtn(new QPushButton(
obs_module_text("AdvSceneSwitcher.calendar.day"), this)),
_viewStack(new QStackedWidget(this)),
_monthView(new CalendarMonthView(this)),
_weekView(new CalendarWeekView(this)),
_dayView(new CalendarDayView(this))
{
// --- Navigation bar ---
SetButtonIcon(_prevBtn, GetThemeTypeName() == "Light"
? "theme:Light/left.svg"
: "theme:Dark/left.svg");
SetButtonIcon(_nextBtn, GetThemeTypeName() == "Light"
? "theme:Light/right.svg"
: "theme:Dark/right.svg");
_navLabel->setAlignment(Qt::AlignCenter);
_monthBtn->setCheckable(true);
_weekBtn->setCheckable(true);
_dayBtn->setCheckable(true);
_weekBtn->setChecked(true);
auto navBar = new QHBoxLayout();
navBar->addWidget(_prevBtn);
navBar->addWidget(_nextBtn);
navBar->addWidget(_todayBtn);
navBar->addStretch();
navBar->addWidget(_navLabel, 1);
navBar->addStretch();
navBar->addWidget(_monthBtn);
navBar->addWidget(_weekBtn);
navBar->addWidget(_dayBtn);
// --- Views ---
_viewStack->addWidget(_monthView);
_viewStack->addWidget(_weekView);
_viewStack->addWidget(_dayView);
// --- Main layout ---
auto mainLayout = new QVBoxLayout(this);
mainLayout->setContentsMargins(0, 0, 0, 0);
mainLayout->addLayout(navBar);
mainLayout->addWidget(_viewStack, 1);
setLayout(mainLayout);
// --- Connections ---
connect(_prevBtn, &QPushButton::clicked, this,
&CalendarWidget::OnPrevClicked);
connect(_nextBtn, &QPushButton::clicked, this,
&CalendarWidget::OnNextClicked);
connect(_todayBtn, &QPushButton::clicked, this,
&CalendarWidget::OnTodayClicked);
connect(_monthBtn, &QPushButton::clicked, this,
&CalendarWidget::OnMonthModeClicked);
connect(_weekBtn, &QPushButton::clicked, this,
&CalendarWidget::OnWeekModeClicked);
connect(_dayBtn, &QPushButton::clicked, this,
&CalendarWidget::OnDayModeClicked);
ConnectView(_monthView);
ConnectView(_weekView);
ConnectView(_dayView);
// Start in week view
SwitchToView(_weekView);
UpdateNavLabel();
}
// ---------------------------------------------------------------------------
// Events
// ---------------------------------------------------------------------------
void CalendarWidget::SetEvents(const QList<CalendarEvent> &events)
{
_events = events;
_monthView->SetEvents(events);
_weekView->SetEvents(events);
_dayView->SetEvents(events);
}
void CalendarWidget::AddEvent(const CalendarEvent &event)
{
_events.append(event);
_monthView->SetEvents(_events);
_weekView->SetEvents(_events);
_dayView->SetEvents(_events);
}
void CalendarWidget::RemoveEvent(const QString &id)
{
_events.erase(std::remove_if(_events.begin(), _events.end(),
[&id](const CalendarEvent &e) {
return e.id == id;
}),
_events.end());
_monthView->SetEvents(_events);
_weekView->SetEvents(_events);
_dayView->SetEvents(_events);
}
void CalendarWidget::ClearEvents()
{
_events.clear();
_monthView->SetEvents(_events);
_weekView->SetEvents(_events);
_dayView->SetEvents(_events);
}
// ---------------------------------------------------------------------------
// Navigation
// ---------------------------------------------------------------------------
void CalendarWidget::GoToDate(const QDate &date)
{
_activeView->SetDate(date);
UpdateNavLabel();
}
void CalendarWidget::GoToToday()
{
GoToDate(QDate::currentDate());
}
QDate CalendarWidget::VisibleRangeStart() const
{
return _activeView ? _activeView->RangeStart() : QDate();
}
QDate CalendarWidget::VisibleRangeEnd() const
{
return _activeView ? _activeView->RangeEnd() : QDate();
}
void CalendarWidget::OnPrevClicked()
{
if (_viewMode == ViewMode::Month) {
_activeView->SetDate(_activeView->CurrentDate().addMonths(-1));
} else if (_viewMode == ViewMode::Week) {
_activeView->SetDate(_activeView->RangeStart().addDays(-7));
} else {
_activeView->SetDate(_activeView->CurrentDate().addDays(-1));
}
UpdateNavLabel();
}
void CalendarWidget::OnNextClicked()
{
if (_viewMode == ViewMode::Month) {
_activeView->SetDate(_activeView->CurrentDate().addMonths(1));
} else if (_viewMode == ViewMode::Week) {
_activeView->SetDate(_activeView->RangeStart().addDays(7));
} else {
_activeView->SetDate(_activeView->CurrentDate().addDays(1));
}
UpdateNavLabel();
}
void CalendarWidget::OnTodayClicked()
{
GoToToday();
}
void CalendarWidget::OnMonthModeClicked()
{
SetViewMode(ViewMode::Month);
}
void CalendarWidget::OnWeekModeClicked()
{
SetViewMode(ViewMode::Week);
}
void CalendarWidget::OnDayModeClicked()
{
SetViewMode(ViewMode::Day);
}
void CalendarWidget::OnViewRangeChanged(const QDate &start, const QDate &end)
{
emit VisibleRangeChanged(start, end);
UpdateNavLabel();
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
void CalendarWidget::ConnectView(CalendarView *view)
{
connect(view, &CalendarView::SlotClicked, this,
&CalendarWidget::SlotClicked);
connect(view, &CalendarView::EventClicked, this,
&CalendarWidget::EventClicked);
connect(view, &CalendarView::EventDoubleClicked, this,
&CalendarWidget::EventDoubleClicked);
connect(view, &CalendarView::VisibleRangeChanged, this,
&CalendarWidget::OnViewRangeChanged);
}
void CalendarWidget::SwitchToView(CalendarView *view)
{
_activeView = view;
_viewStack->setCurrentWidget(view);
view->SetEvents(_events);
UpdateNavLabel();
}
void CalendarWidget::SetViewMode(ViewMode mode)
{
if (_viewMode == mode) {
return;
}
_viewMode = mode;
// Preserve the current date when switching views
const QDate current = _activeView->CurrentDate();
_monthBtn->setChecked(mode == ViewMode::Month);
_weekBtn->setChecked(mode == ViewMode::Week);
_dayBtn->setChecked(mode == ViewMode::Day);
switch (mode) {
case ViewMode::Month:
SwitchToView(_monthView);
_monthView->SetDate(current);
break;
case ViewMode::Week:
SwitchToView(_weekView);
_weekView->SetDate(current);
break;
case ViewMode::Day: {
SwitchToView(_dayView);
const QDate today = QDate::currentDate();
const QDate rangeStart = _activeView->RangeStart();
const QDate rangeEnd = _activeView->RangeEnd();
const QDate target = (today >= rangeStart && today <= rangeEnd)
? today
: rangeStart;
_dayView->SetDate(target);
break;
}
}
UpdateNavLabel();
}
void CalendarWidget::UpdateNavLabel()
{
if (!_activeView) {
return;
}
QLocale locale;
const QDate cur = _activeView->CurrentDate();
if (_viewMode == ViewMode::Month) {
_navLabel->setText(locale.monthName(cur.month()) + " " +
QString::number(cur.year()));
} else if (_viewMode == ViewMode::Day) {
// Day view: "Monday, April 13, 2026"
_navLabel->setText(locale.dayName(cur.dayOfWeek()) + ", " +
locale.monthName(cur.month()) + " " +
QString::number(cur.day()) + ", " +
QString::number(cur.year()));
} else {
// Week view: "Apr 7 Apr 13, 2026"
const QDate s = _activeView->RangeStart();
const QDate e = _activeView->RangeEnd();
if (s.month() == e.month()) {
_navLabel->setText(
locale.monthName(s.month(),
QLocale::ShortFormat) +
" " + QString::number(s.day()) + " - " +
QString::number(e.day()) + ", " +
QString::number(s.year()));
} else {
_navLabel->setText(
locale.monthName(s.month(),
QLocale::ShortFormat) +
" " + QString::number(s.day()) + " - " +
locale.monthName(e.month(),
QLocale::ShortFormat) +
" " + QString::number(e.day()) + ", " +
QString::number(s.year()));
}
}
}
} // namespace advss

View File

@@ -0,0 +1,90 @@
#pragma once
#include "calendar-day-view.hpp"
#include "calendar-event.hpp"
#include "calendar-month-view.hpp"
#include "calendar-week-view.hpp"
#include <QLabel>
#include <QList>
#include <QPushButton>
#include <QStackedWidget>
#include <QWidget>
namespace advss {
class CalendarWidget : public QWidget {
Q_OBJECT
public:
enum class ViewMode { Month, Week, Day };
explicit CalendarWidget(QWidget *parent = nullptr);
// --- View ---
void SetViewMode(ViewMode mode);
ViewMode GetViewMode() const { return _viewMode; }
// --- Events ---
void SetEvents(const QList<CalendarEvent> &events);
void AddEvent(const CalendarEvent &event);
void RemoveEvent(const QString &id);
void ClearEvents();
const QList<CalendarEvent> &GetEvents() const { return _events; }
// --- Navigation ---
void GoToDate(const QDate &date);
void GoToToday();
// --- Visible range ---
QDate VisibleRangeStart() const;
QDate VisibleRangeEnd() const;
signals:
// User clicked an empty slot in the active view.
void SlotClicked(const QDateTime &startTime);
// User single-clicked an event.
void EventClicked(const QString &eventId);
// User double-clicked an event (typically: open edit dialog).
void EventDoubleClicked(const QString &eventId);
// The visible date range changed; reload your events for [start, end].
void VisibleRangeChanged(const QDate &rangeStart,
const QDate &rangeEnd);
private slots:
void OnPrevClicked();
void OnNextClicked();
void OnTodayClicked();
void OnMonthModeClicked();
void OnWeekModeClicked();
void OnDayModeClicked();
void OnViewRangeChanged(const QDate &start, const QDate &end);
private:
void ConnectView(CalendarView *view);
void SwitchToView(CalendarView *view);
void UpdateNavLabel();
// Navigation bar widgets
QPushButton *_prevBtn;
QPushButton *_nextBtn;
QPushButton *_todayBtn;
QLabel *_navLabel;
QPushButton *_monthBtn;
QPushButton *_weekBtn;
QPushButton *_dayBtn;
// View stack
QStackedWidget *_viewStack;
CalendarMonthView *_monthView;
CalendarWeekView *_weekView;
CalendarDayView *_dayView;
CalendarView *_activeView = nullptr;
ViewMode _viewMode = ViewMode::Week;
QList<CalendarEvent> _events;
};
} // namespace advss

View File

@@ -0,0 +1,373 @@
#include "macro-schedule-entry-dialog.hpp"
#include "help-icon.hpp"
#include "obs-module-helper.hpp"
#include "ui-helpers.hpp"
#include <QColorDialog>
#include <QDialogButtonBox>
#include <QFormLayout>
#include <QHBoxLayout>
#include <QVBoxLayout>
namespace advss {
bool MacroScheduleEntryDialog::AskForSettings(QWidget *parent,
MacroScheduleEntry &entry,
bool isNew)
{
MacroScheduleEntryDialog dialog(parent, entry, isNew);
if (dialog.exec() != QDialog::Accepted) {
return false;
}
dialog.ApplyToEntry(entry);
return true;
}
MacroScheduleEntryDialog::MacroScheduleEntryDialog(
QWidget *parent, const MacroScheduleEntry &entry, bool isNew)
: QDialog(parent),
_name(new QLineEdit(this)),
_macroSel(new MacroSelection(this)),
_startDateTime(new QDateTimeEdit(this)),
_hasEndDate(new QCheckBox(
obs_module_text("AdvSceneSwitcher.macroScheduleEntry.endDate"),
this)),
_endDate(new QDateTimeEdit(this)),
_endDateAction(new QComboBox(this)),
_doesRepeat(new QCheckBox(
obs_module_text("AdvSceneSwitcher.macroScheduleEntry.repeat"),
this)),
_repeatInterval(new DurationSelection(this, true, 0.1)),
_repeatEndGroup(new QGroupBox(
obs_module_text(
"AdvSceneSwitcher.macroScheduleEntry.repeatEnd"),
this)),
_endNever(new QRadioButton(
obs_module_text(
"AdvSceneSwitcher.macroScheduleEntry.repeatEnd.never"),
_repeatEndGroup)),
_endAfterN(new QRadioButton(
obs_module_text(
"AdvSceneSwitcher.macroScheduleEntry.repeatEnd.afterNTimes"),
_repeatEndGroup)),
_endCount(new QSpinBox(_repeatEndGroup)),
_endUntil(new QRadioButton(
obs_module_text(
"AdvSceneSwitcher.macroScheduleEntry.repeatEnd.untilDate"),
_repeatEndGroup)),
_endUntilDate(new QDateEdit(_repeatEndGroup)),
_colorBtn(new QPushButton(this)),
_checkConditions(new QCheckBox(
obs_module_text(
"AdvSceneSwitcher.macroScheduleEntry.checkConditions"),
this)),
_runElseActionsOnConditionFailure(new QCheckBox(
obs_module_text(
"AdvSceneSwitcher.macroScheduleEntry.runElseActionsOnConditionFailure"),
this)),
_enabled(new QCheckBox(
obs_module_text("AdvSceneSwitcher.macroScheduleEntry.enabled"),
this))
{
setWindowTitle(obs_module_text(
isNew ? "AdvSceneSwitcher.macroScheduleEntry.dialog.title.add"
: "AdvSceneSwitcher.macroScheduleEntry.dialog.title.edit"));
setMinimumWidth(420);
auto form = new QFormLayout();
form->setFieldGrowthPolicy(QFormLayout::ExpandingFieldsGrow);
// --- Name ---
_name->setPlaceholderText(obs_module_text(
"AdvSceneSwitcher.macroScheduleEntry.name.placeholder"));
form->addRow(
obs_module_text("AdvSceneSwitcher.macroScheduleEntry.name"),
_name);
// --- Macro ---
form->addRow(
obs_module_text("AdvSceneSwitcher.macroScheduleEntry.macro"),
_macroSel);
// --- Start date/time ---
_startDateTime->setDisplayFormat("yyyy-MM-dd HH:mm:ss");
_startDateTime->setCalendarPopup(true);
_startDateTime->setDateTime(QDateTime::currentDateTime());
form->addRow(
obs_module_text(
"AdvSceneSwitcher.macroScheduleEntry.startDateTime"),
_startDateTime);
// --- Optional end date ---
_endDate->setDisplayFormat("yyyy-MM-dd HH:mm:ss");
_endDate->setCalendarPopup(true);
_endDate->setDateTime(QDateTime::currentDateTime().addSecs(60 * 60));
_endDate->setEnabled(false);
auto endDateHelp = new HelpIcon(
obs_module_text(
"AdvSceneSwitcher.macroScheduleEntry.endDate.help"),
this);
_endDateAction->addItem(
obs_module_text(
"AdvSceneSwitcher.macroScheduleEntry.endDate.action.none"),
static_cast<int>(MacroScheduleEntry::EndDateAction::NONE));
_endDateAction->addItem(
obs_module_text(
"AdvSceneSwitcher.macroScheduleEntry.endDate.action.disableEntry"),
static_cast<int>(
MacroScheduleEntry::EndDateAction::DISABLE_ENTRY));
_endDateAction->addItem(
obs_module_text(
"AdvSceneSwitcher.macroScheduleEntry.endDate.action.pauseMacro"),
static_cast<int>(
MacroScheduleEntry::EndDateAction::PAUSE_MACRO));
_endDateAction->addItem(
obs_module_text(
"AdvSceneSwitcher.macroScheduleEntry.endDate.action.stopMacro"),
static_cast<int>(
MacroScheduleEntry::EndDateAction::STOP_MACRO));
_endDateAction->setEnabled(false);
auto endDateRow = new QHBoxLayout();
endDateRow->addWidget(_hasEndDate);
endDateRow->addWidget(_endDate);
endDateRow->addWidget(_endDateAction);
endDateRow->addWidget(endDateHelp);
endDateRow->addStretch();
form->addRow(QString(), endDateRow);
connect(_hasEndDate, &QCheckBox::toggled, _endDate,
&QDateTimeEdit::setEnabled);
connect(_hasEndDate, &QCheckBox::toggled, _endDateAction,
&QComboBox::setEnabled);
// Keep end date minimum in sync with the start date.
_endDate->setMinimumDateTime(_startDateTime->dateTime());
connect(_startDateTime, &QDateTimeEdit::dateTimeChanged, this,
[this](const QDateTime &dt) {
_endDate->setMinimumDateTime(dt);
if (_endDate->dateTime() < dt) {
_endDate->setDateTime(dt);
}
});
// --- Repeat ---
form->addRow(QString(), _doesRepeat);
_repeatInterval->setVisible(false);
form->addRow(
obs_module_text(
"AdvSceneSwitcher.macroScheduleEntry.repeat.interval"),
_repeatInterval);
if (auto *label = form->labelForField(_repeatInterval)) {
label->setVisible(false);
}
connect(_doesRepeat, &QCheckBox::toggled, this,
[form, this](bool checked) {
_repeatInterval->setVisible(checked);
if (auto *label =
form->labelForField(_repeatInterval)) {
label->setVisible(checked);
}
_repeatEndGroup->setVisible(checked);
});
// --- Repeat end group (hidden when NONE) ---
auto repeatEndLayout = new QVBoxLayout(_repeatEndGroup);
_endNever->setChecked(true);
repeatEndLayout->addWidget(_endNever);
auto afterNRow = new QHBoxLayout();
_endCount->setMinimum(1);
_endCount->setMaximum(99999);
_endCount->setValue(1);
_endCount->setEnabled(false);
auto afterNSuffix = new QLabel(
obs_module_text(
"AdvSceneSwitcher.macroScheduleEntry.repeatEnd.afterNTimes.suffix"),
_repeatEndGroup);
afterNRow->addWidget(_endAfterN);
afterNRow->addWidget(_endCount);
afterNRow->addWidget(afterNSuffix);
afterNRow->addStretch();
repeatEndLayout->addLayout(afterNRow);
auto untilRow = new QHBoxLayout();
_endUntilDate->setCalendarPopup(true);
_endUntilDate->setDate(QDate::currentDate().addMonths(1));
_endUntilDate->setEnabled(false);
untilRow->addWidget(_endUntil);
untilRow->addWidget(_endUntilDate);
untilRow->addStretch();
repeatEndLayout->addLayout(untilRow);
_repeatEndGroup->setVisible(false);
form->addRow(_repeatEndGroup);
// Enable/disable child widgets based on radio selection
connect(_endAfterN, &QRadioButton::toggled, _endCount,
&QSpinBox::setEnabled);
connect(_endUntil, &QRadioButton::toggled, _endUntilDate,
&QDateEdit::setEnabled);
// --- Color ---
_colorBtn->setFixedSize(48, 22);
_colorBtn->setFlat(false);
connect(_colorBtn, &QPushButton::clicked, this, [this]() {
const QColor picked = QColorDialog::getColor(
_color, this,
obs_module_text(
"AdvSceneSwitcher.macroScheduleEntry.color.dialog"));
if (picked.isValid()) {
_color = picked;
UpdateColorButton();
}
});
form->addRow(
obs_module_text("AdvSceneSwitcher.macroScheduleEntry.color"),
_colorBtn);
// --- Options ---
form->addRow(QString(), _checkConditions);
_runElseActionsOnConditionFailure->setVisible(false);
form->addRow(QString(), _runElseActionsOnConditionFailure);
connect(_checkConditions, &QCheckBox::toggled,
_runElseActionsOnConditionFailure, &QCheckBox::setVisible);
_enabled->setChecked(true);
form->addRow(QString(), _enabled);
// --- Buttons ---
auto buttons = new QDialogButtonBox(
QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this);
connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept);
connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject);
// --- Main layout ---
auto mainLayout = new QVBoxLayout(this);
mainLayout->addLayout(form);
mainLayout->addWidget(buttons);
setLayout(mainLayout);
PopulateFromEntry(entry);
}
void MacroScheduleEntryDialog::PopulateFromEntry(const MacroScheduleEntry &entry)
{
_name->setText(QString::fromStdString(entry.name));
MacroRef ref = entry.macro;
_macroSel->SetCurrentMacro(ref);
if (entry.startDateTime.isValid()) {
_startDateTime->setDateTime(entry.startDateTime);
}
_hasEndDate->setChecked(entry.hasEndDate);
if (entry.hasEndDate && entry.endDate.isValid()) {
_endDate->setDateTime(entry.endDate);
}
{
const int idx = _endDateAction->findData(
static_cast<int>(entry.endDateAction));
if (idx >= 0) {
_endDateAction->setCurrentIndex(idx);
}
}
_doesRepeat->setChecked(entry.doesRepeat);
_repeatInterval->SetDuration(entry.repeatInterval);
_doesRepeat->toggled(entry.doesRepeat);
switch (entry.repeatEndType) {
case MacroScheduleEntry::RepeatEndType::NEVER:
_endNever->setChecked(true);
break;
case MacroScheduleEntry::RepeatEndType::AFTER_N_TIMES:
_endAfterN->setChecked(true);
_endCount->setValue(entry.repeatMaxCount);
break;
case MacroScheduleEntry::RepeatEndType::UNTIL_DATE:
_endUntil->setChecked(true);
if (entry.repeatUntilDate.isValid()) {
_endUntilDate->setDate(entry.repeatUntilDate.date());
}
break;
}
_color = entry.color.isValid() ? entry.color : QColor(70, 130, 180);
UpdateColorButton();
_checkConditions->setChecked(entry.checkConditions);
_runElseActionsOnConditionFailure->setChecked(
entry.runElseActionsOnConditionFailure);
_runElseActionsOnConditionFailure->setVisible(entry.checkConditions);
_enabled->setChecked(entry.enabled);
}
void MacroScheduleEntryDialog::ApplyToEntry(MacroScheduleEntry &entry) const
{
entry.name = _name->text().toStdString();
const QString macroName = _macroSel->currentText();
entry.macro = macroName;
const QDateTime newStartDateTime = _startDateTime->dateTime();
const bool startChanged = (newStartDateTime != entry.startDateTime);
entry.startDateTime = newStartDateTime;
if (startChanged) {
entry.FastForwardTo(QDateTime::currentDateTime());
}
entry.hasEndDate = _hasEndDate->isChecked();
if (entry.hasEndDate) {
entry.endDate = _endDate->dateTime();
entry.endDateAction =
static_cast<MacroScheduleEntry::EndDateAction>(
_endDateAction->currentData().toInt());
} else {
entry.endDate = QDateTime();
entry.endDateAction = MacroScheduleEntry::EndDateAction::NONE;
}
entry.endDateActionApplied = false;
entry.doesRepeat = _doesRepeat->isChecked();
entry.repeatInterval = _repeatInterval->GetDuration();
if (_endNever->isChecked()) {
entry.repeatEndType = MacroScheduleEntry::RepeatEndType::NEVER;
entry.repeatUntilDate = QDateTime();
} else if (_endAfterN->isChecked()) {
entry.repeatEndType =
MacroScheduleEntry::RepeatEndType::AFTER_N_TIMES;
entry.repeatMaxCount = _endCount->value();
entry.repeatUntilDate = QDateTime();
} else if (_endUntil->isChecked()) {
entry.repeatEndType =
MacroScheduleEntry::RepeatEndType::UNTIL_DATE;
entry.repeatUntilDate =
QDateTime(_endUntilDate->date(), QTime(23, 59, 59));
}
entry.color = _color;
entry.checkConditions = _checkConditions->isChecked();
entry.runElseActionsOnConditionFailure =
_runElseActionsOnConditionFailure->isChecked();
entry.enabled = _enabled->isChecked();
}
void MacroScheduleEntryDialog::UpdateColorButton()
{
// Fill the button with the chosen color so it acts as a swatch
const QString style =
QString("background-color: %1; border: 1px solid #888;")
.arg(_color.name());
_colorBtn->setStyleSheet(style);
}
} // namespace advss

View File

@@ -0,0 +1,71 @@
#pragma once
#include "duration-control.hpp"
#include "macro-schedule.hpp"
#include "macro-selection.hpp"
#include <QCheckBox>
#include <QColor>
#include <QDateEdit>
#include <QDateTimeEdit>
#include <QDialog>
#include <QGroupBox>
#include <QLabel>
#include <QLineEdit>
#include <QPushButton>
#include <QRadioButton>
#include <QSpinBox>
#include <QComboBox>
namespace advss {
// Dialog for creating or editing a MacroScheduleEntry.
class MacroScheduleEntryDialog : public QDialog {
Q_OBJECT
public:
static bool AskForSettings(QWidget *parent, MacroScheduleEntry &entry,
bool isNew);
private:
explicit MacroScheduleEntryDialog(QWidget *parent,
const MacroScheduleEntry &entry,
bool isNew);
void PopulateFromEntry(const MacroScheduleEntry &entry);
void ApplyToEntry(MacroScheduleEntry &entry) const;
// Basic fields
QLineEdit *_name;
MacroSelection *_macroSel;
QDateTimeEdit *_startDateTime;
// Optional end date
QCheckBox *_hasEndDate;
QDateTimeEdit *_endDate;
QComboBox *_endDateAction;
// Repeat
QCheckBox *_doesRepeat;
DurationSelection *_repeatInterval;
// Repeat end (hidden when type == NONE)
QGroupBox *_repeatEndGroup;
QRadioButton *_endNever;
QRadioButton *_endAfterN;
QSpinBox *_endCount;
QRadioButton *_endUntil;
QDateEdit *_endUntilDate;
// Color
QPushButton *_colorBtn;
QColor _color;
void UpdateColorButton();
// Options
QCheckBox *_checkConditions;
QCheckBox *_runElseActionsOnConditionFailure;
QCheckBox *_enabled;
};
} // namespace advss

View File

@@ -0,0 +1,717 @@
#include "macro-schedule-tab.hpp"
#include "calendar/calendar-event.hpp"
#include "macro-schedule-entry-dialog.hpp"
#include "macro-condition.hpp"
#include "macro-helpers.hpp"
#include "macro-signals.hpp"
#include "obs-module-helper.hpp"
#include "sync-helpers.hpp"
#include "tab-helpers.hpp"
#include "ui-helpers.hpp"
#include <QKeyEvent>
#include <QHBoxLayout>
#include <QHeaderView>
#include <QMenu>
#include <QVBoxLayout>
namespace advss {
// ---------------------------------------------------------------------------
// Tab registration
// ---------------------------------------------------------------------------
static bool registerTab();
static bool registerTabDone = registerTab();
static MacroScheduleTab *tabWidget = nullptr;
static constexpr int MACRO_COUNT_THRESHOLD = 5;
static void setTabVisible(QTabWidget *tab, bool visible)
{
SetTabVisibleByName(
tab, visible,
obs_module_text("AdvSceneSwitcher.macroScheduleTab.title"));
}
static bool enoughMacros()
{
return (int)GetAllMacros().size() >= MACRO_COUNT_THRESHOLD;
}
static const QStringList schedulingConditionIds = {"date", "timer"};
static bool anyMacroHasSchedulingCondition()
{
for (const auto &macro : GetAllMacros()) {
auto conditions = GetMacroConditions(macro.get());
if (!conditions) {
continue;
}
for (const auto &condition : *conditions) {
if (schedulingConditionIds.contains(
QString::fromStdString(
condition->GetId()))) {
return true;
}
}
}
return false;
}
static void setupTab(QTabWidget *tab)
{
if (!GetScheduleEntries().empty() || anyMacroHasSchedulingCondition()) {
setTabVisible(tab, true);
return;
} else {
setTabVisible(tab, false);
}
QWidget::connect(MacroSignalManager::Instance(),
&MacroSignalManager::ConditionTypeCreated, tab,
[tab](const QString &typeId) {
if (!schedulingConditionIds.contains(typeId)) {
return;
}
setTabVisible(tab, true);
});
}
static bool registerTab()
{
AddSetupTabCallback("macroScheduleTab", MacroScheduleTab::Create,
setupTab);
return true;
}
// ---------------------------------------------------------------------------
// Entry -> CalendarEvent conversion
// ---------------------------------------------------------------------------
static QColor ColorForEntry(const MacroScheduleEntry &entry)
{
if (!entry.enabled || entry.IsExpired()) {
return QColor(150, 150, 150);
}
return entry.color.isValid() ? entry.color : QColor(70, 130, 180);
}
// Returns one CalendarEvent per occurrence of entry within [rangeStart, rangeEnd].
// For non-repeating entries this is at most one event.
// A hard cap of 500 occurrences prevents infinite loops for misconfigured entries.
static QList<CalendarEvent>
EntryToCalendarEvents(const MacroScheduleEntry &entry, const QDate &rangeStart,
const QDate &rangeEnd)
{
QList<CalendarEvent> result;
const QDateTime windowStart(rangeStart, QTime(0, 0));
const QDateTime windowEnd(rangeEnd, QTime(23, 59, 59));
// Walk occurrences starting from the entry's first trigger time.
// We use a copy to advance lastTriggered without modifying the real entry.
MacroScheduleEntry cursor = entry;
cursor.lastTriggered =
QDateTime(); // start from the very first occurrence
cursor.timesTriggered = 0;
static constexpr int MAX_OCCURRENCES = 500;
for (int i = 0; i < MAX_OCCURRENCES; ++i) {
const QDateTime next = cursor.NextTriggerTime();
if (!next.isValid() || next > windowEnd) {
break;
}
if (next >= windowStart) {
CalendarEvent ev;
// Use id + occurrence index to make each event unique
// while still allowing the tab to look up the parent entry.
ev.id = QString::fromStdString(entry.id);
ev.title = entry.GetSummary();
ev.color = ColorForEntry(entry);
ev.userData = ev.id;
ev.start = next;
if (entry.hasEndDate && entry.endDate.isValid()) {
ev.end = entry.endDate;
}
result.append(ev);
}
if (!entry.doesRepeat) {
break; // one-shot — no more occurrences
}
// Advance cursor past this occurrence
cursor.lastTriggered = next;
++cursor.timesTriggered;
if (cursor.IsExpired()) {
break;
}
}
return result;
}
// ---------------------------------------------------------------------------
// Table columns
// ---------------------------------------------------------------------------
static constexpr int COL_NAME = 0;
static constexpr int COL_MACRO = 1;
static constexpr int COL_SCHEDULE = 2;
static constexpr int COL_NEXT_TRIGGER = 3;
static constexpr int COL_STATUS = 4;
static constexpr int COL_COUNT = 5;
// ---------------------------------------------------------------------------
// MacroScheduleTab
// ---------------------------------------------------------------------------
MacroScheduleTab *MacroScheduleTab::Create()
{
tabWidget = new MacroScheduleTab();
return tabWidget;
}
MacroScheduleTab::MacroScheduleTab(QWidget *parent)
: QWidget(parent),
_calendar(new CalendarWidget(this)),
_table(new QTableWidget(0, COL_COUNT, this)),
_helpLabel(new QLabel(
obs_module_text("AdvSceneSwitcher.macroScheduleTab.help"),
this)),
_addBtn(new QToolButton(this)),
_editBtn(new QPushButton(
obs_module_text("AdvSceneSwitcher.macroScheduleTab.edit"),
this)),
_removeBtn(new QToolButton(this)),
_toggleBtn(new QPushButton(
obs_module_text("AdvSceneSwitcher.macroScheduleTab.enable"),
this)),
_refreshTimer(new QTimer(this))
{
_addBtn->setProperty("themeID",
QVariant(QString::fromUtf8("addIconSmall")));
_addBtn->setProperty("class", QVariant(QString::fromUtf8("icon-plus")));
_addBtn->setToolTip(obs_module_text(
"AdvSceneSwitcher.macroScheduleTab.add.tooltip"));
_removeBtn->setProperty("themeID",
QVariant(QString::fromUtf8("removeIconSmall")));
_removeBtn->setProperty("class",
QVariant(QString::fromUtf8("icon-trash")));
_removeBtn->setToolTip(obs_module_text(
"AdvSceneSwitcher.macroScheduleTab.remove.tooltip"));
if (GetScheduleEntries().empty()) {
_addButtonHighlight =
HighlightWidget(_addBtn, QColor(Qt::green));
}
// --- Table ---
_table->setSelectionBehavior(QAbstractItemView::SelectRows);
_table->setSelectionMode(QAbstractItemView::ExtendedSelection);
_table->setContextMenuPolicy(Qt::CustomContextMenu);
_table->installEventFilter(this);
_table->setEditTriggers(QAbstractItemView::NoEditTriggers);
_table->setAlternatingRowColors(true);
_table->horizontalHeader()->setSectionResizeMode(
QHeaderView::ResizeToContents);
_table->horizontalHeader()->setStretchLastSection(true);
_table->verticalHeader()->setVisible(false);
_table->setHorizontalHeaderLabels(
{obs_module_text(
"AdvSceneSwitcher.macroScheduleTab.column.name"),
obs_module_text(
"AdvSceneSwitcher.macroScheduleTab.column.macro"),
obs_module_text(
"AdvSceneSwitcher.macroScheduleTab.column.schedule"),
obs_module_text(
"AdvSceneSwitcher.macroScheduleTab.column.nextTrigger"),
obs_module_text(
"AdvSceneSwitcher.macroScheduleTab.column.status")});
// --- Help label ---
_helpLabel->setAlignment(Qt::AlignCenter);
_helpLabel->setWordWrap(true);
// --- Button row below the table ---
auto buttonRow = new QHBoxLayout();
buttonRow->setContentsMargins(0, 0, 0, 0);
buttonRow->addWidget(_addBtn);
buttonRow->addWidget(_removeBtn);
buttonRow->addStretch();
buttonRow->addWidget(_editBtn);
buttonRow->addWidget(_toggleBtn);
// --- Right panel: help + table + buttons ---
auto rightPanel = new QWidget(this);
auto rightLayout = new QVBoxLayout(rightPanel);
rightLayout->setContentsMargins(0, 0, 0, 0);
rightLayout->addWidget(_helpLabel);
rightLayout->addWidget(_table, 1);
rightLayout->addLayout(buttonRow);
rightPanel->setLayout(rightLayout);
// --- Splitter ---
auto splitter = new QSplitter(Qt::Horizontal, this);
splitter->addWidget(_calendar);
splitter->addWidget(rightPanel);
splitter->setStretchFactor(0, 1);
splitter->setStretchFactor(1, 1);
// --- Main layout ---
auto mainLayout = new QVBoxLayout(this);
mainLayout->setContentsMargins(4, 4, 4, 4);
mainLayout->addWidget(splitter, 1);
setLayout(mainLayout);
// --- Connections: toolbar buttons ---
connect(_addBtn, &QPushButton::clicked, this, &MacroScheduleTab::Add);
connect(_editBtn, &QPushButton::clicked, this, &MacroScheduleTab::Edit);
connect(_removeBtn, &QPushButton::clicked, this,
&MacroScheduleTab::Remove);
connect(_toggleBtn, &QPushButton::clicked, this,
&MacroScheduleTab::ToggleEnabled);
connect(_table, &QWidget::customContextMenuRequested, this,
&MacroScheduleTab::ShowContextMenu);
// --- Connections: table ---
connect(_table, &QTableWidget::doubleClicked, this,
[this]() { Edit(); });
connect(_table->selectionModel(),
&QItemSelectionModel::selectionChanged, this,
&MacroScheduleTab::OnSelectionChanged);
// --- Connections: calendar ---
connect(_calendar, &CalendarWidget::SlotClicked, this,
&MacroScheduleTab::AddAtTime);
connect(_calendar, &CalendarWidget::EventClicked, this,
&MacroScheduleTab::SelectTableRowById);
connect(_calendar, &CalendarWidget::EventDoubleClicked, this,
&MacroScheduleTab::EditById);
connect(_calendar, &CalendarWidget::VisibleRangeChanged, this,
[this](const QDate &, const QDate &) {
std::deque<MacroScheduleEntry> snapshot;
{
auto lock = LockContext();
snapshot = GetScheduleEntries();
}
RefreshCalendar(snapshot);
});
_refreshTimer->setInterval(30000);
connect(_refreshTimer, &QTimer::timeout, this,
&MacroScheduleTab::Refresh);
_refreshTimer->start();
// Defer the initial refresh: Create() is called while the context lock
// is already held, so we cannot call LockContext() here directly.
QTimer::singleShot(0, this, &MacroScheduleTab::Refresh);
}
// ---------------------------------------------------------------------------
// Refresh
// ---------------------------------------------------------------------------
void MacroScheduleTab::Refresh()
{
std::deque<MacroScheduleEntry> snapshot;
{
auto lock = LockContext();
snapshot = GetScheduleEntries();
}
PopulateTable();
RefreshCalendar(snapshot);
UpdateButtonStates();
}
void MacroScheduleTab::RefreshCalendar(
const std::deque<MacroScheduleEntry> &entries)
{
const QDate rangeStart = _calendar->VisibleRangeStart();
const QDate rangeEnd = _calendar->VisibleRangeEnd();
QList<CalendarEvent> events;
for (const auto &entry : entries) {
events.append(
EntryToCalendarEvents(entry, rangeStart, rangeEnd));
}
_calendar->SetEvents(events);
}
void MacroScheduleTab::PopulateTable()
{
// Remember which entry was selected so we can restore it after repopulating
const int prevIdx = SelectedEntryIndex();
QString prevId;
std::deque<MacroScheduleEntry> snapshot;
{
auto lock = LockContext();
const auto &entries = GetScheduleEntries();
if (prevIdx >= 0 && prevIdx < (int)entries.size()) {
prevId = QString::fromStdString(entries[prevIdx].id);
}
snapshot = entries;
}
_table->setRowCount(0);
for (const auto &entry : snapshot) {
const int row = _table->rowCount();
_table->insertRow(row);
auto makeItem = [](const QString &text) {
auto item = new QTableWidgetItem(text);
item->setFlags(item->flags() & ~Qt::ItemIsEditable);
return item;
};
_table->setItem(row, COL_NAME, makeItem(entry.GetSummary()));
_table->setItem(
row, COL_MACRO,
makeItem(QString::fromStdString(entry.macro.Name())));
_table->setItem(row, COL_SCHEDULE,
makeItem(entry.GetRepeatDescription()));
_table->setItem(row, COL_NEXT_TRIGGER,
makeItem(entry.GetNextTriggerString()));
QString status;
if (!entry.enabled) {
status = obs_module_text(
"AdvSceneSwitcher.macroScheduleTab.status.disabled");
} else if (entry.IsExpired()) {
status = obs_module_text(
"AdvSceneSwitcher.macroScheduleTab.status.expired");
} else {
status = obs_module_text(
"AdvSceneSwitcher.macroScheduleTab.status.active");
}
_table->setItem(row, COL_STATUS, makeItem(status));
_table->item(row, COL_NAME)
->setData(Qt::UserRole,
QString::fromStdString(entry.id));
}
SetHelpVisible(_table->rowCount() == 0);
// Restore selection
if (!prevId.isEmpty()) {
SelectTableRowById(prevId);
}
}
void MacroScheduleTab::SetHelpVisible(bool visible)
{
_helpLabel->setVisible(visible);
_table->setVisible(!visible);
}
// ---------------------------------------------------------------------------
// Selection helpers
// ---------------------------------------------------------------------------
int MacroScheduleTab::SelectedEntryIndex() const
{
const auto selected = _table->selectionModel()->selectedRows();
if (selected.isEmpty()) {
return -1;
}
const QString id = _table->item(selected.first().row(), COL_NAME)
->data(Qt::UserRole)
.toString();
const auto &entries = GetScheduleEntries();
for (int i = 0; i < (int)entries.size(); ++i) {
if (QString::fromStdString(entries[i].id) == id) {
return i;
}
}
return -1;
}
void MacroScheduleTab::SelectTableRowById(const QString &entryId)
{
for (int row = 0; row < _table->rowCount(); ++row) {
auto item = _table->item(row, COL_NAME);
if (item && item->data(Qt::UserRole).toString() == entryId) {
_table->selectRow(row);
_table->scrollToItem(item);
return;
}
}
}
QStringList MacroScheduleTab::SelectedEntryIds() const
{
QStringList ids;
for (const auto &index : _table->selectionModel()->selectedRows()) {
auto item = _table->item(index.row(), COL_NAME);
if (item) {
ids.append(item->data(Qt::UserRole).toString());
}
}
return ids;
}
bool MacroScheduleTab::eventFilter(QObject *obj, QEvent *event)
{
if (obj == _table && event->type() == QEvent::KeyPress) {
const auto keyEvent = static_cast<QKeyEvent *>(event);
if (keyEvent->key() == Qt::Key_Delete) {
Remove();
return true;
}
}
return QWidget::eventFilter(obj, event);
}
void MacroScheduleTab::UpdateButtonStates()
{
const QStringList ids = SelectedEntryIds();
const bool hasSelection = !ids.isEmpty();
const bool singleSelection = ids.size() == 1;
_editBtn->setEnabled(singleSelection);
_removeBtn->setEnabled(hasSelection);
_toggleBtn->setEnabled(hasSelection);
if (hasSelection) {
// Label reflects whether the action will enable or disable:
// disable if all selected are enabled, otherwise enable.
bool allEnabled = true;
{
auto lock = LockContext();
for (const auto &entry : GetScheduleEntries()) {
if (ids.contains(
QString::fromStdString(entry.id)) &&
!entry.enabled) {
allEnabled = false;
break;
}
}
}
_toggleBtn->setText(obs_module_text(
allEnabled
? "AdvSceneSwitcher.macroScheduleTab.disable"
: "AdvSceneSwitcher.macroScheduleTab.enable"));
}
}
void MacroScheduleTab::OnSelectionChanged()
{
UpdateButtonStates();
// Highlight the corresponding calendar event
const int idx = SelectedEntryIndex();
if (idx >= 0) {
QDate triggerDate;
{
auto lock = LockContext();
const auto &entries = GetScheduleEntries();
if (idx < (int)entries.size()) {
const QDateTime next =
entries[idx].NextTriggerTime();
if (next.isValid()) {
triggerDate = next.date();
}
}
}
if (triggerDate.isValid()) {
_calendar->GoToDate(triggerDate);
}
}
}
// ---------------------------------------------------------------------------
// CRUD actions
// ---------------------------------------------------------------------------
void MacroScheduleTab::Add()
{
AddAtTime(QDateTime::currentDateTime());
}
void MacroScheduleTab::AddAtTime(const QDateTime &startTime)
{
MacroScheduleEntry entry;
entry.startDateTime = startTime;
if (!MacroScheduleEntryDialog::AskForSettings(this, entry, true)) {
return;
}
const QString newId = QString::fromStdString(entry.id);
{
auto lock = LockContext();
GetScheduleEntries().emplace_back(std::move(entry));
}
Refresh();
SelectTableRowById(newId);
if (_addButtonHighlight) {
_addButtonHighlight->deleteLater();
_addButtonHighlight = nullptr;
}
}
void MacroScheduleTab::Edit()
{
const int idx = SelectedEntryIndex();
if (idx < 0) {
return;
}
MacroScheduleEntry copy;
{
auto lock = LockContext();
copy = GetScheduleEntries()[idx];
}
const QString id = QString::fromStdString(copy.id);
if (!MacroScheduleEntryDialog::AskForSettings(this, copy, false)) {
return;
}
{
auto lock = LockContext();
GetScheduleEntries()[idx] = std::move(copy);
}
Refresh();
SelectTableRowById(id);
}
void MacroScheduleTab::EditById(const QString &entryId)
{
SelectTableRowById(entryId);
Edit();
}
void MacroScheduleTab::Remove()
{
const QStringList ids = SelectedEntryIds();
if (ids.isEmpty()) {
return;
}
// Build a confirmation message
QString msg;
if (ids.size() == 1) {
QString name;
{
auto lock = LockContext();
const int idx = SelectedEntryIndex();
if (idx >= 0) {
name = GetScheduleEntries()[idx].GetSummary();
}
}
msg = QString(obs_module_text(
"AdvSceneSwitcher.macroScheduleTab.remove.confirm"))
.arg(name);
} else {
msg = QString(obs_module_text(
"AdvSceneSwitcher.macroScheduleTab.remove.confirmMultiple"))
.arg(ids.size());
}
if (!DisplayMessage(msg, true)) {
return;
}
{
auto lock = LockContext();
auto &entries = GetScheduleEntries();
entries.erase(
std::remove_if(entries.begin(), entries.end(),
[&ids](const MacroScheduleEntry &e) {
return ids.contains(
QString::fromStdString(
e.id));
}),
entries.end());
}
Refresh();
}
void MacroScheduleTab::ToggleEnabled()
{
const QStringList ids = SelectedEntryIds();
if (ids.isEmpty()) {
return;
}
// If all selected entries are enabled, disable them; otherwise enable all.
{
auto lock = LockContext();
bool allEnabled = true;
for (const auto &entry : GetScheduleEntries()) {
if (ids.contains(QString::fromStdString(entry.id)) &&
!entry.enabled) {
allEnabled = false;
break;
}
}
const bool newState = !allEnabled;
for (auto &entry : GetScheduleEntries()) {
if (ids.contains(QString::fromStdString(entry.id))) {
entry.enabled = newState;
}
}
}
Refresh();
}
// ---------------------------------------------------------------------------
// Context menu
// ---------------------------------------------------------------------------
void MacroScheduleTab::ShowContextMenu(const QPoint &pos)
{
const QStringList ids = SelectedEntryIds();
const bool hasSelection = !ids.isEmpty();
const bool singleSelection = ids.size() == 1;
QMenu menu(this);
auto addAction = menu.addAction(
obs_module_text("AdvSceneSwitcher.macroScheduleTab.add"), this,
&MacroScheduleTab::Add);
(void)addAction;
menu.addSeparator();
auto editAction = menu.addAction(
obs_module_text("AdvSceneSwitcher.macroScheduleTab.edit"), this,
&MacroScheduleTab::Edit);
editAction->setEnabled(singleSelection);
auto removeAction = menu.addAction(
obs_module_text("AdvSceneSwitcher.macroScheduleTab.remove"),
this, &MacroScheduleTab::Remove);
removeAction->setEnabled(hasSelection);
bool allEnabled = true;
if (hasSelection) {
auto lock = LockContext();
for (const auto &entry : GetScheduleEntries()) {
if (ids.contains(QString::fromStdString(entry.id)) &&
!entry.enabled) {
allEnabled = false;
break;
}
}
}
auto toggleAction = menu.addAction(
obs_module_text(
allEnabled
? "AdvSceneSwitcher.macroScheduleTab.disable"
: "AdvSceneSwitcher.macroScheduleTab.enable"),
this, &MacroScheduleTab::ToggleEnabled);
toggleAction->setEnabled(hasSelection);
menu.exec(_table->viewport()->mapToGlobal(pos));
}
} // namespace advss

View File

@@ -0,0 +1,70 @@
#pragma once
#include "calendar/calendar-widget.hpp"
#include "macro-schedule.hpp"
#include <QEvent>
#include <QLabel>
#include <QPushButton>
#include <QSplitter>
#include <QTableWidget>
#include <QTimer>
#include <QToolButton>
#include <QWidget>
class QTabWidget;
namespace advss {
// The main tab widget registered with the settings window.
class MacroScheduleTab : public QWidget {
Q_OBJECT
public:
static MacroScheduleTab *Create();
// Reload all data into the table and calendar
void Refresh();
private slots:
void Add();
void AddAtTime(const QDateTime &startTime);
void Edit();
void EditById(const QString &entryId);
void Remove();
void ToggleEnabled();
void ShowContextMenu(const QPoint &pos);
void OnSelectionChanged();
private:
explicit MacroScheduleTab(QWidget *parent = nullptr);
bool eventFilter(QObject *obj, QEvent *event) override;
void PopulateTable();
void RefreshCalendar(const std::deque<MacroScheduleEntry> &entries);
void SelectTableRowById(const QString &entryId);
void UpdateButtonStates();
void SetHelpVisible(bool visible);
// Returns the index in GetScheduleEntries() for the first selected row,
// or -1 if nothing is selected.
int SelectedEntryIndex() const;
// Returns the IDs of all selected rows.
QStringList SelectedEntryIds() const;
CalendarWidget *_calendar;
QTableWidget *_table;
QLabel *_helpLabel;
QToolButton *_addBtn;
QPushButton *_editBtn;
QToolButton *_removeBtn;
QPushButton *_toggleBtn;
// Periodic refresh to update "Next Trigger" column
QTimer *_refreshTimer;
QObject *_addButtonHighlight = nullptr;
};
} // namespace advss

View File

@@ -0,0 +1,489 @@
#include "macro-schedule.hpp"
#include "log-helper.hpp"
#include "macro-export-extensions.hpp"
#include "macro-helpers.hpp"
#include "obs-module-helper.hpp"
#include "plugin-state-helpers.hpp"
#include "sync-helpers.hpp"
#include <obs-data.h>
#include <QUuid>
#include <atomic>
#include <condition_variable>
#include <mutex>
#include <thread>
namespace advss {
static std::deque<MacroScheduleEntry> scheduleEntries;
static std::thread schedulerThread;
static std::atomic<bool> schedulerRunning{false};
static std::mutex schedulerWaitMutex;
static std::condition_variable schedulerWaitCV;
static void initScheduler();
static void cleanupScheduler();
static void saveEntries(obs_data_t *obj);
static void loadEntries(obs_data_t *obj);
static bool setup();
static bool setupDone = setup();
static bool setup()
{
AddSaveStep(saveEntries);
AddLoadStep(loadEntries);
AddStartStep(initScheduler);
AddStopStep(cleanupScheduler);
AddPluginCleanupStep([]() {
cleanupScheduler();
scheduleEntries.clear();
});
AddMacroExportExtension(
{"AdvSceneSwitcher.macroTab.export.macroScheduleEntries",
"macroScheduleEntries",
[](obs_data_t *data, const QStringList &selectedIds) {
OBSDataArrayAutoRelease array =
obs_data_array_create();
auto lock = LockContext();
for (const auto &entry : scheduleEntries) {
if (!selectedIds.isEmpty() &&
!selectedIds.contains(
QString::fromStdString(entry.id)))
continue;
OBSDataAutoRelease item = obs_data_create();
entry.Save(item);
obs_data_array_push_back(array, item);
}
obs_data_set_array(data, "macroScheduleEntries",
array);
},
[](obs_data_t *data, const QStringList &) {
OBSDataArrayAutoRelease array = obs_data_get_array(
data, "macroScheduleEntries");
if (!array) {
return;
}
const size_t count = obs_data_array_count(array);
auto lock = LockContext();
for (size_t i = 0; i < count; ++i) {
OBSDataAutoRelease item =
obs_data_array_item(array, i);
MacroScheduleEntry entry;
entry.Load(item);
// Skip if an entry with this id already exists.
const auto &id = entry.id;
const bool exists = std::any_of(
scheduleEntries.begin(),
scheduleEntries.end(),
[&id](const MacroScheduleEntry &e) {
return e.id == id;
});
if (exists) {
continue;
}
// Reset runtime state so the entry fires fresh.
entry.timesTriggered = 0;
entry.lastTriggered = QDateTime();
entry.endDateActionApplied = false;
scheduleEntries.emplace_back(std::move(entry));
}
},
[]() -> QList<QPair<QString, QString>> {
QList<QPair<QString, QString>> items;
auto lock = LockContext();
for (const auto &entry : scheduleEntries) {
items.append({QString::fromStdString(entry.id),
entry.GetSummary()});
}
return items;
}});
return true;
}
// ---------------------------------------------------------------------------
// Data model
// ---------------------------------------------------------------------------
MacroScheduleEntry::MacroScheduleEntry()
: id(QUuid::createUuid().toString(QUuid::WithoutBraces).toStdString()),
startDateTime(QDateTime::currentDateTime())
{
}
static void saveDateTime(obs_data_t *obj, const char *key, const QDateTime &dt)
{
if (dt.isValid()) {
obs_data_set_string(
obj, key,
dt.toString(Qt::ISODate).toUtf8().constData());
}
}
static QDateTime loadDateTime(obs_data_t *obj, const char *key)
{
const char *str = obs_data_get_string(obj, key);
if (!str || !*str) {
return QDateTime();
}
return QDateTime::fromString(QString::fromUtf8(str), Qt::ISODate);
}
void MacroScheduleEntry::Save(obs_data_t *obj) const
{
obs_data_set_string(obj, "id", id.c_str());
obs_data_set_string(obj, "name", name.c_str());
macro.Save(obj);
obs_data_set_string(obj, "color", color.name().toUtf8().constData());
obs_data_set_bool(obj, "checkConditions", checkConditions);
obs_data_set_bool(obj, "runElseActionsOnConditionFailure",
runElseActionsOnConditionFailure);
obs_data_set_bool(obj, "enabled", enabled);
saveDateTime(obj, "startDateTime", startDateTime);
obs_data_set_bool(obj, "hasEndDate", hasEndDate);
if (hasEndDate) {
saveDateTime(obj, "endDate", endDate);
obs_data_set_int(obj, "endDateAction",
static_cast<int>(endDateAction));
}
obs_data_set_bool(obj, "doesRepeat", doesRepeat);
repeatInterval.Save(obj, "repeatInterval");
obs_data_set_int(obj, "repeatEndType", static_cast<int>(repeatEndType));
obs_data_set_int(obj, "repeatMaxCount", repeatMaxCount);
if (repeatEndType == RepeatEndType::UNTIL_DATE &&
repeatUntilDate.isValid()) {
saveDateTime(obj, "repeatUntilDate", repeatUntilDate);
}
obs_data_set_int(obj, "timesTriggered", timesTriggered);
saveDateTime(obj, "lastTriggered", lastTriggered);
obs_data_set_bool(obj, "endDateActionApplied", endDateActionApplied);
}
void MacroScheduleEntry::Load(obs_data_t *obj)
{
const char *idStr = obs_data_get_string(obj, "id");
if (idStr && *idStr) {
id = idStr;
}
const char *nameStr = obs_data_get_string(obj, "name");
if (nameStr) {
name = nameStr;
}
macro.Load(obj);
const char *colorStr = obs_data_get_string(obj, "color");
if (colorStr && *colorStr) {
const QColor loaded(QString::fromUtf8(colorStr));
if (loaded.isValid()) {
color = loaded;
}
}
checkConditions = obs_data_get_bool(obj, "checkConditions");
runElseActionsOnConditionFailure =
obs_data_get_bool(obj, "runElseActionsOnConditionFailure");
obs_data_set_default_bool(obj, "enabled", true);
enabled = obs_data_get_bool(obj, "enabled");
startDateTime = loadDateTime(obj, "startDateTime");
hasEndDate = obs_data_get_bool(obj, "hasEndDate");
if (hasEndDate) {
endDate = loadDateTime(obj, "endDate");
endDateAction = static_cast<EndDateAction>(
obs_data_get_int(obj, "endDateAction"));
}
doesRepeat = obs_data_get_bool(obj, "doesRepeat");
repeatInterval.Load(obj, "repeatInterval");
repeatEndType = static_cast<RepeatEndType>(
obs_data_get_int(obj, "repeatEndType"));
obs_data_set_default_int(obj, "repeatMaxCount", 1);
repeatMaxCount = (int)obs_data_get_int(obj, "repeatMaxCount");
repeatUntilDate = loadDateTime(obj, "repeatUntilDate");
timesTriggered = (int)obs_data_get_int(obj, "timesTriggered");
lastTriggered = loadDateTime(obj, "lastTriggered");
endDateActionApplied = obs_data_get_bool(obj, "endDateActionApplied");
}
QDateTime MacroScheduleEntry::advanceFrom(const QDateTime &base) const
{
const qint64 ms = qRound64(repeatInterval.Seconds() * 1000.0);
if (ms <= 0) {
return QDateTime();
}
return base.addMSecs(ms);
}
QDateTime MacroScheduleEntry::NextTriggerTime() const
{
if (!lastTriggered.isValid()) {
return startDateTime;
}
if (!doesRepeat) {
// If the start was moved beyond the last trigger, treat as not yet fired.
if (startDateTime > lastTriggered) {
return startDateTime;
}
return QDateTime(); // one-shot already triggered
}
QDateTime next = startDateTime;
while (next <= lastTriggered) {
QDateTime candidate = advanceFrom(next);
if (!candidate.isValid()) {
return QDateTime();
}
next = candidate;
}
return next;
}
bool MacroScheduleEntry::IsExpired() const
{
if (!doesRepeat && lastTriggered.isValid()) {
return lastTriggered >= startDateTime;
}
switch (repeatEndType) {
case RepeatEndType::NEVER:
return false;
case RepeatEndType::AFTER_N_TIMES:
return timesTriggered >= repeatMaxCount;
case RepeatEndType::UNTIL_DATE:
return repeatUntilDate.isValid() &&
QDateTime::currentDateTime() > repeatUntilDate;
}
return false;
}
bool MacroScheduleEntry::ShouldTrigger(const QDateTime &now) const
{
if (!enabled) {
return false;
}
if (IsExpired()) {
return false;
}
if (!startDateTime.isValid()) {
return false;
}
const QDateTime next = NextTriggerTime();
if (!next.isValid()) {
return false;
}
return next <= now;
}
void MacroScheduleEntry::MarkTriggered(const QDateTime &now)
{
++timesTriggered;
lastTriggered = now;
}
void MacroScheduleEntry::FastForwardTo(const QDateTime &now)
{
timesTriggered = 0;
lastTriggered = QDateTime();
if (!startDateTime.isValid()) {
return;
}
if (!doesRepeat) {
// One-shot: count it as triggered if the start lies in the past.
if (startDateTime <= now) {
timesTriggered = 1;
lastTriggered = startDateTime;
}
return;
}
// Repeating: walk intervals from startDateTime and count each one
// that falls at or before 'now'.
QDateTime t = startDateTime;
while (t <= now) {
++timesTriggered;
lastTriggered = t;
const QDateTime next = advanceFrom(t);
if (!next.isValid()) {
break;
}
t = next;
}
}
QString MacroScheduleEntry::GetRepeatDescription() const
{
if (!doesRepeat) {
return obs_module_text(
"AdvSceneSwitcher.macroScheduleEntry.repeat.once");
}
return QString(obs_module_text(
"AdvSceneSwitcher.macroScheduleEntry.repeat.every"))
.arg(QString::fromStdString(repeatInterval.ToString()));
}
QString MacroScheduleEntry::GetSummary() const
{
if (!name.empty()) {
return QString::fromStdString(name);
}
const std::string macroName = macro.Name();
return macroName.empty()
? QString(obs_module_text(
"AdvSceneSwitcher.macroScheduleEntry.noMacro"))
: QString::fromStdString(macroName);
}
QString MacroScheduleEntry::GetNextTriggerString() const
{
if (!enabled) {
return obs_module_text(
"AdvSceneSwitcher.macroScheduleTab.status.disabled");
}
if (IsExpired()) {
return obs_module_text(
"AdvSceneSwitcher.macroScheduleTab.status.expired");
}
const QDateTime next = NextTriggerTime();
if (!next.isValid()) {
return QString("-");
}
return next.toString("yyyy-MM-dd HH:mm");
}
std::deque<MacroScheduleEntry> &GetScheduleEntries()
{
return scheduleEntries;
}
// ---------------------------------------------------------------------------
// Persistence
// ---------------------------------------------------------------------------
static void saveEntries(obs_data_t *obj)
{
OBSDataArrayAutoRelease array = obs_data_array_create();
for (const auto &entry : scheduleEntries) {
OBSDataAutoRelease item = obs_data_create();
entry.Save(item);
obs_data_array_push_back(array, item);
}
obs_data_set_array(obj, "macroScheduleEntries", array);
}
static void loadEntries(obs_data_t *obj)
{
scheduleEntries.clear();
OBSDataArrayAutoRelease array =
obs_data_get_array(obj, "macroScheduleEntries");
const size_t count = obs_data_array_count(array);
for (size_t i = 0; i < count; ++i) {
OBSDataAutoRelease item = obs_data_array_item(array, i);
scheduleEntries.emplace_back();
scheduleEntries.back().Load(item);
}
}
// ---------------------------------------------------------------------------
// Scheduler thread
// ---------------------------------------------------------------------------
static void applyEndDateAction(MacroScheduleEntry &entry)
{
if (!entry.hasEndDate || !entry.endDate.isValid()) {
return;
}
if (entry.endDateAction == MacroScheduleEntry::EndDateAction::NONE) {
return;
}
switch (entry.endDateAction) {
case MacroScheduleEntry::EndDateAction::DISABLE_ENTRY:
entry.enabled = false;
break;
case MacroScheduleEntry::EndDateAction::PAUSE_MACRO:
StopMacro(entry.macro.GetMacro().get());
SetMacroPaused(entry.macro.GetMacro().get(), true);
break;
case MacroScheduleEntry::EndDateAction::STOP_MACRO:
StopMacro(entry.macro.GetMacro().get());
break;
default:
break;
}
}
static void checkAndFireEntries()
{
const QDateTime now = QDateTime::currentDateTime();
auto lock = LockContext();
for (auto &entry : scheduleEntries) {
// Apply end-date action once when the entry transitions to expired.
// We detect the transition by checking whether the end date has
// just passed while the entry is still nominally enabled.
if (entry.hasEndDate && entry.endDate.isValid() &&
entry.enabled && now >= entry.endDate &&
entry.endDateAction !=
MacroScheduleEntry::EndDateAction::NONE &&
!entry.endDateActionApplied) {
applyEndDateAction(entry);
entry.endDateActionApplied = true;
}
if (!entry.ShouldTrigger(now)) {
continue;
}
auto macro = entry.macro.GetMacro();
if (!macro) {
// Advance state so we don't spam-check a missing macro
entry.MarkTriggered(now);
blog(LOG_WARNING,
"[macro-schedule] Scheduled macro '%s' not found, skipping.",
entry.macro.Name().c_str());
continue;
}
if (entry.checkConditions) {
if (CheckMacroConditions(macro.get(), true)) {
RunMacroActions(macro.get(), true, true);
} else if (entry.runElseActionsOnConditionFailure) {
RunMacroElseActions(macro.get(), true, true);
}
} else {
RunMacroActions(macro.get(), true, true);
}
entry.MarkTriggered(now);
}
}
static void initScheduler()
{
if (schedulerRunning.exchange(true)) {
return; // already running
}
schedulerThread = std::thread([]() {
while (schedulerRunning) {
checkAndFireEntries();
std::unique_lock<std::mutex> lock(schedulerWaitMutex);
schedulerWaitCV.wait_for(
lock, std::chrono::seconds(1),
[]() { return !schedulerRunning.load(); });
}
});
}
static void cleanupScheduler()
{
schedulerRunning = false;
schedulerWaitCV.notify_all();
if (schedulerThread.joinable()) {
schedulerThread.join();
}
}
} // namespace advss

Some files were not shown because too many files have changed in this diff Show More