Compare commits
149 commits
phase-c-co
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ab47cccd42 | |||
| 99d6d80754 | |||
| dfdfa9e0e1 | |||
| 80d9baf2d0 | |||
| d880941ad5 | |||
| 1cdd4ebd04 | |||
| ea940ffac4 | |||
| aaa2a76814 | |||
| c30a6163c8 | |||
| 54ee578fe9 | |||
| 2552d46210 | |||
| 0e73746b58 | |||
| 191b2c5f52 | |||
| e01fa364e8 | |||
| 09e5b59dfd | |||
| f47edfb2f6 | |||
| 47914fcd77 | |||
| dba7dcc8a8 | |||
| 6c9bee7391 | |||
| 84861dafa5 | |||
| 6505a3cab0 | |||
| d91f95379b | |||
| fbcc56289e | |||
| e96a30b76f | |||
| 1f07992100 | |||
| 2640739bfc | |||
| e67c02c2ff | |||
| d02a2c059b | |||
| 33fca8e955 | |||
| 37390026b3 | |||
| 5a43c9cb6a | |||
| 647deec304 | |||
| 4944de5feb | |||
| 209b643cd5 | |||
| d282e1b0f8 | |||
| c27130302f | |||
| 1d1ce6a2a0 | |||
| 426cf33dec | |||
| 9ae14c8ee9 | |||
| f7249c31c2 | |||
| 7c269f2c40 | |||
| 7ac56c2661 | |||
| 538dd98f54 | |||
| 83c954d80d | |||
| 4ec28adbd9 | |||
| a05c0a75d2 | |||
| 27f47401d9 | |||
| 639a7ea9f9 | |||
| eee307d711 | |||
| a33f80d345 | |||
| 07f4a1b716 | |||
| 166e7d6e6a | |||
| 1687e0c1f5 | |||
| 19072b4add | |||
| 6b45c398e0 | |||
| 46b1ca5874 | |||
| 2f9f7092ed | |||
| 2909d8b1d7 | |||
| c150bce28e | |||
| 8e29c1dc1e | |||
| 48ca16bc5e | |||
| 2e6d2a1e5e | |||
| db341f9446 | |||
| 9e176d8f10 | |||
| cb1402ec8d | |||
| 94b0a71edc | |||
| f12cbe7517 | |||
| 70137147d6 | |||
| b0029a51bf | |||
| 9db0875f9e | |||
| 10a0826fb3 | |||
| d6793d8d9c | |||
| 5c491c9d83 | |||
| 61dce2eecd | |||
| 33f145624e | |||
| cc29c503a9 | |||
| aa07ad9f08 | |||
| 1d5d055b68 | |||
| acc569dd24 | |||
| 3f71a4f29a | |||
| 7ef6b8055e | |||
| b9147183ce | |||
| a9a10e01a4 | |||
| 8e08d7dc6a | |||
| d8186c5eb8 | |||
| 598938ede5 | |||
| e020d1c2ac | |||
| 65d3b78e63 | |||
| 8e66491e09 | |||
| 2c607a70ff | |||
| dc25fe1eef | |||
| d8adb44a8f | |||
| 1b759486c0 | |||
| 2ae0dc2d62 | |||
| b56e2e12e1 | |||
| 0e2927e42c | |||
| 2de65f6d10 | |||
| c53c7a7768 | |||
| 554ab9e570 | |||
| d9eb02a9af | |||
| 6d9407a61f | |||
| 63bd93d0c2 | |||
| dd7827de82 | |||
| 5c0491e46c | |||
| 46fa0d66a1 | |||
| fdd1d1bbfc | |||
| 832aad6a14 | |||
| 7c7520e2be | |||
| 5958b66bfd | |||
| b49e1abf17 | |||
| 6882e654d5 | |||
| 670813f18e | |||
| 179a44adf5 | |||
| e06120044b | |||
| f73552a6b9 | |||
| b8fe344c58 | |||
| e93b8caae0 | |||
| 83224dbd9b | |||
| b5fcc98d40 | |||
| 34a2f1483c | |||
| 4be5b39022 | |||
| 57c2922d1c | |||
| 9cb1cc7b3d | |||
| b2666236ec | |||
| 778e5163e9 | |||
| ab072979d8 | |||
| 0c82ac71f0 | |||
| ff7e949466 | |||
| e8f52a3153 | |||
| 01ef4250d7 | |||
| c08b90b0b2 | |||
| 16e0a483e2 | |||
| bab29b02ab | |||
| 9c231118de | |||
| f07aad1c6a | |||
| 1d85396a90 | |||
| 9891f2444d | |||
| dae8f35db9 | |||
| d2c0c2159f | |||
| 0b24fbb529 | |||
| 6cac486fbe | |||
| 53c06a9af9 | |||
| b542d01835 | |||
| 909237f454 | |||
| fa8d2a8fad | |||
| ca124540a7 | |||
| d90ebb826f | |||
| d14a33a0a3 | |||
| 0f03c272ad |
162 changed files with 20564 additions and 3679 deletions
|
|
@ -59,14 +59,14 @@ jobs:
|
||||||
|
|
||||||
- name: Upload test results
|
- name: Upload test results
|
||||||
if: always()
|
if: always()
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: test-results
|
name: test-results
|
||||||
path: '**/test-results.trx'
|
path: '**/test-results.trx'
|
||||||
|
|
||||||
- name: Upload coverage report
|
- name: Upload coverage report
|
||||||
if: always()
|
if: always()
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: coverage-report
|
name: coverage-report
|
||||||
path: coverage-report/
|
path: coverage-report/
|
||||||
|
|
|
||||||
215
.forgejo/workflows/release.yml
Normal file
215
.forgejo/workflows/release.yml
Normal file
|
|
@ -0,0 +1,215 @@
|
||||||
|
name: Release
|
||||||
|
|
||||||
|
# Triggered by pushing an annotated tag of the form v1.2.3 (or any v-prefixed
|
||||||
|
# semver). The job runs on a Windows runner because building the WiX MSI
|
||||||
|
# requires the WiX SDK which is supported on Windows; the engine + console
|
||||||
|
# can in principle be built on Linux, but for simplicity we do everything in
|
||||||
|
# one job here so the publish output paths line up for the installer.
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*.*.*'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-msi:
|
||||||
|
runs-on: windows-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0 # tags + full history needed to derive the version
|
||||||
|
|
||||||
|
- name: Derive version from tag
|
||||||
|
id: ver
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
# GITHUB_REF is refs/tags/vX.Y.Z; strip the prefix.
|
||||||
|
$tag = "${env:GITHUB_REF}".Replace('refs/tags/', '')
|
||||||
|
$version = $tag.TrimStart('v')
|
||||||
|
"tag=$tag" >> $env:GITHUB_OUTPUT
|
||||||
|
"version=$version" >> $env:GITHUB_OUTPUT
|
||||||
|
Write-Host "Building version $version (tag $tag)"
|
||||||
|
|
||||||
|
- name: Setup .NET 8
|
||||||
|
uses: actions/setup-dotnet@v4
|
||||||
|
with:
|
||||||
|
dotnet-version: 8.0.x
|
||||||
|
|
||||||
|
# Probe for the signing cert up front and expose a step output downstream
|
||||||
|
# steps can use in their `if:` guards. We can't reference `secrets.*`
|
||||||
|
# directly from `if:` (Forgejo/GitHub policy), so we set a dummy env
|
||||||
|
# variable from the secret and check whether it's non-empty here.
|
||||||
|
- name: Detect signing configuration
|
||||||
|
id: signcfg
|
||||||
|
shell: pwsh
|
||||||
|
env:
|
||||||
|
PFX_PROBE: ${{ secrets.SIGN_CERT_PFX_BASE64 }}
|
||||||
|
run: |
|
||||||
|
if ($env:PFX_PROBE) {
|
||||||
|
"enabled=true" >> $env:GITHUB_OUTPUT
|
||||||
|
Write-Host "Code-signing: ENABLED (cert secret detected)."
|
||||||
|
} else {
|
||||||
|
"enabled=false" >> $env:GITHUB_OUTPUT
|
||||||
|
Write-Host "Code-signing: DISABLED (SIGN_CERT_PFX_BASE64 not set). Build will produce unsigned binaries."
|
||||||
|
}
|
||||||
|
|
||||||
|
- name: Restore (Windows solution filter)
|
||||||
|
run: dotnet restore TeamsISO.Windows.slnf
|
||||||
|
|
||||||
|
- name: Build (Release, treat warnings as errors)
|
||||||
|
run: dotnet build TeamsISO.Windows.slnf --configuration Release --no-restore /p:Version=${{ steps.ver.outputs.version }}
|
||||||
|
|
||||||
|
- name: Run unit tests (excluding requires=ndi)
|
||||||
|
run: >
|
||||||
|
dotnet test TeamsISO.Windows.slnf
|
||||||
|
--configuration Release
|
||||||
|
--no-build
|
||||||
|
--filter "Category!=ndi&requires!=ndi"
|
||||||
|
|
||||||
|
- name: Publish TeamsISO.App (framework-dependent, win-x64)
|
||||||
|
run: >
|
||||||
|
dotnet publish src/TeamsISO.App/TeamsISO.App.csproj
|
||||||
|
--configuration Release
|
||||||
|
--runtime win-x64
|
||||||
|
--self-contained false
|
||||||
|
--output publish/TeamsISO
|
||||||
|
/p:Version=${{ steps.ver.outputs.version }}
|
||||||
|
|
||||||
|
- name: Publish TeamsISO.Console (framework-dependent, win-x64)
|
||||||
|
run: >
|
||||||
|
dotnet publish src/TeamsISO.Console/TeamsISO.Console.csproj
|
||||||
|
--configuration Release
|
||||||
|
--runtime win-x64
|
||||||
|
--self-contained false
|
||||||
|
--output publish/TeamsISO-Console
|
||||||
|
/p:Version=${{ steps.ver.outputs.version }}
|
||||||
|
|
||||||
|
# Code-sign the WPF .exe BEFORE the MSI is built, so the MSI's embedded
|
||||||
|
# binaries are signed too. Skipped silently when the signing secrets
|
||||||
|
# aren't configured — that's the default state and keeps unsigned builds
|
||||||
|
# working unchanged.
|
||||||
|
#
|
||||||
|
# To enable signing, set both Forgejo Actions secrets:
|
||||||
|
# SIGN_CERT_PFX_BASE64 — base64 of your code-signing PFX file
|
||||||
|
# ( certutil -encode in.pfx out.b64; strip BEGIN/END lines )
|
||||||
|
# SIGN_CERT_PASSWORD — the PFX password
|
||||||
|
# Optionally:
|
||||||
|
# SIGN_TIMESTAMP_URL — RFC 3161 timestamp server (default: digicert)
|
||||||
|
- name: Sign TeamsISO.exe (optional, skipped if no cert)
|
||||||
|
if: ${{ steps.signcfg.outputs.enabled == 'true' }}
|
||||||
|
env:
|
||||||
|
SIGN_CERT_PFX_BASE64: ${{ secrets.SIGN_CERT_PFX_BASE64 }}
|
||||||
|
SIGN_CERT_PASSWORD: ${{ secrets.SIGN_CERT_PASSWORD }}
|
||||||
|
SIGN_TIMESTAMP_URL: ${{ secrets.SIGN_TIMESTAMP_URL }}
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
$tsUrl = if ($env:SIGN_TIMESTAMP_URL) { $env:SIGN_TIMESTAMP_URL } else { 'http://timestamp.digicert.com' }
|
||||||
|
$pfxPath = Join-Path $env:RUNNER_TEMP 'codesign.pfx'
|
||||||
|
[IO.File]::WriteAllBytes($pfxPath, [Convert]::FromBase64String($env:SIGN_CERT_PFX_BASE64))
|
||||||
|
$signtool = Get-ChildItem 'C:\Program Files (x86)\Windows Kits\10\bin' -Recurse -Filter signtool.exe `
|
||||||
|
| Where-Object { $_.FullName -match '\\x64\\' } `
|
||||||
|
| Select-Object -First 1
|
||||||
|
if (-not $signtool) { throw 'signtool.exe not found on runner' }
|
||||||
|
& $signtool.FullName sign `
|
||||||
|
/f $pfxPath `
|
||||||
|
/p $env:SIGN_CERT_PASSWORD `
|
||||||
|
/fd SHA256 `
|
||||||
|
/td SHA256 `
|
||||||
|
/tr $tsUrl `
|
||||||
|
'publish/TeamsISO/TeamsISO.exe'
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "signtool failed on TeamsISO.exe (exit $LASTEXITCODE)" }
|
||||||
|
Remove-Item $pfxPath -Force
|
||||||
|
|
||||||
|
- name: Build MSI installer
|
||||||
|
run: >
|
||||||
|
dotnet build installer/TeamsISO.Installer.wixproj
|
||||||
|
--configuration Release
|
||||||
|
/p:Version=${{ steps.ver.outputs.version }}
|
||||||
|
|
||||||
|
- name: Locate MSI
|
||||||
|
id: msi
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
$msi = Get-ChildItem -Path installer/bin -Recurse -Filter '*.msi' | Select-Object -First 1
|
||||||
|
if (-not $msi) { throw "No MSI produced under installer/bin." }
|
||||||
|
"path=$($msi.FullName)" >> $env:GITHUB_OUTPUT
|
||||||
|
"name=$($msi.Name)" >> $env:GITHUB_OUTPUT
|
||||||
|
Write-Host "MSI: $($msi.FullName) ($($msi.Length) bytes)"
|
||||||
|
|
||||||
|
# Sign the produced MSI itself. Same gate as exe signing — runs only if
|
||||||
|
# the cert secret is set. Splitting the two stages means the inner exe
|
||||||
|
# is signed before being embedded, AND the wrapping MSI carries its own
|
||||||
|
# signature for SmartScreen.
|
||||||
|
- name: Sign MSI (optional, skipped if no cert)
|
||||||
|
if: ${{ steps.signcfg.outputs.enabled == 'true' }}
|
||||||
|
env:
|
||||||
|
SIGN_CERT_PFX_BASE64: ${{ secrets.SIGN_CERT_PFX_BASE64 }}
|
||||||
|
SIGN_CERT_PASSWORD: ${{ secrets.SIGN_CERT_PASSWORD }}
|
||||||
|
SIGN_TIMESTAMP_URL: ${{ secrets.SIGN_TIMESTAMP_URL }}
|
||||||
|
MSI_PATH: ${{ steps.msi.outputs.path }}
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
$tsUrl = if ($env:SIGN_TIMESTAMP_URL) { $env:SIGN_TIMESTAMP_URL } else { 'http://timestamp.digicert.com' }
|
||||||
|
$pfxPath = Join-Path $env:RUNNER_TEMP 'codesign.pfx'
|
||||||
|
[IO.File]::WriteAllBytes($pfxPath, [Convert]::FromBase64String($env:SIGN_CERT_PFX_BASE64))
|
||||||
|
$signtool = Get-ChildItem 'C:\Program Files (x86)\Windows Kits\10\bin' -Recurse -Filter signtool.exe `
|
||||||
|
| Where-Object { $_.FullName -match '\\x64\\' } `
|
||||||
|
| Select-Object -First 1
|
||||||
|
& $signtool.FullName sign `
|
||||||
|
/f $pfxPath `
|
||||||
|
/p $env:SIGN_CERT_PASSWORD `
|
||||||
|
/fd SHA256 `
|
||||||
|
/td SHA256 `
|
||||||
|
/tr $tsUrl `
|
||||||
|
$env:MSI_PATH
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "signtool failed on MSI (exit $LASTEXITCODE)" }
|
||||||
|
Remove-Item $pfxPath -Force
|
||||||
|
|
||||||
|
- name: Upload MSI as workflow artifact
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: ${{ steps.msi.outputs.name }}
|
||||||
|
path: ${{ steps.msi.outputs.path }}
|
||||||
|
|
||||||
|
# Forgejo doesn't ship a stable upload-release-asset action, so we use
|
||||||
|
# the REST API directly. This: (1) finds the release that the tag push
|
||||||
|
# auto-created, (2) uploads the MSI as an asset on it. Requires that
|
||||||
|
# the repo's "Create a release on tag push" setting is on, OR that the
|
||||||
|
# release was created beforehand. If no release exists, we create one.
|
||||||
|
- name: Attach MSI to release
|
||||||
|
shell: pwsh
|
||||||
|
env:
|
||||||
|
FORGE_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
FORGE_API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
||||||
|
TAG: ${{ steps.ver.outputs.tag }}
|
||||||
|
MSI_PATH: ${{ steps.msi.outputs.path }}
|
||||||
|
MSI_NAME: ${{ steps.msi.outputs.name }}
|
||||||
|
run: |
|
||||||
|
$headers = @{ Authorization = "token $env:FORGE_TOKEN" }
|
||||||
|
|
||||||
|
# Find the release for this tag.
|
||||||
|
try {
|
||||||
|
$release = Invoke-RestMethod -Method Get `
|
||||||
|
-Uri "$env:FORGE_API/releases/tags/$env:TAG" -Headers $headers
|
||||||
|
} catch {
|
||||||
|
Write-Host "No release found for $env:TAG; creating one."
|
||||||
|
$body = @{
|
||||||
|
tag_name = $env:TAG
|
||||||
|
name = "TeamsISO $env:TAG"
|
||||||
|
body = "Automated build from tag $env:TAG."
|
||||||
|
draft = $false
|
||||||
|
prerelease = $env:TAG -match '-(alpha|beta|rc)'
|
||||||
|
} | ConvertTo-Json
|
||||||
|
$release = Invoke-RestMethod -Method Post `
|
||||||
|
-Uri "$env:FORGE_API/releases" -Headers $headers `
|
||||||
|
-ContentType 'application/json' -Body $body
|
||||||
|
}
|
||||||
|
|
||||||
|
# Upload the MSI as an asset.
|
||||||
|
$uploadUri = "$env:FORGE_API/releases/$($release.id)/assets?name=$env:MSI_NAME"
|
||||||
|
curl.exe -fSL `
|
||||||
|
-H "Authorization: token $env:FORGE_TOKEN" `
|
||||||
|
-H "Content-Type: application/octet-stream" `
|
||||||
|
--upload-file "$env:MSI_PATH" `
|
||||||
|
"$uploadUri"
|
||||||
|
Write-Host "Asset $env:MSI_NAME attached to release $env:TAG."
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -28,3 +28,6 @@ publish/
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
|
# Local Claude session metadata
|
||||||
|
.claude/
|
||||||
|
|
|
||||||
86
CHANGELOG.md
Normal file
86
CHANGELOG.md
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to TeamsISO are documented here. The format follows
|
||||||
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project
|
||||||
|
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [1.0.0] — 2026-05-17
|
||||||
|
|
||||||
|
First general release. Windows-only, .NET 8 WPF, NDI 6.
|
||||||
|
|
||||||
|
### Engine
|
||||||
|
|
||||||
|
- **Participant discovery** over NDI with name cleanup — strips the
|
||||||
|
"MS Teams - " / "(Teams) " prefixes and surfaces the operator-friendly
|
||||||
|
display name.
|
||||||
|
- **Per-participant ISO outputs** with normalized framerate, resolution,
|
||||||
|
aspect mode, and audio routing. Each ISO is an individually-addressable
|
||||||
|
NDI source.
|
||||||
|
- **NDI Groups** support — discovery and sender. One-click "Apply
|
||||||
|
transcoder topology" pins Teams' raw broadcasts to a private
|
||||||
|
`teamsiso-input` group while TeamsISO re-emits on `Public`.
|
||||||
|
- **Self-healing finder** — if the NDI runtime stalls (zero discovered
|
||||||
|
sources past a startup grace period, or sources go from present to
|
||||||
|
empty and stay that way), the engine rebuilds the finder automatically.
|
||||||
|
- **Real-time recording** — per-output raw BGRA stream + `manifest.json`
|
||||||
|
+ an FFmpeg `convert.cmd` script for post-production conversion to
|
||||||
|
H.264 MKV. Recording is opt-in globally and per-participant.
|
||||||
|
|
||||||
|
### UI — "Studio Terminal"
|
||||||
|
|
||||||
|
- **Dark and light themes** with a runtime swap and a system-follow mode.
|
||||||
|
The Wild Dragon mark, the participants-grid watermark, and every accent
|
||||||
|
brush respond to the active theme.
|
||||||
|
- **Header**: brand mark, theme toggle, settings gear.
|
||||||
|
- **Transport strip**: session timer, participant count, live ISO count,
|
||||||
|
control-surface URL — at-a-glance status.
|
||||||
|
- **Participants table**: 24px state LED, 106px live thumbnail preview,
|
||||||
|
name + caption, 5-bar audio meter, **inline-editable output name**,
|
||||||
|
CFG button (per-row override editor), ISO enable pill.
|
||||||
|
- **Settings drawer** — slide-over from the right with OUTPUT / NETWORK /
|
||||||
|
APP tabs.
|
||||||
|
- **Ctrl+K command palette** — fuzzy search across Quick / Teams /
|
||||||
|
Presets / Output / Network / App categories.
|
||||||
|
- **Live preview thumbnails** in the participants table; right-click →
|
||||||
|
Open preview… spawns a non-modal floating window suitable for a
|
||||||
|
secondary monitor.
|
||||||
|
|
||||||
|
### Output name template
|
||||||
|
|
||||||
|
- New default: **the speaker's display name** (`{name}`). Per-participant
|
||||||
|
overrides are inline-editable in the table. Empty-name fallback to
|
||||||
|
`TEAMSISO_{guid}` keeps the NDI sender uniquely identifiable while a
|
||||||
|
participant's display name resolves upstream.
|
||||||
|
- Available tokens: `{name}`, `{guid}`, `{machine}`, `{timestamp}`.
|
||||||
|
|
||||||
|
### Operator presets
|
||||||
|
|
||||||
|
- Save current per-participant ISO assignments + custom output names to
|
||||||
|
`%LOCALAPPDATA%\TeamsISO\presets.json`. Optional auto-apply on next
|
||||||
|
launch.
|
||||||
|
|
||||||
|
### Teams orchestration
|
||||||
|
|
||||||
|
- Launch / stop Teams from the app.
|
||||||
|
- Hide Teams' UI windows during a show.
|
||||||
|
- Drive in-call controls (mute, camera, share, leave, raise hand) via
|
||||||
|
UIAutomation.
|
||||||
|
|
||||||
|
### External control surface
|
||||||
|
|
||||||
|
- REST + WebSocket on `127.0.0.1:9755` for Bitfocus Companion / Stream
|
||||||
|
Deck / custom controllers.
|
||||||
|
- OSC on UDP `127.0.0.1:9000` for TouchOSC.
|
||||||
|
- Self-contained HTML control panel at `/ui` — open from any phone on
|
||||||
|
the LAN.
|
||||||
|
|
||||||
|
### Diagnostics & installer
|
||||||
|
|
||||||
|
- Rolling daily Serilog logs under `%LOCALAPPDATA%\TeamsISO\logs\`.
|
||||||
|
- Diagnostic bundle export — zips logs + config + presets for bug reports.
|
||||||
|
- Forgejo-backed update check (manual or silent-on-launch, throttled to
|
||||||
|
24h).
|
||||||
|
- WiX MSI installer with proper Add/Remove Programs metadata, Start Menu
|
||||||
|
+ Desktop shortcuts, and in-place upgrade.
|
||||||
|
|
||||||
|
[1.0.0]: https://forge.wilddragon.net/zgaetano/teamsiso/releases/tag/v1.0.0
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||||
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
|
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
|
||||||
<AnalysisLevel>latest</AnalysisLevel>
|
<AnalysisLevel>latest</AnalysisLevel>
|
||||||
<Version>1.0.0-alpha.0</Version>
|
<Version>1.0.0</Version>
|
||||||
<Authors>Wild Dragon LLC</Authors>
|
<Authors>Wild Dragon LLC</Authors>
|
||||||
<Company>Wild Dragon LLC</Company>
|
<Company>Wild Dragon LLC</Company>
|
||||||
<Product>TeamsISO</Product>
|
<Product>TeamsISO</Product>
|
||||||
|
|
|
||||||
126
README.md
126
README.md
|
|
@ -1,20 +1,128 @@
|
||||||
# TeamsISO
|
# TeamsISO
|
||||||
|
|
||||||
Per-Participant NDI ISO Controller for Microsoft Teams.
|
**Per-participant NDI ISO controller for Microsoft Teams.**
|
||||||
|
|
||||||
TeamsISO sits between Microsoft Teams' raw NDI broadcast output and a live-production environment. It receives each participant's NDI stream, normalizes framerate and resolution per a configured target, and re-emits clean, individually-addressable NDI sources for ingestion into a switcher (vMix, OBS, Ross, hardware capture).
|
TeamsISO sits between Microsoft Teams' raw NDI broadcast output and a
|
||||||
|
live-production environment. It receives each participant's NDI stream,
|
||||||
|
normalizes framerate / resolution / aspect / audio per a configured target,
|
||||||
|
and re-emits clean, individually-addressable NDI sources for ingestion by a
|
||||||
|
switcher — vMix, OBS, Ross, hardware capture.
|
||||||
|
|
||||||
## Status
|
> **Status:** **v1.0.0** — first general release. Windows only. Requires
|
||||||
|
> Microsoft Teams (with NDI broadcast enabled) and the NDI 6 runtime.
|
||||||
|
|
||||||
Pre-1.0. See `docs/superpowers/specs/` for the active spec and `docs/superpowers/plans/` for in-flight implementation plans.
|
---
|
||||||
|
|
||||||
## Build
|
## What it does
|
||||||
|
|
||||||
Requires .NET 8 SDK.
|
- **Discovers participants** as Teams broadcasts each one over NDI. Cleans
|
||||||
|
the Teams-prefixed source name down to a readable display name.
|
||||||
|
- **Normalizes feeds** to a consistent framerate, resolution, aspect mode,
|
||||||
|
and audio routing — so the downstream switcher gets predictable inputs
|
||||||
|
regardless of what each participant's webcam is doing.
|
||||||
|
- **Routes per-participant** as separate NDI sources with a configurable
|
||||||
|
per-row output name. Default is the speaker's display name; override
|
||||||
|
inline in the participants table.
|
||||||
|
- **Records each ISO to disk** simultaneously — raw BGRA + `manifest.json`
|
||||||
|
+ FFmpeg `convert.cmd` — so post-production gets a clean per-guest archive.
|
||||||
|
- **Embeds Teams orchestration**: launch / stop Teams, hide its UI windows
|
||||||
|
during a show, drive in-call controls (mute, camera, share, leave,
|
||||||
|
raise hand) without leaving the operator console.
|
||||||
|
- **Operator presets** save the current per-participant ISO assignment and
|
||||||
|
custom output names, applicable on next launch automatically.
|
||||||
|
- **Live preview thumbnails** in the participants table, plus pop-out
|
||||||
|
floating preview windows for multi-monitor monitoring.
|
||||||
|
- **External control surface** — REST + WebSocket on `127.0.0.1:9755` and
|
||||||
|
OSC on UDP `127.0.0.1:9000` for Bitfocus Companion / Stream Deck /
|
||||||
|
TouchOSC. Self-contained HTML panel at `/ui` for phone-as-controller.
|
||||||
|
- **Theme-aware** — dark and light palettes, system-following or pinned.
|
||||||
|
The Wild Dragon mark and watermark flip to match.
|
||||||
|
|
||||||
dotnet build
|
## Install
|
||||||
dotnet test
|
|
||||||
|
Grab the latest MSI from the
|
||||||
|
[Releases page](https://forge.wilddragon.net/zgaetano/teamsiso/releases),
|
||||||
|
double-click, and accept the install prompts. Per-machine install under
|
||||||
|
`C:\Program Files\Wild Dragon\TeamsISO`.
|
||||||
|
|
||||||
|
**Prerequisites:**
|
||||||
|
- Windows 10 / 11, 64-bit
|
||||||
|
- [.NET 8 Desktop Runtime](https://dotnet.microsoft.com/download/dotnet/8.0)
|
||||||
|
- [NDI 6 Runtime](https://www.ndi.video/tools/) (the installer warns if
|
||||||
|
missing but does not block — operators can stage the app before NDI is
|
||||||
|
rolled out)
|
||||||
|
- Microsoft Teams (NDI broadcast enabled in admin policy)
|
||||||
|
|
||||||
|
## Configure
|
||||||
|
|
||||||
|
First-run defaults work for most setups. If your downstream switcher needs
|
||||||
|
a particular framerate / resolution / NDI group routing, open the **gear
|
||||||
|
icon** in the header to access the settings drawer:
|
||||||
|
|
||||||
|
- **Output** — framerate, resolution, aspect mode, audio routing
|
||||||
|
- **Network** — NDI discovery and output group names
|
||||||
|
- **App** — recording paths, startup behavior, theme
|
||||||
|
|
||||||
|
Per-participant overrides — click the **CFG** column gear on any row to
|
||||||
|
override framerate / resolution / aspect / audio for just that participant.
|
||||||
|
|
||||||
|
## Keyboard shortcuts
|
||||||
|
|
||||||
|
| Key | Action |
|
||||||
|
| --- | --- |
|
||||||
|
| `F1` | Open help / cheat sheet |
|
||||||
|
| `Ctrl + K` (or `Ctrl + P`) | Open the command palette |
|
||||||
|
| `Ctrl + T` | Toggle theme (dark ↔ light) |
|
||||||
|
| `Ctrl + M` | Drop a timestamped marker into every active recording |
|
||||||
|
| `Ctrl + Shift + S` | Stop every running ISO (emergency) |
|
||||||
|
| `Ctrl + R` | Refresh NDI discovery (rebuild finder) |
|
||||||
|
| `1`–`9` / `NumPad 1`–`9` | Toggle the Nth visible participant's ISO |
|
||||||
|
|
||||||
|
## File locations
|
||||||
|
|
||||||
|
| Path | Contents |
|
||||||
|
| --- | --- |
|
||||||
|
| `%APPDATA%\TeamsISO\config.json` | Engine settings (framerate, NDI groups, etc.) |
|
||||||
|
| `%LOCALAPPDATA%\TeamsISO\presets.json` | Saved operator presets + auto-apply preference |
|
||||||
|
| `%LOCALAPPDATA%\TeamsISO\logs\` | Rolling daily diagnostic logs |
|
||||||
|
| `%LOCALAPPDATA%\TeamsISO\Notes\` | Per-day show-notes markdown files |
|
||||||
|
| `%USERPROFILE%\Videos\TeamsISO\<date>\` | Default recording output |
|
||||||
|
| `%APPDATA%\NDI\ndi-config.v1.json` | NDI Access Manager group routing |
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
- [Control surface API](docs/CONTROL-SURFACE.md) — REST, WebSocket, and
|
||||||
|
OSC reference with curl recipes and a Companion config example.
|
||||||
|
- [Real-time recording](docs/REAL-TIME-RECORDING.md) — recorder format,
|
||||||
|
manifest schema, and the FFmpeg conversion path.
|
||||||
|
- [Releasing](docs/RELEASING.md) — tag-push workflow and MSI signing.
|
||||||
|
|
||||||
|
## Build from source
|
||||||
|
|
||||||
|
Requires the .NET 8 SDK on Windows. WPF is the only host.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
dotnet restore TeamsISO.Windows.slnf
|
||||||
|
dotnet build TeamsISO.Windows.slnf -c Release
|
||||||
|
dotnet test TeamsISO.Windows.slnf --filter "Category!=ndi&requires!=ndi"
|
||||||
|
```
|
||||||
|
|
||||||
|
Or use the included helper:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
pwsh -File .\build-and-test.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
To produce a fresh MSI:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
dotnet publish src\TeamsISO.App\TeamsISO.App.csproj `
|
||||||
|
-c Release -r win-x64 --self-contained false `
|
||||||
|
-o publish\TeamsISO
|
||||||
|
dotnet build installer\TeamsISO.Installer.wixproj -c Release
|
||||||
|
# Output: installer\bin\x64\Release\TeamsISO-Setup-<version>.msi
|
||||||
|
```
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
Proprietary, © Wild Dragon LLC 2026.
|
Proprietary, © Wild Dragon LLC 2026. All rights reserved.
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,13 @@
|
||||||
"solution": {
|
"solution": {
|
||||||
"path": "TeamsISO.sln",
|
"path": "TeamsISO.sln",
|
||||||
"projects": [
|
"projects": [
|
||||||
"src/TeamsISO.Engine/TeamsISO.Engine.csproj",
|
"src\\TeamsISO.Engine\\TeamsISO.Engine.csproj",
|
||||||
"src/TeamsISO.Engine.NdiInterop/TeamsISO.Engine.NdiInterop.csproj",
|
"src\\TeamsISO.Engine.NdiInterop\\TeamsISO.Engine.NdiInterop.csproj",
|
||||||
"src/TeamsISO.Console/TeamsISO.Console.csproj",
|
"src\\TeamsISO.Console\\TeamsISO.Console.csproj",
|
||||||
"src/TeamsISO.App/TeamsISO.App.csproj",
|
"src\\TeamsISO.App\\TeamsISO.App.csproj",
|
||||||
"src/tests/TeamsISO.Engine.Tests/TeamsISO.Engine.Tests.csproj",
|
"src\\tests\\TeamsISO.Engine.Tests\\TeamsISO.Engine.Tests.csproj",
|
||||||
"src/tests/TeamsISO.Engine.IntegrationTests/TeamsISO.Engine.IntegrationTests.csproj"
|
"src\\tests\\TeamsISO.Engine.IntegrationTests\\TeamsISO.Engine.IntegrationTests.csproj",
|
||||||
|
"src\\tests\\TeamsISO.App.Tests\\TeamsISO.App.Tests.csproj"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeamsISO.Engine.Integration
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeamsISO.Console", "src\TeamsISO.Console\TeamsISO.Console.csproj", "{C3254998-9428-4264-A8FB-EAC9E1F9F432}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeamsISO.Console", "src\TeamsISO.Console\TeamsISO.Console.csproj", "{C3254998-9428-4264-A8FB-EAC9E1F9F432}"
|
||||||
EndProject
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeamsISO.App.Tests", "src\tests\TeamsISO.App.Tests\TeamsISO.App.Tests.csproj", "{B5A6F1E7-3D2C-4F89-9A55-7E1B2A4C8D6F}"
|
||||||
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
Debug|Any CPU = Debug|Any CPU
|
||||||
|
|
@ -52,6 +54,10 @@ Global
|
||||||
{C3254998-9428-4264-A8FB-EAC9E1F9F432}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{C3254998-9428-4264-A8FB-EAC9E1F9F432}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{C3254998-9428-4264-A8FB-EAC9E1F9F432}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{C3254998-9428-4264-A8FB-EAC9E1F9F432}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
{C3254998-9428-4264-A8FB-EAC9E1F9F432}.Release|Any CPU.Build.0 = Release|Any CPU
|
{C3254998-9428-4264-A8FB-EAC9E1F9F432}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{B5A6F1E7-3D2C-4F89-9A55-7E1B2A4C8D6F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{B5A6F1E7-3D2C-4F89-9A55-7E1B2A4C8D6F}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{B5A6F1E7-3D2C-4F89-9A55-7E1B2A4C8D6F}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{B5A6F1E7-3D2C-4F89-9A55-7E1B2A4C8D6F}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(NestedProjects) = preSolution
|
GlobalSection(NestedProjects) = preSolution
|
||||||
{F0D24EAE-9225-4DC4-B3D2-6966077287A0} = {46E05E34-8A87-4986-87D3-FE0DE4E05F44}
|
{F0D24EAE-9225-4DC4-B3D2-6966077287A0} = {46E05E34-8A87-4986-87D3-FE0DE4E05F44}
|
||||||
|
|
@ -61,5 +67,6 @@ Global
|
||||||
{80DCE039-3BBC-4D3F-B44B-51F324591C29} = {46E05E34-8A87-4986-87D3-FE0DE4E05F44}
|
{80DCE039-3BBC-4D3F-B44B-51F324591C29} = {46E05E34-8A87-4986-87D3-FE0DE4E05F44}
|
||||||
{A85E331D-026E-4BDE-B89C-0CC4C95001CE} = {DBDF4A1D-4215-42D5-B456-2CE7159DF848}
|
{A85E331D-026E-4BDE-B89C-0CC4C95001CE} = {DBDF4A1D-4215-42D5-B456-2CE7159DF848}
|
||||||
{C3254998-9428-4264-A8FB-EAC9E1F9F432} = {46E05E34-8A87-4986-87D3-FE0DE4E05F44}
|
{C3254998-9428-4264-A8FB-EAC9E1F9F432} = {46E05E34-8A87-4986-87D3-FE0DE4E05F44}
|
||||||
|
{B5A6F1E7-3D2C-4F89-9A55-7E1B2A4C8D6F} = {DBDF4A1D-4215-42D5-B456-2CE7159DF848}
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
EndGlobal
|
EndGlobal
|
||||||
|
|
|
||||||
41
build-and-test.ps1
Normal file
41
build-and-test.ps1
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
# Quick build + test verification for TeamsISO.
|
||||||
|
#
|
||||||
|
# Run from the repo root:
|
||||||
|
# pwsh -ExecutionPolicy Bypass -File .\build-and-test.ps1
|
||||||
|
#
|
||||||
|
# Builds TeamsISO.Windows.slnf in Release with TreatWarningsAsErrors=true
|
||||||
|
# (the Directory.Build.props default), then runs unit tests excluding the
|
||||||
|
# requires=ndi tier (those need a live NDI runtime).
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
if (-not (Test-Path 'TeamsISO.Windows.slnf')) {
|
||||||
|
throw "Run from the TeamsISO repo root."
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "=== dotnet --version ===" -ForegroundColor Cyan
|
||||||
|
dotnet --version
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "=== Restore ===" -ForegroundColor Cyan
|
||||||
|
dotnet restore TeamsISO.Windows.slnf
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "Restore failed." }
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "=== Build (Release, TreatWarningsAsErrors=true) ===" -ForegroundColor Cyan
|
||||||
|
dotnet build TeamsISO.Windows.slnf --configuration Release --no-restore --nologo
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
throw "Build failed. Most likely cause for this batch: System.Windows.Automation needs an explicit Reference. If so, add to src/TeamsISO.App/TeamsISO.App.csproj inside an <ItemGroup>: <Reference Include='UIAutomationClient' /> <Reference Include='UIAutomationTypes' />"
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "=== Tests (excluding requires=ndi) ===" -ForegroundColor Cyan
|
||||||
|
dotnet test TeamsISO.Windows.slnf `
|
||||||
|
--configuration Release `
|
||||||
|
--no-build `
|
||||||
|
--nologo `
|
||||||
|
--filter "Category!=ndi&requires!=ndi"
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "Tests failed." }
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Build + tests green." -ForegroundColor Green
|
||||||
298
docs/CONTROL-SURFACE.md
Normal file
298
docs/CONTROL-SURFACE.md
Normal file
|
|
@ -0,0 +1,298 @@
|
||||||
|
# TeamsISO Control Surface — REST API
|
||||||
|
|
||||||
|
TeamsISO can expose a localhost HTTP server so external controllers
|
||||||
|
(Bitfocus Companion, Stream Deck plugins, Bome MIDI Translator, custom
|
||||||
|
node-RED flows, command-line scripts) can drive it without a UI binding.
|
||||||
|
|
||||||
|
## Enabling
|
||||||
|
|
||||||
|
1. Open TeamsISO → Settings → DISPLAY tab.
|
||||||
|
2. Tick "Control surface (Stream Deck / Companion)".
|
||||||
|
3. Default port is **9755**; change it via the port textbox if needed.
|
||||||
|
4. By default the server binds to `127.0.0.1` only — it is NOT reachable
|
||||||
|
from the LAN.
|
||||||
|
5. To allow other machines on the same network to drive TeamsISO (the
|
||||||
|
"headless host PC + thin client" scenario), tick the nested
|
||||||
|
"LAN-reachable" checkbox underneath. The settings panel will display
|
||||||
|
the LAN URL (e.g. `http://192.168.1.42:9755/ui`) with a Copy button.
|
||||||
|
|
||||||
|
When enabled, the toast confirms `Control surface listening on
|
||||||
|
http://127.0.0.1:9755/` (or the all-interfaces equivalent in LAN mode).
|
||||||
|
|
||||||
|
### One-time setup for LAN-reachable mode
|
||||||
|
|
||||||
|
Windows requires elevated permission to bind a non-loopback HTTP listener.
|
||||||
|
If you turn on LAN-reachable mode and don't see a connection from another
|
||||||
|
machine, run this **once** in an Administrator PowerShell (replace `9755`
|
||||||
|
if you've changed the port):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
netsh http add urlacl url=http://+:9755/ user=Everyone
|
||||||
|
```
|
||||||
|
|
||||||
|
Also confirm the Windows Firewall is letting inbound traffic to that port
|
||||||
|
through — `New-NetFirewallRule -DisplayName "TeamsISO Control Surface" -Direction Inbound -Protocol TCP -LocalPort 9755 -Action Allow`
|
||||||
|
in an elevated PowerShell, or add it through Windows Defender Firewall →
|
||||||
|
Advanced Settings → Inbound Rules.
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
None — by design. In localhost-only mode, the loopback bind is the
|
||||||
|
security model: any process on the operator's machine can hit these
|
||||||
|
endpoints, the same threat model as a Stream Deck's USB connection.
|
||||||
|
|
||||||
|
In LAN-reachable mode, the assumption is a closed/trusted network (a
|
||||||
|
production-control LAN, a dedicated show subnet, a private vlan). Any
|
||||||
|
machine that can route to the host on the listener port can drive
|
||||||
|
TeamsISO. **Do not enable LAN-reachable mode on an untrusted network.**
|
||||||
|
|
||||||
|
## Response shape
|
||||||
|
|
||||||
|
All responses are `application/json` with `Access-Control-Allow-Origin: *`
|
||||||
|
so a browser-based control panel served from another origin can call the
|
||||||
|
endpoints. Most successful responses include `"ok": true` plus operation-
|
||||||
|
specific fields. Errors return HTTP 4xx/5xx with `{"error": "..."}`.
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
### `GET /ui`
|
||||||
|
|
||||||
|
Self-contained HTML control panel. Open this in a browser to drive
|
||||||
|
TeamsISO from a phone, tablet, or second monitor. Lists participants live
|
||||||
|
via the same `/ws` WebSocket the rest of the doc describes, and posts to
|
||||||
|
the REST endpoints when you click. Single page, no external dependencies,
|
||||||
|
loads in <50KB.
|
||||||
|
|
||||||
|
### `GET /`
|
||||||
|
|
||||||
|
Returns server info and an endpoint summary. Useful for "is the surface
|
||||||
|
alive?" probes.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"product": "TeamsISO",
|
||||||
|
"version": "1.0.0.0",
|
||||||
|
"endpoints": ["GET /participants", "POST /participants/{id}/iso", ...]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `GET /participants`
|
||||||
|
|
||||||
|
Snapshot of the current participant list as the UI sees it.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"participants": [
|
||||||
|
{
|
||||||
|
"id": "1c3e2a8b-...-...",
|
||||||
|
"displayName": "Jane",
|
||||||
|
"isOnline": true,
|
||||||
|
"isEnabled": false,
|
||||||
|
"customName": null,
|
||||||
|
"stateLabel": "—"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `POST /participants/{id}/iso`
|
||||||
|
|
||||||
|
Enable or disable an ISO by participant Id. Body or query string:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "enabled": true, "customName": "Host" }
|
||||||
|
```
|
||||||
|
|
||||||
|
`enabled` is optional — omitting it toggles the current state. `customName`
|
||||||
|
is optional and overrides the auto-generated NDI output name.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -X POST 'http://127.0.0.1:9755/participants/1c3e2a8b-.../iso?enabled=true&customName=Host'
|
||||||
|
```
|
||||||
|
|
||||||
|
### `POST /participants/iso`
|
||||||
|
|
||||||
|
Same as above but resolves by display name instead of Id. The Id varies
|
||||||
|
across meetings; the display name is the operator-stable identifier.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "displayName": "Jane", "enabled": true }
|
||||||
|
```
|
||||||
|
|
||||||
|
### `POST /presets/{name}/apply`
|
||||||
|
|
||||||
|
Apply a saved preset to the live participant list. Walks every participant
|
||||||
|
in the meeting, matches by display name, sets the custom output name, and
|
||||||
|
reconciles each enable/disable via the engine. Same code path as the
|
||||||
|
Presets dialog and the auto-apply-on-launch flow (`PresetApplier.ApplyAsync`).
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ok": true,
|
||||||
|
"name": "Friday Show",
|
||||||
|
"matched": 4,
|
||||||
|
"changed": 2,
|
||||||
|
"skipped": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`matched` is how many participants in the preset were live in the meeting;
|
||||||
|
`changed` is how many actually flipped state; `skipped` is preset entries
|
||||||
|
with no live counterpart.
|
||||||
|
|
||||||
|
### `POST /presets/refresh-discovery`
|
||||||
|
|
||||||
|
Force NDI discovery to rebuild its finder. Useful after Apply Transcoder
|
||||||
|
Topology or when Teams restarts mid-show. Returns immediately; the rebuild
|
||||||
|
happens on the next poll tick.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -X POST http://127.0.0.1:9755/presets/refresh-discovery
|
||||||
|
```
|
||||||
|
|
||||||
|
### `POST /presets/stop-all`
|
||||||
|
|
||||||
|
Disable every running ISO. Equivalent to clicking "Stop all ISOs" in the
|
||||||
|
header. Returns the count that were running.
|
||||||
|
|
||||||
|
### `POST /teams/mute` / `/camera` / `/share` / `/leave` / `/raise-hand`
|
||||||
|
|
||||||
|
Drive the corresponding Microsoft Teams in-call control via UIAutomation.
|
||||||
|
Returns one of `Invoked` / `TeamsNotRunning` / `ControlNotFound` /
|
||||||
|
`InvokeFailed` in the `result` field.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -X POST http://127.0.0.1:9755/teams/mute
|
||||||
|
```
|
||||||
|
|
||||||
|
### `POST /recording`
|
||||||
|
|
||||||
|
Toggle per-output recording on or off. Body or query string:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "enabled": true, "directory": "D:/recordings/show-2026-05-09" }
|
||||||
|
```
|
||||||
|
|
||||||
|
`directory` is optional when `enabled=false`. Already-running ISOs are not
|
||||||
|
retroactively recorded — the operator should disable + re-enable a
|
||||||
|
participant to start recording it.
|
||||||
|
|
||||||
|
### `POST /recording/marker`
|
||||||
|
|
||||||
|
Drop a timestamped marker into every active recording. Body or query string
|
||||||
|
optionally carries a `label`; if omitted, the label defaults to
|
||||||
|
`Marker @ HH:mm:ss`. Markers land in each recording's `manifest.json` under
|
||||||
|
the `markers[]` array as `{ "offsetMs": 12345.6, "label": "Guest answer" }`.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -X POST 'http://127.0.0.1:9755/recording/marker?label=Guest+answer'
|
||||||
|
```
|
||||||
|
|
||||||
|
### `POST /notes`
|
||||||
|
|
||||||
|
Append a timestamped line to today's show-notes file at
|
||||||
|
`%LOCALAPPDATA%\TeamsISO\Notes\<YYYY-MM-DD>.md`. Body or query string carries
|
||||||
|
`text`. Each line is prefixed with `**HH:mm:ss** —`; the file is markdown so
|
||||||
|
it renders nicely in any editor.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -X POST 'http://127.0.0.1:9755/notes?text=guest+segment+starts'
|
||||||
|
```
|
||||||
|
|
||||||
|
### `POST /recording/roll`
|
||||||
|
|
||||||
|
Roll every active recording into a new chunk. Each running pipeline is
|
||||||
|
disabled (recorder finalizes its `manifest.json`), waits ~150ms, then re-
|
||||||
|
enabled (recorder opens a fresh subdirectory keyed by display name +
|
||||||
|
timestamp). Useful for chaptering between show segments — a Stream Deck
|
||||||
|
button mapped to this gives operators "next segment" without losing the
|
||||||
|
already-recorded footage.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -X POST http://127.0.0.1:9755/recording/roll
|
||||||
|
```
|
||||||
|
|
||||||
|
Response:
|
||||||
|
```json
|
||||||
|
{ "ok": true, "action": "roll-recording", "rolled": 4 }
|
||||||
|
```
|
||||||
|
|
||||||
|
## WebSocket — live state push
|
||||||
|
|
||||||
|
For controllers that want to light a button when an ISO goes LIVE without
|
||||||
|
polling, connect to:
|
||||||
|
|
||||||
|
```
|
||||||
|
ws://127.0.0.1:9755/ws
|
||||||
|
```
|
||||||
|
|
||||||
|
On connect, the server sends a participants snapshot. Whenever the snapshot
|
||||||
|
changes (participant joins/leaves, ISO toggled, custom name edited), a fresh
|
||||||
|
snapshot is pushed within 250ms. Format:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "participants",
|
||||||
|
"participants": [
|
||||||
|
{ "id": "...", "displayName": "Jane", "isOnline": true,
|
||||||
|
"isEnabled": true, "customName": "Host", "stateLabel": "LIVE" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Client→server messages are ignored for v1 — all commands go through REST.
|
||||||
|
|
||||||
|
## OSC over UDP
|
||||||
|
|
||||||
|
Same command surface, different transport. Enable the OSC bridge in the
|
||||||
|
DISPLAY tab (default port **9000** — TouchOSC's default). Bound to
|
||||||
|
`127.0.0.1` by default; honors the same LAN-reachable toggle as the REST
|
||||||
|
surface — when LAN mode is on, OSC binds to `0.0.0.0` so a TouchOSC tablet
|
||||||
|
on the same network can talk to the host directly.
|
||||||
|
|
||||||
|
Address vocabulary:
|
||||||
|
|
||||||
|
```
|
||||||
|
/teamsiso/iso "DisplayName" {0|1} — toggle/set ISO by display name
|
||||||
|
/teamsiso/iso/by-id "guid" {0|1} — toggle/set by Id
|
||||||
|
/teamsiso/preset "Name" — apply preset
|
||||||
|
/teamsiso/teams/mute — UIA toggle mute
|
||||||
|
/teamsiso/teams/camera — UIA toggle camera
|
||||||
|
/teamsiso/teams/leave — UIA leave
|
||||||
|
/teamsiso/teams/share — UIA share tray
|
||||||
|
/teamsiso/teams/raise-hand — UIA raise hand
|
||||||
|
/teamsiso/refresh-discovery — rebuild NDI finder
|
||||||
|
/teamsiso/stop-all — disable every ISO
|
||||||
|
/teamsiso/recording {0|1} — recording on/off (default dir)
|
||||||
|
/teamsiso/recording/marker "Label" — drop a marker on every active recording
|
||||||
|
/teamsiso/recording/roll — roll every active recording into a new chunk
|
||||||
|
/teamsiso/notes "Free-form note" — append a timestamped line to today's notes
|
||||||
|
```
|
||||||
|
|
||||||
|
Companion → Surfaces → "Generic OSC" supports outbound OSC; bind a button
|
||||||
|
press to e.g. `/teamsiso/iso "Jane" 1`. TouchOSC layouts can use the same
|
||||||
|
addresses on the same UDP port.
|
||||||
|
|
||||||
|
## Bitfocus Companion recipe
|
||||||
|
|
||||||
|
Companion ships a generic HTTP module. Configure a button:
|
||||||
|
|
||||||
|
- **Action:** `HTTP: HTTP POST request`
|
||||||
|
- **URL:** `http://127.0.0.1:9755/teams/mute`
|
||||||
|
- **Body type:** None
|
||||||
|
|
||||||
|
Or for a participant-specific toggle:
|
||||||
|
|
||||||
|
- **URL:** `http://127.0.0.1:9755/participants/iso?displayName=Jane&enabled=true`
|
||||||
|
|
||||||
|
## Stream Deck XL recipe (without Companion)
|
||||||
|
|
||||||
|
Use the "Web Requests" plugin (or any equivalent). Set the action to a POST
|
||||||
|
on the appropriate endpoint above.
|
||||||
|
|
||||||
|
## Future work
|
||||||
|
|
||||||
|
- **HTTPS / token auth** — for deployments that don't have a closed
|
||||||
|
network, layer TLS termination + a shared bearer token in front of the
|
||||||
|
HttpListener. Out of scope for v1; the LAN-reachable mode is a
|
||||||
|
trusted-network feature only.
|
||||||
95
docs/REAL-TIME-RECORDING.md
Normal file
95
docs/REAL-TIME-RECORDING.md
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
# Real-time H.264 recording
|
||||||
|
|
||||||
|
The default recorder (`RawBgraRecorderSink`) writes uncompressed BGRA to disk
|
||||||
|
and ships a `convert.cmd` for post-recording FFmpeg encoding. That's safe
|
||||||
|
(no extra dependencies, works without an encoder installed) but disk-heavy:
|
||||||
|
1080p60 = ~500 MB/s, 720p30 = ~88 MB/s.
|
||||||
|
|
||||||
|
For long shows or operators on slower disks, the engine ships a
|
||||||
|
**`MediaFoundationRecorderSink`** that encodes to H.264 in real time using
|
||||||
|
Windows Media Foundation. Inline encoding cuts disk pressure ~10× and
|
||||||
|
produces a finished `.mp4` without the convert step.
|
||||||
|
|
||||||
|
It's behind a build flag because activating it requires adding a NuGet
|
||||||
|
dependency. The structural code is already in
|
||||||
|
`src/TeamsISO.Engine/Pipeline/MediaFoundationRecorderSink.cs`.
|
||||||
|
|
||||||
|
## Status — May 2026
|
||||||
|
|
||||||
|
**Activation deferred.** The Vortice.MediaFoundation 3.6.2 NuGet package
|
||||||
|
is referenced from `TeamsISO.Engine.csproj`, but the `MF_AVAILABLE` symbol
|
||||||
|
is *not* defined. The scaffold in
|
||||||
|
`src/TeamsISO.Engine/Pipeline/MediaFoundationRecorderSink.cs` was written
|
||||||
|
against an older Vortice API and needs a port pass before activation:
|
||||||
|
|
||||||
|
- `MFVersion` → not on `MediaFactory` in 3.6.2; pass the SDK version
|
||||||
|
directly to `MFStartup`.
|
||||||
|
- `MediaFactory.MF_LOW_LATENCY` → relocated to a different attribute
|
||||||
|
constants class.
|
||||||
|
- `IMFAttributes.SetUINT32` → replaced with a generic `Set` overload.
|
||||||
|
- `IMFMediaType.MajorType` / `.SubType` / `.AvgBitrate` properties
|
||||||
|
→ now use `SetGUID(MFAttributeKeys.MajorType, ...)` etc.
|
||||||
|
- `VideoFormatGuids.RGB32` → renamed (likely `Rgb32`).
|
||||||
|
- `IMFMediaBuffer.Lock(out IntPtr, out int, out int)` → explicit out-param
|
||||||
|
signature, no longer returns a locked-buffer wrapper.
|
||||||
|
- `IMFSinkWriter.Finalize_` → renamed (likely `Finalize`).
|
||||||
|
|
||||||
|
Until the port lands, the `RawBgraRecorderSink` is the only IRecorderSink
|
||||||
|
production uses. The raw recorder is reliable and FFmpeg post-processing
|
||||||
|
via the emitted `convert.cmd` produces equivalent .mp4s; you just pay the
|
||||||
|
disk pressure during the show.
|
||||||
|
|
||||||
|
## Activating it (after the port)
|
||||||
|
|
||||||
|
1. **Update the scaffold** to match Vortice 3.6.2's API surface. A clean
|
||||||
|
reference implementation lives in the Vortice samples repo under
|
||||||
|
`samples/MediaFoundationSamples`.
|
||||||
|
|
||||||
|
2. **Define the `MF_AVAILABLE` build symbol** in `TeamsISO.Engine.csproj`:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<PropertyGroup>
|
||||||
|
<DefineConstants>$(DefineConstants);MF_AVAILABLE</DefineConstants>
|
||||||
|
</PropertyGroup>
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Swap the recorder factory** in `IsoController.EnableIsoAsync`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Old:
|
||||||
|
recorder = new RawBgraRecorderSink(_loggerFactory.CreateLogger<RawBgraRecorderSink>());
|
||||||
|
// New:
|
||||||
|
recorder = new MediaFoundationRecorderSink(_loggerFactory.CreateLogger<MediaFoundationRecorderSink>());
|
||||||
|
```
|
||||||
|
|
||||||
|
Both classes implement `IRecorderSink` so the rest of the pipeline is
|
||||||
|
unchanged.
|
||||||
|
|
||||||
|
4. **Build and smoke-test.** Existing unit tests don't touch the recorder;
|
||||||
|
the integration tier covers it once you've enabled MF.
|
||||||
|
|
||||||
|
## What the MF recorder produces
|
||||||
|
|
||||||
|
For each enabled ISO with recording on:
|
||||||
|
- `<recordings>/<participant>/output.mp4` — H.264 video at the engine's
|
||||||
|
configured resolution / framerate, target bitrate ~0.07 bits/pixel
|
||||||
|
(~7 Mbps for 1080p30, ~3 Mbps for 720p30).
|
||||||
|
- `<recordings>/<participant>/markers.txt` — tab-separated marker offsets
|
||||||
|
from `IIsoController.AddRecordingMarker`. Manually chapter the .mp4 with
|
||||||
|
`mp4chaps -c -i markers.txt output.mp4` (mp4chaps from the `mp4v2` tools).
|
||||||
|
|
||||||
|
## Trade-offs vs. RawBgraRecorderSink
|
||||||
|
|
||||||
|
| | Raw BGRA | Media Foundation H.264 |
|
||||||
|
| --------------------- | --------------- | ---------------------- |
|
||||||
|
| Dependencies | None | Vortice.MediaFoundation NuGet |
|
||||||
|
| Disk @ 1080p60 | ~500 MB/s | ~50 MB/s |
|
||||||
|
| Disk @ 720p30 | ~88 MB/s | ~9 MB/s |
|
||||||
|
| CPU | Negligible | Moderate (inline encode) |
|
||||||
|
| Output | `.bgra` + `convert.cmd` for FFmpeg post-pass | Finished `.mp4` |
|
||||||
|
| Markers in container | No (sidecar JSON) | Sidecar `.txt`, chapter via mp4chaps |
|
||||||
|
| Reliable on legacy GPUs | Yes | Yes (MF falls back to software encoder if no hw H.264) |
|
||||||
|
|
||||||
|
If your target machines have NVIDIA NVENC / Intel QuickSync, MF will use
|
||||||
|
the hardware encoder transparently — that's the path that gives you
|
||||||
|
multi-stream realtime H.264 with low CPU.
|
||||||
79
docs/RELEASING.md
Normal file
79
docs/RELEASING.md
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
# Releasing TeamsISO
|
||||||
|
|
||||||
|
The release workflow at `.forgejo/workflows/release.yml` runs on **annotated tag pushes
|
||||||
|
matching `v*.*.*`**. It builds, tests, publishes, packages an MSI, and uploads the
|
||||||
|
MSI as a release asset.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- A **Windows runner** registered to this Forgejo instance. WiX MSI builds require
|
||||||
|
Windows; the existing CI runs on Linux for unit tests, but releases need a
|
||||||
|
separate Windows runner. Register one with `forgejo-runner register` against a
|
||||||
|
Windows host that has the .NET 8 SDK + WiX SDK access (the WiX SDK pulls itself
|
||||||
|
via NuGet at build time, so no separate install).
|
||||||
|
- The repository's **Create release on tag push** setting on (default), or skip it —
|
||||||
|
the workflow will create the release if one doesn't exist.
|
||||||
|
|
||||||
|
## Cutting a release
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Bump the version in Directory.Build.props if you haven't already.
|
||||||
|
git tag -a v1.0.0 -m "TeamsISO 1.0.0"
|
||||||
|
git push origin v1.0.0
|
||||||
|
```
|
||||||
|
|
||||||
|
The workflow will:
|
||||||
|
|
||||||
|
1. Restore + build `TeamsISO.Windows.slnf` in Release with the tag's version.
|
||||||
|
2. Run unit tests (the `requires=ndi` integration tier is skipped — it needs a
|
||||||
|
real NDI runtime which a CI runner won't have).
|
||||||
|
3. Publish `TeamsISO.App` and `TeamsISO.Console` for `win-x64`,
|
||||||
|
framework-dependent (.NET 8 Desktop runtime is the user's responsibility).
|
||||||
|
4. Build `installer/TeamsISO.Installer.wixproj`, producing
|
||||||
|
`TeamsISO-Setup-<version>.msi`.
|
||||||
|
5. Upload the MSI as a workflow artifact (downloadable from the run page).
|
||||||
|
6. Attach the MSI to the GitHub-style Release for the tag, creating the release
|
||||||
|
first if it doesn't exist. Pre-release flag is set automatically when the
|
||||||
|
tag contains `-alpha`, `-beta`, or `-rc`.
|
||||||
|
|
||||||
|
## Code signing
|
||||||
|
|
||||||
|
The release workflow has optional signtool integration. It runs only when the
|
||||||
|
signing-cert secrets are configured on the repository — without them, builds
|
||||||
|
remain unsigned and produce a SmartScreen warning on first launch.
|
||||||
|
|
||||||
|
### Enabling signing
|
||||||
|
|
||||||
|
Set these secrets on `forge.wilddragon.net/zgaetano/teamsiso`
|
||||||
|
→ Settings → Actions → Secrets:
|
||||||
|
|
||||||
|
| Secret | Required | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `SIGN_CERT_PFX_BASE64` | yes | Base64 of your code-signing PFX file. Generate with `certutil -encode in.pfx out.b64`, then strip the `-----BEGIN/END CERTIFICATE-----` lines. |
|
||||||
|
| `SIGN_CERT_PASSWORD` | yes | The PFX password. |
|
||||||
|
| `SIGN_TIMESTAMP_URL` | no | RFC 3161 timestamp server. Defaults to `http://timestamp.digicert.com`. |
|
||||||
|
|
||||||
|
When all three are present, the workflow:
|
||||||
|
|
||||||
|
1. Decodes the PFX to a temp file on the runner before building.
|
||||||
|
2. Signs `publish/TeamsISO/TeamsISO.exe` after publish, before MSI build, so the
|
||||||
|
binary embedded in the MSI is signed too.
|
||||||
|
3. Signs the produced MSI itself after WiX builds it.
|
||||||
|
4. Wipes the temp PFX from disk.
|
||||||
|
|
||||||
|
Both signing steps use SHA-256 for both the file hash and the timestamp digest,
|
||||||
|
which is what current Microsoft / SmartScreen guidance requires.
|
||||||
|
|
||||||
|
### Cert types
|
||||||
|
|
||||||
|
- **OV (Organization Validation, ~$200/yr).** SmartScreen reputation is built
|
||||||
|
per-publisher over time; brand-new OV certs still trip the warning until
|
||||||
|
enough downloads accumulate.
|
||||||
|
- **EV (Extended Validation, ~$300/yr, hardware token).** SmartScreen-trusted
|
||||||
|
immediately. Token-based — to use one in CI you'll need to either (a) keep
|
||||||
|
the runner on a host with the token plugged in, or (b) move to a cloud
|
||||||
|
signing service like Azure Trusted Signing or DigiCert KeyLocker.
|
||||||
|
|
||||||
|
For v1.0 we recommend the Azure Trusted Signing route: replace the PFX block
|
||||||
|
in `release.yml` with `azure/trusted-signing-action` once an Azure subscription
|
||||||
|
is set up. The current PFX path is the simplest thing that works for now.
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,167 +0,0 @@
|
||||||
# TeamsISO Phase B-1 — Pipeline Orchestration Plan
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task.
|
|
||||||
|
|
||||||
**Goal:** Implement the engine-side pipeline orchestration on top of the `INdiInterop` test seam from Phase A — `NdiReceiver`, `NdiSender`, `ExponentialBackoff`, `NdiRuntimeProbe`, `IsoPipeline` (lifecycle + restart loop), and `IsoController` (top-level engine API). All testable on Linux against `FakeNdiInterop`. Phase B-2 (real Windows P/Invoke for `INdiInterop` + libyuv `IFrameScaler` + integration tests) follows.
|
|
||||||
|
|
||||||
**Architecture:** Pure orchestration. Each `IsoPipeline` wires one `NdiReceiver` → existing `FrameProcessor` → one `NdiSender` via two bounded channels. The pipeline owns a restart loop driven by `ExponentialBackoff`. `IsoController` is the top of the engine — holds the pipeline dictionary, the `ParticipantTracker`, the `ConfigStore`, and exposes the contract the WPF host (Phase C) will bind to.
|
|
||||||
|
|
||||||
**Tech Stack:** .NET 8, xUnit, FluentAssertions. No new external dependencies.
|
|
||||||
|
|
||||||
**Source spec:** `docs/superpowers/specs/2026-05-07-teamsiso-v1-design.md`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## File structure additions
|
|
||||||
|
|
||||||
```
|
|
||||||
src/TeamsISO.Engine/
|
|
||||||
├── Pipeline/
|
|
||||||
│ ├── NdiReceiver.cs (NEW)
|
|
||||||
│ ├── NdiSender.cs (NEW)
|
|
||||||
│ ├── ExponentialBackoff.cs (NEW)
|
|
||||||
│ ├── IsoPipeline.cs (NEW)
|
|
||||||
│ └── IsoPipelineConfig.cs (NEW)
|
|
||||||
├── Interop/
|
|
||||||
│ └── NdiRuntimeProbe.cs (NEW)
|
|
||||||
└── Controller/
|
|
||||||
├── IIsoController.cs (NEW)
|
|
||||||
└── IsoController.cs (NEW)
|
|
||||||
|
|
||||||
src/tests/TeamsISO.Engine.Tests/
|
|
||||||
├── Pipeline/NdiReceiverTests.cs (NEW)
|
|
||||||
├── Pipeline/NdiSenderTests.cs (NEW)
|
|
||||||
├── Pipeline/ExponentialBackoffTests.cs (NEW)
|
|
||||||
├── Pipeline/IsoPipelineTests.cs (NEW)
|
|
||||||
├── Interop/NdiRuntimeProbeTests.cs (NEW)
|
|
||||||
└── Controller/IsoControllerTests.cs (NEW)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 1: `NdiReceiver`
|
|
||||||
|
|
||||||
Receiver that wraps `INdiInterop.CaptureFrame` and pushes results into a `ChannelWriter<RawFrame>`. Exposes a `CaptureOnce` test seam mirroring `FrameProcessor.ProcessOnceAsync`. `RunAsync` is the production loop with `LongRunning` thread semantics.
|
|
||||||
|
|
||||||
TDD assertions:
|
|
||||||
- `CaptureOnce` writes a captured frame to the output channel; counter increments.
|
|
||||||
- `CaptureOnce` does nothing on null capture (timeout); counter does not change.
|
|
||||||
- `RunAsync` honors cancellation and disposes the receiver handle on exit.
|
|
||||||
|
|
||||||
Commit: `feat(pipeline): add NdiReceiver with channel-based output`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 2: `NdiSender`
|
|
||||||
|
|
||||||
Sender that pulls from a `ChannelReader<ProcessedFrame>` and forwards to `INdiInterop.SendFrame`. `SendNextAsync` returns true if a frame was sent; false if the channel completed. `RunAsync` loops until cancellation.
|
|
||||||
|
|
||||||
TDD assertions:
|
|
||||||
- `SendNextAsync` forwards a frame to the interop and increments the sent counter.
|
|
||||||
- Returns `false` when channel completes.
|
|
||||||
- `RunAsync` honors cancellation and disposes the sender handle.
|
|
||||||
|
|
||||||
Commit: `feat(pipeline): add NdiSender with channel-based input`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 3: `ExponentialBackoff`
|
|
||||||
|
|
||||||
Pure policy type. Given an attempt count, returns the next delay (1, 2, 4, 8, 16 s, capped at 30 s) and decides whether to give up after N consecutive failures (default 5).
|
|
||||||
|
|
||||||
TDD assertions:
|
|
||||||
- Sequence at attempts 1..5 is 1, 2, 4, 8, 16 seconds.
|
|
||||||
- `ShouldGiveUp` returns true after the 5th attempt.
|
|
||||||
- Cap: at attempt 7 the delay is 30 s, not 64.
|
|
||||||
|
|
||||||
Commit: `feat(pipeline): add ExponentialBackoff policy`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 4: `NdiRuntimeProbe`
|
|
||||||
|
|
||||||
Reads the runtime version via `INdiInterop.GetRuntimeVersion()`, compares to an expected value (passed in by the engine for now; a real comparison against the SDK headers is Phase B-2). Returns either `Match` or `Mismatch` with both versions populated. The `IsoController` will surface `EngineAlert.NdiRuntimeMismatch` from a mismatch.
|
|
||||||
|
|
||||||
TDD assertions:
|
|
||||||
- Match when versions equal.
|
|
||||||
- Mismatch carries detected and expected.
|
|
||||||
|
|
||||||
Commit: `feat(interop): add NdiRuntimeProbe with version-mismatch result`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 5: `IsoPipeline` core lifecycle
|
|
||||||
|
|
||||||
Owns one `NdiReceiver`, one `FrameProcessor`, one `NdiSender`, and the two channels between them. `StartAsync` creates the channels, instantiates the receiver/processor/sender, kicks off the three loops on long-running tasks. `StopAsync` cancels the token, awaits the loops, and disposes everything.
|
|
||||||
|
|
||||||
`IsoState` transitions: `Idle` → `Receiving` (after start) → `Sending` (after first send) → `NoSignal` (handled by FrameProcessor's slate path and exposed via Stats). On exception the loop transitions to `Error`.
|
|
||||||
|
|
||||||
The restart loop is in Task 6.
|
|
||||||
|
|
||||||
TDD assertions:
|
|
||||||
- Start transitions Idle → Receiving.
|
|
||||||
- Stop transitions back to Idle and disposes interop handles.
|
|
||||||
- Receiver/sender handles are created on Start, disposed on Stop.
|
|
||||||
|
|
||||||
Commit: `feat(pipeline): add IsoPipeline core lifecycle`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 6: `IsoPipeline` restart loop
|
|
||||||
|
|
||||||
Wraps the running pipeline in a supervisory loop that catches unhandled exceptions, applies `ExponentialBackoff`, and either restarts or transitions to `Error` after exhausting retries. State observable updates accordingly.
|
|
||||||
|
|
||||||
TDD assertions (using a fault-injecting INdiInterop):
|
|
||||||
- Pipeline that fails once, then runs cleanly, restarts and ends up Sending.
|
|
||||||
- Pipeline that fails 5+ consecutive times transitions to Error and stays there.
|
|
||||||
- Backoff delays are honored (using a fake delay primitive for fast tests).
|
|
||||||
|
|
||||||
Commit: `feat(pipeline): add IsoPipeline restart supervisor with backoff`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 7: `IIsoController` interface + `IsoController` implementation
|
|
||||||
|
|
||||||
The top-of-engine API the WPF host will bind to in Phase C.
|
|
||||||
|
|
||||||
Surface:
|
|
||||||
- `IObservable<IReadOnlyList<Participant>> Participants { get; }`
|
|
||||||
- `IObservable<EngineAlert> Alerts { get; }`
|
|
||||||
- `IsoHealthStats GetStats(Guid participantId)`
|
|
||||||
- `Task EnableIsoAsync(Guid participantId, string? customName, CancellationToken ct)`
|
|
||||||
- `Task DisableIsoAsync(Guid participantId, CancellationToken ct)`
|
|
||||||
- `Task SetGlobalSettingsAsync(FrameProcessingSettings settings, CancellationToken ct)`
|
|
||||||
|
|
||||||
Implementation owns: `ParticipantTracker`, `NdiDiscoveryService`, dictionary of `IsoPipeline`, the `ConfigStore`, the runtime probe.
|
|
||||||
|
|
||||||
TDD assertions:
|
|
||||||
- `EnableIsoAsync` creates and starts a pipeline; `DisableIsoAsync` stops and removes it.
|
|
||||||
- `SetGlobalSettingsAsync` persists via ConfigStore and applies to existing pipelines.
|
|
||||||
- Discovery events flow through to the participants observable.
|
|
||||||
- `NdiRuntimeProbe` mismatch surfaces an alert.
|
|
||||||
|
|
||||||
Commit: `feat(controller): add IIsoController and IsoController implementation`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 8: Wrap-up & milestone tag
|
|
||||||
|
|
||||||
- Run full test suite, confirm all green.
|
|
||||||
- Confirm coverage threshold still ≥80%.
|
|
||||||
- Update `docs/superpowers/plans/_NEXT.md` to describe Phase B-2 (Windows-only).
|
|
||||||
- Tag `phase-b-1-complete`.
|
|
||||||
|
|
||||||
Commit: `chore: phase-b-1 milestone wrap-up`
|
|
||||||
Tag: `phase-b-1-complete`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Self-review
|
|
||||||
|
|
||||||
**Spec coverage:** Spec §4 components NdiReceiver, NdiSender, IsoPipeline, IsoController — Tasks 1, 2, 5, 6, 7. Spec §6 error handling restart/backoff — Task 6. Spec §6 NDI runtime mismatch — Task 4 + Task 7. ConfigStore integration in IsoController — Task 7.
|
|
||||||
|
|
||||||
**Phase B-2 (deferred):** Real `NdiInteropPInvoke` shim, real `LibYuvFrameScaler`, console smoke runner, integration tests against NDI Test Pattern source. All require Windows + NDI runtime so they live in their own plan.
|
|
||||||
|
|
||||||
**Type consistency:** All new types reference Phase A types unchanged. `INdiInterop` surface is sufficient — no additions needed.
|
|
||||||
|
|
||||||
No issues to fix. Ready to execute.
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
# TeamsISO Phase B-2 — Real NDI Interop Plan
|
|
||||||
|
|
||||||
**Goal:** Production `INdiInterop` implementation in `TeamsISO.Engine.NdiInterop` against NDI SDK 6, a managed BGRA scaler with aspect modes, an NDI version constant, and a `TeamsISO.Console` headless smoke runner that wires up the engine end-to-end. After this phase the engine can drive real Teams NDI streams once run on a Windows box with the NDI runtime installed.
|
|
||||||
|
|
||||||
**Architecture:** P/Invoke against `Processing.NDI.Lib.x64.dll`. Frame marshalling translates NDI's `video_frame_v2_t` to/from our managed `RawFrame`/`ProcessedFrame`. Receive in BGRA color space (`NDIlib_recv_color_format_e_BGRX_BGRA`) so the scaler doesn't need to handle UYVY in v1.0. Memory management: every captured frame is freed via `NDIlib_recv_free_video_v2` once we've copied its pixels into a managed buffer.
|
|
||||||
|
|
||||||
**Tech Stack:** .NET 8, `System.Runtime.InteropServices`, plain C# scaler (managed BGRA nearest-neighbor; libyuv is a v1.5 perf optimization). The console runner uses the existing `EngineLogging.CreateConsole`.
|
|
||||||
|
|
||||||
**Source spec:** `docs/superpowers/specs/2026-05-07-teamsiso-v1-design.md`
|
|
||||||
|
|
||||||
## Tasks
|
|
||||||
|
|
||||||
1. **NDI native bindings:** `NdiNative.cs` with all `[DllImport]` declarations needed (`initialize`, `destroy`, `find_create_v2/destroy/get_current_sources`, `recv_create_v3/destroy/capture_v3/free_video_v2`, `send_create/destroy/send_video_v2`, `version`). Define `NDIlib_video_frame_v2_t`, `NDIlib_source_t`, `NDIlib_recv_create_v3_t`, `NDIlib_send_create_t` structs with explicit layout.
|
|
||||||
2. **Handles:** `NdiPInvokeFindHandle`, `NdiPInvokeReceiverHandle`, `NdiPInvokeSenderHandle` deriving from the abstract Phase A handles, owning the unmanaged pointers.
|
|
||||||
3. **NdiInteropPInvoke:** the production `INdiInterop` implementation. Initializes NDI on construction; destroys on dispose. Marshals between native and managed frame structs. Allocates managed pixel buffers and copies; frees the native frame immediately.
|
|
||||||
4. **NdiVersion:** a constants class exposing the version string the engine probe compares against.
|
|
||||||
5. **ManagedNearestNeighborFrameScaler:** managed BGRA scaler with `Pillarbox`, `Letterbox`, `Stretch` aspect modes. Fully unit-tested.
|
|
||||||
6. **TeamsISO.Console:** a small console host. Constructs `IsoController` against `NdiInteropPInvoke` + `ManagedNearestNeighborFrameScaler`, prints participant updates, listens for `q\n` to quit. Useful for headless validation.
|
|
||||||
7. **Wire-up tests:** integration scaffold uses `RuntimeInformation.IsOSPlatform(OSPlatform.Windows)` to skip cleanly on non-Windows. Add a smoke integration test that constructs the interop and probes the version.
|
|
||||||
8. **Wrap-up:** tag `phase-b-2-complete`.
|
|
||||||
|
|
||||||
## What this phase intentionally does NOT include
|
|
||||||
|
|
||||||
- libyuv-backed scaler (deferred to v1.5 per spec — managed scaler is functionally complete).
|
|
||||||
- Actual integration test suite running against an NDI Test Pattern source. Those tests need the NDI runtime; they're authored here but stay tagged `requires=ndi` and skip in the Linux CI.
|
|
||||||
- Audio handling (passthrough video only in this phase; audio support added later if v1.0 needs it before ship).
|
|
||||||
|
|
||||||
## Self-review
|
|
||||||
|
|
||||||
Spec coverage: §4 NdiReceiver/NdiSender/IsoController already done in B-1; this phase fills in the actual NDI SDK calls under `INdiInterop`. §6 startup preflight via `NdiVersion` + the existing `NdiRuntimeProbe`. §8 console smoke runner is a Phase B-2 deliverable for first end-to-end Windows validation before WPF.
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
# TeamsISO Phase C — WPF MVVM UI Plan
|
|
||||||
|
|
||||||
**Goal:** Operator-facing WPF UI bound to `IIsoController`. Displays the live participant list, lets operators enable/disable per-participant ISO outputs, set the global framerate / resolution / aspect / audio mode, view engine alerts, and see basic system health. Plus a WiX MSI installer and a release CI pipeline.
|
|
||||||
|
|
||||||
**Architecture:** MVVM with no third-party MVVM framework — small managed `ObservableObject` and `RelayCommand` helpers. The view models bind directly to `IIsoController`'s observables. UI runs on the WPF dispatcher; observable subscriptions marshal back via a captured `SynchronizationContext`. App.xaml.cs constructs the engine on startup and disposes on exit.
|
|
||||||
|
|
||||||
**Tech stack:** WPF on .NET 8, MVVM hand-rolled, no external UI library yet (MaterialDesignThemes can be added in a polish pass).
|
|
||||||
|
|
||||||
## File structure additions
|
|
||||||
|
|
||||||
```
|
|
||||||
src/TeamsISO.App/
|
|
||||||
├── App.xaml / App.xaml.cs (DI bootstrap)
|
|
||||||
├── MainWindow.xaml / MainWindow.xaml.cs
|
|
||||||
├── ViewModels/
|
|
||||||
│ ├── ObservableObject.cs
|
|
||||||
│ ├── RelayCommand.cs
|
|
||||||
│ ├── MainViewModel.cs
|
|
||||||
│ ├── ParticipantViewModel.cs
|
|
||||||
│ ├── GlobalSettingsViewModel.cs
|
|
||||||
│ └── AlertBannerViewModel.cs
|
|
||||||
├── Converters/
|
|
||||||
│ ├── BoolToVisibilityConverter.cs
|
|
||||||
│ └── EnumDescriptionConverter.cs
|
|
||||||
└── TeamsISO.App.csproj
|
|
||||||
|
|
||||||
src/TeamsISO.Installer/
|
|
||||||
└── TeamsISO.Installer.wixproj (MSI installer; v5)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Tasks
|
|
||||||
|
|
||||||
1. **MVVM helpers** — `ObservableObject` base implementing `INotifyPropertyChanged`; `RelayCommand` and `AsyncRelayCommand`.
|
|
||||||
2. **GlobalSettingsViewModel** — exposes Framerate, Resolution, Aspect, Audio as bindable selected values; `Apply` command calls `controller.SetGlobalSettingsAsync`.
|
|
||||||
3. **ParticipantViewModel** — wraps a `Participant`, exposes IsEnabled, CustomOutputName, and current status; `EnableCommand` and `DisableCommand` call the controller.
|
|
||||||
4. **AlertBannerViewModel** — collects `EngineAlert`s and exposes the most recent one with a "dismiss" command.
|
|
||||||
5. **MainViewModel** — top-level. Owns the controller. Exposes `ObservableCollection<ParticipantViewModel>`, the settings VM, and the banner VM.
|
|
||||||
6. **MainWindow.xaml** — DataGrid for participants with toggle column, settings panel docked to the right, alert banner docked top.
|
|
||||||
7. **Converters** — bool→visibility, enum→display string.
|
|
||||||
8. **App.xaml.cs** — wires DI: build engine + controller + main view model, set MainWindow's DataContext, dispose on exit.
|
|
||||||
9. **WiX installer (Phase C-2)** — separate task; can ship after the UI is alive.
|
|
||||||
|
|
||||||
Each step ships as its own commit. Tag `phase-c-complete` after MainWindow renders and the controller is bound.
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
# Plan Backlog
|
|
||||||
|
|
||||||
## Completed phases
|
|
||||||
|
|
||||||
- **Phase A — Engine Foundation** (tag: `phase-a-complete`) — domain model, parsers, participant tracker, frame processor, config, fakes, CI gate.
|
|
||||||
- **Phase B-1 — Pipeline Orchestration** (tag: `phase-b-1-complete`) — NdiReceiver, NdiSender, ExponentialBackoff, NdiRuntimeProbe, IsoPipeline supervisor, IsoController.
|
|
||||||
- **Phase B-2 — Real NDI Interop** (tag: `phase-b-2-complete`) — `NdiInteropPInvoke` against NDI 6 SDK, managed BGRA scaler, `TeamsISO.Console` headless smoke runner, `NdiVersion` constants.
|
|
||||||
- **Phase C — WPF UI** (tag: `phase-c-complete`) — MVVM helpers, ParticipantViewModel, GlobalSettingsViewModel, AlertBannerViewModel, MainViewModel, MainWindow XAML with participants DataGrid + settings sidebar + alert banner, App.xaml DI bootstrap.
|
|
||||||
|
|
||||||
## Next (Windows-only)
|
|
||||||
|
|
||||||
1. **First end-to-end validation on Windows** — install NDI Runtime, clone the repo, build the full solution, run the integration tests against an NDI Test Pattern source, run the WPF app and validate against a real Teams meeting. Fix any issues found.
|
|
||||||
|
|
||||||
2. **Phase D — WiX Installer & Release** (Windows) — WiX v5 MSI installer detecting NDI Runtime, release pipeline triggering on tag push, code-signing decision implemented.
|
|
||||||
|
|
||||||
3. **Optional polish before v1.0** — system health indicators (CPU/GPU/network meters), per-stream framerate display, output thumbnail previews (deferred from v1.5 if useful), MaterialDesignThemes UI polish.
|
|
||||||
|
|
@ -1,213 +0,0 @@
|
||||||
# TeamsISO v1.0 — Implementation Spec
|
|
||||||
|
|
||||||
**Status:** Draft, ready for plan-writing
|
|
||||||
**Date:** 2026-05-07
|
|
||||||
**Owner:** Zac Gaetano (Wild Dragon LLC)
|
|
||||||
**Source design doc:** `TeamsISO Design Document.docx` v0.1 DRAFT (May 2026)
|
|
||||||
|
|
||||||
This spec turns the source design document into an implementable plan for the v1.0 release. The product vision, problem statement, and feature matrix in the source document remain authoritative; this spec adds the architectural and operational decisions needed to start building.
|
|
||||||
|
|
||||||
## 1. Scope
|
|
||||||
|
|
||||||
v1.0 ships the feature set in §6 of the source document, exactly as written:
|
|
||||||
|
|
||||||
- NDI participant discovery (auto)
|
|
||||||
- Per-participant ISO NDI output
|
|
||||||
- Global framerate lock (23.976 / 24 / 25 / 29.97 / 30 / 50 / 59.94 / 60 fps)
|
|
||||||
- Global resolution normalize (720p / 1080p / 4K)
|
|
||||||
- Custom output stream naming
|
|
||||||
- Isolated audio per ISO with mixed-audio fallback
|
|
||||||
- Screen share as ISO output
|
|
||||||
|
|
||||||
Deferred to v1.5: per-stream framerate override, thumbnail previews, GPU-accelerated scaling.
|
|
||||||
Deferred to v2.0: multi-machine cluster coordination, OSC/WebSocket control API.
|
|
||||||
|
|
||||||
Out of scope for v1.0: automatic peer discovery between TeamsISO instances, audio resampling, code signing of the installer.
|
|
||||||
|
|
||||||
## 2. Architecture
|
|
||||||
|
|
||||||
**Pattern:** engine/UI separation from day one. The NDI engine is a class library with no UI dependency; the WPF app is a thin host that binds to the engine through a typed C# API. v2.0's control APIs and multi-machine coordinator drop in cleanly because the boundary already exists.
|
|
||||||
|
|
||||||
**Solution layout:**
|
|
||||||
|
|
||||||
- `TeamsISO.Engine` — class library. Discovery, receive, frame processing, send, configuration, logging abstraction. Exposes `IIsoController` and observable streams. Owns all threading.
|
|
||||||
- `TeamsISO.Engine.NdiInterop` — internal P/Invoke shim for `NDIlib_*` and libyuv. Kept separate so the rest of the engine speaks managed types and unit tests can fake the interop surface.
|
|
||||||
- `TeamsISO.App` — WPF + MVVM host. Instantiates the engine, binds view models to engine observables, persists window layout. Zero NDI knowledge.
|
|
||||||
- `TeamsISO.Engine.Tests` — xUnit unit tests against `FakeNdiInterop`. Pure managed.
|
|
||||||
- `TeamsISO.Engine.IntegrationTests` — xUnit integration tests against the real NDI runtime. Tagged `[Trait("requires", "ndi")]`.
|
|
||||||
- `TeamsISO.Installer` — WiX v5 project producing the MSI.
|
|
||||||
|
|
||||||
**Engine ↔ App contract:** `IIsoController` exposes `IObservable<IReadOnlyList<Participant>>`, `IObservable<IsoHealthStats>` per output, `IObservable<EngineAlert>`, and async command methods (`EnableIsoAsync`, `SetTargetFramerate`, `SetCustomName`, `SetGlobalSettings`, etc.). All commands are cancellable.
|
|
||||||
|
|
||||||
## 3. Domain model
|
|
||||||
|
|
||||||
Defined in `TeamsISO.Engine.Domain`. All types are immutable records unless noted.
|
|
||||||
|
|
||||||
- **`NdiSource`** — raw discovery record. `string FullName`, parsed `MachineName`, `Kind` (`Participant | ActiveSpeaker | Audio | ScreenShare`), `DisplayName` (null for non-participant kinds).
|
|
||||||
- **`Participant`** — operator-facing identity. `Guid Id` (engine-assigned, stable across rename heuristic), `string DisplayName` (last seen), `NdiSource? CurrentSource`, `DateTimeOffset FirstSeen / LastSeen`. Mutable via the engine; observable.
|
|
||||||
- **`IsoAssignment`** — operator's intent. `Guid ParticipantId`, `bool IsEnabled`, `string? CustomOutputName`. Persisted to `config.json`. Reserves room for v1.5 per-stream overrides.
|
|
||||||
- **`IsoOutput`** — runtime state. `Guid ParticipantId`, `string EffectiveOutputName`, `IsoHealthStats Stats`, `IsoState State` (`Idle | Receiving | Sending | NoSignal | Error`).
|
|
||||||
- **`FrameProcessingSettings`** — `TargetFramerate`, `TargetResolution`, `AspectMode` (`Pillarbox | Letterbox | Stretch`), `AudioMode` (`Isolated | Mixed | Auto`).
|
|
||||||
- **`IsoHealthStats`** — `FramesIn`, `FramesOut`, `FramesDropped`, `FramesDuplicated`, `LastFrameAt`, `IncomingFps`, `IncomingResolution`.
|
|
||||||
- **`EngineConfig`** — root persisted record: `FrameProcessingSettings Global`, `IReadOnlyList<IsoAssignment> Assignments`. Stored at `%APPDATA%\TeamsISO\config.json`.
|
|
||||||
- **`EngineAlert`** — discriminated union: `NdiRuntimeMismatch | OutputNameCollision | PipelineError | ConfigSaveFailed`.
|
|
||||||
|
|
||||||
**Participant identity across rename / disconnect.** Teams source strings change when a participant renames. Engine policy: if a source disappears and within 5 seconds a new participant source with the same `MachineName` appears, the engine transfers the existing `Participant.Id` (and any `IsoAssignment` bound to it) to the new source. The UI shows a brief rename toast. Operators can opt out per-meeting in settings.
|
|
||||||
|
|
||||||
## 4. Components
|
|
||||||
|
|
||||||
Eight subsystems inside `TeamsISO.Engine`. Each has one responsibility.
|
|
||||||
|
|
||||||
**`NdiDiscoveryService`** — owns one `NDIlib_find_create_v2` instance on a long-running background thread. Polls every ~500 ms, diffs the source list, classifies each source, pushes `DiscoveryEvent` (`Added | Removed | Renamed`) onto a `Channel<DiscoveryEvent>`.
|
|
||||||
|
|
||||||
**`ParticipantTracker`** — consumes `DiscoveryEvent`s, applies the rename heuristic, maintains the canonical `IObservable<IReadOnlyList<Participant>>`. Stateful, pure-managed, unit-testable without NDI.
|
|
||||||
|
|
||||||
**`IsoPipeline`** — per-ISO unit. Owns one receiver, one frame processor, one sender, all health stats. Lifecycle methods `Start`, `Stop`. Created by `IsoPipelineFactory` when the operator enables an ISO.
|
|
||||||
|
|
||||||
**`NdiReceiver`** — wraps `NDIlib_recv_create_v3`. Dedicated thread loops on `NDIlib_recv_capture_v3`. Pushes captured frames into a bounded `Channel<RawFrame>` (capacity 4, drop-oldest under backpressure). Records dropped-frame count.
|
|
||||||
|
|
||||||
**`FrameProcessor`** — driven by `PeriodicTimer` at the target framerate. At each tick: read newest frame from the channel non-blocking; if available, scale via libyuv to target resolution + aspect mode, recalculate timecodes, hand to sender; if unavailable, re-emit `lastFrame`; if `lastFrame` is older than 2.5 s, emit a no-signal slate (`SolidFrameRenderer`, mid-grey).
|
|
||||||
|
|
||||||
**`NdiSender`** — wraps `NDIlib_send_create`. Dedicated thread sends video on its tick and audio passthrough on its own queue. Audio mode `Auto` probes for isolated audio at startup and falls back to mixed if unavailable.
|
|
||||||
|
|
||||||
**`IsoController`** — top of engine. Holds the pipeline dictionary, the `ParticipantTracker`, the `ConfigStore`. Exposes the `IIsoController` API. Translates "operator enabled this participant" into pipeline creation and start.
|
|
||||||
|
|
||||||
**`ConfigStore`** — load/save `EngineConfig` to `%APPDATA%\TeamsISO\config.json`. Atomic writes via temp file + rename.
|
|
||||||
|
|
||||||
**Logging:** Serilog file sink at `%APPDATA%\TeamsISO\logs\teamsiso-{Date}.log`, 14-day retention, structured. Engine code logs through `ILogger<T>` from `Microsoft.Extensions.Logging`.
|
|
||||||
|
|
||||||
## 5. Data flow and threading
|
|
||||||
|
|
||||||
Per ISO:
|
|
||||||
|
|
||||||
```
|
|
||||||
NDI source on LAN
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
[Capture thread] (1 dedicated thread)
|
|
||||||
NDIlib_recv_capture_v3, blocking loop
|
|
||||||
│
|
|
||||||
▼ Channel<RawFrame> (capacity 4, drop-oldest)
|
|
||||||
│
|
|
||||||
[Processor tick] (PeriodicTimer on ThreadPool, target framerate)
|
|
||||||
pick newest frame → libyuv scale/aspect → retimecode
|
|
||||||
│
|
|
||||||
▼ ProcessedFrame
|
|
||||||
│
|
|
||||||
[Send thread] (1 dedicated thread)
|
|
||||||
NDIlib_send_send_video_v2 + audio
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
ISO output on LAN
|
|
||||||
```
|
|
||||||
|
|
||||||
System-wide threads at 3 active ISOs: 3 capture + 3 send (dedicated, blocking-friendly), 1 discovery, 1 participant-tracker async loop on ThreadPool, 1 UI dispatcher, processor work on ThreadPool. Approximately 9 dedicated threads plus ThreadPool work — within budget for the recommended hardware.
|
|
||||||
|
|
||||||
**Why dedicated threads for capture and send:** NDI capture and send calls block. Mixing them onto the .NET ThreadPool risks starving worker threads. Processing is short-lived per frame and fits the ThreadPool model.
|
|
||||||
|
|
||||||
**Frame timing strategy (closest-frame):** simple, deterministic, works across all supported framerates without interpolation. Frame duplication = re-send `lastFrame`. After 2.5 s of no incoming frames, slate.
|
|
||||||
|
|
||||||
**Audio:** v1.0 forwards audio passthrough on its own NDI queue, no resampling. Isolated audio is forwarded as-is when available; mixed audio is forwarded on the active-speaker stream only as fallback.
|
|
||||||
|
|
||||||
**Cancellation:** every loop respects a per-ISO `CancellationToken`. Stopping an ISO triggers cancellation, joins capture and send threads (1 s timeout), disposes NDI handles.
|
|
||||||
|
|
||||||
## 6. Error handling and recovery
|
|
||||||
|
|
||||||
**Pipeline isolation.** Each `IsoPipeline` runs independently. One pipeline failing never affects others.
|
|
||||||
|
|
||||||
**Per-pipeline failure recovery.** Unhandled exception → pipeline transitions to `Error`, releases NDI handles, logs with full context, auto-restarts after 1 s. Exponential backoff: 1, 2, 4, 8, 16 s, capped at 30 s. After 5 consecutive failures, stays `Error` and waits for operator action. Participant remains visible in the UI list so the operator can re-enable manually.
|
|
||||||
|
|
||||||
**Source disconnect (expected, not error).** Pipeline transitions to `NoSignal` after 2.5 s, keeps the assignment bound, keeps emitting the slate. If the source returns within 60 s, reconnects automatically. After 60 s the pipeline stops the sender to free NDI bandwidth; reconnects when the source reappears.
|
|
||||||
|
|
||||||
**NDI runtime version mismatch.** Detected at startup by `NdiRuntimeProbe`. Surfaces `EngineAlert.NdiRuntimeMismatch`. UI shows a banner with instructions to re-download Teams' NDI binaries (per source doc §7.2). Engine still attempts to run — it's a warning, not a hard fail.
|
|
||||||
|
|
||||||
**Output name collision on the LAN.** Logged and surfaced as `EngineAlert.OutputNameCollision`. v1.0 does not auto-rename; the operator picks unique names.
|
|
||||||
|
|
||||||
**Startup preflight.** Run before the UI accepts commands:
|
|
||||||
|
|
||||||
- NDI runtime present and queryable
|
|
||||||
- Smoke test: create + destroy one `NDIlib_send_create` instance
|
|
||||||
- Config file readable; corrupt or missing → fall back to defaults and log
|
|
||||||
- libyuv DLL loadable
|
|
||||||
- Write access to `%APPDATA%\TeamsISO\`
|
|
||||||
|
|
||||||
A failing preflight surfaces a single error dialog with a copyable diagnostic string; the app does not enter the main UI.
|
|
||||||
|
|
||||||
**Engine alert channel.** `IObservable<EngineAlert>` exposes structured alerts to the UI for banner display and to the log for ops.
|
|
||||||
|
|
||||||
## 7. Testing
|
|
||||||
|
|
||||||
**Three layers, three test projects.**
|
|
||||||
|
|
||||||
**Unit (`TeamsISO.Engine.Tests`)** — pure managed, no NDI runtime, fast (<1 s). Covers:
|
|
||||||
|
|
||||||
- `ParticipantTracker` rename heuristic (synthetic event streams).
|
|
||||||
- `FrameProcessor` timing logic against fake clock and fake interop. Asserts: 30 fps target / 24 fps incoming yields 30 frames/s with appropriate duplication; 60 fps target / 30 fps incoming doubles each frame; 2.5 s of silence triggers slate.
|
|
||||||
- `IsoPipeline` lifecycle (start → run → stop → restart on simulated fault, with backoff schedule asserted).
|
|
||||||
- `ConfigStore` round-trip (missing → defaults; save → reload identical; corrupt JSON → defaults + log).
|
|
||||||
- `NdiSourceParser` against a corpus of real Teams source strings (participant, active speaker, audio, screen share, multi-word names with parens, unicode).
|
|
||||||
|
|
||||||
**Integration (`TeamsISO.Engine.IntegrationTests`)** — Windows-only, real NDI runtime. Tagged `[Trait("requires", "ndi")]`.
|
|
||||||
|
|
||||||
- Spin up a NewTek NDI Test Pattern source as a synthetic participant; route through `IsoPipeline`; receive on a second NDI receiver; assert output stream existence, naming, framerate (measured over 5 s), resolution.
|
|
||||||
- Source disappear / reappear: stop the test pattern source mid-stream, assert pipeline transitions through `NoSignal`, restart the source, assert pipeline resumes.
|
|
||||||
- Output name collision: spin two pipelines with the same name, assert `EngineAlert.OutputNameCollision`.
|
|
||||||
|
|
||||||
**Manual / live test playbook (`docs/test-playbook.md`)** — checklist for verifying against real Teams meetings before each release.
|
|
||||||
|
|
||||||
**TDD discipline.** Every behavior in the engine starts as a failing unit test against fakes. NDI interop has an `INdiInterop` interface; production wires `NdiInteropPInvoke`, tests wire `FakeNdiInterop`.
|
|
||||||
|
|
||||||
**Coverage target.** 80% line coverage on `TeamsISO.Engine`, excluding the P/Invoke shim. Enforced in CI.
|
|
||||||
|
|
||||||
## 8. Build, packaging, distribution
|
|
||||||
|
|
||||||
**Source repo.** `forge.wilddragon.net/zgaetano/teamsiso`. Default branch `main`. Trunk-based with feature branches; PR review for engine-touching changes.
|
|
||||||
|
|
||||||
**Build.** MSBuild via `dotnet build` and `dotnet publish`. Solution targets `net8.0-windows` with `TargetPlatformVersion=10.0.19041.0`. `TeamsISO.App` publishes self-contained, single-file, ReadyToRun:
|
|
||||||
|
|
||||||
```
|
|
||||||
dotnet publish -c Release -r win-x64 --self-contained -p:PublishSingleFile=true -p:PublishReadyToRun=true
|
|
||||||
```
|
|
||||||
|
|
||||||
**CI.** Forgejo Actions (GitHub-Actions-compatible). Two pipelines:
|
|
||||||
|
|
||||||
- `ci.yml` — every push and PR. Builds, runs unit tests, enforces coverage threshold, lints (treat-warnings-as-errors). Linux runner. Integration tests skip cleanly because `requires=ndi` is absent.
|
|
||||||
- `release.yml` — on tag push (`v*`). Windows runner with NDI runtime preinstalled. Builds release, runs unit + integration, builds WiX installer, attaches `.msi` to a Forgejo release.
|
|
||||||
|
|
||||||
**Versioning.** SemVer in `Directory.Build.props`. Flows to assembly metadata and installer. Tag `v1.0.0` triggers the release pipeline.
|
|
||||||
|
|
||||||
**Installer (WiX v5).** Produces `TeamsISO-x.y.z.msi`. Behavior:
|
|
||||||
|
|
||||||
- Detects NDI runtime via registry probe; if absent or older, prompts the operator to download from `ndi.video/tools/`. The runtime is not bundled — NDI's redistribution license requires user consent.
|
|
||||||
- Installs to `%ProgramFiles%\TeamsISO\`.
|
|
||||||
- Creates Start Menu shortcut, optional desktop shortcut.
|
|
||||||
- `%APPDATA%\TeamsISO\` is created on first run, not at install (per-user data, per-machine MSI).
|
|
||||||
- Adds Add/Remove Programs entry.
|
|
||||||
|
|
||||||
**NDI redistribution.** Per NDI SDK License v5 the runtime is not bundled. Detection is by registry key. Mismatches show a dialog with the official download link. Captured open task: legal review of NDI SDK License v5 before public v1.0 release.
|
|
||||||
|
|
||||||
**Distribution.** v1.0 ships as MSI from Forgejo releases. No auto-update in v1.0. The About dialog shows the current version and links to the Forgejo releases page.
|
|
||||||
|
|
||||||
## 9. Open tasks blocking v1.0 release
|
|
||||||
|
|
||||||
- Legal review of NDI SDK License v5 (per source doc §7.3) — required before public release; not required for development.
|
|
||||||
- Confirmation that the Microsoft Teams tenant has the admin policy enabling NDI broadcast (the relevant Teams meeting-policy setting; current name varies by Teams admin center version — verified against the live tenant during development).
|
|
||||||
- Selection of code-signing approach for v1.0 vs. v1.5 (currently deferred).
|
|
||||||
|
|
||||||
## 10. Out of scope for v1.0 (deferred)
|
|
||||||
|
|
||||||
- Per-stream framerate override (v1.5)
|
|
||||||
- Thumbnail previews (v1.5)
|
|
||||||
- GPU-accelerated frame scaling (v1.5)
|
|
||||||
- Multi-machine cluster auto-coordination (v2.0)
|
|
||||||
- OSC / WebSocket control API (v2.0)
|
|
||||||
- Code signing of the installer
|
|
||||||
- Auto-update
|
|
||||||
- Audio resampling
|
|
||||||
|
|
||||||
## 11. Glossary
|
|
||||||
|
|
||||||
- **NDI** — Network Device Interface (Vizrt/NewTek). LAN video transport protocol used by Teams' broadcast mode.
|
|
||||||
- **ISO** — In live production, an "isolated" feed of a single source, separate from the program mix. ZoomISO and TeamsISO produce per-participant ISO feeds.
|
|
||||||
- **Active speaker** — Teams' auto-mixed feed that follows whoever is talking. A separate NDI source from individual participant streams.
|
|
||||||
- **Slate** — a static frame (typically a solid color or "no signal" graphic) emitted when the source has stopped delivering frames.
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
# TeamsISO Manual Test Playbook
|
|
||||||
|
|
||||||
## Phase A — Engine foundation (CI)
|
|
||||||
|
|
||||||
- [ ] `dotnet build TeamsISO.Linux.slnf` succeeds with zero warnings.
|
|
||||||
- [ ] `dotnet test TeamsISO.Linux.slnf --filter "Category!=ndi&requires!=ndi"` passes.
|
|
||||||
- [ ] CI on Forgejo Actions is green at HEAD.
|
|
||||||
- [ ] Code coverage on `TeamsISO.Engine` is ≥80%.
|
|
||||||
|
|
||||||
## First Windows validation (after Phase B-2 ships)
|
|
||||||
|
|
||||||
Prerequisite: Windows 10/11 + NDI Runtime installed (https://ndi.video/tools/) + .NET 8 SDK.
|
|
||||||
|
|
||||||
- [ ] Clone the repo on the Windows machine: `git clone https://forge.wilddragon.net/zgaetano/teamsiso.git`.
|
|
||||||
- [ ] `dotnet build TeamsISO.sln --configuration Release` succeeds.
|
|
||||||
- [ ] `dotnet test --filter "requires=ndi"` passes against an NDI Test Pattern source (start the test pattern from the NDI Tools menu before running).
|
|
||||||
- [ ] Run `dotnet run --project src/TeamsISO.Console` — confirm the engine starts, version probe matches, and Ctrl+C exits cleanly.
|
|
||||||
|
|
||||||
## Live-meeting validation (after Phase C ships)
|
|
||||||
|
|
||||||
- [ ] Configure a Teams meeting with 3+ participants, with NDI broadcast enabled in Teams.
|
|
||||||
- [ ] `dotnet run --project src/TeamsISO.App` launches the WPF UI without an NDI runtime warning banner.
|
|
||||||
- [ ] Participants list populates within ~2 seconds of opening the app.
|
|
||||||
- [ ] Participant rename mid-meeting transfers the row's identity (the rename heuristic).
|
|
||||||
- [ ] Toggle ISO on for one participant. Confirm the named output appears in vMix / OBS / Studio Monitor on the same LAN.
|
|
||||||
- [ ] Change global framerate to 59.94 fps; click Apply. New ISOs honor the new rate.
|
|
||||||
- [ ] Disconnect one participant; confirm their ISO transitions to the no-signal slate within 2.5 s.
|
|
||||||
- [ ] Run for 30 minutes; check FramesDropped / FramesDuplicated counters in the engine log are reasonable.
|
|
||||||
|
|
||||||
## Pre-release checklist
|
|
||||||
|
|
||||||
- [ ] Legal review of NDI SDK License v5 complete (per spec §7.3).
|
|
||||||
- [ ] Code-signing decision confirmed (yes/no for v1.0).
|
|
||||||
- [ ] WiX installer produces a working MSI on a clean Windows machine.
|
|
||||||
198
installer/Package.wxs
Normal file
198
installer/Package.wxs
Normal file
|
|
@ -0,0 +1,198 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!--
|
||||||
|
TeamsISO — MSI installer (WiX v5)
|
||||||
|
|
||||||
|
Produces: TeamsISO-Setup-<Version>.msi (per-machine install).
|
||||||
|
|
||||||
|
Build:
|
||||||
|
dotnet publish src/TeamsISO.App/TeamsISO.App.csproj -c Release -r win-x64 (no self contained) -o publish/TeamsISO
|
||||||
|
dotnet build installer/TeamsISO.Installer.wixproj -c Release
|
||||||
|
|
||||||
|
Runtime expectations:
|
||||||
|
- .NET 8 Desktop runtime present on target (framework-dependent build)
|
||||||
|
- NDI 6 Runtime present — checked in CheckNdiRuntime; absence WARNS
|
||||||
|
but does not block install (operators can install NDI after the app)
|
||||||
|
-->
|
||||||
|
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs"
|
||||||
|
xmlns:ui="http://wixtoolset.org/schemas/v4/wxs/ui">
|
||||||
|
|
||||||
|
<Package Name="TeamsISO"
|
||||||
|
Manufacturer="Wild Dragon LLC"
|
||||||
|
Version="1.0.0.0"
|
||||||
|
UpgradeCode="9F4A8B2C-1D3E-4A5B-9C6D-8E7F0A1B2C3D"
|
||||||
|
Scope="perMachine"
|
||||||
|
Compressed="yes"
|
||||||
|
InstallerVersion="500">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
SummaryInformation fields surface in File Explorer's "Details" tab and
|
||||||
|
in the Windows Installer "About" dialog. Description and Keywords are
|
||||||
|
what users see if they right-click the MSI before installing; Comments
|
||||||
|
is the longer copy that appears alongside the version in some
|
||||||
|
installer dialogs.
|
||||||
|
-->
|
||||||
|
<SummaryInformation
|
||||||
|
Description="TeamsISO — per-participant NDI ISO controller for Microsoft Teams. Splits each Teams participant into a normalized NDI source for vMix / OBS / Ross / hardware switchers."
|
||||||
|
Manufacturer="Wild Dragon LLC"
|
||||||
|
Keywords="NDI, Microsoft Teams, ISO recording, broadcast, live production, vMix, OBS, switcher, Wild Dragon" />
|
||||||
|
|
||||||
|
<!--
|
||||||
|
MajorUpgrade: a newer install replaces an older one in-place. We
|
||||||
|
disallow downgrades because the engine config schema only carries a
|
||||||
|
forward-migration path; downgrading would leave operators with a
|
||||||
|
config the older binary doesn't understand.
|
||||||
|
-->
|
||||||
|
<MajorUpgrade DowngradeErrorMessage="A newer version of TeamsISO is already installed. Uninstall it before installing this older version."
|
||||||
|
Schedule="afterInstallInitialize" />
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Single MSI feature; users see only the install/uninstall screens.
|
||||||
|
-->
|
||||||
|
<Feature Id="Main" Title="TeamsISO" Level="1">
|
||||||
|
<ComponentGroupRef Id="ApplicationFiles" />
|
||||||
|
<ComponentGroupRef Id="Shortcuts" />
|
||||||
|
<ComponentGroupRef Id="DesktopShortcut" />
|
||||||
|
<ComponentGroupRef Id="ArpEntry" />
|
||||||
|
</Feature>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Friendly install UI. WixToolset.UI.wixext provides several flavors;
|
||||||
|
WixUI_InstallDir lets the user pick the directory.
|
||||||
|
-->
|
||||||
|
<ui:WixUI Id="WixUI_InstallDir" />
|
||||||
|
<Property Id="WIXUI_INSTALLDIR" Value="INSTALLFOLDER" />
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Add/Remove Programs metadata. ARPHELPLINK is the "Help" link; ARPURLINFOABOUT
|
||||||
|
is the manufacturer/about link; ARPCONTACT is the support contact shown
|
||||||
|
when the user clicks "Support information" from the ARP entry. ARPCOMMENTS
|
||||||
|
is the long description displayed in some Settings → Apps surfaces.
|
||||||
|
-->
|
||||||
|
<Property Id="ARPHELPLINK" Value="https://forge.wilddragon.net/zgaetano/teamsiso" />
|
||||||
|
<Property Id="ARPURLINFOABOUT" Value="https://wilddragon.net" />
|
||||||
|
<Property Id="ARPCONTACT" Value="Wild Dragon LLC — support@wilddragon.net" />
|
||||||
|
<Property Id="ARPCOMMENTS" Value="TeamsISO turns Microsoft Teams' raw NDI broadcast into clean, normalized, per-participant NDI sources for ingestion by a live-production switcher (vMix, OBS, Ross, hardware capture). Each participant gets an individually-addressable source with configurable framerate, resolution, aspect mode, and audio routing." />
|
||||||
|
<!-- ARPNOMODIFY is set by WixUI_InstallDir; don't redeclare. -->
|
||||||
|
<Property Id="ARPNOREPAIR" Value="1" />
|
||||||
|
|
||||||
|
<!--
|
||||||
|
ARP icon — references the same .ico the WPF host uses. WiX requires the
|
||||||
|
icon resource to live next to the wxs OR be reachable at build time;
|
||||||
|
we point at the published copy under src/TeamsISO.App/Assets so the icon
|
||||||
|
embedded in the MSI matches the icon in the running exe.
|
||||||
|
-->
|
||||||
|
<Icon Id="TeamsISOIcon" SourceFile="$(var.AssetsDir)teamsiso.ico" />
|
||||||
|
<Property Id="ARPPRODUCTICON" Value="TeamsISOIcon" />
|
||||||
|
|
||||||
|
<!--
|
||||||
|
NDI Runtime detection. We check for NDI_RUNTIME_DIR_V6 in the system
|
||||||
|
environment block. Missing → warn during install, don't block. The
|
||||||
|
engine surfaces a clear MessageBox with an install-NDI link at first
|
||||||
|
launch if the runtime really isn't there.
|
||||||
|
-->
|
||||||
|
<Property Id="NDIRUNTIMEDIR" Value="0">
|
||||||
|
<RegistrySearch Id="NdiRuntimeDirV6Search"
|
||||||
|
Root="HKLM"
|
||||||
|
Key="SYSTEM\CurrentControlSet\Control\Session Manager\Environment"
|
||||||
|
Name="NDI_RUNTIME_DIR_V6"
|
||||||
|
Type="raw" />
|
||||||
|
</Property>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
NDI runtime detection is surfaced at first app launch (App.xaml.cs pops a
|
||||||
|
MessageBox with an install link). We deliberately don't block install on
|
||||||
|
a missing runtime so admins can stage the app before NDI is rolled out.
|
||||||
|
VBScript-based install-time prompts are deprecated in WiX v5 / Windows
|
||||||
|
and rewriting in C++ is overkill for a soft warning.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Install layout under Program Files\Wild Dragon\TeamsISO.
|
||||||
|
-->
|
||||||
|
<StandardDirectory Id="ProgramFiles64Folder">
|
||||||
|
<Directory Id="ManufacturerFolder" Name="Wild Dragon">
|
||||||
|
<Directory Id="INSTALLFOLDER" Name="TeamsISO" />
|
||||||
|
</Directory>
|
||||||
|
</StandardDirectory>
|
||||||
|
|
||||||
|
<StandardDirectory Id="ProgramMenuFolder">
|
||||||
|
<Directory Id="WildDragonStartMenuFolder" Name="Wild Dragon" />
|
||||||
|
</StandardDirectory>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Files: harvested from the publish output dir at build time.
|
||||||
|
WiX v5 understands <Files Include="..."> with glob patterns and
|
||||||
|
synthesizes one Component per file with stable GUIDs.
|
||||||
|
-->
|
||||||
|
<ComponentGroup Id="ApplicationFiles" Directory="INSTALLFOLDER">
|
||||||
|
<Files Include="$(var.PublishDir)**" />
|
||||||
|
</ComponentGroup>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Start Menu and Desktop shortcuts — direct .exe targets.
|
||||||
|
|
||||||
|
Don't wrap the Target in runas.exe /trustlevel:0x20000 (or anything
|
||||||
|
else that demotes the spawned process). The SAFER-restricted token
|
||||||
|
breaks .NET 8 WPF apphost startup: the process appears alive with
|
||||||
|
a window, but no managed code past BAML parse executes. Verified
|
||||||
|
empirically 2026-05-16 — letting TeamsISO inherit the launching
|
||||||
|
token (medium or high integrity, doesn't matter) is the correct
|
||||||
|
behavior. NDI discovery works fine at either integrity level.
|
||||||
|
-->
|
||||||
|
<ComponentGroup Id="Shortcuts" Directory="WildDragonStartMenuFolder">
|
||||||
|
<Component Id="StartMenuShortcut" Guid="*">
|
||||||
|
<Shortcut Id="StartMenuTeamsISO"
|
||||||
|
Name="TeamsISO"
|
||||||
|
Description="Per-Participant NDI ISO Controller for Microsoft Teams"
|
||||||
|
Target="[INSTALLFOLDER]TeamsISO.exe"
|
||||||
|
WorkingDirectory="INSTALLFOLDER"
|
||||||
|
Icon="TeamsISOIcon" />
|
||||||
|
<!-- Required by ICE64: Start Menu folder must be cleaned on uninstall. -->
|
||||||
|
<RemoveFolder Id="RemoveWildDragonStartMenuFolder"
|
||||||
|
Directory="WildDragonStartMenuFolder"
|
||||||
|
On="uninstall" />
|
||||||
|
<RegistryValue Root="HKCU"
|
||||||
|
Key="Software\Wild Dragon\TeamsISO"
|
||||||
|
Name="StartMenuShortcut"
|
||||||
|
Type="integer"
|
||||||
|
Value="1"
|
||||||
|
KeyPath="yes" />
|
||||||
|
</Component>
|
||||||
|
</ComponentGroup>
|
||||||
|
|
||||||
|
<StandardDirectory Id="DesktopFolder" />
|
||||||
|
<ComponentGroup Id="DesktopShortcut" Directory="DesktopFolder">
|
||||||
|
<Component Id="DesktopShortcutComponent" Guid="*">
|
||||||
|
<Shortcut Id="DesktopTeamsISO"
|
||||||
|
Name="TeamsISO"
|
||||||
|
Description="Per-Participant NDI ISO Controller for Microsoft Teams"
|
||||||
|
Target="[INSTALLFOLDER]TeamsISO.exe"
|
||||||
|
WorkingDirectory="INSTALLFOLDER"
|
||||||
|
Icon="TeamsISOIcon" />
|
||||||
|
<RegistryValue Root="HKCU"
|
||||||
|
Key="Software\Wild Dragon\TeamsISO"
|
||||||
|
Name="DesktopShortcut"
|
||||||
|
Type="integer"
|
||||||
|
Value="1"
|
||||||
|
KeyPath="yes" />
|
||||||
|
</Component>
|
||||||
|
</ComponentGroup>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
ARP icon registry entry. Optional — the MSI auto-fills most ARP
|
||||||
|
fields from the Package element. We only need to point at the
|
||||||
|
executable for the ARP icon.
|
||||||
|
-->
|
||||||
|
<ComponentGroup Id="ArpEntry" Directory="INSTALLFOLDER">
|
||||||
|
<Component Id="ArpIconRegistry" Guid="*">
|
||||||
|
<RegistryValue Root="HKLM"
|
||||||
|
Key="Software\Wild Dragon\TeamsISO"
|
||||||
|
Name="InstallPath"
|
||||||
|
Type="string"
|
||||||
|
Value="[INSTALLFOLDER]"
|
||||||
|
KeyPath="yes" />
|
||||||
|
</Component>
|
||||||
|
</ComponentGroup>
|
||||||
|
|
||||||
|
</Package>
|
||||||
|
</Wix>
|
||||||
35
installer/TeamsISO.Installer.wixproj
Normal file
35
installer/TeamsISO.Installer.wixproj
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
<Project Sdk="WixToolset.Sdk/5.0.2">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Package</OutputType>
|
||||||
|
<OutputName>TeamsISO-Setup-$(Version)</OutputName>
|
||||||
|
|
||||||
|
<!-- 64-bit MSI; suppresses ICE80 on components in Program Files (x64). -->
|
||||||
|
<Platform>x64</Platform>
|
||||||
|
<InstallerPlatform>x64</InstallerPlatform>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Built artifact location. The installer expects a published build of
|
||||||
|
TeamsISO.App rooted here. CI / local script:
|
||||||
|
dotnet publish src/TeamsISO.App/TeamsISO.App.csproj
|
||||||
|
-c Release -r win-x64 (with self contained false)
|
||||||
|
-o $(SolutionDir)publish/TeamsISO
|
||||||
|
-->
|
||||||
|
<PublishDir>$(MSBuildThisFileDirectory)..\publish\TeamsISO\</PublishDir>
|
||||||
|
|
||||||
|
<!-- Pass MSBuild values into WiX preprocessor. -->
|
||||||
|
<DefineConstants>PublishDir=$(PublishDir);AssetsDir=$(MSBuildThisFileDirectory)..\src\TeamsISO.App\Assets\</DefineConstants>
|
||||||
|
|
||||||
|
<!-- Code-signing hook (set externally for release builds; left empty for dev). -->
|
||||||
|
<SignOutput Condition=" '$(SignOutput)' == '' ">false</SignOutput>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Reference the WiX UI extension so the MSI shows a friendly progress UI
|
||||||
|
instead of the silent default.
|
||||||
|
-->
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="WixToolset.UI.wixext" Version="5.0.2" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
208
src/TeamsISO.App/AboutWindow.xaml
Normal file
208
src/TeamsISO.App/AboutWindow.xaml
Normal file
|
|
@ -0,0 +1,208 @@
|
||||||
|
<Window x:Class="TeamsISO.App.AboutWindow"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:shell="clr-namespace:System.Windows.Shell;assembly=PresentationFramework"
|
||||||
|
Title="About TeamsISO"
|
||||||
|
Icon="/Assets/teamsiso.ico"
|
||||||
|
Width="460" Height="500"
|
||||||
|
WindowStartupLocation="CenterOwner"
|
||||||
|
WindowStyle="None"
|
||||||
|
ResizeMode="NoResize"
|
||||||
|
Background="{DynamicResource Wd.Canvas}"
|
||||||
|
UseLayoutRounding="True"
|
||||||
|
TextOptions.TextFormattingMode="Ideal"
|
||||||
|
TextOptions.TextRenderingMode="ClearType">
|
||||||
|
|
||||||
|
<shell:WindowChrome.WindowChrome>
|
||||||
|
<shell:WindowChrome
|
||||||
|
CaptionHeight="32"
|
||||||
|
ResizeBorderThickness="0"
|
||||||
|
CornerRadius="0"
|
||||||
|
GlassFrameThickness="0"
|
||||||
|
UseAeroCaptionButtons="False"/>
|
||||||
|
</shell:WindowChrome.WindowChrome>
|
||||||
|
|
||||||
|
<Border BorderBrush="{DynamicResource Wd.Border}" BorderThickness="1">
|
||||||
|
<Grid>
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="*"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
|
<!-- Caption -->
|
||||||
|
<Grid Grid.Row="0">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<TextBlock Text="About TeamsISO"
|
||||||
|
Style="{StaticResource Wd.Text.Caption}"
|
||||||
|
Margin="20,12,0,0"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
<Button Grid.Column="1"
|
||||||
|
Style="{StaticResource Wd.Button.CaptionClose}"
|
||||||
|
Click="OnClose"
|
||||||
|
shell:WindowChrome.IsHitTestVisibleInChrome="True">
|
||||||
|
<Path Data="M 0,0 L 10,10 M 10,0 L 0,10"
|
||||||
|
Stroke="{DynamicResource Wd.Text.Primary}"
|
||||||
|
StrokeThickness="1.2"
|
||||||
|
Width="10" Height="10"
|
||||||
|
Stretch="None"/>
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!-- Body -->
|
||||||
|
<StackPanel Grid.Row="1"
|
||||||
|
Margin="32,16,32,16"
|
||||||
|
VerticalAlignment="Top">
|
||||||
|
<Image Source="/Assets/dragon-mark.png"
|
||||||
|
Width="80" Height="80"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
Margin="0,0,0,16"
|
||||||
|
RenderOptions.BitmapScalingMode="HighQuality"/>
|
||||||
|
|
||||||
|
<TextBlock Text="TeamsISO"
|
||||||
|
Style="{StaticResource Wd.Text.Title}"
|
||||||
|
FontSize="28"
|
||||||
|
HorizontalAlignment="Center"/>
|
||||||
|
|
||||||
|
<TextBlock Text="Per-participant NDI ISO Controller for Microsoft Teams"
|
||||||
|
Style="{StaticResource Wd.Text.Subtle}"
|
||||||
|
Foreground="{DynamicResource Wd.Text.Tertiary}"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
TextAlignment="Center"
|
||||||
|
Margin="0,4,0,0"
|
||||||
|
TextWrapping="Wrap"/>
|
||||||
|
|
||||||
|
<Border Style="{StaticResource Wd.Card}"
|
||||||
|
Margin="0,20,0,0"
|
||||||
|
Padding="16">
|
||||||
|
<Grid>
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
|
<TextBlock Grid.Row="0" Grid.Column="0" Text="Version"
|
||||||
|
Style="{StaticResource Wd.Text.Subtle}"
|
||||||
|
Foreground="{DynamicResource Wd.Text.Tertiary}"
|
||||||
|
Margin="0,0,16,4"/>
|
||||||
|
<TextBlock Grid.Row="0" Grid.Column="1"
|
||||||
|
x:Name="VersionText"
|
||||||
|
Style="{StaticResource Wd.Text.Mono}"
|
||||||
|
FontSize="11"
|
||||||
|
Foreground="{DynamicResource Wd.Text.Primary}"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
Margin="0,0,0,4"/>
|
||||||
|
|
||||||
|
<TextBlock Grid.Row="1" Grid.Column="0" Text=".NET"
|
||||||
|
Style="{StaticResource Wd.Text.Subtle}"
|
||||||
|
Foreground="{DynamicResource Wd.Text.Tertiary}"
|
||||||
|
Margin="0,0,16,4"/>
|
||||||
|
<TextBlock Grid.Row="1" Grid.Column="1"
|
||||||
|
x:Name="RuntimeText"
|
||||||
|
Style="{StaticResource Wd.Text.Mono}"
|
||||||
|
FontSize="11"
|
||||||
|
Foreground="{DynamicResource Wd.Text.Primary}"
|
||||||
|
Margin="0,0,0,4"/>
|
||||||
|
|
||||||
|
<TextBlock Grid.Row="2" Grid.Column="0" Text="OS"
|
||||||
|
Style="{StaticResource Wd.Text.Subtle}"
|
||||||
|
Foreground="{DynamicResource Wd.Text.Tertiary}"
|
||||||
|
Margin="0,0,16,4"/>
|
||||||
|
<TextBlock Grid.Row="2" Grid.Column="1"
|
||||||
|
x:Name="OsText"
|
||||||
|
Style="{StaticResource Wd.Text.Mono}"
|
||||||
|
FontSize="11"
|
||||||
|
Foreground="{DynamicResource Wd.Text.Primary}"
|
||||||
|
Margin="0,0,0,4"/>
|
||||||
|
|
||||||
|
<TextBlock Grid.Row="3" Grid.Column="0" Text="NDI runtime"
|
||||||
|
Style="{StaticResource Wd.Text.Subtle}"
|
||||||
|
Foreground="{DynamicResource Wd.Text.Tertiary}"
|
||||||
|
Margin="0,0,16,0"/>
|
||||||
|
<TextBlock Grid.Row="3" Grid.Column="1"
|
||||||
|
x:Name="NdiText"
|
||||||
|
Style="{StaticResource Wd.Text.Mono}"
|
||||||
|
FontSize="11"
|
||||||
|
Foreground="{DynamicResource Wd.Text.Primary}"
|
||||||
|
TextWrapping="Wrap"/>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- Quick-jump shortcuts to the data directories -->
|
||||||
|
<StackPanel Orientation="Horizontal"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
Margin="0,12,0,0">
|
||||||
|
<Button Style="{StaticResource Wd.Button.Ghost}"
|
||||||
|
Content="Logs"
|
||||||
|
Click="OnOpenLogs"
|
||||||
|
Padding="14,6"
|
||||||
|
Margin="0,0,8,0"
|
||||||
|
ToolTip="Open %LOCALAPPDATA%\TeamsISO\Logs in Explorer"/>
|
||||||
|
<Button Style="{StaticResource Wd.Button.Ghost}"
|
||||||
|
Content="Notes"
|
||||||
|
Click="OnOpenNotes"
|
||||||
|
Padding="14,6"
|
||||||
|
ToolTip="Open %LOCALAPPDATA%\TeamsISO\Notes in Explorer"/>
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<Border Grid.Row="2"
|
||||||
|
BorderBrush="{DynamicResource Wd.Border}"
|
||||||
|
BorderThickness="0,1,0,0"
|
||||||
|
Padding="20,12">
|
||||||
|
<Grid>
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<TextBlock Style="{StaticResource Wd.Text.Mono}"
|
||||||
|
Foreground="{DynamicResource Wd.Text.Tertiary}"
|
||||||
|
VerticalAlignment="Center">
|
||||||
|
<Hyperlink x:Name="WebsiteLink"
|
||||||
|
Foreground="{DynamicResource Wd.Accent.Cyan}"
|
||||||
|
TextDecorations="None"
|
||||||
|
Click="OnWebsiteClick">
|
||||||
|
wilddragon.net
|
||||||
|
</Hyperlink>
|
||||||
|
<Run Text=" · © Wild Dragon LLC"/>
|
||||||
|
</TextBlock>
|
||||||
|
<StackPanel Grid.Column="1" Orientation="Horizontal">
|
||||||
|
<Button Style="{StaticResource Wd.Button.Ghost}"
|
||||||
|
Content="Export diagnostics"
|
||||||
|
Click="OnExportDiagnostics"
|
||||||
|
MinWidth="150"
|
||||||
|
Margin="0,0,8,0"
|
||||||
|
ToolTip="Bundle logs + config + presets into a zip in your Downloads folder. Attach the zip to a bug report."/>
|
||||||
|
<Button Style="{StaticResource Wd.Button.Ghost}"
|
||||||
|
Content="Check for updates"
|
||||||
|
Click="OnCheckUpdate"
|
||||||
|
x:Name="UpdateButton"
|
||||||
|
MinWidth="140"
|
||||||
|
Margin="0,0,8,0"
|
||||||
|
ToolTip="Ask forge.wilddragon.net whether a newer release tag exists than the one you're running."/>
|
||||||
|
<Button Style="{StaticResource Wd.Button.Ghost}"
|
||||||
|
Content="Show welcome"
|
||||||
|
Click="OnShowOnboarding"
|
||||||
|
MinWidth="120"
|
||||||
|
Margin="0,0,8,0"
|
||||||
|
ToolTip="Re-open the first-launch welcome dialog with the setup checklist."/>
|
||||||
|
<Button Style="{StaticResource Wd.Button.Ghost}"
|
||||||
|
Content="Close"
|
||||||
|
Click="OnClose"
|
||||||
|
MinWidth="80"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</Window>
|
||||||
214
src/TeamsISO.App/AboutWindow.xaml.cs
Normal file
214
src/TeamsISO.App/AboutWindow.xaml.cs
Normal file
|
|
@ -0,0 +1,214 @@
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.IO;
|
||||||
|
using System.Reflection;
|
||||||
|
using System.Runtime.Versioning;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Navigation;
|
||||||
|
using TeamsISO.App.Services;
|
||||||
|
using TeamsISO.Engine.NdiInterop;
|
||||||
|
|
||||||
|
namespace TeamsISO.App;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Modal "About" dialog. Surfaces enough info that a user filing a support ticket
|
||||||
|
/// can paste version + NDI runtime + OS in a single screenshot.
|
||||||
|
/// </summary>
|
||||||
|
public partial class AboutWindow : Window
|
||||||
|
{
|
||||||
|
public AboutWindow()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
PopulateText();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void PopulateText()
|
||||||
|
{
|
||||||
|
var asm = typeof(App).Assembly;
|
||||||
|
var info = asm.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion
|
||||||
|
?? asm.GetName().Version?.ToString()
|
||||||
|
?? "unknown";
|
||||||
|
VersionText.Text = info;
|
||||||
|
RuntimeText.Text = $".NET {Environment.Version}";
|
||||||
|
OsText.Text = Environment.OSVersion.ToString();
|
||||||
|
NdiText.Text = TryGetNdiVersion();
|
||||||
|
}
|
||||||
|
|
||||||
|
[SupportedOSPlatform("windows")]
|
||||||
|
private static string TryGetNdiVersion()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var interop = new NdiInteropPInvoke(
|
||||||
|
Microsoft.Extensions.Logging.Abstractions.NullLogger<NdiInteropPInvoke>.Instance);
|
||||||
|
return interop.GetRuntimeVersion();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return $"not initialized ({ex.Message})";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnClose(object sender, RoutedEventArgs e) => Close();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Re-open the first-launch welcome dialog from About so users can revisit
|
||||||
|
/// the setup checklist without having to delete the suppression flag file
|
||||||
|
/// by hand. The "Don't show again" checkbox in the welcome dialog defaults
|
||||||
|
/// to checked so a re-shown welcome won't unset the suppression on close.
|
||||||
|
/// </summary>
|
||||||
|
private void OnShowOnboarding(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
var onboarding = new OnboardingWindow { Owner = this };
|
||||||
|
onboarding.ShowDialog();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Quick-jump: open a path in Explorer. Creates the directory if missing
|
||||||
|
/// (operator might click "Recordings" before any have been made). Best-
|
||||||
|
/// effort — Explorer launch failures don't surface a dialog.
|
||||||
|
/// </summary>
|
||||||
|
private static void OpenInExplorer(string path)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!Directory.Exists(path)) Directory.CreateDirectory(path);
|
||||||
|
Process.Start(new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = path,
|
||||||
|
UseShellExecute = true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// No-op: shell launch failed (path inaccessible / Explorer crashed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnOpenLogs(object sender, RoutedEventArgs e) =>
|
||||||
|
OpenInExplorer(Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||||
|
"TeamsISO", "Logs"));
|
||||||
|
|
||||||
|
// OnOpenRecordings removed — recording feature axed.
|
||||||
|
|
||||||
|
private void OnOpenNotes(object sender, RoutedEventArgs e) =>
|
||||||
|
OpenInExplorer(Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||||
|
"TeamsISO", "Notes"));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Build the diagnostic bundle and tell the operator where it landed. The
|
||||||
|
/// bundle is just zipped logs / config / presets — no screenshots, no
|
||||||
|
/// memory dumps. Intended to be attached to a bug report.
|
||||||
|
/// </summary>
|
||||||
|
private void OnExportDiagnostics(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var path = DiagnosticsBundle.Export();
|
||||||
|
var open = MessageBox.Show(
|
||||||
|
this,
|
||||||
|
$"Diagnostic bundle written to:\n\n{path}\n\nOpen the containing folder?",
|
||||||
|
"TeamsISO — Diagnostics exported",
|
||||||
|
MessageBoxButton.YesNo,
|
||||||
|
MessageBoxImage.Information);
|
||||||
|
if (open == MessageBoxResult.Yes)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Process.Start(new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = "explorer.exe",
|
||||||
|
Arguments = $"/select,\"{path}\"",
|
||||||
|
UseShellExecute = true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch { /* shell launch failure is best-effort */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
MessageBox.Show(
|
||||||
|
this,
|
||||||
|
$"Diagnostic export failed.\n\n{ex.Message}",
|
||||||
|
"TeamsISO — Diagnostic export",
|
||||||
|
MessageBoxButton.OK,
|
||||||
|
MessageBoxImage.Warning);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Click handler for "Check for updates". Disables the button while the
|
||||||
|
/// HTTP call is in flight (so a second click doesn't spawn parallel
|
||||||
|
/// requests), then surfaces the result via MessageBox. On
|
||||||
|
/// <see cref="UpdateChecker.UpdateStatus.UpdateAvailable"/> we offer
|
||||||
|
/// to open the releases page so the operator can grab the new MSI.
|
||||||
|
/// </summary>
|
||||||
|
private async void OnCheckUpdate(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
UpdateButton.IsEnabled = false;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await UpdateChecker.CheckAsync();
|
||||||
|
switch (result.Status)
|
||||||
|
{
|
||||||
|
case UpdateChecker.UpdateStatus.UpdateAvailable:
|
||||||
|
var open = MessageBox.Show(
|
||||||
|
this,
|
||||||
|
$"{result.Message}\n\n" +
|
||||||
|
$"You're on {result.CurrentVersion}; latest is {result.LatestTag}.\n\n" +
|
||||||
|
"Open the releases page to download the new MSI?",
|
||||||
|
"TeamsISO — Update available",
|
||||||
|
MessageBoxButton.YesNo,
|
||||||
|
MessageBoxImage.Information);
|
||||||
|
if (open == MessageBoxResult.Yes)
|
||||||
|
UpdateChecker.OpenReleasesPage();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case UpdateChecker.UpdateStatus.UpToDate:
|
||||||
|
MessageBox.Show(
|
||||||
|
this,
|
||||||
|
result.Message ?? "You're on the latest release.",
|
||||||
|
"TeamsISO — Up to date",
|
||||||
|
MessageBoxButton.OK,
|
||||||
|
MessageBoxImage.Information);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case UpdateChecker.UpdateStatus.Error:
|
||||||
|
default:
|
||||||
|
MessageBox.Show(
|
||||||
|
this,
|
||||||
|
$"Couldn't check for updates.\n\n{result.Message}",
|
||||||
|
"TeamsISO — Update check failed",
|
||||||
|
MessageBoxButton.OK,
|
||||||
|
MessageBoxImage.Warning);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
UpdateButton.IsEnabled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Open the company site in the default browser. We intentionally use the
|
||||||
|
/// shell's URL handler rather than a tab inside the app — this is a
|
||||||
|
/// "tell me more" link, not a workflow.
|
||||||
|
/// </summary>
|
||||||
|
private void OnWebsiteClick(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Process.Start(new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = "https://wilddragon.net",
|
||||||
|
UseShellExecute = true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// best-effort; if shell launch fails the click is a no-op
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
250
src/TeamsISO.App/App.Bootstrap.cs
Normal file
250
src/TeamsISO.App/App.Bootstrap.cs
Normal file
|
|
@ -0,0 +1,250 @@
|
||||||
|
using System.IO;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Interop;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using TeamsISO.App.Services;
|
||||||
|
using TeamsISO.App.ViewModels;
|
||||||
|
using TeamsISO.Engine.Controller;
|
||||||
|
using TeamsISO.Engine.Interop;
|
||||||
|
using TeamsISO.Engine.NdiInterop;
|
||||||
|
using TeamsISO.Engine.Persistence;
|
||||||
|
using TeamsISO.Engine.Pipeline;
|
||||||
|
|
||||||
|
namespace TeamsISO.App;
|
||||||
|
|
||||||
|
// Linear bootstrap steps that OnStartup walks through, extracted so the
|
||||||
|
// main file reads as a wiring pipeline rather than a single 200-line
|
||||||
|
// procedure. Each method here either does its own work or returns a
|
||||||
|
// signal (bool / nullable) so OnStartup can bail early on failure.
|
||||||
|
public partial class App
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Acquire the per-user named mutex that gates a single TeamsISO
|
||||||
|
/// instance per Windows user. Two TeamsISOs on the same machine for
|
||||||
|
/// the same user race over the NDI finder, the NDI senders, and
|
||||||
|
/// %APPDATA%\TeamsISO\config.json — none of those are safe to share.
|
||||||
|
///
|
||||||
|
/// On loss: broadcast the bring-to-front message to wake the existing
|
||||||
|
/// instance and signal the caller to <see cref="Application.Shutdown(int)"/>
|
||||||
|
/// silently. On win: install the message-pump filter so subsequent
|
||||||
|
/// duplicate launches can surface us.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>true if this is the first instance; false if we should exit.</returns>
|
||||||
|
private bool TryAcquireSingleInstance()
|
||||||
|
{
|
||||||
|
_singleInstanceMutex = new System.Threading.Mutex(initiallyOwned: true, SingleInstanceMutexName, out var createdNew);
|
||||||
|
_ownsSingleInstanceMutex = createdNew;
|
||||||
|
if (!createdNew)
|
||||||
|
{
|
||||||
|
var bringToFront = RegisterWindowMessageW("WildDragon.TeamsISO.BringToFront");
|
||||||
|
if (bringToFront != 0)
|
||||||
|
SendNotifyMessageW(HWND_BROADCAST, bringToFront, IntPtr.Zero, IntPtr.Zero);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// We're the first instance. Install the message-pump filter so a
|
||||||
|
// *subsequent* launch that broadcasts our bring-to-front message
|
||||||
|
// surfaces our window. Hold the delegate in a field so OnExit can
|
||||||
|
// unsubscribe cleanly (ComponentDispatcher is process-static).
|
||||||
|
var bringToFrontMsg = RegisterWindowMessageW("WildDragon.TeamsISO.BringToFront");
|
||||||
|
_bringToFrontHandler = (ref MSG msg, ref bool handled) =>
|
||||||
|
{
|
||||||
|
if (msg.message == (int)bringToFrontMsg && MainWindow is not null)
|
||||||
|
{
|
||||||
|
if (MainWindow.WindowState == WindowState.Minimized) MainWindow.WindowState = WindowState.Normal;
|
||||||
|
MainWindow.Activate();
|
||||||
|
MainWindow.Topmost = true;
|
||||||
|
MainWindow.Topmost = false;
|
||||||
|
handled = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
ComponentDispatcher.ThreadFilterMessage += _bringToFrontHandler;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initialize the NDI interop layer. On failure (most commonly: NDI
|
||||||
|
/// Runtime isn't installed), show the operator a "go to ndi.video/tools"
|
||||||
|
/// dialog and signal a clean shutdown. The boolean return is checked
|
||||||
|
/// by OnStartup so we don't continue past a broken NDI host.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>true on success; false if OnStartup should Shutdown(2).</returns>
|
||||||
|
private bool TryBootstrapNdiInterop()
|
||||||
|
{
|
||||||
|
if (_loggerFactory is null) return false;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_interop = new NdiInteropPInvoke(_loggerFactory.CreateLogger<NdiInteropPInvoke>());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
MessageBox.Show(
|
||||||
|
"TeamsISO could not initialize the NDI runtime.\n\n" +
|
||||||
|
"Install the NDI Runtime from https://ndi.video/tools/ and try again.\n\n" +
|
||||||
|
"Details: " + ex.Message,
|
||||||
|
"TeamsISO — NDI runtime missing",
|
||||||
|
MessageBoxButton.OK,
|
||||||
|
MessageBoxImage.Error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Wire the engine: configstore, NDI runtime probe, frame scaler,
|
||||||
|
/// pipeline factory, IsoController. Doesn't start the engine — that's
|
||||||
|
/// MainViewModel.InitializeAsync's job.
|
||||||
|
/// </summary>
|
||||||
|
private void BootstrapEngine()
|
||||||
|
{
|
||||||
|
if (_loggerFactory is null || _interop is null) return;
|
||||||
|
|
||||||
|
var configPath = Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||||
|
"TeamsISO", "config.json");
|
||||||
|
var configStore = new ConfigStore(configPath, _loggerFactory.CreateLogger<ConfigStore>());
|
||||||
|
|
||||||
|
var probe = new NdiRuntimeProbe(_interop, NdiVersion.ExpectedRuntimeVersionPrefix);
|
||||||
|
var scaler = new ManagedNearestNeighborFrameScaler();
|
||||||
|
|
||||||
|
var loggerFactoryRef = _loggerFactory;
|
||||||
|
var interopRef = _interop;
|
||||||
|
IsoPipeline PipelineFactory(IsoPipelineConfig config)
|
||||||
|
{
|
||||||
|
var clock = new PeriodicTimerFrameClock(config.Settings.FramerateHz);
|
||||||
|
return new IsoPipeline(
|
||||||
|
config, interopRef, scaler, clock,
|
||||||
|
ExponentialBackoff.Default,
|
||||||
|
(delay, ct) => Task.Delay(delay, ct),
|
||||||
|
loggerFactoryRef);
|
||||||
|
}
|
||||||
|
|
||||||
|
_controller = new IsoController(
|
||||||
|
_interop, PipelineFactory, configStore, probe, _loggerFactory);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Construct the view-model, the main window, and show it. After this
|
||||||
|
/// returns, <see cref="Application.MainWindow"/> is non-null and the
|
||||||
|
/// window is on screen.
|
||||||
|
/// </summary>
|
||||||
|
private MainWindow ConstructAndShowMainWindow()
|
||||||
|
{
|
||||||
|
_viewModel = new MainViewModel(_controller!, Dispatcher);
|
||||||
|
var window = new MainWindow(_viewModel);
|
||||||
|
window.Show();
|
||||||
|
MainWindow = window;
|
||||||
|
return window;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// REST + WebSocket control surface for Stream Deck / Companion and
|
||||||
|
/// the OSC bridge. Created always; only Started if the operator had
|
||||||
|
/// the toggle on in the previous session (the settings VM's setter
|
||||||
|
/// handles the in-session flip path). Failures log + toast — we don't
|
||||||
|
/// want a port-bind error to block app start.
|
||||||
|
/// </summary>
|
||||||
|
private void BootstrapControlSurfaceServices()
|
||||||
|
{
|
||||||
|
if (_controller is null || _viewModel is null || _loggerFactory is null) return;
|
||||||
|
|
||||||
|
_controlSurface = new ControlSurfaceServer(
|
||||||
|
_controller,
|
||||||
|
() => _viewModel,
|
||||||
|
_loggerFactory.CreateLogger<ControlSurfaceServer>());
|
||||||
|
_oscBridge = new OscBridge(
|
||||||
|
_controller,
|
||||||
|
() => _viewModel,
|
||||||
|
_loggerFactory.CreateLogger<OscBridge>());
|
||||||
|
|
||||||
|
if (_viewModel.Settings.ControlSurfaceEnabled)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_controlSurface.Start(
|
||||||
|
_viewModel.Settings.ControlSurfacePort,
|
||||||
|
_viewModel.Settings.ControlSurfaceLanReachable);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_loggerFactory.CreateLogger<App>().LogWarning(ex,
|
||||||
|
"Control surface auto-start failed; operator can retry via Settings.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tray-icon host. Hosting from App (not MainWindow) ensures icon
|
||||||
|
/// lifetime matches the process, so the icon stays visible during a
|
||||||
|
/// minimize-to-tray (when MainWindow is hidden).
|
||||||
|
/// </summary>
|
||||||
|
private void BootstrapTrayIcon(MainWindow window)
|
||||||
|
{
|
||||||
|
if (_viewModel is null) return;
|
||||||
|
_trayIcon = new TrayIconHost(window)
|
||||||
|
{
|
||||||
|
Enabled = _viewModel.Settings.MinimizeToTray,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// First-launch onboarding dialog. Shown AFTER MainWindow so it has
|
||||||
|
/// a sensible Owner for centering + z-order. Suppressed forever once
|
||||||
|
/// the user dismisses with the checkbox checked.
|
||||||
|
/// </summary>
|
||||||
|
private static void TryShowOnboarding(MainWindow window)
|
||||||
|
{
|
||||||
|
if (!OnboardingWindow.ShouldShow()) return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var onboarding = new OnboardingWindow { Owner = window };
|
||||||
|
onboarding.ShowDialog();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Defensive: an onboarding-dialog failure should never block startup.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Auto-launch Teams in the background if the operator opted in.
|
||||||
|
/// Combined with AutoHideTeamsWindows this gives the "I only see
|
||||||
|
/// TeamsISO" experience. Fire-and-forget — a slow Teams launch must
|
||||||
|
/// not delay TeamsISO's own window from appearing.
|
||||||
|
/// </summary>
|
||||||
|
private void TryAutoLaunchTeams(ILogger logger)
|
||||||
|
{
|
||||||
|
if (_viewModel is null) return;
|
||||||
|
var settings = _viewModel.Settings;
|
||||||
|
|
||||||
|
if (settings.LaunchTeamsOnStartup && !TeamsLauncher.IsRunning())
|
||||||
|
{
|
||||||
|
_ = Task.Run(() =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (TeamsLauncher.TryLaunch(out var launchError))
|
||||||
|
{
|
||||||
|
if (settings.AutoHideTeamsWindows)
|
||||||
|
_ = TeamsLauncher.AutoHideAfterLaunchAsync();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
logger.LogWarning("Auto-launch Teams on startup failed: {Error}", launchError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogWarning(ex, "Auto-launch Teams on startup threw");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else if (settings.AutoHideTeamsWindows && TeamsLauncher.IsRunning())
|
||||||
|
{
|
||||||
|
// Teams is already up from a previous session. If auto-hide is
|
||||||
|
// on, hide it now so the operator's "I only see TeamsISO" rule
|
||||||
|
// applies even when Teams was launched externally.
|
||||||
|
_ = TeamsLauncher.AutoHideAfterLaunchAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
93
src/TeamsISO.App/App.CrashHandlers.cs
Normal file
93
src/TeamsISO.App/App.CrashHandlers.cs
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
using System.IO;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Threading;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace TeamsISO.App;
|
||||||
|
|
||||||
|
// Crash diagnostics — the three exception channels WPF leaves open by
|
||||||
|
// default, wired to a single handler that logs Fatal to Serilog (rolling
|
||||||
|
// daily file at %LOCALAPPDATA%\TeamsISO\Logs) and then shows the user a
|
||||||
|
// dialog with the log path so they can attach it to a bug report.
|
||||||
|
//
|
||||||
|
// We deliberately don't catch StackOverflowException or
|
||||||
|
// ExecutionEngineException — both are uncatchable in modern .NET; if one
|
||||||
|
// fires the OS Watson dialog takes it from here.
|
||||||
|
public partial class App
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Where the rolling Serilog file sink writes. Reused by the crash
|
||||||
|
/// dialog so we can show the user the exact directory to attach when
|
||||||
|
/// filing a bug.
|
||||||
|
/// </summary>
|
||||||
|
private static string LogDirectory =>
|
||||||
|
Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||||
|
"TeamsISO", "Logs");
|
||||||
|
|
||||||
|
private void OnAppDomainUnhandled(object sender, UnhandledExceptionEventArgs e)
|
||||||
|
{
|
||||||
|
// IsTerminating is almost always true here — finalizers and
|
||||||
|
// managed-thread top-frames don't have a graceful path back. Log
|
||||||
|
// + show a dialog inline since the process will exit either way.
|
||||||
|
var ex = e.ExceptionObject as Exception;
|
||||||
|
TryLogFatal("AppDomain.UnhandledException", ex);
|
||||||
|
TryShowCrashDialog(ex, terminating: e.IsTerminating);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnDispatcherUnhandled(object sender, DispatcherUnhandledExceptionEventArgs e)
|
||||||
|
{
|
||||||
|
TryLogFatal("Dispatcher.UnhandledException", e.Exception);
|
||||||
|
TryShowCrashDialog(e.Exception, terminating: false);
|
||||||
|
// Mark Handled so a single bad UI thunk doesn't take the whole app
|
||||||
|
// down — the user has the dialog and the log; they can choose to
|
||||||
|
// keep going.
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnUnobservedTaskException(object? sender, System.Threading.Tasks.UnobservedTaskExceptionEventArgs e)
|
||||||
|
{
|
||||||
|
TryLogFatal("TaskScheduler.UnobservedTaskException", e.Exception);
|
||||||
|
// Don't show a dialog here — these fire from the finalizer thread
|
||||||
|
// and tend to be cleanup-time noise, not user-actionable. Log only.
|
||||||
|
e.SetObserved();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void TryLogFatal(string source, Exception? ex)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var logger = _loggerFactory?.CreateLogger<App>();
|
||||||
|
logger?.LogCritical(ex, "{Source} fired", source);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Logger itself failed (rare — disk full, permission denied).
|
||||||
|
// Swallow: nothing useful to do, and re-throwing during crash
|
||||||
|
// handling makes things worse.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void TryShowCrashDialog(Exception? ex, bool terminating)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var heading = terminating
|
||||||
|
? "TeamsISO encountered an unrecoverable error and will exit."
|
||||||
|
: "TeamsISO encountered an error.";
|
||||||
|
var details = ex?.GetType().Name + ": " + (ex?.Message ?? "(no details)");
|
||||||
|
var body =
|
||||||
|
heading + "\n\n" +
|
||||||
|
details + "\n\n" +
|
||||||
|
$"A full diagnostic log has been written to:\n{LogDirectory}\n\n" +
|
||||||
|
"Attach the most recent file from that directory to your bug report.";
|
||||||
|
MessageBox.Show(body, "TeamsISO — Error",
|
||||||
|
MessageBoxButton.OK, MessageBoxImage.Error);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Even the dialog failed (e.g., during shutdown when the
|
||||||
|
// message pump is already gone). Nothing more to do.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
42
src/TeamsISO.App/App.UpdateCheckBootstrap.cs
Normal file
42
src/TeamsISO.App/App.UpdateCheckBootstrap.cs
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using TeamsISO.App.Services;
|
||||||
|
|
||||||
|
namespace TeamsISO.App;
|
||||||
|
|
||||||
|
// Background update check, throttled to once per 24h. Fire-and-forget
|
||||||
|
// so a slow / offline update server never delays startup. Surfaces a
|
||||||
|
// banner via UpdateBanner if newer; failures just log.
|
||||||
|
public partial class App
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Kick off the launch-time update check if the operator hasn't opted
|
||||||
|
/// out via the flag file. Called from OnStartup right after the engine
|
||||||
|
/// + view-model are live. Returns immediately; the actual HTTP call
|
||||||
|
/// runs on a worker.
|
||||||
|
/// </summary>
|
||||||
|
private void StartBackgroundUpdateCheck(ILogger logger)
|
||||||
|
{
|
||||||
|
if (!UpdateChecker.LaunchCheckEnabled) return;
|
||||||
|
if (_viewModel is null) return;
|
||||||
|
|
||||||
|
var vm = _viewModel;
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await UpdateChecker.CheckIfDueAsync(TimeSpan.FromHours(24));
|
||||||
|
if (result?.Status == UpdateChecker.UpdateStatus.UpdateAvailable
|
||||||
|
&& !string.IsNullOrEmpty(result.LatestTag)
|
||||||
|
&& !string.IsNullOrEmpty(result.CurrentVersion))
|
||||||
|
{
|
||||||
|
await Dispatcher.InvokeAsync(() =>
|
||||||
|
vm.UpdateBanner.Show(result.CurrentVersion!, result.LatestTag!));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogDebug(ex, "Background update check failed");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,23 @@
|
||||||
<Application x:Class="TeamsISO.App.App"
|
<Application x:Class="TeamsISO.App.App"
|
||||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||||
<Application.Resources/>
|
<Application.Resources>
|
||||||
|
<ResourceDictionary>
|
||||||
|
<ResourceDictionary.MergedDictionaries>
|
||||||
|
<!--
|
||||||
|
Theme color brushes — DARK by default. ThemeManager.Apply()
|
||||||
|
swaps this entry to Theme.Light.xaml at runtime; the brushes
|
||||||
|
are referenced via DynamicResource from WildDragonTheme.xaml
|
||||||
|
so the visual tree re-resolves without an app restart.
|
||||||
|
The DEFAULT here is dark so the app boots into a
|
||||||
|
deterministic state before ThemeManager runs on startup;
|
||||||
|
if the operator's preference is Light or system app-mode
|
||||||
|
is Light, the dictionary swap happens before MainWindow
|
||||||
|
is shown so there's no visible flash.
|
||||||
|
-->
|
||||||
|
<ResourceDictionary Source="/Themes/Theme.Dark.xaml"/>
|
||||||
|
<ResourceDictionary Source="/Themes/WildDragonTheme.xaml"/>
|
||||||
|
</ResourceDictionary.MergedDictionaries>
|
||||||
|
</ResourceDictionary>
|
||||||
|
</Application.Resources>
|
||||||
</Application>
|
</Application>
|
||||||
|
|
|
||||||
|
|
@ -1,84 +1,199 @@
|
||||||
using System.IO;
|
using System.IO;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
using System.Windows;
|
using System.Windows;
|
||||||
using System.Windows.Threading;
|
using System.Windows.Interop;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using TeamsISO.App.ViewModels;
|
using TeamsISO.App.ViewModels;
|
||||||
using TeamsISO.Engine.Controller;
|
using TeamsISO.Engine.Controller;
|
||||||
using TeamsISO.Engine.Interop;
|
|
||||||
using TeamsISO.Engine.Logging;
|
using TeamsISO.Engine.Logging;
|
||||||
using TeamsISO.Engine.NdiInterop;
|
using TeamsISO.Engine.NdiInterop;
|
||||||
using TeamsISO.Engine.Persistence;
|
|
||||||
using TeamsISO.Engine.Pipeline;
|
// Application + MessageBox aliases live in GlobalUsings.cs (project-wide).
|
||||||
|
// Don't redeclare here — Roslyn errors with CS1537 on duplicate alias.
|
||||||
|
|
||||||
namespace TeamsISO.App;
|
namespace TeamsISO.App;
|
||||||
|
|
||||||
|
// Split across partial files by responsibility:
|
||||||
|
// • App.xaml.cs — class skeleton, OnStartup (the wiring
|
||||||
|
// pipeline that calls into the partials),
|
||||||
|
// OnExit, CLI arg parser.
|
||||||
|
// • App.Bootstrap.cs — the linear setup steps OnStartup walks
|
||||||
|
// (single-instance gate, NDI interop, engine,
|
||||||
|
// main window, control surface, tray icon,
|
||||||
|
// onboarding, Teams auto-launch).
|
||||||
|
// • App.CrashHandlers.cs — AppDomain / Dispatcher / Task exception
|
||||||
|
// handlers + crash dialog + LogDirectory.
|
||||||
|
// • App.UpdateCheckBootstrap.cs — the background update-checker
|
||||||
|
// kickoff (24h-throttled).
|
||||||
public partial class App : Application
|
public partial class App : Application
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Per-user mutex name. Including the username (acting as a SID proxy) ensures two
|
||||||
|
/// different Windows users can each run TeamsISO on the same machine, while one
|
||||||
|
/// user can't spawn duplicate instances that would contend over the NDI runtime
|
||||||
|
/// and the shared %APPDATA%\TeamsISO\config.json.
|
||||||
|
///
|
||||||
|
/// The "Global\" prefix puts the named object in the system-wide namespace
|
||||||
|
/// (not session-local or integrity-isolated). This matters because when an
|
||||||
|
/// admin user has UAC effectively disabled, launches from different parents
|
||||||
|
/// (elevated File Explorer, non-elevated shell, etc.) can land in slightly
|
||||||
|
/// different security contexts. A "Local\" mutex was being created in
|
||||||
|
/// different views per integrity level on some boxes, letting two TeamsISO
|
||||||
|
/// instances run concurrently — the second's REST surface couldn't bind port
|
||||||
|
/// 9755 (already held) and its Serilog file sink couldn't open the daily log
|
||||||
|
/// (already held with shared=false), producing a window that looked like
|
||||||
|
/// the app but had no engine attached. Global\ closes that gap.
|
||||||
|
/// </summary>
|
||||||
|
private static readonly string SingleInstanceMutexName =
|
||||||
|
$"Global\\WildDragon.TeamsISO.SingleInstance.{Environment.UserName}";
|
||||||
|
|
||||||
|
private System.Threading.Mutex? _singleInstanceMutex;
|
||||||
|
private bool _ownsSingleInstanceMutex;
|
||||||
|
private ThreadMessageEventHandler? _bringToFrontHandler;
|
||||||
private ILoggerFactory? _loggerFactory;
|
private ILoggerFactory? _loggerFactory;
|
||||||
private NdiInteropPInvoke? _interop;
|
private NdiInteropPInvoke? _interop;
|
||||||
private IsoController? _controller;
|
private IsoController? _controller;
|
||||||
private MainViewModel? _viewModel;
|
private MainViewModel? _viewModel;
|
||||||
|
private TeamsISO.App.Services.ControlSurfaceServer? _controlSurface;
|
||||||
|
private TeamsISO.App.Services.OscBridge? _oscBridge;
|
||||||
|
// _diskSpaceWatcher removed — only existed to auto-disable recording at low free space.
|
||||||
|
private TeamsISO.App.Services.TrayIconHost? _trayIcon;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// REST control surface lifetime. Lives on App so the settings VM can flip
|
||||||
|
/// it on/off without us plumbing yet another DI dependency through MainViewModel.
|
||||||
|
/// Null between process startup and the OnStartup wire-up, and after OnExit.
|
||||||
|
/// </summary>
|
||||||
|
internal TeamsISO.App.Services.ControlSurfaceServer? ControlSurface => _controlSurface;
|
||||||
|
|
||||||
|
/// <summary>OSC bridge (UDP) lifetime — same lifecycle pattern as the REST surface.</summary>
|
||||||
|
internal TeamsISO.App.Services.OscBridge? OscBridge => _oscBridge;
|
||||||
|
|
||||||
|
/// <summary>Tray-icon host. Exposed so the settings VM can flip the minimize-to-tray toggle.</summary>
|
||||||
|
internal TeamsISO.App.Services.TrayIconHost? TrayIcon => _trayIcon;
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
private static extern uint RegisterWindowMessageW(string lpString);
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
private static extern int PostMessageW(IntPtr hWnd, uint Msg, IntPtr wParam, IntPtr lParam);
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
private static extern int SendNotifyMessageW(IntPtr hWnd, uint Msg, IntPtr wParam, IntPtr lParam);
|
||||||
|
private const IntPtr HWND_BROADCAST = -1;
|
||||||
|
|
||||||
protected override async void OnStartup(StartupEventArgs e)
|
protected override async void OnStartup(StartupEventArgs e)
|
||||||
{
|
{
|
||||||
|
// RAW TRACE — captures startup BEFORE Serilog comes up. Helps diagnose
|
||||||
|
// launches where the Serilog log stays empty (silent file-sink failure,
|
||||||
|
// pre-logger crash, weird parent-spawn environment, etc.). Writes to
|
||||||
|
// %LOCALAPPDATA%\TeamsISO\startup-trace.log.
|
||||||
|
var parentName = "(unknown)";
|
||||||
|
try { parentName = TryGetParentProcessName() ?? "(null)"; } catch { }
|
||||||
|
StartupTrace.Write($"OnStartup ENTER. exe={Environment.ProcessPath} parent={parentName} args=[{string.Join(' ', e.Args)}]");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var id = System.Security.Principal.WindowsIdentity.GetCurrent();
|
||||||
|
var pr = new System.Security.Principal.WindowsPrincipal(id);
|
||||||
|
StartupTrace.Write($"identity user={id.Name} isAdmin={pr.IsInRole(System.Security.Principal.WindowsBuiltInRole.Administrator)} integrity-token={id.User}");
|
||||||
|
}
|
||||||
|
catch (Exception ex) { StartupTrace.Write($"identity probe FAILED: {ex}"); }
|
||||||
|
|
||||||
base.OnStartup(e);
|
base.OnStartup(e);
|
||||||
|
StartupTrace.Write("base.OnStartup returned");
|
||||||
|
|
||||||
|
// De-elevation via runas /trustlevel:0x20000 was tried (commits 191b2c5,
|
||||||
|
// 54ee578) on the theory that elevated TeamsISO can't discover NDI
|
||||||
|
// sources. THAT THEORY WAS WRONG — verified 2026-05-16 that elevated
|
||||||
|
// TeamsISO discovers NDI sources fine. The SAFER-restricted token
|
||||||
|
// produced by runas /trustlevel was the ACTUAL cause of every "no
|
||||||
|
// participants" report: it breaks .NET 8 WPF startup such that the
|
||||||
|
// process appears alive with a window but the managed code never gets
|
||||||
|
// past BAML parsing. No logs, no port binds. We now skip the check
|
||||||
|
// entirely. The --keep-elevation arg, originally an opt-out, is now
|
||||||
|
// accepted but no-op'd (kept to avoid breaking any operator scripts).
|
||||||
|
if (Array.IndexOf(e.Args, "--keep-elevation") >= 0)
|
||||||
|
StartupTrace.Write("--keep-elevation flag present (no-op now; de-elevation removed)");
|
||||||
|
|
||||||
|
// Crash diagnostics — wire the three exception channels WPF leaves open by
|
||||||
|
// default to a single handler that logs Fatal to Serilog.
|
||||||
|
AppDomain.CurrentDomain.UnhandledException += OnAppDomainUnhandled;
|
||||||
|
DispatcherUnhandledException += OnDispatcherUnhandled;
|
||||||
|
System.Threading.Tasks.TaskScheduler.UnobservedTaskException += OnUnobservedTaskException;
|
||||||
|
StartupTrace.Write("crash handlers registered");
|
||||||
|
|
||||||
|
try { TeamsISO.App.Services.ThemeManager.Current.Apply(); StartupTrace.Write("ThemeManager.Apply OK"); }
|
||||||
|
catch (Exception ex) { StartupTrace.Write($"ThemeManager.Apply THREW: {ex}"); }
|
||||||
|
|
||||||
|
// Single-instance gate. Trace the mutex acquisition.
|
||||||
|
bool acquired = false;
|
||||||
|
try { acquired = TryAcquireSingleInstance(); } catch (Exception ex) { StartupTrace.Write($"TryAcquireSingleInstance THREW: {ex}"); }
|
||||||
|
StartupTrace.Write($"TryAcquireSingleInstance returned: {acquired}");
|
||||||
|
if (!acquired)
|
||||||
|
{
|
||||||
|
StartupTrace.Write("not first instance — Shutdown(0)");
|
||||||
|
Shutdown(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_loggerFactory = EngineLogging.CreateConsole(LogLevel.Information);
|
StartupTrace.Write("Bootstrap try-block ENTER");
|
||||||
|
_loggerFactory = EngineLogging.CreateDefault(LogLevel.Information);
|
||||||
|
StartupTrace.Write("EngineLogging.CreateDefault OK");
|
||||||
var logger = _loggerFactory.CreateLogger<App>();
|
var logger = _loggerFactory.CreateLogger<App>();
|
||||||
|
logger.LogInformation(
|
||||||
|
"TeamsISO.App starting up. Build: {Version}. Process: {Pid}.",
|
||||||
|
typeof(App).Assembly.GetName().Version,
|
||||||
|
Environment.ProcessId);
|
||||||
|
StartupTrace.Write("Serilog first write attempted");
|
||||||
|
|
||||||
// ---- Preflight: NDI runtime ----
|
if (!TryBootstrapNdiInterop())
|
||||||
try
|
|
||||||
{
|
{
|
||||||
_interop = new NdiInteropPInvoke(_loggerFactory.CreateLogger<NdiInteropPInvoke>());
|
StartupTrace.Write("TryBootstrapNdiInterop returned false — Shutdown(2)");
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
MessageBox.Show(
|
|
||||||
"TeamsISO could not initialize the NDI runtime.\n\n" +
|
|
||||||
"Install the NDI Runtime from https://ndi.video/tools/ and try again.\n\n" +
|
|
||||||
"Details: " + ex.Message,
|
|
||||||
"TeamsISO — NDI runtime missing",
|
|
||||||
MessageBoxButton.OK,
|
|
||||||
MessageBoxImage.Error);
|
|
||||||
Shutdown(2);
|
Shutdown(2);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
StartupTrace.Write("TryBootstrapNdiInterop OK");
|
||||||
|
|
||||||
// ---- Engine wiring ----
|
BootstrapEngine();
|
||||||
var configPath = Path.Combine(
|
StartupTrace.Write("BootstrapEngine OK");
|
||||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
var window = ConstructAndShowMainWindow();
|
||||||
"TeamsISO", "config.json");
|
StartupTrace.Write("ConstructAndShowMainWindow OK (window shown)");
|
||||||
var configStore = new ConfigStore(configPath, _loggerFactory.CreateLogger<ConfigStore>());
|
BootstrapControlSurfaceServices();
|
||||||
|
StartupTrace.Write("BootstrapControlSurfaceServices OK");
|
||||||
|
BootstrapTrayIcon(window);
|
||||||
|
StartupTrace.Write("BootstrapTrayIcon OK");
|
||||||
|
TryShowOnboarding(window);
|
||||||
|
StartupTrace.Write("TryShowOnboarding returned");
|
||||||
|
|
||||||
var probe = new NdiRuntimeProbe(_interop, NdiVersion.ExpectedRuntimeVersionPrefix);
|
ApplyCommandLineArgs(e.Args);
|
||||||
var scaler = new ManagedNearestNeighborFrameScaler();
|
StartupTrace.Write("ApplyCommandLineArgs OK");
|
||||||
|
|
||||||
var loggerFactoryRef = _loggerFactory;
|
StartupTrace.Write("about to await _viewModel.InitializeAsync");
|
||||||
var interopRef = _interop;
|
await _viewModel!.InitializeAsync(CancellationToken.None);
|
||||||
IsoPipeline PipelineFactory(IsoPipelineConfig config)
|
StartupTrace.Write("_viewModel.InitializeAsync COMPLETED");
|
||||||
|
|
||||||
|
TryAutoLaunchTeams(logger);
|
||||||
|
StartBackgroundUpdateCheck(logger);
|
||||||
|
StartupTrace.Write("OnStartup COMPLETE");
|
||||||
|
|
||||||
|
// 5-second post-init participant probe — tells us whether discovery
|
||||||
|
// is actually producing rows once the engine is up.
|
||||||
|
_ = Task.Run(async () =>
|
||||||
{
|
{
|
||||||
var clock = new PeriodicTimerFrameClock(config.Settings.FramerateHz);
|
await Task.Delay(5000);
|
||||||
return new IsoPipeline(
|
try
|
||||||
config, interopRef, scaler, clock,
|
{
|
||||||
ExponentialBackoff.Default,
|
var n = await Dispatcher.InvokeAsync(() => _viewModel?.Participants.Count ?? -1);
|
||||||
(delay, ct) => Task.Delay(delay, ct),
|
StartupTrace.Write($"+5s after init: vm.Participants.Count={n}");
|
||||||
loggerFactoryRef);
|
}
|
||||||
}
|
catch (Exception ex) { StartupTrace.Write($"+5s probe THREW: {ex.Message}"); }
|
||||||
|
});
|
||||||
_controller = new IsoController(
|
|
||||||
_interop, PipelineFactory, configStore, probe, _loggerFactory);
|
|
||||||
|
|
||||||
_viewModel = new MainViewModel(_controller, Dispatcher);
|
|
||||||
var window = new MainWindow(_viewModel);
|
|
||||||
window.Show();
|
|
||||||
MainWindow = window;
|
|
||||||
|
|
||||||
await _viewModel.InitializeAsync(CancellationToken.None);
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
StartupTrace.Write($"OnStartup CATCH: {ex}");
|
||||||
|
try { _loggerFactory?.CreateLogger<App>().LogCritical(ex, "OnStartup failed before main loop"); }
|
||||||
|
catch { /* defensive */ }
|
||||||
MessageBox.Show(
|
MessageBox.Show(
|
||||||
"TeamsISO failed to start.\n\nDetails: " + ex,
|
"TeamsISO failed to start.\n\nDetails: " + ex,
|
||||||
"TeamsISO — startup error",
|
"TeamsISO — startup error",
|
||||||
|
|
@ -88,10 +203,77 @@ public partial class App : Application
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// De-elevation helpers (ShouldDeElevate, TryDeElevateAndExit, the
|
||||||
|
// TEAMSISO_RELAUNCHED env var) were removed 2026-05-16. The whole
|
||||||
|
// pattern was treating a symptom that wasn't actually the problem
|
||||||
|
// (elevation does NOT break NDI Find); the SAFER token produced by
|
||||||
|
// runas /trustlevel:0x20000 broke .NET 8 WPF startup itself, so the
|
||||||
|
// "fix" was the actual bug. See git log for the dead code, App.xaml.cs
|
||||||
|
// commit history around 191b2c5 / 54ee578 / removal.
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Look up our parent process's image name (without extension). Returns
|
||||||
|
/// null if it can't be determined (PID gone, denied, etc.).
|
||||||
|
/// </summary>
|
||||||
|
private static string? TryGetParentProcessName()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var pid = Environment.ProcessId;
|
||||||
|
using var search = new System.Management.ManagementObjectSearcher(
|
||||||
|
$"SELECT ParentProcessId FROM Win32_Process WHERE ProcessId={pid}");
|
||||||
|
foreach (var m in search.Get())
|
||||||
|
{
|
||||||
|
var ppid = Convert.ToInt32(m["ParentProcessId"]);
|
||||||
|
using var parent = System.Diagnostics.Process.GetProcessById(ppid);
|
||||||
|
return parent.ProcessName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { /* fall through */ }
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TryDeElevateAndExit removed 2026-05-16 (see comment above ShouldDeElevate).
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parse the supported CLI flags. Currently:
|
||||||
|
/// <c>--apply-preset NAME</c> — apply the named preset once participants
|
||||||
|
/// populate. Equivalent to running TeamsISO and clicking Presets → select →
|
||||||
|
/// Apply, but driven from a desktop shortcut.
|
||||||
|
/// Unrecognized flags are silently ignored — operators using shortcut.lnk
|
||||||
|
/// files don't need to fight argument parsers.
|
||||||
|
/// </summary>
|
||||||
|
private void ApplyCommandLineArgs(string[] args)
|
||||||
|
{
|
||||||
|
if (_viewModel is null) return;
|
||||||
|
for (var i = 0; i < args.Length; i++)
|
||||||
|
{
|
||||||
|
switch (args[i])
|
||||||
|
{
|
||||||
|
case "--apply-preset":
|
||||||
|
if (i + 1 < args.Length && !string.IsNullOrEmpty(args[i + 1]))
|
||||||
|
{
|
||||||
|
_viewModel.RequestApplyPresetOnStartup(args[i + 1]);
|
||||||
|
i++; // consume the value
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Crash handlers (OnAppDomainUnhandled / OnDispatcherUnhandled /
|
||||||
|
// OnUnobservedTaskException / TryLogFatal / TryShowCrashDialog / LogDirectory)
|
||||||
|
// live in App.CrashHandlers.cs.
|
||||||
|
|
||||||
protected override async void OnExit(ExitEventArgs e)
|
protected override async void OnExit(ExitEventArgs e)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
_trayIcon?.Dispose();
|
||||||
|
if (_controlSurface is not null)
|
||||||
|
await _controlSurface.DisposeAsync();
|
||||||
|
if (_oscBridge is not null)
|
||||||
|
await _oscBridge.DisposeAsync();
|
||||||
_viewModel?.Dispose();
|
_viewModel?.Dispose();
|
||||||
if (_controller is not null)
|
if (_controller is not null)
|
||||||
await _controller.DisposeAsync();
|
await _controller.DisposeAsync();
|
||||||
|
|
@ -102,6 +284,22 @@ public partial class App : Application
|
||||||
{
|
{
|
||||||
// Best-effort shutdown
|
// Best-effort shutdown
|
||||||
}
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
// Unsubscribe the bring-to-front filter so the delegate doesn't outlive
|
||||||
|
// the App; ComponentDispatcher is process-static.
|
||||||
|
if (_bringToFrontHandler is not null)
|
||||||
|
{
|
||||||
|
ComponentDispatcher.ThreadFilterMessage -= _bringToFrontHandler;
|
||||||
|
_bringToFrontHandler = null;
|
||||||
|
}
|
||||||
|
// Release the Mutex iff we acquired it. The "lost the race" path above
|
||||||
|
// sets _ownsSingleInstanceMutex=false and we skip ReleaseMutex (which
|
||||||
|
// would throw ApplicationException on an unowned Mutex).
|
||||||
|
try { if (_ownsSingleInstanceMutex) _singleInstanceMutex?.ReleaseMutex(); }
|
||||||
|
catch { /* defensive: already-released or invalid handle */ }
|
||||||
|
_singleInstanceMutex?.Dispose();
|
||||||
|
}
|
||||||
base.OnExit(e);
|
base.OnExit(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
BIN
src/TeamsISO.App/Assets/Fonts/Inter.ttf
Normal file
BIN
src/TeamsISO.App/Assets/Fonts/Inter.ttf
Normal file
Binary file not shown.
BIN
src/TeamsISO.App/Assets/Fonts/JetBrainsMono.ttf
Normal file
BIN
src/TeamsISO.App/Assets/Fonts/JetBrainsMono.ttf
Normal file
Binary file not shown.
34
src/TeamsISO.App/Assets/_recolor_dragon.py
Normal file
34
src/TeamsISO.App/Assets/_recolor_dragon.py
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
from PIL import Image
|
||||||
|
import os
|
||||||
|
|
||||||
|
# We treat the navy-blue dragon-mark.png as a silhouette source: anything with
|
||||||
|
# nontrivial alpha is "dragon", everything else stays transparent. We emit a
|
||||||
|
# pure-black and pure-white variant, tightly cropped to the actual content
|
||||||
|
# bbox so they center cleanly when used as a watermark.
|
||||||
|
ROOT = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
src_path = os.path.join(ROOT, "dragon-mark.png")
|
||||||
|
src = Image.open(src_path).convert("RGBA")
|
||||||
|
|
||||||
|
alpha = src.split()[-1]
|
||||||
|
# Threshold to drop anti-alias fringe that can fool getbbox into reporting
|
||||||
|
# the whole canvas as "in".
|
||||||
|
mask = alpha.point(lambda v: 255 if v > 16 else 0)
|
||||||
|
bbox = mask.getbbox()
|
||||||
|
print("content bbox =", bbox, "size =", (bbox[2] - bbox[0], bbox[3] - bbox[1]))
|
||||||
|
|
||||||
|
cropped = src.crop(bbox)
|
||||||
|
_, _, _, ca = cropped.split()
|
||||||
|
|
||||||
|
for name, rgb in (("black", (0, 0, 0)), ("white", (255, 255, 255))):
|
||||||
|
flat = Image.merge(
|
||||||
|
"RGBA",
|
||||||
|
(
|
||||||
|
Image.new("L", cropped.size, rgb[0]),
|
||||||
|
Image.new("L", cropped.size, rgb[1]),
|
||||||
|
Image.new("L", cropped.size, rgb[2]),
|
||||||
|
ca,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
out_path = os.path.join(ROOT, f"dragon-mark-{name}.png")
|
||||||
|
flat.save(out_path, "PNG", optimize=True)
|
||||||
|
print("wrote", out_path, flat.size)
|
||||||
BIN
src/TeamsISO.App/Assets/dragon-mark-black.png
Normal file
BIN
src/TeamsISO.App/Assets/dragon-mark-black.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
BIN
src/TeamsISO.App/Assets/dragon-mark-white.png
Normal file
BIN
src/TeamsISO.App/Assets/dragon-mark-white.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
BIN
src/TeamsISO.App/Assets/dragon-mark.png
Normal file
BIN
src/TeamsISO.App/Assets/dragon-mark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
BIN
src/TeamsISO.App/Assets/teamsiso.ico
Normal file
BIN
src/TeamsISO.App/Assets/teamsiso.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 39 KiB |
BIN
src/TeamsISO.App/Assets/wild-dragon-wordmark.png
Normal file
BIN
src/TeamsISO.App/Assets/wild-dragon-wordmark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 94 KiB |
32
src/TeamsISO.App/Converters/CountToVisibilityConverter.cs
Normal file
32
src/TeamsISO.App/Converters/CountToVisibilityConverter.cs
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
using System.Collections;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Data;
|
||||||
|
|
||||||
|
namespace TeamsISO.App.Converters;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maps a collection (or its count) to <see cref="Visibility"/>. Pass
|
||||||
|
/// <c>"empty"</c> as the converter parameter to invert the sense (Visible when
|
||||||
|
/// count == 0). Used to swap an empty-state placeholder in for the participants
|
||||||
|
/// DataGrid when no Teams sources are visible yet.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CountToVisibilityConverter : IValueConverter
|
||||||
|
{
|
||||||
|
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||||
|
{
|
||||||
|
var count = value switch
|
||||||
|
{
|
||||||
|
int n => n,
|
||||||
|
ICollection c => c.Count,
|
||||||
|
null => 0,
|
||||||
|
_ => 1, // anything else: treat as non-empty
|
||||||
|
};
|
||||||
|
var invert = string.Equals(parameter as string, "empty", StringComparison.OrdinalIgnoreCase);
|
||||||
|
var visible = invert ? count == 0 : count > 0;
|
||||||
|
return visible ? Visibility.Visible : Visibility.Collapsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) =>
|
||||||
|
throw new NotSupportedException();
|
||||||
|
}
|
||||||
30
src/TeamsISO.App/Converters/InitialsConverter.cs
Normal file
30
src/TeamsISO.App/Converters/InitialsConverter.cs
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Windows.Data;
|
||||||
|
|
||||||
|
namespace TeamsISO.App.Converters;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Converts a display name to up to two uppercase initials for an avatar bubble.
|
||||||
|
/// "Brendon Power" → "BP". "(Local)" → "L". Falls back to "·" for empty inputs.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class InitialsConverter : IValueConverter
|
||||||
|
{
|
||||||
|
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||||
|
{
|
||||||
|
var s = value as string;
|
||||||
|
if (string.IsNullOrWhiteSpace(s)) return "·";
|
||||||
|
|
||||||
|
// Strip surrounding parens / punctuation that would otherwise become
|
||||||
|
// useless initials (e.g. "(Local)" should yield "L", not "(").
|
||||||
|
var cleaned = new string(s.Where(c => char.IsLetterOrDigit(c) || char.IsWhiteSpace(c)).ToArray()).Trim();
|
||||||
|
if (cleaned.Length == 0) return "·";
|
||||||
|
|
||||||
|
var parts = cleaned.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||||
|
if (parts.Length == 0) return "·";
|
||||||
|
if (parts.Length == 1) return char.ToUpperInvariant(parts[0][0]).ToString();
|
||||||
|
return $"{char.ToUpperInvariant(parts[0][0])}{char.ToUpperInvariant(parts[^1][0])}";
|
||||||
|
}
|
||||||
|
|
||||||
|
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||||
|
=> throw new NotSupportedException();
|
||||||
|
}
|
||||||
42
src/TeamsISO.App/Converters/LevelThresholdConverter.cs
Normal file
42
src/TeamsISO.App/Converters/LevelThresholdConverter.cs
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Windows.Data;
|
||||||
|
|
||||||
|
namespace TeamsISO.App.Converters;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maps an audio level (0.0–1.0) to an opacity for a single audio-meter
|
||||||
|
/// segment. The XAML binds five copies, each with a different
|
||||||
|
/// <see cref="Binding.ConverterParameter"/> threshold (0.2, 0.4, 0.6,
|
||||||
|
/// 0.8, 1.0). A segment renders at full opacity when the live level
|
||||||
|
/// exceeds its threshold; below that it dims to a faint silhouette so the
|
||||||
|
/// inactive segments still read as "the meter has 5 steps" rather than
|
||||||
|
/// blank space.
|
||||||
|
///
|
||||||
|
/// Designed for the v2 "Studio Terminal" participants table's audio meter.
|
||||||
|
/// Broadcast engineers expect instantaneous (non-averaged) bars; the
|
||||||
|
/// converter is stateless and trusts the caller to push raw levels.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class LevelThresholdConverter : IValueConverter
|
||||||
|
{
|
||||||
|
/// <summary>Opacity for an above-threshold segment. Defaults to 1.0.</summary>
|
||||||
|
public double ActiveOpacity { get; set; } = 1.0;
|
||||||
|
|
||||||
|
/// <summary>Opacity for a below-threshold segment. Defaults to 0.18 — visible enough to read the segment shape but clearly off.</summary>
|
||||||
|
public double InactiveOpacity { get; set; } = 0.18;
|
||||||
|
|
||||||
|
public object Convert(object value, System.Type targetType, object parameter, CultureInfo culture)
|
||||||
|
{
|
||||||
|
var level = value switch
|
||||||
|
{
|
||||||
|
double d => d,
|
||||||
|
float f => f,
|
||||||
|
_ => 0.0,
|
||||||
|
};
|
||||||
|
if (!double.TryParse(parameter?.ToString(), NumberStyles.Float, CultureInfo.InvariantCulture, out var threshold))
|
||||||
|
threshold = 1.0;
|
||||||
|
return level >= threshold ? ActiveOpacity : InactiveOpacity;
|
||||||
|
}
|
||||||
|
|
||||||
|
public object ConvertBack(object value, System.Type targetType, object parameter, CultureInfo culture) =>
|
||||||
|
System.Windows.Data.Binding.DoNothing;
|
||||||
|
}
|
||||||
24
src/TeamsISO.App/Converters/NullToCollapsedConverter.cs
Normal file
24
src/TeamsISO.App/Converters/NullToCollapsedConverter.cs
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Data;
|
||||||
|
|
||||||
|
namespace TeamsISO.App.Converters;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tiny utility converter — null or empty string ⇒ Collapsed, otherwise
|
||||||
|
/// Visible. Used by the v2 command palette's optional shortcut chip
|
||||||
|
/// (e.g. "Ctrl+R") so rows without a bound shortcut don't render the
|
||||||
|
/// empty pill outline.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class NullToCollapsedConverter : IValueConverter
|
||||||
|
{
|
||||||
|
public object Convert(object value, System.Type targetType, object parameter, CultureInfo culture)
|
||||||
|
{
|
||||||
|
if (value is null) return Visibility.Collapsed;
|
||||||
|
if (value is string s && string.IsNullOrEmpty(s)) return Visibility.Collapsed;
|
||||||
|
return Visibility.Visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
public object ConvertBack(object value, System.Type targetType, object parameter, CultureInfo culture) =>
|
||||||
|
System.Windows.Data.Binding.DoNothing;
|
||||||
|
}
|
||||||
14
src/TeamsISO.App/GlobalUsings.cs
Normal file
14
src/TeamsISO.App/GlobalUsings.cs
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
// Project-wide using aliases.
|
||||||
|
//
|
||||||
|
// Why: enabling <UseWindowsForms>true</UseWindowsForms> for the system-tray
|
||||||
|
// NotifyIcon brings in System.Windows.Forms.Application and
|
||||||
|
// System.Windows.Forms.MessageBox, both of which collide with their WPF
|
||||||
|
// counterparts (System.Windows.*). Every existing call site was written
|
||||||
|
// for the WPF type. Aliasing globally here is one declaration that keeps
|
||||||
|
// all the call sites compiling without per-file pollution.
|
||||||
|
//
|
||||||
|
// If you ever need the WinForms types, qualify them explicitly as
|
||||||
|
// `System.Windows.Forms.MessageBox` etc.
|
||||||
|
|
||||||
|
global using Application = System.Windows.Application;
|
||||||
|
global using MessageBox = System.Windows.MessageBox;
|
||||||
223
src/TeamsISO.App/HelpWindow.xaml
Normal file
223
src/TeamsISO.App/HelpWindow.xaml
Normal file
|
|
@ -0,0 +1,223 @@
|
||||||
|
<Window x:Class="TeamsISO.App.HelpWindow"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:shell="clr-namespace:System.Windows.Shell;assembly=PresentationFramework"
|
||||||
|
Title="Help"
|
||||||
|
Icon="/Assets/teamsiso.ico"
|
||||||
|
Width="540" Height="560"
|
||||||
|
WindowStartupLocation="CenterOwner"
|
||||||
|
WindowStyle="None"
|
||||||
|
ResizeMode="NoResize"
|
||||||
|
Background="{DynamicResource Wd.Canvas}"
|
||||||
|
UseLayoutRounding="True"
|
||||||
|
TextOptions.TextFormattingMode="Ideal"
|
||||||
|
TextOptions.TextRenderingMode="ClearType">
|
||||||
|
|
||||||
|
<shell:WindowChrome.WindowChrome>
|
||||||
|
<shell:WindowChrome
|
||||||
|
CaptionHeight="32"
|
||||||
|
ResizeBorderThickness="0"
|
||||||
|
CornerRadius="0"
|
||||||
|
GlassFrameThickness="0"
|
||||||
|
UseAeroCaptionButtons="False"/>
|
||||||
|
</shell:WindowChrome.WindowChrome>
|
||||||
|
|
||||||
|
<Border BorderBrush="{DynamicResource Wd.Border}" BorderThickness="1">
|
||||||
|
<Grid Margin="28,18,28,22">
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="*"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
|
<!-- Caption -->
|
||||||
|
<Grid Grid.Row="0">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<TextBlock Text="HELP"
|
||||||
|
Style="{StaticResource Wd.Text.Caption}"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
<Button Grid.Column="1"
|
||||||
|
Style="{StaticResource Wd.Button.CaptionClose}"
|
||||||
|
Click="OnClose"
|
||||||
|
shell:WindowChrome.IsHitTestVisibleInChrome="True">
|
||||||
|
<Path Data="M 0,0 L 10,10 M 10,0 L 0,10"
|
||||||
|
Stroke="{DynamicResource Wd.Text.Primary}"
|
||||||
|
StrokeThickness="1.2"
|
||||||
|
Width="10" Height="10"
|
||||||
|
Stretch="None"/>
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<StackPanel Grid.Row="1" Margin="0,16,0,16">
|
||||||
|
<TextBlock Text="TeamsISO cheat sheet"
|
||||||
|
Style="{StaticResource Wd.Text.Title}"/>
|
||||||
|
<TextBlock Text="Keyboard shortcuts, file locations, and quick links."
|
||||||
|
Style="{StaticResource Wd.Text.Subtle}"
|
||||||
|
Foreground="{DynamicResource Wd.Text.Tertiary}"
|
||||||
|
Margin="0,4,0,0"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- Body -->
|
||||||
|
<ScrollViewer Grid.Row="2" VerticalScrollBarVisibility="Auto">
|
||||||
|
<StackPanel>
|
||||||
|
|
||||||
|
<Border Style="{StaticResource Wd.Card}" Padding="16" Margin="0,0,0,12">
|
||||||
|
<StackPanel>
|
||||||
|
<TextBlock Text="KEYBOARD SHORTCUTS"
|
||||||
|
Style="{StaticResource Wd.Text.Caption}"
|
||||||
|
Margin="0,0,0,12"/>
|
||||||
|
<Grid>
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
|
<TextBlock Grid.Row="0" Grid.Column="0"
|
||||||
|
Text="F1"
|
||||||
|
Style="{StaticResource Wd.Text.Mono}"
|
||||||
|
Foreground="{DynamicResource Wd.Accent.Cyan}"
|
||||||
|
Margin="0,0,16,6"/>
|
||||||
|
<TextBlock Grid.Row="0" Grid.Column="1"
|
||||||
|
Text="Open this help"
|
||||||
|
Style="{StaticResource Wd.Text.Body}"
|
||||||
|
FontSize="12"
|
||||||
|
Margin="0,0,0,6"/>
|
||||||
|
|
||||||
|
<TextBlock Grid.Row="1" Grid.Column="0"
|
||||||
|
Text="Ctrl + M"
|
||||||
|
Style="{StaticResource Wd.Text.Mono}"
|
||||||
|
Foreground="{DynamicResource Wd.Accent.Cyan}"
|
||||||
|
Margin="0,0,16,6"/>
|
||||||
|
<TextBlock Grid.Row="1" Grid.Column="1"
|
||||||
|
Text="Drop a timestamped marker into every active recording"
|
||||||
|
Style="{StaticResource Wd.Text.Body}"
|
||||||
|
FontSize="12"
|
||||||
|
Margin="0,0,0,6"/>
|
||||||
|
|
||||||
|
<TextBlock Grid.Row="2" Grid.Column="0"
|
||||||
|
Text="Ctrl + Shift + S"
|
||||||
|
Style="{StaticResource Wd.Text.Mono}"
|
||||||
|
Foreground="{DynamicResource Wd.Accent.Cyan}"
|
||||||
|
Margin="0,0,16,6"/>
|
||||||
|
<TextBlock Grid.Row="2" Grid.Column="1"
|
||||||
|
Text="Stop every running ISO (emergency)"
|
||||||
|
Style="{StaticResource Wd.Text.Body}"
|
||||||
|
FontSize="12"
|
||||||
|
Margin="0,0,0,6"/>
|
||||||
|
|
||||||
|
<TextBlock Grid.Row="3" Grid.Column="0"
|
||||||
|
Text="Ctrl + R"
|
||||||
|
Style="{StaticResource Wd.Text.Mono}"
|
||||||
|
Foreground="{DynamicResource Wd.Accent.Cyan}"
|
||||||
|
Margin="0,0,16,6"/>
|
||||||
|
<TextBlock Grid.Row="3" Grid.Column="1"
|
||||||
|
Text="Refresh NDI discovery (rebuild finder)"
|
||||||
|
Style="{StaticResource Wd.Text.Body}"
|
||||||
|
FontSize="12"
|
||||||
|
Margin="0,0,0,6"/>
|
||||||
|
</Grid>
|
||||||
|
<TextBlock Margin="0,12,0,0"
|
||||||
|
Style="{StaticResource Wd.Text.Body}"
|
||||||
|
FontSize="12"
|
||||||
|
Foreground="{DynamicResource Wd.Text.Secondary}"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
Text="Numpad 1-9 (or top-row 1-9) toggles the Nth visible participant's ISO. Sort + filter aware — the index matches what you see in the DataGrid, not the underlying storage order."/>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<Border Style="{StaticResource Wd.Card}" Padding="16" Margin="0,0,0,12">
|
||||||
|
<StackPanel>
|
||||||
|
<TextBlock Text="FILE LOCATIONS"
|
||||||
|
Style="{StaticResource Wd.Text.Caption}"
|
||||||
|
Margin="0,0,0,12"/>
|
||||||
|
<TextBlock Style="{StaticResource Wd.Text.Mono}"
|
||||||
|
FontSize="11"
|
||||||
|
Foreground="{DynamicResource Wd.Text.Secondary}"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
LineHeight="20">
|
||||||
|
<Run Text="Engine config" Foreground="{DynamicResource Wd.Text.Tertiary}"/>
|
||||||
|
<LineBreak/>
|
||||||
|
<Run Text="%APPDATA%\TeamsISO\config.json"/>
|
||||||
|
<LineBreak/>
|
||||||
|
<LineBreak/>
|
||||||
|
<Run Text="Operator presets" Foreground="{DynamicResource Wd.Text.Tertiary}"/>
|
||||||
|
<LineBreak/>
|
||||||
|
<Run Text="%LOCALAPPDATA%\TeamsISO\presets.json"/>
|
||||||
|
<LineBreak/>
|
||||||
|
<LineBreak/>
|
||||||
|
<Run Text="Diagnostic logs (rolling daily)" Foreground="{DynamicResource Wd.Text.Tertiary}"/>
|
||||||
|
<LineBreak/>
|
||||||
|
<Run Text="%LOCALAPPDATA%\TeamsISO\Logs\"/>
|
||||||
|
<LineBreak/>
|
||||||
|
<LineBreak/>
|
||||||
|
<Run Text="Recordings (default)" Foreground="{DynamicResource Wd.Text.Tertiary}"/>
|
||||||
|
<LineBreak/>
|
||||||
|
<Run Text="%USERPROFILE%\Videos\TeamsISO\<date>\"/>
|
||||||
|
<LineBreak/>
|
||||||
|
<LineBreak/>
|
||||||
|
<Run Text="NDI Access Manager config" Foreground="{DynamicResource Wd.Text.Tertiary}"/>
|
||||||
|
<LineBreak/>
|
||||||
|
<Run Text="%APPDATA%\NDI\ndi-config.v1.json"/>
|
||||||
|
</TextBlock>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<Border Style="{StaticResource Wd.Card}" Padding="16">
|
||||||
|
<StackPanel>
|
||||||
|
<TextBlock Text="EXTERNAL CONTROL"
|
||||||
|
Style="{StaticResource Wd.Text.Caption}"
|
||||||
|
Margin="0,0,0,12"/>
|
||||||
|
<TextBlock Style="{StaticResource Wd.Text.Body}"
|
||||||
|
FontSize="12"
|
||||||
|
Foreground="{DynamicResource Wd.Text.Secondary}"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
Text="REST API (default :9755) and OSC bridge (default :9000) are off by default. Enable from Settings → DISPLAY. Stream Deck / Companion / TouchOSC can drive ISO toggles, presets, recording, mute/camera/leave, and marker drops."
|
||||||
|
Margin="0,0,0,8"/>
|
||||||
|
<TextBlock Style="{StaticResource Wd.Text.Body}"
|
||||||
|
FontSize="12"
|
||||||
|
Foreground="{DynamicResource Wd.Text.Secondary}"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
Text="LAN-reachable mode (DISPLAY tab) makes the surfaces reachable from other machines on the same network — useful for headless host PC + thin client setups. Closed-network only; first-time use requires a one-shot 'netsh http add urlacl' (see docs/CONTROL-SURFACE.md)."
|
||||||
|
Margin="0,0,0,8"/>
|
||||||
|
<TextBlock Style="{StaticResource Wd.Text.Mono}"
|
||||||
|
FontSize="11"
|
||||||
|
Foreground="{DynamicResource Wd.Text.Tertiary}">
|
||||||
|
<Hyperlink x:Name="DocsLink"
|
||||||
|
Foreground="{DynamicResource Wd.Accent.Cyan}"
|
||||||
|
TextDecorations="None"
|
||||||
|
Click="OnDocsClick">
|
||||||
|
forge.wilddragon.net/zgaetano/teamsiso
|
||||||
|
</Hyperlink>
|
||||||
|
</TextBlock>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<Grid Grid.Row="3" Margin="0,18,0,0">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<Button Grid.Column="1"
|
||||||
|
Style="{StaticResource Wd.Button.Primary}"
|
||||||
|
Content="Got it"
|
||||||
|
Click="OnClose"
|
||||||
|
Padding="22,8"/>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</Window>
|
||||||
32
src/TeamsISO.App/HelpWindow.xaml.cs
Normal file
32
src/TeamsISO.App/HelpWindow.xaml.cs
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Windows;
|
||||||
|
|
||||||
|
namespace TeamsISO.App;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// F1 cheat-sheet dialog. Lists keyboard shortcuts, file locations, and a
|
||||||
|
/// link to the public documentation. Same chromeless style as the rest of
|
||||||
|
/// the host's modal dialogs.
|
||||||
|
/// </summary>
|
||||||
|
public partial class HelpWindow : Window
|
||||||
|
{
|
||||||
|
public HelpWindow() => InitializeComponent();
|
||||||
|
|
||||||
|
private void OnClose(object sender, RoutedEventArgs e) => Close();
|
||||||
|
|
||||||
|
private void OnDocsClick(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Process.Start(new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = "https://forge.wilddragon.net/zgaetano/teamsiso",
|
||||||
|
UseShellExecute = true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Best-effort browser launch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,4 +1,8 @@
|
||||||
|
using System;
|
||||||
|
using System.Linq;
|
||||||
using System.Windows;
|
using System.Windows;
|
||||||
|
using System.Windows.Input;
|
||||||
|
using TeamsISO.App.Services;
|
||||||
using TeamsISO.App.ViewModels;
|
using TeamsISO.App.ViewModels;
|
||||||
|
|
||||||
namespace TeamsISO.App;
|
namespace TeamsISO.App;
|
||||||
|
|
@ -8,10 +12,276 @@ public partial class MainWindow : Window
|
||||||
public MainWindow()
|
public MainWindow()
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
|
SourceInitialized += OnSourceInitialized;
|
||||||
|
Closing += OnClosing;
|
||||||
|
// Esc dismisses the settings drawer when it's open. Bound at the
|
||||||
|
// window level so any focused control inside the drawer also gets
|
||||||
|
// the affordance.
|
||||||
|
PreviewKeyDown += OnPreviewKeyDown;
|
||||||
}
|
}
|
||||||
|
|
||||||
public MainWindow(MainViewModel viewModel) : this()
|
public MainWindow(MainViewModel viewModel) : this()
|
||||||
{
|
{
|
||||||
DataContext = viewModel;
|
DataContext = viewModel;
|
||||||
|
// Hand the view-model the palette-opener callback so Ctrl+K's
|
||||||
|
// KeyBinding (which lives on the VM as an ICommand) can reach
|
||||||
|
// back into the view layer to materialize the window.
|
||||||
|
viewModel.RegisterCommandPaletteOpener(() => OnCommandPaletteClick(this, new RoutedEventArgs()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Restore the window's previous placement after the HWND is created (so
|
||||||
|
/// SetWindowPos / WindowState transitions actually take effect). Falls
|
||||||
|
/// silently back to the XAML-default startup location if no snapshot exists.
|
||||||
|
/// </summary>
|
||||||
|
private void OnSourceInitialized(object? sender, EventArgs e)
|
||||||
|
{
|
||||||
|
WindowStateStore.TryApply(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Persist the placement on close so next launch lands in the same spot.</summary>
|
||||||
|
private void OnClosing(object? sender, System.ComponentModel.CancelEventArgs e)
|
||||||
|
{
|
||||||
|
// A failure persisting window state must NEVER block the window from
|
||||||
|
// closing — operator's shutdown comes first. WindowStateStore.Save
|
||||||
|
// already swallows its own IO errors; this is defense-in-depth for
|
||||||
|
// anything that escapes (NRE, future regression, etc.).
|
||||||
|
try { WindowStateStore.Save(this); }
|
||||||
|
catch { /* best-effort: forgo placement memory for one launch */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Opens the About dialog — version, NDI runtime, build SHA.</summary>
|
||||||
|
private void OnAboutClick(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
var about = new AboutWindow { Owner = this };
|
||||||
|
about.ShowDialog();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Opens the operator-presets dialog. Hands it the current participants
|
||||||
|
/// snapshot (so Save captures live state) and the engine controller (so
|
||||||
|
/// Apply can reconcile enable/disable). Owner is set so the chromeless
|
||||||
|
/// dialog centers over the main window and inherits z-order.
|
||||||
|
/// </summary>
|
||||||
|
private void OnPresetsClick(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (DataContext is not MainViewModel vm) return;
|
||||||
|
var dialog = new PresetsDialog(vm.Controller, vm.Participants.ToList(), vm.Toast)
|
||||||
|
{
|
||||||
|
Owner = this,
|
||||||
|
};
|
||||||
|
dialog.ShowDialog();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tracks whether we have hidden Teams' windows so the next click reverses
|
||||||
|
/// the action. We treat this as "intent" rather than a query of OS state
|
||||||
|
/// because hidden windows still report as hidden if the operator manually
|
||||||
|
/// re-opens them and we only care about TeamsISO's own toggle history.
|
||||||
|
/// </summary>
|
||||||
|
private bool _teamsWindowsHidden;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Phase E.2 toggle. Hides every visible top-level Teams window on first
|
||||||
|
/// click; shows them again on the next. Surfaces the result via the toast
|
||||||
|
/// so the operator gets feedback even though the affected windows aren't
|
||||||
|
/// visible anymore.
|
||||||
|
/// </summary>
|
||||||
|
private void OnToggleTeamsWindowClick(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (!TeamsLauncher.IsRunning())
|
||||||
|
{
|
||||||
|
MessageBox.Show(
|
||||||
|
Properties.Strings.HideShowTeams_NotRunning_Message,
|
||||||
|
Properties.Strings.HideShowTeams_Title,
|
||||||
|
MessageBoxButton.OK,
|
||||||
|
MessageBoxImage.Information);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var toast = (DataContext as MainViewModel)?.Toast;
|
||||||
|
if (_teamsWindowsHidden)
|
||||||
|
{
|
||||||
|
var shown = TeamsLauncher.ShowWindows();
|
||||||
|
_teamsWindowsHidden = false;
|
||||||
|
toast?.Show(shown > 0 ? $"Restored {shown} Teams window(s)" : "No Teams windows to restore");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var hidden = TeamsLauncher.HideWindows();
|
||||||
|
_teamsWindowsHidden = hidden > 0;
|
||||||
|
toast?.Show(hidden > 0 ? $"Hid {hidden} Teams window(s)" : "Teams has no visible windows yet");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Three-state click behavior matching operator intuition:
|
||||||
|
/// 1. Teams not running → launch it via TeamsLauncher's fallback chain.
|
||||||
|
/// 2. Teams running but its windows are hidden → restore + foreground them.
|
||||||
|
/// 3. Teams running with visible windows → bring the most recent to front.
|
||||||
|
/// (Stopping Teams is a right-click action; see OnLaunchTeamsRightClick.)
|
||||||
|
/// </summary>
|
||||||
|
private void OnLaunchTeamsClick(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
var toast = (DataContext as MainViewModel)?.Toast;
|
||||||
|
|
||||||
|
if (!TeamsLauncher.IsRunning())
|
||||||
|
{
|
||||||
|
if (!TeamsLauncher.TryLaunch(out var error))
|
||||||
|
{
|
||||||
|
MessageBox.Show(
|
||||||
|
string.Format(Properties.Strings.LaunchTeams_Failed_MessageFormat, error),
|
||||||
|
Properties.Strings.LaunchTeams_Title,
|
||||||
|
MessageBoxButton.OK,
|
||||||
|
MessageBoxImage.Warning);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var autoHide = (DataContext as MainViewModel)?.Settings.AutoHideTeamsWindows ?? false;
|
||||||
|
toast?.Show(autoHide
|
||||||
|
? "Launching Microsoft Teams (will hide windows automatically)…"
|
||||||
|
: "Launching Microsoft Teams…");
|
||||||
|
if (autoHide)
|
||||||
|
{
|
||||||
|
_ = TeamsLauncher.AutoHideAfterLaunchAsync();
|
||||||
|
_teamsWindowsHidden = true;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_teamsWindowsHidden = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var shown = TeamsLauncher.ShowWindows();
|
||||||
|
_teamsWindowsHidden = false;
|
||||||
|
toast?.Show(shown > 0
|
||||||
|
? $"Teams is already running — surfaced {shown} window(s)"
|
||||||
|
: "Teams is running but has no visible windows yet");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Right-click on the Launch button asks to stop Teams. Split out from the
|
||||||
|
/// left-click so a normal click is "open / surface" rather than the previous
|
||||||
|
/// "open OR ambush you with a stop dialog". The confirmation dialog here is
|
||||||
|
/// intentional — Stop Teams is a destructive mid-show action; explicit
|
||||||
|
/// confirmation is the right pattern, not the "ambush" anti-pattern that
|
||||||
|
/// was fixed for left-click. The palette also offers Stop Teams for
|
||||||
|
/// keyboard-first operators.
|
||||||
|
/// </summary>
|
||||||
|
private void OnLaunchTeamsRightClick(object sender, MouseButtonEventArgs e)
|
||||||
|
{
|
||||||
|
if (!TeamsLauncher.IsRunning()) return;
|
||||||
|
|
||||||
|
var confirm = MessageBox.Show(
|
||||||
|
Properties.Strings.StopTeams_Confirm_Message,
|
||||||
|
Properties.Strings.StopTeams_Title,
|
||||||
|
MessageBoxButton.YesNo,
|
||||||
|
MessageBoxImage.Question);
|
||||||
|
if (confirm != MessageBoxResult.Yes) return;
|
||||||
|
|
||||||
|
var asked = TeamsLauncher.StopAll();
|
||||||
|
if (TeamsLauncher.IsRunning())
|
||||||
|
{
|
||||||
|
MessageBox.Show(
|
||||||
|
asked == 0
|
||||||
|
? Properties.Strings.StopTeams_NoneResponded
|
||||||
|
: string.Format(Properties.Strings.StopTeams_AskedFormat, asked),
|
||||||
|
Properties.Strings.StopTeams_Title,
|
||||||
|
MessageBoxButton.OK,
|
||||||
|
MessageBoxImage.Information);
|
||||||
|
}
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Open the experimental Teams embed window. Operator enables the
|
||||||
|
/// preference first; this button materializes the host.
|
||||||
|
/// </summary>
|
||||||
|
private void OnOpenEmbedWindowClick(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
var w = new TeamsEmbedWindow { Owner = this };
|
||||||
|
w.Show();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Toggle the v2 settings drawer overlay. The header gear button and the
|
||||||
|
/// drawer's own Close button both call this. State is held by the
|
||||||
|
/// overlay's <see cref="UIElement.Visibility"/> directly — no separate
|
||||||
|
/// flag — so the toggle is idempotent regardless of how many entry
|
||||||
|
/// points open / close it.
|
||||||
|
/// </summary>
|
||||||
|
private void OnSettingsToggleClick(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (SettingsDrawerOverlay is null) return;
|
||||||
|
SettingsDrawerOverlay.Visibility = SettingsDrawerOverlay.Visibility == Visibility.Visible
|
||||||
|
? Visibility.Collapsed
|
||||||
|
: Visibility.Visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Clicking the scrim behind the drawer dismisses it — same affordance as
|
||||||
|
/// every well-behaved slide-over on every platform.
|
||||||
|
/// </summary>
|
||||||
|
private void OnSettingsScrimClick(object sender, MouseButtonEventArgs e)
|
||||||
|
{
|
||||||
|
if (SettingsDrawerOverlay is null) return;
|
||||||
|
SettingsDrawerOverlay.Visibility = Visibility.Collapsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Open the v2 Ctrl+K command palette. Bound to the header ⌘K button and
|
||||||
|
/// to the Ctrl+K keyboard binding. The palette is a chromeless floating
|
||||||
|
/// window owned by this MainWindow so it centers correctly, closes on
|
||||||
|
/// Deactivated (click outside), and inherits z-order. We construct a
|
||||||
|
/// fresh view-model each time so the filter starts empty.
|
||||||
|
/// </summary>
|
||||||
|
private void OnCommandPaletteClick(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (DataContext is not MainViewModel vm) return;
|
||||||
|
var paletteVm = new ViewModels.CommandPaletteViewModel(vm, Dispatcher);
|
||||||
|
var palette = new Views.CommandPaletteWindow(paletteVm) { Owner = this };
|
||||||
|
palette.ShowDialog();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Open the per-participant ISO override editor. Bound to the gear button
|
||||||
|
/// in the participant row. The dialog reads the engine's current override
|
||||||
|
/// (if any) and lets the operator edit framerate / resolution / aspect /
|
||||||
|
/// audio for that specific pipeline; Apply / Clear / Cancel are handled by
|
||||||
|
/// the dialog's view-model, so this handler is just plumbing.
|
||||||
|
/// </summary>
|
||||||
|
private void OnIsoOverrideClick(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (DataContext is not MainViewModel vm) return;
|
||||||
|
if (sender is not FrameworkElement fe) return;
|
||||||
|
if (fe.DataContext is not ParticipantViewModel p) return;
|
||||||
|
|
||||||
|
var currentOverride = vm.Controller.GetIsoOverride(p.Id);
|
||||||
|
var dialogVm = new ViewModels.IsoOverrideDialogViewModel(
|
||||||
|
vm.Controller,
|
||||||
|
vm.Settings,
|
||||||
|
p.Id,
|
||||||
|
p.DisplayName,
|
||||||
|
currentOverride,
|
||||||
|
vm.Toast);
|
||||||
|
var dialog = new Views.IsoOverrideDialog(dialogVm) { Owner = this };
|
||||||
|
dialog.ShowDialog();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Esc closes the drawer when it's open. We use PreviewKeyDown rather than
|
||||||
|
/// KeyDown so the drawer's nested inputs (TextBox, ComboBox) don't swallow
|
||||||
|
/// the key before this handler sees it.
|
||||||
|
/// </summary>
|
||||||
|
private void OnPreviewKeyDown(object sender, System.Windows.Input.KeyEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.Key != Key.Escape) return;
|
||||||
|
if (SettingsDrawerOverlay?.Visibility == Visibility.Visible)
|
||||||
|
{
|
||||||
|
SettingsDrawerOverlay.Visibility = Visibility.Collapsed;
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
129
src/TeamsISO.App/NotesWindow.xaml
Normal file
129
src/TeamsISO.App/NotesWindow.xaml
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
<Window x:Class="TeamsISO.App.NotesWindow"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:shell="clr-namespace:System.Windows.Shell;assembly=PresentationFramework"
|
||||||
|
Title="Show notes"
|
||||||
|
Icon="/Assets/teamsiso.ico"
|
||||||
|
Width="540" Height="560"
|
||||||
|
WindowStartupLocation="CenterOwner"
|
||||||
|
WindowStyle="None"
|
||||||
|
ResizeMode="CanResize"
|
||||||
|
Background="{DynamicResource Wd.Canvas}"
|
||||||
|
UseLayoutRounding="True"
|
||||||
|
TextOptions.TextFormattingMode="Ideal"
|
||||||
|
TextOptions.TextRenderingMode="ClearType">
|
||||||
|
|
||||||
|
<shell:WindowChrome.WindowChrome>
|
||||||
|
<shell:WindowChrome
|
||||||
|
CaptionHeight="32"
|
||||||
|
ResizeBorderThickness="6"
|
||||||
|
CornerRadius="0"
|
||||||
|
GlassFrameThickness="0"
|
||||||
|
UseAeroCaptionButtons="False"/>
|
||||||
|
</shell:WindowChrome.WindowChrome>
|
||||||
|
|
||||||
|
<Border BorderBrush="{DynamicResource Wd.Border}" BorderThickness="1">
|
||||||
|
<Grid Margin="24,16,24,20">
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="*"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
|
<!-- Caption -->
|
||||||
|
<Grid Grid.Row="0">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<TextBlock Text="SHOW NOTES"
|
||||||
|
Style="{StaticResource Wd.Text.Caption}"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
<Button Grid.Column="1"
|
||||||
|
Style="{StaticResource Wd.Button.CaptionClose}"
|
||||||
|
Click="OnClose"
|
||||||
|
shell:WindowChrome.IsHitTestVisibleInChrome="True">
|
||||||
|
<Path Data="M 0,0 L 10,10 M 10,0 L 0,10"
|
||||||
|
Stroke="{DynamicResource Wd.Text.Primary}"
|
||||||
|
StrokeThickness="1.2"
|
||||||
|
Width="10" Height="10"
|
||||||
|
Stretch="None"/>
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<TextBlock Grid.Row="1"
|
||||||
|
x:Name="DateLine"
|
||||||
|
Style="{StaticResource Wd.Text.Subtle}"
|
||||||
|
Foreground="{DynamicResource Wd.Text.Tertiary}"
|
||||||
|
FontSize="12"
|
||||||
|
Margin="0,12,0,12"/>
|
||||||
|
|
||||||
|
<!-- Notes view -->
|
||||||
|
<Border Grid.Row="2" Style="{StaticResource Wd.Card}" Padding="0">
|
||||||
|
<ScrollViewer x:Name="Scroller"
|
||||||
|
VerticalScrollBarVisibility="Auto"
|
||||||
|
Padding="14,12">
|
||||||
|
<TextBox x:Name="NotesText"
|
||||||
|
IsReadOnly="True"
|
||||||
|
AcceptsReturn="True"
|
||||||
|
Background="Transparent"
|
||||||
|
BorderThickness="0"
|
||||||
|
FontFamily="{StaticResource Wd.Font.Mono}"
|
||||||
|
FontSize="11"
|
||||||
|
Foreground="{DynamicResource Wd.Text.Primary}"
|
||||||
|
TextWrapping="Wrap"/>
|
||||||
|
</ScrollViewer>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- Inline note input — quick stamping without leaving the dialog -->
|
||||||
|
<Grid Grid.Row="3" Margin="0,12,0,0">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<TextBox Grid.Column="0"
|
||||||
|
x:Name="NewNoteBox"
|
||||||
|
Padding="10,7"
|
||||||
|
FontSize="12"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
KeyDown="OnNewNoteKey"
|
||||||
|
ToolTip="Type a note and press Enter (or click 'Add'). Lands in today's file with a HH:mm:ss timestamp."/>
|
||||||
|
<Button Grid.Column="1"
|
||||||
|
Style="{StaticResource Wd.Button.Primary}"
|
||||||
|
Content="Add"
|
||||||
|
Click="OnAddNote"
|
||||||
|
Margin="8,0,0,0"
|
||||||
|
Padding="20,8"/>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<Grid Grid.Row="4" Margin="0,12,0,0">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<Button Grid.Column="0"
|
||||||
|
Style="{StaticResource Wd.Button.Ghost}"
|
||||||
|
Content="Open in editor"
|
||||||
|
Click="OnOpenInEditor"
|
||||||
|
Padding="14,8"
|
||||||
|
ToolTip="Launch the notes file in the system default editor."/>
|
||||||
|
<Button Grid.Column="2"
|
||||||
|
Style="{StaticResource Wd.Button.Ghost}"
|
||||||
|
Content="Refresh"
|
||||||
|
Click="OnRefresh"
|
||||||
|
Margin="0,0,8,0"
|
||||||
|
Padding="14,8"/>
|
||||||
|
<Button Grid.Column="3"
|
||||||
|
Style="{StaticResource Wd.Button.Ghost}"
|
||||||
|
Content="Close"
|
||||||
|
Click="OnClose"
|
||||||
|
Padding="20,8"/>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</Window>
|
||||||
137
src/TeamsISO.App/NotesWindow.xaml.cs
Normal file
137
src/TeamsISO.App/NotesWindow.xaml.cs
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.IO;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Threading;
|
||||||
|
using TeamsISO.App.Services;
|
||||||
|
|
||||||
|
namespace TeamsISO.App;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Inline viewer for the daily show-notes file. Reads
|
||||||
|
/// <see cref="NotesService.TodayPath"/> on open and polls every 2s while
|
||||||
|
/// shown so REST/OSC-driven note appends surface live without the operator
|
||||||
|
/// having to click Refresh.
|
||||||
|
///
|
||||||
|
/// We don't allow editing here — the file is intentionally a one-way log
|
||||||
|
/// (operator stamps, post-show review). If someone wants to edit, they
|
||||||
|
/// click "Open in editor" and use Notepad.
|
||||||
|
/// </summary>
|
||||||
|
public partial class NotesWindow : Window
|
||||||
|
{
|
||||||
|
private readonly DispatcherTimer _refreshTimer;
|
||||||
|
private long _lastFileSize = -1;
|
||||||
|
private DateTime _lastFileWrite = DateTime.MinValue;
|
||||||
|
|
||||||
|
public NotesWindow()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
_refreshTimer = new DispatcherTimer
|
||||||
|
{
|
||||||
|
Interval = TimeSpan.FromSeconds(2),
|
||||||
|
};
|
||||||
|
_refreshTimer.Tick += (_, _) => RefreshIfChanged();
|
||||||
|
Loaded += (_, _) =>
|
||||||
|
{
|
||||||
|
DateLine.Text = $"{DateTimeOffset.Now:dddd, MMMM d, yyyy} · {NotesService.TodayPath}";
|
||||||
|
ReloadFromDisk();
|
||||||
|
_refreshTimer.Start();
|
||||||
|
};
|
||||||
|
Closed += (_, _) => _refreshTimer.Stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnClose(object sender, RoutedEventArgs e) => Close();
|
||||||
|
|
||||||
|
private void OnRefresh(object sender, RoutedEventArgs e) => ReloadFromDisk();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Cheap mtime/size check — only re-reads the file when something changed.
|
||||||
|
/// Saves the textbox a flicker on every 2s tick when no notes are being
|
||||||
|
/// added. Falls through to a full reload if the file got smaller (operator
|
||||||
|
/// might have edited externally).
|
||||||
|
/// </summary>
|
||||||
|
private void RefreshIfChanged()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var path = NotesService.TodayPath;
|
||||||
|
if (!File.Exists(path)) return;
|
||||||
|
var info = new FileInfo(path);
|
||||||
|
if (info.Length != _lastFileSize || info.LastWriteTimeUtc != _lastFileWrite)
|
||||||
|
ReloadFromDisk();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Disk hiccups shouldn't stop the timer.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ReloadFromDisk()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var path = NotesService.TodayPath;
|
||||||
|
if (!File.Exists(path))
|
||||||
|
{
|
||||||
|
NotesText.Text = "No notes yet. Stamp one via the REST or OSC endpoint and refresh.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var info = new FileInfo(path);
|
||||||
|
_lastFileSize = info.Length;
|
||||||
|
_lastFileWrite = info.LastWriteTimeUtc;
|
||||||
|
NotesText.Text = File.ReadAllText(path);
|
||||||
|
// Scroll to bottom so the latest stamp is visible — operators are
|
||||||
|
// typically reading "what just happened" not "what happened first."
|
||||||
|
Dispatcher.BeginInvoke(new Action(() =>
|
||||||
|
{
|
||||||
|
Scroller.ScrollToEnd();
|
||||||
|
}), DispatcherPriority.Background);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
NotesText.Text = "Couldn't read notes file: " + ex.Message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Append the input box's text to today's notes file via NotesService,
|
||||||
|
/// then clear the box and refresh the view. Bound to the "Add" button +
|
||||||
|
/// Enter key in the input. Empty/whitespace input is a no-op.
|
||||||
|
/// </summary>
|
||||||
|
private void OnAddNote(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
var text = NewNoteBox.Text?.Trim();
|
||||||
|
if (string.IsNullOrEmpty(text)) return;
|
||||||
|
if (NotesService.Append(text))
|
||||||
|
{
|
||||||
|
NewNoteBox.Clear();
|
||||||
|
ReloadFromDisk();
|
||||||
|
NewNoteBox.Focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Enter key in the input commits the note, same as the Add button.</summary>
|
||||||
|
private void OnNewNoteKey(object sender, System.Windows.Input.KeyEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.Key == System.Windows.Input.Key.Enter)
|
||||||
|
{
|
||||||
|
OnAddNote(sender, e);
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnOpenInEditor(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Process.Start(new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = NotesService.TodayPath,
|
||||||
|
UseShellExecute = true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Best-effort; if no .md handler is registered the OS shows its own dialog.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
294
src/TeamsISO.App/OnboardingWindow.xaml
Normal file
294
src/TeamsISO.App/OnboardingWindow.xaml
Normal file
|
|
@ -0,0 +1,294 @@
|
||||||
|
<Window x:Class="TeamsISO.App.OnboardingWindow"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:shell="clr-namespace:System.Windows.Shell;assembly=PresentationFramework"
|
||||||
|
Title="Welcome to TeamsISO"
|
||||||
|
Icon="/Assets/teamsiso.ico"
|
||||||
|
Width="560" Height="600"
|
||||||
|
WindowStartupLocation="CenterOwner"
|
||||||
|
WindowStyle="None"
|
||||||
|
ResizeMode="NoResize"
|
||||||
|
Background="{DynamicResource Wd.Canvas}"
|
||||||
|
UseLayoutRounding="True"
|
||||||
|
TextOptions.TextFormattingMode="Ideal"
|
||||||
|
TextOptions.TextRenderingMode="ClearType">
|
||||||
|
|
||||||
|
<shell:WindowChrome.WindowChrome>
|
||||||
|
<shell:WindowChrome
|
||||||
|
CaptionHeight="32"
|
||||||
|
ResizeBorderThickness="0"
|
||||||
|
CornerRadius="0"
|
||||||
|
GlassFrameThickness="0"
|
||||||
|
UseAeroCaptionButtons="False"/>
|
||||||
|
</shell:WindowChrome.WindowChrome>
|
||||||
|
|
||||||
|
<Border BorderBrush="{DynamicResource Wd.Border}" BorderThickness="1">
|
||||||
|
<Grid Margin="32,20,32,24">
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="*"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
|
<!-- Caption -->
|
||||||
|
<Grid Grid.Row="0">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<TextBlock Text="WELCOME"
|
||||||
|
Style="{StaticResource Wd.Text.Caption}"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
<Button Grid.Column="1"
|
||||||
|
Style="{StaticResource Wd.Button.CaptionClose}"
|
||||||
|
Click="OnDismiss"
|
||||||
|
shell:WindowChrome.IsHitTestVisibleInChrome="True">
|
||||||
|
<Path Data="M 0,0 L 10,10 M 10,0 L 0,10"
|
||||||
|
Stroke="{DynamicResource Wd.Text.Primary}"
|
||||||
|
StrokeThickness="1.2"
|
||||||
|
Width="10" Height="10"
|
||||||
|
Stretch="None"/>
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!-- Hero -->
|
||||||
|
<StackPanel Grid.Row="1" Margin="0,16,0,20">
|
||||||
|
<Image Source="/Assets/dragon-mark.png"
|
||||||
|
Width="56" Height="56"
|
||||||
|
HorizontalAlignment="Left"
|
||||||
|
Margin="0,0,0,12"
|
||||||
|
RenderOptions.BitmapScalingMode="HighQuality"/>
|
||||||
|
<TextBlock Text="TeamsISO routes Microsoft Teams participants as isolated NDI feeds."
|
||||||
|
Style="{StaticResource Wd.Text.Title}"
|
||||||
|
TextWrapping="Wrap"/>
|
||||||
|
<TextBlock Text="A few one-time setup notes before you start."
|
||||||
|
Style="{StaticResource Wd.Text.Subtle}"
|
||||||
|
Foreground="{DynamicResource Wd.Text.Tertiary}"
|
||||||
|
Margin="0,6,0,0"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- Body: numbered checklist -->
|
||||||
|
<ScrollViewer Grid.Row="2" VerticalScrollBarVisibility="Auto">
|
||||||
|
<StackPanel>
|
||||||
|
|
||||||
|
<!-- Step 1 — NDI runtime -->
|
||||||
|
<Border Style="{StaticResource Wd.Card}" Padding="16" Margin="0,0,0,12">
|
||||||
|
<StackPanel>
|
||||||
|
<StackPanel Orientation="Horizontal" Margin="0,0,0,8">
|
||||||
|
<Border Width="22" Height="22"
|
||||||
|
CornerRadius="11"
|
||||||
|
Background="{DynamicResource Wd.Accent.CyanMuted}"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Margin="0,0,12,0">
|
||||||
|
<TextBlock Text="1"
|
||||||
|
Foreground="{DynamicResource Wd.Accent.Cyan}"
|
||||||
|
FontFamily="{StaticResource Wd.Font.Mono}"
|
||||||
|
FontSize="12"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
</Border>
|
||||||
|
<TextBlock Text="Install the NDI 6 runtime"
|
||||||
|
Style="{StaticResource Wd.Text.Heading}"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
</StackPanel>
|
||||||
|
<TextBlock TextWrapping="Wrap"
|
||||||
|
Style="{StaticResource Wd.Text.Body}"
|
||||||
|
FontSize="12"
|
||||||
|
Foreground="{DynamicResource Wd.Text.Secondary}"
|
||||||
|
Text="TeamsISO depends on NDI 6 from NewTek/Vizrt. Get it at ndi.video/tools — pick the Tools bundle for free, or use the Runtime-only installer if you have an NDI subscription."/>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- Step 2 — Teams NDI permission -->
|
||||||
|
<Border Style="{StaticResource Wd.Card}" Padding="16" Margin="0,0,0,12">
|
||||||
|
<StackPanel>
|
||||||
|
<StackPanel Orientation="Horizontal" Margin="0,0,0,8">
|
||||||
|
<Border Width="22" Height="22"
|
||||||
|
CornerRadius="11"
|
||||||
|
Background="{DynamicResource Wd.Accent.CyanMuted}"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Margin="0,0,12,0">
|
||||||
|
<TextBlock Text="2"
|
||||||
|
Foreground="{DynamicResource Wd.Accent.Cyan}"
|
||||||
|
FontFamily="{StaticResource Wd.Font.Mono}"
|
||||||
|
FontSize="12"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
</Border>
|
||||||
|
<TextBlock Text="Enable broadcast in Teams"
|
||||||
|
Style="{StaticResource Wd.Text.Heading}"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
</StackPanel>
|
||||||
|
<TextBlock TextWrapping="Wrap"
|
||||||
|
Style="{StaticResource Wd.Text.Body}"
|
||||||
|
FontSize="12"
|
||||||
|
Foreground="{DynamicResource Wd.Text.Secondary}"
|
||||||
|
Text="In Microsoft Teams: Settings → Devices → 'Broadcast over NDI / SDI'. Your Teams admin may need to enable this at the tenant level (Teams admin center → Meetings → Meeting policies → 'Allow NDI broadcasting')."/>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- Step 3 — Transcoder topology -->
|
||||||
|
<Border Style="{StaticResource Wd.Card}" Padding="16" Margin="0,0,0,12">
|
||||||
|
<StackPanel>
|
||||||
|
<StackPanel Orientation="Horizontal" Margin="0,0,0,8">
|
||||||
|
<Border Width="22" Height="22"
|
||||||
|
CornerRadius="11"
|
||||||
|
Background="{DynamicResource Wd.Accent.CyanMuted}"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Margin="0,0,12,0">
|
||||||
|
<TextBlock Text="3"
|
||||||
|
Foreground="{DynamicResource Wd.Accent.Cyan}"
|
||||||
|
FontFamily="{StaticResource Wd.Font.Mono}"
|
||||||
|
FontSize="12"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
</Border>
|
||||||
|
<TextBlock Text="Click 'Apply transcoder topology'"
|
||||||
|
Style="{StaticResource Wd.Text.Heading}"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
</StackPanel>
|
||||||
|
<TextBlock TextWrapping="Wrap"
|
||||||
|
Style="{StaticResource Wd.Text.Body}"
|
||||||
|
FontSize="12"
|
||||||
|
Foreground="{DynamicResource Wd.Text.Secondary}"
|
||||||
|
Text="Settings → NETWORK → Apply transcoder topology. This pins Teams' raw NDI broadcasts to a private 'teamsiso-input' group so they don't pollute the Public network. Restart Teams once after applying."/>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- Step 4 — Save a preset -->
|
||||||
|
<Border Style="{StaticResource Wd.Card}" Padding="16" Margin="0,0,0,12">
|
||||||
|
<StackPanel>
|
||||||
|
<StackPanel Orientation="Horizontal" Margin="0,0,0,8">
|
||||||
|
<Border Width="22" Height="22"
|
||||||
|
CornerRadius="11"
|
||||||
|
Background="{DynamicResource Wd.Accent.CyanMuted}"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Margin="0,0,12,0">
|
||||||
|
<TextBlock Text="4"
|
||||||
|
Foreground="{DynamicResource Wd.Accent.Cyan}"
|
||||||
|
FontFamily="{StaticResource Wd.Font.Mono}"
|
||||||
|
FontSize="12"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
</Border>
|
||||||
|
<TextBlock Text="Save a preset for recurring shows"
|
||||||
|
Style="{StaticResource Wd.Text.Heading}"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
</StackPanel>
|
||||||
|
<TextBlock TextWrapping="Wrap"
|
||||||
|
Style="{StaticResource Wd.Text.Body}"
|
||||||
|
FontSize="12"
|
||||||
|
Foreground="{DynamicResource Wd.Text.Secondary}"
|
||||||
|
Text="Once you've toggled the right ISOs, click Presets in the participants header to save them by display name. Turn on 'Auto-apply last preset on launch' under DISPLAY settings and TeamsISO will restore that routing on every subsequent launch."/>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- Step 5 — Headless Teams ("I only see TeamsISO") -->
|
||||||
|
<Border Style="{StaticResource Wd.Card}" Padding="16" Margin="0,0,0,12">
|
||||||
|
<StackPanel>
|
||||||
|
<StackPanel Orientation="Horizontal" Margin="0,0,0,8">
|
||||||
|
<Border Width="22" Height="22"
|
||||||
|
CornerRadius="11"
|
||||||
|
Background="{DynamicResource Wd.Accent.CyanMuted}"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Margin="0,0,12,0">
|
||||||
|
<TextBlock Text="5"
|
||||||
|
Foreground="{DynamicResource Wd.Accent.Cyan}"
|
||||||
|
FontFamily="{StaticResource Wd.Font.Mono}"
|
||||||
|
FontSize="12"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
</Border>
|
||||||
|
<TextBlock Text="Run Teams headless (optional)"
|
||||||
|
Style="{StaticResource Wd.Text.Heading}"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
</StackPanel>
|
||||||
|
<TextBlock TextWrapping="Wrap"
|
||||||
|
Style="{StaticResource Wd.Text.Body}"
|
||||||
|
FontSize="12"
|
||||||
|
Foreground="{DynamicResource Wd.Text.Secondary}"
|
||||||
|
Text="To use TeamsISO as your only window: tick both 'Launch Microsoft Teams on TeamsISO startup' and 'Auto-hide Teams windows when launched' under Settings → DISPLAY. Teams runs in the background; the IN-CALL bar shows the meeting state (READY / IN CALL · meeting title), the Mute/Camera/Share/Leave buttons drive Teams via UIAutomation, and the URL paste-box joins meetings directly. Use the eye-icon button in the left rail to manually restore Teams' windows when you need them."/>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- Step 6 — Where things live -->
|
||||||
|
<Border Style="{StaticResource Wd.Card}" Padding="16" Margin="0,0,0,12">
|
||||||
|
<StackPanel>
|
||||||
|
<StackPanel Orientation="Horizontal" Margin="0,0,0,8">
|
||||||
|
<Border Width="22" Height="22"
|
||||||
|
CornerRadius="11"
|
||||||
|
Background="{DynamicResource Wd.Accent.CyanMuted}"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Margin="0,0,12,0">
|
||||||
|
<TextBlock Text="6"
|
||||||
|
Foreground="{DynamicResource Wd.Accent.Cyan}"
|
||||||
|
FontFamily="{StaticResource Wd.Font.Mono}"
|
||||||
|
FontSize="12"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
</Border>
|
||||||
|
<TextBlock Text="Drive from another machine (optional)"
|
||||||
|
Style="{StaticResource Wd.Text.Heading}"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
</StackPanel>
|
||||||
|
<TextBlock TextWrapping="Wrap"
|
||||||
|
Style="{StaticResource Wd.Text.Body}"
|
||||||
|
FontSize="12"
|
||||||
|
Foreground="{DynamicResource Wd.Text.Secondary}"
|
||||||
|
Text="For headless host PC + thin-client setups: tick 'Control surface' then 'LAN-reachable' under DISPLAY. TeamsISO listens on http://<your-lan-ip>:9755/ui — open that URL from any browser on the LAN. First-time use needs a one-shot 'netsh http add urlacl url=http://+:9755/ user=Everyone' in an Administrator PowerShell."/>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- Step 7 — Where things live -->
|
||||||
|
<Border Style="{StaticResource Wd.Card}" Padding="16">
|
||||||
|
<StackPanel>
|
||||||
|
<StackPanel Orientation="Horizontal" Margin="0,0,0,8">
|
||||||
|
<Border Width="22" Height="22"
|
||||||
|
CornerRadius="11"
|
||||||
|
Background="{DynamicResource Wd.Accent.CyanMuted}"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Margin="0,0,12,0">
|
||||||
|
<TextBlock Text="7"
|
||||||
|
Foreground="{DynamicResource Wd.Accent.Cyan}"
|
||||||
|
FontFamily="{StaticResource Wd.Font.Mono}"
|
||||||
|
FontSize="12"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
</Border>
|
||||||
|
<TextBlock Text="If something breaks…"
|
||||||
|
Style="{StaticResource Wd.Text.Heading}"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
</StackPanel>
|
||||||
|
<TextBlock TextWrapping="Wrap"
|
||||||
|
Style="{StaticResource Wd.Text.Body}"
|
||||||
|
FontSize="12"
|
||||||
|
Foreground="{DynamicResource Wd.Text.Secondary}"
|
||||||
|
Text="Diagnostic logs roll daily under %LOCALAPPDATA%\TeamsISO\Logs. Settings live at %APPDATA%\TeamsISO\config.json; presets at %LOCALAPPDATA%\TeamsISO\presets.json. Attach the most recent log when filing an issue at forge.wilddragon.net/zgaetano/teamsiso."/>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<Grid Grid.Row="3" Margin="0,20,0,0">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<CheckBox Grid.Column="0"
|
||||||
|
x:Name="SuppressBox"
|
||||||
|
Content="Don't show this again"
|
||||||
|
IsChecked="True"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
<Button Grid.Column="2"
|
||||||
|
Style="{StaticResource Wd.Button.Primary}"
|
||||||
|
Content="Get started"
|
||||||
|
Click="OnDismiss"
|
||||||
|
Padding="22,8"/>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</Window>
|
||||||
57
src/TeamsISO.App/OnboardingWindow.xaml.cs
Normal file
57
src/TeamsISO.App/OnboardingWindow.xaml.cs
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
using System.IO;
|
||||||
|
using System.Windows;
|
||||||
|
|
||||||
|
namespace TeamsISO.App;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// First-launch welcome dialog. Walks the user through the once-per-machine
|
||||||
|
/// setup that's not derivable from the UI alone (NDI runtime install, Teams
|
||||||
|
/// admin permission, transcoder topology) and points them at where logs and
|
||||||
|
/// presets live for later self-service.
|
||||||
|
///
|
||||||
|
/// Suppression is governed by a marker file at
|
||||||
|
/// <c>%LOCALAPPDATA%\TeamsISO\onboarding.flag</c>. The presence of the file —
|
||||||
|
/// regardless of contents — means "don't show again." The user can restore
|
||||||
|
/// the dialog by deleting that file.
|
||||||
|
/// </summary>
|
||||||
|
public partial class OnboardingWindow : Window
|
||||||
|
{
|
||||||
|
private static string FlagPath =>
|
||||||
|
Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||||
|
"TeamsISO", "onboarding.flag");
|
||||||
|
|
||||||
|
public OnboardingWindow() => InitializeComponent();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns true on first launch (and on launches where the user previously
|
||||||
|
/// unchecked "Don't show this again" so the marker file was never created).
|
||||||
|
/// </summary>
|
||||||
|
public static bool ShouldShow()
|
||||||
|
{
|
||||||
|
try { return !File.Exists(FlagPath); }
|
||||||
|
catch { return false; } // permission errors → assume already shown
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnDismiss(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (SuppressBox.IsChecked == true)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var dir = Path.GetDirectoryName(FlagPath);
|
||||||
|
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
|
||||||
|
File.WriteAllText(FlagPath,
|
||||||
|
"Onboarding dialog dismissed at " + DateTimeOffset.UtcNow.ToString("o") + ". " +
|
||||||
|
"Delete this file to see the welcome dialog again on next launch.");
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Disk full / permission denied — show the dialog again next launch
|
||||||
|
// rather than fail noisily.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DialogResult = true;
|
||||||
|
Close();
|
||||||
|
}
|
||||||
|
}
|
||||||
209
src/TeamsISO.App/PresetsDialog.xaml
Normal file
209
src/TeamsISO.App/PresetsDialog.xaml
Normal file
|
|
@ -0,0 +1,209 @@
|
||||||
|
<Window x:Class="TeamsISO.App.PresetsDialog"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:shell="clr-namespace:System.Windows.Shell;assembly=PresentationFramework"
|
||||||
|
Title="Operator presets"
|
||||||
|
Icon="/Assets/teamsiso.ico"
|
||||||
|
Width="460" Height="520"
|
||||||
|
WindowStartupLocation="CenterOwner"
|
||||||
|
WindowStyle="None"
|
||||||
|
ResizeMode="NoResize"
|
||||||
|
Background="{DynamicResource Wd.Canvas}"
|
||||||
|
UseLayoutRounding="True"
|
||||||
|
TextOptions.TextFormattingMode="Ideal"
|
||||||
|
TextOptions.TextRenderingMode="ClearType">
|
||||||
|
|
||||||
|
<shell:WindowChrome.WindowChrome>
|
||||||
|
<shell:WindowChrome
|
||||||
|
CaptionHeight="32"
|
||||||
|
ResizeBorderThickness="0"
|
||||||
|
CornerRadius="0"
|
||||||
|
GlassFrameThickness="0"
|
||||||
|
UseAeroCaptionButtons="False"/>
|
||||||
|
</shell:WindowChrome.WindowChrome>
|
||||||
|
|
||||||
|
<Border BorderBrush="{DynamicResource Wd.Border}" BorderThickness="1">
|
||||||
|
<Grid Margin="24,16,24,20">
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="*"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
|
<!-- Caption -->
|
||||||
|
<Grid Grid.Row="0">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<TextBlock Text="OPERATOR PRESETS"
|
||||||
|
Style="{StaticResource Wd.Text.Caption}"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
<Button Grid.Column="1"
|
||||||
|
Style="{StaticResource Wd.Button.CaptionClose}"
|
||||||
|
Click="OnCancel"
|
||||||
|
shell:WindowChrome.IsHitTestVisibleInChrome="True">
|
||||||
|
<Path Data="M 0,0 L 10,10 M 10,0 L 0,10"
|
||||||
|
Stroke="{DynamicResource Wd.Text.Primary}"
|
||||||
|
StrokeThickness="1.2"
|
||||||
|
Width="10" Height="10"
|
||||||
|
Stretch="None"/>
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<TextBlock Grid.Row="1"
|
||||||
|
Text="Save the current ISO assignments as a named preset, or load an existing preset to restore them."
|
||||||
|
Style="{StaticResource Wd.Text.Subtle}"
|
||||||
|
Foreground="{DynamicResource Wd.Text.Tertiary}"
|
||||||
|
FontSize="12"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
Margin="0,12,0,16"/>
|
||||||
|
|
||||||
|
<!-- Save row: name textbox + Save button -->
|
||||||
|
<Grid Grid.Row="2">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<TextBox x:Name="NameBox"
|
||||||
|
ToolTip="Name for the new preset (or pick an existing one to overwrite)"
|
||||||
|
VerticalContentAlignment="Center"
|
||||||
|
Margin="0,0,12,0"/>
|
||||||
|
<Button Grid.Column="1"
|
||||||
|
Style="{StaticResource Wd.Button.Primary}"
|
||||||
|
Content="Save"
|
||||||
|
Click="OnSave"
|
||||||
|
Padding="20,8"/>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!-- Existing presets list -->
|
||||||
|
<Border Grid.Row="3"
|
||||||
|
Style="{StaticResource Wd.Card}"
|
||||||
|
Padding="0"
|
||||||
|
Margin="0,16,0,0">
|
||||||
|
<Grid>
|
||||||
|
<ListBox x:Name="PresetsList"
|
||||||
|
Background="Transparent"
|
||||||
|
BorderThickness="0"
|
||||||
|
SelectionMode="Single"
|
||||||
|
ScrollViewer.HorizontalScrollBarVisibility="Disabled"
|
||||||
|
SelectionChanged="OnSelectionChanged">
|
||||||
|
<ListBox.ItemContainerStyle>
|
||||||
|
<Style TargetType="ListBoxItem">
|
||||||
|
<Setter Property="Padding" Value="12,8"/>
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource Wd.Text.Primary}"/>
|
||||||
|
<Setter Property="Background" Value="Transparent"/>
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate TargetType="ListBoxItem">
|
||||||
|
<Border x:Name="Bd"
|
||||||
|
Background="{TemplateBinding Background}"
|
||||||
|
Padding="{TemplateBinding Padding}"
|
||||||
|
CornerRadius="4"
|
||||||
|
Margin="4,2">
|
||||||
|
<ContentPresenter/>
|
||||||
|
</Border>
|
||||||
|
<ControlTemplate.Triggers>
|
||||||
|
<Trigger Property="IsMouseOver" Value="True">
|
||||||
|
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource Wd.SurfaceHover}"/>
|
||||||
|
</Trigger>
|
||||||
|
<Trigger Property="IsSelected" Value="True">
|
||||||
|
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource Wd.SurfaceElevated}"/>
|
||||||
|
</Trigger>
|
||||||
|
</ControlTemplate.Triggers>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
</ListBox.ItemContainerStyle>
|
||||||
|
<ListBox.ItemTemplate>
|
||||||
|
<DataTemplate>
|
||||||
|
<StackPanel>
|
||||||
|
<TextBlock Text="{Binding Name}"
|
||||||
|
Style="{StaticResource Wd.Text.Body}"
|
||||||
|
FontWeight="Medium"/>
|
||||||
|
<TextBlock Style="{StaticResource Wd.Text.Mono}"
|
||||||
|
FontSize="10"
|
||||||
|
Foreground="{DynamicResource Wd.Text.Tertiary}">
|
||||||
|
<Run Text="{Binding SavedAtDisplay, Mode=OneWay}"/>
|
||||||
|
<Run Text=" · "/>
|
||||||
|
<Run Text="{Binding AssignmentCount, Mode=OneWay}"/>
|
||||||
|
<Run Text=" assignments"/>
|
||||||
|
</TextBlock>
|
||||||
|
</StackPanel>
|
||||||
|
</DataTemplate>
|
||||||
|
</ListBox.ItemTemplate>
|
||||||
|
</ListBox>
|
||||||
|
|
||||||
|
<!-- Empty-state inside the card -->
|
||||||
|
<TextBlock x:Name="EmptyState"
|
||||||
|
Text="No presets yet. Type a name above and click Save."
|
||||||
|
Style="{StaticResource Wd.Text.Subtle}"
|
||||||
|
Foreground="{DynamicResource Wd.Text.Tertiary}"
|
||||||
|
FontSize="12"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Visibility="Collapsed"/>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- Footer buttons -->
|
||||||
|
<Grid Grid.Row="4" Margin="0,16,0,0">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<Button Grid.Column="0"
|
||||||
|
Style="{StaticResource Wd.Button.Ghost}"
|
||||||
|
Content="Delete"
|
||||||
|
Click="OnDelete"
|
||||||
|
IsEnabled="False"
|
||||||
|
x:Name="DeleteButton"
|
||||||
|
Padding="14,8"/>
|
||||||
|
<Button Grid.Column="1"
|
||||||
|
Style="{StaticResource Wd.Button.Ghost}"
|
||||||
|
Content="Duplicate"
|
||||||
|
Click="OnDuplicate"
|
||||||
|
IsEnabled="False"
|
||||||
|
x:Name="DuplicateButton"
|
||||||
|
Margin="8,0,0,0"
|
||||||
|
Padding="14,8"
|
||||||
|
ToolTip="Copy the selected preset to a new name. Useful when iterating on variants of a recurring show."/>
|
||||||
|
<Button Grid.Column="2"
|
||||||
|
Style="{StaticResource Wd.Button.Ghost}"
|
||||||
|
Content="Export…"
|
||||||
|
Click="OnExport"
|
||||||
|
Margin="8,0,0,0"
|
||||||
|
Padding="14,8"
|
||||||
|
ToolTip="Save every preset as a single .json bundle. Useful for moving a curated library between machines, or sharing with a colleague."/>
|
||||||
|
<Button Grid.Column="3"
|
||||||
|
Style="{StaticResource Wd.Button.Ghost}"
|
||||||
|
Content="Import…"
|
||||||
|
Click="OnImport"
|
||||||
|
Margin="8,0,0,0"
|
||||||
|
Padding="14,8"
|
||||||
|
ToolTip="Load presets from a .json bundle. Existing presets with the same name are skipped unless you confirm overwrite."/>
|
||||||
|
<Button Grid.Column="5"
|
||||||
|
Style="{StaticResource Wd.Button.Ghost}"
|
||||||
|
Content="Cancel"
|
||||||
|
Click="OnCancel"
|
||||||
|
Margin="0,0,8,0"
|
||||||
|
Padding="14,8"/>
|
||||||
|
<Button Grid.Column="6"
|
||||||
|
Style="{StaticResource Wd.Button.Primary}"
|
||||||
|
Content="Apply"
|
||||||
|
Click="OnApply"
|
||||||
|
IsEnabled="False"
|
||||||
|
x:Name="ApplyButton"
|
||||||
|
Padding="20,8"/>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</Window>
|
||||||
426
src/TeamsISO.App/PresetsDialog.xaml.cs
Normal file
426
src/TeamsISO.App/PresetsDialog.xaml.cs
Normal file
|
|
@ -0,0 +1,426 @@
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Controls;
|
||||||
|
using TeamsISO.App.Services;
|
||||||
|
using TeamsISO.App.ViewModels;
|
||||||
|
using TeamsISO.Engine.Controller;
|
||||||
|
|
||||||
|
namespace TeamsISO.App;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Modal dialog for saving and loading operator presets. Owned by
|
||||||
|
/// <see cref="MainWindow"/>; given a snapshot of the current
|
||||||
|
/// <see cref="ParticipantViewModel"/> list and the
|
||||||
|
/// <see cref="IIsoController"/> so it can re-apply assignments
|
||||||
|
/// (which requires calling EnableIsoAsync/DisableIsoAsync on the engine).
|
||||||
|
/// </summary>
|
||||||
|
public partial class PresetsDialog : Window
|
||||||
|
{
|
||||||
|
private readonly IIsoController _controller;
|
||||||
|
private readonly IReadOnlyList<ParticipantViewModel> _participants;
|
||||||
|
private readonly ToastViewModel? _toast;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Display-side wrapper for an <see cref="OperatorPresetStore.Preset"/>.
|
||||||
|
/// Adds derived presentation-only properties so the ListBox template can
|
||||||
|
/// render without inline converters or value-conversion logic.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PresetRow
|
||||||
|
{
|
||||||
|
public OperatorPresetStore.Preset Preset { get; }
|
||||||
|
public string Name => Preset.Name;
|
||||||
|
public string SavedAtDisplay => Preset.SavedAt.LocalDateTime.ToString("yyyy-MM-dd HH:mm");
|
||||||
|
public int AssignmentCount => Preset.Assignments.Count(a => a.Enabled);
|
||||||
|
public PresetRow(OperatorPresetStore.Preset preset) => Preset = preset;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ObservableCollection<PresetRow> Rows { get; } = new();
|
||||||
|
|
||||||
|
public PresetsDialog(
|
||||||
|
IIsoController controller,
|
||||||
|
IReadOnlyList<ParticipantViewModel> participants,
|
||||||
|
ToastViewModel? toast = null)
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
_controller = controller;
|
||||||
|
_participants = participants;
|
||||||
|
_toast = toast;
|
||||||
|
PresetsList.ItemsSource = Rows;
|
||||||
|
ReloadPresets();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Refresh the ListBox from disk and reflect emptiness in the empty-state TextBlock.</summary>
|
||||||
|
private void ReloadPresets()
|
||||||
|
{
|
||||||
|
Rows.Clear();
|
||||||
|
foreach (var p in OperatorPresetStore.LoadAll().OrderByDescending(p => p.SavedAt))
|
||||||
|
Rows.Add(new PresetRow(p));
|
||||||
|
EmptyState.Visibility = Rows.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
|
||||||
|
UpdateButtonStates();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateButtonStates()
|
||||||
|
{
|
||||||
|
var hasSelection = PresetsList.SelectedItem is PresetRow;
|
||||||
|
ApplyButton.IsEnabled = hasSelection;
|
||||||
|
DeleteButton.IsEnabled = hasSelection;
|
||||||
|
DuplicateButton.IsEnabled = hasSelection;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnSelectionChanged(object sender, SelectionChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (PresetsList.SelectedItem is PresetRow row)
|
||||||
|
{
|
||||||
|
// Mirror the selected name into the textbox so a re-save overwrites
|
||||||
|
// by default; operator can still type a new name to fork.
|
||||||
|
NameBox.Text = row.Name;
|
||||||
|
}
|
||||||
|
UpdateButtonStates();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnSave(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
var name = NameBox.Text?.Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(name))
|
||||||
|
{
|
||||||
|
_toast?.Warn("Enter a name for the preset");
|
||||||
|
NameBox.Focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var assignments = _participants
|
||||||
|
.Select(p => new OperatorPresetStore.Assignment(
|
||||||
|
DisplayName: p.DisplayName,
|
||||||
|
CustomOutputName: string.IsNullOrWhiteSpace(p.CustomName) ? null : p.CustomName,
|
||||||
|
Enabled: p.IsEnabled))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var existing = OperatorPresetStore.Find(name);
|
||||||
|
if (existing is not null)
|
||||||
|
{
|
||||||
|
var confirm = MessageBox.Show(
|
||||||
|
this,
|
||||||
|
$"A preset named \"{name}\" already exists. Overwrite it?",
|
||||||
|
"TeamsISO — Overwrite preset",
|
||||||
|
MessageBoxButton.YesNo,
|
||||||
|
MessageBoxImage.Question,
|
||||||
|
MessageBoxResult.No);
|
||||||
|
if (confirm != MessageBoxResult.Yes) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
OperatorPresetStore.Save(new OperatorPresetStore.Preset(
|
||||||
|
Name: name,
|
||||||
|
SavedAt: DateTimeOffset.Now,
|
||||||
|
Assignments: assignments));
|
||||||
|
_toast?.Show($"Saved preset \"{name}\"");
|
||||||
|
ReloadPresets();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
MessageBox.Show(
|
||||||
|
this,
|
||||||
|
$"Could not save preset.\n\n{ex.Message}",
|
||||||
|
"TeamsISO — Save preset",
|
||||||
|
MessageBoxButton.OK,
|
||||||
|
MessageBoxImage.Warning);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Apply the selected preset: walks the current participants list, matching
|
||||||
|
/// by display name (the only stable join key across meetings — Ids are
|
||||||
|
/// regenerated each meeting). For each match, set the custom output name and
|
||||||
|
/// reconcile its enabled state with the preset by calling EnableIsoAsync /
|
||||||
|
/// DisableIsoAsync as needed. Participants in the preset who aren't in the
|
||||||
|
/// current meeting are silently skipped (and reported in the toast).
|
||||||
|
/// </summary>
|
||||||
|
private async void OnApply(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (PresetsList.SelectedItem is not PresetRow row) return;
|
||||||
|
|
||||||
|
ApplyButton.IsEnabled = false;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// PresetApplier owns the apply loop — same code path the REST control
|
||||||
|
// surface and auto-apply-on-launch use. Dialog passes null dispatcher
|
||||||
|
// since OnApply already runs on the UI thread.
|
||||||
|
var result = await PresetApplier.ApplyAsync(
|
||||||
|
row.Preset, _participants, _controller, dispatcher: null);
|
||||||
|
|
||||||
|
var summary = result.Skipped > 0
|
||||||
|
? $"Applied \"{row.Name}\" — {result.Changed} change(s); {result.Skipped} not in meeting"
|
||||||
|
: $"Applied \"{row.Name}\" — {result.Changed} change(s)";
|
||||||
|
_toast?.Show(summary);
|
||||||
|
DialogResult = true;
|
||||||
|
Close();
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
ApplyButton.IsEnabled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Duplicate the selected preset under a new name. We auto-suggest
|
||||||
|
/// "<original> (copy)" but pop a tiny input dialog so the operator
|
||||||
|
/// can pick something meaningful. WPF doesn't ship an InputBox; we
|
||||||
|
/// use a quick custom prompt below.
|
||||||
|
/// </summary>
|
||||||
|
private void OnDuplicate(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (PresetsList.SelectedItem is not PresetRow row) return;
|
||||||
|
|
||||||
|
var defaultName = SuggestCopyName(row.Name);
|
||||||
|
var newName = PromptForName("Duplicate preset", "New name:", defaultName);
|
||||||
|
if (string.IsNullOrWhiteSpace(newName)) return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var existing = OperatorPresetStore.Find(newName);
|
||||||
|
if (existing is not null)
|
||||||
|
{
|
||||||
|
var confirm = MessageBox.Show(
|
||||||
|
this,
|
||||||
|
$"A preset named \"{newName}\" already exists. Overwrite it?",
|
||||||
|
"TeamsISO — Duplicate preset",
|
||||||
|
MessageBoxButton.YesNo,
|
||||||
|
MessageBoxImage.Question,
|
||||||
|
MessageBoxResult.No);
|
||||||
|
if (confirm != MessageBoxResult.Yes) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-using Save() with a fresh SavedAt timestamp — Save's overwrite
|
||||||
|
// semantics handle the name-collision case cleanly.
|
||||||
|
OperatorPresetStore.Save(new OperatorPresetStore.Preset(
|
||||||
|
Name: newName,
|
||||||
|
SavedAt: DateTimeOffset.Now,
|
||||||
|
Assignments: row.Preset.Assignments));
|
||||||
|
_toast?.Show($"Duplicated to \"{newName}\"");
|
||||||
|
ReloadPresets();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
MessageBox.Show(this,
|
||||||
|
$"Could not duplicate preset.\n\n{ex.Message}",
|
||||||
|
"TeamsISO — Duplicate preset",
|
||||||
|
MessageBoxButton.OK,
|
||||||
|
MessageBoxImage.Warning);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Suggest a name for a copy: "Foo" → "Foo (copy)", "Foo (copy)" → "Foo (copy 2)".
|
||||||
|
/// Bumps the digit if the operator iterates from a copy.
|
||||||
|
/// </summary>
|
||||||
|
private static string SuggestCopyName(string original)
|
||||||
|
{
|
||||||
|
if (!original.EndsWith(")", StringComparison.Ordinal))
|
||||||
|
return original + " (copy)";
|
||||||
|
var match = System.Text.RegularExpressions.Regex.Match(original, @" \(copy(?: (\d+))?\)$");
|
||||||
|
if (!match.Success) return original + " (copy)";
|
||||||
|
var n = match.Groups[1].Success && int.TryParse(match.Groups[1].Value, out var parsed) ? parsed + 1 : 2;
|
||||||
|
return original[..(original.Length - match.Length)] + $" (copy {n})";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Quick input dialog for a single string. WPF doesn't ship one, so we
|
||||||
|
/// build a minimal modal here. Keeps the dialog dependency-free.
|
||||||
|
/// </summary>
|
||||||
|
private string? PromptForName(string title, string prompt, string defaultValue)
|
||||||
|
{
|
||||||
|
var dlg = new System.Windows.Window
|
||||||
|
{
|
||||||
|
Title = title,
|
||||||
|
Owner = this,
|
||||||
|
Width = 400,
|
||||||
|
Height = 170,
|
||||||
|
WindowStartupLocation = System.Windows.WindowStartupLocation.CenterOwner,
|
||||||
|
ResizeMode = System.Windows.ResizeMode.NoResize,
|
||||||
|
Background = (System.Windows.Media.Brush)FindResource("Wd.Canvas"),
|
||||||
|
};
|
||||||
|
var stack = new System.Windows.Controls.StackPanel { Margin = new System.Windows.Thickness(20) };
|
||||||
|
stack.Children.Add(new System.Windows.Controls.TextBlock
|
||||||
|
{
|
||||||
|
Text = prompt,
|
||||||
|
Margin = new System.Windows.Thickness(0, 0, 0, 8),
|
||||||
|
Foreground = (System.Windows.Media.Brush)FindResource("Wd.Text.Primary"),
|
||||||
|
});
|
||||||
|
var tb = new System.Windows.Controls.TextBox { Text = defaultValue, Padding = new System.Windows.Thickness(8, 6, 8, 6) };
|
||||||
|
stack.Children.Add(tb);
|
||||||
|
var buttons = new System.Windows.Controls.StackPanel
|
||||||
|
{
|
||||||
|
Orientation = System.Windows.Controls.Orientation.Horizontal,
|
||||||
|
HorizontalAlignment = System.Windows.HorizontalAlignment.Right,
|
||||||
|
Margin = new System.Windows.Thickness(0, 16, 0, 0),
|
||||||
|
};
|
||||||
|
var ok = new System.Windows.Controls.Button { Content = "OK", IsDefault = true, Padding = new System.Windows.Thickness(20, 6, 20, 6), Style = (System.Windows.Style)FindResource("Wd.Button.Primary") };
|
||||||
|
var cancel = new System.Windows.Controls.Button { Content = "Cancel", IsCancel = true, Padding = new System.Windows.Thickness(14, 6, 14, 6), Margin = new System.Windows.Thickness(0, 0, 8, 0), Style = (System.Windows.Style)FindResource("Wd.Button.Ghost") };
|
||||||
|
ok.Click += (_, _) => { dlg.DialogResult = true; dlg.Close(); };
|
||||||
|
buttons.Children.Add(cancel);
|
||||||
|
buttons.Children.Add(ok);
|
||||||
|
stack.Children.Add(buttons);
|
||||||
|
dlg.Content = stack;
|
||||||
|
tb.Focus();
|
||||||
|
tb.SelectAll();
|
||||||
|
var result = dlg.ShowDialog();
|
||||||
|
return result == true ? tb.Text.Trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnDelete(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (PresetsList.SelectedItem is not PresetRow row) return;
|
||||||
|
|
||||||
|
var confirm = MessageBox.Show(
|
||||||
|
this,
|
||||||
|
$"Delete preset \"{row.Name}\"? This cannot be undone.",
|
||||||
|
"TeamsISO — Delete preset",
|
||||||
|
MessageBoxButton.YesNo,
|
||||||
|
MessageBoxImage.Warning,
|
||||||
|
MessageBoxResult.No);
|
||||||
|
if (confirm != MessageBoxResult.Yes) return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
OperatorPresetStore.Delete(row.Name);
|
||||||
|
_toast?.Show($"Deleted preset \"{row.Name}\"");
|
||||||
|
ReloadPresets();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
MessageBox.Show(
|
||||||
|
this,
|
||||||
|
$"Could not delete preset.\n\n{ex.Message}",
|
||||||
|
"TeamsISO — Delete preset",
|
||||||
|
MessageBoxButton.OK,
|
||||||
|
MessageBoxImage.Warning);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnCancel(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
DialogResult = false;
|
||||||
|
Close();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Save every preset as a single .json bundle to a path the user picks via
|
||||||
|
/// SaveFileDialog. We use Microsoft.Win32.SaveFileDialog because it doesn't
|
||||||
|
/// drag in WinForms; the WPF host doesn't ship a built-in alternative.
|
||||||
|
/// </summary>
|
||||||
|
private void OnExport(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
var dlg = new Microsoft.Win32.SaveFileDialog
|
||||||
|
{
|
||||||
|
Title = "Export TeamsISO presets",
|
||||||
|
FileName = $"teamsiso-presets-{DateTimeOffset.Now:yyyy-MM-dd}.json",
|
||||||
|
Filter = "TeamsISO preset bundle (*.json)|*.json",
|
||||||
|
DefaultExt = "json",
|
||||||
|
};
|
||||||
|
if (dlg.ShowDialog(this) != true) return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var json = OperatorPresetStore.ExportAllAsJson();
|
||||||
|
System.IO.File.WriteAllText(dlg.FileName, json);
|
||||||
|
_toast?.Show($"Exported {Rows.Count} preset(s)");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
MessageBox.Show(this,
|
||||||
|
$"Could not export presets.\n\n{ex.Message}",
|
||||||
|
"TeamsISO — Export presets",
|
||||||
|
MessageBoxButton.OK,
|
||||||
|
MessageBoxImage.Warning);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Load a bundle from a path the user picks. On name collision we ask once
|
||||||
|
/// (covering all collisions) whether to overwrite — a per-preset prompt would
|
||||||
|
/// be exhausting for a 20-preset bundle. The Y/N here drives the overwrite
|
||||||
|
/// flag passed to <see cref="OperatorPresetStore.ImportBundle"/>.
|
||||||
|
/// </summary>
|
||||||
|
private void OnImport(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
var dlg = new Microsoft.Win32.OpenFileDialog
|
||||||
|
{
|
||||||
|
Title = "Import TeamsISO presets",
|
||||||
|
Filter = "TeamsISO preset bundle (*.json)|*.json|All files (*.*)|*.*",
|
||||||
|
};
|
||||||
|
if (dlg.ShowDialog(this) != true) return;
|
||||||
|
|
||||||
|
string json;
|
||||||
|
try { json = System.IO.File.ReadAllText(dlg.FileName); }
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
MessageBox.Show(this,
|
||||||
|
$"Could not read the file.\n\n{ex.Message}",
|
||||||
|
"TeamsISO — Import presets",
|
||||||
|
MessageBoxButton.OK,
|
||||||
|
MessageBoxImage.Warning);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quick parse to sniff for collisions before asking the operator anything.
|
||||||
|
OperatorPresetStore.Bundle? bundle;
|
||||||
|
try { bundle = System.Text.Json.JsonSerializer.Deserialize<OperatorPresetStore.Bundle>(json); }
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
MessageBox.Show(this,
|
||||||
|
"That file isn't a valid TeamsISO preset bundle.",
|
||||||
|
"TeamsISO — Import presets",
|
||||||
|
MessageBoxButton.OK,
|
||||||
|
MessageBoxImage.Warning);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (bundle is null || bundle.Presets is null || bundle.Presets.Count == 0)
|
||||||
|
{
|
||||||
|
MessageBox.Show(this,
|
||||||
|
"The bundle is empty.",
|
||||||
|
"TeamsISO — Import presets",
|
||||||
|
MessageBoxButton.OK,
|
||||||
|
MessageBoxImage.Information);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var existingNames = OperatorPresetStore.LoadAll()
|
||||||
|
.Select(p => p.Name)
|
||||||
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||||
|
var collisions = bundle.Presets.Count(p => existingNames.Contains(p.Name));
|
||||||
|
|
||||||
|
var overwrite = false;
|
||||||
|
if (collisions > 0)
|
||||||
|
{
|
||||||
|
var choice = MessageBox.Show(
|
||||||
|
this,
|
||||||
|
$"{collisions} preset name(s) in this bundle already exist on this machine.\n\n" +
|
||||||
|
"Yes = overwrite local copies with the bundle's versions.\n" +
|
||||||
|
"No = keep local copies; only import new presets.",
|
||||||
|
"TeamsISO — Import presets",
|
||||||
|
MessageBoxButton.YesNoCancel,
|
||||||
|
MessageBoxImage.Question,
|
||||||
|
MessageBoxResult.No);
|
||||||
|
if (choice == MessageBoxResult.Cancel) return;
|
||||||
|
overwrite = choice == MessageBoxResult.Yes;
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = OperatorPresetStore.ImportBundle(json, overwrite);
|
||||||
|
if (result.Error is not null)
|
||||||
|
{
|
||||||
|
MessageBox.Show(this,
|
||||||
|
$"Import failed.\n\n{result.Error}",
|
||||||
|
"TeamsISO — Import presets",
|
||||||
|
MessageBoxButton.OK,
|
||||||
|
MessageBoxImage.Warning);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var summary = $"Imported — {result.Added} new";
|
||||||
|
if (result.Overwritten > 0) summary += $", {result.Overwritten} overwritten";
|
||||||
|
if (result.Skipped > 0) summary += $", {result.Skipped} skipped";
|
||||||
|
_toast?.Show(summary);
|
||||||
|
ReloadPresets();
|
||||||
|
}
|
||||||
|
}
|
||||||
68
src/TeamsISO.App/PreviewWindow.xaml
Normal file
68
src/TeamsISO.App/PreviewWindow.xaml
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
<Window x:Class="TeamsISO.App.PreviewWindow"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:shell="clr-namespace:System.Windows.Shell;assembly=PresentationFramework"
|
||||||
|
Title="Preview"
|
||||||
|
Icon="/Assets/teamsiso.ico"
|
||||||
|
Width="640" Height="400"
|
||||||
|
MinWidth="320" MinHeight="200"
|
||||||
|
Background="Black"
|
||||||
|
WindowStyle="None"
|
||||||
|
ResizeMode="CanResize"
|
||||||
|
UseLayoutRounding="True">
|
||||||
|
|
||||||
|
<shell:WindowChrome.WindowChrome>
|
||||||
|
<shell:WindowChrome
|
||||||
|
CaptionHeight="32"
|
||||||
|
ResizeBorderThickness="6"
|
||||||
|
CornerRadius="0"
|
||||||
|
GlassFrameThickness="0"
|
||||||
|
UseAeroCaptionButtons="False"/>
|
||||||
|
</shell:WindowChrome.WindowChrome>
|
||||||
|
|
||||||
|
<Border BorderBrush="{DynamicResource Wd.Border}" BorderThickness="1">
|
||||||
|
<Grid>
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="*"/>
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
|
<!-- Caption -->
|
||||||
|
<Grid Grid.Row="0" Background="{DynamicResource Wd.Surface}">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<TextBlock x:Name="TitleText"
|
||||||
|
Text="Preview"
|
||||||
|
Style="{StaticResource Wd.Text.Caption}"
|
||||||
|
Margin="14,0,0,0"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
<TextBlock Grid.Column="1"
|
||||||
|
x:Name="ResolutionText"
|
||||||
|
Style="{StaticResource Wd.Text.Mono}"
|
||||||
|
FontSize="11"
|
||||||
|
Foreground="{DynamicResource Wd.Text.Tertiary}"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Margin="0,0,12,0"/>
|
||||||
|
<Button Grid.Column="2"
|
||||||
|
Style="{StaticResource Wd.Button.CaptionClose}"
|
||||||
|
Click="OnClose"
|
||||||
|
shell:WindowChrome.IsHitTestVisibleInChrome="True">
|
||||||
|
<Path Data="M 0,0 L 10,10 M 10,0 L 0,10"
|
||||||
|
Stroke="{DynamicResource Wd.Text.Primary}"
|
||||||
|
StrokeThickness="1.2"
|
||||||
|
Width="10" Height="10"
|
||||||
|
Stretch="None"/>
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!-- Live preview -->
|
||||||
|
<Image Grid.Row="1"
|
||||||
|
x:Name="PreviewImage"
|
||||||
|
Stretch="Uniform"
|
||||||
|
RenderOptions.BitmapScalingMode="HighQuality"/>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</Window>
|
||||||
95
src/TeamsISO.App/PreviewWindow.xaml.cs
Normal file
95
src/TeamsISO.App/PreviewWindow.xaml.cs
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Media;
|
||||||
|
using System.Windows.Media.Imaging;
|
||||||
|
using System.Windows.Threading;
|
||||||
|
using TeamsISO.Engine.Controller;
|
||||||
|
using TeamsISO.Engine.Pipeline;
|
||||||
|
|
||||||
|
namespace TeamsISO.App;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Non-modal floating preview window for a single participant. Shows the
|
||||||
|
/// engine's most recent <see cref="ProcessedFrame"/> at a higher refresh
|
||||||
|
/// rate (~20Hz) than the participants DataGrid thumbnails (1Hz). Multi-
|
||||||
|
/// monitor friendly: operator drags it to a second display, leaves the
|
||||||
|
/// main TeamsISO window on the primary.
|
||||||
|
///
|
||||||
|
/// Uses <see cref="WriteableBitmap"/> with <see cref="WriteableBitmap.WritePixels(Int32Rect, IntPtr, int, int)"/>
|
||||||
|
/// — the engine produces full-resolution BGRA frames so we can write them
|
||||||
|
/// straight into the bitmap without scaling. WPF's Image control with
|
||||||
|
/// Stretch=Uniform handles aspect-correct fit to the window size.
|
||||||
|
/// </summary>
|
||||||
|
public partial class PreviewWindow : Window
|
||||||
|
{
|
||||||
|
private readonly IIsoController _controller;
|
||||||
|
private readonly Guid _participantId;
|
||||||
|
private readonly DispatcherTimer _refreshTimer;
|
||||||
|
private WriteableBitmap? _bitmap;
|
||||||
|
private int _lastWidth;
|
||||||
|
private int _lastHeight;
|
||||||
|
|
||||||
|
public PreviewWindow(IIsoController controller, Guid participantId, string displayName)
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
_controller = controller;
|
||||||
|
_participantId = participantId;
|
||||||
|
TitleText.Text = displayName;
|
||||||
|
|
||||||
|
_refreshTimer = new DispatcherTimer(DispatcherPriority.Background, Dispatcher)
|
||||||
|
{
|
||||||
|
// 50ms = 20Hz. High enough for a smooth-feeling preview without
|
||||||
|
// hogging the dispatcher; still cheap because each refresh is just
|
||||||
|
// a memcpy from the engine's last frame into our pinned BackBuffer.
|
||||||
|
Interval = TimeSpan.FromMilliseconds(50),
|
||||||
|
};
|
||||||
|
_refreshTimer.Tick += OnTick;
|
||||||
|
Loaded += (_, _) => _refreshTimer.Start();
|
||||||
|
Closed += (_, _) =>
|
||||||
|
{
|
||||||
|
_refreshTimer.Stop();
|
||||||
|
_refreshTimer.Tick -= OnTick;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnClose(object sender, RoutedEventArgs e) => Close();
|
||||||
|
|
||||||
|
private void OnTick(object? sender, EventArgs e)
|
||||||
|
{
|
||||||
|
ProcessedFrame? frame;
|
||||||
|
try { frame = _controller.GetLatestProcessedFrame(_participantId); }
|
||||||
|
catch { return; }
|
||||||
|
if (frame is null || frame.Pixels.IsEmpty || frame.Width <= 0 || frame.Height <= 0) return;
|
||||||
|
if (frame.Pixels.Length < frame.Width * frame.Height * 4) return;
|
||||||
|
|
||||||
|
// (Re)allocate the WriteableBitmap when the source resolution changes.
|
||||||
|
// FrameProcessor normalizes to a configured target so this happens at
|
||||||
|
// most once per session, but we still defend against switches.
|
||||||
|
if (_bitmap is null || frame.Width != _lastWidth || frame.Height != _lastHeight)
|
||||||
|
{
|
||||||
|
_bitmap = new WriteableBitmap(
|
||||||
|
frame.Width, frame.Height, 96, 96, PixelFormats.Bgra32, null);
|
||||||
|
PreviewImage.Source = _bitmap;
|
||||||
|
_lastWidth = frame.Width;
|
||||||
|
_lastHeight = frame.Height;
|
||||||
|
ResolutionText.Text = $"{frame.Width}×{frame.Height}";
|
||||||
|
}
|
||||||
|
|
||||||
|
// WritePixels takes a buffer + stride + rect. Stride = width * 4 for
|
||||||
|
// BGRA. We slice the ProcessedFrame's ReadOnlyMemory<byte> via .Span
|
||||||
|
// and use the IntPtr overload via MemoryMarshal — but the
|
||||||
|
// byte-array overload is simpler and the compiler picks the right
|
||||||
|
// ToArray-free path because the engine already allocates a fresh
|
||||||
|
// array per frame.
|
||||||
|
unsafe
|
||||||
|
{
|
||||||
|
fixed (byte* p = frame.Pixels.Span)
|
||||||
|
{
|
||||||
|
_bitmap.WritePixels(
|
||||||
|
new Int32Rect(0, 0, frame.Width, frame.Height),
|
||||||
|
(IntPtr)p,
|
||||||
|
frame.Pixels.Length,
|
||||||
|
frame.Width * 4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
36
src/TeamsISO.App/Properties/Strings.Designer.cs
generated
Normal file
36
src/TeamsISO.App/Properties/Strings.Designer.cs
generated
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
// Hand-written strongly-typed accessor for Properties/Strings.resx. Kept
|
||||||
|
// out of Visual Studio's "ResXFileCodeGenerator" auto-regeneration loop
|
||||||
|
// so the .csproj stays simple and the file doesn't churn on every save.
|
||||||
|
// If you add a key in Strings.resx, add a matching property here.
|
||||||
|
|
||||||
|
// The compiler treats `*.Designer.cs` as auto-generated and refuses
|
||||||
|
// nullable annotations without an explicit directive — opt in.
|
||||||
|
#nullable enable
|
||||||
|
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Resources;
|
||||||
|
|
||||||
|
namespace TeamsISO.App.Properties;
|
||||||
|
|
||||||
|
internal static class Strings
|
||||||
|
{
|
||||||
|
private static readonly ResourceManager ResourceManager = new(
|
||||||
|
baseName: "TeamsISO.App.Properties.Strings",
|
||||||
|
assembly: typeof(Strings).Assembly);
|
||||||
|
|
||||||
|
public static CultureInfo? Culture { get; set; }
|
||||||
|
|
||||||
|
private static string Get(string key) =>
|
||||||
|
ResourceManager.GetString(key, Culture) ?? string.Empty;
|
||||||
|
|
||||||
|
public static string HideShowTeams_Title => Get(nameof(HideShowTeams_Title));
|
||||||
|
public static string HideShowTeams_NotRunning_Message => Get(nameof(HideShowTeams_NotRunning_Message));
|
||||||
|
|
||||||
|
public static string LaunchTeams_Title => Get(nameof(LaunchTeams_Title));
|
||||||
|
public static string LaunchTeams_Failed_MessageFormat => Get(nameof(LaunchTeams_Failed_MessageFormat));
|
||||||
|
|
||||||
|
public static string StopTeams_Title => Get(nameof(StopTeams_Title));
|
||||||
|
public static string StopTeams_Confirm_Message => Get(nameof(StopTeams_Confirm_Message));
|
||||||
|
public static string StopTeams_NoneResponded => Get(nameof(StopTeams_NoneResponded));
|
||||||
|
public static string StopTeams_AskedFormat => Get(nameof(StopTeams_AskedFormat));
|
||||||
|
}
|
||||||
75
src/TeamsISO.App/Properties/Strings.resx
Normal file
75
src/TeamsISO.App/Properties/Strings.resx
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<root>
|
||||||
|
<!--
|
||||||
|
User-facing English strings shown by MainWindow's MessageBox prompts.
|
||||||
|
Pulled out of code-behind so a future localizer has a single seam to
|
||||||
|
translate. Strings.Designer.cs is a hand-rolled accessor backed by
|
||||||
|
ResourceManager — no Visual-Studio auto-regeneration needed.
|
||||||
|
-->
|
||||||
|
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
|
||||||
|
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
|
||||||
|
<xsd:element name="root" msdata:IsDataSet="true">
|
||||||
|
<xsd:complexType>
|
||||||
|
<xsd:choice maxOccurs="unbounded">
|
||||||
|
<xsd:element name="data">
|
||||||
|
<xsd:complexType>
|
||||||
|
<xsd:sequence>
|
||||||
|
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
|
||||||
|
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
|
||||||
|
</xsd:sequence>
|
||||||
|
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
|
||||||
|
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
|
||||||
|
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
|
||||||
|
<xsd:attribute ref="xml:space" />
|
||||||
|
</xsd:complexType>
|
||||||
|
</xsd:element>
|
||||||
|
<xsd:element name="resheader">
|
||||||
|
<xsd:complexType>
|
||||||
|
<xsd:sequence>
|
||||||
|
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
|
||||||
|
</xsd:sequence>
|
||||||
|
<xsd:attribute name="name" type="xsd:string" use="required" />
|
||||||
|
</xsd:complexType>
|
||||||
|
</xsd:element>
|
||||||
|
</xsd:choice>
|
||||||
|
</xsd:complexType>
|
||||||
|
</xsd:element>
|
||||||
|
</xsd:schema>
|
||||||
|
<resheader name="resmimetype"><value>text/microsoft-resx</value></resheader>
|
||||||
|
<resheader name="version"><value>2.0</value></resheader>
|
||||||
|
<resheader name="reader"><value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value></resheader>
|
||||||
|
<resheader name="writer"><value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value></resheader>
|
||||||
|
|
||||||
|
<data name="HideShowTeams_Title" xml:space="preserve">
|
||||||
|
<value>TeamsISO — Hide / show Teams</value>
|
||||||
|
</data>
|
||||||
|
<data name="HideShowTeams_NotRunning_Message" xml:space="preserve">
|
||||||
|
<value>Microsoft Teams isn't running. Click the camera icon above to launch it first.</value>
|
||||||
|
</data>
|
||||||
|
|
||||||
|
<data name="LaunchTeams_Title" xml:space="preserve">
|
||||||
|
<value>TeamsISO — Launch Teams</value>
|
||||||
|
</data>
|
||||||
|
<data name="LaunchTeams_Failed_MessageFormat" xml:space="preserve">
|
||||||
|
<value>Could not launch Microsoft Teams.
|
||||||
|
|
||||||
|
{0}</value>
|
||||||
|
<comment>{0} = error string from TeamsLauncher.TryLaunch.</comment>
|
||||||
|
</data>
|
||||||
|
|
||||||
|
<data name="StopTeams_Title" xml:space="preserve">
|
||||||
|
<value>TeamsISO — Stop Teams</value>
|
||||||
|
</data>
|
||||||
|
<data name="StopTeams_Confirm_Message" xml:space="preserve">
|
||||||
|
<value>Microsoft Teams is currently running.
|
||||||
|
|
||||||
|
Close all Teams windows now?</value>
|
||||||
|
</data>
|
||||||
|
<data name="StopTeams_NoneResponded" xml:space="preserve">
|
||||||
|
<value>No Teams windows responded to close.</value>
|
||||||
|
</data>
|
||||||
|
<data name="StopTeams_AskedFormat" xml:space="preserve">
|
||||||
|
<value>Sent close to {0} Teams window(s); some may still be exiting.</value>
|
||||||
|
<comment>{0} = number of windows the launcher asked to close.</comment>
|
||||||
|
</data>
|
||||||
|
</root>
|
||||||
449
src/TeamsISO.App/Services/ControlPanelHtml.cs
Normal file
449
src/TeamsISO.App/Services/ControlPanelHtml.cs
Normal file
|
|
@ -0,0 +1,449 @@
|
||||||
|
namespace TeamsISO.App.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The HTML / CSS / JS for the embedded control panel served at
|
||||||
|
/// <c>GET /ui</c>. Single self-contained string — no external CDN deps, no
|
||||||
|
/// build step, no React. Phone-friendly remote that connects via WebSocket
|
||||||
|
/// to <c>/ws</c> and posts to the existing REST endpoints.
|
||||||
|
///
|
||||||
|
/// v2 additions:
|
||||||
|
/// - Live preview tiles per participant via GET /participants/{id}/thumbnail.jpg
|
||||||
|
/// (engine encodes the latest ProcessedFrame as a 192-wide JPEG; refreshed
|
||||||
|
/// ~1Hz alongside the WebSocket state push).
|
||||||
|
/// - Topology toggle card — shows whether raw Teams NDI sources are
|
||||||
|
/// hidden from the LAN, with Apply / Restore buttons that hit the
|
||||||
|
/// /topology/apply + /topology/restore REST endpoints. Operator still
|
||||||
|
/// has to restart Teams afterward, surfaced in a banner on apply.
|
||||||
|
/// </summary>
|
||||||
|
internal static class ControlPanelHtml
|
||||||
|
{
|
||||||
|
private const string Html = @"<!doctype html>
|
||||||
|
<html lang='en'>
|
||||||
|
<head>
|
||||||
|
<meta charset='utf-8'>
|
||||||
|
<meta name='viewport' content='width=device-width, initial-scale=1'>
|
||||||
|
<title>TeamsISO Control</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #0a0a0a;
|
||||||
|
--surface: #141414;
|
||||||
|
--surface-elev: #1c1c1c;
|
||||||
|
--border: #262626;
|
||||||
|
--border-strong: #3a3b40;
|
||||||
|
--text: #f5f5f5;
|
||||||
|
--text-2: #a3a3a3;
|
||||||
|
--text-3: #6b6b6b;
|
||||||
|
--cyan: #97edf0;
|
||||||
|
--cyan-mute: #1b3537;
|
||||||
|
--cyan-text: #97edf0;
|
||||||
|
--coral: #fb819c;
|
||||||
|
--coral-bg: #3a1922;
|
||||||
|
--green: #4ade80;
|
||||||
|
--amber: #fbbf24;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body { margin: 0; background: var(--bg); color: var(--text);
|
||||||
|
font-family: 'Inter', 'Segoe UI', system-ui, sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
body { padding: 16px; max-width: 920px; margin: 0 auto; }
|
||||||
|
h1 {
|
||||||
|
font-size: 11px; letter-spacing: 0.12em; font-weight: 600;
|
||||||
|
text-transform: uppercase; color: var(--text-3); margin: 0 0 14px;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
background: var(--surface); border: 1px solid var(--border);
|
||||||
|
border-radius: 12px; padding: 14px; margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.row { display: flex; align-items: center; gap: 10px; }
|
||||||
|
.row + .row { margin-top: 10px; }
|
||||||
|
.grow { flex: 1; min-width: 0; }
|
||||||
|
button {
|
||||||
|
background: var(--surface-elev); color: var(--text); border: 1px solid var(--border);
|
||||||
|
border-radius: 8px; padding: 10px 14px; cursor: pointer;
|
||||||
|
font: inherit; font-size: 13px;
|
||||||
|
transition: background 80ms ease, border-color 80ms ease;
|
||||||
|
}
|
||||||
|
button:hover { background: #242424; border-color: var(--border-strong); }
|
||||||
|
button.primary { background: var(--cyan); color: #042830; border-color: var(--cyan); font-weight: 500; }
|
||||||
|
button.primary:hover { background: #b5f2f4; }
|
||||||
|
button.danger { background: transparent; color: var(--coral); border-color: var(--coral); }
|
||||||
|
button.danger:hover { background: var(--coral-bg); }
|
||||||
|
button.live { background: var(--cyan-mute); color: var(--cyan-text); border-color: var(--cyan); }
|
||||||
|
.dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; flex-shrink: 0; }
|
||||||
|
.dot.cyan { background: var(--cyan); }
|
||||||
|
.dot.coral { background: var(--coral); }
|
||||||
|
.dot.green { background: var(--green); }
|
||||||
|
.dot.amber { background: var(--amber); }
|
||||||
|
.dot.gray { background: var(--text-3); }
|
||||||
|
.name { font-weight: 500; font-size: 14px; }
|
||||||
|
.sub { color: var(--text-3); font-size: 11px;
|
||||||
|
font-family: 'JetBrains Mono', 'Cascadia Mono', monospace;
|
||||||
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.label-caps { font-size: 10px; font-weight: 600; letter-spacing: 0.08em;
|
||||||
|
color: var(--text-3); text-transform: uppercase; }
|
||||||
|
.status {
|
||||||
|
display: flex; gap: 16px; align-items: center; flex-wrap: wrap;
|
||||||
|
font-family: 'JetBrains Mono', 'Cascadia Mono', monospace;
|
||||||
|
font-size: 11px; color: var(--text-3);
|
||||||
|
}
|
||||||
|
.status .ok { color: var(--green); }
|
||||||
|
.status .err { color: var(--coral); }
|
||||||
|
.empty { color: var(--text-3); font-size: 12px; padding: 18px; text-align: center; }
|
||||||
|
.global-actions { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; }
|
||||||
|
@media (min-width: 480px) { .global-actions { grid-template-columns: repeat(4, 1fr); } }
|
||||||
|
.participant-wrap { border-radius: 10px; }
|
||||||
|
.participant-wrap + .participant-wrap { margin-top: 6px; }
|
||||||
|
.participant-wrap.override { box-shadow: inset 3px 0 0 var(--cyan); }
|
||||||
|
.participant-row { display: flex; align-items: center; gap: 14px; padding: 10px; border-radius: 10px; }
|
||||||
|
.participant-row.speaking { background: var(--cyan-mute); }
|
||||||
|
.preview {
|
||||||
|
width: 112px; height: 63px; flex-shrink: 0; border-radius: 6px;
|
||||||
|
background: var(--surface-elev); border: 1px solid var(--border);
|
||||||
|
object-fit: cover; display: block;
|
||||||
|
}
|
||||||
|
.preview.empty { display: flex; align-items: center; justify-content: center;
|
||||||
|
color: var(--text-3); font-family: 'JetBrains Mono', monospace; font-size: 18px; }
|
||||||
|
.ovr-pill { display: inline-block; margin-left: 6px; padding: 1px 6px;
|
||||||
|
border-radius: 999px; background: var(--cyan-mute); color: var(--cyan-text);
|
||||||
|
font-size: 9px; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase;
|
||||||
|
vertical-align: middle; }
|
||||||
|
.cfg-caption { font-family: 'JetBrains Mono', 'Cascadia Mono', monospace;
|
||||||
|
font-size: 10px; color: var(--text-3); margin-right: 6px;
|
||||||
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||||
|
max-width: 140px; }
|
||||||
|
.gear-btn { padding: 6px 10px; font-size: 12px; }
|
||||||
|
.row-right { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
|
||||||
|
.override-panel { display: none; padding: 12px 10px 14px;
|
||||||
|
border-top: 1px solid var(--border); background: var(--surface-elev);
|
||||||
|
border-bottom-left-radius: 10px; border-bottom-right-radius: 10px; }
|
||||||
|
.override-panel.open { display: block; }
|
||||||
|
.override-grid { display: grid; grid-template-columns: 1fr; gap: 10px; }
|
||||||
|
@media (min-width: 560px) {
|
||||||
|
.override-grid { grid-template-columns: repeat(4, 1fr); }
|
||||||
|
}
|
||||||
|
.override-field { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
|
||||||
|
.override-field label { font-size: 10px; font-weight: 600; letter-spacing: 0.08em;
|
||||||
|
color: var(--text-3); text-transform: uppercase; }
|
||||||
|
.override-field select {
|
||||||
|
background: var(--surface); color: var(--text);
|
||||||
|
border: 1px solid var(--border); border-radius: 8px;
|
||||||
|
padding: 8px 10px; font: inherit; font-size: 12px;
|
||||||
|
appearance: none; -webkit-appearance: none;
|
||||||
|
}
|
||||||
|
.override-field select:focus { outline: none; border-color: var(--cyan); }
|
||||||
|
.override-actions { display: flex; gap: 8px; margin-top: 12px; flex-wrap: wrap; }
|
||||||
|
.topology-card { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; }
|
||||||
|
.topology-state { display: flex; align-items: center; gap: 10px; flex: 1; min-width: 200px; }
|
||||||
|
.topology-state strong { font-size: 13px; color: var(--text); }
|
||||||
|
.topology-banner { margin: 10px 0 0; padding: 10px 12px; border-radius: 8px;
|
||||||
|
background: var(--cyan-mute); color: var(--cyan-text); font-size: 12px; display: none; }
|
||||||
|
.topology-banner.show { display: block; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>TeamsISO control surface</h1>
|
||||||
|
|
||||||
|
<div class='card'>
|
||||||
|
<div class='status'>
|
||||||
|
<span><span id='conn' class='dot gray'></span> <span id='conn-text'>connecting…</span></span>
|
||||||
|
<span id='count' class='sub'></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class='card topology-card'>
|
||||||
|
<div class='topology-state'>
|
||||||
|
<span id='topo-dot' class='dot gray'></span>
|
||||||
|
<div>
|
||||||
|
<div class='label-caps'>Network topology</div>
|
||||||
|
<strong id='topo-label'>—</strong>
|
||||||
|
<div id='topo-detail' class='sub' style='margin-top: 2px;'></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style='display: flex; gap: 8px;'>
|
||||||
|
<button id='topo-apply' onclick='applyTopology()'>Hide Teams sources</button>
|
||||||
|
<button id='topo-restore' onclick='restoreTopology()'>Restore defaults</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id='topo-banner' class='topology-banner'></div>
|
||||||
|
|
||||||
|
<div class='card'>
|
||||||
|
<div class='global-actions'>
|
||||||
|
<button onclick='post(""/teams/mute"")'>Mute</button>
|
||||||
|
<button onclick='post(""/teams/camera"")'>Camera</button>
|
||||||
|
<button onclick='post(""/teams/share"")'>Share</button>
|
||||||
|
<button onclick='post(""/teams/leave"")'>Leave</button>
|
||||||
|
<button onclick='dropNote()'>Note…</button>
|
||||||
|
<button onclick='post(""/presets/refresh-discovery"")'>Refresh</button>
|
||||||
|
<button class='danger' onclick='post(""/presets/stop-all"")'>Stop all ISOs</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id='participants'></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const list = document.getElementById('participants');
|
||||||
|
const conn = document.getElementById('conn');
|
||||||
|
const connText = document.getElementById('conn-text');
|
||||||
|
const count = document.getElementById('count');
|
||||||
|
const topoDot = document.getElementById('topo-dot');
|
||||||
|
const topoLabel = document.getElementById('topo-label');
|
||||||
|
const topoDetail = document.getElementById('topo-detail');
|
||||||
|
const topoBanner = document.getElementById('topo-banner');
|
||||||
|
|
||||||
|
function setConn(state, text) {
|
||||||
|
conn.className = 'dot ' + state;
|
||||||
|
connText.textContent = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function post(path, body) {
|
||||||
|
try {
|
||||||
|
const opts = { method: 'POST' };
|
||||||
|
if (body) { opts.headers = { 'Content-Type': 'application/json' }; opts.body = JSON.stringify(body); }
|
||||||
|
const r = await fetch(path, opts);
|
||||||
|
return r.ok ? await r.json().catch(() => null) : null;
|
||||||
|
} catch (e) { console.warn(e); return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function dropNote() {
|
||||||
|
const text = prompt('Note (will be timestamped in the day file):');
|
||||||
|
if (text && text.trim()) post('/notes', { text: text.trim() });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchTopology() {
|
||||||
|
try {
|
||||||
|
const r = await fetch('/topology');
|
||||||
|
if (!r.ok) return;
|
||||||
|
const t = await r.json();
|
||||||
|
paintTopology(t);
|
||||||
|
} catch (e) { console.warn(e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function paintTopology(t) {
|
||||||
|
if (!t) return;
|
||||||
|
if (t.mode === 'hidden') {
|
||||||
|
topoDot.className = 'dot cyan';
|
||||||
|
topoLabel.textContent = 'Teams hidden from LAN';
|
||||||
|
} else if (t.mode === 'public') {
|
||||||
|
topoDot.className = 'dot amber';
|
||||||
|
topoLabel.textContent = 'Public — raw Teams visible';
|
||||||
|
} else {
|
||||||
|
topoDot.className = 'dot gray';
|
||||||
|
topoLabel.textContent = 'Unknown';
|
||||||
|
}
|
||||||
|
const sends = (t.senders || []).join(', ') || '—';
|
||||||
|
const recvs = (t.receivers || []).join(', ') || '—';
|
||||||
|
topoDetail.textContent = 'send: ' + sends + ' · recv: ' + recvs;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyTopology() {
|
||||||
|
const r = await post('/topology/apply');
|
||||||
|
if (r && r.ok) {
|
||||||
|
topoBanner.textContent = '✓ ' + (r.note || 'Applied. Restart Microsoft Teams for it to take effect.');
|
||||||
|
topoBanner.classList.add('show');
|
||||||
|
setTimeout(() => topoBanner.classList.remove('show'), 8000);
|
||||||
|
}
|
||||||
|
fetchTopology();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restoreTopology() {
|
||||||
|
if (!confirm('Restore default NDI groups? Teams will broadcast on public again after you restart it.')) return;
|
||||||
|
const r = await post('/topology/restore');
|
||||||
|
if (r && r.ok) {
|
||||||
|
topoBanner.textContent = '✓ Defaults restored. Restart Microsoft Teams for it to take effect.';
|
||||||
|
topoBanner.classList.add('show');
|
||||||
|
setTimeout(() => topoBanner.classList.remove('show'), 8000);
|
||||||
|
}
|
||||||
|
fetchTopology();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enum options for the per-participant override selects. Values match the
|
||||||
|
// .NET enum names so they round-trip through POST /participants/{id}/override
|
||||||
|
// without translation.
|
||||||
|
const FRAMERATE_OPTS = [
|
||||||
|
['Fps23_976', '23.976'], ['Fps24', '24'], ['Fps25', '25'],
|
||||||
|
['Fps29_97', '29.97'], ['Fps30', '30'], ['Fps50', '50'],
|
||||||
|
['Fps59_94', '59.94'], ['Fps60', '60'],
|
||||||
|
];
|
||||||
|
const RESOLUTION_OPTS = [
|
||||||
|
['R720p', '720p'], ['R1080p', '1080p'], ['R4K', '4K'],
|
||||||
|
];
|
||||||
|
const ASPECT_OPTS = [
|
||||||
|
['Pillarbox', 'Pillarbox'], ['Letterbox', 'Letterbox'], ['Stretch', 'Stretch'],
|
||||||
|
];
|
||||||
|
const AUDIO_OPTS = [
|
||||||
|
['Auto', 'Auto'], ['Isolated', 'Isolated'], ['Mixed', 'Mixed'],
|
||||||
|
];
|
||||||
|
|
||||||
|
// Track which participant rows have the override panel expanded so it
|
||||||
|
// survives re-renders driven by the WS state push (otherwise every
|
||||||
|
// 1Hz snapshot would collapse it under the operator's finger).
|
||||||
|
const openPanels = new Set();
|
||||||
|
|
||||||
|
function shortFps(v) {
|
||||||
|
for (const [k, label] of FRAMERATE_OPTS) if (k === v) return label;
|
||||||
|
return v || '—';
|
||||||
|
}
|
||||||
|
function shortRes(v) {
|
||||||
|
for (const [k, label] of RESOLUTION_OPTS) if (k === v) return label;
|
||||||
|
return v || '—';
|
||||||
|
}
|
||||||
|
function shortAudio(v) {
|
||||||
|
for (const [k, label] of AUDIO_OPTS) if (k === v) return label;
|
||||||
|
return v || '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSelect(opts, current) {
|
||||||
|
let html = '';
|
||||||
|
for (const [val, label] of opts) {
|
||||||
|
const sel = (val === current) ? ' selected' : '';
|
||||||
|
html += ""<option value='"" + val + ""'"" + sel + "">"" + label + ""</option>"";
|
||||||
|
}
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(participants) {
|
||||||
|
if (!participants || participants.length === 0) {
|
||||||
|
list.innerHTML = ""<div class='card empty'>No participants visible. Open Teams and join a meeting; sources will populate within seconds.</div>"";
|
||||||
|
count.textContent = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const live = participants.filter(p => p.isEnabled).length;
|
||||||
|
count.textContent = live + ' / ' + participants.length + ' live';
|
||||||
|
list.innerHTML = '';
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'card';
|
||||||
|
for (const p of participants) {
|
||||||
|
const wrap = document.createElement('div');
|
||||||
|
wrap.className = 'participant-wrap';
|
||||||
|
const eff = p.effective || {};
|
||||||
|
const isOverride = !!eff.isOverride;
|
||||||
|
if (isOverride) wrap.classList.add('override');
|
||||||
|
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'participant-row';
|
||||||
|
const stateColor = p.isEnabled ? 'cyan' : (p.isOnline ? 'gray' : 'coral');
|
||||||
|
// Live preview tile — cache-bust with a 1s-bucket query param so the
|
||||||
|
// browser refreshes the image without flickering on every WS message.
|
||||||
|
const bust = Math.floor(Date.now() / 1000);
|
||||||
|
const previewUrl = '/participants/' + p.id + '/thumbnail.jpg?t=' + bust;
|
||||||
|
row.innerHTML =
|
||||||
|
""<span class='dot "" + stateColor + ""'></span>"" +
|
||||||
|
""<img class='preview' alt='' onerror=\""this.style.display='none'; this.nextElementSibling.style.display='flex';\"" >"" +
|
||||||
|
""<div class='preview empty' style='display:none;'>—</div>"" +
|
||||||
|
""<div class='grow'>"" +
|
||||||
|
""<div class='name'></div>"" +
|
||||||
|
""<div class='sub'></div>"" +
|
||||||
|
""</div>"" +
|
||||||
|
""<div class='row-right'>"" +
|
||||||
|
""<span class='cfg-caption'></span>"" +
|
||||||
|
""<button class='gear-btn' title='Output settings'>⚙</button>"" +
|
||||||
|
""<button class='enable-btn'></button>"" +
|
||||||
|
""</div>"";
|
||||||
|
const img = row.querySelector('img.preview');
|
||||||
|
img.src = previewUrl;
|
||||||
|
row.querySelector('.name').textContent = p.displayName;
|
||||||
|
const subEl = row.querySelector('.sub');
|
||||||
|
subEl.textContent =
|
||||||
|
(p.stateLabel || (p.isOnline ? 'online' : 'offline')) +
|
||||||
|
(p.customName ? ' · ' + p.customName : '');
|
||||||
|
if (isOverride) {
|
||||||
|
const pill = document.createElement('span');
|
||||||
|
pill.className = 'ovr-pill';
|
||||||
|
pill.textContent = 'OVR';
|
||||||
|
subEl.appendChild(pill);
|
||||||
|
}
|
||||||
|
row.querySelector('.cfg-caption').textContent =
|
||||||
|
shortFps(eff.framerate) + ' · ' + shortRes(eff.resolution) + ' · ' + shortAudio(eff.audio);
|
||||||
|
const enableBtn = row.querySelector('.enable-btn');
|
||||||
|
enableBtn.className = 'enable-btn ' + (p.isEnabled ? 'live' : '');
|
||||||
|
enableBtn.textContent = p.isEnabled ? '● LIVE' : 'Enable';
|
||||||
|
enableBtn.onclick = () => post('/participants/iso', {
|
||||||
|
displayName: p.displayName,
|
||||||
|
enabled: !p.isEnabled,
|
||||||
|
});
|
||||||
|
|
||||||
|
const panel = document.createElement('div');
|
||||||
|
panel.className = 'override-panel' + (openPanels.has(p.id) ? ' open' : '');
|
||||||
|
panel.innerHTML =
|
||||||
|
""<div class='override-grid'>"" +
|
||||||
|
""<div class='override-field'><label>Framerate</label>"" +
|
||||||
|
""<select data-k='framerate'>"" + buildSelect(FRAMERATE_OPTS, eff.framerate) + ""</select></div>"" +
|
||||||
|
""<div class='override-field'><label>Resolution</label>"" +
|
||||||
|
""<select data-k='resolution'>"" + buildSelect(RESOLUTION_OPTS, eff.resolution) + ""</select></div>"" +
|
||||||
|
""<div class='override-field'><label>Aspect</label>"" +
|
||||||
|
""<select data-k='aspect'>"" + buildSelect(ASPECT_OPTS, eff.aspect) + ""</select></div>"" +
|
||||||
|
""<div class='override-field'><label>Audio</label>"" +
|
||||||
|
""<select data-k='audio'>"" + buildSelect(AUDIO_OPTS, eff.audio) + ""</select></div>"" +
|
||||||
|
""</div>"" +
|
||||||
|
""<div class='override-actions'>"" +
|
||||||
|
""<button class='primary apply-btn'>Apply</button>"" +
|
||||||
|
""<button class='danger clear-btn'>Clear (use global)</button>"" +
|
||||||
|
""</div>"";
|
||||||
|
|
||||||
|
const gearBtn = row.querySelector('.gear-btn');
|
||||||
|
gearBtn.onclick = () => {
|
||||||
|
if (openPanels.has(p.id)) {
|
||||||
|
openPanels.delete(p.id);
|
||||||
|
panel.classList.remove('open');
|
||||||
|
} else {
|
||||||
|
openPanels.add(p.id);
|
||||||
|
panel.classList.add('open');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
panel.querySelector('.apply-btn').onclick = async () => {
|
||||||
|
const body = {};
|
||||||
|
panel.querySelectorAll('select[data-k]').forEach(s => { body[s.dataset.k] = s.value; });
|
||||||
|
await fetch('/participants/' + p.id + '/override', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}).catch(e => console.warn(e));
|
||||||
|
openPanels.delete(p.id);
|
||||||
|
panel.classList.remove('open');
|
||||||
|
};
|
||||||
|
|
||||||
|
panel.querySelector('.clear-btn').onclick = async () => {
|
||||||
|
await fetch('/participants/' + p.id + '/override', { method: 'DELETE' })
|
||||||
|
.catch(e => console.warn(e));
|
||||||
|
openPanels.delete(p.id);
|
||||||
|
panel.classList.remove('open');
|
||||||
|
};
|
||||||
|
|
||||||
|
wrap.appendChild(row);
|
||||||
|
wrap.appendChild(panel);
|
||||||
|
card.appendChild(wrap);
|
||||||
|
}
|
||||||
|
list.appendChild(card);
|
||||||
|
}
|
||||||
|
|
||||||
|
function connect() {
|
||||||
|
setConn('gray', 'connecting…');
|
||||||
|
const ws = new WebSocket(
|
||||||
|
(location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/ws');
|
||||||
|
ws.onopen = () => { setConn('green', 'live'); fetchTopology(); };
|
||||||
|
ws.onmessage = (ev) => {
|
||||||
|
try {
|
||||||
|
const m = JSON.parse(ev.data);
|
||||||
|
if (m.type === 'participants') render(m.participants);
|
||||||
|
} catch (e) { console.warn(e); }
|
||||||
|
};
|
||||||
|
ws.onclose = () => {
|
||||||
|
setConn('coral', 'disconnected — retry in 3s');
|
||||||
|
setTimeout(connect, 3000);
|
||||||
|
};
|
||||||
|
ws.onerror = () => setConn('coral', 'error');
|
||||||
|
}
|
||||||
|
|
||||||
|
connect();
|
||||||
|
// Re-poll topology every 30s in case the operator changes the machine NDI
|
||||||
|
// config externally (NDI Access Manager, manual edit). Cheap — one HTTP GET.
|
||||||
|
setInterval(fetchTopology, 30000);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
";
|
||||||
|
|
||||||
|
public static string Get() => Html;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
namespace TeamsISO.App.Services;
|
||||||
|
|
||||||
|
// GET / — server info + endpoint catalogue. Returned as the JSON
|
||||||
|
// homepage when a Companion / Stream Deck plugin first probes the
|
||||||
|
// surface; humans see it via curl http://127.0.0.1:9755/.
|
||||||
|
public sealed partial class ControlSurfaceServer
|
||||||
|
{
|
||||||
|
private object GetServerInfo()
|
||||||
|
{
|
||||||
|
// Best-effort engine snapshot — wrapped in TryRead so a transient
|
||||||
|
// controller error doesn't 500 the homepage poll.
|
||||||
|
var settings = TryRead(() => _controller.GlobalSettings);
|
||||||
|
var groups = TryRead(() => _controller.GroupSettings);
|
||||||
|
return new
|
||||||
|
{
|
||||||
|
product = "TeamsISO",
|
||||||
|
version = typeof(ControlSurfaceServer).Assembly.GetName().Version?.ToString() ?? "unknown",
|
||||||
|
engine = new
|
||||||
|
{
|
||||||
|
framerateHz = settings?.FramerateHz,
|
||||||
|
targetResolution = settings?.Resolution.ToString(),
|
||||||
|
aspectMode = settings?.Aspect.ToString(),
|
||||||
|
audioMode = settings?.Audio.ToString(),
|
||||||
|
discoveryGroups = groups?.DiscoveryGroups,
|
||||||
|
outputGroups = groups?.OutputGroups,
|
||||||
|
},
|
||||||
|
endpoints = new[]
|
||||||
|
{
|
||||||
|
"GET / (this)",
|
||||||
|
"GET /ui (HTML control panel)",
|
||||||
|
"GET /participants",
|
||||||
|
"GET /ws (WebSocket: live participant snapshots)",
|
||||||
|
"POST /participants/{id}/iso",
|
||||||
|
"POST /participants/iso (body: displayName + enabled)",
|
||||||
|
"POST /presets/{name}/apply",
|
||||||
|
"POST /presets/refresh-discovery",
|
||||||
|
"POST /presets/stop-all",
|
||||||
|
"POST /teams/mute, /camera, /leave, /share, /raise-hand",
|
||||||
|
"POST /notes (body: text)",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static T? TryRead<T>(Func<T> reader) where T : class
|
||||||
|
{
|
||||||
|
try { return reader(); }
|
||||||
|
catch { return null; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
using System.Collections.Specialized;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace TeamsISO.App.Services;
|
||||||
|
|
||||||
|
// /notes/* route handlers — append-only operator show-notes file.
|
||||||
|
//
|
||||||
|
// POST /notes (body: { "text": "..." }) → AppendNote
|
||||||
|
public sealed partial class ControlSurfaceServer
|
||||||
|
{
|
||||||
|
private object AppendNote(JsonElement body, NameValueCollection query)
|
||||||
|
{
|
||||||
|
var text = TryGetString(body, query, "text");
|
||||||
|
if (string.IsNullOrWhiteSpace(text))
|
||||||
|
return new { ok = false, error = "text required" };
|
||||||
|
var ok = NotesService.Append(text);
|
||||||
|
return new { ok, action = "note", path = NotesService.TodayPath };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,191 @@
|
||||||
|
using System.Collections.Specialized;
|
||||||
|
using System.Text.Json;
|
||||||
|
using TeamsISO.Engine.Domain;
|
||||||
|
|
||||||
|
namespace TeamsISO.App.Services;
|
||||||
|
|
||||||
|
// /participants/* route handlers. Anything that reads or writes
|
||||||
|
// participant + per-pipeline state lives here.
|
||||||
|
//
|
||||||
|
// GET /participants → GetParticipants
|
||||||
|
// POST /participants/{id}/iso → ToggleIsoByIdAsync
|
||||||
|
// POST /participants/iso → ToggleIsoByNameAsync
|
||||||
|
// POST /participants/{id}/override → SetIsoOverrideByIdAsync
|
||||||
|
// DELETE /participants/{id}/override → ClearIsoOverrideByIdAsync
|
||||||
|
public sealed partial class ControlSurfaceServer
|
||||||
|
{
|
||||||
|
private object GetParticipants()
|
||||||
|
{
|
||||||
|
var vm = _viewModel();
|
||||||
|
if (vm is null) return new { participants = Array.Empty<object>() };
|
||||||
|
// Synchronously snapshot on the UI thread — ObservableCollection
|
||||||
|
// isn't safe to enumerate from this request handler's thread-pool
|
||||||
|
// task, and the ParticipantViewModel property reads chase
|
||||||
|
// data-binding state.
|
||||||
|
var dispatcher = System.Windows.Application.Current?.Dispatcher;
|
||||||
|
if (dispatcher is null) return new { participants = Array.Empty<object>() };
|
||||||
|
var globals = _controller.GlobalSettings;
|
||||||
|
var list = dispatcher.Invoke(() => vm.Participants.Select(p => {
|
||||||
|
var ovr = _controller.GetIsoOverride(p.Id);
|
||||||
|
return (object)new
|
||||||
|
{
|
||||||
|
id = p.Id,
|
||||||
|
displayName = p.DisplayName,
|
||||||
|
isOnline = p.IsOnline,
|
||||||
|
isEnabled = p.IsEnabled,
|
||||||
|
customName = string.IsNullOrEmpty(p.CustomName) ? null : p.CustomName,
|
||||||
|
stateLabel = p.StateLabel,
|
||||||
|
// Effective settings = override if set, else globals. The
|
||||||
|
// web UI uses this to show the current per-row values
|
||||||
|
// without a separate round-trip to /global.
|
||||||
|
effective = new
|
||||||
|
{
|
||||||
|
framerate = (ovr ?? globals).Framerate.ToString(),
|
||||||
|
resolution = (ovr ?? globals).Resolution.ToString(),
|
||||||
|
aspect = (ovr ?? globals).Aspect.ToString(),
|
||||||
|
audio = (ovr ?? globals).Audio.ToString(),
|
||||||
|
isOverride = ovr is not null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}).ToArray());
|
||||||
|
return new { participants = list, globals = new {
|
||||||
|
framerate = globals.Framerate.ToString(),
|
||||||
|
resolution = globals.Resolution.ToString(),
|
||||||
|
aspect = globals.Aspect.ToString(),
|
||||||
|
audio = globals.Audio.ToString(),
|
||||||
|
} };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// POST /participants/{id}/override — set or replace the per-pipeline
|
||||||
|
/// override. Body fields: framerate (enum string), resolution (enum
|
||||||
|
/// string), aspect (enum string), audio (enum string). All fields are
|
||||||
|
/// optional; missing fields fall back to the current global value.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<object> SetIsoOverrideByIdAsync(string path, JsonElement body)
|
||||||
|
{
|
||||||
|
var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
if (segments.Length != 3 || segments[0] != "participants" || segments[2] != "override")
|
||||||
|
return new { ok = false, error = "expected /participants/{id}/override" };
|
||||||
|
if (!Guid.TryParse(segments[1], out var id))
|
||||||
|
return new { ok = false, error = "invalid id" };
|
||||||
|
|
||||||
|
var g = _controller.GlobalSettings;
|
||||||
|
var framerate = TryParseEnum(body, "framerate", g.Framerate);
|
||||||
|
var resolution = TryParseEnum(body, "resolution", g.Resolution);
|
||||||
|
var aspect = TryParseEnum(body, "aspect", g.Aspect);
|
||||||
|
var audio = TryParseEnum(body, "audio", g.Audio);
|
||||||
|
var ovr = new FrameProcessingSettings(framerate, resolution, aspect, audio);
|
||||||
|
await _controller.SetIsoOverrideAsync(id, ovr, CancellationToken.None);
|
||||||
|
return new { ok = true, id, effective = new
|
||||||
|
{
|
||||||
|
framerate = ovr.Framerate.ToString(),
|
||||||
|
resolution = ovr.Resolution.ToString(),
|
||||||
|
aspect = ovr.Aspect.ToString(),
|
||||||
|
audio = ovr.Audio.ToString(),
|
||||||
|
isOverride = true,
|
||||||
|
} };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>DELETE /participants/{id}/override — pipeline reverts to global settings.</summary>
|
||||||
|
private async Task<object> ClearIsoOverrideByIdAsync(string path)
|
||||||
|
{
|
||||||
|
var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
if (segments.Length != 3 || segments[0] != "participants" || segments[2] != "override")
|
||||||
|
return new { ok = false, error = "expected /participants/{id}/override" };
|
||||||
|
if (!Guid.TryParse(segments[1], out var id))
|
||||||
|
return new { ok = false, error = "invalid id" };
|
||||||
|
await _controller.SetIsoOverrideAsync(id, null, CancellationToken.None);
|
||||||
|
return new { ok = true, id, cleared = true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parse an enum value from a JSON body, falling back to a default when
|
||||||
|
/// the field is missing or the value doesn't match any enum member.
|
||||||
|
/// Case-insensitive. Used by SetIsoOverrideByIdAsync for the four
|
||||||
|
/// FrameProcessingSettings enums.
|
||||||
|
/// </summary>
|
||||||
|
private static TEnum TryParseEnum<TEnum>(JsonElement body, string field, TEnum fallback)
|
||||||
|
where TEnum : struct, Enum
|
||||||
|
{
|
||||||
|
if (body.ValueKind != JsonValueKind.Object) return fallback;
|
||||||
|
if (!body.TryGetProperty(field, out var prop)) return fallback;
|
||||||
|
if (prop.ValueKind != JsonValueKind.String) return fallback;
|
||||||
|
var s = prop.GetString();
|
||||||
|
if (string.IsNullOrEmpty(s)) return fallback;
|
||||||
|
return Enum.TryParse<TEnum>(s, ignoreCase: true, out var result) ? result : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<object> ToggleIsoByIdAsync(string path, JsonElement body, NameValueCollection query)
|
||||||
|
{
|
||||||
|
// path = /participants/<guid>/iso
|
||||||
|
var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
if (segments.Length != 3 || segments[0] != "participants" || segments[2] != "iso")
|
||||||
|
return NotFound();
|
||||||
|
if (!Guid.TryParse(segments[1], out var id))
|
||||||
|
return new { ok = false, error = "invalid id" };
|
||||||
|
return await ToggleByIdAsync(id, body, query);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<object> ToggleIsoByNameAsync(JsonElement body, NameValueCollection query)
|
||||||
|
{
|
||||||
|
var displayName = TryGetString(body, query, "displayName");
|
||||||
|
if (string.IsNullOrWhiteSpace(displayName))
|
||||||
|
return new { ok = false, error = "displayName required" };
|
||||||
|
var vm = _viewModel();
|
||||||
|
var dispatcher = System.Windows.Application.Current?.Dispatcher;
|
||||||
|
if (vm is null || dispatcher is null)
|
||||||
|
return new { ok = false, error = "view-model not ready" };
|
||||||
|
var p = await dispatcher.InvokeAsync(() => vm.Participants.FirstOrDefault(x =>
|
||||||
|
string.Equals(x.DisplayName, displayName, StringComparison.OrdinalIgnoreCase)));
|
||||||
|
if (p is null) return new { ok = false, error = "participant not found", displayName };
|
||||||
|
return await ToggleByIdAsync(p.Id, body, query);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<object> ToggleByIdAsync(Guid id, JsonElement body, NameValueCollection query)
|
||||||
|
{
|
||||||
|
var enabled = TryGetBool(body, query, "enabled");
|
||||||
|
var customName = TryGetString(body, query, "customName");
|
||||||
|
var vm = _viewModel();
|
||||||
|
var dispatcher = System.Windows.Application.Current?.Dispatcher;
|
||||||
|
if (vm is null || dispatcher is null)
|
||||||
|
return new { ok = false, error = "view-model not ready" };
|
||||||
|
|
||||||
|
// Look up the VM and snapshot its current state on the UI thread —
|
||||||
|
// ObservableCollection enumeration and view-model property reads
|
||||||
|
// both need to happen there.
|
||||||
|
var lookup = await dispatcher.InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
var p = vm.Participants.FirstOrDefault(x => x.Id == id);
|
||||||
|
return p is null
|
||||||
|
? null
|
||||||
|
: new { Pvm = p, p.IsEnabled, p.CustomName };
|
||||||
|
});
|
||||||
|
if (lookup is null) return new { ok = false, error = "participant not found", id };
|
||||||
|
|
||||||
|
var target = enabled ?? !lookup.IsEnabled;
|
||||||
|
var nameToUse = !string.IsNullOrEmpty(customName) ? customName : lookup.CustomName;
|
||||||
|
|
||||||
|
if (target == lookup.IsEnabled && string.IsNullOrEmpty(customName))
|
||||||
|
return new { ok = true, id, enabled = lookup.IsEnabled, action = "noop" };
|
||||||
|
|
||||||
|
// Apply CustomName change first (if any) on the UI thread so a
|
||||||
|
// subsequent EnableIsoAsync sees the new name.
|
||||||
|
if (!string.IsNullOrEmpty(customName))
|
||||||
|
await dispatcher.InvokeAsync(() => lookup.Pvm.CustomName = customName);
|
||||||
|
|
||||||
|
if (target)
|
||||||
|
{
|
||||||
|
await _controller.EnableIsoAsync(id,
|
||||||
|
string.IsNullOrWhiteSpace(nameToUse) ? null : nameToUse,
|
||||||
|
CancellationToken.None);
|
||||||
|
await dispatcher.InvokeAsync(() => lookup.Pvm.IsEnabled = true);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await _controller.DisableIsoAsync(id, CancellationToken.None);
|
||||||
|
await dispatcher.InvokeAsync(() => lookup.Pvm.IsEnabled = false);
|
||||||
|
}
|
||||||
|
return new { ok = true, id, enabled = target };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,71 @@
|
||||||
|
namespace TeamsISO.App.Services;
|
||||||
|
|
||||||
|
// /presets/* route handlers.
|
||||||
|
//
|
||||||
|
// POST /presets/refresh-discovery → RefreshDiscovery
|
||||||
|
// POST /presets/stop-all → StopAllAsync
|
||||||
|
// POST /presets/{name}/apply → ApplyPresetAsync
|
||||||
|
public sealed partial class ControlSurfaceServer
|
||||||
|
{
|
||||||
|
private object RefreshDiscovery()
|
||||||
|
{
|
||||||
|
_controller.RefreshDiscovery();
|
||||||
|
return new { ok = true, action = "refresh-discovery" };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<object> StopAllAsync()
|
||||||
|
{
|
||||||
|
var vm = _viewModel();
|
||||||
|
if (vm is null) return new { ok = false, error = "view-model not ready" };
|
||||||
|
var dispatcher = System.Windows.Application.Current?.Dispatcher;
|
||||||
|
if (dispatcher is null) return new { ok = false, error = "dispatcher not ready" };
|
||||||
|
|
||||||
|
// Snapshot the enabled set on the UI thread — ObservableCollection
|
||||||
|
// isn't safe to enumerate from a thread-pool task, and reading the
|
||||||
|
// IsEnabled property indirectly walks the data-binding system.
|
||||||
|
var enabled = await dispatcher.InvokeAsync(() =>
|
||||||
|
vm.Participants.Where(p => p.IsEnabled).ToArray());
|
||||||
|
|
||||||
|
foreach (var p in enabled)
|
||||||
|
{
|
||||||
|
try { await _controller.DisableIsoAsync(p.Id, CancellationToken.None); }
|
||||||
|
catch { /* defensive */ }
|
||||||
|
await dispatcher.InvokeAsync(() => p.IsEnabled = false);
|
||||||
|
}
|
||||||
|
return new { ok = true, action = "stop-all", count = enabled.Length };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<object> ApplyPresetAsync(string path)
|
||||||
|
{
|
||||||
|
// path = /presets/<name>/apply
|
||||||
|
var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
if (segments.Length != 3 || segments[0] != "presets" || segments[2] != "apply")
|
||||||
|
return NotFound();
|
||||||
|
var name = Uri.UnescapeDataString(segments[1]);
|
||||||
|
var preset = OperatorPresetStore.Find(name);
|
||||||
|
if (preset is null) return new { ok = false, error = "preset not found", name };
|
||||||
|
|
||||||
|
var vm = _viewModel();
|
||||||
|
var dispatcher = System.Windows.Application.Current?.Dispatcher;
|
||||||
|
if (vm is null || dispatcher is null)
|
||||||
|
return new { ok = false, error = "view-model not ready" };
|
||||||
|
|
||||||
|
// Snapshot participants on the UI thread — ObservableCollection
|
||||||
|
// enumeration and ParticipantViewModel state reads both need to
|
||||||
|
// happen there. PresetApplier marshals subsequent property writes
|
||||||
|
// via the dispatcher.
|
||||||
|
var snapshot = await dispatcher.InvokeAsync(() => vm.Participants.ToList());
|
||||||
|
|
||||||
|
var result = await PresetApplier.ApplyAsync(
|
||||||
|
preset, snapshot, _controller, dispatcher);
|
||||||
|
|
||||||
|
return new
|
||||||
|
{
|
||||||
|
ok = true,
|
||||||
|
name = preset.Name,
|
||||||
|
matched = result.Matched,
|
||||||
|
changed = result.Changed,
|
||||||
|
skipped = result.Skipped,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
namespace TeamsISO.App.Services;
|
||||||
|
|
||||||
|
// /teams/* route handlers — UIAutomation-driven in-call controls.
|
||||||
|
//
|
||||||
|
// POST /teams/mute → InvokeTeams(ToggleMute, "mute")
|
||||||
|
// POST /teams/camera → InvokeTeams(ToggleCamera, "camera")
|
||||||
|
// POST /teams/leave → InvokeTeams(LeaveCall, "leave")
|
||||||
|
// POST /teams/share → InvokeTeams(OpenShareTray, "share")
|
||||||
|
// POST /teams/raise-hand → InvokeTeams(ToggleRaiseHand, "raise-hand")
|
||||||
|
public sealed partial class ControlSurfaceServer
|
||||||
|
{
|
||||||
|
private object InvokeTeams(Func<TeamsControlBridge.InvokeResult> invoke, string action)
|
||||||
|
{
|
||||||
|
var result = invoke();
|
||||||
|
return new
|
||||||
|
{
|
||||||
|
ok = result == TeamsControlBridge.InvokeResult.Invoked,
|
||||||
|
action,
|
||||||
|
result = result.ToString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,113 @@
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace TeamsISO.App.Services;
|
||||||
|
|
||||||
|
// GET /participants/{id}/thumbnail.bmp — small BMP of the latest
|
||||||
|
// processed frame. Used by the embedded HTML control panel for live
|
||||||
|
// preview tiles with a cache-busting query param at ~1Hz.
|
||||||
|
//
|
||||||
|
// BMP (not JPEG) because the System.Windows.Media.Imaging path NREs on
|
||||||
|
// non-UI threads and marshaling 1Hz JPEG encodes through the WPF
|
||||||
|
// dispatcher hurts responsiveness. ~40KB at 192-wide compresses fine
|
||||||
|
// over LAN gzip.
|
||||||
|
public sealed partial class ControlSurfaceServer
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Encode the engine's most recent processed frame for the given
|
||||||
|
/// participant as a BMP. Returns null when no pipeline is running for
|
||||||
|
/// this participant or the frame can't be encoded.
|
||||||
|
/// </summary>
|
||||||
|
private byte[]? TryEncodeThumbnailJpeg(Guid participantId)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var frame = _controller.GetLatestProcessedFrame(participantId);
|
||||||
|
if (frame is null)
|
||||||
|
{
|
||||||
|
_logger?.LogDebug("Thumbnail: no frame for {Id}", participantId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (frame.Pixels.Length == 0)
|
||||||
|
{
|
||||||
|
_logger?.LogDebug("Thumbnail: empty pixel buffer for {Id} ({W}x{H})", participantId, frame.Width, frame.Height);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nearest-neighbor downscale to ~192 wide. Source is BGRA32.
|
||||||
|
const int targetWidth = 192;
|
||||||
|
var ratio = (double)frame.Height / frame.Width;
|
||||||
|
var targetHeight = Math.Max(1, (int)(targetWidth * ratio));
|
||||||
|
return EncodeBmpDownscaled(frame.Pixels.Span, frame.Width, frame.Height, targetWidth, targetHeight);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger?.LogWarning(ex, "Thumbnail encode failed for {Id}", participantId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Nearest-neighbor downscale of a BGRA32 source then write a 32-bpp
|
||||||
|
/// top-down BMP. Returns the full BMP file bytes including the 14-byte
|
||||||
|
/// BMP header and 40-byte BITMAPINFOHEADER. Browsers handle it directly
|
||||||
|
/// (no JPEG / PNG codec needed in-process).
|
||||||
|
/// </summary>
|
||||||
|
private static byte[] EncodeBmpDownscaled(ReadOnlySpan<byte> srcBgra, int srcW, int srcH, int dstW, int dstH)
|
||||||
|
{
|
||||||
|
var pixelBytes = dstW * dstH * 4;
|
||||||
|
var bmp = new byte[54 + pixelBytes];
|
||||||
|
|
||||||
|
// BMP file header (14 bytes): 'BM', file size, reserved, pixel offset.
|
||||||
|
bmp[0] = (byte)'B'; bmp[1] = (byte)'M';
|
||||||
|
WriteUInt32LE(bmp, 2, (uint)bmp.Length);
|
||||||
|
WriteUInt32LE(bmp, 6, 0);
|
||||||
|
WriteUInt32LE(bmp, 10, 54);
|
||||||
|
|
||||||
|
// DIB header (BITMAPINFOHEADER, 40 bytes): negative height = top-down.
|
||||||
|
WriteUInt32LE(bmp, 14, 40);
|
||||||
|
WriteInt32LE(bmp, 18, dstW);
|
||||||
|
WriteInt32LE(bmp, 22, -dstH);
|
||||||
|
WriteUInt16LE(bmp, 26, 1);
|
||||||
|
WriteUInt16LE(bmp, 28, 32);
|
||||||
|
WriteUInt32LE(bmp, 30, 0);
|
||||||
|
WriteUInt32LE(bmp, 34, (uint)pixelBytes);
|
||||||
|
WriteUInt32LE(bmp, 38, 2835);
|
||||||
|
WriteUInt32LE(bmp, 42, 2835);
|
||||||
|
WriteUInt32LE(bmp, 46, 0);
|
||||||
|
WriteUInt32LE(bmp, 50, 0);
|
||||||
|
|
||||||
|
// Nearest-neighbor downscale, top-down (matches negative-height header).
|
||||||
|
var srcStride = srcW * 4;
|
||||||
|
var dstOffset = 54;
|
||||||
|
for (var dy = 0; dy < dstH; dy++)
|
||||||
|
{
|
||||||
|
var sy = (int)((long)dy * srcH / dstH);
|
||||||
|
for (var dx = 0; dx < dstW; dx++)
|
||||||
|
{
|
||||||
|
var sx = (int)((long)dx * srcW / dstW);
|
||||||
|
var si = sy * srcStride + sx * 4;
|
||||||
|
bmp[dstOffset++] = srcBgra[si];
|
||||||
|
bmp[dstOffset++] = srcBgra[si + 1];
|
||||||
|
bmp[dstOffset++] = srcBgra[si + 2];
|
||||||
|
bmp[dstOffset++] = srcBgra[si + 3];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bmp;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteUInt16LE(byte[] buf, int offset, ushort value)
|
||||||
|
{
|
||||||
|
buf[offset] = (byte)(value & 0xFF);
|
||||||
|
buf[offset + 1] = (byte)((value >> 8) & 0xFF);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteInt32LE(byte[] buf, int offset, int value) => WriteUInt32LE(buf, offset, (uint)value);
|
||||||
|
|
||||||
|
private static void WriteUInt32LE(byte[] buf, int offset, uint value)
|
||||||
|
{
|
||||||
|
buf[offset] = (byte)(value & 0xFF);
|
||||||
|
buf[offset + 1] = (byte)((value >> 8) & 0xFF);
|
||||||
|
buf[offset + 2] = (byte)((value >> 16) & 0xFF);
|
||||||
|
buf[offset + 3] = (byte)((value >> 24) & 0xFF);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,91 @@
|
||||||
|
namespace TeamsISO.App.Services;
|
||||||
|
|
||||||
|
// /topology/* route handlers — read + apply / restore the machine NDI
|
||||||
|
// access-manager config so the operator can flip transcoder topology
|
||||||
|
// without leaving the web UI.
|
||||||
|
//
|
||||||
|
// GET /topology → GetTopology
|
||||||
|
// POST /topology/apply → ApplyTopologyAsync
|
||||||
|
// POST /topology/restore → RestoreTopologyAsync
|
||||||
|
public sealed partial class ControlSurfaceServer
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Report the current NDI machine topology. "mode" is "hidden" when
|
||||||
|
/// local senders are confined to the private group (raw Teams sources
|
||||||
|
/// invisible to the rest of the LAN), "public" otherwise. Reads the
|
||||||
|
/// machine NDI config file directly — no caching, so the result
|
||||||
|
/// reflects whatever state the file is in right now (including
|
||||||
|
/// manual edits).
|
||||||
|
/// </summary>
|
||||||
|
private object GetTopology()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var (mode, sends, recvs) = NdiAccessManagerConfig.ReadCurrent();
|
||||||
|
return new
|
||||||
|
{
|
||||||
|
mode,
|
||||||
|
senders = sends,
|
||||||
|
receivers = recvs,
|
||||||
|
configPath = NdiAccessManagerConfig.ConfigPath,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return new { ok = false, error = ex.Message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Apply the transcoder topology: machine senders → <c>teamsiso-input</c>,
|
||||||
|
/// receivers → <c>public + teamsiso-input</c>; engine groups updated to
|
||||||
|
/// match (discover from teamsiso-input, broadcast on public). Operator
|
||||||
|
/// MUST restart Teams afterward for it to read the new NDI config.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<object> ApplyTopologyAsync()
|
||||||
|
{
|
||||||
|
var result = NdiAccessManagerConfig.ApplyTranscoderTopology();
|
||||||
|
if (!result.Success)
|
||||||
|
{
|
||||||
|
return new { ok = false, error = result.ErrorMessage, configPath = result.ConfigPath };
|
||||||
|
}
|
||||||
|
// Mirror what the WPF settings VM does so the engine groups +
|
||||||
|
// machine config stay in lockstep.
|
||||||
|
var ourGroups = new TeamsISO.Engine.Domain.NdiGroupSettings(
|
||||||
|
DiscoveryGroups: NdiAccessManagerConfig.TranscoderInputGroup,
|
||||||
|
OutputGroups: "public");
|
||||||
|
await _controller.SetGroupSettingsAsync(ourGroups, CancellationToken.None);
|
||||||
|
return new
|
||||||
|
{
|
||||||
|
ok = true,
|
||||||
|
mode = "hidden",
|
||||||
|
backupPath = result.BackupPath,
|
||||||
|
note = "Restart Microsoft Teams for the new NDI config to take effect there.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Restore the machine NDI defaults: senders + receivers both on
|
||||||
|
/// <c>public</c>. Engine groups go back to null/defaults too. Operator
|
||||||
|
/// must restart Teams for it to broadcast on public again.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<object> RestoreTopologyAsync()
|
||||||
|
{
|
||||||
|
var result = NdiAccessManagerConfig.RestoreDefaults();
|
||||||
|
if (!result.Success)
|
||||||
|
{
|
||||||
|
return new { ok = false, error = result.ErrorMessage, configPath = result.ConfigPath };
|
||||||
|
}
|
||||||
|
var ourGroups = new TeamsISO.Engine.Domain.NdiGroupSettings(
|
||||||
|
DiscoveryGroups: null,
|
||||||
|
OutputGroups: null);
|
||||||
|
await _controller.SetGroupSettingsAsync(ourGroups, CancellationToken.None);
|
||||||
|
return new
|
||||||
|
{
|
||||||
|
ok = true,
|
||||||
|
mode = "public",
|
||||||
|
backupPath = result.BackupPath,
|
||||||
|
note = "Restart Microsoft Teams for the new NDI config to take effect there.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
147
src/TeamsISO.App/Services/ControlSurface/WebSocketHub.cs
Normal file
147
src/TeamsISO.App/Services/ControlSurface/WebSocketHub.cs
Normal file
|
|
@ -0,0 +1,147 @@
|
||||||
|
using System.Net.WebSockets;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace TeamsISO.App.Services;
|
||||||
|
|
||||||
|
// GET /ws — upgrades to WebSocket and pushes participant-list snapshots
|
||||||
|
// at 4Hz with diffing (no push when nothing changed). Lets controllers
|
||||||
|
// stay live-synced without polling /participants.
|
||||||
|
//
|
||||||
|
// Lifecycle:
|
||||||
|
// • Server's accept loop upgrades the request and hands the socket here.
|
||||||
|
// • HandleWebSocketAsync owns the connection until the client closes.
|
||||||
|
// • The Start() method wires a 4Hz DispatcherTimer that calls
|
||||||
|
// PushSnapshotIfChangedAsync to fan out to every connected client.
|
||||||
|
public sealed partial class ControlSurfaceServer
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Owns a single client connection until it closes. Sends an immediate
|
||||||
|
/// snapshot on connect (so the client doesn't have to wait up to 250ms
|
||||||
|
/// for the next push tick), then sits in a receive loop draining any
|
||||||
|
/// incoming text — we ignore client→server messages for v1 since all
|
||||||
|
/// commands are REST. The receive loop is the canonical way to detect
|
||||||
|
/// graceful close: when WebSocket.ReceiveAsync returns CloseReceived,
|
||||||
|
/// we close back and remove the client.
|
||||||
|
/// </summary>
|
||||||
|
private async Task HandleWebSocketAsync(WebSocket ws)
|
||||||
|
{
|
||||||
|
var clientId = Guid.NewGuid();
|
||||||
|
_clients[clientId] = ws;
|
||||||
|
_logger?.LogInformation("WebSocket client {Id} connected.", clientId);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Initial snapshot — fetch synchronously on the UI thread so the
|
||||||
|
// ObservableCollection isn't enumerated cross-thread.
|
||||||
|
await SendAsync(ws, await GetSnapshotJsonAsync());
|
||||||
|
|
||||||
|
var buf = new byte[1024];
|
||||||
|
while (ws.State == WebSocketState.Open)
|
||||||
|
{
|
||||||
|
var result = await ws.ReceiveAsync(new ArraySegment<byte>(buf), CancellationToken.None);
|
||||||
|
if (result.MessageType == WebSocketMessageType.Close)
|
||||||
|
{
|
||||||
|
await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "bye", CancellationToken.None);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// Ignore any client-sent messages for now; future bidirectional
|
||||||
|
// commands could route through here.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (WebSocketException) { /* client crashed; drop */ }
|
||||||
|
catch (ObjectDisposedException) { /* Stop() aborted us */ }
|
||||||
|
catch (OperationCanceledException) { /* server shutting down */ }
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_clients.TryRemove(clientId, out _);
|
||||||
|
// Don't double-dispose: Stop() already disposed the WebSocket if
|
||||||
|
// it's tearing us down. Aborting an already-disposed socket is a
|
||||||
|
// no-op throw which we catch + ignore.
|
||||||
|
try { ws.Dispose(); } catch { /* defensive */ }
|
||||||
|
_logger?.LogInformation("WebSocket client {Id} disconnected.", clientId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Dispatcher-tick handler. Reads the current participants snapshot,
|
||||||
|
/// and if it differs from what we last pushed, broadcasts the new
|
||||||
|
/// JSON to every connected client. Diffing on the JSON string is
|
||||||
|
/// cheap and saves wire bytes when nothing's actually changing —
|
||||||
|
/// typical operator workflow has long periods of no state churn
|
||||||
|
/// between meetings.
|
||||||
|
/// </summary>
|
||||||
|
private async Task PushSnapshotIfChangedAsync()
|
||||||
|
{
|
||||||
|
if (_clients.IsEmpty) return;
|
||||||
|
|
||||||
|
string snapshot;
|
||||||
|
try { snapshot = await GetSnapshotJsonAsync(); }
|
||||||
|
catch { return; }
|
||||||
|
|
||||||
|
if (snapshot == _lastPushedSnapshot) return;
|
||||||
|
_lastPushedSnapshot = snapshot;
|
||||||
|
|
||||||
|
var bytes = Encoding.UTF8.GetBytes(snapshot);
|
||||||
|
foreach (var (id, ws) in _clients.ToArray())
|
||||||
|
{
|
||||||
|
if (ws.State != WebSocketState.Open)
|
||||||
|
{
|
||||||
|
_clients.TryRemove(id, out _);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await ws.SendAsync(
|
||||||
|
new ArraySegment<byte>(bytes),
|
||||||
|
WebSocketMessageType.Text,
|
||||||
|
endOfMessage: true,
|
||||||
|
CancellationToken.None);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
_clients.TryRemove(id, out _);
|
||||||
|
try { ws.Dispose(); } catch { /* defensive */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task SendAsync(WebSocket ws, string text)
|
||||||
|
{
|
||||||
|
var bytes = Encoding.UTF8.GetBytes(text);
|
||||||
|
await ws.SendAsync(
|
||||||
|
new ArraySegment<byte>(bytes),
|
||||||
|
WebSocketMessageType.Text,
|
||||||
|
endOfMessage: true,
|
||||||
|
CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Build the same payload as <c>GET /participants</c> but as a JSON
|
||||||
|
/// string for direct WebSocket Send. Reads the ObservableCollection
|
||||||
|
/// via the UI dispatcher because WPF's ObservableCollection isn't
|
||||||
|
/// thread-safe to enumerate from a non-UI thread.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<string> GetSnapshotJsonAsync()
|
||||||
|
{
|
||||||
|
var dispatcher = System.Windows.Application.Current?.Dispatcher;
|
||||||
|
var participants = dispatcher is null
|
||||||
|
? Array.Empty<object>()
|
||||||
|
: await dispatcher.InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
var vm = _viewModel();
|
||||||
|
if (vm is null) return Array.Empty<object>();
|
||||||
|
return vm.Participants.Select(p => (object)new
|
||||||
|
{
|
||||||
|
id = p.Id,
|
||||||
|
displayName = p.DisplayName,
|
||||||
|
isOnline = p.IsOnline,
|
||||||
|
isEnabled = p.IsEnabled,
|
||||||
|
customName = string.IsNullOrEmpty(p.CustomName) ? null : p.CustomName,
|
||||||
|
stateLabel = p.StateLabel,
|
||||||
|
}).ToArray();
|
||||||
|
});
|
||||||
|
return JsonSerializer.Serialize(new { type = "participants", participants }, JsonOpts);
|
||||||
|
}
|
||||||
|
}
|
||||||
400
src/TeamsISO.App/Services/ControlSurfaceServer.cs
Normal file
400
src/TeamsISO.App/Services/ControlSurfaceServer.cs
Normal file
|
|
@ -0,0 +1,400 @@
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
using System.IO;
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.WebSockets;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using System.Windows.Threading;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using TeamsISO.App.ViewModels;
|
||||||
|
using TeamsISO.Engine.Controller;
|
||||||
|
using TeamsISO.Engine.Domain;
|
||||||
|
|
||||||
|
namespace TeamsISO.App.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Localhost-only HTTP control surface. Lets external controllers (Bitfocus
|
||||||
|
/// Companion, Stream Deck plugins, Bome MIDI Translator, custom node-RED flows,
|
||||||
|
/// etc.) drive TeamsISO without needing to embed a UI binding.
|
||||||
|
///
|
||||||
|
/// Bound to 127.0.0.1 by default — exposing this to LAN would require auth, and
|
||||||
|
/// the typical operator workflow is "Stream Deck on the same machine as TeamsISO".
|
||||||
|
/// If a future user needs LAN access, add a token check + bind to a configurable
|
||||||
|
/// address; both are deliberately punted for v1.
|
||||||
|
///
|
||||||
|
/// Endpoints (all return application/json):
|
||||||
|
///
|
||||||
|
/// GET / — server info + endpoint list
|
||||||
|
/// GET /participants — list of {id, displayName, isOnline, isEnabled}
|
||||||
|
/// POST /participants/{id}/iso — body {"enabled":bool,"customName":string?}
|
||||||
|
/// POST /participants/iso — body {"displayName":string,"enabled":bool} (look up by name)
|
||||||
|
/// POST /presets/{name}/apply — apply a saved preset
|
||||||
|
/// POST /presets/refresh-discovery — rebuild NDI finder
|
||||||
|
/// POST /presets/stop-all — disable every running ISO
|
||||||
|
/// POST /teams/mute — toggle mute via UIA
|
||||||
|
/// POST /teams/camera — toggle camera via UIA
|
||||||
|
/// POST /teams/leave — leave the call via UIA
|
||||||
|
/// POST /teams/share — open share tray via UIA
|
||||||
|
/// POST /teams/raise-hand — toggle raise hand via UIA
|
||||||
|
/// POST /recording — body {"enabled":bool,"directory":string?}
|
||||||
|
///
|
||||||
|
/// All POST bodies are optional — endpoints that take parameters accept them
|
||||||
|
/// either via JSON body or via query string (?enabled=true&customName=Host).
|
||||||
|
/// This is friendly to Companion's "URL with query string" mode.
|
||||||
|
/// </summary>
|
||||||
|
// Endpoint handlers live in partial files under Services/ControlSurface/Endpoints/.
|
||||||
|
// This file holds the host: listener lifecycle, accept loop, dispatch table,
|
||||||
|
// response helpers, and the WebSocket push loop.
|
||||||
|
public sealed partial class ControlSurfaceServer : IAsyncDisposable
|
||||||
|
{
|
||||||
|
public const int DefaultPort = 9755;
|
||||||
|
|
||||||
|
private readonly IIsoController _controller;
|
||||||
|
private readonly Func<MainViewModel?> _viewModel;
|
||||||
|
private readonly ILogger<ControlSurfaceServer>? _logger;
|
||||||
|
private HttpListener? _listener;
|
||||||
|
private CancellationTokenSource? _cts;
|
||||||
|
private Task? _acceptTask;
|
||||||
|
private DispatcherTimer? _pushTimer;
|
||||||
|
private readonly ConcurrentDictionary<Guid, WebSocket> _clients = new();
|
||||||
|
private string _lastPushedSnapshot = string.Empty;
|
||||||
|
|
||||||
|
public bool IsRunning { get; private set; }
|
||||||
|
public int Port { get; private set; } = DefaultPort;
|
||||||
|
/// <summary>True when the listener is bound to all interfaces (LAN-reachable) rather than just 127.0.0.1.</summary>
|
||||||
|
public bool BoundToLan { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// JSON serializer options shared across all responses. Camel-case property
|
||||||
|
/// naming matches Companion's request shape and what most JS clients expect.
|
||||||
|
/// </summary>
|
||||||
|
private static readonly JsonSerializerOptions JsonOpts = new()
|
||||||
|
{
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||||
|
};
|
||||||
|
|
||||||
|
public ControlSurfaceServer(
|
||||||
|
IIsoController controller,
|
||||||
|
Func<MainViewModel?> viewModel,
|
||||||
|
ILogger<ControlSurfaceServer>? logger = null)
|
||||||
|
{
|
||||||
|
_controller = controller;
|
||||||
|
_viewModel = viewModel;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Start listening on the given port. Idempotent: if already running on the
|
||||||
|
/// same (port, bindToLan) combination, no-op; otherwise stop + restart.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="port">TCP port to listen on.</param>
|
||||||
|
/// <param name="bindToLan">
|
||||||
|
/// When true, binds to all interfaces (<c>http://+:port/</c>) so other
|
||||||
|
/// machines on the LAN can reach the control surface — typical for
|
||||||
|
/// "headless show machine + thin client controller" setups. When false
|
||||||
|
/// (default), binds to <c>127.0.0.1</c> only.
|
||||||
|
///
|
||||||
|
/// LAN binding requires either running TeamsISO as Administrator OR a
|
||||||
|
/// one-time URL ACL reservation at the OS level:
|
||||||
|
/// <code>netsh http add urlacl url=http://+:9755/ user=Everyone</code>
|
||||||
|
/// If neither is in place the listener throws AccessDeniedException
|
||||||
|
/// which we catch and surface as a logger warning.
|
||||||
|
/// </summary>
|
||||||
|
public void Start(int port, bool bindToLan = false)
|
||||||
|
{
|
||||||
|
if (IsRunning && Port == port && BoundToLan == bindToLan) return;
|
||||||
|
Stop();
|
||||||
|
|
||||||
|
Port = port;
|
||||||
|
BoundToLan = bindToLan;
|
||||||
|
_listener = new HttpListener();
|
||||||
|
var prefix = bindToLan
|
||||||
|
? $"http://+:{port}/"
|
||||||
|
: $"http://127.0.0.1:{port}/";
|
||||||
|
_listener.Prefixes.Add(prefix);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_listener.Start();
|
||||||
|
}
|
||||||
|
catch (HttpListenerException ex)
|
||||||
|
{
|
||||||
|
_logger?.LogWarning(ex,
|
||||||
|
"Could not start control surface on {Prefix}. " +
|
||||||
|
"If binding to LAN, run as Administrator once OR run: " +
|
||||||
|
"netsh http add urlacl url=http://+:{Port}/ user=Everyone",
|
||||||
|
prefix, port);
|
||||||
|
_listener = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_cts = new CancellationTokenSource();
|
||||||
|
_acceptTask = Task.Run(() => AcceptLoopAsync(_cts.Token));
|
||||||
|
|
||||||
|
// Drive the WebSocket push loop on the UI dispatcher so we can read the
|
||||||
|
// ObservableCollection-backed Participants list without thread races. 4Hz
|
||||||
|
// is fast enough that operators see immediate feedback when they flip an
|
||||||
|
// ISO on the Stream Deck without us spamming the wire when nothing's
|
||||||
|
// changing — the snapshot serializer dedupes against the previous push.
|
||||||
|
var dispatcher = System.Windows.Application.Current?.Dispatcher;
|
||||||
|
if (dispatcher is not null)
|
||||||
|
{
|
||||||
|
_pushTimer = new DispatcherTimer(DispatcherPriority.Background, dispatcher)
|
||||||
|
{
|
||||||
|
Interval = TimeSpan.FromMilliseconds(250),
|
||||||
|
};
|
||||||
|
_pushTimer.Tick += async (_, _) => await PushSnapshotIfChangedAsync();
|
||||||
|
_pushTimer.Start();
|
||||||
|
}
|
||||||
|
|
||||||
|
IsRunning = true;
|
||||||
|
_logger?.LogInformation("Control surface listening on {Prefix} (REST + ws)", prefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Stop()
|
||||||
|
{
|
||||||
|
if (!IsRunning) return;
|
||||||
|
try { _pushTimer?.Stop(); } catch { /* ignore */ }
|
||||||
|
_pushTimer = null;
|
||||||
|
// Close + drop every connected WebSocket; clients will reconnect when the
|
||||||
|
// operator re-enables the surface.
|
||||||
|
foreach (var (id, ws) in _clients.ToArray())
|
||||||
|
{
|
||||||
|
try { ws.Abort(); } catch { /* ignore */ }
|
||||||
|
try { ws.Dispose(); } catch { /* ignore */ }
|
||||||
|
_clients.TryRemove(id, out _);
|
||||||
|
}
|
||||||
|
try { _cts?.Cancel(); } catch { /* ignore */ }
|
||||||
|
try { _listener?.Stop(); } catch { /* ignore */ }
|
||||||
|
try { _listener?.Close(); } catch { /* ignore */ }
|
||||||
|
try { _acceptTask?.Wait(TimeSpan.FromSeconds(2)); } catch { /* ignore */ }
|
||||||
|
_listener = null;
|
||||||
|
_cts?.Dispose();
|
||||||
|
_cts = null;
|
||||||
|
_acceptTask = null;
|
||||||
|
IsRunning = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
Stop();
|
||||||
|
await Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task AcceptLoopAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
while (!ct.IsCancellationRequested && _listener is not null && _listener.IsListening)
|
||||||
|
{
|
||||||
|
HttpListenerContext ctx;
|
||||||
|
try { ctx = await _listener.GetContextAsync(); }
|
||||||
|
catch (HttpListenerException) { break; } // listener stopped
|
||||||
|
catch (ObjectDisposedException) { break; }
|
||||||
|
catch (InvalidOperationException) { break; }
|
||||||
|
|
||||||
|
// Each request gets its own task so a slow handler doesn't head-of-line block
|
||||||
|
// others. Handlers are short (no I/O beyond the controller call) so this is
|
||||||
|
// fine without explicit concurrency limits.
|
||||||
|
_ = Task.Run(() => HandleRequestAsync(ctx));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task HandleRequestAsync(HttpListenerContext ctx)
|
||||||
|
{
|
||||||
|
var req = ctx.Request;
|
||||||
|
var res = ctx.Response;
|
||||||
|
// Tracks whether we should call res.Close() in the finally. WebSocket
|
||||||
|
// upgrades transfer ownership of the connection to the WebSocket
|
||||||
|
// instance — closing the response here would tear down the freshly-
|
||||||
|
// upgraded socket immediately. So we skip the finally close on that
|
||||||
|
// path.
|
||||||
|
var closeResponseInFinally = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
res.Headers["Access-Control-Allow-Origin"] = "*";
|
||||||
|
if (req.HttpMethod == "OPTIONS")
|
||||||
|
{
|
||||||
|
res.Headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS";
|
||||||
|
res.Headers["Access-Control-Allow-Headers"] = "Content-Type";
|
||||||
|
res.StatusCode = 204;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var path = req.Url?.AbsolutePath?.TrimEnd('/') ?? "";
|
||||||
|
|
||||||
|
// WebSocket upgrade: live state push for controllers that don't want
|
||||||
|
// to poll. Returns immediately after upgrading; HandleWebSocketAsync
|
||||||
|
// owns the connection until the client disconnects.
|
||||||
|
if (req.IsWebSocketRequest && path == "/ws")
|
||||||
|
{
|
||||||
|
var wsContext = await ctx.AcceptWebSocketAsync(subProtocol: null);
|
||||||
|
closeResponseInFinally = false;
|
||||||
|
_ = Task.Run(() => HandleWebSocketAsync(wsContext.WebSocket));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var body = await ReadBodyAsync(req);
|
||||||
|
|
||||||
|
// GET /ui — embedded HTML control panel. Served as text/html
|
||||||
|
// rather than JSON so a browser renders it directly.
|
||||||
|
if (req.HttpMethod == "GET" && path == "/ui")
|
||||||
|
{
|
||||||
|
res.ContentType = "text/html; charset=utf-8";
|
||||||
|
var html = ControlPanelHtml.Get();
|
||||||
|
var bytes = System.Text.Encoding.UTF8.GetBytes(html);
|
||||||
|
res.ContentLength64 = bytes.Length;
|
||||||
|
await res.OutputStream.WriteAsync(bytes);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /participants/{id}/thumbnail.bmp — small BMP of the latest
|
||||||
|
// processed frame. Returns 404 when no pipeline is running for
|
||||||
|
// this participant. The HTML control panel uses this URL with
|
||||||
|
// a cache-busting query param every ~1s to drive live preview
|
||||||
|
// tiles. BMP (not JPEG) because WPF imaging types NRE from
|
||||||
|
// non-UI threads and BMP encodes in plain managed code; the
|
||||||
|
// 40KB payload at 192-wide compresses fine over LAN gzip.
|
||||||
|
// Old /thumbnail.jpg URL accepted for backward compat.
|
||||||
|
if (req.HttpMethod == "GET" && path.StartsWith("/participants/", StringComparison.Ordinal)
|
||||||
|
&& (path.EndsWith("/thumbnail.bmp", StringComparison.Ordinal) || path.EndsWith("/thumbnail.jpg", StringComparison.Ordinal)))
|
||||||
|
{
|
||||||
|
var ext = path.EndsWith("/thumbnail.bmp", StringComparison.Ordinal) ? "/thumbnail.bmp" : "/thumbnail.jpg";
|
||||||
|
var idSegment = path.AsSpan("/participants/".Length,
|
||||||
|
path.Length - "/participants/".Length - ext.Length).ToString();
|
||||||
|
if (!Guid.TryParse(idSegment, out var thumbId))
|
||||||
|
{
|
||||||
|
res.StatusCode = 400;
|
||||||
|
await WriteJsonAsync(res, new { error = "invalid id" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var bmp = TryEncodeThumbnailJpeg(thumbId);
|
||||||
|
if (bmp is null)
|
||||||
|
{
|
||||||
|
res.StatusCode = 404;
|
||||||
|
await WriteJsonAsync(res, new { error = "no frame", id = thumbId });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.ContentType = "image/bmp";
|
||||||
|
res.AddHeader("Cache-Control", "no-store, must-revalidate");
|
||||||
|
res.ContentLength64 = bmp.Length;
|
||||||
|
await res.OutputStream.WriteAsync(bmp);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
object? response = (req.HttpMethod, path) switch
|
||||||
|
{
|
||||||
|
("GET", "" or "/") => GetServerInfo(),
|
||||||
|
("GET", "/participants") => GetParticipants(),
|
||||||
|
("POST", "/presets/refresh-discovery") => RefreshDiscovery(),
|
||||||
|
("POST", "/presets/stop-all") => await StopAllAsync(),
|
||||||
|
("POST", "/teams/mute") => InvokeTeams(TeamsControlBridge.ToggleMute, "mute"),
|
||||||
|
("POST", "/teams/camera") => InvokeTeams(TeamsControlBridge.ToggleCamera, "camera"),
|
||||||
|
("POST", "/teams/leave") => InvokeTeams(TeamsControlBridge.LeaveCall, "leave"),
|
||||||
|
("POST", "/teams/share") => InvokeTeams(TeamsControlBridge.OpenShareTray, "share"),
|
||||||
|
("POST", "/teams/raise-hand") => InvokeTeams(TeamsControlBridge.ToggleRaiseHand, "raise-hand"),
|
||||||
|
// /recording routes removed alongside the rest of the recording surface.
|
||||||
|
// Topology — read the machine NDI config to report whether raw
|
||||||
|
// Teams NDI sources are hidden from the LAN, and let the
|
||||||
|
// operator apply / restore without leaving the web UI.
|
||||||
|
("GET", "/topology") => GetTopology(),
|
||||||
|
("POST", "/topology/apply") => await ApplyTopologyAsync(),
|
||||||
|
("POST", "/topology/restore") => await RestoreTopologyAsync(),
|
||||||
|
_ when req.HttpMethod == "POST" && path.StartsWith("/participants/", StringComparison.Ordinal)
|
||||||
|
&& path.EndsWith("/override", StringComparison.Ordinal)
|
||||||
|
=> await SetIsoOverrideByIdAsync(path, body),
|
||||||
|
_ when req.HttpMethod == "DELETE" && path.StartsWith("/participants/", StringComparison.Ordinal)
|
||||||
|
&& path.EndsWith("/override", StringComparison.Ordinal)
|
||||||
|
=> await ClearIsoOverrideByIdAsync(path),
|
||||||
|
("POST", "/notes") => AppendNote(body, req.QueryString),
|
||||||
|
("POST", "/participants/iso") => await ToggleIsoByNameAsync(body, req.QueryString),
|
||||||
|
_ when req.HttpMethod == "POST" && path.StartsWith("/participants/", StringComparison.Ordinal)
|
||||||
|
=> await ToggleIsoByIdAsync(path, body, req.QueryString),
|
||||||
|
_ when req.HttpMethod == "POST" && path.StartsWith("/presets/", StringComparison.Ordinal)
|
||||||
|
&& path.EndsWith("/apply", StringComparison.Ordinal)
|
||||||
|
=> await ApplyPresetAsync(path),
|
||||||
|
_ => NotFound(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (response is null)
|
||||||
|
{
|
||||||
|
res.StatusCode = 404;
|
||||||
|
await WriteJsonAsync(res, new { error = "not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await WriteJsonAsync(res, response);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger?.LogWarning(ex, "Control surface request failed: {Path}", req.Url?.AbsolutePath);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
res.StatusCode = 500;
|
||||||
|
await WriteJsonAsync(res, new { error = ex.Message });
|
||||||
|
}
|
||||||
|
catch { /* defensive */ }
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (closeResponseInFinally)
|
||||||
|
{
|
||||||
|
try { res.Close(); } catch { /* defensive */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── handlers ───────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Endpoint bodies live in Services/ControlSurface/Endpoints/*.cs as
|
||||||
|
// partials of this class. See HomeEndpoints, ParticipantsEndpoints,
|
||||||
|
// PresetsEndpoints, TeamsEndpoints, TopologyEndpoints, NotesEndpoints,
|
||||||
|
// and ThumbnailEndpoint. The WebSocket push surface is at
|
||||||
|
// Services/ControlSurface/WebSocketHub.cs.
|
||||||
|
|
||||||
|
[SuppressMessage("Performance", "CA1822", Justification = "Method group used in switch arm.")]
|
||||||
|
private object NotFound() => new { error = "not found" };
|
||||||
|
|
||||||
|
// ─── helpers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static async Task<JsonElement> ReadBodyAsync(HttpListenerRequest req)
|
||||||
|
{
|
||||||
|
if (req.HttpMethod != "POST" || req.ContentLength64 == 0) return default;
|
||||||
|
using var sr = new StreamReader(req.InputStream, req.ContentEncoding ?? Encoding.UTF8);
|
||||||
|
var raw = await sr.ReadToEndAsync();
|
||||||
|
if (string.IsNullOrWhiteSpace(raw)) return default;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return JsonSerializer.Deserialize<JsonElement>(raw);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return default;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task WriteJsonAsync(HttpListenerResponse res, object payload)
|
||||||
|
{
|
||||||
|
res.ContentType = "application/json; charset=utf-8";
|
||||||
|
var json = JsonSerializer.Serialize(payload, JsonOpts);
|
||||||
|
var bytes = Encoding.UTF8.GetBytes(json);
|
||||||
|
res.ContentLength64 = bytes.Length;
|
||||||
|
await res.OutputStream.WriteAsync(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool? TryGetBool(JsonElement body, System.Collections.Specialized.NameValueCollection query, string key)
|
||||||
|
{
|
||||||
|
if (body.ValueKind == JsonValueKind.Object &&
|
||||||
|
body.TryGetProperty(key, out var v) && v.ValueKind is JsonValueKind.True or JsonValueKind.False)
|
||||||
|
return v.GetBoolean();
|
||||||
|
var q = query[key];
|
||||||
|
if (q is null) return null;
|
||||||
|
return q.Equals("true", StringComparison.OrdinalIgnoreCase) || q == "1";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? TryGetString(JsonElement body, System.Collections.Specialized.NameValueCollection query, string key)
|
||||||
|
{
|
||||||
|
if (body.ValueKind == JsonValueKind.Object &&
|
||||||
|
body.TryGetProperty(key, out var v) && v.ValueKind == JsonValueKind.String)
|
||||||
|
return v.GetString();
|
||||||
|
return query[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
134
src/TeamsISO.App/Services/DiagnosticsBundle.cs
Normal file
134
src/TeamsISO.App/Services/DiagnosticsBundle.cs
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
using System.IO;
|
||||||
|
using System.IO.Compression;
|
||||||
|
using System.Reflection;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace TeamsISO.App.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gathers logs + config + presets + version metadata into a single .zip the
|
||||||
|
/// operator can attach to a bug report. Surfaced via the "Export diagnostics"
|
||||||
|
/// button in About.
|
||||||
|
///
|
||||||
|
/// We deliberately do NOT include screenshots or any process/memory dumps —
|
||||||
|
/// that's outside the scope of a v1 support bundle and would raise privacy
|
||||||
|
/// flags. The bundle has only files the user already wrote with their TeamsISO
|
||||||
|
/// usage; nothing here is hidden state.
|
||||||
|
/// </summary>
|
||||||
|
public static class DiagnosticsBundle
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Build the bundle and return the path it was written to.
|
||||||
|
/// Throws on disk failure — the caller toasts/dialogs.
|
||||||
|
/// </summary>
|
||||||
|
public static string Export()
|
||||||
|
{
|
||||||
|
var ts = DateTimeOffset.Now.ToString("yyyyMMdd-HHmmss");
|
||||||
|
var fileName = $"teamsiso-diagnostics-{ts}.zip";
|
||||||
|
var outDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
||||||
|
var downloads = Path.Combine(outDir, "Downloads");
|
||||||
|
if (!Directory.Exists(downloads)) downloads = outDir; // fall back to ~/
|
||||||
|
var outPath = Path.Combine(downloads, fileName);
|
||||||
|
|
||||||
|
using var fs = new FileStream(outPath, FileMode.Create, FileAccess.Write, FileShare.None);
|
||||||
|
using var zip = new ZipArchive(fs, ZipArchiveMode.Create, leaveOpen: false);
|
||||||
|
|
||||||
|
WriteEnvironmentTxt(zip);
|
||||||
|
TryCopyDirectory(zip, "logs", LogsDirectory);
|
||||||
|
TryCopyFile(zip, "config.json", AppDataPath("config.json"));
|
||||||
|
TryCopyFile(zip, "presets.json", LocalAppDataPath("presets.json"));
|
||||||
|
TryCopyFile(zip, "window.json", LocalAppDataPath("window.json"));
|
||||||
|
TryCopyFile(zip, "ndi-config.v1.json", NdiConfigPath());
|
||||||
|
TryCopyFile(zip, "output-name-template.txt", LocalAppDataPath("output-name-template.txt"));
|
||||||
|
|
||||||
|
return outPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteEnvironmentTxt(ZipArchive zip)
|
||||||
|
{
|
||||||
|
var asm = typeof(DiagnosticsBundle).Assembly;
|
||||||
|
var version = asm.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion
|
||||||
|
?? asm.GetName().Version?.ToString()
|
||||||
|
?? "unknown";
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
sb.AppendLine("TeamsISO diagnostic bundle");
|
||||||
|
sb.AppendLine($"Generated: {DateTimeOffset.Now:o}");
|
||||||
|
sb.AppendLine($"TeamsISO version: {version}");
|
||||||
|
sb.AppendLine($".NET runtime: {Environment.Version}");
|
||||||
|
sb.AppendLine($"OS: {Environment.OSVersion}");
|
||||||
|
sb.AppendLine($"Machine: {Environment.MachineName}");
|
||||||
|
sb.AppendLine($"User: {Environment.UserName}");
|
||||||
|
sb.AppendLine($"Process bits: {(Environment.Is64BitProcess ? "64" : "32")}");
|
||||||
|
sb.AppendLine($"OS bits: {(Environment.Is64BitOperatingSystem ? "64" : "32")}");
|
||||||
|
sb.AppendLine($"Working set: {Environment.WorkingSet / (1024 * 1024)} MB");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("Files included (when present):");
|
||||||
|
sb.AppendLine(" logs/ Serilog rolling daily logs");
|
||||||
|
sb.AppendLine(" config.json Engine settings (framerate, NDI groups, etc.)");
|
||||||
|
sb.AppendLine(" presets.json Saved operator presets");
|
||||||
|
sb.AppendLine(" window.json Last main-window placement");
|
||||||
|
sb.AppendLine(" ndi-config.v1.json NDI Access Manager config (group routing)");
|
||||||
|
sb.AppendLine(" output-name-template.txt NDI source name template override");
|
||||||
|
|
||||||
|
var entry = zip.CreateEntry("environment.txt");
|
||||||
|
using var w = new StreamWriter(entry.Open(), Encoding.UTF8);
|
||||||
|
w.Write(sb.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void TryCopyFile(ZipArchive zip, string entryName, string sourcePath)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!File.Exists(sourcePath)) return;
|
||||||
|
zip.CreateEntryFromFile(sourcePath, entryName);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// One missing or locked file shouldn't kill the rest of the bundle.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void TryCopyDirectory(ZipArchive zip, string prefix, string sourceDir)
|
||||||
|
{
|
||||||
|
if (!Directory.Exists(sourceDir)) return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (var file in Directory.EnumerateFiles(sourceDir, "*", SearchOption.AllDirectories))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var rel = Path.GetRelativePath(sourceDir, file).Replace('\\', '/');
|
||||||
|
zip.CreateEntryFromFile(file, $"{prefix}/{rel}");
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Skip locked files (e.g., today's actively-written log).
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Permission denied on the dir as a whole; nothing more to do.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string LogsDirectory =>
|
||||||
|
Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||||
|
"TeamsISO", "Logs");
|
||||||
|
|
||||||
|
private static string LocalAppDataPath(string fileName) =>
|
||||||
|
Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||||
|
"TeamsISO", fileName);
|
||||||
|
|
||||||
|
private static string AppDataPath(string fileName) =>
|
||||||
|
Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||||
|
"TeamsISO", fileName);
|
||||||
|
|
||||||
|
private static string NdiConfigPath() =>
|
||||||
|
Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||||
|
"NDI", "ndi-config.v1.json");
|
||||||
|
}
|
||||||
210
src/TeamsISO.App/Services/NdiAccessManagerConfig.cs
Normal file
210
src/TeamsISO.App/Services/NdiAccessManagerConfig.cs
Normal file
|
|
@ -0,0 +1,210 @@
|
||||||
|
using System.IO;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
|
||||||
|
namespace TeamsISO.App.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads and writes NDI Access Manager's per-user config at
|
||||||
|
/// <c>%APPDATA%\NDI\ndi-config.v1.json</c>. This file controls global NDI behavior for
|
||||||
|
/// every NDI application on the machine — sender groups, receiver groups, RUDP/TCP
|
||||||
|
/// transport toggles, allowed adapters, etc. NDI applications read it on startup, so
|
||||||
|
/// changes here only take effect after restarting the affected app (Teams, OBS, etc.).
|
||||||
|
///
|
||||||
|
/// We use it to implement the "transcoder topology" requested by the user: pin Teams'
|
||||||
|
/// raw at-source-resolution NDI broadcasts to a private group (<c>teamsiso-input</c>) so
|
||||||
|
/// they don't pollute the production network, while TeamsISO's own clean normalized ISO
|
||||||
|
/// outputs continue to broadcast on the standard <c>Public</c> group that downstream
|
||||||
|
/// switchers and recorders default to.
|
||||||
|
///
|
||||||
|
/// The shape of ndi-config.v1.json is documented in the NDI 6 SDK headers; we work in
|
||||||
|
/// terms of <see cref="JsonNode"/> trees so we don't clobber unrelated keys (e.g. RUDP
|
||||||
|
/// settings the user may have customized in Access Manager).
|
||||||
|
/// </summary>
|
||||||
|
public static class NdiAccessManagerConfig
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Path to the NDI Access Manager config. <c>%APPDATA%\NDI\ndi-config.v1.json</c>.
|
||||||
|
/// </summary>
|
||||||
|
public static string ConfigPath =>
|
||||||
|
Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||||
|
"NDI",
|
||||||
|
"ndi-config.v1.json");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Default name of the private group used for the transcoder topology.
|
||||||
|
/// Matches the convention referenced in the NDI Network settings UI.
|
||||||
|
/// </summary>
|
||||||
|
public const string TranscoderInputGroup = "teamsiso-input";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Result of an apply attempt. <see cref="Success"/> indicates the file was
|
||||||
|
/// written or already had the desired groups. <see cref="BackupPath"/> is set
|
||||||
|
/// to the path of the saved-aside copy of the prior config (when one existed),
|
||||||
|
/// so the user can revert if they don't like the change.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record ApplyResult(
|
||||||
|
bool Success,
|
||||||
|
string ConfigPath,
|
||||||
|
string? BackupPath,
|
||||||
|
string? ErrorMessage);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Configures the machine-wide NDI groups so:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item>All local senders (Teams, anything else) broadcast on
|
||||||
|
/// <paramref name="senderGroup"/> only — i.e. the private input group.</item>
|
||||||
|
/// <item>All local receivers see both <paramref name="senderGroup"/> and
|
||||||
|
/// <c>public</c> so TeamsISO can discover Teams' sources AND any
|
||||||
|
/// standard public sources from elsewhere on the network.</item>
|
||||||
|
/// </list>
|
||||||
|
/// TeamsISO's own per-pipeline <c>OutputGroups</c> still overrides the per-machine
|
||||||
|
/// default at the sender level, so its normalized ISO outputs go on Public.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="senderGroup">Private group name for Teams' raw broadcasts.</param>
|
||||||
|
public static ApplyResult ApplyTranscoderTopology(string senderGroup = TranscoderInputGroup)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var root = LoadOrCreate();
|
||||||
|
var ndi = EnsureObject(root, "ndi");
|
||||||
|
var groups = EnsureObject(ndi, "groups");
|
||||||
|
groups["send"] = new JsonArray(senderGroup);
|
||||||
|
groups["recv"] = new JsonArray("public", senderGroup);
|
||||||
|
|
||||||
|
var backupPath = WriteWithBackup(root);
|
||||||
|
return new ApplyResult(Success: true, ConfigPath: ConfigPath, BackupPath: backupPath, ErrorMessage: null);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return new ApplyResult(Success: false, ConfigPath: ConfigPath, BackupPath: null, ErrorMessage: ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Restores defaults: senders on <c>public</c>, receivers on <c>public</c>.
|
||||||
|
/// Equivalent to undoing <see cref="ApplyTranscoderTopology"/>.
|
||||||
|
/// </summary>
|
||||||
|
public static ApplyResult RestoreDefaults()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var root = LoadOrCreate();
|
||||||
|
var ndi = EnsureObject(root, "ndi");
|
||||||
|
var groups = EnsureObject(ndi, "groups");
|
||||||
|
groups["send"] = new JsonArray("public");
|
||||||
|
groups["recv"] = new JsonArray("public");
|
||||||
|
var backupPath = WriteWithBackup(root);
|
||||||
|
return new ApplyResult(Success: true, ConfigPath: ConfigPath, BackupPath: backupPath, ErrorMessage: null);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return new ApplyResult(Success: false, ConfigPath: ConfigPath, BackupPath: null, ErrorMessage: ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads the current sender / receiver group lists, or null if the config doesn't
|
||||||
|
/// exist yet (NDI Access Manager has never been opened on this machine).
|
||||||
|
/// </summary>
|
||||||
|
public static (IReadOnlyList<string>? Send, IReadOnlyList<string>? Recv) ReadCurrentGroups()
|
||||||
|
{
|
||||||
|
if (!File.Exists(ConfigPath)) return (null, null);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var stream = File.OpenRead(ConfigPath);
|
||||||
|
var root = JsonNode.Parse(stream);
|
||||||
|
var groups = root?["ndi"]?["groups"];
|
||||||
|
return (
|
||||||
|
AsStringList(groups?["send"]),
|
||||||
|
AsStringList(groups?["recv"]));
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return (null, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<string>? AsStringList(JsonNode? node) =>
|
||||||
|
node is JsonArray arr ? arr.Select(n => n?.GetValue<string>() ?? string.Empty).ToArray() : null;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One-call shape for the control surface's <c>GET /topology</c>: returns
|
||||||
|
/// the current sender + receiver group lists alongside a computed
|
||||||
|
/// <c>mode</c> string. "hidden" when senders are confined to the private
|
||||||
|
/// transcoder-input group (raw Teams sources invisible on the LAN);
|
||||||
|
/// "public" when senders are on the default group; "unknown" when the
|
||||||
|
/// config file is missing or malformed (treated by callers as "public"
|
||||||
|
/// because NDI's runtime defaults to public when no config is present).
|
||||||
|
/// </summary>
|
||||||
|
public static (string Mode, IReadOnlyList<string> Senders, IReadOnlyList<string> Receivers) ReadCurrent()
|
||||||
|
{
|
||||||
|
var (send, recv) = ReadCurrentGroups();
|
||||||
|
var senders = send ?? Array.Empty<string>();
|
||||||
|
var receivers = recv ?? Array.Empty<string>();
|
||||||
|
string mode;
|
||||||
|
if (send is null)
|
||||||
|
{
|
||||||
|
mode = "unknown";
|
||||||
|
}
|
||||||
|
else if (send.Count == 1 && string.Equals(send[0], TranscoderInputGroup, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
mode = "hidden";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
mode = "public";
|
||||||
|
}
|
||||||
|
return (mode, senders, receivers);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JsonObject LoadOrCreate()
|
||||||
|
{
|
||||||
|
if (File.Exists(ConfigPath))
|
||||||
|
{
|
||||||
|
using var stream = File.OpenRead(ConfigPath);
|
||||||
|
var existing = JsonNode.Parse(stream) as JsonObject;
|
||||||
|
if (existing is not null) return existing;
|
||||||
|
}
|
||||||
|
return new JsonObject();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JsonObject EnsureObject(JsonNode? parent, string key)
|
||||||
|
{
|
||||||
|
if (parent is not JsonObject obj)
|
||||||
|
throw new InvalidOperationException($"Cannot ensure key '{key}' on a non-object parent.");
|
||||||
|
if (obj[key] is JsonObject existing) return existing;
|
||||||
|
var fresh = new JsonObject();
|
||||||
|
obj[key] = fresh;
|
||||||
|
return fresh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Writes the config atomically (temp file + replace) and saves a backup of the
|
||||||
|
/// prior contents next to the original with a timestamp suffix. Returns the
|
||||||
|
/// backup path if a prior file existed; null on first-write.
|
||||||
|
/// </summary>
|
||||||
|
private static string? WriteWithBackup(JsonNode root)
|
||||||
|
{
|
||||||
|
var dir = Path.GetDirectoryName(ConfigPath);
|
||||||
|
if (!string.IsNullOrEmpty(dir))
|
||||||
|
Directory.CreateDirectory(dir);
|
||||||
|
|
||||||
|
string? backupPath = null;
|
||||||
|
if (File.Exists(ConfigPath))
|
||||||
|
{
|
||||||
|
var stamp = DateTime.UtcNow.ToString("yyyyMMddHHmmss");
|
||||||
|
backupPath = ConfigPath + $".bak-{stamp}";
|
||||||
|
File.Copy(ConfigPath, backupPath, overwrite: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
var json = root.ToJsonString(new JsonSerializerOptions { WriteIndented = true });
|
||||||
|
var tempPath = ConfigPath + ".tmp";
|
||||||
|
File.WriteAllText(tempPath, json);
|
||||||
|
if (File.Exists(ConfigPath)) File.Replace(tempPath, ConfigPath, destinationBackupFileName: null);
|
||||||
|
else File.Move(tempPath, ConfigPath);
|
||||||
|
|
||||||
|
return backupPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
68
src/TeamsISO.App/Services/NotesService.cs
Normal file
68
src/TeamsISO.App/Services/NotesService.cs
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
using System.IO;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace TeamsISO.App.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Append-only show-notes log. Each call writes a timestamped line to a daily
|
||||||
|
/// markdown file at <c>%LOCALAPPDATA%\TeamsISO\Notes\<YYYY-MM-DD>.md</c>.
|
||||||
|
/// Operators stamp notes via the REST <c>POST /notes</c> endpoint or the OSC
|
||||||
|
/// <c>/teamsiso/notes "..."</c> address — typically wired to a Stream Deck
|
||||||
|
/// button so a note can be left without leaving the show.
|
||||||
|
///
|
||||||
|
/// We deliberately don't surface the notes inside the WPF UI: the file is
|
||||||
|
/// trivial to open in any editor, and inline note-taking would be a much
|
||||||
|
/// bigger feature (textarea, scrollback, autosave). The endpoint is the
|
||||||
|
/// minimum-viable affordance for live note capture.
|
||||||
|
/// </summary>
|
||||||
|
public static class NotesService
|
||||||
|
{
|
||||||
|
private static readonly object _gate = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Test-only seam — when set, overrides the default
|
||||||
|
/// %LOCALAPPDATA%\TeamsISO\Notes path. Lets tests write to a
|
||||||
|
/// tempdir without polluting the dev's real notes folder.
|
||||||
|
/// InternalsVisibleTo grants TeamsISO.App.Tests access.
|
||||||
|
/// </summary>
|
||||||
|
internal static string? DirectoryOverride { get; set; }
|
||||||
|
|
||||||
|
private static string NotesDirectory =>
|
||||||
|
DirectoryOverride ?? Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||||
|
"TeamsISO", "Notes");
|
||||||
|
|
||||||
|
/// <summary>Today's notes file path (created lazily on first append).</summary>
|
||||||
|
public static string TodayPath =>
|
||||||
|
Path.Combine(NotesDirectory, $"{DateTimeOffset.Now:yyyy-MM-dd}.md");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Append a single timestamped line. Concurrent callers serialize through
|
||||||
|
/// the static gate so we don't end up with interleaved writes from the
|
||||||
|
/// REST handler thread vs. the OSC dispatcher.
|
||||||
|
/// </summary>
|
||||||
|
public static bool Append(string text)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(text)) return false;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(NotesDirectory);
|
||||||
|
var path = TodayPath;
|
||||||
|
var line = $"- **{DateTimeOffset.Now:HH:mm:ss}** — {text.Trim()}{Environment.NewLine}";
|
||||||
|
if (!File.Exists(path))
|
||||||
|
{
|
||||||
|
var header = $"# TeamsISO show notes — {DateTimeOffset.Now:yyyy-MM-dd}{Environment.NewLine}{Environment.NewLine}";
|
||||||
|
File.WriteAllText(path, header, Encoding.UTF8);
|
||||||
|
}
|
||||||
|
File.AppendAllText(path, line, Encoding.UTF8);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
273
src/TeamsISO.App/Services/OperatorPresetStore.cs
Normal file
273
src/TeamsISO.App/Services/OperatorPresetStore.cs
Normal file
|
|
@ -0,0 +1,273 @@
|
||||||
|
using System.IO;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace TeamsISO.App.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Persistent named snapshots of which participants should have ISOs enabled and
|
||||||
|
/// what their custom output names are. Useful for recurring shows: an operator
|
||||||
|
/// can save the assignment they spent 5 minutes setting up, and on the next
|
||||||
|
/// meeting load the same preset and auto-enable everyone whose display name
|
||||||
|
/// matches.
|
||||||
|
///
|
||||||
|
/// Persisted as JSON at <c>%LOCALAPPDATA%\TeamsISO\presets.json</c>. We key by
|
||||||
|
/// participant <see cref="DisplayName"/> rather than <see cref="Guid"/> Id
|
||||||
|
/// because the Id is freshly generated for every meeting (Teams' NDI source
|
||||||
|
/// identity isn't stable across sessions); display name is the operator's
|
||||||
|
/// natural identifier and is what they see in the UI anyway.
|
||||||
|
/// </summary>
|
||||||
|
public static class OperatorPresetStore
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Test-only override for the presets file path. Tests set this to a temp
|
||||||
|
/// path so they don't pollute the operator's real %LOCALAPPDATA% store.
|
||||||
|
/// Null in production. <see cref="System.Runtime.CompilerServices.InternalsVisibleToAttribute"/>
|
||||||
|
/// in the project file grants the test assembly access.
|
||||||
|
/// </summary>
|
||||||
|
internal static string? PathOverride { get; set; }
|
||||||
|
|
||||||
|
private static string PresetsPath =>
|
||||||
|
PathOverride ?? Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||||
|
"TeamsISO",
|
||||||
|
"presets.json");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One operator preset: a name, when it was saved, and a list of
|
||||||
|
/// per-participant assignments keyed by display name.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record Preset(
|
||||||
|
string Name,
|
||||||
|
DateTimeOffset SavedAt,
|
||||||
|
IReadOnlyList<Assignment> Assignments);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Single participant's assignment within a preset. Both fields are stable
|
||||||
|
/// across meetings; <see cref="DisplayName"/> is the join key when applying.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record Assignment(
|
||||||
|
string DisplayName,
|
||||||
|
string? CustomOutputName,
|
||||||
|
bool Enabled);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// On-disk shape: a list of presets indexed by name. Wrapped in an object so
|
||||||
|
/// we can grow the schema (versioning, defaults, last-used) without breaking
|
||||||
|
/// existing files. <see cref="LastAppliedName"/> + <see cref="AutoApplyOnStartup"/>
|
||||||
|
/// drive the "auto-apply on startup" feature; reading older files (which lack
|
||||||
|
/// these fields) falls back to default values via the records' default ctor.
|
||||||
|
/// </summary>
|
||||||
|
private sealed record File(
|
||||||
|
int Version,
|
||||||
|
IReadOnlyList<Preset> Presets,
|
||||||
|
string? LastAppliedName = null,
|
||||||
|
bool AutoApplyOnStartup = false);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Operator-level preferences that travel inside the same JSON envelope as the
|
||||||
|
/// presets themselves. Currently used for the "auto-apply last preset on launch"
|
||||||
|
/// feature so the host can decide on startup whether to silently re-apply the
|
||||||
|
/// most recent preset and which one to apply.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record StartupPreference(string? LastAppliedName, bool AutoApplyOnStartup);
|
||||||
|
|
||||||
|
/// <summary>Returns all stored presets, oldest first. Empty list if no file exists.</summary>
|
||||||
|
public static IReadOnlyList<Preset> LoadAll() => LoadFile().Presets ?? Array.Empty<Preset>();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the operator's startup preference (which preset, if any, should be
|
||||||
|
/// auto-applied on launch). Defaults to <c>(null, false)</c> when no file exists
|
||||||
|
/// or the file predates the field — older preset.json files deserialize cleanly
|
||||||
|
/// because both fields are optional with default values.
|
||||||
|
/// </summary>
|
||||||
|
public static StartupPreference GetStartupPreference()
|
||||||
|
{
|
||||||
|
var file = LoadFile();
|
||||||
|
return new StartupPreference(file.LastAppliedName, file.AutoApplyOnStartup);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Records that <paramref name="name"/> was just successfully applied. Combined
|
||||||
|
/// with <see cref="SetAutoApplyOnStartup"/>, drives the auto-apply-on-launch flow.
|
||||||
|
/// Preserves the rest of the file (presets, AutoApplyOnStartup flag) intact.
|
||||||
|
/// </summary>
|
||||||
|
public static void MarkApplied(string name)
|
||||||
|
{
|
||||||
|
var file = LoadFile();
|
||||||
|
WriteFile(file with { LastAppliedName = name });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Toggles whether the host should auto-apply <see cref="StartupPreference.LastAppliedName"/>
|
||||||
|
/// on next launch. Independent of <see cref="MarkApplied"/> so the operator can flip
|
||||||
|
/// the toggle without losing the most-recent name.
|
||||||
|
/// </summary>
|
||||||
|
public static void SetAutoApplyOnStartup(bool enabled)
|
||||||
|
{
|
||||||
|
var file = LoadFile();
|
||||||
|
WriteFile(file with { AutoApplyOnStartup = enabled });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds (or replaces) a preset by name. Atomic write: writes to a temp file
|
||||||
|
/// then File.Replace so a crash mid-write doesn't corrupt the existing file.
|
||||||
|
/// Preserves <see cref="StartupPreference"/> across writes.
|
||||||
|
/// </summary>
|
||||||
|
public static void Save(Preset preset)
|
||||||
|
{
|
||||||
|
var file = LoadFile();
|
||||||
|
var presets = (file.Presets ?? Array.Empty<Preset>())
|
||||||
|
.Where(p => !string.Equals(p.Name, preset.Name, StringComparison.OrdinalIgnoreCase))
|
||||||
|
.Append(preset)
|
||||||
|
.OrderBy(p => p.Name, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToArray();
|
||||||
|
WriteFile(file with { Presets = presets });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes a preset by name. No-op if not present. If the deleted preset was
|
||||||
|
/// the last-applied one, clears that field so we don't try to re-apply a missing
|
||||||
|
/// preset on next launch.
|
||||||
|
/// </summary>
|
||||||
|
public static void Delete(string name)
|
||||||
|
{
|
||||||
|
var file = LoadFile();
|
||||||
|
var existing = file.Presets ?? Array.Empty<Preset>();
|
||||||
|
var remaining = existing
|
||||||
|
.Where(p => !string.Equals(p.Name, name, StringComparison.OrdinalIgnoreCase))
|
||||||
|
.ToArray();
|
||||||
|
if (remaining.Length == existing.Count) return; // not present
|
||||||
|
|
||||||
|
var clearedLastApplied =
|
||||||
|
string.Equals(file.LastAppliedName, name, StringComparison.OrdinalIgnoreCase)
|
||||||
|
? null
|
||||||
|
: file.LastAppliedName;
|
||||||
|
|
||||||
|
WriteFile(file with { Presets = remaining, LastAppliedName = clearedLastApplied });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Looks up a preset by name (case-insensitive). Null if not present.</summary>
|
||||||
|
public static Preset? Find(string name) =>
|
||||||
|
LoadAll().FirstOrDefault(p => string.Equals(p.Name, name, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bundle format for the export/import surface. Wraps the preset list with
|
||||||
|
/// a version stamp + an export timestamp so a future-format-aware importer
|
||||||
|
/// can migrate the data. We deliberately export a flat preset list — not
|
||||||
|
/// the full <see cref="File"/> envelope — because StartupPreference is
|
||||||
|
/// machine-local (operator A's "auto-apply Friday Show" shouldn't follow
|
||||||
|
/// the bundle to operator B's machine).
|
||||||
|
/// </summary>
|
||||||
|
public sealed record Bundle(
|
||||||
|
string Schema,
|
||||||
|
DateTimeOffset ExportedAt,
|
||||||
|
IReadOnlyList<Preset> Presets)
|
||||||
|
{
|
||||||
|
public const string CurrentSchema = "teamsiso-presets-bundle/v1";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Serialize every preset to a JSON string suitable for writing to disk.
|
||||||
|
/// The shape is human-readable (WriteIndented) so an operator can diff
|
||||||
|
/// two bundles in their editor.
|
||||||
|
/// </summary>
|
||||||
|
public static string ExportAllAsJson()
|
||||||
|
{
|
||||||
|
var bundle = new Bundle(
|
||||||
|
Schema: Bundle.CurrentSchema,
|
||||||
|
ExportedAt: DateTimeOffset.Now,
|
||||||
|
Presets: LoadAll());
|
||||||
|
return JsonSerializer.Serialize(bundle, new JsonSerializerOptions { WriteIndented = true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Result of an import attempt — counts so the UI can toast a clear summary.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record ImportResult(int Added, int Overwritten, int Skipped, string? Error)
|
||||||
|
{
|
||||||
|
public static ImportResult Failed(string error) => new(0, 0, 0, error);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Import a bundle JSON. Per-preset name collision policy is determined by
|
||||||
|
/// <paramref name="overwrite"/>: when true, identically-named presets in the
|
||||||
|
/// bundle replace local ones; when false they're skipped. Returns counts
|
||||||
|
/// so the caller can toast a "added X, overwrote Y, skipped Z" summary.
|
||||||
|
/// </summary>
|
||||||
|
public static ImportResult ImportBundle(string json, bool overwrite)
|
||||||
|
{
|
||||||
|
Bundle? bundle;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
bundle = JsonSerializer.Deserialize<Bundle>(json);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return ImportResult.Failed("Could not parse bundle: " + ex.Message);
|
||||||
|
}
|
||||||
|
if (bundle is null || bundle.Presets is null)
|
||||||
|
return ImportResult.Failed("Bundle was empty or malformed.");
|
||||||
|
|
||||||
|
var existingNames = new HashSet<string>(
|
||||||
|
LoadAll().Select(p => p.Name),
|
||||||
|
StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
var added = 0;
|
||||||
|
var overwritten = 0;
|
||||||
|
var skipped = 0;
|
||||||
|
|
||||||
|
foreach (var p in bundle.Presets)
|
||||||
|
{
|
||||||
|
if (existingNames.Contains(p.Name))
|
||||||
|
{
|
||||||
|
if (!overwrite) { skipped++; continue; }
|
||||||
|
overwritten++;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
added++;
|
||||||
|
}
|
||||||
|
try { Save(p); }
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// One bad preset shouldn't abort the rest. Count as skipped so
|
||||||
|
// the user knows their import wasn't 100% clean.
|
||||||
|
if (overwrite && existingNames.Contains(p.Name)) overwritten--;
|
||||||
|
else added--;
|
||||||
|
skipped++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ImportResult(added, overwritten, skipped, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static File LoadFile()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!System.IO.File.Exists(PresetsPath))
|
||||||
|
return new File(1, Array.Empty<Preset>());
|
||||||
|
var json = System.IO.File.ReadAllText(PresetsPath);
|
||||||
|
return JsonSerializer.Deserialize<File>(json) ?? new File(1, Array.Empty<Preset>());
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return new File(1, Array.Empty<Preset>());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteFile(File file)
|
||||||
|
{
|
||||||
|
var dir = Path.GetDirectoryName(PresetsPath);
|
||||||
|
if (!string.IsNullOrEmpty(dir))
|
||||||
|
Directory.CreateDirectory(dir);
|
||||||
|
|
||||||
|
var json = JsonSerializer.Serialize(file, new JsonSerializerOptions { WriteIndented = true });
|
||||||
|
var temp = PresetsPath + ".tmp";
|
||||||
|
System.IO.File.WriteAllText(temp, json);
|
||||||
|
if (System.IO.File.Exists(PresetsPath))
|
||||||
|
System.IO.File.Replace(temp, PresetsPath, destinationBackupFileName: null);
|
||||||
|
else
|
||||||
|
System.IO.File.Move(temp, PresetsPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
368
src/TeamsISO.App/Services/OscBridge.cs
Normal file
368
src/TeamsISO.App/Services/OscBridge.cs
Normal file
|
|
@ -0,0 +1,368 @@
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
using System.Text;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using TeamsISO.App.ViewModels;
|
||||||
|
using TeamsISO.Engine.Controller;
|
||||||
|
|
||||||
|
namespace TeamsISO.App.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// OSC over UDP. Companion, TouchOSC, Bome, and most lighting consoles speak
|
||||||
|
/// OSC natively, so wrapping the same command surface in OSC opens the
|
||||||
|
/// product to the broader live-show ecosystem without a Companion bridge.
|
||||||
|
///
|
||||||
|
/// Protocol — minimal OSC 1.0:
|
||||||
|
/// - Address pattern (null-terminated string, padded to 4-byte boundary)
|
||||||
|
/// - Type tag (",iiisf" etc., null-terminated, padded to 4)
|
||||||
|
/// - Args in order
|
||||||
|
///
|
||||||
|
/// We don't implement bundles, time tags, blob args, or pattern matching
|
||||||
|
/// — none are needed for the verbs we support. If a sender uses bundles
|
||||||
|
/// we ignore them; if a sender uses a wildcard address ("/teamsiso/*") we
|
||||||
|
/// ignore it. Operators get a clear log line in either case.
|
||||||
|
///
|
||||||
|
/// Routes:
|
||||||
|
/// /teamsiso/iso "DisplayName" {0|1} — toggle/set ISO by display name
|
||||||
|
/// /teamsiso/iso/by-id "guid" {0|1} — toggle/set by Id
|
||||||
|
/// /teamsiso/preset "Name" — apply preset
|
||||||
|
/// /teamsiso/teams/mute — UIA toggle mute
|
||||||
|
/// /teamsiso/teams/camera — UIA toggle camera
|
||||||
|
/// /teamsiso/teams/leave — UIA leave
|
||||||
|
/// /teamsiso/teams/share — UIA share tray
|
||||||
|
/// /teamsiso/teams/raise-hand — UIA raise hand
|
||||||
|
/// /teamsiso/refresh-discovery — rebuild NDI finder
|
||||||
|
/// /teamsiso/stop-all — disable every ISO
|
||||||
|
/// /teamsiso/recording {0|1} — recording on/off (default dir)
|
||||||
|
/// </summary>
|
||||||
|
public sealed class OscBridge : IAsyncDisposable
|
||||||
|
{
|
||||||
|
public const int DefaultPort = 9000;
|
||||||
|
|
||||||
|
private readonly IIsoController _controller;
|
||||||
|
private readonly Func<MainViewModel?> _viewModel;
|
||||||
|
private readonly ILogger<OscBridge>? _logger;
|
||||||
|
private UdpClient? _udp;
|
||||||
|
private CancellationTokenSource? _cts;
|
||||||
|
private Task? _receiveTask;
|
||||||
|
|
||||||
|
public bool IsRunning { get; private set; }
|
||||||
|
public int Port { get; private set; } = DefaultPort;
|
||||||
|
/// <summary>True when the listener is bound to all interfaces rather than just loopback.</summary>
|
||||||
|
public bool BoundToLan { get; private set; }
|
||||||
|
|
||||||
|
public OscBridge(
|
||||||
|
IIsoController controller,
|
||||||
|
Func<MainViewModel?> viewModel,
|
||||||
|
ILogger<OscBridge>? logger = null)
|
||||||
|
{
|
||||||
|
_controller = controller;
|
||||||
|
_viewModel = viewModel;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Start the OSC listener on the given UDP port. <paramref name="bindToLan"/>
|
||||||
|
/// flag selects between loopback (default — only this machine) and any-
|
||||||
|
/// interface binding (LAN-reachable, for thin-client controllers).
|
||||||
|
/// Unlike the REST surface, UDP doesn't need a URL ACL — binding 0.0.0.0
|
||||||
|
/// is just an unprivileged port reservation.
|
||||||
|
/// </summary>
|
||||||
|
public void Start(int port, bool bindToLan = false)
|
||||||
|
{
|
||||||
|
if (IsRunning && Port == port && BoundToLan == bindToLan) return;
|
||||||
|
Stop();
|
||||||
|
|
||||||
|
Port = port;
|
||||||
|
BoundToLan = bindToLan;
|
||||||
|
var bindAddr = bindToLan ? IPAddress.Any : IPAddress.Loopback;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_udp = new UdpClient(new IPEndPoint(bindAddr, port));
|
||||||
|
}
|
||||||
|
catch (SocketException ex)
|
||||||
|
{
|
||||||
|
_logger?.LogWarning(ex, "Could not bind OSC bridge to udp://{Addr}:{Port}.", bindAddr, port);
|
||||||
|
_udp = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_cts = new CancellationTokenSource();
|
||||||
|
_receiveTask = Task.Run(() => ReceiveLoopAsync(_cts.Token));
|
||||||
|
IsRunning = true;
|
||||||
|
_logger?.LogInformation("OSC bridge listening on udp://{Addr}:{Port}/", bindAddr, port);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Stop()
|
||||||
|
{
|
||||||
|
if (!IsRunning) return;
|
||||||
|
try { _cts?.Cancel(); } catch { /* ignore */ }
|
||||||
|
try { _udp?.Close(); } catch { /* ignore */ }
|
||||||
|
try { _receiveTask?.Wait(TimeSpan.FromSeconds(2)); } catch { /* ignore */ }
|
||||||
|
_udp?.Dispose();
|
||||||
|
_udp = null;
|
||||||
|
_cts?.Dispose();
|
||||||
|
_cts = null;
|
||||||
|
_receiveTask = null;
|
||||||
|
IsRunning = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
Stop();
|
||||||
|
await Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ReceiveLoopAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
while (!ct.IsCancellationRequested && _udp is not null)
|
||||||
|
{
|
||||||
|
UdpReceiveResult result;
|
||||||
|
try { result = await _udp.ReceiveAsync(ct); }
|
||||||
|
catch (OperationCanceledException) { break; }
|
||||||
|
catch (ObjectDisposedException) { break; }
|
||||||
|
catch (SocketException ex)
|
||||||
|
{
|
||||||
|
_logger?.LogWarning(ex, "OSC receive failed; continuing.");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var msg = OscMessage.TryParse(result.Buffer);
|
||||||
|
if (msg is null) continue;
|
||||||
|
await DispatchAsync(msg);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger?.LogWarning(ex, "OSC dispatch failed for packet from {Endpoint}.", result.RemoteEndPoint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Internal so unit tests can construct an OscMessage and verify
|
||||||
|
// route dispatch reaches the right controller / TeamsControlBridge /
|
||||||
|
// NotesService call without driving the full UDP receive loop.
|
||||||
|
internal async Task DispatchAsync(OscMessage msg)
|
||||||
|
{
|
||||||
|
var addr = msg.Address;
|
||||||
|
switch (addr)
|
||||||
|
{
|
||||||
|
case "/teamsiso/teams/mute": InvokeTeams(TeamsControlBridge.ToggleMute); return;
|
||||||
|
case "/teamsiso/teams/camera": InvokeTeams(TeamsControlBridge.ToggleCamera); return;
|
||||||
|
case "/teamsiso/teams/leave": InvokeTeams(TeamsControlBridge.LeaveCall); return;
|
||||||
|
case "/teamsiso/teams/share": InvokeTeams(TeamsControlBridge.OpenShareTray); return;
|
||||||
|
case "/teamsiso/teams/raise-hand":InvokeTeams(TeamsControlBridge.ToggleRaiseHand); return;
|
||||||
|
case "/teamsiso/refresh-discovery":_controller.RefreshDiscovery(); return;
|
||||||
|
case "/teamsiso/stop-all": await StopAllAsync(); return;
|
||||||
|
// /teamsiso/recording routes removed alongside the rest of the recording surface.
|
||||||
|
case "/teamsiso/notes": AppendNote(msg); return;
|
||||||
|
case "/teamsiso/iso": await ToggleByNameAsync(msg); return;
|
||||||
|
case "/teamsiso/iso/by-id": await ToggleByIdAsync(msg); return;
|
||||||
|
case "/teamsiso/preset": await ApplyPresetAsync(msg); return;
|
||||||
|
default:
|
||||||
|
_logger?.LogDebug("Ignoring unknown OSC address {Addr}", addr);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── handler helpers ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static void InvokeTeams(Func<TeamsControlBridge.InvokeResult> action) => _ = action();
|
||||||
|
|
||||||
|
private async Task StopAllAsync()
|
||||||
|
{
|
||||||
|
var vm = _viewModel();
|
||||||
|
if (vm is null) return;
|
||||||
|
var dispatcher = System.Windows.Application.Current?.Dispatcher;
|
||||||
|
if (dispatcher is null) return;
|
||||||
|
var enabled = await dispatcher.InvokeAsync(() =>
|
||||||
|
vm.Participants.Where(p => p.IsEnabled).ToArray());
|
||||||
|
foreach (var p in enabled)
|
||||||
|
{
|
||||||
|
try { await _controller.DisableIsoAsync(p.Id, CancellationToken.None); }
|
||||||
|
catch { /* defensive */ }
|
||||||
|
await dispatcher.InvokeAsync(() => p.IsEnabled = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetRecording / DropMarker / RollRecordingAsync handlers removed alongside
|
||||||
|
// the rest of the recording surface.
|
||||||
|
|
||||||
|
private static void AppendNote(OscMessage msg)
|
||||||
|
{
|
||||||
|
var text = msg.GetStringArg(0);
|
||||||
|
if (!string.IsNullOrWhiteSpace(text)) NotesService.Append(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ToggleByNameAsync(OscMessage msg)
|
||||||
|
{
|
||||||
|
var name = msg.GetStringArg(0);
|
||||||
|
if (string.IsNullOrEmpty(name)) return;
|
||||||
|
var enabled = msg.GetBoolArg(1);
|
||||||
|
|
||||||
|
var vm = _viewModel();
|
||||||
|
var dispatcher = System.Windows.Application.Current?.Dispatcher;
|
||||||
|
if (vm is null || dispatcher is null) return;
|
||||||
|
var p = await dispatcher.InvokeAsync(() =>
|
||||||
|
vm.Participants.FirstOrDefault(x =>
|
||||||
|
string.Equals(x.DisplayName, name, StringComparison.OrdinalIgnoreCase)));
|
||||||
|
if (p is null) return;
|
||||||
|
await ApplyToggleAsync(p, enabled, dispatcher);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ToggleByIdAsync(OscMessage msg)
|
||||||
|
{
|
||||||
|
var idStr = msg.GetStringArg(0);
|
||||||
|
if (!Guid.TryParse(idStr, out var id)) return;
|
||||||
|
var enabled = msg.GetBoolArg(1);
|
||||||
|
|
||||||
|
var vm = _viewModel();
|
||||||
|
var dispatcher = System.Windows.Application.Current?.Dispatcher;
|
||||||
|
if (vm is null || dispatcher is null) return;
|
||||||
|
var p = await dispatcher.InvokeAsync(() =>
|
||||||
|
vm.Participants.FirstOrDefault(x => x.Id == id));
|
||||||
|
if (p is null) return;
|
||||||
|
await ApplyToggleAsync(p, enabled, dispatcher);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ApplyToggleAsync(ParticipantViewModel p, bool? enabled, System.Windows.Threading.Dispatcher dispatcher)
|
||||||
|
{
|
||||||
|
var target = enabled ?? !p.IsEnabled;
|
||||||
|
if (target == p.IsEnabled) return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (target)
|
||||||
|
{
|
||||||
|
await _controller.EnableIsoAsync(p.Id,
|
||||||
|
string.IsNullOrWhiteSpace(p.CustomName) ? null : p.CustomName,
|
||||||
|
CancellationToken.None);
|
||||||
|
await dispatcher.InvokeAsync(() => p.IsEnabled = true);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await _controller.DisableIsoAsync(p.Id, CancellationToken.None);
|
||||||
|
await dispatcher.InvokeAsync(() => p.IsEnabled = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
/* defensive: OSC senders are typically fire-and-forget */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ApplyPresetAsync(OscMessage msg)
|
||||||
|
{
|
||||||
|
var name = msg.GetStringArg(0);
|
||||||
|
if (string.IsNullOrEmpty(name)) return;
|
||||||
|
var preset = OperatorPresetStore.Find(name);
|
||||||
|
if (preset is null) return;
|
||||||
|
|
||||||
|
var vm = _viewModel();
|
||||||
|
var dispatcher = System.Windows.Application.Current?.Dispatcher;
|
||||||
|
if (vm is null || dispatcher is null) return;
|
||||||
|
var snapshot = await dispatcher.InvokeAsync(() => vm.Participants.ToList());
|
||||||
|
await PresetApplier.ApplyAsync(preset, snapshot, _controller, dispatcher);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── OSC message parser ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Minimal OSC 1.0 message parser. Supports the subset we care about:
|
||||||
|
/// integer (i), float (f), string (s) args. Bundles / time tags / blobs are
|
||||||
|
/// not implemented — incoming packets that look like bundles return null
|
||||||
|
/// and the caller logs + skips them.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class OscMessage
|
||||||
|
{
|
||||||
|
public string Address { get; init; } = "";
|
||||||
|
public string TypeTag { get; init; } = "";
|
||||||
|
public IReadOnlyList<object> Args { get; init; } = Array.Empty<object>();
|
||||||
|
|
||||||
|
/// <summary>Parse a single OSC packet. Returns null if malformed or a bundle.</summary>
|
||||||
|
public static OscMessage? TryParse(byte[] bytes)
|
||||||
|
{
|
||||||
|
if (bytes.Length < 8) return null;
|
||||||
|
// Bundle marker — we don't support bundles. Skip.
|
||||||
|
if (bytes[0] == '#') return null;
|
||||||
|
|
||||||
|
var idx = 0;
|
||||||
|
var address = ReadOscString(bytes, ref idx);
|
||||||
|
if (address is null || !address.StartsWith('/')) return null;
|
||||||
|
|
||||||
|
if (idx >= bytes.Length) return new OscMessage { Address = address };
|
||||||
|
var typeTag = ReadOscString(bytes, ref idx);
|
||||||
|
if (typeTag is null || !typeTag.StartsWith(',')) return null;
|
||||||
|
|
||||||
|
var args = new List<object>();
|
||||||
|
for (var i = 1; i < typeTag.Length; i++)
|
||||||
|
{
|
||||||
|
switch (typeTag[i])
|
||||||
|
{
|
||||||
|
case 'i':
|
||||||
|
if (idx + 4 > bytes.Length) return null;
|
||||||
|
args.Add(ReadInt32BE(bytes, idx));
|
||||||
|
idx += 4;
|
||||||
|
break;
|
||||||
|
case 'f':
|
||||||
|
if (idx + 4 > bytes.Length) return null;
|
||||||
|
args.Add(ReadFloat32BE(bytes, idx));
|
||||||
|
idx += 4;
|
||||||
|
break;
|
||||||
|
case 's':
|
||||||
|
var s = ReadOscString(bytes, ref idx);
|
||||||
|
if (s is null) return null;
|
||||||
|
args.Add(s);
|
||||||
|
break;
|
||||||
|
case 'T': args.Add(true); break;
|
||||||
|
case 'F': args.Add(false); break;
|
||||||
|
default:
|
||||||
|
// Unknown type — bail rather than mis-aligning subsequent args.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new OscMessage { Address = address, TypeTag = typeTag, Args = args };
|
||||||
|
}
|
||||||
|
|
||||||
|
public string? GetStringArg(int idx) =>
|
||||||
|
idx < Args.Count && Args[idx] is string s ? s : null;
|
||||||
|
|
||||||
|
public bool? GetBoolArg(int idx)
|
||||||
|
{
|
||||||
|
if (idx >= Args.Count) return null;
|
||||||
|
return Args[idx] switch
|
||||||
|
{
|
||||||
|
bool b => b,
|
||||||
|
int i => i != 0,
|
||||||
|
float f => f != 0f,
|
||||||
|
string s => s.Equals("true", StringComparison.OrdinalIgnoreCase) || s == "1",
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ReadOscString(byte[] bytes, ref int idx)
|
||||||
|
{
|
||||||
|
var start = idx;
|
||||||
|
while (idx < bytes.Length && bytes[idx] != 0) idx++;
|
||||||
|
if (idx >= bytes.Length) return null;
|
||||||
|
var s = Encoding.ASCII.GetString(bytes, start, idx - start);
|
||||||
|
// Advance past the trailing null and align to 4-byte boundary.
|
||||||
|
idx++;
|
||||||
|
var pad = (4 - (idx - start) % 4) % 4;
|
||||||
|
idx += pad;
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int ReadInt32BE(byte[] bytes, int offset) =>
|
||||||
|
(bytes[offset] << 24) | (bytes[offset + 1] << 16) | (bytes[offset + 2] << 8) | bytes[offset + 3];
|
||||||
|
|
||||||
|
private static float ReadFloat32BE(byte[] bytes, int offset)
|
||||||
|
{
|
||||||
|
Span<byte> tmp = stackalloc byte[4];
|
||||||
|
tmp[0] = bytes[offset + 3];
|
||||||
|
tmp[1] = bytes[offset + 2];
|
||||||
|
tmp[2] = bytes[offset + 1];
|
||||||
|
tmp[3] = bytes[offset];
|
||||||
|
return BitConverter.ToSingle(tmp);
|
||||||
|
}
|
||||||
|
}
|
||||||
150
src/TeamsISO.App/Services/OutputNameTemplate.cs
Normal file
150
src/TeamsISO.App/Services/OutputNameTemplate.cs
Normal file
|
|
@ -0,0 +1,150 @@
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace TeamsISO.App.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// User-editable template for the NDI source name a participant's ISO is
|
||||||
|
/// published as. Default <c>"{name}"</c> renders the speaker's display name
|
||||||
|
/// directly, which is what downstream switchers want when they key on
|
||||||
|
/// readable identifiers. Operators can override globally to
|
||||||
|
/// <c>"TEAMSISO_{guid}"</c> for the legacy stable-id behavior, or
|
||||||
|
/// <c>"TEAMSISO_{machine}_{name}"</c> when multiple TeamsISO machines feed
|
||||||
|
/// the same NDI network and you want the source name to carry both.
|
||||||
|
/// Per-participant overrides take priority over whatever template is set.
|
||||||
|
///
|
||||||
|
/// Tokens expanded in <see cref="Render"/>:
|
||||||
|
/// <c>{name}</c> participant display name, sanitized (alphanumeric + underscore)
|
||||||
|
/// <c>{guid}</c> first 8 hex chars of the participant's Id, uppercase
|
||||||
|
/// <c>{machine}</c> sanitized PC hostname (Environment.MachineName)
|
||||||
|
/// <c>{timestamp}</c> current local time as <c>yyyyMMdd_HHmmss</c>
|
||||||
|
///
|
||||||
|
/// Empty-name fallback: if the rendered result is empty/whitespace (e.g.
|
||||||
|
/// template was <c>"{name}"</c> and the participant joined with no display
|
||||||
|
/// name yet), <see cref="Render"/> falls back to <c>TEAMSISO_{guid}</c> so
|
||||||
|
/// the NDI sender always has a usable, unique identifier.
|
||||||
|
///
|
||||||
|
/// Persisted to <c>%LOCALAPPDATA%\TeamsISO\output-name-template.txt</c>.
|
||||||
|
/// </summary>
|
||||||
|
public static class OutputNameTemplate
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Default template — renders just the speaker's display name. Was
|
||||||
|
/// <c>"TEAMSISO_{guid}"</c> in pre-v1 builds; switched 2026-05-16 so
|
||||||
|
/// new installs get human-readable source names out of the box.
|
||||||
|
/// </summary>
|
||||||
|
public const string DefaultTemplate = "{name}";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stable fallback used when the rendered template produces an empty
|
||||||
|
/// string (typically because a participant has no display name yet).
|
||||||
|
/// Mirrors the engine's legacy DefaultOutputName so the NDI sender is
|
||||||
|
/// always uniquely identifiable.
|
||||||
|
/// </summary>
|
||||||
|
private const string EmptyNameFallback = "TEAMSISO_{guid}";
|
||||||
|
|
||||||
|
private static string TemplatePath =>
|
||||||
|
Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||||
|
"TeamsISO", "output-name-template.txt");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get the operator's current template, or the shipped default when no
|
||||||
|
/// override has been saved (or the override file is missing/unreadable).
|
||||||
|
/// </summary>
|
||||||
|
public static string Get()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (File.Exists(TemplatePath))
|
||||||
|
{
|
||||||
|
var raw = File.ReadAllText(TemplatePath).Trim();
|
||||||
|
if (!string.IsNullOrEmpty(raw)) return raw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Disk read failure → fall through to default. The next Set() call
|
||||||
|
// will overwrite cleanly.
|
||||||
|
}
|
||||||
|
return DefaultTemplate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void Set(string template)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var dir = Path.GetDirectoryName(TemplatePath);
|
||||||
|
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
|
||||||
|
File.WriteAllText(TemplatePath, template ?? string.Empty);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Best-effort persistence; the in-memory value still sticks for
|
||||||
|
// this session.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Expand tokens in <paramref name="template"/> for a specific participant.
|
||||||
|
/// Result is sanitized into NDI-safe characters: alphanumeric, underscore,
|
||||||
|
/// hyphen, period. NDI spec allows more, but a conservative set keeps
|
||||||
|
/// downstream switchers happy.
|
||||||
|
/// </summary>
|
||||||
|
public static string Render(string template, Guid participantId, string displayName)
|
||||||
|
{
|
||||||
|
var safeName = SanitizeForNdi(displayName);
|
||||||
|
var guid = participantId.ToString("N")[..8].ToUpperInvariant();
|
||||||
|
var machine = SanitizeForNdi(Environment.MachineName);
|
||||||
|
var timestamp = DateTimeOffset.Now.ToString("yyyyMMdd_HHmmss");
|
||||||
|
|
||||||
|
var result = template
|
||||||
|
.Replace("{name}", safeName)
|
||||||
|
.Replace("{guid}", guid)
|
||||||
|
.Replace("{machine}", machine)
|
||||||
|
.Replace("{timestamp}", timestamp);
|
||||||
|
|
||||||
|
// Final sanitize on the rendered result — protects against a template
|
||||||
|
// that includes literal characters NDI doesn't accept.
|
||||||
|
var sanitized = SanitizeForNdi(result);
|
||||||
|
|
||||||
|
// Empty-name fallback. The default template "{name}" can render to
|
||||||
|
// an unusable result for participants whose DisplayName hasn't been
|
||||||
|
// populated yet (Teams sometimes delivers the displayName a tick
|
||||||
|
// after the participant join event). Two failure modes to catch:
|
||||||
|
//
|
||||||
|
// • DisplayName == "" → "{name}" expands to "" → sanitized "".
|
||||||
|
// • DisplayName == " " → "{name}" expands to "___" because the
|
||||||
|
// sanitizer converts whitespace to underscores.
|
||||||
|
//
|
||||||
|
// Neither is a meaningful NDI source identifier, so we substitute
|
||||||
|
// TEAMSISO_{guid}. The Any(char.IsLetterOrDigit) check covers both
|
||||||
|
// cases — anything without at least one alphanumeric is unusable.
|
||||||
|
// We apply this AFTER token expansion (not on the raw input) so a
|
||||||
|
// template like "PFX_{name}" with empty displayName still works:
|
||||||
|
// it renders to "PFX_" which contains alphanumerics and is left
|
||||||
|
// alone.
|
||||||
|
if (string.IsNullOrWhiteSpace(sanitized) || !sanitized.Any(char.IsLetterOrDigit))
|
||||||
|
{
|
||||||
|
sanitized = SanitizeForNdi(EmptyNameFallback.Replace("{guid}", guid));
|
||||||
|
}
|
||||||
|
|
||||||
|
return sanitized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string SanitizeForNdi(string s)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(s)) return string.Empty;
|
||||||
|
var sb = new StringBuilder(s.Length);
|
||||||
|
foreach (var c in s)
|
||||||
|
{
|
||||||
|
if (char.IsLetterOrDigit(c) || c is '_' or '-' or '.')
|
||||||
|
sb.Append(c);
|
||||||
|
else if (char.IsWhiteSpace(c))
|
||||||
|
sb.Append('_');
|
||||||
|
// else: skip
|
||||||
|
}
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
104
src/TeamsISO.App/Services/PresetApplier.cs
Normal file
104
src/TeamsISO.App/Services/PresetApplier.cs
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
using System.Windows.Threading;
|
||||||
|
using TeamsISO.App.ViewModels;
|
||||||
|
using TeamsISO.Engine.Controller;
|
||||||
|
|
||||||
|
namespace TeamsISO.App.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shared preset-application logic. Originally lived inline in
|
||||||
|
/// <c>PresetsDialog.OnApply</c>; lifted out so the REST control surface
|
||||||
|
/// (<see cref="ControlSurfaceServer"/>) and the auto-apply-on-launch path
|
||||||
|
/// (<see cref="MainViewModel.TryAutoApplyPendingPreset"/>) can call the same
|
||||||
|
/// implementation. Single source of truth for "what does Apply mean."
|
||||||
|
///
|
||||||
|
/// Application proceeds participant-by-participant, matching by display name
|
||||||
|
/// (the only stable join key across meetings since Ids regen each session).
|
||||||
|
/// For each match, the custom output name is updated and IsEnabled is
|
||||||
|
/// reconciled with the preset's value via <see cref="IIsoController.EnableIsoAsync"/>
|
||||||
|
/// / <see cref="IIsoController.DisableIsoAsync"/>. Per-participant failures are
|
||||||
|
/// caught and counted; one bad row never aborts applying the rest.
|
||||||
|
/// </summary>
|
||||||
|
public static class PresetApplier
|
||||||
|
{
|
||||||
|
/// <summary>Result counts from an apply pass.</summary>
|
||||||
|
public sealed record ApplyResult(int Matched, int Changed, int Skipped);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Apply <paramref name="preset"/> to the live <paramref name="participants"/>
|
||||||
|
/// list. <paramref name="dispatcher"/>, when supplied, is used to marshal
|
||||||
|
/// IsEnabled / CustomName property writes onto the UI thread; pass null in
|
||||||
|
/// contexts that already run on the UI thread (e.g. the dialog's button click).
|
||||||
|
/// </summary>
|
||||||
|
public static async Task<ApplyResult> ApplyAsync(
|
||||||
|
Services.OperatorPresetStore.Preset preset,
|
||||||
|
IReadOnlyList<ParticipantViewModel> participants,
|
||||||
|
IIsoController controller,
|
||||||
|
Dispatcher? dispatcher = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
// Build the lookup once, case-insensitive — Teams display names are
|
||||||
|
// human-typed, so "Jane" and "jane" should match the same row.
|
||||||
|
var byName = preset.Assignments.ToDictionary(
|
||||||
|
a => a.DisplayName,
|
||||||
|
StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
var matched = 0;
|
||||||
|
var changed = 0;
|
||||||
|
|
||||||
|
foreach (var p in participants)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
if (!byName.TryGetValue(p.DisplayName, out var assignment)) continue;
|
||||||
|
matched++;
|
||||||
|
|
||||||
|
await SetOnUiAsync(dispatcher, () => p.CustomName = assignment.CustomOutputName ?? string.Empty);
|
||||||
|
|
||||||
|
if (assignment.Enabled && !p.IsEnabled)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await controller.EnableIsoAsync(
|
||||||
|
p.Id,
|
||||||
|
string.IsNullOrWhiteSpace(p.CustomName) ? null : p.CustomName,
|
||||||
|
cancellationToken);
|
||||||
|
await SetOnUiAsync(dispatcher, () => p.IsEnabled = true);
|
||||||
|
changed++;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Per-participant best-effort: the rest still get applied.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (!assignment.Enabled && p.IsEnabled)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await controller.DisableIsoAsync(p.Id, cancellationToken);
|
||||||
|
await SetOnUiAsync(dispatcher, () => p.IsEnabled = false);
|
||||||
|
changed++;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
/* defensive */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark applied so auto-apply-on-launch picks the right preset next time.
|
||||||
|
try { Services.OperatorPresetStore.MarkApplied(preset.Name); }
|
||||||
|
catch { /* preference write is best-effort */ }
|
||||||
|
|
||||||
|
var skipped = preset.Assignments.Count - matched;
|
||||||
|
return new ApplyResult(matched, changed, skipped);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Task SetOnUiAsync(Dispatcher? dispatcher, Action action)
|
||||||
|
{
|
||||||
|
if (dispatcher is null || dispatcher.CheckAccess())
|
||||||
|
{
|
||||||
|
action();
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
return dispatcher.InvokeAsync(action).Task;
|
||||||
|
}
|
||||||
|
}
|
||||||
398
src/TeamsISO.App/Services/TeamsControlBridge.cs
Normal file
398
src/TeamsISO.App/Services/TeamsControlBridge.cs
Normal file
|
|
@ -0,0 +1,398 @@
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Windows.Automation;
|
||||||
|
|
||||||
|
namespace TeamsISO.App.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Phase E.3 — UIAutomation bridge for the in-call controls (mute, camera,
|
||||||
|
/// leave, share screen). Walks Teams' automation tree to locate the relevant
|
||||||
|
/// buttons and invokes their <see cref="InvokePattern"/> or <see cref="TogglePattern"/>.
|
||||||
|
///
|
||||||
|
/// This is intentionally tolerant of Teams' UI volatility: we search by a
|
||||||
|
/// chain of (AutomationId, Name, LocalizedControlType) candidates rather than
|
||||||
|
/// pinning to a single identifier. When Teams ships a new build that renames a
|
||||||
|
/// button, the operator gets a clear "control not found" toast rather than a
|
||||||
|
/// crash, and we add the new identifier to the candidate list.
|
||||||
|
///
|
||||||
|
/// Limitations:
|
||||||
|
/// - Requires Teams' main window to be present (not minimized to the system tray
|
||||||
|
/// in a way that detaches its automation peers; minimized to taskbar is fine).
|
||||||
|
/// - Some Teams builds host the call UI in a separate WebView2-backed top-level
|
||||||
|
/// window; we enumerate every top-level window owned by every Teams process,
|
||||||
|
/// so we'll find it wherever it lives.
|
||||||
|
/// - Hidden windows (after <see cref="TeamsLauncher.HideWindows"/>) are still
|
||||||
|
/// traversable by UIAutomation — the buttons exist in the automation tree
|
||||||
|
/// even when their HWND is SW_HIDDEN. This is what makes the "hide Teams,
|
||||||
|
/// drive it from TeamsISO" workflow viable.
|
||||||
|
/// </summary>
|
||||||
|
public static class TeamsControlBridge
|
||||||
|
{
|
||||||
|
// ────────────────────────────────────────────────────────────────────
|
||||||
|
// Localized candidate-name lists.
|
||||||
|
//
|
||||||
|
// Teams localizes the AutomationElement.Name we match against. The lookup
|
||||||
|
// strategy is: ALL candidate strings across all locales are tried for each
|
||||||
|
// command, and the first match wins. This gives us a single binary that
|
||||||
|
// works regardless of the Teams UI language without needing to detect it
|
||||||
|
// — at the cost of a slightly broader match surface (a non-mute button
|
||||||
|
// with the German word "Stumm" in its name would false-positive). In
|
||||||
|
// practice Teams' button Names are highly distinctive and we haven't seen
|
||||||
|
// false positives during development.
|
||||||
|
//
|
||||||
|
// Adding a locale: append the localized strings to each command's array.
|
||||||
|
// Order doesn't matter for correctness; for performance we put the most
|
||||||
|
// common locales first since the array is iterated in order.
|
||||||
|
// ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static readonly string[] MuteCandidates =
|
||||||
|
{
|
||||||
|
// English (US/UK)
|
||||||
|
"Mute", "Unmute", "Microphone", "Toggle mute",
|
||||||
|
// German
|
||||||
|
"Stumm", "Stummschaltung", "Stummschalten", "Stummschaltung aufheben", "Mikrofon",
|
||||||
|
// Spanish
|
||||||
|
"Silenciar", "Activar audio", "Micrófono",
|
||||||
|
// French
|
||||||
|
"Désactiver le micro", "Activer le micro", "Micro", "Microphone",
|
||||||
|
// Portuguese
|
||||||
|
"Desativar áudio", "Ativar áudio", "Microfone",
|
||||||
|
// Japanese
|
||||||
|
"ミュート", "ミュート解除", "マイク",
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly string[] CameraCandidates =
|
||||||
|
{
|
||||||
|
"Camera", "Turn camera on", "Turn camera off", "Video",
|
||||||
|
// German
|
||||||
|
"Kamera", "Kamera einschalten", "Kamera ausschalten", "Video",
|
||||||
|
// Spanish
|
||||||
|
"Cámara", "Activar cámara", "Desactivar cámara", "Vídeo",
|
||||||
|
// French
|
||||||
|
"Caméra", "Activer la caméra", "Désactiver la caméra", "Vidéo",
|
||||||
|
// Portuguese
|
||||||
|
"Câmera", "Ativar câmera", "Desativar câmera",
|
||||||
|
// Japanese
|
||||||
|
"カメラ", "ビデオ",
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly string[] LeaveCandidates =
|
||||||
|
{
|
||||||
|
"Leave", "Hang up", "End call", "Leave call",
|
||||||
|
// German
|
||||||
|
"Verlassen", "Auflegen", "Anruf beenden",
|
||||||
|
// Spanish
|
||||||
|
"Salir", "Colgar", "Finalizar llamada",
|
||||||
|
// French
|
||||||
|
"Quitter", "Raccrocher", "Terminer l'appel",
|
||||||
|
// Portuguese
|
||||||
|
"Sair", "Desligar", "Encerrar chamada",
|
||||||
|
// Japanese
|
||||||
|
"退出", "通話を終了",
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly string[] ShareCandidates =
|
||||||
|
{
|
||||||
|
"Share", "Share content", "Share screen", "Open share tray",
|
||||||
|
// German
|
||||||
|
"Teilen", "Inhalt teilen", "Bildschirm teilen",
|
||||||
|
// Spanish
|
||||||
|
"Compartir", "Compartir contenido", "Compartir pantalla",
|
||||||
|
// French
|
||||||
|
"Partager", "Partager du contenu", "Partager l'écran",
|
||||||
|
// Portuguese
|
||||||
|
"Compartilhar", "Compartilhar conteúdo", "Compartilhar tela",
|
||||||
|
// Japanese
|
||||||
|
"共有", "コンテンツの共有", "画面を共有",
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly string[] RaiseHandCandidates =
|
||||||
|
{
|
||||||
|
"Raise", "Raise hand", "Lower hand",
|
||||||
|
// German
|
||||||
|
"Hand heben", "Hand senken",
|
||||||
|
// Spanish
|
||||||
|
"Levantar la mano", "Bajar la mano",
|
||||||
|
// French
|
||||||
|
"Lever la main", "Baisser la main",
|
||||||
|
// Portuguese
|
||||||
|
"Levantar a mão", "Abaixar a mão",
|
||||||
|
// Japanese
|
||||||
|
"手を挙げる", "手を下ろす",
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly string[] ToggleChatCandidates =
|
||||||
|
{
|
||||||
|
"Show conversation", "Hide conversation", "Chat", "Show chat",
|
||||||
|
// German
|
||||||
|
"Unterhaltung anzeigen", "Unterhaltung ausblenden", "Chat",
|
||||||
|
// Spanish
|
||||||
|
"Mostrar conversación", "Ocultar conversación", "Chat",
|
||||||
|
// French
|
||||||
|
"Afficher la conversation", "Masquer la conversation", "Conversation",
|
||||||
|
// Portuguese
|
||||||
|
"Mostrar conversa", "Ocultar conversa", "Chat",
|
||||||
|
// Japanese
|
||||||
|
"会話を表示", "会話を非表示", "チャット",
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly string[] BackgroundBlurCandidates =
|
||||||
|
{
|
||||||
|
"Background effects", "Apply background effects", "Background filters",
|
||||||
|
// German
|
||||||
|
"Hintergrundeffekte", "Hintergrundfilter",
|
||||||
|
// Spanish
|
||||||
|
"Efectos de fondo", "Filtros de fondo",
|
||||||
|
// French
|
||||||
|
"Effets d'arrière-plan", "Filtres d'arrière-plan",
|
||||||
|
// Portuguese
|
||||||
|
"Efeitos de plano de fundo", "Filtros de plano de fundo",
|
||||||
|
// Japanese
|
||||||
|
"背景効果", "背景フィルター",
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>Result of attempting one of the in-call commands.</summary>
|
||||||
|
public enum InvokeResult
|
||||||
|
{
|
||||||
|
/// <summary>The control was found and invoked successfully.</summary>
|
||||||
|
Invoked,
|
||||||
|
/// <summary>Teams isn't running, or its automation root couldn't be located.</summary>
|
||||||
|
TeamsNotRunning,
|
||||||
|
/// <summary>Teams is running but the matching button isn't currently exposed (maybe not in a call).</summary>
|
||||||
|
ControlNotFound,
|
||||||
|
/// <summary>The button was found but didn't expose a usable invoke / toggle pattern.</summary>
|
||||||
|
InvokeFailed,
|
||||||
|
}
|
||||||
|
|
||||||
|
public static InvokeResult ToggleMute() => InvokeFirstMatch(MuteCandidates);
|
||||||
|
public static InvokeResult ToggleCamera() => InvokeFirstMatch(CameraCandidates);
|
||||||
|
public static InvokeResult LeaveCall() => InvokeFirstMatch(LeaveCandidates);
|
||||||
|
public static InvokeResult OpenShareTray() => InvokeFirstMatch(ShareCandidates);
|
||||||
|
public static InvokeResult ToggleRaiseHand() => InvokeFirstMatch(RaiseHandCandidates);
|
||||||
|
public static InvokeResult ToggleChat() => InvokeFirstMatch(ToggleChatCandidates);
|
||||||
|
public static InvokeResult OpenBackgroundEffects() => InvokeFirstMatch(BackgroundBlurCandidates);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Snapshot of the current call's local-user state. Read via a single
|
||||||
|
/// UIA traversal in <see cref="DetectCallState"/>; null sub-fields when
|
||||||
|
/// the call isn't active or the button isn't in the tree.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record CallStateSnapshot(bool IsInCall, bool? IsMuted, bool? IsCameraOff);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One-shot UIA probe of Teams' in-call controls. The Mute and Camera
|
||||||
|
/// buttons toggle their Name between "Mute"/"Unmute" and "Turn camera
|
||||||
|
/// on"/"Turn camera off" depending on state, so reading the Name tells
|
||||||
|
/// us whether the operator is currently muted / camera-off.
|
||||||
|
///
|
||||||
|
/// Returns IsInCall=false if Teams isn't running or no Leave button
|
||||||
|
/// exists. Returns IsMuted/IsCameraOff as null if those buttons aren't
|
||||||
|
/// found in this build (defensive — Teams sometimes uses different
|
||||||
|
/// candidate names across locales).
|
||||||
|
/// </summary>
|
||||||
|
public static CallStateSnapshot DetectCallState()
|
||||||
|
{
|
||||||
|
var roots = GetTeamsAutomationRoots();
|
||||||
|
if (roots.Count == 0) return new CallStateSnapshot(false, null, null);
|
||||||
|
|
||||||
|
var inCall = false;
|
||||||
|
bool? muted = null;
|
||||||
|
bool? camOff = null;
|
||||||
|
|
||||||
|
foreach (var root in roots)
|
||||||
|
{
|
||||||
|
AutomationElementCollection allButtons;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
allButtons = root.FindAll(
|
||||||
|
TreeScope.Descendants,
|
||||||
|
new PropertyCondition(AutomationElement.ControlTypeProperty, ControlType.Button));
|
||||||
|
}
|
||||||
|
catch { continue; }
|
||||||
|
|
||||||
|
foreach (AutomationElement btn in allButtons)
|
||||||
|
{
|
||||||
|
var name = SafeGetName(btn);
|
||||||
|
if (string.IsNullOrEmpty(name)) continue;
|
||||||
|
var lower = name.ToLowerInvariant();
|
||||||
|
|
||||||
|
if (!inCall && LeaveCandidates.Any(c => name.Contains(c, StringComparison.OrdinalIgnoreCase)))
|
||||||
|
inCall = true;
|
||||||
|
|
||||||
|
// Mute button: name is "Mute" when active-can-mute, "Unmute"
|
||||||
|
// when currently muted. Detect by checking for "unmute" first
|
||||||
|
// (more specific) before falling to "mute" (more general).
|
||||||
|
if (muted is null)
|
||||||
|
{
|
||||||
|
if (lower.Contains("unmute") || lower.Contains("stummschaltung aufheben") ||
|
||||||
|
lower.Contains("activar audio") || lower.Contains("activer le micro") ||
|
||||||
|
lower.Contains("ativar áudio") || lower.Contains("ミュート解除"))
|
||||||
|
muted = true;
|
||||||
|
else if (lower.Contains("mute") || lower.Contains("stummschalten") ||
|
||||||
|
lower.Contains("silenciar") || lower.Contains("désactiver le micro") ||
|
||||||
|
lower.Contains("desativar áudio") || lower.Contains("ミュート"))
|
||||||
|
muted = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Camera button: name is "Turn camera off" when on, "Turn
|
||||||
|
// camera on" when off.
|
||||||
|
if (camOff is null)
|
||||||
|
{
|
||||||
|
if (lower.Contains("turn camera on") || lower.Contains("kamera einschalten") ||
|
||||||
|
lower.Contains("activar cámara") || lower.Contains("activer la caméra") ||
|
||||||
|
lower.Contains("ativar câmera"))
|
||||||
|
camOff = true;
|
||||||
|
else if (lower.Contains("turn camera off") || lower.Contains("kamera ausschalten") ||
|
||||||
|
lower.Contains("desactivar cámara") || lower.Contains("désactiver la caméra") ||
|
||||||
|
lower.Contains("desativar câmera"))
|
||||||
|
camOff = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new CallStateSnapshot(inCall, muted, camOff);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True if Teams is currently in an active call. The Leave / Hang-up
|
||||||
|
/// button only exists in the automation tree when a call is in progress,
|
||||||
|
/// so its presence is a reliable in-call signal across Teams versions.
|
||||||
|
/// Returns false if Teams isn't running, isn't in a call, or the call
|
||||||
|
/// UI is in a state we don't recognize.
|
||||||
|
///
|
||||||
|
/// This is the "tell me what Teams is doing without me having to look
|
||||||
|
/// at it" probe — operators using auto-hide Teams want a status pill
|
||||||
|
/// that says "In call · ready" without having to restore the Teams
|
||||||
|
/// window. Safe to call from any thread (UIA traversal is thread-safe);
|
||||||
|
/// not free (walks the descendant tree) so callers should poll at most
|
||||||
|
/// a few times per second.
|
||||||
|
/// </summary>
|
||||||
|
public static bool IsInCall()
|
||||||
|
{
|
||||||
|
var roots = GetTeamsAutomationRoots();
|
||||||
|
if (roots.Count == 0) return false;
|
||||||
|
|
||||||
|
foreach (var root in roots)
|
||||||
|
{
|
||||||
|
AutomationElementCollection allButtons;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
allButtons = root.FindAll(
|
||||||
|
TreeScope.Descendants,
|
||||||
|
new PropertyCondition(AutomationElement.ControlTypeProperty, ControlType.Button));
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Window died mid-traversal; try the next root.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (AutomationElement btn in allButtons)
|
||||||
|
{
|
||||||
|
var name = SafeGetName(btn);
|
||||||
|
if (string.IsNullOrEmpty(name)) continue;
|
||||||
|
if (LeaveCandidates.Any(c => name.Contains(c, StringComparison.OrdinalIgnoreCase)))
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static InvokeResult InvokeFirstMatch(IReadOnlyList<string> candidateNames)
|
||||||
|
{
|
||||||
|
var roots = GetTeamsAutomationRoots();
|
||||||
|
if (roots.Count == 0) return InvokeResult.TeamsNotRunning;
|
||||||
|
|
||||||
|
foreach (var root in roots)
|
||||||
|
{
|
||||||
|
// Search by Name first (most common case for Teams). Use a NameProperty
|
||||||
|
// contains-style match by collecting all Buttons in the subtree and then
|
||||||
|
// filtering manually — Condition only supports equality, and Teams'
|
||||||
|
// labels can include trailing state ("(unmuted)") that breaks equality.
|
||||||
|
var allButtons = root.FindAll(
|
||||||
|
TreeScope.Descendants,
|
||||||
|
new PropertyCondition(AutomationElement.ControlTypeProperty, ControlType.Button));
|
||||||
|
|
||||||
|
foreach (AutomationElement btn in allButtons)
|
||||||
|
{
|
||||||
|
var name = SafeGetName(btn);
|
||||||
|
if (string.IsNullOrEmpty(name)) continue;
|
||||||
|
if (!candidateNames.Any(c => name.Contains(c, StringComparison.OrdinalIgnoreCase)))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (TryInvoke(btn)) return InvokeResult.Invoked;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return InvokeResult.ControlNotFound;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the AutomationElement root for every top-level window owned by
|
||||||
|
/// any running Teams process. Multiple roots is the normal case for new
|
||||||
|
/// MSTeams (which uses one window per call/chat).
|
||||||
|
/// </summary>
|
||||||
|
private static List<AutomationElement> GetTeamsAutomationRoots()
|
||||||
|
{
|
||||||
|
var teamsPids = new HashSet<int>(
|
||||||
|
Process.GetProcessesByName("ms-teams")
|
||||||
|
.Concat(Process.GetProcessesByName("msteams"))
|
||||||
|
.Concat(Process.GetProcessesByName("Teams"))
|
||||||
|
.Select(p => { try { return p.Id; } finally { p.Dispose(); } }));
|
||||||
|
|
||||||
|
if (teamsPids.Count == 0) return new List<AutomationElement>();
|
||||||
|
|
||||||
|
// Filter the desktop's children to windows whose ProcessId matches.
|
||||||
|
var desktop = AutomationElement.RootElement;
|
||||||
|
var allWindows = desktop.FindAll(
|
||||||
|
TreeScope.Children,
|
||||||
|
new PropertyCondition(AutomationElement.ControlTypeProperty, ControlType.Window));
|
||||||
|
|
||||||
|
var roots = new List<AutomationElement>();
|
||||||
|
foreach (AutomationElement w in allWindows)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var pid = (int)w.Current.ProcessId;
|
||||||
|
if (teamsPids.Contains(pid)) roots.Add(w);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Window died between enumeration and property read; skip.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return roots;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string SafeGetName(AutomationElement el)
|
||||||
|
{
|
||||||
|
try { return el.Current.Name ?? string.Empty; }
|
||||||
|
catch { return string.Empty; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Try Invoke first (most buttons), then Toggle (mute/camera are usually
|
||||||
|
/// toggle-pattern). Returns true if either succeeded.
|
||||||
|
/// </summary>
|
||||||
|
private static bool TryInvoke(AutomationElement el)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (el.TryGetCurrentPattern(InvokePattern.Pattern, out var invoke))
|
||||||
|
{
|
||||||
|
((InvokePattern)invoke).Invoke();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (el.TryGetCurrentPattern(TogglePattern.Pattern, out var toggle))
|
||||||
|
{
|
||||||
|
((TogglePattern)toggle).Toggle();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// ElementNotEnabledException, ElementNotAvailableException — Teams
|
||||||
|
// disabled the button mid-traversal (e.g. mute is disabled before
|
||||||
|
// joining a call). Treat as "found but couldn't invoke" so the
|
||||||
|
// caller can surface a useful message.
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
177
src/TeamsISO.App/Services/TeamsEmbedHost.cs
Normal file
177
src/TeamsISO.App/Services/TeamsEmbedHost.cs
Normal file
|
|
@ -0,0 +1,177 @@
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
|
||||||
|
namespace TeamsISO.App.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Phase E.4 — Embedded Teams via SetParent.
|
||||||
|
///
|
||||||
|
/// Reparents Teams' main top-level window into a TeamsISO-owned host
|
||||||
|
/// (typically a Border element's HWND). Strips the captured window's
|
||||||
|
/// caption + thick frame so it integrates flush with the host, and
|
||||||
|
/// remembers enough about the original to restore it cleanly later.
|
||||||
|
///
|
||||||
|
/// The Win32 behavior is well understood for classic Win32 apps, but
|
||||||
|
/// modern Teams runs WebView2 in its main window; WebView2's renderer is
|
||||||
|
/// sensitive to parent changes and may flash white frames during
|
||||||
|
/// reparent, drop input focus, or refuse to redraw until forced. We mark
|
||||||
|
/// the feature experimental and ensure the restore path always runs (the
|
||||||
|
/// caller wraps Embed in a finally block) so operators can fall back to
|
||||||
|
/// auto-hide mode if embedding misbehaves on their specific Teams build.
|
||||||
|
///
|
||||||
|
/// Lives in its own static class — separated from <see cref="TeamsLauncher"/>
|
||||||
|
/// because the embedding lifecycle (reparent → resize → restore) is its
|
||||||
|
/// own thing, and the Win32 surface it requires (SetParent / window-style
|
||||||
|
/// muck / SetWindowPos / MoveWindow) isn't reused by the launch / hide /
|
||||||
|
/// in-call control paths.
|
||||||
|
/// </summary>
|
||||||
|
public static class TeamsEmbedHost
|
||||||
|
{
|
||||||
|
[DllImport("user32.dll", SetLastError = true)]
|
||||||
|
private static extern IntPtr SetParent(IntPtr hWndChild, IntPtr hWndNewParent);
|
||||||
|
|
||||||
|
[DllImport("user32.dll", SetLastError = true)]
|
||||||
|
private static extern int GetWindowLongPtr(IntPtr hWnd, int nIndex);
|
||||||
|
|
||||||
|
[DllImport("user32.dll", SetLastError = true)]
|
||||||
|
private static extern int SetWindowLongPtr(IntPtr hWnd, int nIndex, int dwNewLong);
|
||||||
|
|
||||||
|
[DllImport("user32.dll", SetLastError = true)]
|
||||||
|
[return: MarshalAs(UnmanagedType.Bool)]
|
||||||
|
private static extern bool MoveWindow(IntPtr hWnd, int X, int Y, int nWidth, int nHeight, [MarshalAs(UnmanagedType.Bool)] bool bRepaint);
|
||||||
|
|
||||||
|
[DllImport("user32.dll", SetLastError = true)]
|
||||||
|
[return: MarshalAs(UnmanagedType.Bool)]
|
||||||
|
private static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter, int X, int Y, int cx, int cy, uint uFlags);
|
||||||
|
|
||||||
|
[DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
||||||
|
private static extern int GetWindowTextLengthW(IntPtr hWnd);
|
||||||
|
|
||||||
|
private const int GWL_STYLE = -16;
|
||||||
|
private const long WS_CHILD = 0x40000000;
|
||||||
|
private const long WS_POPUP = unchecked((long)0x80000000);
|
||||||
|
private const long WS_CAPTION = 0x00C00000;
|
||||||
|
private const long WS_THICKFRAME = 0x00040000;
|
||||||
|
private const long WS_BORDER = 0x00800000;
|
||||||
|
private const long WS_DLGFRAME = 0x00400000;
|
||||||
|
private const uint SWP_FRAMECHANGED = 0x0020;
|
||||||
|
private const uint SWP_NOMOVE = 0x0002;
|
||||||
|
private const uint SWP_NOSIZE = 0x0001;
|
||||||
|
private const uint SWP_NOZORDER = 0x0004;
|
||||||
|
private const uint SWP_NOACTIVATE = 0x0010;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Captures the original parent + window style so embedding can be
|
||||||
|
/// reversed cleanly. Tracked per-HWND so multiple consecutive
|
||||||
|
/// embed / unembed cycles don't lose the original chrome.
|
||||||
|
/// </summary>
|
||||||
|
private static (IntPtr OriginalParent, int OriginalStyle)? _embedSavedState;
|
||||||
|
private static IntPtr _embeddedHwnd = IntPtr.Zero;
|
||||||
|
|
||||||
|
/// <summary>True when a Teams window is currently parented inside a TeamsISO host.</summary>
|
||||||
|
public static bool IsEmbedded => _embeddedHwnd != IntPtr.Zero;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reparents Teams' most-recently-used top-level window into
|
||||||
|
/// <paramref name="hostHwnd"/>. Strips Teams' caption + thick frame
|
||||||
|
/// so it integrates flush with the host. Returns true on success,
|
||||||
|
/// false if no Teams window could be found.
|
||||||
|
///
|
||||||
|
/// The host HWND is typically obtained via:
|
||||||
|
/// var src = (System.Windows.Interop.HwndSource)
|
||||||
|
/// PresentationSource.FromVisual(MyHostBorder);
|
||||||
|
/// src.Handle // → IntPtr suitable for hostHwnd
|
||||||
|
/// </summary>
|
||||||
|
public static bool EmbedTeamsInto(IntPtr hostHwnd, int width, int height)
|
||||||
|
{
|
||||||
|
if (hostHwnd == IntPtr.Zero) return false;
|
||||||
|
var teamsWindows = TeamsLauncher.EnumerateTopLevelTeamsWindows();
|
||||||
|
if (teamsWindows.Count == 0) return false;
|
||||||
|
|
||||||
|
// Pick the longest-title window as the "main" one — same
|
||||||
|
// heuristic GetActiveWindowTitle uses; matches the call /
|
||||||
|
// meeting window.
|
||||||
|
IntPtr best = IntPtr.Zero;
|
||||||
|
int bestLen = -1;
|
||||||
|
foreach (var w in teamsWindows)
|
||||||
|
{
|
||||||
|
var len = GetWindowTextLengthW(w);
|
||||||
|
if (len > bestLen) { bestLen = len; best = w; }
|
||||||
|
}
|
||||||
|
if (best == IntPtr.Zero) return false;
|
||||||
|
|
||||||
|
// Already embedded? Unembed first to clean state.
|
||||||
|
if (_embeddedHwnd != IntPtr.Zero) RestoreEmbed();
|
||||||
|
|
||||||
|
// Save original style + parent so we can fully reverse later.
|
||||||
|
var originalStyle = GetWindowLongPtr(best, GWL_STYLE);
|
||||||
|
var originalParent = SetParent(best, hostHwnd); // returns old parent
|
||||||
|
|
||||||
|
_embedSavedState = (originalParent, originalStyle);
|
||||||
|
_embeddedHwnd = best;
|
||||||
|
|
||||||
|
// Strip top-level decorations + add WS_CHILD so the OS treats
|
||||||
|
// it as a child window of the host.
|
||||||
|
var newStyle = originalStyle;
|
||||||
|
unchecked
|
||||||
|
{
|
||||||
|
newStyle &= ~(int)WS_CAPTION;
|
||||||
|
newStyle &= ~(int)WS_THICKFRAME;
|
||||||
|
newStyle &= ~(int)WS_BORDER;
|
||||||
|
newStyle &= ~(int)WS_DLGFRAME;
|
||||||
|
newStyle &= ~(int)WS_POPUP;
|
||||||
|
newStyle |= (int)WS_CHILD;
|
||||||
|
}
|
||||||
|
SetWindowLongPtr(best, GWL_STYLE, newStyle);
|
||||||
|
|
||||||
|
// Force a non-client recalculation so the style change takes
|
||||||
|
// effect.
|
||||||
|
SetWindowPos(best, IntPtr.Zero, 0, 0, 0, 0,
|
||||||
|
SWP_FRAMECHANGED | SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE);
|
||||||
|
|
||||||
|
// Place at top-left of host, full host size.
|
||||||
|
MoveWindow(best, 0, 0, width, height, true);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resize the currently-embedded Teams window to <paramref name="width"/>
|
||||||
|
/// × <paramref name="height"/>. Called when the host element resizes
|
||||||
|
/// (window resize, layout change, etc.). No-op if nothing is embedded.
|
||||||
|
/// </summary>
|
||||||
|
public static void ResizeEmbedded(int width, int height)
|
||||||
|
{
|
||||||
|
if (_embeddedHwnd == IntPtr.Zero || width <= 0 || height <= 0) return;
|
||||||
|
MoveWindow(_embeddedHwnd, 0, 0, width, height, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reverse an active embed: SetParent back to desktop + restore the
|
||||||
|
/// original window style so Teams looks/behaves like a normal
|
||||||
|
/// top-level window again. Safe to call when nothing is embedded —
|
||||||
|
/// no-op.
|
||||||
|
/// </summary>
|
||||||
|
public static void RestoreEmbed()
|
||||||
|
{
|
||||||
|
if (_embeddedHwnd == IntPtr.Zero || _embedSavedState is null) return;
|
||||||
|
var (origParent, origStyle) = _embedSavedState.Value;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Restore original style FIRST so when we reparent the
|
||||||
|
// window's top-level decorations come back correctly.
|
||||||
|
SetWindowLongPtr(_embeddedHwnd, GWL_STYLE, origStyle);
|
||||||
|
// SetParent(hwnd, Zero) returns to desktop. We could pass
|
||||||
|
// origParent verbatim but for Teams that's always the
|
||||||
|
// desktop anyway, and IntPtr.Zero is documented as
|
||||||
|
// "reparent to desktop".
|
||||||
|
SetParent(_embeddedHwnd, IntPtr.Zero);
|
||||||
|
SetWindowPos(_embeddedHwnd, IntPtr.Zero, 0, 0, 0, 0,
|
||||||
|
SWP_FRAMECHANGED | SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE);
|
||||||
|
}
|
||||||
|
catch { /* defensive — restore must never throw */ }
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_embedSavedState = null;
|
||||||
|
_embeddedHwnd = IntPtr.Zero;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
510
src/TeamsISO.App/Services/TeamsLauncher.cs
Normal file
510
src/TeamsISO.App/Services/TeamsLauncher.cs
Normal file
|
|
@ -0,0 +1,510 @@
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.IO;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
|
||||||
|
namespace TeamsISO.App.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Small Win32 wrapper that launches the Microsoft Teams desktop client as a
|
||||||
|
/// subprocess of TeamsISO. First step toward Phase E.1 of the embedded-Teams
|
||||||
|
/// roadmap (see docs/superpowers/specs/2026-05-08-embedded-teams-orchestration.md):
|
||||||
|
/// the operator can launch Teams from within TeamsISO so they don't have to
|
||||||
|
/// switch apps to start a meeting.
|
||||||
|
///
|
||||||
|
/// The launcher tries (in order):
|
||||||
|
/// 1. ms-teams: URI (works for both classic and new Teams)
|
||||||
|
/// 2. MSTeams.exe in %LOCALAPPDATA%\Microsoft\WindowsApps\
|
||||||
|
/// 3. Teams.exe in %LOCALAPPDATA%\Microsoft\Teams\Update.exe (legacy)
|
||||||
|
///
|
||||||
|
/// Group-routing automation (writing NDI Access Manager config so Teams
|
||||||
|
/// broadcasts on a private group) is deferred to a follow-up — for v1.0 we
|
||||||
|
/// document the manual steps in RELEASING.md and trust the operator to set
|
||||||
|
/// them once per machine.
|
||||||
|
/// </summary>
|
||||||
|
public static class TeamsLauncher
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Heuristic process-name candidates we'll consider as "the Teams process" when
|
||||||
|
/// the rail toggle wants to find a running instance. New MSTeams comes first.
|
||||||
|
/// </summary>
|
||||||
|
private static readonly string[] TeamsProcessNames =
|
||||||
|
{
|
||||||
|
"ms-teams", // new MSTeams binary basename
|
||||||
|
"msteams", // alternate basename observed on some installs
|
||||||
|
"Teams", // classic Teams desktop client
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True if any process matching the known Teams binary basenames is running.
|
||||||
|
/// Used by the rail to decide whether to show "Launch Teams" vs "Stop Teams".
|
||||||
|
/// </summary>
|
||||||
|
public static bool IsRunning() =>
|
||||||
|
TeamsProcessNames.Any(n => Process.GetProcessesByName(n).Length > 0);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Launches Teams. Returns true if a launch was started successfully (the
|
||||||
|
/// process may take a few seconds to actually appear). False if every
|
||||||
|
/// fallback path failed; <paramref name="errorMessage"/> includes the
|
||||||
|
/// reasons each attempt was rejected so the operator can see why.
|
||||||
|
///
|
||||||
|
/// Path order matters:
|
||||||
|
/// 1. <c>ms-teams:</c> URI — new Teams (MSTeams AppX) registers this
|
||||||
|
/// handler at install. Activates through the AppX shell so the
|
||||||
|
/// stub <c>ms-teams.exe</c> in WindowsApps gets the right context.
|
||||||
|
/// 2. AppsFolder shell verb — direct AppX activation. Belt-and-braces
|
||||||
|
/// fallback if a misconfigured registry breaks the URI handler.
|
||||||
|
/// 3. Classic Teams Update.exe — pre-2024 Teams installations.
|
||||||
|
/// We deliberately DON'T try the bare <c>ms-teams.exe</c> WindowsApps
|
||||||
|
/// path: it's a 0-byte AppX placeholder that fails silently when invoked
|
||||||
|
/// without AppX activation context. Looked plausible, never worked.
|
||||||
|
/// </summary>
|
||||||
|
public static bool TryLaunch(out string? errorMessage)
|
||||||
|
{
|
||||||
|
errorMessage = null;
|
||||||
|
var attempts = new List<string>();
|
||||||
|
|
||||||
|
// Path 1: URI scheme. The shell handler picks the registered Teams
|
||||||
|
// (new MSTeams takes priority on modern Windows). UseShellExecute=true
|
||||||
|
// is required — Win32 Process creation can't open URIs directly.
|
||||||
|
if (TryStart("ms-teams:", useShell: true, out var err1)) return true;
|
||||||
|
attempts.Add($"ms-teams: URI → {err1}");
|
||||||
|
|
||||||
|
// Path 2: AppX activation via the explorer.exe shell. Modern Teams
|
||||||
|
// ships as MSTeams_8wekyb3d8bbwe; if other code on the box has
|
||||||
|
// clobbered the URI registration, this still works because it goes
|
||||||
|
// through the AppsFolder verb the OS itself uses for Start menu launches.
|
||||||
|
if (TryStart("explorer.exe", false, out var err2,
|
||||||
|
arguments: "shell:appsFolder\\MSTeams_8wekyb3d8bbwe!MSTeams"))
|
||||||
|
return true;
|
||||||
|
attempts.Add($"AppsFolder shell → {err2}");
|
||||||
|
|
||||||
|
// Path 3: classic Teams Update.exe with --processStart hands off to
|
||||||
|
// the actual Teams.exe via Squirrel.
|
||||||
|
var classicUpdater = Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||||
|
"Microsoft", "Teams", "Update.exe");
|
||||||
|
if (File.Exists(classicUpdater))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Process.Start(new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = classicUpdater,
|
||||||
|
Arguments = "--processStart \"Teams.exe\"",
|
||||||
|
UseShellExecute = false,
|
||||||
|
CreateNoWindow = true,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
attempts.Add($"classic Update.exe → {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
attempts.Add($"classic Update.exe → not found at {classicUpdater}");
|
||||||
|
}
|
||||||
|
|
||||||
|
errorMessage = "No Microsoft Teams installation could be launched. " +
|
||||||
|
"Install Teams from https://www.microsoft.com/microsoft-teams and try again.\n\n" +
|
||||||
|
"Attempts:\n • " + string.Join("\n • ", attempts);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Asks every running Teams process to close gracefully via WM_CLOSE
|
||||||
|
/// (CloseMainWindow). Returns the count of processes that exited cleanly within
|
||||||
|
/// <paramref name="gracePeriod"/>. Stragglers are NOT force-killed — Teams' own
|
||||||
|
/// "are you sure" prompt may legitimately keep a process alive briefly, and we
|
||||||
|
/// don't want to nuke the user's call mid-transition.
|
||||||
|
/// </summary>
|
||||||
|
public static int StopAll(TimeSpan? gracePeriod = null)
|
||||||
|
{
|
||||||
|
var grace = gracePeriod ?? TimeSpan.FromSeconds(3);
|
||||||
|
var deadline = DateTime.UtcNow + grace;
|
||||||
|
var asked = 0;
|
||||||
|
foreach (var name in TeamsProcessNames)
|
||||||
|
{
|
||||||
|
foreach (var p in Process.GetProcessesByName(name))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (p.HasExited) { p.Dispose(); continue; }
|
||||||
|
if (p.MainWindowHandle != IntPtr.Zero)
|
||||||
|
{
|
||||||
|
p.CloseMainWindow();
|
||||||
|
asked++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { /* defensive: process may have died between enumeration and signal */ }
|
||||||
|
finally { p.Dispose(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Best-effort wait so the rail can flip its icon promptly.
|
||||||
|
while (DateTime.UtcNow < deadline && IsRunning())
|
||||||
|
Thread.Sleep(150);
|
||||||
|
return asked;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Hand a meeting URL off to the Teams shell handler. Accepts both the
|
||||||
|
/// <c>https://teams.microsoft.com/l/meetup-join/...</c> web format and
|
||||||
|
/// the <c>msteams:/l/meetup-join/...</c> deep-link form (either causes
|
||||||
|
/// Teams to launch + join the meeting in one shot — the OS shell maps
|
||||||
|
/// teams.microsoft.com URLs to the registered ms-teams: handler).
|
||||||
|
///
|
||||||
|
/// Use case: operator pastes a meeting link they got over email / chat
|
||||||
|
/// into TeamsISO's quick-join field instead of opening Teams,
|
||||||
|
/// hunting down the calendar entry, and clicking Join. With auto-hide
|
||||||
|
/// on, the Teams window flashes briefly then disappears; the operator
|
||||||
|
/// is now in the meeting, driving routing from TeamsISO.
|
||||||
|
///
|
||||||
|
/// Returns true if the shell accepted the URL; false if URL is malformed
|
||||||
|
/// or rejected. errorMessage populated on failure.
|
||||||
|
/// </summary>
|
||||||
|
public static bool TryJoinMeeting(string url, out string? errorMessage)
|
||||||
|
{
|
||||||
|
errorMessage = null;
|
||||||
|
if (string.IsNullOrWhiteSpace(url))
|
||||||
|
{
|
||||||
|
errorMessage = "URL is empty.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var trimmed = url.Trim();
|
||||||
|
|
||||||
|
// Defensive sanity-check: only accept URLs that obviously target
|
||||||
|
// Teams. We don't want to invoke arbitrary shell handlers from a
|
||||||
|
// clipboard paste — if someone pastes "calc.exe" into the input we
|
||||||
|
// shouldn't launch it. Specifically: http(s) URLs must contain
|
||||||
|
// "teams.microsoft.com" or "teams.live.com"; otherwise must start
|
||||||
|
// with "msteams:".
|
||||||
|
var lower = trimmed.ToLowerInvariant();
|
||||||
|
var looksLikeTeams =
|
||||||
|
lower.StartsWith("msteams:") ||
|
||||||
|
(lower.StartsWith("http://") || lower.StartsWith("https://")) &&
|
||||||
|
(lower.Contains("teams.microsoft.com") || lower.Contains("teams.live.com"));
|
||||||
|
if (!looksLikeTeams)
|
||||||
|
{
|
||||||
|
errorMessage = "Not a Microsoft Teams meeting URL. " +
|
||||||
|
"Expected a https://teams.microsoft.com/l/meetup-join/... " +
|
||||||
|
"or msteams:/l/meetup-join/... link.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (TryStart(trimmed, useShell: true, out var err))
|
||||||
|
return true;
|
||||||
|
errorMessage = err;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryStart(string target, bool useShell, out string error, string? arguments = null)
|
||||||
|
{
|
||||||
|
error = string.Empty;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var info = new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = target,
|
||||||
|
UseShellExecute = useShell,
|
||||||
|
CreateNoWindow = true,
|
||||||
|
};
|
||||||
|
if (arguments is not null) info.Arguments = arguments;
|
||||||
|
Process.Start(info);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
error = ex.Message;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════════════════
|
||||||
|
// Phase E.2 — window orchestration
|
||||||
|
//
|
||||||
|
// Once Teams is running, we want to be able to hide its main window so the
|
||||||
|
// operator only sees TeamsISO. We do this by enumerating top-level windows,
|
||||||
|
// matching against each Teams process Id, and ShowWindow(SW_HIDE)-ing each
|
||||||
|
// match. To restore we ShowWindow(SW_SHOW) and SetForegroundWindow.
|
||||||
|
//
|
||||||
|
// We deliberately don't use the Process.MainWindowHandle convenience because
|
||||||
|
// new MSTeams (WebView2-hosted) creates several top-level windows per
|
||||||
|
// process and Process picks an inconsistent one across launches; iterating
|
||||||
|
// via EnumWindows + GetWindowThreadProcessId catches every visible window
|
||||||
|
// owned by the process.
|
||||||
|
// ════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
private const int SW_HIDE = 0;
|
||||||
|
private const int SW_SHOWNORMAL = 1;
|
||||||
|
private const int SW_SHOW = 5;
|
||||||
|
private const int SW_RESTORE = 9;
|
||||||
|
|
||||||
|
private const uint GW_OWNER = 4;
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
private static extern bool EnumWindows(EnumWindowsProc lpEnumFunc, IntPtr lParam);
|
||||||
|
|
||||||
|
[DllImport("user32.dll", SetLastError = true)]
|
||||||
|
private static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId);
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
[return: MarshalAs(UnmanagedType.Bool)]
|
||||||
|
private static extern bool IsWindowVisible(IntPtr hWnd);
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
private static extern IntPtr GetWindow(IntPtr hWnd, uint uCmd);
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
[return: MarshalAs(UnmanagedType.Bool)]
|
||||||
|
private static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
[return: MarshalAs(UnmanagedType.Bool)]
|
||||||
|
private static extern bool SetForegroundWindow(IntPtr hWnd);
|
||||||
|
|
||||||
|
[DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
||||||
|
private static extern int GetWindowTextW(IntPtr hWnd, [Out] System.Text.StringBuilder lpString, int nMaxCount);
|
||||||
|
|
||||||
|
[DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
||||||
|
private static extern int GetWindowTextLengthW(IntPtr hWnd);
|
||||||
|
|
||||||
|
private delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam);
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the title bar text of Teams' most-recently-used top-level
|
||||||
|
/// window, or empty string if Teams isn't running. Modern Teams puts
|
||||||
|
/// the meeting title in the window title while in a call ("Meeting with
|
||||||
|
/// Alice | Microsoft Teams"), so this is the cheapest way to surface
|
||||||
|
/// meeting context to TeamsISO's UI without burning a UIA traversal.
|
||||||
|
///
|
||||||
|
/// Includes hidden windows — operators using auto-hide still get the
|
||||||
|
/// title surfaced, which is the whole point.
|
||||||
|
/// </summary>
|
||||||
|
public static string GetActiveWindowTitle()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var teamsPids = new HashSet<uint>(
|
||||||
|
TeamsProcessNames
|
||||||
|
.SelectMany(n => Process.GetProcessesByName(n))
|
||||||
|
.Select(p => { try { return (uint)p.Id; } finally { p.Dispose(); } }));
|
||||||
|
if (teamsPids.Count == 0) return string.Empty;
|
||||||
|
|
||||||
|
string longestTitle = string.Empty;
|
||||||
|
EnumWindows((hWnd, _) =>
|
||||||
|
{
|
||||||
|
if (GetWindow(hWnd, GW_OWNER) != IntPtr.Zero) return true;
|
||||||
|
GetWindowThreadProcessId(hWnd, out var pid);
|
||||||
|
if (!teamsPids.Contains(pid)) return true;
|
||||||
|
|
||||||
|
var len = GetWindowTextLengthW(hWnd);
|
||||||
|
if (len <= 0) return true;
|
||||||
|
var sb = new System.Text.StringBuilder(len + 1);
|
||||||
|
GetWindowTextW(hWnd, sb, sb.Capacity);
|
||||||
|
var title = sb.ToString();
|
||||||
|
// Teams creates a few top-level windows per process; the
|
||||||
|
// call/meeting window has the longest title (other windows
|
||||||
|
// tend to just be "Microsoft Teams"). Pick the longest one
|
||||||
|
// as a heuristic for "most informative".
|
||||||
|
if (title.Length > longestTitle.Length) longestTitle = title;
|
||||||
|
return true;
|
||||||
|
}, IntPtr.Zero);
|
||||||
|
return longestTitle;
|
||||||
|
}
|
||||||
|
catch { return string.Empty; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Enumerate every visible top-level window owned by any running Teams
|
||||||
|
/// process. "Top-level" means GetWindow(GW_OWNER) is null (the window is
|
||||||
|
/// not a tooltip or popup of another). Used by Hide/Show.
|
||||||
|
/// </summary>
|
||||||
|
/// <summary>
|
||||||
|
/// Return the visible top-level windows owned by any Teams process.
|
||||||
|
/// Exposed internal so <see cref="TeamsEmbedHost"/> can pick the
|
||||||
|
/// "best" candidate to reparent without re-implementing the
|
||||||
|
/// enumeration. Keep this in TeamsLauncher because the launch /
|
||||||
|
/// hide / show paths use the same list.
|
||||||
|
/// </summary>
|
||||||
|
internal static List<IntPtr> EnumerateTopLevelTeamsWindows()
|
||||||
|
{
|
||||||
|
var teamsPids = new HashSet<uint>(
|
||||||
|
TeamsProcessNames
|
||||||
|
.SelectMany(n => Process.GetProcessesByName(n))
|
||||||
|
.Select(p => { try { return (uint)p.Id; } finally { p.Dispose(); } }));
|
||||||
|
if (teamsPids.Count == 0) return new List<IntPtr>();
|
||||||
|
|
||||||
|
var windows = new List<IntPtr>();
|
||||||
|
EnumWindows((hWnd, _) =>
|
||||||
|
{
|
||||||
|
if (!IsWindowVisible(hWnd)) return true;
|
||||||
|
if (GetWindow(hWnd, GW_OWNER) != IntPtr.Zero) return true; // not top-level
|
||||||
|
GetWindowThreadProcessId(hWnd, out var pid);
|
||||||
|
if (teamsPids.Contains(pid)) windows.Add(hWnd);
|
||||||
|
return true;
|
||||||
|
}, IntPtr.Zero);
|
||||||
|
return windows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Hides every visible top-level Teams window. Returns the count hidden;
|
||||||
|
/// 0 means Teams isn't running or has no visible windows yet (it can take
|
||||||
|
/// a couple seconds after launch for the splash to materialize).
|
||||||
|
/// </summary>
|
||||||
|
public static int HideWindows()
|
||||||
|
{
|
||||||
|
var windows = EnumerateTopLevelTeamsWindows();
|
||||||
|
foreach (var w in windows) ShowWindow(w, SW_HIDE);
|
||||||
|
return windows.Count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fire-and-forget background watcher that polls every 250ms for up to
|
||||||
|
/// <paramref name="timeout"/> and hides any visible top-level Teams
|
||||||
|
/// windows it finds. Used after launch so the operator never sees the
|
||||||
|
/// Teams UI flash on screen — Teams takes 2-5s to splash + render its
|
||||||
|
/// main window, and the splash arrives separately from the main window
|
||||||
|
/// (so we keep polling past the first hide to catch follow-up windows).
|
||||||
|
///
|
||||||
|
/// Returns the Task so callers can await completion if they want, but
|
||||||
|
/// production code should fire-and-forget. Exceptions are swallowed —
|
||||||
|
/// failure to hide is harmless (user just sees Teams briefly).
|
||||||
|
/// </summary>
|
||||||
|
public static Task AutoHideAfterLaunchAsync(TimeSpan? timeout = null, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var deadline = DateTime.UtcNow + (timeout ?? TimeSpan.FromSeconds(15));
|
||||||
|
return Task.Run(async () =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var hiddenAny = false;
|
||||||
|
while (!ct.IsCancellationRequested && DateTime.UtcNow < deadline)
|
||||||
|
{
|
||||||
|
// Poll for visible windows. Each iteration may catch new
|
||||||
|
// ones — Teams sometimes opens a small splash, then a
|
||||||
|
// larger main window 1-2s later, then a "What's new"
|
||||||
|
// banner. Keep hiding until we've gone a full second
|
||||||
|
// with nothing new appearing.
|
||||||
|
var hidden = HideWindows();
|
||||||
|
if (hidden > 0)
|
||||||
|
{
|
||||||
|
hiddenAny = true;
|
||||||
|
// Settling delay: after we hide windows, wait a beat
|
||||||
|
// before polling again so we don't busy-loop while
|
||||||
|
// Teams' window manager catches up.
|
||||||
|
await Task.Delay(750, ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
else if (hiddenAny)
|
||||||
|
{
|
||||||
|
// We hid at least once; if the next poll finds
|
||||||
|
// nothing, Teams has settled. Bail early.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Teams hasn't materialized yet; keep waiting.
|
||||||
|
await Task.Delay(250, ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { /* expected on cancel */ }
|
||||||
|
catch { /* defensive — auto-hide is best-effort, never breaks the app */ }
|
||||||
|
}, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────
|
||||||
|
// Keyboard-shortcut forwarding (PostMessage path).
|
||||||
|
//
|
||||||
|
// UIAutomation (TeamsControlBridge) is our preferred way to drive Teams
|
||||||
|
// because it works regardless of foreground/visibility state. PostMessage
|
||||||
|
// is a fallback for shortcuts that don't have a stable UIA-discoverable
|
||||||
|
// button — chat scroll, custom keymap actions, etc. Note: WebView2-hosted
|
||||||
|
// Teams (the modern client) frequently ignores PostMessage(WM_KEYDOWN) at
|
||||||
|
// its app-shortcut layer because shortcut routing happens after focus
|
||||||
|
// changes, not on raw key messages. Treat this as best-effort.
|
||||||
|
// ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private const uint WM_KEYDOWN = 0x0100;
|
||||||
|
private const uint WM_KEYUP = 0x0101;
|
||||||
|
private const uint WM_CHAR = 0x0102;
|
||||||
|
private const uint WM_SYSKEYDOWN = 0x0104;
|
||||||
|
private const uint WM_SYSKEYUP = 0x0105;
|
||||||
|
|
||||||
|
[Flags]
|
||||||
|
public enum ShortcutModifiers
|
||||||
|
{
|
||||||
|
None = 0,
|
||||||
|
Ctrl = 1,
|
||||||
|
Shift = 2,
|
||||||
|
Alt = 4,
|
||||||
|
}
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
[return: MarshalAs(UnmanagedType.Bool)]
|
||||||
|
private static extern bool PostMessage(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sends a synthesized key press (modifier-down, key-down, key-up,
|
||||||
|
/// modifier-up) to the most recently used top-level Teams window via
|
||||||
|
/// PostMessage. Returns true if a window was found to send to. Note that
|
||||||
|
/// returning true doesn't guarantee Teams reacted — modern WebView2-based
|
||||||
|
/// Teams sometimes ignores synthesized key messages at the app-shortcut
|
||||||
|
/// layer. Prefer UIA (<see cref="TeamsControlBridge"/>) when an equivalent
|
||||||
|
/// button exists.
|
||||||
|
/// </summary>
|
||||||
|
public static bool SendShortcut(ShortcutModifiers modifiers, int virtualKey)
|
||||||
|
{
|
||||||
|
var windows = EnumerateTopLevelTeamsWindows();
|
||||||
|
if (windows.Count == 0) return false;
|
||||||
|
var hwnd = windows[^1];
|
||||||
|
|
||||||
|
// Modifier key downs
|
||||||
|
if ((modifiers & ShortcutModifiers.Ctrl) != 0)
|
||||||
|
PostMessage(hwnd, WM_KEYDOWN, (IntPtr)0x11, IntPtr.Zero); // VK_CONTROL
|
||||||
|
if ((modifiers & ShortcutModifiers.Shift) != 0)
|
||||||
|
PostMessage(hwnd, WM_KEYDOWN, (IntPtr)0x10, IntPtr.Zero); // VK_SHIFT
|
||||||
|
if ((modifiers & ShortcutModifiers.Alt) != 0)
|
||||||
|
PostMessage(hwnd, WM_SYSKEYDOWN, (IntPtr)0x12, IntPtr.Zero); // VK_MENU
|
||||||
|
|
||||||
|
// Main key down + up
|
||||||
|
PostMessage(hwnd, WM_KEYDOWN, (IntPtr)virtualKey, IntPtr.Zero);
|
||||||
|
PostMessage(hwnd, WM_KEYUP, (IntPtr)virtualKey, IntPtr.Zero);
|
||||||
|
|
||||||
|
// Modifier key ups (reverse order)
|
||||||
|
if ((modifiers & ShortcutModifiers.Alt) != 0)
|
||||||
|
PostMessage(hwnd, WM_SYSKEYUP, (IntPtr)0x12, IntPtr.Zero);
|
||||||
|
if ((modifiers & ShortcutModifiers.Shift) != 0)
|
||||||
|
PostMessage(hwnd, WM_KEYUP, (IntPtr)0x10, IntPtr.Zero);
|
||||||
|
if ((modifiers & ShortcutModifiers.Ctrl) != 0)
|
||||||
|
PostMessage(hwnd, WM_KEYUP, (IntPtr)0x11, IntPtr.Zero);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Restores every Teams top-level window from hidden state and brings the
|
||||||
|
/// most recently used one to the foreground. Returns the count shown.
|
||||||
|
/// </summary>
|
||||||
|
public static int ShowWindows()
|
||||||
|
{
|
||||||
|
// To find hidden windows too we still enumerate, but our IsWindowVisible
|
||||||
|
// filter would skip them. Re-implement here with the visible check off.
|
||||||
|
var teamsPids = new HashSet<uint>(
|
||||||
|
TeamsProcessNames
|
||||||
|
.SelectMany(n => Process.GetProcessesByName(n))
|
||||||
|
.Select(p => { try { return (uint)p.Id; } finally { p.Dispose(); } }));
|
||||||
|
var windows = new List<IntPtr>();
|
||||||
|
EnumWindows((hWnd, _) =>
|
||||||
|
{
|
||||||
|
if (GetWindow(hWnd, GW_OWNER) != IntPtr.Zero) return true;
|
||||||
|
GetWindowThreadProcessId(hWnd, out var pid);
|
||||||
|
if (teamsPids.Contains(pid)) windows.Add(hWnd);
|
||||||
|
return true;
|
||||||
|
}, IntPtr.Zero);
|
||||||
|
|
||||||
|
foreach (var w in windows) ShowWindow(w, SW_SHOW);
|
||||||
|
if (windows.Count > 0) SetForegroundWindow(windows[^1]);
|
||||||
|
return windows.Count;
|
||||||
|
}
|
||||||
|
}
|
||||||
245
src/TeamsISO.App/Services/ThemeManager.cs
Normal file
245
src/TeamsISO.App/Services/ThemeManager.cs
Normal file
|
|
@ -0,0 +1,245 @@
|
||||||
|
using System;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Windows;
|
||||||
|
using Microsoft.Win32;
|
||||||
|
|
||||||
|
namespace TeamsISO.App.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Owns the active theme for the WPF host. Three preferences:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><c>System</c> — follows the Windows app-mode setting (default for new
|
||||||
|
/// users; re-reads on <see cref="SystemEvents.UserPreferenceChanged"/>).</item>
|
||||||
|
/// <item><c>Dark</c> — pin dark regardless of OS.</item>
|
||||||
|
/// <item><c>Light</c> — pin light regardless of OS.</item>
|
||||||
|
/// </list>
|
||||||
|
/// The two color files (<c>Theme.Dark.xaml</c> + <c>Theme.Light.xaml</c>) are
|
||||||
|
/// kept in lockstep on the same set of brush keys; this manager swaps the
|
||||||
|
/// MergedDictionaries entry at runtime. Styles + control templates in
|
||||||
|
/// <c>WildDragonTheme.xaml</c> reach the brushes via <see langword="DynamicResource"/>,
|
||||||
|
/// so the visual tree re-resolves without an app restart.
|
||||||
|
///
|
||||||
|
/// Preference is persisted via <see cref="UIPreferences"/>'s <c>Theme</c> field,
|
||||||
|
/// reserved on disk during the v1 → v2 rollout so the rebuild doesn't lose the
|
||||||
|
/// operator's choice.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ThemeManager
|
||||||
|
{
|
||||||
|
public static ThemeManager Current { get; } = new(
|
||||||
|
isSystemDark: ReadSystemDarkFromRegistry,
|
||||||
|
loadPreference: TryLoadPreferenceFromDisk,
|
||||||
|
savePreference: TrySavePreferenceToDisk,
|
||||||
|
subscribeToSystemPreference: true);
|
||||||
|
|
||||||
|
// Pack URIs (rather than relative "/Themes/…") so the resolution
|
||||||
|
// works equally well from production (where Application.Current's
|
||||||
|
// base URI is the TeamsISO entry assembly) and from xUnit tests
|
||||||
|
// (where it's the test assembly — relative URIs would miss).
|
||||||
|
private const string DarkUri = "pack://application:,,,/TeamsISO;component/Themes/Theme.Dark.xaml";
|
||||||
|
private const string LightUri = "pack://application:,,,/TeamsISO;component/Themes/Theme.Light.xaml";
|
||||||
|
private const string PreferenceKeySystem = "System";
|
||||||
|
private const string PreferenceKeyDark = "Dark";
|
||||||
|
private const string PreferenceKeyLight = "Light";
|
||||||
|
|
||||||
|
// Test seams. The production singleton wires these to the real
|
||||||
|
// registry / UIPreferences. Tests construct via the internal ctor
|
||||||
|
// with their own stubs so they don't touch HKCU or %LOCALAPPDATA%.
|
||||||
|
private readonly Func<bool> _isSystemDark;
|
||||||
|
private readonly Action<string> _savePreference;
|
||||||
|
|
||||||
|
internal ThemeManager(
|
||||||
|
Func<bool> isSystemDark,
|
||||||
|
Func<string?> loadPreference,
|
||||||
|
Action<string> savePreference,
|
||||||
|
bool subscribeToSystemPreference)
|
||||||
|
{
|
||||||
|
_isSystemDark = isSystemDark;
|
||||||
|
_savePreference = savePreference;
|
||||||
|
|
||||||
|
// Hydrate preference from the seam on first access. Disk / load
|
||||||
|
// failures fall back to defaults so the app always boots into a
|
||||||
|
// deterministic theme.
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var loaded = loadPreference();
|
||||||
|
if (IsValidPreference(loaded))
|
||||||
|
{
|
||||||
|
_preference = loaded!;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Defensive — ctor must not throw or the app loses theming.
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-evaluate when Windows app-mode flips, but only when the
|
||||||
|
// operator hasn't pinned a preference. The explicit choice wins.
|
||||||
|
// Tests opt out so they don't latch into a process-wide event.
|
||||||
|
if (subscribeToSystemPreference)
|
||||||
|
{
|
||||||
|
SystemEvents.UserPreferenceChanged += OnSystemPreferenceChanged;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private string _preference = PreferenceKeySystem;
|
||||||
|
|
||||||
|
/// <summary>Current preference. One of "System", "Dark", "Light".</summary>
|
||||||
|
public string Preference => _preference;
|
||||||
|
|
||||||
|
/// <summary>Fires after a theme swap with the resolved (absolute) theme.</summary>
|
||||||
|
public event EventHandler<string>? Themed;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolve the preference to an absolute theme name ("Dark" or "Light")
|
||||||
|
/// suitable for the dictionary lookup. "System" resolves to the OS
|
||||||
|
/// app-mode at the time of the call.
|
||||||
|
/// </summary>
|
||||||
|
public string ResolveTheme() => _preference switch
|
||||||
|
{
|
||||||
|
PreferenceKeyDark => PreferenceKeyDark,
|
||||||
|
PreferenceKeyLight => PreferenceKeyLight,
|
||||||
|
_ => _isSystemDark() ? PreferenceKeyDark : PreferenceKeyLight,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Set the operator's preference, persist, and apply the resolved theme.
|
||||||
|
/// </summary>
|
||||||
|
public void Set(string preference)
|
||||||
|
{
|
||||||
|
if (!IsValidPreference(preference))
|
||||||
|
{
|
||||||
|
throw new ArgumentException(
|
||||||
|
"Preference must be 'System', 'Dark', or 'Light'.",
|
||||||
|
nameof(preference));
|
||||||
|
}
|
||||||
|
|
||||||
|
_preference = preference;
|
||||||
|
try { _savePreference(preference); }
|
||||||
|
catch { /* persistence is best-effort */ }
|
||||||
|
Apply();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Cycle the theme between Dark and Light (one-click toggle from the header
|
||||||
|
/// theme icon). If the current preference is "System", the cycle pins to
|
||||||
|
/// the OPPOSITE of the currently-resolved theme so the click has a
|
||||||
|
/// visible effect.
|
||||||
|
/// </summary>
|
||||||
|
public void Toggle()
|
||||||
|
{
|
||||||
|
var current = ResolveTheme();
|
||||||
|
Set(current == PreferenceKeyDark ? PreferenceKeyLight : PreferenceKeyDark);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Apply the current resolved theme. Should be called once during app
|
||||||
|
/// startup (after Application.Current.Resources is initialized) and
|
||||||
|
/// whenever <see cref="Preference"/> changes — <see cref="Set"/> already
|
||||||
|
/// does the latter for you.
|
||||||
|
/// </summary>
|
||||||
|
public void Apply()
|
||||||
|
{
|
||||||
|
var theme = ResolveTheme();
|
||||||
|
var uri = theme == PreferenceKeyDark ? DarkUri : LightUri;
|
||||||
|
SwapColorDictionary(uri);
|
||||||
|
Themed?.Invoke(this, theme);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void SwapColorDictionary(string newUri)
|
||||||
|
{
|
||||||
|
var app = Application.Current;
|
||||||
|
if (app is null) return;
|
||||||
|
var dicts = app.Resources.MergedDictionaries;
|
||||||
|
|
||||||
|
// Find the existing theme color dictionary by source URI. We
|
||||||
|
// distinguish "color" dictionaries from "WildDragonTheme" by name —
|
||||||
|
// the color files are at Theme.Dark.xaml / Theme.Light.xaml; the
|
||||||
|
// styles file is at WildDragonTheme.xaml. Replace in place to
|
||||||
|
// preserve merge order so DynamicResource refs resolve to the new
|
||||||
|
// brushes.
|
||||||
|
ResourceDictionary? old = null;
|
||||||
|
for (var i = 0; i < dicts.Count; i++)
|
||||||
|
{
|
||||||
|
var src = dicts[i].Source?.OriginalString ?? string.Empty;
|
||||||
|
if (src.EndsWith("Theme.Dark.xaml", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
src.EndsWith("Theme.Light.xaml", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
old = dicts[i];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var fresh = new ResourceDictionary { Source = new Uri(newUri, UriKind.Absolute) };
|
||||||
|
if (old is null)
|
||||||
|
{
|
||||||
|
dicts.Insert(0, fresh);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var idx = dicts.IndexOf(old);
|
||||||
|
dicts.RemoveAt(idx);
|
||||||
|
dicts.Insert(idx, fresh);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Read Windows' AppsUseLightTheme registry value. 1 = light, 0 = dark.
|
||||||
|
/// Returns true (dark) on any read failure — the dark scene is the
|
||||||
|
/// default per DESIGN.md so a missing value still lands somewhere
|
||||||
|
/// sensible. Backs the singleton's _isSystemDark seam.
|
||||||
|
/// </summary>
|
||||||
|
private static bool ReadSystemDarkFromRegistry()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var key = Registry.CurrentUser.OpenSubKey(
|
||||||
|
@"SOFTWARE\Microsoft\Windows\CurrentVersion\Themes\Personalize");
|
||||||
|
if (key?.GetValue("AppsUseLightTheme") is int value)
|
||||||
|
{
|
||||||
|
return value == 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Registry access can fail under unusual security contexts.
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Load the operator's persisted theme preference from
|
||||||
|
/// %LOCALAPPDATA%\TeamsISO\ui-prefs.json. Returns null on any read
|
||||||
|
/// failure (missing file, corrupt JSON, schema mismatch) so the
|
||||||
|
/// caller falls back to the in-memory default of "System". Backs
|
||||||
|
/// the singleton's loadPreference seam.
|
||||||
|
/// </summary>
|
||||||
|
private static string? TryLoadPreferenceFromDisk()
|
||||||
|
{
|
||||||
|
try { return UIPreferences.Load().Theme; }
|
||||||
|
catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Persist the operator's theme preference to ui-prefs.json. Errors
|
||||||
|
/// are swallowed — persistence is best-effort and a single failed
|
||||||
|
/// save shouldn't break the in-session UI experience. Backs the
|
||||||
|
/// singleton's savePreference seam.
|
||||||
|
/// </summary>
|
||||||
|
private static void TrySavePreferenceToDisk(string preference)
|
||||||
|
{
|
||||||
|
try { UIPreferences.SetTheme(preference); }
|
||||||
|
catch { /* best-effort */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnSystemPreferenceChanged(object? sender, UserPreferenceChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.Category != UserPreferenceCategory.General) return;
|
||||||
|
if (_preference != PreferenceKeySystem) return;
|
||||||
|
// Marshal to the UI thread — registry events fire on a system pool
|
||||||
|
// thread and resource dictionary mutations require dispatcher access.
|
||||||
|
Application.Current?.Dispatcher.BeginInvoke(new Action(Apply));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsValidPreference(string? value) =>
|
||||||
|
value is PreferenceKeySystem or PreferenceKeyDark or PreferenceKeyLight;
|
||||||
|
}
|
||||||
140
src/TeamsISO.App/Services/TrayIconHost.cs
Normal file
140
src/TeamsISO.App/Services/TrayIconHost.cs
Normal file
|
|
@ -0,0 +1,140 @@
|
||||||
|
using System.Drawing;
|
||||||
|
using System.IO;
|
||||||
|
using System.Reflection;
|
||||||
|
using System.Runtime.Versioning;
|
||||||
|
using System.Windows;
|
||||||
|
using WinForms = System.Windows.Forms;
|
||||||
|
|
||||||
|
namespace TeamsISO.App.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Wraps a WinForms <see cref="WinForms.NotifyIcon"/> so the WPF host can
|
||||||
|
/// minimize-to-tray during long shows. Operators with a Stream Deck setup
|
||||||
|
/// often want TeamsISO running but invisible — the tray icon keeps the
|
||||||
|
/// process alive (and the engine routing live) while the window stays
|
||||||
|
/// hidden.
|
||||||
|
///
|
||||||
|
/// Lifecycle pattern: instantiate from <c>App.OnStartup</c> after the main
|
||||||
|
/// window exists; dispose from <c>App.OnExit</c>. The host hooks the main
|
||||||
|
/// window's <c>StateChanged</c> to detect minimize and toggles
|
||||||
|
/// <c>WindowState.Minimized</c> + <c>ShowInTaskbar=false</c> + <c>Hide()</c>.
|
||||||
|
/// </summary>
|
||||||
|
[SupportedOSPlatform("windows")]
|
||||||
|
public sealed class TrayIconHost : IDisposable
|
||||||
|
{
|
||||||
|
private readonly Window _mainWindow;
|
||||||
|
private readonly WinForms.NotifyIcon _notifyIcon;
|
||||||
|
private bool _enabled;
|
||||||
|
|
||||||
|
public TrayIconHost(Window mainWindow)
|
||||||
|
{
|
||||||
|
_mainWindow = mainWindow;
|
||||||
|
_notifyIcon = new WinForms.NotifyIcon
|
||||||
|
{
|
||||||
|
Text = "TeamsISO",
|
||||||
|
Icon = LoadEmbeddedIcon(),
|
||||||
|
Visible = false,
|
||||||
|
};
|
||||||
|
_notifyIcon.DoubleClick += (_, _) => RestoreFromTray();
|
||||||
|
_notifyIcon.ContextMenuStrip = BuildMenu();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Toggles the minimize-to-tray behavior. When on, minimizing the window
|
||||||
|
/// hides it and shows a tray icon; when off, minimize is normal Windows
|
||||||
|
/// behavior. Read by the operator's checkbox in DISPLAY settings; the
|
||||||
|
/// setting persists via <see cref="UIPreferences"/>.
|
||||||
|
/// </summary>
|
||||||
|
public bool Enabled
|
||||||
|
{
|
||||||
|
get => _enabled;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (_enabled == value) return;
|
||||||
|
_enabled = value;
|
||||||
|
if (value)
|
||||||
|
{
|
||||||
|
_mainWindow.StateChanged += OnMainWindowStateChanged;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_mainWindow.StateChanged -= OnMainWindowStateChanged;
|
||||||
|
// If we're currently minimized + hidden, restore so the user
|
||||||
|
// doesn't lose the window when they disable the setting.
|
||||||
|
RestoreFromTray();
|
||||||
|
_notifyIcon.Visible = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnMainWindowStateChanged(object? sender, EventArgs e)
|
||||||
|
{
|
||||||
|
if (_mainWindow.WindowState != WindowState.Minimized) return;
|
||||||
|
// Hide from taskbar + hide the window, show the tray icon.
|
||||||
|
_mainWindow.ShowInTaskbar = false;
|
||||||
|
_mainWindow.Hide();
|
||||||
|
_notifyIcon.Visible = true;
|
||||||
|
_notifyIcon.ShowBalloonTip(
|
||||||
|
timeout: 1500,
|
||||||
|
tipTitle: "TeamsISO is still running",
|
||||||
|
tipText: "Engine + control surface are live. Double-click the tray icon to restore the window.",
|
||||||
|
tipIcon: WinForms.ToolTipIcon.Info);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RestoreFromTray()
|
||||||
|
{
|
||||||
|
_mainWindow.Show();
|
||||||
|
_mainWindow.WindowState = WindowState.Normal;
|
||||||
|
_mainWindow.ShowInTaskbar = true;
|
||||||
|
_mainWindow.Activate();
|
||||||
|
_notifyIcon.Visible = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private WinForms.ContextMenuStrip BuildMenu()
|
||||||
|
{
|
||||||
|
var menu = new WinForms.ContextMenuStrip();
|
||||||
|
menu.Items.Add("Show TeamsISO", null, (_, _) => RestoreFromTray());
|
||||||
|
menu.Items.Add("-");
|
||||||
|
menu.Items.Add("Stop all ISOs", null, (_, _) =>
|
||||||
|
{
|
||||||
|
// Reach into the VM via the main window. Using string-keyed
|
||||||
|
// command lookup would be more decoupled but adds overhead.
|
||||||
|
if (_mainWindow.DataContext is ViewModels.MainViewModel vm
|
||||||
|
&& vm.StopAllIsosCommand.CanExecute(null))
|
||||||
|
{
|
||||||
|
vm.StopAllIsosCommand.Execute(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
menu.Items.Add("-");
|
||||||
|
menu.Items.Add("Exit TeamsISO", null, (_, _) => System.Windows.Application.Current.Shutdown());
|
||||||
|
return menu;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Load the bundled teamsiso.ico from this assembly's resources. We use
|
||||||
|
/// the embedded resource rather than the file-system path because the
|
||||||
|
/// app may be run from any CWD (via the MSI install or a developer dotnet run).
|
||||||
|
/// </summary>
|
||||||
|
private static Icon LoadEmbeddedIcon()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var asm = Assembly.GetExecutingAssembly();
|
||||||
|
var uri = new Uri("pack://application:,,,/Assets/teamsiso.ico");
|
||||||
|
using var stream = System.Windows.Application.GetResourceStream(uri)?.Stream;
|
||||||
|
if (stream is not null) return new Icon(stream);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Fall through to the OS default
|
||||||
|
}
|
||||||
|
return SystemIcons.Application;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
try { _mainWindow.StateChanged -= OnMainWindowStateChanged; } catch { /* ignore */ }
|
||||||
|
try { _notifyIcon.Visible = false; } catch { /* ignore */ }
|
||||||
|
try { _notifyIcon.Dispose(); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
106
src/TeamsISO.App/Services/UIPreferences.cs
Normal file
106
src/TeamsISO.App/Services/UIPreferences.cs
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
using System.IO;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace TeamsISO.App.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Persistent UI-side toggles that don't belong in <see cref="TeamsISO.Engine.Persistence.ConfigStore"/>
|
||||||
|
/// (which is the engine's domain model — framerate, NDI groups, ISO assignments).
|
||||||
|
///
|
||||||
|
/// Each toggle is a property on a single record persisted as JSON at
|
||||||
|
/// <c>%LOCALAPPDATA%\TeamsISO\ui-prefs.json</c>. Defaults match the original
|
||||||
|
/// in-memory behavior: HideLocalSelf=true (filter the operator's own preview
|
||||||
|
/// out of the participants list) and AutoDisableOnDeparture=false (a participant
|
||||||
|
/// going offline doesn't tear down their pipeline by default — operators
|
||||||
|
/// usually want to keep the routing in case they reconnect).
|
||||||
|
///
|
||||||
|
/// Centralizing these here means the settings VM doesn't have to plumb
|
||||||
|
/// individual Set methods to dedicated services for every new bool.
|
||||||
|
/// </summary>
|
||||||
|
public static class UIPreferences
|
||||||
|
{
|
||||||
|
private static readonly object _gate = new();
|
||||||
|
|
||||||
|
private static string PrefsPath =>
|
||||||
|
Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||||
|
"TeamsISO", "ui-prefs.json");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sort modes for the participants DataGrid. <see cref="JoinOrder"/> is the default
|
||||||
|
/// and matches the engine's discovery order (operators with custom Stream Deck
|
||||||
|
/// layouts sometimes prefer Alphabetical for stability across meetings).
|
||||||
|
/// <see cref="LoudestFirst"/> resorts at the 1Hz stats tick so the active
|
||||||
|
/// speaker bubbles to the top — useful for operators reacting to who's talking.
|
||||||
|
/// </summary>
|
||||||
|
public enum SortMode { JoinOrder, Alphabetical, OnlineFirst, LoudestFirst }
|
||||||
|
|
||||||
|
/// <summary>The on-disk shape. New fields added here become opt-in for older files via default values.</summary>
|
||||||
|
public sealed record Prefs(
|
||||||
|
bool HideLocalSelf = true,
|
||||||
|
bool AutoDisableOnDeparture = false,
|
||||||
|
SortMode ParticipantSort = SortMode.JoinOrder,
|
||||||
|
bool MinimizeToTray = false,
|
||||||
|
bool ControlSurfaceLanReachable = false,
|
||||||
|
// Phase E.1 / E.2 quality-of-life. With both true, the operator launches
|
||||||
|
// TeamsISO and never sees the Teams UI — Teams auto-starts in the
|
||||||
|
// background and its windows are auto-hidden as soon as they materialize.
|
||||||
|
// All control happens via the IN-CALL bar + participants DataGrid.
|
||||||
|
bool LaunchTeamsOnStartup = false,
|
||||||
|
bool AutoHideTeamsWindows = false,
|
||||||
|
// Experimental Phase E.4. SetParent-reparents Teams' main window
|
||||||
|
// into a TeamsISO-owned host. WebView2 in modern Teams can render
|
||||||
|
// weirdly after reparent; if so the operator unticks and falls
|
||||||
|
// back to auto-hide mode. Off by default.
|
||||||
|
bool EmbedTeamsWindow = false,
|
||||||
|
// Theme preference for the v2 redesign. One of "System" (follow
|
||||||
|
// Windows app-mode), "Dark", or "Light". ThemeManager hydrates
|
||||||
|
// from this on startup and persists back here on toggle. Default
|
||||||
|
// "System" matches DESIGN.md's "Follow Windows" choice — the
|
||||||
|
// operator who doesn't care gets whatever Windows is set to.
|
||||||
|
string Theme = "System",
|
||||||
|
// REST + WebSocket control surface auto-start. When true, the
|
||||||
|
// server starts on app launch instead of waiting for the operator
|
||||||
|
// to click the toggle in settings each session. The desktop GUI's
|
||||||
|
// settings checkbox writes here and re-reads on launch.
|
||||||
|
bool ControlSurfaceEnabled = false);
|
||||||
|
|
||||||
|
/// <summary>Update just the Theme field without touching other prefs.</summary>
|
||||||
|
public static void SetTheme(string theme)
|
||||||
|
{
|
||||||
|
var current = Load();
|
||||||
|
Save(current with { Theme = theme });
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Prefs Load()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!File.Exists(PrefsPath)) return new Prefs();
|
||||||
|
var json = File.ReadAllText(PrefsPath);
|
||||||
|
return JsonSerializer.Deserialize<Prefs>(json) ?? new Prefs();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return new Prefs();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void Save(Prefs prefs)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
var dir = Path.GetDirectoryName(PrefsPath);
|
||||||
|
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
|
||||||
|
var json = JsonSerializer.Serialize(prefs, new JsonSerializerOptions { WriteIndented = true });
|
||||||
|
File.WriteAllText(PrefsPath, json);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Disk full / permission denied — in-memory state still holds for this session.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
243
src/TeamsISO.App/Services/UpdateChecker.cs
Normal file
243
src/TeamsISO.App/Services/UpdateChecker.cs
Normal file
|
|
@ -0,0 +1,243 @@
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.IO;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Reflection;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace TeamsISO.App.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Asks Forgejo's REST API whether a newer release tag exists than the one
|
||||||
|
/// we're running. Manual-only for v1 — there's no background polling. The
|
||||||
|
/// operator can click "Check for updates" in the About dialog whenever they
|
||||||
|
/// want, and a positive result opens the release page in their browser
|
||||||
|
/// (rather than auto-downloading; we don't want a long-running show
|
||||||
|
/// interrupted by a surprise installer).
|
||||||
|
///
|
||||||
|
/// We use the public release endpoint so no auth is needed:
|
||||||
|
/// GET /api/v1/repos/zgaetano/teamsiso/releases?limit=1
|
||||||
|
///
|
||||||
|
/// On any error (offline, DNS failure, repo private, malformed response),
|
||||||
|
/// the caller gets <see cref="UpdateCheckResult.Failed"/> with a short
|
||||||
|
/// human-readable message rather than an exception.
|
||||||
|
/// </summary>
|
||||||
|
public static class UpdateChecker
|
||||||
|
{
|
||||||
|
private const string ReleasesApi =
|
||||||
|
"https://forge.wilddragon.net/api/v1/repos/zgaetano/teamsiso/releases?limit=1";
|
||||||
|
|
||||||
|
private const string ReleasesPage =
|
||||||
|
"https://forge.wilddragon.net/zgaetano/teamsiso/releases";
|
||||||
|
|
||||||
|
/// <summary>Outcome of a single check.</summary>
|
||||||
|
public sealed record UpdateCheckResult(
|
||||||
|
UpdateStatus Status,
|
||||||
|
string? LatestTag,
|
||||||
|
string? CurrentVersion,
|
||||||
|
string? Message)
|
||||||
|
{
|
||||||
|
public static UpdateCheckResult Failed(string message) =>
|
||||||
|
new(UpdateStatus.Error, null, null, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum UpdateStatus
|
||||||
|
{
|
||||||
|
UpToDate,
|
||||||
|
UpdateAvailable,
|
||||||
|
Error,
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<UpdateCheckResult> CheckAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var current = GetCurrentVersion();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(8) };
|
||||||
|
client.DefaultRequestHeaders.Add("User-Agent", "TeamsISO/" + current);
|
||||||
|
|
||||||
|
using var res = await client.GetAsync(ReleasesApi, ct);
|
||||||
|
if (!res.IsSuccessStatusCode)
|
||||||
|
return UpdateCheckResult.Failed($"Server returned {(int)res.StatusCode}.");
|
||||||
|
|
||||||
|
var json = await res.Content.ReadAsStringAsync(ct);
|
||||||
|
using var doc = JsonDocument.Parse(json);
|
||||||
|
if (doc.RootElement.ValueKind != JsonValueKind.Array || doc.RootElement.GetArrayLength() == 0)
|
||||||
|
return new UpdateCheckResult(UpdateStatus.UpToDate, null, current,
|
||||||
|
"No releases published yet.");
|
||||||
|
|
||||||
|
var first = doc.RootElement[0];
|
||||||
|
if (!first.TryGetProperty("tag_name", out var tagProp))
|
||||||
|
return UpdateCheckResult.Failed("Release record missing tag_name.");
|
||||||
|
|
||||||
|
var latestTag = tagProp.GetString();
|
||||||
|
if (string.IsNullOrWhiteSpace(latestTag))
|
||||||
|
return UpdateCheckResult.Failed("Latest tag was empty.");
|
||||||
|
|
||||||
|
var latestVersion = TryParseSemVer(latestTag);
|
||||||
|
var currentVersion = TryParseSemVer("v" + current);
|
||||||
|
|
||||||
|
if (latestVersion is null || currentVersion is null)
|
||||||
|
return new UpdateCheckResult(UpdateStatus.UpToDate, latestTag, current,
|
||||||
|
"Couldn't compare versions; latest tag is " + latestTag);
|
||||||
|
|
||||||
|
return latestVersion > currentVersion
|
||||||
|
? new UpdateCheckResult(UpdateStatus.UpdateAvailable, latestTag, current,
|
||||||
|
$"A newer version ({latestTag}) is available.")
|
||||||
|
: new UpdateCheckResult(UpdateStatus.UpToDate, latestTag, current,
|
||||||
|
"You're on the latest release.");
|
||||||
|
}
|
||||||
|
catch (TaskCanceledException)
|
||||||
|
{
|
||||||
|
return UpdateCheckResult.Failed("Update check timed out.");
|
||||||
|
}
|
||||||
|
catch (HttpRequestException ex)
|
||||||
|
{
|
||||||
|
return UpdateCheckResult.Failed("Couldn't reach the update server: " + ex.Message);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return UpdateCheckResult.Failed("Unexpected error: " + ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Open the releases page in the user's default browser. Used by the
|
||||||
|
/// "Update available" dialog button — we deliberately don't download/run
|
||||||
|
/// the MSI ourselves, so the operator decides when to install.
|
||||||
|
/// </summary>
|
||||||
|
public static void OpenReleasesPage()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Process.Start(new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = ReleasesPage,
|
||||||
|
UseShellExecute = true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// best-effort; the dialog already shows the URL as text fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Silent throttled launch-time check. Returns the result if a check actually
|
||||||
|
/// happened, or null if the cooldown window suppressed it. The cooldown lives
|
||||||
|
/// in <c>%LOCALAPPDATA%\TeamsISO\last-update-check.txt</c> as an ISO 8601
|
||||||
|
/// timestamp; a missing file means "never checked, do it now."
|
||||||
|
/// </summary>
|
||||||
|
public static async Task<UpdateCheckResult?> CheckIfDueAsync(TimeSpan cooldown, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var path = CooldownPath;
|
||||||
|
if (File.Exists(path))
|
||||||
|
{
|
||||||
|
var raw = File.ReadAllText(path).Trim();
|
||||||
|
if (DateTimeOffset.TryParse(raw, out var last) &&
|
||||||
|
DateTimeOffset.UtcNow - last < cooldown)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Throttle check is best-effort; on read failures we just check now.
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await CheckAsync(ct);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var dir = Path.GetDirectoryName(CooldownPath);
|
||||||
|
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
|
||||||
|
File.WriteAllText(CooldownPath, DateTimeOffset.UtcNow.ToString("o"));
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// If we can't write the cooldown stamp, future launches will check
|
||||||
|
// again immediately. Annoying but not broken.
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Test-only seam — when set, overrides the default
|
||||||
|
/// %LOCALAPPDATA%\TeamsISO path that holds the cooldown stamp +
|
||||||
|
/// the opt-out flag. Tests use this to write to a tempdir so
|
||||||
|
/// CheckIfDueAsync's throttle path can be exercised without
|
||||||
|
/// hitting real disk paths or the real network (the throttle
|
||||||
|
/// short-circuits before the HTTP call).
|
||||||
|
/// </summary>
|
||||||
|
internal static string? StateDirectoryOverride { get; set; }
|
||||||
|
|
||||||
|
private static string StateDirectory => StateDirectoryOverride ??
|
||||||
|
Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||||
|
"TeamsISO");
|
||||||
|
|
||||||
|
private static string CooldownPath =>
|
||||||
|
Path.Combine(StateDirectory, "last-update-check.txt");
|
||||||
|
|
||||||
|
private static string OptOutPath =>
|
||||||
|
Path.Combine(StateDirectory, "no-update-check.flag");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether launch-time update checks are enabled. Inverted-flag-file storage:
|
||||||
|
/// the absence of the file means "checks on" (default), the presence means
|
||||||
|
/// "checks off". Operators can ship the flag file via group policy / config-
|
||||||
|
/// management to suppress checks across a fleet.
|
||||||
|
/// </summary>
|
||||||
|
public static bool LaunchCheckEnabled
|
||||||
|
{
|
||||||
|
get => !File.Exists(OptOutPath);
|
||||||
|
set
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (value)
|
||||||
|
{
|
||||||
|
if (File.Exists(OptOutPath)) File.Delete(OptOutPath);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var dir = Path.GetDirectoryName(OptOutPath);
|
||||||
|
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
|
||||||
|
File.WriteAllText(OptOutPath, "Update checks suppressed by user. Delete this file to re-enable.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Best-effort; toggle won't persist if disk is read-only, but
|
||||||
|
// the in-memory checkbox state still reflects the user's intent
|
||||||
|
// for this session.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetCurrentVersion()
|
||||||
|
{
|
||||||
|
var asm = typeof(UpdateChecker).Assembly;
|
||||||
|
return asm.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion
|
||||||
|
?? asm.GetName().Version?.ToString()
|
||||||
|
?? "0.0.0";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parse a "vX.Y.Z" or "X.Y.Z(.N)" string into a Version. Strips any
|
||||||
|
/// pre-release suffix ("-alpha", "-beta") so the comparison is on
|
||||||
|
/// numeric components only — pre-release vs. release ordering is a
|
||||||
|
/// follow-up if we need it. Internal so tests can pin parsing
|
||||||
|
/// behaviour without HTTP.
|
||||||
|
/// </summary>
|
||||||
|
internal static Version? TryParseSemVer(string s)
|
||||||
|
{
|
||||||
|
var trimmed = s.TrimStart('v', 'V');
|
||||||
|
var dash = trimmed.IndexOf('-');
|
||||||
|
if (dash >= 0) trimmed = trimmed[..dash];
|
||||||
|
return Version.TryParse(trimmed, out var v) ? v : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
124
src/TeamsISO.App/Services/WindowStateStore.cs
Normal file
124
src/TeamsISO.App/Services/WindowStateStore.cs
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
using System.IO;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Windows;
|
||||||
|
|
||||||
|
namespace TeamsISO.App.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Saves / restores the main window's size, position, and state across launches.
|
||||||
|
/// Stored as JSON at <c>%LOCALAPPDATA%\TeamsISO\window.json</c>. Multi-monitor
|
||||||
|
/// friendly: a saved position that no longer falls inside any working area is
|
||||||
|
/// rejected on restore so the window doesn't disappear off-screen when a monitor
|
||||||
|
/// has been disconnected.
|
||||||
|
/// </summary>
|
||||||
|
public static class WindowStateStore
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Test-only seam — when set, overrides the default
|
||||||
|
/// %LOCALAPPDATA%\TeamsISO\window.json path. Lets tests verify
|
||||||
|
/// the serialization round-trip without polluting the dev's
|
||||||
|
/// real placement state.
|
||||||
|
/// </summary>
|
||||||
|
internal static string? PathOverride { get; set; }
|
||||||
|
|
||||||
|
private static string Path => PathOverride ??
|
||||||
|
System.IO.Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||||
|
"TeamsISO",
|
||||||
|
"window.json");
|
||||||
|
|
||||||
|
public sealed record Snapshot(
|
||||||
|
double Left,
|
||||||
|
double Top,
|
||||||
|
double Width,
|
||||||
|
double Height,
|
||||||
|
WindowState State);
|
||||||
|
|
||||||
|
/// <summary>Save the current window placement.</summary>
|
||||||
|
public static void Save(Window window)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var snap = new Snapshot(
|
||||||
|
Left: window.Left,
|
||||||
|
Top: window.Top,
|
||||||
|
Width: window.ActualWidth,
|
||||||
|
Height: window.ActualHeight,
|
||||||
|
State: window.WindowState == WindowState.Minimized ? WindowState.Normal : window.WindowState);
|
||||||
|
var dir = System.IO.Path.GetDirectoryName(Path);
|
||||||
|
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
|
||||||
|
File.WriteAllText(Path, JsonSerializer.Serialize(snap, new JsonSerializerOptions { WriteIndented = true }));
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Best-effort persistence; never crash on shutdown for a UI nicety.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Apply a previously-saved placement. Clamps onto a visible work area so a
|
||||||
|
/// monitor change doesn't strand the window off-screen. Returns true if a
|
||||||
|
/// valid snapshot was applied; false if no file existed or the snapshot was
|
||||||
|
/// rejected for being entirely outside any visible work area.
|
||||||
|
/// </summary>
|
||||||
|
public static bool TryApply(Window window)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!File.Exists(Path)) return false;
|
||||||
|
var json = File.ReadAllText(Path);
|
||||||
|
var snap = JsonSerializer.Deserialize<Snapshot>(json);
|
||||||
|
if (snap is null) return false;
|
||||||
|
|
||||||
|
// Sanity-check sizes (don't restore a 0×0 or absurdly large window).
|
||||||
|
if (snap.Width < 320 || snap.Height < 240) return false;
|
||||||
|
if (snap.Width > 16000 || snap.Height > 12000) return false;
|
||||||
|
|
||||||
|
// Reject if entirely off-screen (any working area on any screen contains
|
||||||
|
// a corner). System.Windows.Forms gives us per-monitor work areas here;
|
||||||
|
// we deliberately stick with WPF's SystemParameters which only reports the
|
||||||
|
// primary, so we use a generous on-screen check rather than refusing
|
||||||
|
// multi-monitor positions.
|
||||||
|
if (!IsAnyCornerOnScreen(snap)) return false;
|
||||||
|
|
||||||
|
window.WindowStartupLocation = WindowStartupLocation.Manual;
|
||||||
|
window.Left = snap.Left;
|
||||||
|
window.Top = snap.Top;
|
||||||
|
window.Width = snap.Width;
|
||||||
|
window.Height = snap.Height;
|
||||||
|
window.WindowState = snap.State;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Approximate "is at least one corner of the saved rect within the virtual
|
||||||
|
/// screen?" check. Uses SystemParameters.VirtualScreen* which spans every
|
||||||
|
/// monitor.
|
||||||
|
/// </summary>
|
||||||
|
private static bool IsAnyCornerOnScreen(Snapshot snap)
|
||||||
|
{
|
||||||
|
var minX = SystemParameters.VirtualScreenLeft;
|
||||||
|
var minY = SystemParameters.VirtualScreenTop;
|
||||||
|
var maxX = minX + SystemParameters.VirtualScreenWidth;
|
||||||
|
var maxY = minY + SystemParameters.VirtualScreenHeight;
|
||||||
|
|
||||||
|
var corners = new[]
|
||||||
|
{
|
||||||
|
(snap.Left, snap.Top),
|
||||||
|
(snap.Left + snap.Width, snap.Top),
|
||||||
|
(snap.Left, snap.Top + snap.Height),
|
||||||
|
(snap.Left + snap.Width, snap.Top + snap.Height),
|
||||||
|
};
|
||||||
|
foreach (var (x, y) in corners)
|
||||||
|
{
|
||||||
|
if (x >= minX && x <= maxX && y >= minY && y <= maxY)
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
40
src/TeamsISO.App/StartupTrace.cs
Normal file
40
src/TeamsISO.App/StartupTrace.cs
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
using System.IO;
|
||||||
|
|
||||||
|
namespace TeamsISO.App;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bare-metal startup tracer that opens, appends, and closes a file on
|
||||||
|
/// every call. Used to capture what's happening BEFORE Serilog comes up
|
||||||
|
/// (and to capture failures that would prevent Serilog from coming up at
|
||||||
|
/// all). Failures here are swallowed — we never want diagnostics to crash
|
||||||
|
/// the very thing we're trying to diagnose.
|
||||||
|
///
|
||||||
|
/// File lives at <c>%LOCALAPPDATA%\TeamsISO\startup-trace.log</c>. Grows
|
||||||
|
/// without rotation; expected to be tiny since each launch writes ~20
|
||||||
|
/// lines. Acceptable cost for catching launch-time regressions.
|
||||||
|
/// </summary>
|
||||||
|
internal static class StartupTrace
|
||||||
|
{
|
||||||
|
private static readonly object _gate = new();
|
||||||
|
|
||||||
|
public static void Write(string message)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var dir = Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||||
|
"TeamsISO");
|
||||||
|
Directory.CreateDirectory(dir);
|
||||||
|
var path = Path.Combine(dir, "startup-trace.log");
|
||||||
|
var line = $"[{DateTimeOffset.Now:yyyy-MM-dd HH:mm:ss.fff}] [PID {Environment.ProcessId}] {message}{Environment.NewLine}";
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
File.AppendAllText(path, line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Diagnostics must NEVER crash startup.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
76
src/TeamsISO.App/TeamsEmbedWindow.xaml
Normal file
76
src/TeamsISO.App/TeamsEmbedWindow.xaml
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
<Window x:Class="TeamsISO.App.TeamsEmbedWindow"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:shell="clr-namespace:System.Windows.Shell;assembly=PresentationFramework"
|
||||||
|
Title="Teams (embedded)"
|
||||||
|
Icon="/Assets/teamsiso.ico"
|
||||||
|
Width="1280" Height="720"
|
||||||
|
MinWidth="640" MinHeight="360"
|
||||||
|
Background="Black"
|
||||||
|
WindowStyle="None"
|
||||||
|
ResizeMode="CanResize"
|
||||||
|
UseLayoutRounding="True">
|
||||||
|
|
||||||
|
<shell:WindowChrome.WindowChrome>
|
||||||
|
<shell:WindowChrome
|
||||||
|
CaptionHeight="32"
|
||||||
|
ResizeBorderThickness="6"
|
||||||
|
CornerRadius="0"
|
||||||
|
GlassFrameThickness="0"
|
||||||
|
UseAeroCaptionButtons="False"/>
|
||||||
|
</shell:WindowChrome.WindowChrome>
|
||||||
|
|
||||||
|
<Border BorderBrush="{DynamicResource Wd.Border}" BorderThickness="1">
|
||||||
|
<Grid>
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="*"/>
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
|
<!-- Caption with experimental warning. The X button restores
|
||||||
|
Teams' chrome before closing, never leaves Teams in a
|
||||||
|
reparented-orphan state. -->
|
||||||
|
<Grid Grid.Row="0" Background="{DynamicResource Wd.Surface}">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<StackPanel Orientation="Horizontal" Margin="14,0,0,0">
|
||||||
|
<TextBlock Text="TEAMS (EMBEDDED)"
|
||||||
|
Style="{StaticResource Wd.Text.Caption}"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
<Border Style="{StaticResource Wd.Pill}"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Margin="10,0,0,0"
|
||||||
|
Padding="8,2"
|
||||||
|
Background="{DynamicResource Wd.Accent.CoralBg}">
|
||||||
|
<TextBlock Text="experimental"
|
||||||
|
FontFamily="{StaticResource Wd.Font.Mono}"
|
||||||
|
FontSize="10"
|
||||||
|
Foreground="{DynamicResource Wd.Accent.Coral}"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
</Border>
|
||||||
|
</StackPanel>
|
||||||
|
<Button Grid.Column="1"
|
||||||
|
Style="{StaticResource Wd.Button.CaptionClose}"
|
||||||
|
Click="OnClose"
|
||||||
|
shell:WindowChrome.IsHitTestVisibleInChrome="True"
|
||||||
|
ToolTip="Close embed window. Teams' chrome will be restored before this window closes.">
|
||||||
|
<Path Data="M 0,0 L 10,10 M 10,0 L 0,10"
|
||||||
|
Stroke="{DynamicResource Wd.Text.Primary}"
|
||||||
|
StrokeThickness="1.2"
|
||||||
|
Width="10" Height="10"
|
||||||
|
Stretch="None"/>
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!-- Embed host: the Teams window gets SetParent-reparented
|
||||||
|
into this Border's HWND on Loaded. SizeChanged drives
|
||||||
|
MoveWindow to keep Teams fitted to our bounds. -->
|
||||||
|
<Border x:Name="EmbedHost"
|
||||||
|
Grid.Row="1"
|
||||||
|
Background="Black"
|
||||||
|
SizeChanged="OnHostSizeChanged"/>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</Window>
|
||||||
72
src/TeamsISO.App/TeamsEmbedWindow.xaml.cs
Normal file
72
src/TeamsISO.App/TeamsEmbedWindow.xaml.cs
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Interop;
|
||||||
|
using TeamsISO.App.Services;
|
||||||
|
|
||||||
|
namespace TeamsISO.App;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Phase E.4 experimental — hosts an embedded copy of the Teams main
|
||||||
|
/// window via SetParent. Operator opens this from Settings → DISPLAY →
|
||||||
|
/// 'Embed Teams window'. The host Border's HWND becomes Teams' parent on
|
||||||
|
/// Loaded; SizeChanged keeps Teams fitted; Closing always restores Teams
|
||||||
|
/// to a normal top-level window before we exit.
|
||||||
|
///
|
||||||
|
/// Failsafes:
|
||||||
|
/// • If no Teams window is found at Loaded, show a friendly message
|
||||||
|
/// instead of leaving the host blank.
|
||||||
|
/// • Restore-on-close runs in a finally block so a crash mid-host
|
||||||
|
/// can't leave Teams orphaned with stripped window styles.
|
||||||
|
/// • TeamsEmbedHost.RestoreEmbed is idempotent — safe to call even if
|
||||||
|
/// embedding never succeeded.
|
||||||
|
/// </summary>
|
||||||
|
public partial class TeamsEmbedWindow : Window
|
||||||
|
{
|
||||||
|
public TeamsEmbedWindow()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
Loaded += OnWindowLoaded;
|
||||||
|
Closed += OnWindowClosed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnWindowLoaded(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
var src = PresentationSource.FromVisual(EmbedHost) as HwndSource;
|
||||||
|
if (src is null || src.Handle == IntPtr.Zero)
|
||||||
|
{
|
||||||
|
MessageBox.Show(
|
||||||
|
"Couldn't obtain a host HWND for the embed window. " +
|
||||||
|
"Try closing and re-opening the embed window.",
|
||||||
|
"TeamsISO — embed",
|
||||||
|
MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var w = (int)EmbedHost.ActualWidth;
|
||||||
|
var h = (int)EmbedHost.ActualHeight;
|
||||||
|
if (!TeamsEmbedHost.EmbedTeamsInto(src.Handle, w, h))
|
||||||
|
{
|
||||||
|
MessageBox.Show(
|
||||||
|
"Couldn't find a Microsoft Teams window to embed. " +
|
||||||
|
"Launch Teams first (rail camera icon), then re-open this window.",
|
||||||
|
"TeamsISO — embed",
|
||||||
|
MessageBoxButton.OK, MessageBoxImage.Information);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnHostSizeChanged(object sender, SizeChangedEventArgs e)
|
||||||
|
{
|
||||||
|
// Keep Teams sized to match the host as the embed window resizes.
|
||||||
|
// No-op when nothing is embedded.
|
||||||
|
TeamsEmbedHost.ResizeEmbedded((int)e.NewSize.Width, (int)e.NewSize.Height);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnWindowClosed(object? sender, EventArgs e)
|
||||||
|
{
|
||||||
|
// ALWAYS restore Teams to top-level state when this window closes,
|
||||||
|
// even if the embed never succeeded. Idempotent.
|
||||||
|
try { TeamsEmbedHost.RestoreEmbed(); }
|
||||||
|
catch { /* defensive — restore is best-effort */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnClose(object sender, RoutedEventArgs e) => Close();
|
||||||
|
}
|
||||||
|
|
@ -4,15 +4,85 @@
|
||||||
<OutputType>WinExe</OutputType>
|
<OutputType>WinExe</OutputType>
|
||||||
<TargetFramework>net8.0-windows</TargetFramework>
|
<TargetFramework>net8.0-windows</TargetFramework>
|
||||||
<UseWPF>true</UseWPF>
|
<UseWPF>true</UseWPF>
|
||||||
|
<!--
|
||||||
|
WinForms in addition to WPF for the system-tray NotifyIcon — there's no
|
||||||
|
WPF equivalent. The two frameworks coexist cleanly in .NET 8; UseWindowsForms
|
||||||
|
adds System.Windows.Forms.dll without changing the application model.
|
||||||
|
-->
|
||||||
|
<UseWindowsForms>true</UseWindowsForms>
|
||||||
<RootNamespace>TeamsISO.App</RootNamespace>
|
<RootNamespace>TeamsISO.App</RootNamespace>
|
||||||
<AssemblyName>TeamsISO</AssemblyName>
|
<AssemblyName>TeamsISO</AssemblyName>
|
||||||
<EnableWindowsTargeting>true</EnableWindowsTargeting>
|
<EnableWindowsTargeting>true</EnableWindowsTargeting>
|
||||||
<ApplicationIcon></ApplicationIcon>
|
<ApplicationIcon>Assets\teamsiso.ico</ApplicationIcon>
|
||||||
|
<!--
|
||||||
|
Required by ParticipantViewModel.ScaleNearestNeighborBgra, which writes
|
||||||
|
directly into a WriteableBitmap's pinned BackBuffer (IntPtr) for 10×
|
||||||
|
better thumbnail update perf than going through Span<byte>.
|
||||||
|
-->
|
||||||
|
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\TeamsISO.Engine\TeamsISO.Engine.csproj" />
|
<ProjectReference Include="..\TeamsISO.Engine\TeamsISO.Engine.csproj" />
|
||||||
<ProjectReference Include="..\TeamsISO.Engine.NdiInterop\TeamsISO.Engine.NdiInterop.csproj" />
|
<ProjectReference Include="..\TeamsISO.Engine.NdiInterop\TeamsISO.Engine.NdiInterop.csproj" />
|
||||||
|
<!--
|
||||||
|
System.Management gives us Win32_Process via ManagementObjectSearcher,
|
||||||
|
used in App.xaml.cs's ShouldDeElevate() to look up the parent process
|
||||||
|
name (we re-spawn ourselves via runas /trustlevel:0x20000 when the
|
||||||
|
parent is explorer.exe AND we're elevated — that combo triggers an
|
||||||
|
NDI mDNS-isolation bug that returns zero discovered sources).
|
||||||
|
-->
|
||||||
|
<PackageReference Include="System.Management" Version="8.0.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Grant the test assembly access to internal types — specifically the
|
||||||
|
OperatorPresetStore.PathOverride hook used to redirect file IO away from
|
||||||
|
%LOCALAPPDATA% during tests. We use AssemblyAttribute rather than
|
||||||
|
AssemblyInfo.cs so it co-locates with the project's other config.
|
||||||
|
-->
|
||||||
|
<ItemGroup>
|
||||||
|
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleToAttribute">
|
||||||
|
<_Parameter1>TeamsISO.App.Tests</_Parameter1>
|
||||||
|
</AssemblyAttribute>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Strings.resx — user-facing English MessageBox copy. Embedded as a
|
||||||
|
.NET resource so ResourceManager can resolve TeamsISO.App.Properties.Strings
|
||||||
|
by basename. Strings.Designer.cs is hand-written (see file comment).
|
||||||
|
-->
|
||||||
|
<ItemGroup>
|
||||||
|
<EmbeddedResource Update="Properties\Strings.resx">
|
||||||
|
<LogicalName>TeamsISO.App.Properties.Strings.resources</LogicalName>
|
||||||
|
</EmbeddedResource>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<!-- Wild Dragon brand assets — embedded as resources so the published binary is self-contained. -->
|
||||||
|
<ItemGroup>
|
||||||
|
<!-- Source navy-blue dragon-mark, kept for AboutWindow / installer iconography. -->
|
||||||
|
<Resource Include="Assets\dragon-mark.png" />
|
||||||
|
<!--
|
||||||
|
Theme-aware silhouette variants used by Theme.Dark / Theme.Light to expose
|
||||||
|
a single Wd.BrandMark.Image resource key. The dark theme picks the white
|
||||||
|
dragon (visible on #0A0A0A), the light theme picks the black dragon
|
||||||
|
(visible on #FAFAFB). Generated from dragon-mark.png via
|
||||||
|
Assets/_recolor_dragon.py — re-run if the source mark ever changes.
|
||||||
|
-->
|
||||||
|
<Resource Include="Assets\dragon-mark-white.png" />
|
||||||
|
<Resource Include="Assets\dragon-mark-black.png" />
|
||||||
|
<Resource Include="Assets\wild-dragon-wordmark.png" />
|
||||||
|
<Resource Include="Assets\teamsiso.ico" />
|
||||||
|
<!--
|
||||||
|
Inter Variable from rsms/inter v3.19 (OFL). Single .ttf covers every weight
|
||||||
|
from 100 (Thin) to 900 (Black) so we don't ship a directory of duplicates.
|
||||||
|
-->
|
||||||
|
<Resource Include="Assets\Fonts\Inter.ttf" />
|
||||||
|
<!--
|
||||||
|
JetBrains Mono Variable v2.304 (OFL). Used for machine names, source IDs,
|
||||||
|
and stat counters where a fixed-width font reads better than Inter.
|
||||||
|
-->
|
||||||
|
<Resource Include="Assets\Fonts\JetBrainsMono.ttf" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|
|
||||||
63
src/TeamsISO.App/Themes/Theme.Dark.xaml
Normal file
63
src/TeamsISO.App/Themes/Theme.Dark.xaml
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
<ResourceDictionary
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Wild Dragon — Dark palette. Color resources ONLY, no styles.
|
||||||
|
Loaded into App.xaml's MergedDictionaries; swapped at runtime
|
||||||
|
for Theme.Light.xaml via Services/ThemeManager.cs.
|
||||||
|
|
||||||
|
Every key here MUST also exist in Theme.Light.xaml with the
|
||||||
|
same name. Keep the two files in lockstep — adding a new
|
||||||
|
brush in one without the other will break light-mode rendering.
|
||||||
|
|
||||||
|
References inside WildDragonTheme.xaml (which carries the styles
|
||||||
|
+ control templates) reach these brushes via {DynamicResource},
|
||||||
|
so the runtime swap re-resolves automatically.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<SolidColorBrush x:Key="Wd.Canvas" Color="#0A0A0A"/>
|
||||||
|
<SolidColorBrush x:Key="Wd.Rail" Color="#080808"/>
|
||||||
|
<SolidColorBrush x:Key="Wd.Surface" Color="#141414"/>
|
||||||
|
<SolidColorBrush x:Key="Wd.SurfaceElevated" Color="#1C1C1C"/>
|
||||||
|
<SolidColorBrush x:Key="Wd.SurfaceHover" Color="#2A2A2A"/>
|
||||||
|
<SolidColorBrush x:Key="Wd.SurfaceActive" Color="#363636"/>
|
||||||
|
<SolidColorBrush x:Key="Wd.Border" Color="#262626"/>
|
||||||
|
<SolidColorBrush x:Key="Wd.BorderStrong" Color="#383838"/>
|
||||||
|
<SolidColorBrush x:Key="Wd.Button.HoverBg" Color="#33333A"/>
|
||||||
|
<SolidColorBrush x:Key="Wd.Button.PressBg" Color="#3F3F47"/>
|
||||||
|
|
||||||
|
<SolidColorBrush x:Key="Wd.Text.Primary" Color="#F5F5F5"/>
|
||||||
|
<SolidColorBrush x:Key="Wd.Text.Secondary" Color="#A3A3A3"/>
|
||||||
|
<SolidColorBrush x:Key="Wd.Text.Tertiary" Color="#6B6B6B"/>
|
||||||
|
<SolidColorBrush x:Key="Wd.Text.Disabled" Color="#404040"/>
|
||||||
|
|
||||||
|
<SolidColorBrush x:Key="Wd.Accent.Cyan" Color="#97EDF0"/>
|
||||||
|
<SolidColorBrush x:Key="Wd.Accent.CyanHover" Color="#B5F2F4"/>
|
||||||
|
<SolidColorBrush x:Key="Wd.Accent.CyanMuted" Color="#1B3537"/>
|
||||||
|
<SolidColorBrush x:Key="Wd.Accent.CyanText" Color="#97EDF0"/>
|
||||||
|
<SolidColorBrush x:Key="Wd.Accent.Blue" Color="#9AE0FD"/>
|
||||||
|
<SolidColorBrush x:Key="Wd.Accent.Coral" Color="#FB819C"/>
|
||||||
|
<SolidColorBrush x:Key="Wd.Accent.CoralBg" Color="#3A1922"/>
|
||||||
|
|
||||||
|
<SolidColorBrush x:Key="Wd.Status.Live" Color="#4ADE80"/>
|
||||||
|
<SolidColorBrush x:Key="Wd.Status.LiveBg" Color="#13261A"/>
|
||||||
|
<SolidColorBrush x:Key="Wd.Status.Warn" Color="#FBBF24"/>
|
||||||
|
<SolidColorBrush x:Key="Wd.Status.Error" Color="#FB819C"/>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Brand mark image, theme-flipped. Dark mode shows the WHITE dragon so it
|
||||||
|
reads against the near-black canvas. The light theme exposes the same
|
||||||
|
key pointing at the BLACK dragon. Consumers bind via
|
||||||
|
{DynamicResource Wd.BrandMark.Image} so the swap is automatic on
|
||||||
|
ThemeManager.Toggle().
|
||||||
|
|
||||||
|
CacheOption=OnLoad decodes the PNG at load time and releases the
|
||||||
|
underlying stream, which matters because the source files are 1243×1125
|
||||||
|
— without OnLoad the BitmapImage holds the stream open for the life
|
||||||
|
of the resource dictionary.
|
||||||
|
-->
|
||||||
|
<BitmapImage x:Key="Wd.BrandMark.Image"
|
||||||
|
UriSource="pack://application:,,,/TeamsISO;component/Assets/dragon-mark-white.png"
|
||||||
|
CacheOption="OnLoad"/>
|
||||||
|
</ResourceDictionary>
|
||||||
60
src/TeamsISO.App/Themes/Theme.Light.xaml
Normal file
60
src/TeamsISO.App/Themes/Theme.Light.xaml
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
<ResourceDictionary
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Wild Dragon — Light palette. Color resources ONLY, no styles.
|
||||||
|
Mirror of Theme.Dark.xaml — same keys, light-mode values.
|
||||||
|
|
||||||
|
Light-palette discipline (from DESIGN.md):
|
||||||
|
- Neutrals are cyan-tinted off-whites, not pure white, so the
|
||||||
|
surface still reads as Wild Dragon brand, not as generic OS.
|
||||||
|
- Wd.Accent.Cyan stays at #97EDF0 because its primary use is as
|
||||||
|
a fill where text-on-top is near-black (LIVE pill works in
|
||||||
|
both modes unchanged).
|
||||||
|
- Wd.Accent.CyanText drops to a darker cyan (#0E7C82) for
|
||||||
|
contrast when cyan is used as text/icon foreground on the
|
||||||
|
light canvas. Use this key for "cyan as text"; use
|
||||||
|
Wd.Accent.Cyan for "cyan as background fill".
|
||||||
|
-->
|
||||||
|
|
||||||
|
<SolidColorBrush x:Key="Wd.Canvas" Color="#FAFAFB"/>
|
||||||
|
<SolidColorBrush x:Key="Wd.Rail" Color="#F0F1F3"/>
|
||||||
|
<SolidColorBrush x:Key="Wd.Surface" Color="#FFFFFF"/>
|
||||||
|
<SolidColorBrush x:Key="Wd.SurfaceElevated" Color="#FFFFFF"/>
|
||||||
|
<SolidColorBrush x:Key="Wd.SurfaceHover" Color="#ECEEF1"/>
|
||||||
|
<SolidColorBrush x:Key="Wd.SurfaceActive" Color="#E0E3E7"/>
|
||||||
|
<SolidColorBrush x:Key="Wd.Border" Color="#E5E7EB"/>
|
||||||
|
<SolidColorBrush x:Key="Wd.BorderStrong" Color="#D1D5DA"/>
|
||||||
|
<SolidColorBrush x:Key="Wd.Button.HoverBg" Color="#E0E3E7"/>
|
||||||
|
<SolidColorBrush x:Key="Wd.Button.PressBg" Color="#D1D5DA"/>
|
||||||
|
|
||||||
|
<SolidColorBrush x:Key="Wd.Text.Primary" Color="#0A0A0A"/>
|
||||||
|
<SolidColorBrush x:Key="Wd.Text.Secondary" Color="#4A4B50"/>
|
||||||
|
<SolidColorBrush x:Key="Wd.Text.Tertiary" Color="#71747A"/>
|
||||||
|
<SolidColorBrush x:Key="Wd.Text.Disabled" Color="#B3B6BC"/>
|
||||||
|
|
||||||
|
<SolidColorBrush x:Key="Wd.Accent.Cyan" Color="#97EDF0"/>
|
||||||
|
<SolidColorBrush x:Key="Wd.Accent.CyanHover" Color="#0890A0"/>
|
||||||
|
<SolidColorBrush x:Key="Wd.Accent.CyanMuted" Color="#E6F8F9"/>
|
||||||
|
<SolidColorBrush x:Key="Wd.Accent.CyanText" Color="#0E7C82"/>
|
||||||
|
<SolidColorBrush x:Key="Wd.Accent.Blue" Color="#3578A8"/>
|
||||||
|
<SolidColorBrush x:Key="Wd.Accent.Coral" Color="#D43E5C"/>
|
||||||
|
<SolidColorBrush x:Key="Wd.Accent.CoralBg" Color="#FDECF0"/>
|
||||||
|
|
||||||
|
<SolidColorBrush x:Key="Wd.Status.Live" Color="#15803D"/>
|
||||||
|
<SolidColorBrush x:Key="Wd.Status.LiveBg" Color="#DCFCE7"/>
|
||||||
|
<SolidColorBrush x:Key="Wd.Status.Warn" Color="#B45309"/>
|
||||||
|
<SolidColorBrush x:Key="Wd.Status.Error" Color="#D43E5C"/>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Brand mark image, theme-flipped. Light mode shows the BLACK dragon so it
|
||||||
|
reads against the cyan-tinted off-white canvas. Mirror of the Dark
|
||||||
|
theme's resource — same key, opposite silhouette. Consumers use
|
||||||
|
{DynamicResource Wd.BrandMark.Image} so the swap is automatic.
|
||||||
|
See Theme.Dark.xaml's comment for the CacheOption=OnLoad rationale.
|
||||||
|
-->
|
||||||
|
<BitmapImage x:Key="Wd.BrandMark.Image"
|
||||||
|
UriSource="pack://application:,,,/TeamsISO;component/Assets/dragon-mark-black.png"
|
||||||
|
CacheOption="OnLoad"/>
|
||||||
|
</ResourceDictionary>
|
||||||
1039
src/TeamsISO.App/Themes/WildDragonTheme.xaml
Normal file
1039
src/TeamsISO.App/Themes/WildDragonTheme.xaml
Normal file
File diff suppressed because it is too large
Load diff
210
src/TeamsISO.App/ViewModels/CommandPaletteViewModel.cs
Normal file
210
src/TeamsISO.App/ViewModels/CommandPaletteViewModel.cs
Normal file
|
|
@ -0,0 +1,210 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Windows.Threading;
|
||||||
|
using TeamsISO.App.Services;
|
||||||
|
|
||||||
|
namespace TeamsISO.App.ViewModels;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// View-model for the v2 Ctrl+K command palette. Owns the static list of
|
||||||
|
/// commands the operator can invoke, plus a free-text filter that whittles
|
||||||
|
/// the visible list down.
|
||||||
|
///
|
||||||
|
/// The palette is the v2 redesign's navigation surface — it replaces the
|
||||||
|
/// v1 rail's launch / hide / settings buttons (still discoverable in the
|
||||||
|
/// 32px header) AND the buried-in-tabs operator actions like "Apply
|
||||||
|
/// transcoder topology" or "Stop all ISOs" that previously needed
|
||||||
|
/// hunting through menus. Type two letters, press Enter, action invokes.
|
||||||
|
///
|
||||||
|
/// Match shape: case-insensitive Contains across Label + Category + the
|
||||||
|
/// optional Keywords list. Fuzzy (Sublime / Linear style) matching is a
|
||||||
|
/// future evolution if Contains proves insufficient; broadcasters have
|
||||||
|
/// short attention budgets and Contains is the predictable answer.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CommandPaletteViewModel : ObservableObject
|
||||||
|
{
|
||||||
|
private readonly MainViewModel _main;
|
||||||
|
private readonly Dispatcher _dispatcher;
|
||||||
|
private readonly List<PaletteCommand> _all;
|
||||||
|
private string _filter = string.Empty;
|
||||||
|
private PaletteCommand? _selected;
|
||||||
|
|
||||||
|
public CommandPaletteViewModel(MainViewModel main, Dispatcher dispatcher)
|
||||||
|
{
|
||||||
|
_main = main;
|
||||||
|
_dispatcher = dispatcher;
|
||||||
|
_all = BuildCommands();
|
||||||
|
Visible = new ObservableCollection<PaletteCommand>(_all);
|
||||||
|
Selected = Visible.FirstOrDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Free-text filter. Empty string shows all commands.</summary>
|
||||||
|
public string Filter
|
||||||
|
{
|
||||||
|
get => _filter;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (!SetField(ref _filter, value)) return;
|
||||||
|
ApplyFilter();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Filtered command list, bound to the palette ListBox.</summary>
|
||||||
|
public ObservableCollection<PaletteCommand> Visible { get; }
|
||||||
|
|
||||||
|
/// <summary>Currently-highlighted command. Driven by ↑/↓ in the search box and by mouse hover.</summary>
|
||||||
|
public PaletteCommand? Selected
|
||||||
|
{
|
||||||
|
get => _selected;
|
||||||
|
set => SetField(ref _selected, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Move the selection up or down within the visible list, wrapping at the
|
||||||
|
/// edges. Called from the palette's PreviewKeyDown when the operator
|
||||||
|
/// presses ↑ / ↓ while focus is in the search box.
|
||||||
|
/// </summary>
|
||||||
|
public void MoveSelection(int direction)
|
||||||
|
{
|
||||||
|
if (Visible.Count == 0) return;
|
||||||
|
var idx = Selected is null ? -1 : Visible.IndexOf(Selected);
|
||||||
|
idx = (idx + direction + Visible.Count) % Visible.Count;
|
||||||
|
Selected = Visible[idx];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Invoke the current selection's action. Returns true if something fired.</summary>
|
||||||
|
public bool InvokeSelection()
|
||||||
|
{
|
||||||
|
var sel = Selected;
|
||||||
|
if (sel is null) return false;
|
||||||
|
try { sel.Invoke(); }
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_main.Toast.Warn($"{sel.Label}: {ex.Message}");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyFilter()
|
||||||
|
{
|
||||||
|
var query = _filter.Trim();
|
||||||
|
var prevSelected = Selected;
|
||||||
|
Visible.Clear();
|
||||||
|
if (string.IsNullOrEmpty(query))
|
||||||
|
{
|
||||||
|
foreach (var c in _all) Visible.Add(c);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
foreach (var c in _all)
|
||||||
|
{
|
||||||
|
if (Matches(c, query)) Visible.Add(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Selected = Visible.Contains(prevSelected!)
|
||||||
|
? prevSelected
|
||||||
|
: Visible.FirstOrDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static bool Matches(PaletteCommand c, string query)
|
||||||
|
{
|
||||||
|
if (c.Label.Contains(query, StringComparison.OrdinalIgnoreCase)) return true;
|
||||||
|
if (c.Category.Contains(query, StringComparison.OrdinalIgnoreCase)) return true;
|
||||||
|
// Keywords is a single space-separated string of synonyms — Contains
|
||||||
|
// over the whole blob suffices for the operator's short-token typing.
|
||||||
|
if (!string.IsNullOrEmpty(c.Keywords) &&
|
||||||
|
c.Keywords.Contains(query, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Build the static command list. Order within a category matters for
|
||||||
|
/// keyboard-only operators: the most-frequent command of each category
|
||||||
|
/// goes first.
|
||||||
|
/// </summary>
|
||||||
|
private List<PaletteCommand> BuildCommands()
|
||||||
|
{
|
||||||
|
var vm = _main;
|
||||||
|
return new List<PaletteCommand>
|
||||||
|
{
|
||||||
|
// ─── QUICK ─── operator's top-of-mind verbs
|
||||||
|
new("Quick", "Enable all online", "ISOs enable everyone start everything live", "Ctrl+E",
|
||||||
|
() => InvokeIfReady(vm.EnableAllOnlineCommand)),
|
||||||
|
new("Quick", "Stop all ISOs", "panic stop everything kill disable", "Ctrl+Shift+S",
|
||||||
|
() => InvokeIfReady(vm.StopAllIsosCommand)),
|
||||||
|
new("Quick", "Refresh discovery", "rediscover sources rebuild finder NDI", "Ctrl+R",
|
||||||
|
() => InvokeIfReady(vm.RefreshDiscoveryCommand)),
|
||||||
|
|
||||||
|
// ─── TEAMS ─── direct UIA orchestration
|
||||||
|
new("Teams", "Mute / unmute", "microphone audio silence toggle", null,
|
||||||
|
() => InvokeIfReady(vm.ToggleMuteCommand)),
|
||||||
|
new("Teams", "Toggle camera", "video webcam on off", null,
|
||||||
|
() => InvokeIfReady(vm.ToggleCameraCommand)),
|
||||||
|
new("Teams", "Open share tray", "screen share present", null,
|
||||||
|
() => InvokeIfReady(vm.OpenShareTrayCommand)),
|
||||||
|
new("Teams", "Leave call", "exit end disconnect quit", null,
|
||||||
|
() => InvokeIfReady(vm.LeaveCallCommand)),
|
||||||
|
new("Teams", "Launch Microsoft Teams", "start open run app", null,
|
||||||
|
() => RunOnUi(() =>
|
||||||
|
{
|
||||||
|
if (!TeamsLauncher.IsRunning() && TeamsLauncher.TryLaunch(out _))
|
||||||
|
vm.Toast.Show("Launching Microsoft Teams…");
|
||||||
|
else
|
||||||
|
TeamsLauncher.ShowWindows();
|
||||||
|
})),
|
||||||
|
new("Teams", "Hide Teams windows", "minimize cloak", null,
|
||||||
|
() => RunOnUi(() =>
|
||||||
|
{
|
||||||
|
var n = TeamsLauncher.HideWindows();
|
||||||
|
vm.Toast.Show(n > 0 ? $"Hid {n} Teams window(s)" : "No Teams windows to hide");
|
||||||
|
})),
|
||||||
|
new("Teams", "Show Teams windows", "restore unhide", null,
|
||||||
|
() => RunOnUi(() =>
|
||||||
|
{
|
||||||
|
var n = TeamsLauncher.ShowWindows();
|
||||||
|
vm.Toast.Show(n > 0 ? $"Restored {n} Teams window(s)" : "No Teams windows to restore");
|
||||||
|
})),
|
||||||
|
|
||||||
|
// ─── NETWORK ───
|
||||||
|
new("Network", "Apply transcoder topology", "ndi groups isolate teamsiso-input private", null,
|
||||||
|
() => InvokeIfReady(vm.Settings.ApplyTranscoderTopologyCommand)),
|
||||||
|
|
||||||
|
// ─── APP ───
|
||||||
|
new("App", "Theme: dark", "appearance night mode", null,
|
||||||
|
() => RunOnUi(() => ThemeManager.Current.Set("Dark"))),
|
||||||
|
new("App", "Theme: light", "appearance day mode bright", null,
|
||||||
|
() => RunOnUi(() => ThemeManager.Current.Set("Light"))),
|
||||||
|
new("App", "Theme: follow Windows", "system auto", null,
|
||||||
|
() => RunOnUi(() => ThemeManager.Current.Set("System"))),
|
||||||
|
new("App", "Help", "shortcuts cheatsheet f1", "F1",
|
||||||
|
() => InvokeIfReady(vm.ShowHelpCommand)),
|
||||||
|
new("App", "Show notes", "show notes daily journal", null,
|
||||||
|
() => InvokeIfReady(vm.ShowNotesCommand)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RunOnUi(Action action) => _dispatcher.BeginInvoke(action);
|
||||||
|
|
||||||
|
private static void InvokeIfReady(System.Windows.Input.ICommand cmd)
|
||||||
|
{
|
||||||
|
if (cmd?.CanExecute(null) == true) cmd.Execute(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One command in the Ctrl+K palette. <see cref="Keywords"/> is an optional
|
||||||
|
/// space of additional search terms — the operator might type "ndi" or
|
||||||
|
/// "private" and still match "Apply transcoder topology".
|
||||||
|
/// </summary>
|
||||||
|
public sealed record PaletteCommand(
|
||||||
|
string Category,
|
||||||
|
string Label,
|
||||||
|
string? Keywords,
|
||||||
|
string? Shortcut,
|
||||||
|
Action Invoke);
|
||||||
|
|
@ -1,28 +1,141 @@
|
||||||
|
using System.IO;
|
||||||
|
using System.Windows;
|
||||||
|
using TeamsISO.App.Services;
|
||||||
using TeamsISO.Engine.Controller;
|
using TeamsISO.Engine.Controller;
|
||||||
using TeamsISO.Engine.Domain;
|
using TeamsISO.Engine.Domain;
|
||||||
|
|
||||||
namespace TeamsISO.App.ViewModels;
|
namespace TeamsISO.App.ViewModels;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Bindings for the global settings panel: framerate, resolution, aspect, audio.
|
/// Bindings for the global settings panel: framerate, resolution, aspect, audio,
|
||||||
|
/// NDI groups (discovery + output), and the participant-list hide-Local toggle.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class GlobalSettingsViewModel : ObservableObject
|
public sealed class GlobalSettingsViewModel : ObservableObject
|
||||||
{
|
{
|
||||||
private readonly IIsoController _controller;
|
private readonly IIsoController _controller;
|
||||||
|
private readonly ToastViewModel? _toast;
|
||||||
private TargetFramerate _framerate;
|
private TargetFramerate _framerate;
|
||||||
private TargetResolution _resolution;
|
private TargetResolution _resolution;
|
||||||
private AspectMode _aspect;
|
private AspectMode _aspect;
|
||||||
private AudioMode _audio;
|
private AudioMode _audio;
|
||||||
|
private string _discoveryGroups;
|
||||||
|
private string _outputGroups;
|
||||||
|
private bool _hideLocalSelf = true;
|
||||||
|
private bool _autoDisableOnDeparture = false;
|
||||||
|
private bool _autoApplyLastPreset;
|
||||||
|
// Recording-related fields removed alongside the rest of the recording surface.
|
||||||
|
private bool _controlSurfaceEnabled;
|
||||||
|
private int _controlSurfacePort = ControlSurfaceServer.DefaultPort;
|
||||||
|
private bool _oscBridgeEnabled;
|
||||||
|
private int _oscBridgePort = OscBridge.DefaultPort;
|
||||||
|
private bool _updateCheckOnLaunch = UpdateChecker.LaunchCheckEnabled;
|
||||||
|
private string _outputNameTemplate = TeamsISO.App.Services.OutputNameTemplate.Get();
|
||||||
|
private UIPreferences.SortMode _participantSort = UIPreferences.SortMode.JoinOrder;
|
||||||
|
private bool _minimizeToTray;
|
||||||
|
private bool _controlSurfaceLanReachable;
|
||||||
|
private bool _launchTeamsOnStartup;
|
||||||
|
private bool _autoHideTeamsWindows;
|
||||||
|
// _autoRecordOnCall removed — recording surface axed.
|
||||||
|
private bool _embedTeamsWindow;
|
||||||
|
|
||||||
public GlobalSettingsViewModel(IIsoController controller)
|
public GlobalSettingsViewModel(IIsoController controller, ToastViewModel? toast = null)
|
||||||
{
|
{
|
||||||
_controller = controller;
|
_controller = controller;
|
||||||
|
_toast = toast;
|
||||||
var current = controller.GlobalSettings;
|
var current = controller.GlobalSettings;
|
||||||
_framerate = current.Framerate;
|
_framerate = current.Framerate;
|
||||||
_resolution = current.Resolution;
|
_resolution = current.Resolution;
|
||||||
_aspect = current.Aspect;
|
_aspect = current.Aspect;
|
||||||
_audio = current.Audio;
|
_audio = current.Audio;
|
||||||
|
|
||||||
|
var groups = controller.GroupSettings;
|
||||||
|
_discoveryGroups = groups.DiscoveryGroups ?? string.Empty;
|
||||||
|
_outputGroups = groups.OutputGroups ?? string.Empty;
|
||||||
|
|
||||||
|
// Restore persisted UI toggles so the operator's preference survives
|
||||||
|
// process restarts. UIPreferences keeps a tiny JSON file under
|
||||||
|
// %LOCALAPPDATA%\TeamsISO\ui-prefs.json — defaults match the original
|
||||||
|
// in-memory init (HideLocalSelf=true, AutoDisableOnDeparture=false).
|
||||||
|
var uiPrefs = UIPreferences.Load();
|
||||||
|
_hideLocalSelf = uiPrefs.HideLocalSelf;
|
||||||
|
_autoDisableOnDeparture = uiPrefs.AutoDisableOnDeparture;
|
||||||
|
_participantSort = uiPrefs.ParticipantSort;
|
||||||
|
_minimizeToTray = uiPrefs.MinimizeToTray;
|
||||||
|
_controlSurfaceLanReachable = uiPrefs.ControlSurfaceLanReachable;
|
||||||
|
_launchTeamsOnStartup = uiPrefs.LaunchTeamsOnStartup;
|
||||||
|
_autoHideTeamsWindows = uiPrefs.AutoHideTeamsWindows;
|
||||||
|
// AutoRecordOnCall removed — recording surface axed.
|
||||||
|
_embedTeamsWindow = uiPrefs.EmbedTeamsWindow;
|
||||||
|
_controlSurfaceEnabled = uiPrefs.ControlSurfaceEnabled;
|
||||||
|
|
||||||
|
// Bring the auto-apply flag in from the presets store so the checkbox
|
||||||
|
// reflects the user's prior choice when the settings panel opens.
|
||||||
|
try { _autoApplyLastPreset = OperatorPresetStore.GetStartupPreference().AutoApplyOnStartup; }
|
||||||
|
catch { /* best-effort — disk read failures shouldn't block UI startup */ }
|
||||||
|
|
||||||
|
// Recording-directory init removed alongside the rest of the recording surface.
|
||||||
|
|
||||||
ApplyCommand = new AsyncRelayCommand(ApplyAsync);
|
ApplyCommand = new AsyncRelayCommand(ApplyAsync);
|
||||||
|
ApplyTranscoderTopologyCommand = new AsyncRelayCommand(ApplyTranscoderTopologyAsync);
|
||||||
|
ResetOutputDefaultsCommand = new RelayCommand(ResetOutputDefaults);
|
||||||
|
CopyControlSurfaceUrlCommand = new RelayCommand(() =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
System.Windows.Clipboard.SetText(ControlSurfaceUrl);
|
||||||
|
_toast?.Show($"Copied: {ControlSurfaceUrl}");
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Clipboard occasionally errors when something else has it locked.
|
||||||
|
}
|
||||||
|
});
|
||||||
|
OpenControlSurfaceCommand = new RelayCommand(() =>
|
||||||
|
{
|
||||||
|
// Hands the URL off to the OS shell so the user's default browser
|
||||||
|
// opens it. Operators previewing how the control panel looks on
|
||||||
|
// their phone / tablet / second monitor would otherwise have to
|
||||||
|
// copy-paste the URL — this is a one-click preview.
|
||||||
|
try
|
||||||
|
{
|
||||||
|
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = ControlSurfaceUrl,
|
||||||
|
UseShellExecute = true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_toast?.Warn($"Couldn't open: {ex.Message}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Open the embedded HTML control panel (the <c>/ui</c> endpoint) in the
|
||||||
|
/// default browser. Enabled regardless of whether the control surface is
|
||||||
|
/// running — if it isn't, the browser will show a connection error, which
|
||||||
|
/// is informative; operators learn the surface needs to be enabled first.
|
||||||
|
/// </summary>
|
||||||
|
public RelayCommand OpenControlSurfaceCommand { get; }
|
||||||
|
|
||||||
|
private void ResetOutputDefaults()
|
||||||
|
{
|
||||||
|
var confirm = MessageBox.Show(
|
||||||
|
"Reset framerate, resolution, aspect and audio to TeamsISO defaults?\n\n" +
|
||||||
|
"This won't touch your NDI group configuration or display toggles.",
|
||||||
|
"TeamsISO — Reset output defaults",
|
||||||
|
MessageBoxButton.YesNo,
|
||||||
|
MessageBoxImage.Question,
|
||||||
|
MessageBoxResult.No);
|
||||||
|
if (confirm != MessageBoxResult.Yes) return;
|
||||||
|
|
||||||
|
var defaults = FrameProcessingSettings.Default;
|
||||||
|
Framerate = defaults.Framerate;
|
||||||
|
Resolution = defaults.Resolution;
|
||||||
|
Aspect = defaults.Aspect;
|
||||||
|
Audio = defaults.Audio;
|
||||||
|
_toast?.Show("Output settings reset to defaults — click Apply Changes to commit");
|
||||||
}
|
}
|
||||||
|
|
||||||
public IEnumerable<TargetFramerate> AvailableFramerates => Enum.GetValues<TargetFramerate>();
|
public IEnumerable<TargetFramerate> AvailableFramerates => Enum.GetValues<TargetFramerate>();
|
||||||
|
|
@ -35,11 +148,485 @@ public sealed class GlobalSettingsViewModel : ObservableObject
|
||||||
public AspectMode Aspect { get => _aspect; set => SetField(ref _aspect, value); }
|
public AspectMode Aspect { get => _aspect; set => SetField(ref _aspect, value); }
|
||||||
public AudioMode Audio { get => _audio; set => SetField(ref _audio, value); }
|
public AudioMode Audio { get => _audio; set => SetField(ref _audio, value); }
|
||||||
|
|
||||||
|
/// <summary>NDI discovery group(s) — comma-separated. Empty = default (Public).</summary>
|
||||||
|
public string DiscoveryGroups { get => _discoveryGroups; set => SetField(ref _discoveryGroups, value); }
|
||||||
|
|
||||||
|
/// <summary>NDI output group(s) — comma-separated. Empty = default (Public).</summary>
|
||||||
|
public string OutputGroups { get => _outputGroups; set => SetField(ref _outputGroups, value); }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Hide the user's own self-preview ("(Local)") from the participants list.
|
||||||
|
/// On by default — operators rarely want to ISO-route their own preview.
|
||||||
|
/// Read by <see cref="MainViewModel"/> when filtering the list it presents.
|
||||||
|
/// Persisted to <c>ui-prefs.json</c> via <see cref="UIPreferences"/>.
|
||||||
|
/// </summary>
|
||||||
|
public bool HideLocalSelf
|
||||||
|
{
|
||||||
|
get => _hideLocalSelf;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (SetField(ref _hideLocalSelf, value)) PersistUiPrefs();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When a participant leaves the meeting (their NDI source disappears),
|
||||||
|
/// automatically tear down their ISO pipeline. Off by default so transient
|
||||||
|
/// drops don't lose the operator's routing — but useful for clean
|
||||||
|
/// show-end behavior. Read by MainViewModel when reconciling departures.
|
||||||
|
/// Persisted to <c>ui-prefs.json</c>.
|
||||||
|
/// </summary>
|
||||||
|
public bool AutoDisableOnDeparture
|
||||||
|
{
|
||||||
|
get => _autoDisableOnDeparture;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (SetField(ref _autoDisableOnDeparture, value)) PersistUiPrefs();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Available sort modes for the dropdown in DISPLAY settings.
|
||||||
|
/// </summary>
|
||||||
|
public IEnumerable<UIPreferences.SortMode> AvailableSortModes => Enum.GetValues<UIPreferences.SortMode>();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// How the participants DataGrid is sorted. Persisted across launches via
|
||||||
|
/// <see cref="UIPreferences"/>. Reaches into <see cref="MainViewModel.SetSortMode"/>
|
||||||
|
/// on Application.Current to actually apply the sort to the live view —
|
||||||
|
/// the settings VM doesn't directly know about the main VM but App holds
|
||||||
|
/// both and exposes the main window via its DataContext.
|
||||||
|
/// </summary>
|
||||||
|
public UIPreferences.SortMode ParticipantSort
|
||||||
|
{
|
||||||
|
get => _participantSort;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (!SetField(ref _participantSort, value)) return;
|
||||||
|
PersistUiPrefs();
|
||||||
|
// Apply to the live view immediately. App.MainWindow.DataContext
|
||||||
|
// is the MainViewModel; cast and call.
|
||||||
|
var main = (Application.Current?.MainWindow?.DataContext) as MainViewModel;
|
||||||
|
main?.SetSortMode(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Minimize-to-tray behavior. When on, minimizing the main window hides
|
||||||
|
/// it from the taskbar and shows a tray icon (double-click to restore).
|
||||||
|
/// Right-click menu on the tray icon offers "Show", "Stop all ISOs", "Exit".
|
||||||
|
/// Useful for long unattended shows where the operator wants TeamsISO
|
||||||
|
/// running but invisible.
|
||||||
|
/// </summary>
|
||||||
|
public bool MinimizeToTray
|
||||||
|
{
|
||||||
|
get => _minimizeToTray;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (!SetField(ref _minimizeToTray, value)) return;
|
||||||
|
PersistUiPrefs();
|
||||||
|
// Reach into the App-owned tray host. App constructs it after the
|
||||||
|
// main window exists, so the cast is safe at any time the settings
|
||||||
|
// panel is interactable.
|
||||||
|
var tray = (Application.Current as App)?.TrayIcon;
|
||||||
|
if (tray is not null) tray.Enabled = value;
|
||||||
|
_toast?.Show(value
|
||||||
|
? "Minimize-to-tray enabled — minimizing now hides the window"
|
||||||
|
: "Minimize-to-tray disabled");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Snapshot the current persistable UI state to disk. Called from any
|
||||||
|
/// <see cref="UIPreferences.Prefs"/>-backed setter. Best-effort — disk
|
||||||
|
/// failures don't surface to the operator (the in-memory state still
|
||||||
|
/// reflects their click for this session).
|
||||||
|
/// </summary>
|
||||||
|
private void PersistUiPrefs()
|
||||||
|
{
|
||||||
|
// Theme isn't owned by this VM — read whatever ThemeManager has
|
||||||
|
// persisted (or default) so we don't clobber it on save.
|
||||||
|
var existing = UIPreferences.Load();
|
||||||
|
UIPreferences.Save(new UIPreferences.Prefs(
|
||||||
|
HideLocalSelf: _hideLocalSelf,
|
||||||
|
AutoDisableOnDeparture: _autoDisableOnDeparture,
|
||||||
|
ParticipantSort: _participantSort,
|
||||||
|
MinimizeToTray: _minimizeToTray,
|
||||||
|
ControlSurfaceLanReachable: _controlSurfaceLanReachable,
|
||||||
|
LaunchTeamsOnStartup: _launchTeamsOnStartup,
|
||||||
|
AutoHideTeamsWindows: _autoHideTeamsWindows,
|
||||||
|
EmbedTeamsWindow: _embedTeamsWindow,
|
||||||
|
Theme: existing.Theme,
|
||||||
|
ControlSurfaceEnabled: _controlSurfaceEnabled));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Auto-launch the Microsoft Teams desktop client when TeamsISO starts.
|
||||||
|
/// Paired with <see cref="AutoHideTeamsWindows"/> gives the operator a
|
||||||
|
/// "TeamsISO is the only window I see" experience — Teams runs in the
|
||||||
|
/// background, all interaction happens through the participants DataGrid
|
||||||
|
/// + IN-CALL bar.
|
||||||
|
/// </summary>
|
||||||
|
public bool LaunchTeamsOnStartup
|
||||||
|
{
|
||||||
|
get => _launchTeamsOnStartup;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (SetField(ref _launchTeamsOnStartup, value)) PersistUiPrefs();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Auto-hide Teams' top-level windows as soon as they materialize after
|
||||||
|
/// a launch (whether triggered via <see cref="LaunchTeamsOnStartup"/>,
|
||||||
|
/// the rail button, or the eye-toggle). Runs a brief background poll
|
||||||
|
/// that calls <c>TeamsLauncher.HideWindows</c> every ~250ms for up to
|
||||||
|
/// 15 seconds, catching splash + main + follow-up panels.
|
||||||
|
/// </summary>
|
||||||
|
public bool AutoHideTeamsWindows
|
||||||
|
{
|
||||||
|
get => _autoHideTeamsWindows;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (SetField(ref _autoHideTeamsWindows, value)) PersistUiPrefs();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AutoRecordOnCall / RecordIsosToDisk / RecordingDirectory properties
|
||||||
|
// removed alongside the rest of the recording surface.
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// EXPERIMENTAL: SetParent-reparents Teams' main window into a TeamsISO-
|
||||||
|
/// owned host so Teams visually appears inside our window. WebView2 in
|
||||||
|
/// modern Teams may render weirdly after reparent — if so, untick and
|
||||||
|
/// fall back to the auto-hide flow. Polling logic in MainWindow.xaml.cs
|
||||||
|
/// applies / restores the embed; this property is just the persisted
|
||||||
|
/// toggle.
|
||||||
|
/// </summary>
|
||||||
|
public bool EmbedTeamsWindow
|
||||||
|
{
|
||||||
|
get => _embedTeamsWindow;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (SetField(ref _embedTeamsWindow, value)) PersistUiPrefs();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// REST control surface — Stream Deck / Companion / thin-client controllers.
|
||||||
|
/// Off by default. Bind address depends on <see cref="ControlSurfaceLanReachable"/>:
|
||||||
|
/// loopback (127.0.0.1) by default, or all-interfaces when LAN-reachable.
|
||||||
|
/// </summary>
|
||||||
|
public bool ControlSurfaceEnabled
|
||||||
|
{
|
||||||
|
get => _controlSurfaceEnabled;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (!SetField(ref _controlSurfaceEnabled, value)) return;
|
||||||
|
PersistUiPrefs();
|
||||||
|
var srv = (Application.Current as App)?.ControlSurface;
|
||||||
|
if (srv is null) return;
|
||||||
|
if (value) srv.Start(_controlSurfacePort, _controlSurfaceLanReachable);
|
||||||
|
else srv.Stop();
|
||||||
|
_toast?.Show(value
|
||||||
|
? $"Control surface listening on {ControlSurfaceUrl}"
|
||||||
|
: "Control surface stopped");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Port the control surface binds to. Editable while the surface is off; while on,
|
||||||
|
/// changing the port stops + restarts the listener on the new port.
|
||||||
|
/// </summary>
|
||||||
|
public int ControlSurfacePort
|
||||||
|
{
|
||||||
|
get => _controlSurfacePort;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (!SetField(ref _controlSurfacePort, value)) return;
|
||||||
|
OnPropertyChanged(nameof(ControlSurfaceUrl));
|
||||||
|
if (!_controlSurfaceEnabled) return;
|
||||||
|
var srv = (Application.Current as App)?.ControlSurface;
|
||||||
|
srv?.Start(value, _controlSurfaceLanReachable);
|
||||||
|
_toast?.Show($"Control surface restarted on {ControlSurfaceUrl}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// LAN-reachable mode. When false (default), control surface binds to
|
||||||
|
/// 127.0.0.1 — only this machine. When true, binds to all interfaces so
|
||||||
|
/// a thin-client controller on a phone or another laptop can drive
|
||||||
|
/// TeamsISO. The OSC bridge follows suit if it's running.
|
||||||
|
///
|
||||||
|
/// Important: HttpListener requires either Administrator privilege OR a
|
||||||
|
/// one-time URL ACL reservation for non-loopback prefixes:
|
||||||
|
/// <code>netsh http add urlacl url=http://+:9755/ user=Everyone</code>
|
||||||
|
/// (run from an elevated PowerShell). Without that the listener throws
|
||||||
|
/// AccessDeniedException on Start; the failure surfaces as a logger
|
||||||
|
/// warning with the exact netsh command.
|
||||||
|
/// </summary>
|
||||||
|
public bool ControlSurfaceLanReachable
|
||||||
|
{
|
||||||
|
get => _controlSurfaceLanReachable;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (!SetField(ref _controlSurfaceLanReachable, value)) return;
|
||||||
|
PersistUiPrefs();
|
||||||
|
OnPropertyChanged(nameof(ControlSurfaceUrl));
|
||||||
|
if (!_controlSurfaceEnabled) return;
|
||||||
|
var srv = (Application.Current as App)?.ControlSurface;
|
||||||
|
srv?.Start(_controlSurfacePort, value);
|
||||||
|
var osc = (Application.Current as App)?.OscBridge;
|
||||||
|
if (osc?.IsRunning == true) osc.Start(_oscBridgePort, value);
|
||||||
|
_toast?.Show(value
|
||||||
|
? $"Control surface now LAN-reachable: {ControlSurfaceUrl}"
|
||||||
|
: "Control surface now loopback-only");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Friendly URL of the running surface, for the settings panel + status
|
||||||
|
/// bar tooltip. Resolves to the first non-loopback IPv4 address when
|
||||||
|
/// LAN-reachable; loopback otherwise. Computed on demand because the
|
||||||
|
/// LAN IP may change between settings opens (Wi-Fi swap, VPN connect).
|
||||||
|
/// </summary>
|
||||||
|
public string ControlSurfaceUrl
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
var host = _controlSurfaceLanReachable
|
||||||
|
? GetLanIPv4() ?? "127.0.0.1"
|
||||||
|
: "127.0.0.1";
|
||||||
|
return $"http://{host}:{_controlSurfacePort}/ui";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Best-effort routable IPv4 address suitable for showing the operator a
|
||||||
|
/// "paste me into the thin client" URL. Skips:
|
||||||
|
/// • loopback interfaces (127.x)
|
||||||
|
/// • tunnel/virtual interfaces (NetworkInterfaceType.Tunnel — e.g. WSL,
|
||||||
|
/// Hyper-V, Tailscale, OpenVPN-style virtuals)
|
||||||
|
/// • APIPA/link-local addresses (169.254.x — assigned when DHCP fails;
|
||||||
|
/// a host with one of these AND a real DHCP lease should pick the lease)
|
||||||
|
/// Prefers Ethernet/Wi-Fi over everything else, then falls back to the
|
||||||
|
/// first non-link-local non-loopback IPv4. Returns null only if no
|
||||||
|
/// usable address exists at all.
|
||||||
|
/// </summary>
|
||||||
|
private static string? GetLanIPv4()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
string? linkLocalFallback = null;
|
||||||
|
string? otherFallback = null;
|
||||||
|
foreach (var ni in System.Net.NetworkInformation.NetworkInterface.GetAllNetworkInterfaces())
|
||||||
|
{
|
||||||
|
if (ni.OperationalStatus != System.Net.NetworkInformation.OperationalStatus.Up) continue;
|
||||||
|
if (ni.NetworkInterfaceType == System.Net.NetworkInformation.NetworkInterfaceType.Loopback) continue;
|
||||||
|
if (ni.NetworkInterfaceType == System.Net.NetworkInformation.NetworkInterfaceType.Tunnel) continue;
|
||||||
|
var isPhysical =
|
||||||
|
ni.NetworkInterfaceType == System.Net.NetworkInformation.NetworkInterfaceType.Ethernet ||
|
||||||
|
ni.NetworkInterfaceType == System.Net.NetworkInformation.NetworkInterfaceType.GigabitEthernet ||
|
||||||
|
ni.NetworkInterfaceType == System.Net.NetworkInformation.NetworkInterfaceType.Wireless80211;
|
||||||
|
|
||||||
|
foreach (var ua in ni.GetIPProperties().UnicastAddresses)
|
||||||
|
{
|
||||||
|
if (ua.Address.AddressFamily != System.Net.Sockets.AddressFamily.InterNetwork) continue;
|
||||||
|
if (System.Net.IPAddress.IsLoopback(ua.Address)) continue;
|
||||||
|
var addr = ua.Address.ToString();
|
||||||
|
var isLinkLocal = addr.StartsWith("169.254.", StringComparison.Ordinal);
|
||||||
|
|
||||||
|
if (isPhysical && !isLinkLocal) return addr; // best
|
||||||
|
if (!isLinkLocal) otherFallback ??= addr; // routable but virtual NIC
|
||||||
|
if (isLinkLocal) linkLocalFallback ??= addr; // worst
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return otherFallback ?? linkLocalFallback;
|
||||||
|
}
|
||||||
|
catch { /* best-effort */ }
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// OSC bridge over UDP — same command surface as the REST endpoints,
|
||||||
|
/// reachable from Companion / TouchOSC / lighting consoles. Off by default;
|
||||||
|
/// bound to 127.0.0.1 only.
|
||||||
|
/// </summary>
|
||||||
|
public bool OscBridgeEnabled
|
||||||
|
{
|
||||||
|
get => _oscBridgeEnabled;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (!SetField(ref _oscBridgeEnabled, value)) return;
|
||||||
|
var bridge = (Application.Current as App)?.OscBridge;
|
||||||
|
if (bridge is null) return;
|
||||||
|
if (value) bridge.Start(_oscBridgePort, _controlSurfaceLanReachable);
|
||||||
|
else bridge.Stop();
|
||||||
|
var host = _controlSurfaceLanReachable ? "0.0.0.0" : "127.0.0.1";
|
||||||
|
_toast?.Show(value
|
||||||
|
? $"OSC bridge listening on udp://{host}:{_oscBridgePort}/"
|
||||||
|
: "OSC bridge stopped");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>OSC bridge UDP port. Default 9000 (TouchOSC's default).</summary>
|
||||||
|
public int OscBridgePort
|
||||||
|
{
|
||||||
|
get => _oscBridgePort;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (!SetField(ref _oscBridgePort, value)) return;
|
||||||
|
if (!_oscBridgeEnabled) return;
|
||||||
|
var bridge = (Application.Current as App)?.OscBridge;
|
||||||
|
bridge?.Start(value, _controlSurfaceLanReachable);
|
||||||
|
var host = _controlSurfaceLanReachable ? "0.0.0.0" : "127.0.0.1";
|
||||||
|
_toast?.Show($"OSC bridge restarted on udp://{host}:{value}/");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Output-name template applied when the operator enables an ISO without
|
||||||
|
/// a per-participant CustomName. Default <c>"{name}"</c> renders the
|
||||||
|
/// speaker's display name directly (changed from the legacy
|
||||||
|
/// <c>"TEAMSISO_{guid}"</c> in 0.9.0-rc19, since downstream switchers
|
||||||
|
/// almost always want human-readable identifiers). Switch back to a
|
||||||
|
/// guid-based template if you need stable IDs that survive participant
|
||||||
|
/// name changes. See <see cref="OutputNameTemplate"/> for the supported
|
||||||
|
/// tokens.
|
||||||
|
/// </summary>
|
||||||
|
public string OutputNameTemplate
|
||||||
|
{
|
||||||
|
get => _outputNameTemplate;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (SetField(ref _outputNameTemplate, value))
|
||||||
|
{
|
||||||
|
TeamsISO.App.Services.OutputNameTemplate.Set(value ?? string.Empty);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Background update check on launch. Throttled to once per 24h via a
|
||||||
|
/// timestamp file. When a newer release is found, surfaces a non-modal
|
||||||
|
/// banner with a "Get update" button. Off-by-default would be friendlier
|
||||||
|
/// for paranoid setups; on-by-default is friendlier for adoption.
|
||||||
|
/// </summary>
|
||||||
|
public bool UpdateCheckOnLaunch
|
||||||
|
{
|
||||||
|
get => _updateCheckOnLaunch;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (SetField(ref _updateCheckOnLaunch, value))
|
||||||
|
{
|
||||||
|
UpdateChecker.LaunchCheckEnabled = value;
|
||||||
|
_toast?.Show(value
|
||||||
|
? "Update checks enabled — runs once per 24h on launch"
|
||||||
|
: "Update checks disabled");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// On launch, automatically re-apply the most recently applied operator preset.
|
||||||
|
/// Closes the loop on the recurring-show workflow: the operator clicks Apply
|
||||||
|
/// once, and from that point on TeamsISO restores the same routing on every
|
||||||
|
/// subsequent launch as soon as the matching participants come online.
|
||||||
|
/// Persisted to <c>presets.json</c>'s <c>AutoApplyOnStartup</c> field via
|
||||||
|
/// <see cref="OperatorPresetStore"/>.
|
||||||
|
/// </summary>
|
||||||
|
public bool AutoApplyLastPreset
|
||||||
|
{
|
||||||
|
get => _autoApplyLastPreset;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (SetField(ref _autoApplyLastPreset, value))
|
||||||
|
{
|
||||||
|
try { OperatorPresetStore.SetAutoApplyOnStartup(value); }
|
||||||
|
catch { /* best-effort */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public AsyncRelayCommand ApplyCommand { get; }
|
public AsyncRelayCommand ApplyCommand { get; }
|
||||||
|
|
||||||
private Task ApplyAsync()
|
/// <summary>
|
||||||
|
/// Copy the current control-surface URL to the clipboard. Operators on a
|
||||||
|
/// thin-client setup tap this, then paste into a phone browser. Bound to
|
||||||
|
/// a small button next to the LAN-reachable toggle.
|
||||||
|
/// </summary>
|
||||||
|
public RelayCommand CopyControlSurfaceUrlCommand { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Restore Output settings (framerate, resolution, aspect, audio) to the engine's
|
||||||
|
/// shipping defaults. Doesn't touch NDI groups (those are intentionally sticky —
|
||||||
|
/// the operator's transcoder topology is a per-machine setting that survives
|
||||||
|
/// preferences resets) and doesn't touch Display toggles. Confirms first.
|
||||||
|
/// </summary>
|
||||||
|
public RelayCommand ResetOutputDefaultsCommand { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One-click "set up the transcoder topology" — writes ndi-config.v1.json so all
|
||||||
|
/// local senders broadcast on a private group ("teamsiso-input") while local
|
||||||
|
/// receivers can see both that and "public", then sets the engine's discovery and
|
||||||
|
/// output groups to align (engine receives from the private group, emits on Public).
|
||||||
|
/// User has to restart Teams for the new ndi-config.v1.json to take effect there.
|
||||||
|
/// </summary>
|
||||||
|
public AsyncRelayCommand ApplyTranscoderTopologyCommand { get; }
|
||||||
|
|
||||||
|
private async Task ApplyAsync()
|
||||||
{
|
{
|
||||||
var settings = new FrameProcessingSettings(_framerate, _resolution, _aspect, _audio);
|
var settings = new FrameProcessingSettings(_framerate, _resolution, _aspect, _audio);
|
||||||
return _controller.SetGlobalSettingsAsync(settings, CancellationToken.None);
|
await _controller.SetGlobalSettingsAsync(settings, CancellationToken.None);
|
||||||
|
|
||||||
|
var groups = new NdiGroupSettings(
|
||||||
|
DiscoveryGroups: string.IsNullOrWhiteSpace(_discoveryGroups) ? null : _discoveryGroups.Trim(),
|
||||||
|
OutputGroups: string.IsNullOrWhiteSpace(_outputGroups) ? null : _outputGroups.Trim());
|
||||||
|
await _controller.SetGroupSettingsAsync(groups, CancellationToken.None);
|
||||||
|
|
||||||
|
_toast?.Show("Settings saved");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ApplyTranscoderTopologyAsync()
|
||||||
|
{
|
||||||
|
// 1. Update the machine-wide NDI config so Teams' raw broadcasts go to the
|
||||||
|
// private group instead of polluting Public.
|
||||||
|
var result = NdiAccessManagerConfig.ApplyTranscoderTopology();
|
||||||
|
if (!result.Success)
|
||||||
|
{
|
||||||
|
MessageBox.Show(
|
||||||
|
$"Could not write NDI Access Manager config.\n\n{result.ErrorMessage}\n\nPath: {result.ConfigPath}",
|
||||||
|
"TeamsISO — Apply transcoder topology",
|
||||||
|
MessageBoxButton.OK,
|
||||||
|
MessageBoxImage.Warning);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Update the engine: receive only from the private group, emit on Public.
|
||||||
|
var ourGroups = new NdiGroupSettings(
|
||||||
|
DiscoveryGroups: NdiAccessManagerConfig.TranscoderInputGroup,
|
||||||
|
OutputGroups: "public");
|
||||||
|
await _controller.SetGroupSettingsAsync(ourGroups, CancellationToken.None);
|
||||||
|
|
||||||
|
// 3. Reflect the new values in the bound text boxes.
|
||||||
|
DiscoveryGroups = NdiAccessManagerConfig.TranscoderInputGroup;
|
||||||
|
OutputGroups = "public";
|
||||||
|
|
||||||
|
var backupNote = result.BackupPath is null
|
||||||
|
? "No prior NDI config existed; a fresh one was created."
|
||||||
|
: $"A backup of your prior NDI config was saved to:\n{result.BackupPath}";
|
||||||
|
|
||||||
|
MessageBox.Show(
|
||||||
|
"Transcoder topology applied. ✓\n\n" +
|
||||||
|
"• Local senders (Teams, etc.) will broadcast on group 'teamsiso-input'.\n" +
|
||||||
|
"• Local receivers will see both 'public' and 'teamsiso-input'.\n" +
|
||||||
|
"• TeamsISO will discover from 'teamsiso-input' and re-emit on 'public'.\n\n" +
|
||||||
|
"RESTART Microsoft Teams for the new NDI config to take effect there.\n\n" +
|
||||||
|
backupNote,
|
||||||
|
"TeamsISO — Apply transcoder topology",
|
||||||
|
MessageBoxButton.OK,
|
||||||
|
MessageBoxImage.Information);
|
||||||
|
|
||||||
|
_toast?.Show("Transcoder topology applied — restart Teams to take effect");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
135
src/TeamsISO.App/ViewModels/IsoOverrideDialogViewModel.cs
Normal file
135
src/TeamsISO.App/ViewModels/IsoOverrideDialogViewModel.cs
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
using System;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using TeamsISO.Engine.Controller;
|
||||||
|
using TeamsISO.Engine.Domain;
|
||||||
|
|
||||||
|
namespace TeamsISO.App.ViewModels;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// View-model for the per-participant <see cref="FrameProcessingSettings"/> override
|
||||||
|
/// editor. Holds the operator's in-progress choice of framerate / resolution /
|
||||||
|
/// aspect / audio for a single participant, and exposes Apply / Clear / Cancel
|
||||||
|
/// commands that drive the engine through <see cref="IIsoController"/>.
|
||||||
|
///
|
||||||
|
/// "Following global settings" state is reflected via <see cref="HasOverride"/>,
|
||||||
|
/// which is true exactly when the participant currently has a non-null override
|
||||||
|
/// on the engine side (i.e. their pipeline beats the global defaults).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class IsoOverrideDialogViewModel : ObservableObject
|
||||||
|
{
|
||||||
|
private readonly IIsoController _controller;
|
||||||
|
private readonly ToastViewModel? _toast;
|
||||||
|
private TargetFramerate _framerate;
|
||||||
|
private TargetResolution _resolution;
|
||||||
|
private AspectMode _aspect;
|
||||||
|
private AudioMode _audio;
|
||||||
|
private bool _hasOverride;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Participant identity. The engine API is Guid-keyed; we cache the display
|
||||||
|
/// name once at construction so the dialog title doesn't need to re-resolve
|
||||||
|
/// the participant from the live list (and so it survives the participant
|
||||||
|
/// going offline mid-dialog).
|
||||||
|
/// </summary>
|
||||||
|
public Guid ParticipantId { get; }
|
||||||
|
public string DisplayName { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reference to the existing <see cref="GlobalSettingsViewModel"/> so the
|
||||||
|
/// dialog's ComboBoxes can bind directly to its Available* lists — there's
|
||||||
|
/// no point duplicating the Enum.GetValues calls here.
|
||||||
|
/// </summary>
|
||||||
|
public GlobalSettingsViewModel Settings { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Action the dialog code-behind wires up so the VM can close the window
|
||||||
|
/// after Apply / Clear / Cancel without taking a Window dependency.
|
||||||
|
/// </summary>
|
||||||
|
public Action? RequestClose { get; set; }
|
||||||
|
|
||||||
|
public IsoOverrideDialogViewModel(
|
||||||
|
IIsoController controller,
|
||||||
|
GlobalSettingsViewModel settings,
|
||||||
|
Guid participantId,
|
||||||
|
string displayName,
|
||||||
|
FrameProcessingSettings? currentOverride,
|
||||||
|
ToastViewModel? toast = null)
|
||||||
|
{
|
||||||
|
_controller = controller;
|
||||||
|
Settings = settings;
|
||||||
|
_toast = toast;
|
||||||
|
ParticipantId = participantId;
|
||||||
|
DisplayName = displayName;
|
||||||
|
|
||||||
|
// Initialize the four enum values from the existing override (if any)
|
||||||
|
// or fall back to the global settings — the dialog should always open
|
||||||
|
// with values that already reflect what this pipeline is using.
|
||||||
|
var source = currentOverride ?? new FrameProcessingSettings(
|
||||||
|
settings.Framerate,
|
||||||
|
settings.Resolution,
|
||||||
|
settings.Aspect,
|
||||||
|
settings.Audio);
|
||||||
|
|
||||||
|
_framerate = source.Framerate;
|
||||||
|
_resolution = source.Resolution;
|
||||||
|
_aspect = source.Aspect;
|
||||||
|
_audio = source.Audio;
|
||||||
|
_hasOverride = currentOverride is not null;
|
||||||
|
|
||||||
|
ApplyCommand = new AsyncRelayCommand(ApplyAsync);
|
||||||
|
ClearCommand = new AsyncRelayCommand(ClearAsync, () => _hasOverride);
|
||||||
|
CancelCommand = new RelayCommand(() => RequestClose?.Invoke());
|
||||||
|
}
|
||||||
|
|
||||||
|
public TargetFramerate Framerate { get => _framerate; set => SetField(ref _framerate, value); }
|
||||||
|
public TargetResolution Resolution { get => _resolution; set => SetField(ref _resolution, value); }
|
||||||
|
public AspectMode Aspect { get => _aspect; set => SetField(ref _aspect, value); }
|
||||||
|
public AudioMode Audio { get => _audio; set => SetField(ref _audio, value); }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True when the participant currently has a non-null override (i.e. this
|
||||||
|
/// dialog opened on an already-overridden pipeline). Toggles the visibility
|
||||||
|
/// of the "Following global settings" indicator and gates the
|
||||||
|
/// <see cref="ClearCommand"/>.
|
||||||
|
/// </summary>
|
||||||
|
public bool HasOverride
|
||||||
|
{
|
||||||
|
get => _hasOverride;
|
||||||
|
private set
|
||||||
|
{
|
||||||
|
if (SetField(ref _hasOverride, value))
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(FollowingGlobalsVisible));
|
||||||
|
ClearCommand.RaiseCanExecuteChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Convenience for XAML visibility binding — true when we should show the
|
||||||
|
/// "Following global settings · Reset to global" affordance.
|
||||||
|
/// </summary>
|
||||||
|
public bool FollowingGlobalsVisible => _hasOverride;
|
||||||
|
|
||||||
|
public AsyncRelayCommand ApplyCommand { get; }
|
||||||
|
public AsyncRelayCommand ClearCommand { get; }
|
||||||
|
public RelayCommand CancelCommand { get; }
|
||||||
|
|
||||||
|
private async Task ApplyAsync()
|
||||||
|
{
|
||||||
|
var settings = new FrameProcessingSettings(_framerate, _resolution, _aspect, _audio);
|
||||||
|
await _controller.SetIsoOverrideAsync(ParticipantId, settings, CancellationToken.None);
|
||||||
|
HasOverride = true;
|
||||||
|
_toast?.Show($"Override saved for {DisplayName}");
|
||||||
|
RequestClose?.Invoke();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ClearAsync()
|
||||||
|
{
|
||||||
|
await _controller.SetIsoOverrideAsync(ParticipantId, null, CancellationToken.None);
|
||||||
|
HasOverride = false;
|
||||||
|
_toast?.Show($"{DisplayName} now follows global settings");
|
||||||
|
RequestClose?.Invoke();
|
||||||
|
}
|
||||||
|
}
|
||||||
149
src/TeamsISO.App/ViewModels/MainViewModel.BulkCommands.cs
Normal file
149
src/TeamsISO.App/ViewModels/MainViewModel.BulkCommands.cs
Normal file
|
|
@ -0,0 +1,149 @@
|
||||||
|
using TeamsISO.App.Services;
|
||||||
|
|
||||||
|
namespace TeamsISO.App.ViewModels;
|
||||||
|
|
||||||
|
// Bulk operations that touch every (or every-enabled) participant —
|
||||||
|
// Stop all ISOs, Enable all online, Snapshot all enabled frames.
|
||||||
|
// Split out of MainViewModel.cs so the main file isn't dominated by
|
||||||
|
// long async iteration loops.
|
||||||
|
//
|
||||||
|
// The RecordingCommands partial originally planned at this slot is
|
||||||
|
// intentionally absent: the recording surface was axed earlier in the
|
||||||
|
// May 2026 batch (see commit 1d1ce6a). What remains is bulk-state
|
||||||
|
// manipulation across the participants collection.
|
||||||
|
public sealed partial class MainViewModel
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Enable ISOs for every online + non-enabled participant in
|
||||||
|
/// parallel-ish (sequential await, but each individual EnableIsoAsync
|
||||||
|
/// is fast). Tolerates per-participant failures so one bad source
|
||||||
|
/// doesn't abort the rest.
|
||||||
|
/// </summary>
|
||||||
|
private async Task EnableAllOnlineAsync()
|
||||||
|
{
|
||||||
|
var candidates = Participants.Where(p => p.IsOnline && !p.IsEnabled).ToArray();
|
||||||
|
var enabled = 0;
|
||||||
|
foreach (var p in candidates)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var resolvedName = string.IsNullOrWhiteSpace(p.CustomName)
|
||||||
|
? OutputNameTemplate.Render(
|
||||||
|
OutputNameTemplate.Get(),
|
||||||
|
p.Id,
|
||||||
|
p.DisplayName)
|
||||||
|
: p.CustomName;
|
||||||
|
// 3-arg overload (no recordOverride) — recording surface axed,
|
||||||
|
// so the engine's per-pipeline recorder sink stays unattached.
|
||||||
|
await _controller.EnableIsoAsync(p.Id, resolvedName, CancellationToken.None);
|
||||||
|
p.IsEnabled = true;
|
||||||
|
enabled++;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Per-participant best-effort — one bad source shouldn't
|
||||||
|
// abort the bulk operation.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Toast.Show(enabled == 0
|
||||||
|
? "No participants to enable"
|
||||||
|
: $"Enabled {enabled} ISO(s)");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Emergency-stop: disable every running ISO. Confirmation dialog with
|
||||||
|
/// default-No guards mid-show misclicks; the regret cost of yanking 5
|
||||||
|
/// ISOs is far higher than the Enter-press cost of the prompt.
|
||||||
|
/// </summary>
|
||||||
|
private async Task StopAllIsosAsync()
|
||||||
|
{
|
||||||
|
// Snapshot first so the collection doesn't mutate while we iterate.
|
||||||
|
var enabled = Participants.Where(p => p.IsEnabled).ToArray();
|
||||||
|
if (enabled.Length == 0)
|
||||||
|
{
|
||||||
|
Toast.Show("No ISOs to stop");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var confirm = System.Windows.MessageBox.Show(
|
||||||
|
$"Stop {enabled.Length} running ISO(s)?\n\nThis tears down every active pipeline immediately.",
|
||||||
|
"TeamsISO — Stop all ISOs",
|
||||||
|
System.Windows.MessageBoxButton.YesNo,
|
||||||
|
System.Windows.MessageBoxImage.Warning,
|
||||||
|
System.Windows.MessageBoxResult.No);
|
||||||
|
if (confirm != System.Windows.MessageBoxResult.Yes) return;
|
||||||
|
|
||||||
|
foreach (var p in enabled)
|
||||||
|
{
|
||||||
|
try { await _controller.DisableIsoAsync(p.Id, CancellationToken.None); }
|
||||||
|
catch { /* defensive */ }
|
||||||
|
p.IsEnabled = false;
|
||||||
|
}
|
||||||
|
Toast.Show($"Stopped {enabled.Length} ISO(s)");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Save a PNG of every currently-enabled participant's latest
|
||||||
|
/// processed frame to a timestamped subdirectory under
|
||||||
|
/// %USERPROFILE%\Pictures\TeamsISO\. One folder per Snapshot All click
|
||||||
|
/// so back-to-back clicks don't comingle. Useful for end-of-meeting
|
||||||
|
/// archives, recapping who showed up, etc.
|
||||||
|
/// </summary>
|
||||||
|
private void SnapshotAll()
|
||||||
|
{
|
||||||
|
var enabled = Participants.Where(p => p.IsEnabled).ToArray();
|
||||||
|
if (enabled.Length == 0)
|
||||||
|
{
|
||||||
|
Toast.Warn("No enabled participants to snapshot");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var rootDir = System.IO.Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.MyPictures),
|
||||||
|
"TeamsISO",
|
||||||
|
$"snapshots-{DateTimeOffset.Now:yyyyMMdd_HHmmss}");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
System.IO.Directory.CreateDirectory(rootDir);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Toast.Warn($"Couldn't create snapshot dir: {ex.Message}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var saved = 0;
|
||||||
|
var failed = 0;
|
||||||
|
foreach (var p in enabled)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var frame = _controller.GetLatestProcessedFrame(p.Id);
|
||||||
|
if (frame is null || frame.Pixels.IsEmpty) { failed++; continue; }
|
||||||
|
|
||||||
|
var safeName = string.Join("_", p.DisplayName.Split(System.IO.Path.GetInvalidFileNameChars()));
|
||||||
|
if (string.IsNullOrWhiteSpace(safeName)) safeName = "participant";
|
||||||
|
var path = System.IO.Path.Combine(rootDir, $"{safeName}.png");
|
||||||
|
|
||||||
|
var stride = frame.Width * 4;
|
||||||
|
var bmp = new System.Windows.Media.Imaging.WriteableBitmap(
|
||||||
|
frame.Width, frame.Height, 96, 96,
|
||||||
|
System.Windows.Media.PixelFormats.Bgra32, null);
|
||||||
|
bmp.WritePixels(
|
||||||
|
new System.Windows.Int32Rect(0, 0, frame.Width, frame.Height),
|
||||||
|
frame.Pixels.ToArray(), stride, 0);
|
||||||
|
|
||||||
|
using var fs = new System.IO.FileStream(path, System.IO.FileMode.Create);
|
||||||
|
var encoder = new System.Windows.Media.Imaging.PngBitmapEncoder();
|
||||||
|
encoder.Frames.Add(System.Windows.Media.Imaging.BitmapFrame.Create(bmp));
|
||||||
|
encoder.Save(fs);
|
||||||
|
saved++;
|
||||||
|
}
|
||||||
|
catch { failed++; }
|
||||||
|
}
|
||||||
|
|
||||||
|
Toast.Show(failed > 0
|
||||||
|
? $"Saved {saved} snapshot(s) ({failed} failed) to Pictures\\TeamsISO\\{System.IO.Path.GetFileName(rootDir)}"
|
||||||
|
: $"Saved {saved} snapshot(s) to Pictures\\TeamsISO\\{System.IO.Path.GetFileName(rootDir)}");
|
||||||
|
}
|
||||||
|
}
|
||||||
108
src/TeamsISO.App/ViewModels/MainViewModel.PresetCommands.cs
Normal file
108
src/TeamsISO.App/ViewModels/MainViewModel.PresetCommands.cs
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
using System.Windows.Threading;
|
||||||
|
using TeamsISO.App.Services;
|
||||||
|
|
||||||
|
namespace TeamsISO.App.ViewModels;
|
||||||
|
|
||||||
|
// Auto-apply-last-preset path. Split out of MainViewModel.cs so the
|
||||||
|
// pending-preset bookkeeping doesn't clutter the main file.
|
||||||
|
//
|
||||||
|
// Lifecycle:
|
||||||
|
// • InitializeAsync (in main file) reads operator preference + last-applied
|
||||||
|
// name from OperatorPresetStore and sets _pendingPresetName + deadline.
|
||||||
|
// • OnParticipantsChanged (in main file) calls TryAutoApplyPendingPreset
|
||||||
|
// once participants populate.
|
||||||
|
// • RequestApplyPresetOnStartup is the CLI hook: `--apply-preset NAME`.
|
||||||
|
public sealed partial class MainViewModel
|
||||||
|
{
|
||||||
|
// Set on InitializeAsync from disk; cleared once we successfully apply
|
||||||
|
// (so we don't re-apply when the participant list later mutates). The
|
||||||
|
// grace deadline gives Teams enough time to publish all initial sources
|
||||||
|
// after engine start before we attempt the apply — applying before
|
||||||
|
// everyone's visible would partially-restore the routing and silently
|
||||||
|
// drop assignments for late-appearing participants.
|
||||||
|
private string? _pendingPresetName;
|
||||||
|
private DateTimeOffset _pendingPresetDeadline;
|
||||||
|
private bool _pendingPresetApplied;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// CLI override for the launch-time auto-apply. Called from App.OnStartup
|
||||||
|
/// after parsing <c>--apply-preset</c>. Sets the same pending-preset state
|
||||||
|
/// that the user-toggled auto-apply path uses, so a single trigger flow
|
||||||
|
/// covers both. Wins over the persisted preference (operator's CLI intent
|
||||||
|
/// is more recent than what's on disk).
|
||||||
|
/// </summary>
|
||||||
|
public void RequestApplyPresetOnStartup(string presetName)
|
||||||
|
{
|
||||||
|
_pendingPresetName = presetName;
|
||||||
|
_pendingPresetDeadline = DateTimeOffset.UtcNow.AddSeconds(30);
|
||||||
|
_pendingPresetApplied = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads the operator's auto-apply preference + last-applied preset name
|
||||||
|
/// from disk and seeds the pending-preset state. Called by InitializeAsync
|
||||||
|
/// during engine startup. Failures are swallowed — a preset read fault
|
||||||
|
/// should never block the engine from coming up.
|
||||||
|
/// </summary>
|
||||||
|
private void LoadPendingPresetFromPreferences()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var pref = OperatorPresetStore.GetStartupPreference();
|
||||||
|
if (pref.AutoApplyOnStartup && !string.IsNullOrEmpty(pref.LastAppliedName))
|
||||||
|
{
|
||||||
|
_pendingPresetName = pref.LastAppliedName;
|
||||||
|
// 30s grace window is generous: Teams typically advertises all
|
||||||
|
// existing participants within 5–10s of NDI discovery starting.
|
||||||
|
// After this deadline we apply with whoever is visible.
|
||||||
|
_pendingPresetDeadline = DateTimeOffset.UtcNow.AddSeconds(30);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { /* preset read failures shouldn't block engine startup */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Attempts to apply <c>_pendingPresetName</c> if either every preset
|
||||||
|
/// assignment matches a live participant, or the grace deadline has
|
||||||
|
/// passed. Idempotent — repeat calls without state change are no-ops;
|
||||||
|
/// once we fire we flag <c>_pendingPresetApplied</c> so subsequent
|
||||||
|
/// participant churn doesn't trigger a second apply. Failures (missing
|
||||||
|
/// preset on disk, preset that no longer matches anyone) are swallowed:
|
||||||
|
/// the operator can always re-apply manually via the dialog. Delegates
|
||||||
|
/// to <see cref="PresetApplier.ApplyAsync"/> for the actual
|
||||||
|
/// reconciliation so the dialog, REST surface, and this auto-apply path
|
||||||
|
/// all share a single implementation.
|
||||||
|
/// </summary>
|
||||||
|
private void TryAutoApplyPendingPreset()
|
||||||
|
{
|
||||||
|
OperatorPresetStore.Preset? preset;
|
||||||
|
try { preset = OperatorPresetStore.Find(_pendingPresetName!); }
|
||||||
|
catch { preset = null; }
|
||||||
|
if (preset is null)
|
||||||
|
{
|
||||||
|
_pendingPresetApplied = true; // give up; nothing on disk to apply
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var liveNames = new HashSet<string>(
|
||||||
|
Participants.Select(p => p.DisplayName),
|
||||||
|
StringComparer.OrdinalIgnoreCase);
|
||||||
|
var allPresent = preset.Assignments.All(a => liveNames.Contains(a.DisplayName));
|
||||||
|
if (!allPresent && DateTimeOffset.UtcNow < _pendingPresetDeadline)
|
||||||
|
return; // wait for the rest of the meeting to populate
|
||||||
|
|
||||||
|
_pendingPresetApplied = true;
|
||||||
|
var captured = preset;
|
||||||
|
// Snapshot the participants list since we're about to await on a
|
||||||
|
// worker thread; the live ObservableCollection isn't safe to
|
||||||
|
// enumerate from outside the dispatcher.
|
||||||
|
var snapshot = Participants.ToList();
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
var result = await PresetApplier.ApplyAsync(
|
||||||
|
captured, snapshot, _controller, _dispatcher);
|
||||||
|
await _dispatcher.InvokeAsync(() =>
|
||||||
|
Toast.Show($"Auto-applied preset \"{captured.Name}\" — {result.Matched} participant(s)"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
130
src/TeamsISO.App/ViewModels/MainViewModel.TeamsCommands.cs
Normal file
130
src/TeamsISO.App/ViewModels/MainViewModel.TeamsCommands.cs
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
using System.Windows.Threading;
|
||||||
|
using TeamsISO.App.Services;
|
||||||
|
|
||||||
|
namespace TeamsISO.App.ViewModels;
|
||||||
|
|
||||||
|
// Teams launch / in-call / join-by-URL command helpers — split out of
|
||||||
|
// MainViewModel.cs so the body methods don't live alongside the
|
||||||
|
// constructor wiring + reactive subscriptions. The four command
|
||||||
|
// PROPERTIES are declared back in MainViewModel.cs (public API surface);
|
||||||
|
// this file holds the helpers they invoke.
|
||||||
|
public sealed partial class MainViewModel
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Wraps a <see cref="TeamsControlBridge"/> invocation in a RelayCommand
|
||||||
|
/// that translates the result to a user-visible toast. Centralizes the
|
||||||
|
/// toast wording so the four control commands stay consistent.
|
||||||
|
/// </summary>
|
||||||
|
private RelayCommand MakeTeamsCommand(string label, Func<TeamsControlBridge.InvokeResult> invoke, string successMessage)
|
||||||
|
{
|
||||||
|
return new RelayCommand(() =>
|
||||||
|
{
|
||||||
|
switch (invoke())
|
||||||
|
{
|
||||||
|
case TeamsControlBridge.InvokeResult.Invoked:
|
||||||
|
Toast.Show(successMessage);
|
||||||
|
break;
|
||||||
|
case TeamsControlBridge.InvokeResult.TeamsNotRunning:
|
||||||
|
Toast.Warn("Teams isn't running.");
|
||||||
|
break;
|
||||||
|
case TeamsControlBridge.InvokeResult.ControlNotFound:
|
||||||
|
Toast.Warn($"{label} control not found — are you in a call?");
|
||||||
|
break;
|
||||||
|
case TeamsControlBridge.InvokeResult.InvokeFailed:
|
||||||
|
Toast.Warn($"{label} button found but disabled.");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Body of <c>JoinMeetingCommand</c>. Trims the pasted URL, hands it to
|
||||||
|
/// <see cref="TeamsLauncher.TryJoinMeeting"/>, and runs the auto-hide
|
||||||
|
/// follow-up if the operator has that preference set.
|
||||||
|
/// </summary>
|
||||||
|
private void JoinPastedMeeting()
|
||||||
|
{
|
||||||
|
var url = (_joinMeetingUrl ?? string.Empty).Trim();
|
||||||
|
if (string.IsNullOrEmpty(url)) return;
|
||||||
|
if (TeamsLauncher.TryJoinMeeting(url, out var error))
|
||||||
|
{
|
||||||
|
Toast.Show("Joining Teams meeting…");
|
||||||
|
JoinMeetingUrl = string.Empty;
|
||||||
|
if (Settings.AutoHideTeamsWindows)
|
||||||
|
_ = TeamsLauncher.AutoHideAfterLaunchAsync();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Toast.Warn($"Could not join: {error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pull the meaningful "meeting title" out of Teams' raw window title.
|
||||||
|
/// Teams uses formats like:
|
||||||
|
/// "Weekly Standup | Microsoft Teams"
|
||||||
|
/// "Meeting with Alice | Microsoft Teams"
|
||||||
|
/// "Microsoft Teams" (no meeting, just the app)
|
||||||
|
/// Strip the trailing " | Microsoft Teams" so the IN-CALL pill stays
|
||||||
|
/// short and readable. Truncate beyond 50 chars so a long meeting
|
||||||
|
/// subject doesn't push the rest of the IN-CALL bar off screen.
|
||||||
|
/// </summary>
|
||||||
|
internal static string ExtractMeetingTitle(string windowTitle)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(windowTitle)) return string.Empty;
|
||||||
|
var t = windowTitle.Trim();
|
||||||
|
foreach (var sep in new[] { " | Microsoft Teams", " | Teams", " - Microsoft Teams", " - Teams" })
|
||||||
|
{
|
||||||
|
var idx = t.IndexOf(sep, StringComparison.Ordinal);
|
||||||
|
if (idx > 0) { t = t.Substring(0, idx).Trim(); break; }
|
||||||
|
}
|
||||||
|
if (t.Equals("Microsoft Teams", StringComparison.OrdinalIgnoreCase)) return string.Empty;
|
||||||
|
if (t.Length > 50) t = t.Substring(0, 47) + "…";
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Meeting-state probe — runs on every 1Hz stats tick. We fire the UIA
|
||||||
|
/// traversal on a worker thread because it can take 50–200ms in a busy
|
||||||
|
/// call; the result is marshalled back to the dispatcher to update the
|
||||||
|
/// view-model properties. One-tick latency on the displayed state is
|
||||||
|
/// preferable to a UI hiccup.
|
||||||
|
/// </summary>
|
||||||
|
private void PollTeamsMeetingState()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var teamsRunning = TeamsLauncher.IsRunning();
|
||||||
|
if (!teamsRunning)
|
||||||
|
{
|
||||||
|
TeamsMeetingState = string.Empty;
|
||||||
|
IsTeamsInCall = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = Task.Run(() =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Single UIA traversal returns all three signals (in-call /
|
||||||
|
// muted / camera-off) so we don't pay for three walks of
|
||||||
|
// the same descendant tree at 1Hz.
|
||||||
|
var snap = TeamsControlBridge.DetectCallState();
|
||||||
|
var inCall = snap.IsInCall;
|
||||||
|
var title = inCall ? ExtractMeetingTitle(TeamsLauncher.GetActiveWindowTitle()) : null;
|
||||||
|
_dispatcher.InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
IsTeamsInCall = inCall;
|
||||||
|
TeamsMeetingState = inCall
|
||||||
|
? (string.IsNullOrEmpty(title) ? "IN CALL" : $"IN CALL · {title}")
|
||||||
|
: "READY";
|
||||||
|
IsLocalMuted = inCall && (snap.IsMuted ?? false);
|
||||||
|
IsLocalCameraOff = inCall && (snap.IsCameraOff ?? false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch { /* UIA flakiness shouldn't crash the stats tick */ }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch { /* defensive — probe failures must never break the tick */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
|
using System.ComponentModel;
|
||||||
using System.Reactive.Concurrency;
|
using System.Reactive.Concurrency;
|
||||||
using System.Reactive.Linq;
|
using System.Reactive.Linq;
|
||||||
|
using System.Windows.Data;
|
||||||
using System.Windows.Threading;
|
using System.Windows.Threading;
|
||||||
|
using TeamsISO.App.Services;
|
||||||
using TeamsISO.Engine.Controller;
|
using TeamsISO.Engine.Controller;
|
||||||
using TeamsISO.Engine.Domain;
|
using TeamsISO.Engine.Domain;
|
||||||
|
|
||||||
|
|
@ -11,19 +14,216 @@ namespace TeamsISO.App.ViewModels;
|
||||||
/// Top-level view model for the main window. Owns the live collection of <see cref="ParticipantViewModel"/>,
|
/// Top-level view model for the main window. Owns the live collection of <see cref="ParticipantViewModel"/>,
|
||||||
/// the global settings panel, and the alert banner. Subscribes to <see cref="IIsoController"/>'s observables
|
/// the global settings panel, and the alert banner. Subscribes to <see cref="IIsoController"/>'s observables
|
||||||
/// and marshals updates onto the UI dispatcher.
|
/// and marshals updates onto the UI dispatcher.
|
||||||
|
///
|
||||||
|
/// Split across partial files by responsibility:
|
||||||
|
/// • <c>MainViewModel.cs</c> — fields, properties, constructor (wires commands), OnStatsTick, Dispose
|
||||||
|
/// • <c>MainViewModel.TeamsCommands.cs</c> — Mute / Cam / Leave / Share + Join URL + Teams meeting-state poll
|
||||||
|
/// • <c>MainViewModel.PresetCommands.cs</c> — auto-apply-last-preset path
|
||||||
|
/// • <c>MainViewModel.BulkCommands.cs</c> — Stop all / Enable all / Snapshot all
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class MainViewModel : ObservableObject, IDisposable
|
public sealed partial class MainViewModel : ObservableObject, IDisposable
|
||||||
{
|
{
|
||||||
private readonly IIsoController _controller;
|
private readonly IIsoController _controller;
|
||||||
private readonly Dispatcher _dispatcher;
|
private readonly Dispatcher _dispatcher;
|
||||||
private readonly IDisposable _participantsSub;
|
private readonly IDisposable _participantsSub;
|
||||||
private readonly IDisposable _alertsSub;
|
private readonly IDisposable _alertsSub;
|
||||||
|
private readonly DispatcherTimer _statsTimer;
|
||||||
private readonly Dictionary<Guid, ParticipantViewModel> _byId = new();
|
private readonly Dictionary<Guid, ParticipantViewModel> _byId = new();
|
||||||
private string _statusText = "Starting…";
|
private string _statusText = "Starting…";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Wall-clock at which <see cref="InitializeAsync"/> kicked the engine. Used to
|
||||||
|
/// gate the "Scanning for NDI sources…" placeholder so it shows for a few
|
||||||
|
/// seconds after launch even when ParticipantCount == 0 (the bleak
|
||||||
|
/// "no ndi sources yet" empty state was being shown immediately and
|
||||||
|
/// operators assumed the app was broken before discovery had a chance to fire).
|
||||||
|
/// Null until InitializeAsync runs.
|
||||||
|
/// </summary>
|
||||||
|
private DateTimeOffset? _engineStartedAt;
|
||||||
|
|
||||||
|
/// <summary>How long after engine start to keep showing "Scanning…" instead of the empty-state copy.</summary>
|
||||||
|
private static readonly TimeSpan DiscoveryGracePeriod = TimeSpan.FromSeconds(8);
|
||||||
|
|
||||||
|
// _pendingPresetName / Deadline / Applied + the auto-apply path
|
||||||
|
// moved to MainViewModel.PresetCommands.cs.
|
||||||
|
|
||||||
public ObservableCollection<ParticipantViewModel> Participants { get; } = new();
|
public ObservableCollection<ParticipantViewModel> Participants { get; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Filter-backed view over <see cref="Participants"/>. The DataGrid binds
|
||||||
|
/// to this rather than the raw collection so the operator's filter text
|
||||||
|
/// hides non-matching rows without mutating the underlying observable
|
||||||
|
/// (which would break IsoController's identity tracking).
|
||||||
|
/// </summary>
|
||||||
|
public ICollectionView ParticipantsView { get; }
|
||||||
|
|
||||||
|
private string _participantFilter = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Apply the operator's saved sort preference to <see cref="ParticipantsView"/>.
|
||||||
|
/// JoinOrder = no SortDescriptions (whatever order participants are added in);
|
||||||
|
/// Alphabetical = ascending by DisplayName; OnlineFirst = IsOnline desc then
|
||||||
|
/// DisplayName asc. Called on construction and from <see cref="SetSortMode"/>.
|
||||||
|
/// </summary>
|
||||||
|
private void ApplySortFromPrefs()
|
||||||
|
{
|
||||||
|
var prefs = Services.UIPreferences.Load();
|
||||||
|
SetSortMode(prefs.ParticipantSort);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Re-applies the sort descriptions on the ParticipantsView. Called from the
|
||||||
|
/// settings panel when the operator picks a different sort mode.
|
||||||
|
/// </summary>
|
||||||
|
public void SetSortMode(Services.UIPreferences.SortMode mode)
|
||||||
|
{
|
||||||
|
_currentSortMode = mode;
|
||||||
|
ParticipantsView.SortDescriptions.Clear();
|
||||||
|
switch (mode)
|
||||||
|
{
|
||||||
|
case Services.UIPreferences.SortMode.Alphabetical:
|
||||||
|
ParticipantsView.SortDescriptions.Add(new SortDescription(
|
||||||
|
nameof(ParticipantViewModel.DisplayName), ListSortDirection.Ascending));
|
||||||
|
break;
|
||||||
|
case Services.UIPreferences.SortMode.OnlineFirst:
|
||||||
|
ParticipantsView.SortDescriptions.Add(new SortDescription(
|
||||||
|
nameof(ParticipantViewModel.IsOnline), ListSortDirection.Descending));
|
||||||
|
ParticipantsView.SortDescriptions.Add(new SortDescription(
|
||||||
|
nameof(ParticipantViewModel.DisplayName), ListSortDirection.Ascending));
|
||||||
|
break;
|
||||||
|
case Services.UIPreferences.SortMode.LoudestFirst:
|
||||||
|
// Sort by the displayed audio level (which already includes the
|
||||||
|
// decay envelope) so participants don't snap-reorder on every
|
||||||
|
// tiny audio frame. ParticipantsView.Refresh() at the stats
|
||||||
|
// tick re-evaluates the sort with the latest values.
|
||||||
|
ParticipantsView.SortDescriptions.Add(new SortDescription(
|
||||||
|
nameof(ParticipantViewModel.DisplayedAudioLevel), ListSortDirection.Descending));
|
||||||
|
ParticipantsView.SortDescriptions.Add(new SortDescription(
|
||||||
|
nameof(ParticipantViewModel.DisplayName), ListSortDirection.Ascending));
|
||||||
|
break;
|
||||||
|
// JoinOrder: leave SortDescriptions empty.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private Services.UIPreferences.SortMode _currentSortMode = Services.UIPreferences.SortMode.JoinOrder;
|
||||||
|
/// <summary>
|
||||||
|
/// Live filter substring. Empty = show everyone. Matched case-insensitively
|
||||||
|
/// against display name. Setter refreshes the view immediately so the
|
||||||
|
/// DataGrid reflows as the operator types.
|
||||||
|
/// </summary>
|
||||||
|
public string ParticipantFilter
|
||||||
|
{
|
||||||
|
get => _participantFilter;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (SetField(ref _participantFilter, value))
|
||||||
|
ParticipantsView.Refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
public GlobalSettingsViewModel Settings { get; }
|
public GlobalSettingsViewModel Settings { get; }
|
||||||
public AlertBannerViewModel AlertBanner { get; } = new();
|
public AlertBannerViewModel AlertBanner { get; } = new();
|
||||||
|
public ToastViewModel Toast { get; }
|
||||||
|
public UpdateBannerViewModel UpdateBanner { get; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Engine-side controller. Exposed so the PresetsDialog (a Window, not a VM)
|
||||||
|
/// can re-issue EnableIsoAsync / DisableIsoAsync when applying a preset
|
||||||
|
/// without us having to plumb a per-action command through the participant
|
||||||
|
/// view-models from the dialog's XAML.
|
||||||
|
/// </summary>
|
||||||
|
internal IIsoController Controller => _controller;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Emergency-stop: disable every running ISO. Bound to a small "Stop all" affordance
|
||||||
|
/// near the participants header so an operator can kill all outputs in a single click
|
||||||
|
/// when something goes sideways during a live show.
|
||||||
|
/// </summary>
|
||||||
|
public AsyncRelayCommand StopAllIsosCommand { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bulk-enable: turn on ISOs for every online participant whose pipeline isn't
|
||||||
|
/// already running. Useful for "everyone joined, hit one button, every route goes
|
||||||
|
/// live." Skips offline rows (no source) and rows already enabled.
|
||||||
|
/// </summary>
|
||||||
|
public AsyncRelayCommand EnableAllOnlineCommand { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Force NDI discovery to rebuild its finder. Surfaced as a small "Refresh" pill
|
||||||
|
/// next to the participants header — useful right after Apply Transcoder Topology
|
||||||
|
/// or when Teams restarts mid-session and stale TTLs are masking new sources.
|
||||||
|
/// </summary>
|
||||||
|
public RelayCommand RefreshDiscoveryCommand { get; }
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════════════════
|
||||||
|
// Phase E.3 — In-call controls. Each command drives a UIAutomation lookup
|
||||||
|
// against Teams' window tree and reports a toast on outcome. Best-effort:
|
||||||
|
// a control-not-found result toasts a hint rather than throwing, since
|
||||||
|
// Teams isn't always in a call (the buttons only appear in-call).
|
||||||
|
// ════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
public RelayCommand ToggleMuteCommand { get; }
|
||||||
|
public RelayCommand ToggleCameraCommand { get; }
|
||||||
|
public RelayCommand LeaveCallCommand { get; }
|
||||||
|
public RelayCommand OpenShareTrayCommand { get; }
|
||||||
|
|
||||||
|
// Recording-marker and roll-recording commands removed — recording feature axed.
|
||||||
|
|
||||||
|
/// <summary>F1 binding — opens the help / cheat-sheet dialog.</summary>
|
||||||
|
public RelayCommand ShowHelpCommand { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ctrl+T binding — cycles dark ↔ light theme via ThemeManager.
|
||||||
|
/// Persists the operator's choice through UIPreferences.Theme.
|
||||||
|
/// The v2 header surfaces this as a click affordance too; the
|
||||||
|
/// command exists once so both bindings reach the same path.
|
||||||
|
/// </summary>
|
||||||
|
public RelayCommand ToggleThemeCommand { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ctrl+K binding — opens the v2 command palette. The actual window
|
||||||
|
/// open call lives in <see cref="MainWindow"/> (view-side concern);
|
||||||
|
/// this command delegates through an Action callback the view sets
|
||||||
|
/// after construction so the VM stays unaware of WPF Window types.
|
||||||
|
/// </summary>
|
||||||
|
public RelayCommand OpenCommandPaletteCommand { get; }
|
||||||
|
private Action? _openCommandPalette;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Wire the view's palette-opening callback. Called by MainWindow's
|
||||||
|
/// constructor right after DataContext is set. Idempotent — second
|
||||||
|
/// call replaces the first.
|
||||||
|
/// </summary>
|
||||||
|
public void RegisterCommandPaletteOpener(Action openPalette) =>
|
||||||
|
_openCommandPalette = openPalette;
|
||||||
|
|
||||||
|
/// <summary>Opens the inline notes viewer for today's show-notes file.</summary>
|
||||||
|
public RelayCommand ShowNotesCommand { get; }
|
||||||
|
|
||||||
|
/// <summary>Join a Teams meeting from a pasted URL — see <see cref="JoinMeetingUrl"/>.</summary>
|
||||||
|
public RelayCommand JoinMeetingCommand { get; }
|
||||||
|
|
||||||
|
/// <summary>Save a PNG snapshot of every enabled participant's current frame.</summary>
|
||||||
|
public RelayCommand SnapshotAllCommand { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Toggle the ISO for the Nth visible participant (1-based, matches the
|
||||||
|
/// numpad layout). Used by the NumPad1..NumPad9 hotkeys; resolves
|
||||||
|
/// against ParticipantsView so the index matches what the operator
|
||||||
|
/// sees in the current sort + filter.
|
||||||
|
/// </summary>
|
||||||
|
public RelayCommand<string> ToggleByIndexCommand { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Two-way bound to the quick-join input. Whatever the operator pastes
|
||||||
|
/// gets handed to <see cref="TeamsLauncher.TryJoinMeeting"/> when the
|
||||||
|
/// Join button fires. Cleared on success so the field is ready for the
|
||||||
|
/// next paste.
|
||||||
|
/// </summary>
|
||||||
|
public string JoinMeetingUrl
|
||||||
|
{
|
||||||
|
get => _joinMeetingUrl;
|
||||||
|
set => SetField(ref _joinMeetingUrl, value);
|
||||||
|
}
|
||||||
|
private string _joinMeetingUrl = string.Empty;
|
||||||
|
|
||||||
public string StatusText
|
public string StatusText
|
||||||
{
|
{
|
||||||
|
|
@ -31,16 +231,160 @@ public sealed class MainViewModel : ObservableObject, IDisposable
|
||||||
set => SetField(ref _statusText, value);
|
set => SetField(ref _statusText, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Recording-status properties (IsRecording, ActiveRecordingCount,
|
||||||
|
// RecordingElapsed, RecordingFreeSpace, IsLowDiskSpace) removed when the
|
||||||
|
// recording feature was axed.
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Total visible participants — feeds the v2 transport strip's "PART N"
|
||||||
|
/// readout. Updated on every 1Hz stats tick alongside <see cref="LiveCount"/>.
|
||||||
|
/// </summary>
|
||||||
|
public int ParticipantCount
|
||||||
|
{
|
||||||
|
get => _participantCount;
|
||||||
|
private set => SetField(ref _participantCount, value);
|
||||||
|
}
|
||||||
|
private int _participantCount;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True for the first <see cref="DiscoveryGracePeriod"/> after engine start.
|
||||||
|
/// The XAML uses this to swap the empty-state placeholder from the bleak
|
||||||
|
/// "no ndi sources yet — open teams and start a meeting" copy (which reads
|
||||||
|
/// as broken to operators who just launched into an active meeting) to a
|
||||||
|
/// neutral "Scanning for NDI sources…" status while NDI Find resolves
|
||||||
|
/// mDNS responses. Always false once participants populate.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsDiscovering
|
||||||
|
{
|
||||||
|
get => _isDiscovering;
|
||||||
|
private set => SetField(ref _isDiscovering, value);
|
||||||
|
}
|
||||||
|
private bool _isDiscovering;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Currently-enabled (live) ISO count — feeds the v2 transport strip's
|
||||||
|
/// "LIVE N" readout. The number is cyan-tinted when non-zero to draw
|
||||||
|
/// the operator's eye to active state.
|
||||||
|
/// </summary>
|
||||||
|
public int LiveCount
|
||||||
|
{
|
||||||
|
get => _liveCount;
|
||||||
|
private set => SetField(ref _liveCount, value);
|
||||||
|
}
|
||||||
|
private int _liveCount;
|
||||||
|
|
||||||
|
/// <summary>True when the REST control surface (or OSC bridge, or both) is listening.</summary>
|
||||||
|
public bool IsControlSurfaceRunning
|
||||||
|
{
|
||||||
|
get => _isControlSurfaceRunning;
|
||||||
|
private set => SetField(ref _isControlSurfaceRunning, value);
|
||||||
|
}
|
||||||
|
private bool _isControlSurfaceRunning;
|
||||||
|
|
||||||
|
/// <summary>Human-readable string for the control-surface tooltip ("REST :9755 + OSC :9000").</summary>
|
||||||
|
public string ControlSurfaceText
|
||||||
|
{
|
||||||
|
get => _controlSurfaceText;
|
||||||
|
private set => SetField(ref _controlSurfaceText, value);
|
||||||
|
}
|
||||||
|
private string _controlSurfaceText = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// "IN CALL" when Teams is in an active meeting; "READY" when Teams is
|
||||||
|
/// running but not in a call; empty when Teams isn't running. Surfaced
|
||||||
|
/// as a status pill in the IN-CALL bar so operators with auto-hide on
|
||||||
|
/// can see Teams' state without restoring its window.
|
||||||
|
/// </summary>
|
||||||
|
public string TeamsMeetingState
|
||||||
|
{
|
||||||
|
get => _teamsMeetingState;
|
||||||
|
private set
|
||||||
|
{
|
||||||
|
if (SetField(ref _teamsMeetingState, value))
|
||||||
|
OnPropertyChanged(nameof(HasTeamsState));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private string _teamsMeetingState = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>True when Teams is currently in a call (Leave button present in UIA tree).</summary>
|
||||||
|
public bool IsTeamsInCall
|
||||||
|
{
|
||||||
|
get => _isTeamsInCall;
|
||||||
|
private set => SetField(ref _isTeamsInCall, value);
|
||||||
|
}
|
||||||
|
private bool _isTeamsInCall;
|
||||||
|
|
||||||
|
/// <summary>True when <see cref="TeamsMeetingState"/> is non-empty. Used to gate visibility of the IN-CALL bar status pill via the existing BoolToVis converter.</summary>
|
||||||
|
public bool HasTeamsState => !string.IsNullOrEmpty(_teamsMeetingState);
|
||||||
|
|
||||||
|
/// <summary>True when the local user's mic is muted in the active Teams call.</summary>
|
||||||
|
public bool IsLocalMuted
|
||||||
|
{
|
||||||
|
get => _isLocalMuted;
|
||||||
|
private set => SetField(ref _isLocalMuted, value);
|
||||||
|
}
|
||||||
|
private bool _isLocalMuted;
|
||||||
|
|
||||||
|
/// <summary>True when the local user's camera is off in the active Teams call.</summary>
|
||||||
|
public bool IsLocalCameraOff
|
||||||
|
{
|
||||||
|
get => _isLocalCameraOff;
|
||||||
|
private set => SetField(ref _isLocalCameraOff, value);
|
||||||
|
}
|
||||||
|
private bool _isLocalCameraOff;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Elapsed time since the first ISO went live, formatted "HH:mm:ss". Empty
|
||||||
|
/// when nothing's running. Useful for operators tracking show length.
|
||||||
|
/// Resets when all ISOs go offline (next time one comes back, the timer
|
||||||
|
/// starts from 00:00:00 again).
|
||||||
|
/// </summary>
|
||||||
|
public string SessionElapsed
|
||||||
|
{
|
||||||
|
get => _sessionElapsed;
|
||||||
|
private set => SetField(ref _sessionElapsed, value);
|
||||||
|
}
|
||||||
|
private string _sessionElapsed = string.Empty;
|
||||||
|
public bool IsSessionActive
|
||||||
|
{
|
||||||
|
get => _isSessionActive;
|
||||||
|
private set => SetField(ref _isSessionActive, value);
|
||||||
|
}
|
||||||
|
private bool _isSessionActive;
|
||||||
|
private DateTimeOffset? _sessionStartedAt;
|
||||||
|
|
||||||
public MainViewModel(IIsoController controller, Dispatcher dispatcher)
|
public MainViewModel(IIsoController controller, Dispatcher dispatcher)
|
||||||
{
|
{
|
||||||
_controller = controller;
|
_controller = controller;
|
||||||
_dispatcher = dispatcher;
|
_dispatcher = dispatcher;
|
||||||
Settings = new GlobalSettingsViewModel(controller);
|
Toast = new ToastViewModel(dispatcher);
|
||||||
|
Settings = new GlobalSettingsViewModel(controller, Toast);
|
||||||
|
|
||||||
|
// Set up the filter-aware view AFTER Participants is non-null. The
|
||||||
|
// CollectionView binds to the live collection; Filter callback runs
|
||||||
|
// each time Refresh() is called or the collection mutates.
|
||||||
|
ParticipantsView = CollectionViewSource.GetDefaultView(Participants);
|
||||||
|
ParticipantsView.Filter = obj =>
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(_participantFilter)) return true;
|
||||||
|
return obj is ParticipantViewModel p &&
|
||||||
|
p.DisplayName.Contains(_participantFilter, StringComparison.OrdinalIgnoreCase);
|
||||||
|
};
|
||||||
|
// Apply the operator's saved sort preference, if any.
|
||||||
|
ApplySortFromPrefs();
|
||||||
|
|
||||||
|
// Subscribe directly (no ObserveOn) and marshal to the UI thread inside
|
||||||
|
// the callback via Dispatcher.InvokeAsync. The previous ObserveOn(
|
||||||
|
// SynchronizationContextScheduler) path captured SynchronizationContext
|
||||||
|
// .Current at subscribe time — fragile in WPF startup ordering, where
|
||||||
|
// the UI thread's SyncContext can be in a transitional state during
|
||||||
|
// App.OnStartup and the captured context never pumps subsequent
|
||||||
|
// OnNext calls. Direct subscribe + explicit dispatcher marshal is the
|
||||||
|
// pattern proven by Console.Program.cs (engine emits, consumer marshals).
|
||||||
_participantsSub = controller.Participants
|
_participantsSub = controller.Participants
|
||||||
.ObserveOn(new SynchronizationContextScheduler(
|
.Subscribe(snapshot => _dispatcher.InvokeAsync(
|
||||||
System.Threading.SynchronizationContext.Current ?? new DispatcherSynchronizationContext(_dispatcher)))
|
() => OnParticipantsChanged(snapshot),
|
||||||
.Subscribe(OnParticipantsChanged);
|
DispatcherPriority.Background));
|
||||||
|
|
||||||
_alertsSub = controller.Alerts
|
_alertsSub = controller.Alerts
|
||||||
.ObserveOn(new SynchronizationContextScheduler(
|
.ObserveOn(new SynchronizationContextScheduler(
|
||||||
|
|
@ -49,34 +393,321 @@ public sealed class MainViewModel : ObservableObject, IDisposable
|
||||||
{
|
{
|
||||||
AlertBanner.Current = alert;
|
AlertBanner.Current = alert;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 1 Hz stats poll — pull live frame counters from each running pipeline and
|
||||||
|
// push them onto the per-participant view models. Cheap (just reads volatile
|
||||||
|
// fields on the engine side) and runs on the UI dispatcher so SetField is safe.
|
||||||
|
_statsTimer = new DispatcherTimer(DispatcherPriority.Background, _dispatcher)
|
||||||
|
{
|
||||||
|
Interval = TimeSpan.FromSeconds(1),
|
||||||
|
};
|
||||||
|
_statsTimer.Tick += OnStatsTick;
|
||||||
|
_statsTimer.Start();
|
||||||
|
|
||||||
|
StopAllIsosCommand = new AsyncRelayCommand(StopAllIsosAsync, () => Participants.Any(p => p.IsEnabled));
|
||||||
|
EnableAllOnlineCommand = new AsyncRelayCommand(EnableAllOnlineAsync,
|
||||||
|
() => Participants.Any(p => p.IsOnline && !p.IsEnabled));
|
||||||
|
RefreshDiscoveryCommand = new RelayCommand(() =>
|
||||||
|
{
|
||||||
|
_controller.RefreshDiscovery();
|
||||||
|
Toast.Show("Refreshing NDI discovery…");
|
||||||
|
});
|
||||||
|
|
||||||
|
ToggleThemeCommand = new RelayCommand(() =>
|
||||||
|
{
|
||||||
|
// ThemeManager.Toggle persists the new preference to UIPreferences
|
||||||
|
// and fires the resource-dictionary swap on the dispatcher thread.
|
||||||
|
Services.ThemeManager.Current.Toggle();
|
||||||
|
});
|
||||||
|
|
||||||
|
OpenCommandPaletteCommand = new RelayCommand(() => _openCommandPalette?.Invoke());
|
||||||
|
|
||||||
|
ShowHelpCommand = new RelayCommand(() =>
|
||||||
|
{
|
||||||
|
// Showing a Window from a VM violates strict MVVM, but TeamsISO doesn't
|
||||||
|
// ship a navigation service and a HelpWindow is purely a UI concern.
|
||||||
|
// Owner is set so the dialog centers and inherits z-order.
|
||||||
|
var help = new HelpWindow { Owner = System.Windows.Application.Current?.MainWindow };
|
||||||
|
help.ShowDialog();
|
||||||
|
});
|
||||||
|
|
||||||
|
ShowNotesCommand = new RelayCommand(() =>
|
||||||
|
{
|
||||||
|
var notes = new NotesWindow { Owner = System.Windows.Application.Current?.MainWindow };
|
||||||
|
notes.Show(); // non-modal so operators can stamp + read alongside the show
|
||||||
|
});
|
||||||
|
|
||||||
|
SnapshotAllCommand = new RelayCommand(SnapshotAll, () => Participants.Any(p => p.IsEnabled));
|
||||||
|
|
||||||
|
ToggleByIndexCommand = new RelayCommand<string>(s =>
|
||||||
|
{
|
||||||
|
// Numpad / digit hotkeys pass "1".."9" as a string. Resolve
|
||||||
|
// against the filtered/sorted view so the index matches what
|
||||||
|
// the operator sees on screen, not the underlying storage order.
|
||||||
|
if (!int.TryParse(s, out var idx) || idx < 1 || idx > 9) return;
|
||||||
|
var i = 0;
|
||||||
|
foreach (var item in ParticipantsView)
|
||||||
|
{
|
||||||
|
if (item is not ParticipantViewModel p) continue;
|
||||||
|
if (++i == idx)
|
||||||
|
{
|
||||||
|
if (p.ToggleIsoCommand.CanExecute(null))
|
||||||
|
p.ToggleIsoCommand.Execute(null);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
JoinMeetingCommand = new RelayCommand(JoinPastedMeeting);
|
||||||
|
|
||||||
|
ToggleMuteCommand = MakeTeamsCommand(
|
||||||
|
label: "Mute",
|
||||||
|
invoke: TeamsControlBridge.ToggleMute,
|
||||||
|
successMessage: "Toggled mute");
|
||||||
|
ToggleCameraCommand = MakeTeamsCommand(
|
||||||
|
label: "Camera",
|
||||||
|
invoke: TeamsControlBridge.ToggleCamera,
|
||||||
|
successMessage: "Toggled camera");
|
||||||
|
LeaveCallCommand = MakeTeamsCommand(
|
||||||
|
label: "Leave",
|
||||||
|
invoke: TeamsControlBridge.LeaveCall,
|
||||||
|
successMessage: "Left the call");
|
||||||
|
OpenShareTrayCommand = MakeTeamsCommand(
|
||||||
|
label: "Share",
|
||||||
|
invoke: TeamsControlBridge.OpenShareTray,
|
||||||
|
successMessage: "Opened share tray");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Body methods extracted to themed partial files:
|
||||||
|
// MainViewModel.BulkCommands.cs — EnableAllOnlineAsync, StopAllIsosAsync, SnapshotAll
|
||||||
|
// MainViewModel.TeamsCommands.cs — MakeTeamsCommand, JoinPastedMeeting,
|
||||||
|
// ExtractMeetingTitle, PollTeamsMeetingState
|
||||||
|
// MainViewModel.PresetCommands.cs — RequestApplyPresetOnStartup,
|
||||||
|
// LoadPendingPresetFromPreferences,
|
||||||
|
// TryAutoApplyPendingPreset
|
||||||
|
|
||||||
|
private void OnStatsTick(object? sender, EventArgs e)
|
||||||
|
{
|
||||||
|
foreach (var vm in Participants)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var stats = _controller.GetStats(vm.Id);
|
||||||
|
vm.UpdateStats(stats);
|
||||||
|
// Refresh preview thumbnail from the engine's most recent
|
||||||
|
// processed frame. Returns null if no pipeline is running for
|
||||||
|
// this participant; UpdateThumbnail short-circuits in that
|
||||||
|
// case, leaving the previous frame in place rather than
|
||||||
|
// visibly blanking when the pipeline restarts.
|
||||||
|
vm.UpdateThumbnail(_controller.GetLatestProcessedFrame(vm.Id));
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Stats are advisory; never let a transient read failure
|
||||||
|
// tear down the timer or surface an error to the user.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Active-speaker highlight: find the loudest enabled participant
|
||||||
|
// and mark their IsActiveSpeaker flag. Only one row at a time;
|
||||||
|
// ties broken by enumeration order (first one wins). Threshold of
|
||||||
|
// 0.05 prevents constant flicker between near-silent participants
|
||||||
|
// when nobody's really speaking.
|
||||||
|
ParticipantViewModel? loudest = null;
|
||||||
|
double loudestLevel = 0.05;
|
||||||
|
foreach (var p in Participants)
|
||||||
|
{
|
||||||
|
if (!p.IsEnabled) continue;
|
||||||
|
if (p.DisplayedAudioLevel > loudestLevel)
|
||||||
|
{
|
||||||
|
loudest = p;
|
||||||
|
loudestLevel = p.DisplayedAudioLevel;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach (var p in Participants)
|
||||||
|
{
|
||||||
|
var shouldHighlight = ReferenceEquals(p, loudest);
|
||||||
|
if (p.IsActiveSpeaker != shouldHighlight)
|
||||||
|
p.IsActiveSpeaker = shouldHighlight;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If sort mode is LoudestFirst, refresh the view so the new audio
|
||||||
|
// peaks re-evaluate the sort. Skipped for the other sort modes
|
||||||
|
// since their keys (name, online state) don't change every tick —
|
||||||
|
// no need to pay the Refresh cost.
|
||||||
|
if (_currentSortMode == Services.UIPreferences.SortMode.LoudestFirst)
|
||||||
|
{
|
||||||
|
try { ParticipantsView.Refresh(); }
|
||||||
|
catch { /* defensive — Refresh occasionally throws on collection mutations */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update footer badges. Recording count is "ISOs that have a recorder
|
||||||
|
// attached" — _controller.RecordingEnabled tells us the global toggle,
|
||||||
|
// but the actual recorder count = number of running pipelines while
|
||||||
|
// that toggle was on (transient enables can mean fewer recorders than
|
||||||
|
// running pipelines). Approximate by ANDing global toggle + running
|
||||||
|
// ISO count; close enough for an at-a-glance footer.
|
||||||
|
var totalParticipants = Participants.Count;
|
||||||
|
var enabledCount = Participants.Count(p => p.IsEnabled);
|
||||||
|
// Recording-elapsed timer + disk-free polling removed alongside the rest
|
||||||
|
// of the recording surface.
|
||||||
|
|
||||||
|
// Expose counts as VM properties for the v2 transport-strip binding.
|
||||||
|
// The strip's "PART 4 · LIVE 2" reads these — pushing them on the
|
||||||
|
// 1Hz tick keeps the cost off the per-frame UI path.
|
||||||
|
ParticipantCount = totalParticipants;
|
||||||
|
LiveCount = enabledCount;
|
||||||
|
|
||||||
|
// IsDiscovering gates the "Scanning for NDI sources…" placeholder.
|
||||||
|
// True for DiscoveryGracePeriod after engine start AS LONG AS we
|
||||||
|
// haven't seen any participants yet; once anything arrives we drop
|
||||||
|
// out of the discovering state immediately (back to the OK path).
|
||||||
|
if (totalParticipants == 0 && _engineStartedAt is { } startedAt)
|
||||||
|
{
|
||||||
|
IsDiscovering = DateTimeOffset.UtcNow - startedAt < DiscoveryGracePeriod;
|
||||||
|
}
|
||||||
|
else if (IsDiscovering)
|
||||||
|
{
|
||||||
|
IsDiscovering = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Session timer — start on first ISO going live, reset when none are
|
||||||
|
// live anymore. Subsequent enables after a full-zero gap restart the
|
||||||
|
// timer rather than resuming, which is the operator's mental model:
|
||||||
|
// "the show started when the first feed went live."
|
||||||
|
if (enabledCount > 0)
|
||||||
|
{
|
||||||
|
_sessionStartedAt ??= DateTimeOffset.UtcNow;
|
||||||
|
var elapsed = DateTimeOffset.UtcNow - _sessionStartedAt.Value;
|
||||||
|
SessionElapsed = elapsed.TotalHours >= 1
|
||||||
|
? $"{(int)elapsed.TotalHours:D2}:{elapsed.Minutes:D2}:{elapsed.Seconds:D2}"
|
||||||
|
: $"{elapsed.Minutes:D2}:{elapsed.Seconds:D2}";
|
||||||
|
IsSessionActive = true;
|
||||||
|
}
|
||||||
|
else if (_sessionStartedAt is not null)
|
||||||
|
{
|
||||||
|
_sessionStartedAt = null;
|
||||||
|
SessionElapsed = string.Empty;
|
||||||
|
IsSessionActive = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dynamic status text — replaces the static "Engine running at X fps"
|
||||||
|
// once ISOs are live. The framerate target is still implicit (the user
|
||||||
|
// set it in OUTPUT settings; surfacing it constantly steals footer
|
||||||
|
// real estate from more-actionable info).
|
||||||
|
if (totalParticipants == 0)
|
||||||
|
{
|
||||||
|
StatusText = "Discovering NDI sources…";
|
||||||
|
}
|
||||||
|
else if (enabledCount == 0)
|
||||||
|
{
|
||||||
|
StatusText = totalParticipants == 1
|
||||||
|
? "1 participant visible"
|
||||||
|
: $"{totalParticipants} participants visible";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
StatusText = $"{enabledCount}/{totalParticipants} ISOs live";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Teams meeting state — UIA traversal at 1Hz; off-thread so a slow
|
||||||
|
// UIA call doesn't stall the UI tick. Implementation in
|
||||||
|
// MainViewModel.TeamsCommands.cs.
|
||||||
|
PollTeamsMeetingState();
|
||||||
|
|
||||||
|
// Control-surface state — peek at App's owned services.
|
||||||
|
var app = System.Windows.Application.Current as App;
|
||||||
|
var rest = app?.ControlSurface?.IsRunning ?? false;
|
||||||
|
var osc = app?.OscBridge?.IsRunning ?? false;
|
||||||
|
IsControlSurfaceRunning = rest || osc;
|
||||||
|
// When LAN-reachable mode is on, the footer text shows the routable
|
||||||
|
// URL instead of just the port — operators setting up a thin client
|
||||||
|
// shouldn't have to open Settings to find what to type into a
|
||||||
|
// browser. We trust the Settings VM's ControlSurfaceLanReachable
|
||||||
|
// boolean since that's where the toggle is persisted.
|
||||||
|
var lanMode = rest && (app?.ControlSurface?.BoundToLan ?? false);
|
||||||
|
var lanHost = lanMode ? Settings.ControlSurfaceUrl.Replace("/ui", "") : null;
|
||||||
|
ControlSurfaceText = (rest, osc) switch
|
||||||
|
{
|
||||||
|
(true, true) when lanMode => $"{lanHost} + OSC :{app!.OscBridge!.Port}",
|
||||||
|
(true, false) when lanMode => lanHost!,
|
||||||
|
(true, true) => $"REST :{app!.ControlSurface!.Port} + OSC :{app.OscBridge!.Port}",
|
||||||
|
(true, false) => $"REST :{app!.ControlSurface!.Port}",
|
||||||
|
(false, true) => $"OSC :{app!.OscBridge!.Port}",
|
||||||
|
_ => string.Empty,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task InitializeAsync(CancellationToken cancellationToken)
|
public async Task InitializeAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
StatusText = "Discovering NDI sources…";
|
StatusText = "Discovering NDI sources…";
|
||||||
|
_engineStartedAt = DateTimeOffset.UtcNow;
|
||||||
|
IsDiscovering = true;
|
||||||
await _controller.StartAsync(cancellationToken);
|
await _controller.StartAsync(cancellationToken);
|
||||||
StatusText = $"Engine running at {_controller.GlobalSettings.FramerateHz:F2} fps target.";
|
StatusText = $"Engine running at {_controller.GlobalSettings.FramerateHz:F2} fps target.";
|
||||||
|
|
||||||
|
// Auto-apply last preset bookkeeping. We don't apply here —
|
||||||
|
// participants haven't been discovered yet — instead we record
|
||||||
|
// the intent and let OnParticipantsChanged trigger the apply
|
||||||
|
// once the meeting has populated. Implementation in
|
||||||
|
// MainViewModel.PresetCommands.cs.
|
||||||
|
LoadPendingPresetFromPreferences();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnParticipantsChanged(IReadOnlyList<Participant> incoming)
|
private void OnParticipantsChanged(IReadOnlyList<Participant> incoming)
|
||||||
{
|
{
|
||||||
var seenIds = new HashSet<Guid>();
|
var seenIds = new HashSet<Guid>();
|
||||||
|
var hideLocal = Settings.HideLocalSelf;
|
||||||
|
var autoDisable = Settings.AutoDisableOnDeparture;
|
||||||
foreach (var p in incoming)
|
foreach (var p in incoming)
|
||||||
{
|
{
|
||||||
|
// The new Teams client emits a "(Local)" pseudo-participant for the user's
|
||||||
|
// own preview — operators rarely want it as a routable ISO. Suppress when
|
||||||
|
// HideLocalSelf is on (default).
|
||||||
|
if (hideLocal && IsLocalSelf(p)) continue;
|
||||||
|
|
||||||
seenIds.Add(p.Id);
|
seenIds.Add(p.Id);
|
||||||
if (_byId.TryGetValue(p.Id, out var vm))
|
if (_byId.TryGetValue(p.Id, out var vm))
|
||||||
{
|
{
|
||||||
|
var wasOnline = vm.IsOnline;
|
||||||
vm.Update(p);
|
vm.Update(p);
|
||||||
|
// Departure: source went from non-null to null. Always toast so the
|
||||||
|
// operator notices, even when AutoDisableOnDeparture is off — the
|
||||||
|
// ISO might still be "running" but emitting a slate frame, which
|
||||||
|
// looks fine in TeamsISO's UI but is broken downstream.
|
||||||
|
if (wasOnline && !vm.IsOnline && vm.IsEnabled)
|
||||||
|
{
|
||||||
|
if (autoDisable)
|
||||||
|
{
|
||||||
|
var captured = vm;
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
try { await _controller.DisableIsoAsync(captured.Id, CancellationToken.None); }
|
||||||
|
catch { /* defensive */ }
|
||||||
|
await _dispatcher.InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
captured.IsEnabled = false;
|
||||||
|
Toast.Show($"Auto-disabled ISO: {captured.DisplayName} left the meeting");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// ISO stays running on a slate frame; warn the operator so
|
||||||
|
// they can decide whether to disable manually.
|
||||||
|
Toast.Warn($"{vm.DisplayName} disconnected — ISO still running on slate");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
vm = new ParticipantViewModel(_controller, p);
|
vm = new ParticipantViewModel(_controller, p, Toast);
|
||||||
_byId[p.Id] = vm;
|
_byId[p.Id] = vm;
|
||||||
Participants.Add(vm);
|
Participants.Add(vm);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove participants no longer present
|
// Remove participants no longer present (or now hidden by the filter).
|
||||||
for (var i = Participants.Count - 1; i >= 0; i--)
|
for (var i = Participants.Count - 1; i >= 0; i--)
|
||||||
{
|
{
|
||||||
var vm = Participants[i];
|
var vm = Participants[i];
|
||||||
|
|
@ -86,10 +717,25 @@ public sealed class MainViewModel : ObservableObject, IDisposable
|
||||||
Participants.RemoveAt(i);
|
Participants.RemoveAt(i);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auto-apply-last-preset, second half: once participants populate, kick the
|
||||||
|
// apply. We fire it under either of two conditions: (a) every display name
|
||||||
|
// referenced by the preset is present (best case — the meeting is fully
|
||||||
|
// populated, no skipped assignments), or (b) the grace deadline has passed
|
||||||
|
// (give up waiting and apply with whoever's online).
|
||||||
|
if (_pendingPresetName is not null && !_pendingPresetApplied)
|
||||||
|
{
|
||||||
|
TryAutoApplyPendingPreset();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool IsLocalSelf(Participant p) =>
|
||||||
|
string.Equals(p.DisplayName, "(Local)", StringComparison.Ordinal);
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
|
_statsTimer.Stop();
|
||||||
|
_statsTimer.Tick -= OnStatsTick;
|
||||||
_participantsSub.Dispose();
|
_participantsSub.Dispose();
|
||||||
_alertsSub.Dispose();
|
_alertsSub.Dispose();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,10 @@
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Media;
|
||||||
|
using System.Windows.Media.Imaging;
|
||||||
using TeamsISO.Engine.Controller;
|
using TeamsISO.Engine.Controller;
|
||||||
using TeamsISO.Engine.Domain;
|
using TeamsISO.Engine.Domain;
|
||||||
|
using TeamsISO.Engine.Pipeline;
|
||||||
|
using IsoHealthStats = TeamsISO.Engine.Domain.IsoHealthStats;
|
||||||
|
|
||||||
namespace TeamsISO.App.ViewModels;
|
namespace TeamsISO.App.ViewModels;
|
||||||
|
|
||||||
|
|
@ -10,17 +15,269 @@ namespace TeamsISO.App.ViewModels;
|
||||||
public sealed class ParticipantViewModel : ObservableObject
|
public sealed class ParticipantViewModel : ObservableObject
|
||||||
{
|
{
|
||||||
private readonly IIsoController _controller;
|
private readonly IIsoController _controller;
|
||||||
|
private readonly ToastViewModel? _toast;
|
||||||
private Participant _participant;
|
private Participant _participant;
|
||||||
private bool _isEnabled;
|
private bool _isEnabled;
|
||||||
private bool _isProcessing;
|
private bool _isProcessing;
|
||||||
private string _customName;
|
private string _customName;
|
||||||
|
|
||||||
public ParticipantViewModel(IIsoController controller, Participant participant)
|
/// <summary>Thumbnail dimensions. 16:9 ratio matches the typical Teams output aspect.</summary>
|
||||||
|
private const int ThumbnailWidth = 160;
|
||||||
|
private const int ThumbnailHeight = 90;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Live preview of the most recent processed frame, scaled to <see cref="ThumbnailWidth"/>×
|
||||||
|
/// <see cref="ThumbnailHeight"/>. Updated by <see cref="UpdateThumbnail"/> on the UI
|
||||||
|
/// thread, called from MainViewModel's stats tick. Null until the first frame arrives.
|
||||||
|
/// </summary>
|
||||||
|
public WriteableBitmap? Thumbnail
|
||||||
|
{
|
||||||
|
get => _thumbnail;
|
||||||
|
private set
|
||||||
|
{
|
||||||
|
if (SetField(ref _thumbnail, value))
|
||||||
|
OnPropertyChanged(nameof(HasThumbnail));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private WriteableBitmap? _thumbnail;
|
||||||
|
|
||||||
|
/// <summary>True when <see cref="Thumbnail"/> is non-null. Bound to Visibility in XAML.</summary>
|
||||||
|
public bool HasThumbnail => _thumbnail is not null;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True when this participant is currently the loudest among the live
|
||||||
|
/// set — set by MainViewModel at the 1Hz stats tick. Bound to a cyan
|
||||||
|
/// border accent on the DataGrid row so operators can spot who's
|
||||||
|
/// speaking without watching every VU bar individually.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsActiveSpeaker
|
||||||
|
{
|
||||||
|
get => _isActiveSpeaker;
|
||||||
|
internal set => SetField(ref _isActiveSpeaker, value);
|
||||||
|
}
|
||||||
|
private bool _isActiveSpeaker;
|
||||||
|
|
||||||
|
public ParticipantViewModel(IIsoController controller, Participant participant, ToastViewModel? toast = null)
|
||||||
{
|
{
|
||||||
_controller = controller;
|
_controller = controller;
|
||||||
|
_toast = toast;
|
||||||
_participant = participant;
|
_participant = participant;
|
||||||
_customName = string.Empty;
|
_customName = string.Empty;
|
||||||
ToggleIsoCommand = new AsyncRelayCommand(ToggleIsoAsync, () => !_isProcessing);
|
ToggleIsoCommand = new AsyncRelayCommand(ToggleIsoAsync, () => !_isProcessing);
|
||||||
|
CopySourceNameCommand = new RelayCommand(() =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var src = _participant.CurrentSource?.FullName;
|
||||||
|
if (!string.IsNullOrEmpty(src))
|
||||||
|
System.Windows.Clipboard.SetText(src);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Clipboard occasionally errors when something else has it locked;
|
||||||
|
// best-effort, no user-visible failure.
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
OpenPreviewCommand = new RelayCommand(() =>
|
||||||
|
{
|
||||||
|
// Non-modal — operator can open multiple previews at once.
|
||||||
|
// Owner is the main window so the preview centers nicely and
|
||||||
|
// closes cleanly when the host exits.
|
||||||
|
var preview = new PreviewWindow(_controller, Id, DisplayName);
|
||||||
|
preview.Show();
|
||||||
|
});
|
||||||
|
|
||||||
|
RestartIsoCommand = new AsyncRelayCommand(RestartIsoAsync,
|
||||||
|
() => _isEnabled && !_isProcessing);
|
||||||
|
|
||||||
|
SaveSnapshotCommand = new RelayCommand(SaveSnapshot, () => _isEnabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Encode the most recent <see cref="IsoPipeline.LatestProcessedFrame"/>
|
||||||
|
/// as a PNG and write it to <c>%USERPROFILE%\Pictures\TeamsISO\</c>. Used
|
||||||
|
/// by the participants' context menu for grabbing a stillframe — useful
|
||||||
|
/// for highlight reels, social posts, bug reports. Best-effort: a no-op
|
||||||
|
/// + warn-toast if no frame is currently available (pipeline just spun
|
||||||
|
/// up, or recording isn't enabled). Filename includes participant name
|
||||||
|
/// + timestamp so back-to-back snapshots don't collide.
|
||||||
|
/// </summary>
|
||||||
|
private void SaveSnapshot()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var frame = _controller.GetLatestProcessedFrame(Id);
|
||||||
|
if (frame is null || frame.Pixels.IsEmpty)
|
||||||
|
{
|
||||||
|
_toast?.Warn("No frame available yet — try again in a few seconds");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var dir = System.IO.Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.MyPictures),
|
||||||
|
"TeamsISO");
|
||||||
|
System.IO.Directory.CreateDirectory(dir);
|
||||||
|
|
||||||
|
var safeName = string.Join("_", DisplayName.Split(System.IO.Path.GetInvalidFileNameChars()));
|
||||||
|
if (string.IsNullOrWhiteSpace(safeName)) safeName = "participant";
|
||||||
|
var path = System.IO.Path.Combine(dir,
|
||||||
|
$"{safeName}_{DateTimeOffset.Now:yyyyMMdd_HHmmss}.png");
|
||||||
|
|
||||||
|
// ProcessedFrame is BGRA32, top-down. WriteableBitmap with
|
||||||
|
// Bgra32 pixel format takes the bytes verbatim.
|
||||||
|
var stride = frame.Width * 4;
|
||||||
|
var bmp = new System.Windows.Media.Imaging.WriteableBitmap(
|
||||||
|
frame.Width, frame.Height,
|
||||||
|
96, 96,
|
||||||
|
System.Windows.Media.PixelFormats.Bgra32, null);
|
||||||
|
bmp.WritePixels(
|
||||||
|
new System.Windows.Int32Rect(0, 0, frame.Width, frame.Height),
|
||||||
|
frame.Pixels.ToArray(), stride, 0);
|
||||||
|
|
||||||
|
using var fs = new System.IO.FileStream(path, System.IO.FileMode.Create);
|
||||||
|
var encoder = new System.Windows.Media.Imaging.PngBitmapEncoder();
|
||||||
|
encoder.Frames.Add(System.Windows.Media.Imaging.BitmapFrame.Create(bmp));
|
||||||
|
encoder.Save(fs);
|
||||||
|
|
||||||
|
_toast?.Show($"Saved snapshot: {System.IO.Path.GetFileName(path)}");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_toast?.Warn($"Snapshot failed: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Disable + re-enable this pipeline. Brief delay between for the engine
|
||||||
|
/// to fully tear down before we ask for a fresh sender. The processing
|
||||||
|
/// flag suppresses the toggle button + restart action while in flight.
|
||||||
|
/// </summary>
|
||||||
|
private async Task RestartIsoAsync()
|
||||||
|
{
|
||||||
|
if (!IsEnabled) return;
|
||||||
|
IsProcessing = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _controller.DisableIsoAsync(Id, CancellationToken.None);
|
||||||
|
// Short delay so any in-flight NDI sender disposal completes before
|
||||||
|
// we ask CreateSender for the same name. Empirically 250ms is plenty.
|
||||||
|
await Task.Delay(250);
|
||||||
|
var resolvedName = string.IsNullOrWhiteSpace(_customName)
|
||||||
|
? Services.OutputNameTemplate.Render(
|
||||||
|
Services.OutputNameTemplate.Get(),
|
||||||
|
Id,
|
||||||
|
DisplayName)
|
||||||
|
: _customName;
|
||||||
|
bool? recordOverride = _recordToDisk ? null : false;
|
||||||
|
await _controller.EnableIsoAsync(Id, resolvedName, recordOverride, CancellationToken.None);
|
||||||
|
// IsEnabled is already true (we never set it false); re-fire the
|
||||||
|
// change notification so any UI bindings sensitive to a transition
|
||||||
|
// observe the restart.
|
||||||
|
OnPropertyChanged(nameof(IsEnabled));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsProcessing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Refresh the preview thumbnail from the engine's most recent processed frame.
|
||||||
|
/// Must be called on the UI thread (MainViewModel's stats DispatcherTimer is fine).
|
||||||
|
/// Allocates the WriteableBitmap lazily on the first call so we don't pay for it
|
||||||
|
/// on participants that never have an ISO enabled. Skips work if the engine has
|
||||||
|
/// no frame yet (no pipeline, or pipeline still warming up).
|
||||||
|
/// </summary>
|
||||||
|
public void UpdateThumbnail(ProcessedFrame? frame)
|
||||||
|
{
|
||||||
|
if (frame is null || frame.Pixels.IsEmpty)
|
||||||
|
{
|
||||||
|
// Don't clear a previously-rendered thumbnail on transient null reads —
|
||||||
|
// a brief gap between frames shouldn't visibly blank the preview. The
|
||||||
|
// Thumbnail is only set to null when the pipeline genuinely stops, which
|
||||||
|
// we observe by IsEnabled flipping false elsewhere.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Defense in depth: if the engine ever hands us a frame whose pixel buffer
|
||||||
|
// doesn't match the declared dimensions (would imply an engine bug), don't
|
||||||
|
// crash the UI on IndexOutOfRangeException — silently skip this update and
|
||||||
|
// wait for a sane frame.
|
||||||
|
var expectedBytes = frame.Width * frame.Height * 4;
|
||||||
|
if (frame.Width <= 0 || frame.Height <= 0 || frame.Pixels.Length < expectedBytes)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (_thumbnail is null)
|
||||||
|
{
|
||||||
|
// 96 DPI matches the WPF default; PixelFormats.Bgra32 matches the
|
||||||
|
// engine's BGRA pixel layout so the WritePixels call is a memcpy.
|
||||||
|
// The setter fires PropertyChanged for both Thumbnail and HasThumbnail
|
||||||
|
// so the DataGrid's Visibility bindings flip in the same change cycle.
|
||||||
|
Thumbnail = new WriteableBitmap(
|
||||||
|
ThumbnailWidth, ThumbnailHeight, 96, 96, PixelFormats.Bgra32, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
var thumb = _thumbnail!;
|
||||||
|
thumb.Lock();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
ScaleNearestNeighborBgra(
|
||||||
|
src: frame.Pixels.Span,
|
||||||
|
srcW: frame.Width,
|
||||||
|
srcH: frame.Height,
|
||||||
|
dst: thumb.BackBuffer,
|
||||||
|
dstStride: thumb.BackBufferStride,
|
||||||
|
dstW: ThumbnailWidth,
|
||||||
|
dstH: ThumbnailHeight);
|
||||||
|
thumb.AddDirtyRect(new Int32Rect(0, 0, ThumbnailWidth, ThumbnailHeight));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
thumb.Unlock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Inline nearest-neighbor scaler — copies one downsampled BGRA frame into the
|
||||||
|
/// WriteableBitmap's back buffer. We don't reuse <see cref="ManagedNearestNeighborFrameScaler"/>
|
||||||
|
/// because it allocates a managed buffer per scale; here we want to write
|
||||||
|
/// directly into the WriteableBitmap's pinned native memory to avoid a copy.
|
||||||
|
///
|
||||||
|
/// The arithmetic is the same: for each destination pixel, compute the source
|
||||||
|
/// pixel via integer ratio and copy 4 bytes. At 1Hz × 10 thumbnails that's
|
||||||
|
/// ~144,000 pixel reads per second — negligible CPU.
|
||||||
|
/// </summary>
|
||||||
|
private static void ScaleNearestNeighborBgra(
|
||||||
|
ReadOnlySpan<byte> src, int srcW, int srcH,
|
||||||
|
IntPtr dst, int dstStride, int dstW, int dstH)
|
||||||
|
{
|
||||||
|
// Pre-compute the x-ratio table once per call so the inner loop is just two
|
||||||
|
// multiplies and a memcpy. Java-style fixed-point would be faster but for
|
||||||
|
// 160×90 the overhead is irrelevant.
|
||||||
|
Span<int> srcXFor = stackalloc int[dstW];
|
||||||
|
for (var x = 0; x < dstW; x++) srcXFor[x] = x * srcW / dstW;
|
||||||
|
|
||||||
|
unsafe
|
||||||
|
{
|
||||||
|
var dstPtr = (byte*)dst;
|
||||||
|
var srcStride = srcW * 4;
|
||||||
|
for (var y = 0; y < dstH; y++)
|
||||||
|
{
|
||||||
|
var srcY = y * srcH / dstH;
|
||||||
|
var srcRow = srcY * srcStride;
|
||||||
|
var dstRow = y * dstStride;
|
||||||
|
for (var x = 0; x < dstW; x++)
|
||||||
|
{
|
||||||
|
var srcOff = srcRow + srcXFor[x] * 4;
|
||||||
|
var dstOff = dstRow + x * 4;
|
||||||
|
dstPtr[dstOff + 0] = src[srcOff + 0];
|
||||||
|
dstPtr[dstOff + 1] = src[srcOff + 1];
|
||||||
|
dstPtr[dstOff + 2] = src[srcOff + 2];
|
||||||
|
dstPtr[dstOff + 3] = src[srcOff + 3];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public Guid Id => _participant.Id;
|
public Guid Id => _participant.Id;
|
||||||
|
|
@ -35,6 +292,120 @@ public sealed class ParticipantViewModel : ObservableObject
|
||||||
set => SetField(ref _isEnabled, value);
|
set => SetField(ref _isEnabled, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When true (default), the operator wants this participant's ISO recorded
|
||||||
|
/// when the global recording toggle is on. When false, this participant is
|
||||||
|
/// opted out of recording even with global on. The flag is read at the
|
||||||
|
/// EnableIsoAsync call so changing it after enabling has no effect on a
|
||||||
|
/// running pipeline; operator must disable + re-enable to apply.
|
||||||
|
/// </summary>
|
||||||
|
public bool RecordToDisk
|
||||||
|
{
|
||||||
|
get => _recordToDisk;
|
||||||
|
set => SetField(ref _recordToDisk, value);
|
||||||
|
}
|
||||||
|
private bool _recordToDisk = true;
|
||||||
|
|
||||||
|
private long _framesIn;
|
||||||
|
private long _framesOut;
|
||||||
|
private long _framesDropped;
|
||||||
|
private string _incomingResolution = "—";
|
||||||
|
private string _incomingFps = "—";
|
||||||
|
|
||||||
|
/// <summary>Number of frames the receiver has captured so far.</summary>
|
||||||
|
public long FramesIn { get => _framesIn; set => SetField(ref _framesIn, value); }
|
||||||
|
|
||||||
|
/// <summary>Number of frames the sender has emitted so far.</summary>
|
||||||
|
public long FramesOut { get => _framesOut; set => SetField(ref _framesOut, value); }
|
||||||
|
|
||||||
|
/// <summary>Frames dropped by the closest-frame strategy when the receiver outpaces the processor.</summary>
|
||||||
|
public long FramesDropped { get => _framesDropped; set => SetField(ref _framesDropped, value); }
|
||||||
|
|
||||||
|
private string _stateLabel = "—";
|
||||||
|
private string _stateColor = "Wd.Text.Tertiary";
|
||||||
|
private double _peakAudioLevel;
|
||||||
|
private double _displayedAudioLevel;
|
||||||
|
private DateTimeOffset _lastPeakAt;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Smoothed audio level for display (0.0 to 1.0). Decays toward 0 between
|
||||||
|
/// peak updates so the VU bar feels alive even when audio is sparse. The
|
||||||
|
/// raw peak from the engine arrives at the 1Hz stats poll; we interpolate
|
||||||
|
/// down between polls in the property getter (technically a slight
|
||||||
|
/// abstraction leak but simpler than wiring another timer).
|
||||||
|
/// </summary>
|
||||||
|
public double DisplayedAudioLevel
|
||||||
|
{
|
||||||
|
get => _displayedAudioLevel;
|
||||||
|
private set => SetField(ref _displayedAudioLevel, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// VU-bar fill width as a 0-100 number, suitable for a Width binding on
|
||||||
|
/// a fixed-size 100-px-wide indicator. Returns the displayed (decayed)
|
||||||
|
/// audio level scaled to [0, 100]; 0 when no recent audio has been seen.
|
||||||
|
/// </summary>
|
||||||
|
public double AudioLevelWidthPercent => Math.Clamp(_displayedAudioLevel * 100, 0, 100);
|
||||||
|
|
||||||
|
/// <summary>Human-readable pipeline state ("Receiving", "Error", "—").</summary>
|
||||||
|
public string StateLabel { get => _stateLabel; set => SetField(ref _stateLabel, value); }
|
||||||
|
|
||||||
|
/// <summary>Resource key of the brush to color the state pill with.</summary>
|
||||||
|
public string StateColor { get => _stateColor; set => SetField(ref _stateColor, value); }
|
||||||
|
|
||||||
|
/// <summary>Source resolution as "WxH", or em-dash when no frames have been seen yet.</summary>
|
||||||
|
public string IncomingResolution { get => _incomingResolution; set => SetField(ref _incomingResolution, value); }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Live incoming framerate as "59.94 fps", or em-dash when fewer than 2 frames
|
||||||
|
/// have been observed since the pipeline started. Computed in the engine via a
|
||||||
|
/// 30-frame moving window.
|
||||||
|
/// </summary>
|
||||||
|
public string IncomingFps { get => _incomingFps; set => SetField(ref _incomingFps, value); }
|
||||||
|
|
||||||
|
/// <summary>Updates the live stats display from a controller-side snapshot.</summary>
|
||||||
|
public void UpdateStats(IsoHealthStats stats)
|
||||||
|
{
|
||||||
|
// Audio level: take the new peak when it's higher than what we're
|
||||||
|
// currently displaying (instant attack), otherwise decay toward zero
|
||||||
|
// (slow release). 0.7 multiplier per 1Hz tick = ~half-life of ~1.6s,
|
||||||
|
// which feels like a real VU meter. When the engine starts feeding
|
||||||
|
// real PeakAudioLevel values, this code starts working without
|
||||||
|
// further changes.
|
||||||
|
if (stats.PeakAudioLevel > _displayedAudioLevel)
|
||||||
|
{
|
||||||
|
_displayedAudioLevel = stats.PeakAudioLevel;
|
||||||
|
_lastPeakAt = DateTimeOffset.UtcNow;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_displayedAudioLevel *= 0.7;
|
||||||
|
if (_displayedAudioLevel < 0.01) _displayedAudioLevel = 0;
|
||||||
|
}
|
||||||
|
_peakAudioLevel = stats.PeakAudioLevel;
|
||||||
|
OnPropertyChanged(nameof(DisplayedAudioLevel));
|
||||||
|
OnPropertyChanged(nameof(AudioLevelWidthPercent));
|
||||||
|
|
||||||
|
FramesIn = stats.FramesIn;
|
||||||
|
FramesOut = stats.FramesOut;
|
||||||
|
FramesDropped = stats.FramesDropped;
|
||||||
|
IncomingResolution = stats.IncomingWidth > 0 && stats.IncomingHeight > 0
|
||||||
|
? $"{stats.IncomingWidth}×{stats.IncomingHeight}"
|
||||||
|
: "—";
|
||||||
|
IncomingFps = stats.IncomingFps > 0
|
||||||
|
? $"{stats.IncomingFps:0.0} fps"
|
||||||
|
: "—";
|
||||||
|
(StateLabel, StateColor) = stats.State switch
|
||||||
|
{
|
||||||
|
IsoState.Receiving => ("LIVE", "Wd.Status.Live"),
|
||||||
|
IsoState.Sending => ("LIVE", "Wd.Status.Live"),
|
||||||
|
IsoState.NoSignal => ("NO SIGNAL", "Wd.Status.Warn"),
|
||||||
|
IsoState.Error => ("ERROR", "Wd.Status.Error"),
|
||||||
|
IsoState.Idle => (IsEnabled ? "STARTING" : "—", "Wd.Text.Tertiary"),
|
||||||
|
_ => ("—", "Wd.Text.Tertiary"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
public bool IsProcessing
|
public bool IsProcessing
|
||||||
{
|
{
|
||||||
get => _isProcessing;
|
get => _isProcessing;
|
||||||
|
|
@ -48,11 +419,81 @@ public sealed class ParticipantViewModel : ObservableObject
|
||||||
public string CustomName
|
public string CustomName
|
||||||
{
|
{
|
||||||
get => _customName;
|
get => _customName;
|
||||||
set => SetField(ref _customName, value);
|
set
|
||||||
|
{
|
||||||
|
if (SetField(ref _customName, value))
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(OutputName));
|
||||||
|
OnPropertyChanged(nameof(EditableOutputName));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The NDI source name TeamsISO will broadcast this participant as. Prefers
|
||||||
|
/// the operator's <see cref="CustomName"/> when set; otherwise renders the
|
||||||
|
/// active template (default <c>"{name}"</c>, falling back to
|
||||||
|
/// <c>TEAMSISO_{guid}</c> when the participant has no display name yet).
|
||||||
|
/// Bound by the v2 participants table's mono "output name" column for
|
||||||
|
/// read-only display contexts.
|
||||||
|
/// </summary>
|
||||||
|
public string OutputName =>
|
||||||
|
string.IsNullOrWhiteSpace(_customName)
|
||||||
|
? Services.OutputNameTemplate.Render(
|
||||||
|
Services.OutputNameTemplate.Get(),
|
||||||
|
_participant.Id,
|
||||||
|
_participant.DisplayName)
|
||||||
|
: _customName;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Two-way binding endpoint for the inline-editable Output column. Reads
|
||||||
|
/// resolve to whatever name will actually be broadcast (<see cref="OutputName"/>);
|
||||||
|
/// writes set <see cref="CustomName"/> with a couple of UX niceties:
|
||||||
|
///
|
||||||
|
/// • Clearing the field (empty / whitespace) reverts to the template
|
||||||
|
/// default — the user doesn't have to remember the template syntax to
|
||||||
|
/// "undo" a customization.
|
||||||
|
///
|
||||||
|
/// • Typing a value that exactly matches the resolved default is treated
|
||||||
|
/// as a no-op (CustomName stays empty), so the participant continues
|
||||||
|
/// to follow the template when their display name changes upstream.
|
||||||
|
/// Without this, typing the auto-suggested value would silently
|
||||||
|
/// "pin" the participant to a stale name forever.
|
||||||
|
/// </summary>
|
||||||
|
public string EditableOutputName
|
||||||
|
{
|
||||||
|
get => OutputName;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
var trimmed = (value ?? string.Empty).Trim();
|
||||||
|
var defaultRendered = Services.OutputNameTemplate.Render(
|
||||||
|
Services.OutputNameTemplate.Get(),
|
||||||
|
_participant.Id,
|
||||||
|
_participant.DisplayName);
|
||||||
|
|
||||||
|
CustomName = string.IsNullOrWhiteSpace(trimmed) ||
|
||||||
|
string.Equals(trimmed, defaultRendered, StringComparison.Ordinal)
|
||||||
|
? string.Empty
|
||||||
|
: trimmed;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public AsyncRelayCommand ToggleIsoCommand { get; }
|
public AsyncRelayCommand ToggleIsoCommand { get; }
|
||||||
|
|
||||||
|
/// <summary>Copy the participant's NDI source FullName to the clipboard. Useful for pasting into Studio Monitor.</summary>
|
||||||
|
public RelayCommand CopySourceNameCommand { get; }
|
||||||
|
|
||||||
|
/// <summary>Open a non-modal floating preview window for this participant. Multi-monitor friendly.</summary>
|
||||||
|
public RelayCommand OpenPreviewCommand { get; }
|
||||||
|
public RelayCommand SaveSnapshotCommand { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Restart the pipeline for this participant: disable, brief pause, re-enable.
|
||||||
|
/// Useful when a single feed flakes (drops climb, framerate jitters) without
|
||||||
|
/// affecting other ISOs. No-op when the pipeline isn't currently enabled.
|
||||||
|
/// </summary>
|
||||||
|
public AsyncRelayCommand RestartIsoCommand { get; }
|
||||||
|
|
||||||
/// <summary>Refreshes the underlying participant data (called when the controller emits an updated list).</summary>
|
/// <summary>Refreshes the underlying participant data (called when the controller emits an updated list).</summary>
|
||||||
public void Update(Participant updated)
|
public void Update(Participant updated)
|
||||||
{
|
{
|
||||||
|
|
@ -61,6 +502,12 @@ public sealed class ParticipantViewModel : ObservableObject
|
||||||
OnPropertyChanged(nameof(SourceMachine));
|
OnPropertyChanged(nameof(SourceMachine));
|
||||||
OnPropertyChanged(nameof(SourceFullName));
|
OnPropertyChanged(nameof(SourceFullName));
|
||||||
OnPropertyChanged(nameof(IsOnline));
|
OnPropertyChanged(nameof(IsOnline));
|
||||||
|
// OutputName/EditableOutputName both derive from _participant.DisplayName
|
||||||
|
// when no per-participant CustomName is set — re-notify so the Output
|
||||||
|
// column tracks upstream Teams name changes for participants who
|
||||||
|
// haven't been manually renamed.
|
||||||
|
OnPropertyChanged(nameof(OutputName));
|
||||||
|
OnPropertyChanged(nameof(EditableOutputName));
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task ToggleIsoAsync()
|
private async Task ToggleIsoAsync()
|
||||||
|
|
@ -75,13 +522,51 @@ public sealed class ParticipantViewModel : ObservableObject
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
// Resolve the output name: explicit per-participant CustomName
|
||||||
|
// wins; otherwise expand the operator's template (default is
|
||||||
|
// "{name}" since 0.9.0-rc19, with an empty-name fallback to
|
||||||
|
// TEAMSISO_{guid} inside Render). Passing the rendered name
|
||||||
|
// to EnableIsoAsync as customName overrides the engine's
|
||||||
|
// DefaultOutputName path.
|
||||||
|
var resolvedName = string.IsNullOrWhiteSpace(_customName)
|
||||||
|
? Services.OutputNameTemplate.Render(
|
||||||
|
Services.OutputNameTemplate.Get(),
|
||||||
|
Id,
|
||||||
|
DisplayName)
|
||||||
|
: _customName;
|
||||||
|
// Per-participant recording opt-out: when RecordToDisk is false,
|
||||||
|
// pass a false override so the engine doesn't attach a recorder
|
||||||
|
// even if global recording is on.
|
||||||
|
bool? recordOverride = _recordToDisk ? null : false;
|
||||||
await _controller.EnableIsoAsync(
|
await _controller.EnableIsoAsync(
|
||||||
Id,
|
Id,
|
||||||
string.IsNullOrWhiteSpace(_customName) ? null : _customName,
|
resolvedName,
|
||||||
|
recordOverride,
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
IsEnabled = true;
|
IsEnabled = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
catch (InvalidOperationException)
|
||||||
|
{
|
||||||
|
// Race window: participant left the meeting between when the operator
|
||||||
|
// clicked Enable/Disable and when the engine resolved the ID. The
|
||||||
|
// controller throws InvalidOperationException with a "not currently
|
||||||
|
// visible on the network" message in this case. Surface it as a soft
|
||||||
|
// warning toast rather than letting it escape into the dispatcher's
|
||||||
|
// unhandled-exception channel (which fires a fatal crash dialog).
|
||||||
|
//
|
||||||
|
// Leave IsEnabled at its current value — the engine refused the state
|
||||||
|
// change, so the VM should reflect the actual engine state.
|
||||||
|
_toast?.Warn($"{DisplayName} just left the meeting");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// Defensive catch-all for any other engine-side failure (port bind
|
||||||
|
// race, pipeline factory throw, etc.). Same reasoning as above —
|
||||||
|
// an exception from an operator click should never tear down the
|
||||||
|
// dispatcher.
|
||||||
|
_toast?.Warn($"Couldn't toggle ISO for {DisplayName}: {ex.Message}");
|
||||||
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
IsProcessing = false;
|
IsProcessing = false;
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,38 @@ public sealed class RelayCommand : ICommand
|
||||||
public void RaiseCanExecuteChanged() => CanExecuteChanged?.Invoke(this, EventArgs.Empty);
|
public void RaiseCanExecuteChanged() => CanExecuteChanged?.Invoke(this, EventArgs.Empty);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Synchronous command that accepts a typed parameter. Used by hotkeys
|
||||||
|
/// that need to pass an index (e.g. NumPad1..NumPad9 → 1..9). The
|
||||||
|
/// parameter is converted from object via Convert.ChangeType so XAML
|
||||||
|
/// CommandParameter="1" works for int T.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class RelayCommand<T> : ICommand
|
||||||
|
{
|
||||||
|
private readonly Action<T> _execute;
|
||||||
|
private readonly Func<T, bool>? _canExecute;
|
||||||
|
|
||||||
|
public RelayCommand(Action<T> execute, Func<T, bool>? canExecute = null)
|
||||||
|
{
|
||||||
|
_execute = execute;
|
||||||
|
_canExecute = canExecute;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool CanExecute(object? parameter) => _canExecute?.Invoke(Convert<T>(parameter)) ?? true;
|
||||||
|
public void Execute(object? parameter) => _execute(Convert<T>(parameter));
|
||||||
|
|
||||||
|
public event EventHandler? CanExecuteChanged;
|
||||||
|
public void RaiseCanExecuteChanged() => CanExecuteChanged?.Invoke(this, EventArgs.Empty);
|
||||||
|
|
||||||
|
private static TValue Convert<TValue>(object? value)
|
||||||
|
{
|
||||||
|
if (value is null) return default!;
|
||||||
|
if (value is TValue typed) return typed;
|
||||||
|
try { return (TValue)System.Convert.ChangeType(value, typeof(TValue)); }
|
||||||
|
catch { return default!; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Async command that suppresses re-entrancy while running.
|
/// Async command that suppresses re-entrancy while running.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
|
||||||
84
src/TeamsISO.App/ViewModels/ToastViewModel.cs
Normal file
84
src/TeamsISO.App/ViewModels/ToastViewModel.cs
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
using System.Windows.Input;
|
||||||
|
using System.Windows.Threading;
|
||||||
|
|
||||||
|
namespace TeamsISO.App.ViewModels;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Lightweight transient-notification view-model. The main view holds a single
|
||||||
|
/// instance bound to a small overlay at the bottom of the content area.
|
||||||
|
/// <see cref="Show"/> displays a message for a fixed duration before auto-hiding;
|
||||||
|
/// successive Show calls reset the timer instead of stacking, so the user always
|
||||||
|
/// sees the most recent action's feedback.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ToastViewModel : ObservableObject
|
||||||
|
{
|
||||||
|
private readonly DispatcherTimer _hideTimer;
|
||||||
|
private string _message = string.Empty;
|
||||||
|
private bool _isVisible;
|
||||||
|
private string _accent = "Wd.Accent.Cyan";
|
||||||
|
|
||||||
|
public ToastViewModel(Dispatcher dispatcher)
|
||||||
|
{
|
||||||
|
_hideTimer = new DispatcherTimer(DispatcherPriority.Background, dispatcher)
|
||||||
|
{
|
||||||
|
Interval = TimeSpan.FromSeconds(3),
|
||||||
|
};
|
||||||
|
_hideTimer.Tick += (_, _) =>
|
||||||
|
{
|
||||||
|
_hideTimer.Stop();
|
||||||
|
IsVisible = false;
|
||||||
|
};
|
||||||
|
DismissCommand = new RelayCommand(Hide);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Manual dismiss. Stops the auto-hide timer and hides the toast
|
||||||
|
/// immediately. Bound to the X close button on the toast overlay so an
|
||||||
|
/// operator running a live show can clear visual clutter without waiting
|
||||||
|
/// 3 seconds.
|
||||||
|
/// </summary>
|
||||||
|
public ICommand DismissCommand { get; }
|
||||||
|
|
||||||
|
private void Hide()
|
||||||
|
{
|
||||||
|
_hideTimer.Stop();
|
||||||
|
IsVisible = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Message
|
||||||
|
{
|
||||||
|
get => _message;
|
||||||
|
private set => SetField(ref _message, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsVisible
|
||||||
|
{
|
||||||
|
get => _isVisible;
|
||||||
|
private set => SetField(ref _isVisible, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Brush resource key for the accent dot. "Wd.Accent.Cyan" for success-style
|
||||||
|
/// (default), "Wd.Accent.Coral" for warnings.
|
||||||
|
/// </summary>
|
||||||
|
public string Accent
|
||||||
|
{
|
||||||
|
get => _accent;
|
||||||
|
private set => SetField(ref _accent, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Show a success-style toast for ~3 seconds.</summary>
|
||||||
|
public void Show(string message) => ShowImpl(message, "Wd.Accent.Cyan");
|
||||||
|
|
||||||
|
/// <summary>Show a warning-style toast (coral accent) for ~3 seconds.</summary>
|
||||||
|
public void Warn(string message) => ShowImpl(message, "Wd.Accent.Coral");
|
||||||
|
|
||||||
|
private void ShowImpl(string message, string accentKey)
|
||||||
|
{
|
||||||
|
Message = message;
|
||||||
|
Accent = accentKey;
|
||||||
|
IsVisible = true;
|
||||||
|
_hideTimer.Stop();
|
||||||
|
_hideTimer.Start();
|
||||||
|
}
|
||||||
|
}
|
||||||
65
src/TeamsISO.App/ViewModels/UpdateBannerViewModel.cs
Normal file
65
src/TeamsISO.App/ViewModels/UpdateBannerViewModel.cs
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
using TeamsISO.App.Services;
|
||||||
|
|
||||||
|
namespace TeamsISO.App.ViewModels;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Persistent banner shown when the launch-time update check finds a newer
|
||||||
|
/// release. Stays visible until the operator clicks "Get update" (which opens
|
||||||
|
/// the releases page) or "Dismiss" (which hides until next launch).
|
||||||
|
///
|
||||||
|
/// Distinct from <see cref="AlertBannerViewModel"/> because that one's tied to
|
||||||
|
/// engine alerts and would force us to leak update concerns into the engine
|
||||||
|
/// layer; the update banner is purely a host-shell concern.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class UpdateBannerViewModel : ObservableObject
|
||||||
|
{
|
||||||
|
private bool _isVisible;
|
||||||
|
private string _latestVersion = string.Empty;
|
||||||
|
private string _currentVersion = string.Empty;
|
||||||
|
|
||||||
|
public bool IsVisible
|
||||||
|
{
|
||||||
|
get => _isVisible;
|
||||||
|
private set => SetField(ref _isVisible, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string LatestVersion
|
||||||
|
{
|
||||||
|
get => _latestVersion;
|
||||||
|
private set => SetField(ref _latestVersion, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string CurrentVersion
|
||||||
|
{
|
||||||
|
get => _currentVersion;
|
||||||
|
private set => SetField(ref _currentVersion, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Friendly composite "1.0.0 → 1.1.0" string for the banner.</summary>
|
||||||
|
public string Message => $"Update available — v{_currentVersion} → {_latestVersion}";
|
||||||
|
|
||||||
|
public RelayCommand OpenReleasePageCommand { get; }
|
||||||
|
public RelayCommand DismissCommand { get; }
|
||||||
|
|
||||||
|
public UpdateBannerViewModel()
|
||||||
|
{
|
||||||
|
OpenReleasePageCommand = new RelayCommand(() =>
|
||||||
|
{
|
||||||
|
UpdateChecker.OpenReleasesPage();
|
||||||
|
IsVisible = false;
|
||||||
|
});
|
||||||
|
DismissCommand = new RelayCommand(() => IsVisible = false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Show the banner with a "you're on X, latest is Y" message. Idempotent:
|
||||||
|
/// re-showing while already visible just refreshes the version strings.
|
||||||
|
/// </summary>
|
||||||
|
public void Show(string current, string latest)
|
||||||
|
{
|
||||||
|
CurrentVersion = current;
|
||||||
|
LatestVersion = latest;
|
||||||
|
OnPropertyChanged(nameof(Message));
|
||||||
|
IsVisible = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
198
src/TeamsISO.App/Views/CommandPaletteWindow.xaml
Normal file
198
src/TeamsISO.App/Views/CommandPaletteWindow.xaml
Normal file
|
|
@ -0,0 +1,198 @@
|
||||||
|
<Window x:Class="TeamsISO.App.Views.CommandPaletteWindow"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:vm="clr-namespace:TeamsISO.App.ViewModels"
|
||||||
|
xmlns:conv="clr-namespace:TeamsISO.App.Converters"
|
||||||
|
Width="560" Height="360"
|
||||||
|
WindowStartupLocation="CenterOwner"
|
||||||
|
WindowStyle="None"
|
||||||
|
AllowsTransparency="True"
|
||||||
|
ShowInTaskbar="False"
|
||||||
|
Background="Transparent"
|
||||||
|
ResizeMode="NoResize"
|
||||||
|
Topmost="True"
|
||||||
|
d:DataContext="{d:DesignInstance Type=vm:CommandPaletteViewModel}"
|
||||||
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
mc:Ignorable="d">
|
||||||
|
<Window.Resources>
|
||||||
|
<conv:NullToCollapsedConverter x:Key="NullToCollapsed"/>
|
||||||
|
</Window.Resources>
|
||||||
|
<!--
|
||||||
|
v2 command palette — Ctrl+K / Ctrl+P. The navigation surface for the
|
||||||
|
Studio Terminal redesign: operators with attention budgets in the
|
||||||
|
low single digits type two letters, press Enter, action fires.
|
||||||
|
|
||||||
|
Chromeless window so the palette feels like a popover, not another
|
||||||
|
OS-level top-level. The single rounded card inside is the entire
|
||||||
|
affordance; the host window background is Transparent and the card
|
||||||
|
sits centered. Esc closes the window from code-behind.
|
||||||
|
-->
|
||||||
|
<Border CornerRadius="{StaticResource Radius.L}"
|
||||||
|
Background="{DynamicResource Wd.SurfaceElevated}"
|
||||||
|
BorderBrush="{DynamicResource Wd.BorderStrong}"
|
||||||
|
BorderThickness="1"
|
||||||
|
SnapsToDevicePixels="True">
|
||||||
|
<Border.Effect>
|
||||||
|
<DropShadowEffect BlurRadius="32" ShadowDepth="0" Opacity="0.35" Color="Black"/>
|
||||||
|
</Border.Effect>
|
||||||
|
<DockPanel LastChildFill="True">
|
||||||
|
|
||||||
|
<!-- Search input — autofocused on open. The placeholder doubles
|
||||||
|
as a help hint until the operator types. -->
|
||||||
|
<Border DockPanel.Dock="Top"
|
||||||
|
BorderBrush="{DynamicResource Wd.Border}"
|
||||||
|
BorderThickness="0,0,0,1"
|
||||||
|
Padding="16,12">
|
||||||
|
<Grid>
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<TextBlock Grid.Column="0"
|
||||||
|
Text="⌘"
|
||||||
|
FontFamily="{StaticResource Wd.Font.Mono}"
|
||||||
|
FontSize="14"
|
||||||
|
Foreground="{DynamicResource Wd.Text.Tertiary}"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Margin="0,0,12,0"/>
|
||||||
|
<TextBox Grid.Column="1"
|
||||||
|
x:Name="FilterBox"
|
||||||
|
Text="{Binding Filter, UpdateSourceTrigger=PropertyChanged, Delay=20}"
|
||||||
|
FontFamily="{StaticResource Wd.Font.Sans}"
|
||||||
|
FontSize="14"
|
||||||
|
Background="Transparent"
|
||||||
|
BorderThickness="0"
|
||||||
|
Foreground="{DynamicResource Wd.Text.Primary}"
|
||||||
|
CaretBrush="{DynamicResource Wd.Accent.Cyan}"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Padding="0">
|
||||||
|
<TextBox.Style>
|
||||||
|
<Style TargetType="TextBox">
|
||||||
|
<Style.Resources>
|
||||||
|
<VisualBrush x:Key="PlaceholderBrush" TileMode="None" Stretch="None" AlignmentX="Left" AlignmentY="Center">
|
||||||
|
<VisualBrush.Visual>
|
||||||
|
<TextBlock Text="Type a command…"
|
||||||
|
FontFamily="{StaticResource Wd.Font.Sans}"
|
||||||
|
FontSize="14"
|
||||||
|
Foreground="{DynamicResource Wd.Text.Tertiary}"/>
|
||||||
|
</VisualBrush.Visual>
|
||||||
|
</VisualBrush>
|
||||||
|
</Style.Resources>
|
||||||
|
<Style.Triggers>
|
||||||
|
<Trigger Property="Text" Value="">
|
||||||
|
<Setter Property="Background" Value="{StaticResource PlaceholderBrush}"/>
|
||||||
|
</Trigger>
|
||||||
|
</Style.Triggers>
|
||||||
|
</Style>
|
||||||
|
</TextBox.Style>
|
||||||
|
</TextBox>
|
||||||
|
<TextBlock Grid.Column="2"
|
||||||
|
Text="esc to close"
|
||||||
|
FontFamily="{StaticResource Wd.Font.Mono}"
|
||||||
|
FontSize="10"
|
||||||
|
Foreground="{DynamicResource Wd.Text.Disabled}"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Margin="12,0,0,0"/>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- Footer hint -->
|
||||||
|
<Border DockPanel.Dock="Bottom"
|
||||||
|
BorderBrush="{DynamicResource Wd.Border}"
|
||||||
|
BorderThickness="0,1,0,0"
|
||||||
|
Padding="16,8">
|
||||||
|
<TextBlock FontFamily="{StaticResource Wd.Font.Mono}"
|
||||||
|
FontSize="10"
|
||||||
|
Foreground="{DynamicResource Wd.Text.Tertiary}">
|
||||||
|
<Run Text="↑ ↓ navigate"/>
|
||||||
|
<Run Text="·" Foreground="{DynamicResource Wd.Text.Disabled}"/>
|
||||||
|
<Run Text="enter invoke"/>
|
||||||
|
<Run Text="·" Foreground="{DynamicResource Wd.Text.Disabled}"/>
|
||||||
|
<Run Text="esc close"/>
|
||||||
|
</TextBlock>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- Results list — bound to CommandPaletteViewModel.Visible -->
|
||||||
|
<ListBox x:Name="ResultsList"
|
||||||
|
ItemsSource="{Binding Visible}"
|
||||||
|
SelectedItem="{Binding Selected}"
|
||||||
|
Background="Transparent"
|
||||||
|
BorderThickness="0"
|
||||||
|
Padding="6,6,6,6"
|
||||||
|
Focusable="False"
|
||||||
|
ScrollViewer.HorizontalScrollBarVisibility="Disabled"
|
||||||
|
ScrollViewer.VerticalScrollBarVisibility="Auto"
|
||||||
|
HorizontalContentAlignment="Stretch">
|
||||||
|
<ListBox.ItemContainerStyle>
|
||||||
|
<Style TargetType="ListBoxItem">
|
||||||
|
<Setter Property="Background" Value="Transparent"/>
|
||||||
|
<Setter Property="Padding" Value="10,8"/>
|
||||||
|
<Setter Property="Margin" Value="0,1"/>
|
||||||
|
<Setter Property="Focusable" Value="False"/>
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate TargetType="ListBoxItem">
|
||||||
|
<Border x:Name="Bd"
|
||||||
|
Background="{TemplateBinding Background}"
|
||||||
|
CornerRadius="{StaticResource Radius.S}"
|
||||||
|
Padding="{TemplateBinding Padding}">
|
||||||
|
<ContentPresenter/>
|
||||||
|
</Border>
|
||||||
|
<ControlTemplate.Triggers>
|
||||||
|
<Trigger Property="IsSelected" Value="True">
|
||||||
|
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource Wd.Accent.CyanMuted}"/>
|
||||||
|
</Trigger>
|
||||||
|
<Trigger Property="IsMouseOver" Value="True">
|
||||||
|
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource Wd.SurfaceHover}"/>
|
||||||
|
</Trigger>
|
||||||
|
</ControlTemplate.Triggers>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
</ListBox.ItemContainerStyle>
|
||||||
|
<ListBox.ItemTemplate>
|
||||||
|
<DataTemplate DataType="{x:Type vm:PaletteCommand}">
|
||||||
|
<Grid>
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="80"/>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<TextBlock Grid.Column="0"
|
||||||
|
Text="{Binding Category}"
|
||||||
|
FontFamily="{StaticResource Wd.Font.Sans}"
|
||||||
|
FontSize="10"
|
||||||
|
FontWeight="SemiBold"
|
||||||
|
Foreground="{DynamicResource Wd.Text.Tertiary}"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
<TextBlock Grid.Column="1"
|
||||||
|
Text="{Binding Label}"
|
||||||
|
FontFamily="{StaticResource Wd.Font.Sans}"
|
||||||
|
FontSize="13"
|
||||||
|
Foreground="{DynamicResource Wd.Text.Primary}"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
TextTrimming="CharacterEllipsis"/>
|
||||||
|
<Border Grid.Column="2"
|
||||||
|
Background="{DynamicResource Wd.Surface}"
|
||||||
|
BorderBrush="{DynamicResource Wd.Border}"
|
||||||
|
BorderThickness="1"
|
||||||
|
CornerRadius="{StaticResource Radius.S}"
|
||||||
|
Padding="6,1"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Margin="12,0,0,0"
|
||||||
|
Visibility="{Binding Shortcut, Converter={StaticResource NullToCollapsed}}">
|
||||||
|
<TextBlock Text="{Binding Shortcut}"
|
||||||
|
FontFamily="{StaticResource Wd.Font.Mono}"
|
||||||
|
FontSize="10"
|
||||||
|
Foreground="{DynamicResource Wd.Text.Secondary}"/>
|
||||||
|
</Border>
|
||||||
|
</Grid>
|
||||||
|
</DataTemplate>
|
||||||
|
</ListBox.ItemTemplate>
|
||||||
|
</ListBox>
|
||||||
|
</DockPanel>
|
||||||
|
</Border>
|
||||||
|
</Window>
|
||||||
84
src/TeamsISO.App/Views/CommandPaletteWindow.xaml.cs
Normal file
84
src/TeamsISO.App/Views/CommandPaletteWindow.xaml.cs
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Controls;
|
||||||
|
using System.Windows.Input;
|
||||||
|
using TeamsISO.App.ViewModels;
|
||||||
|
|
||||||
|
namespace TeamsISO.App.Views;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The Ctrl+K command palette window. Centered over its <see cref="Window.Owner"/>,
|
||||||
|
/// chromeless, dismissable with Esc or clicking outside the palette card.
|
||||||
|
///
|
||||||
|
/// Keyboard contract:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item>Type to filter (autofocus on the TextBox at open time)</item>
|
||||||
|
/// <item>↑ / ↓ — move the selection</item>
|
||||||
|
/// <item>Enter — invoke the highlighted command, then close</item>
|
||||||
|
/// <item>Esc — close without invoking</item>
|
||||||
|
/// </list>
|
||||||
|
/// </summary>
|
||||||
|
public partial class CommandPaletteWindow : Window
|
||||||
|
{
|
||||||
|
public CommandPaletteWindow()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
// Autofocus the filter input on open so the operator can start typing
|
||||||
|
// immediately — the whole point of Ctrl+K is "no mouse required".
|
||||||
|
Loaded += (_, _) => FilterBox.Focus();
|
||||||
|
// Closing on click-outside: WindowStyle=None means we don't get the
|
||||||
|
// standard "deactivated" behavior cleanly, but Deactivated still
|
||||||
|
// fires when focus leaves the window (e.g., clicking the main shell).
|
||||||
|
Deactivated += (_, _) => Close();
|
||||||
|
}
|
||||||
|
|
||||||
|
public CommandPaletteWindow(CommandPaletteViewModel vm) : this()
|
||||||
|
{
|
||||||
|
DataContext = vm;
|
||||||
|
}
|
||||||
|
|
||||||
|
private CommandPaletteViewModel Vm => (CommandPaletteViewModel)DataContext;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Window-level key handling. We use PreviewKeyDown so the TextBox's
|
||||||
|
/// internal handling doesn't swallow ↑/↓/Enter before we see them.
|
||||||
|
/// </summary>
|
||||||
|
protected override void OnPreviewKeyDown(System.Windows.Input.KeyEventArgs e)
|
||||||
|
{
|
||||||
|
switch (e.Key)
|
||||||
|
{
|
||||||
|
case Key.Escape:
|
||||||
|
e.Handled = true;
|
||||||
|
Close();
|
||||||
|
break;
|
||||||
|
case Key.Down:
|
||||||
|
e.Handled = true;
|
||||||
|
Vm?.MoveSelection(+1);
|
||||||
|
ScrollSelectedIntoView();
|
||||||
|
break;
|
||||||
|
case Key.Up:
|
||||||
|
e.Handled = true;
|
||||||
|
Vm?.MoveSelection(-1);
|
||||||
|
ScrollSelectedIntoView();
|
||||||
|
break;
|
||||||
|
case Key.Enter:
|
||||||
|
e.Handled = true;
|
||||||
|
var invoked = Vm?.InvokeSelection() ?? false;
|
||||||
|
// Close regardless — Enter on an empty palette is "I'm done";
|
||||||
|
// Enter on a real command means the action fired and the
|
||||||
|
// operator wants to return to the main shell.
|
||||||
|
if (invoked) Close();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
base.OnPreviewKeyDown(e);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ScrollSelectedIntoView()
|
||||||
|
{
|
||||||
|
if (ResultsList is null) return;
|
||||||
|
var sel = ResultsList.SelectedItem;
|
||||||
|
if (sel is null) return;
|
||||||
|
ResultsList.ScrollIntoView(sel);
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue