Compare commits
154 commits
phase-b-2-
...
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 | |||
| e3321ff279 | |||
| d64b110550 | |||
| 8c441318d8 | |||
| fbb73bcf04 | |||
| 2c6fbdf861 |
165 changed files with 21052 additions and 3421 deletions
|
|
@ -59,14 +59,14 @@ jobs:
|
|||
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: test-results
|
||||
path: '**/test-results.trx'
|
||||
|
||||
- name: Upload coverage report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: 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
|
||||
.DS_Store
|
||||
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>
|
||||
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
|
||||
<AnalysisLevel>latest</AnalysisLevel>
|
||||
<Version>1.0.0-alpha.0</Version>
|
||||
<Version>1.0.0</Version>
|
||||
<Authors>Wild Dragon LLC</Authors>
|
||||
<Company>Wild Dragon LLC</Company>
|
||||
<Product>TeamsISO</Product>
|
||||
|
|
|
|||
126
README.md
126
README.md
|
|
@ -1,20 +1,128 @@
|
|||
# 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
|
||||
dotnet test
|
||||
## Install
|
||||
|
||||
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
|
||||
|
||||
Proprietary, © Wild Dragon LLC 2026.
|
||||
Proprietary, © Wild Dragon LLC 2026. All rights reserved.
|
||||
|
|
|
|||
14
TeamsISO.Windows.slnf
Normal file
14
TeamsISO.Windows.slnf
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"solution": {
|
||||
"path": "TeamsISO.sln",
|
||||
"projects": [
|
||||
"src\\TeamsISO.Engine\\TeamsISO.Engine.csproj",
|
||||
"src\\TeamsISO.Engine.NdiInterop\\TeamsISO.Engine.NdiInterop.csproj",
|
||||
"src\\TeamsISO.Console\\TeamsISO.Console.csproj",
|
||||
"src\\TeamsISO.App\\TeamsISO.App.csproj",
|
||||
"src\\tests\\TeamsISO.Engine.Tests\\TeamsISO.Engine.Tests.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
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeamsISO.Console", "src\TeamsISO.Console\TeamsISO.Console.csproj", "{C3254998-9428-4264-A8FB-EAC9E1F9F432}"
|
||||
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
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
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}.Release|Any CPU.ActiveCfg = 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
|
||||
GlobalSection(NestedProjects) = preSolution
|
||||
{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}
|
||||
{A85E331D-026E-4BDE-B89C-0CC4C95001CE} = {DBDF4A1D-4215-42D5-B456-2CE7159DF848}
|
||||
{C3254998-9428-4264-A8FB-EAC9E1F9F432} = {46E05E34-8A87-4986-87D3-FE0DE4E05F44}
|
||||
{B5A6F1E7-3D2C-4F89-9A55-7E1B2A4C8D6F} = {DBDF4A1D-4215-42D5-B456-2CE7159DF848}
|
||||
EndGlobalSection
|
||||
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,13 +0,0 @@
|
|||
# Plan Backlog
|
||||
|
||||
## Completed phases
|
||||
|
||||
- **Phase A — Engine Foundation** (tag: `phase-a-complete`) — domain model, parsers, participant tracker, frame processor, config persistence, fakes, CI with 80% coverage gate.
|
||||
- **Phase B-1 — Pipeline Orchestration** (tag: `phase-b-1-complete`) — `NdiReceiver`, `NdiSender`, `ExponentialBackoff`, `NdiRuntimeProbe`, `IsoPipeline` (with restart supervisor), `IsoController`. All testable on Linux.
|
||||
- **Phase B-2 — Real NDI Interop** (tag: `phase-b-2-complete`) — production `NdiInteropPInvoke` against NDI 6 SDK, managed BGRA scaler with aspect modes, `TeamsISO.Console` headless smoke runner, `NdiVersion` constants. **Compiles on Linux; runs only on Windows with the NDI Runtime installed.**
|
||||
|
||||
## Next
|
||||
|
||||
1. **Phase C — UI & Packaging** (Windows) — WPF MVVM app on top of `IIsoController`. Participant list (DataGrid bound to `Participants` observable), global settings view (framerate, resolution, aspect, audio mode), engine alert banner, system health indicators. WiX MSI installer, release pipeline on tag, About dialog.
|
||||
|
||||
2. **First Windows validation** — once on a Windows machine: install NDI Runtime, run `dotnet build`, run `dotnet test --filter "requires=ndi"` against an NDI Test Pattern source, run `TeamsISO.Console --enable-all` against a real Teams meeting.
|
||||
|
|
@ -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,15 +0,0 @@
|
|||
# TeamsISO Manual Test Playbook
|
||||
|
||||
This doc grows with each phase. Phase A is unit-test only — nothing to verify against live Teams yet. Phase B will fill in NDI runtime checks; Phase C will add the live-meeting end-to-end checklist.
|
||||
|
||||
## Pre-checks (run before each release branch)
|
||||
|
||||
- [ ] `dotnet build TeamsISO.sln` succeeds with zero warnings on Windows.
|
||||
- [ ] `dotnet build TeamsISO.Linux.slnf` succeeds with zero warnings on Linux/macOS.
|
||||
- [ ] `dotnet test TeamsISO.Linux.slnf --filter "Category!=ndi&requires!=ndi"` reports all unit tests passing.
|
||||
- [ ] CI run on `main` is green.
|
||||
- [ ] Code coverage on `TeamsISO.Engine` is ≥80%.
|
||||
|
||||
## Live-meeting checklist (Phase C)
|
||||
|
||||
(To be added.)
|
||||
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,6 +1,23 @@
|
|||
<Application x:Class="TeamsISO.App.App"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
StartupUri="MainWindow.xaml">
|
||||
<Application.Resources/>
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,305 @@
|
|||
using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Windows;
|
||||
using System.Windows.Interop;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using TeamsISO.App.ViewModels;
|
||||
using TeamsISO.Engine.Controller;
|
||||
using TeamsISO.Engine.Logging;
|
||||
using TeamsISO.Engine.NdiInterop;
|
||||
|
||||
// Application + MessageBox aliases live in GlobalUsings.cs (project-wide).
|
||||
// Don't redeclare here — Roslyn errors with CS1537 on duplicate alias.
|
||||
|
||||
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
|
||||
{
|
||||
/// <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 NdiInteropPInvoke? _interop;
|
||||
private IsoController? _controller;
|
||||
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)
|
||||
{
|
||||
// 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);
|
||||
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
|
||||
{
|
||||
StartupTrace.Write("Bootstrap try-block ENTER");
|
||||
_loggerFactory = EngineLogging.CreateDefault(LogLevel.Information);
|
||||
StartupTrace.Write("EngineLogging.CreateDefault OK");
|
||||
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");
|
||||
|
||||
if (!TryBootstrapNdiInterop())
|
||||
{
|
||||
StartupTrace.Write("TryBootstrapNdiInterop returned false — Shutdown(2)");
|
||||
Shutdown(2);
|
||||
return;
|
||||
}
|
||||
StartupTrace.Write("TryBootstrapNdiInterop OK");
|
||||
|
||||
BootstrapEngine();
|
||||
StartupTrace.Write("BootstrapEngine OK");
|
||||
var window = ConstructAndShowMainWindow();
|
||||
StartupTrace.Write("ConstructAndShowMainWindow OK (window shown)");
|
||||
BootstrapControlSurfaceServices();
|
||||
StartupTrace.Write("BootstrapControlSurfaceServices OK");
|
||||
BootstrapTrayIcon(window);
|
||||
StartupTrace.Write("BootstrapTrayIcon OK");
|
||||
TryShowOnboarding(window);
|
||||
StartupTrace.Write("TryShowOnboarding returned");
|
||||
|
||||
ApplyCommandLineArgs(e.Args);
|
||||
StartupTrace.Write("ApplyCommandLineArgs OK");
|
||||
|
||||
StartupTrace.Write("about to await _viewModel.InitializeAsync");
|
||||
await _viewModel!.InitializeAsync(CancellationToken.None);
|
||||
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 () =>
|
||||
{
|
||||
await Task.Delay(5000);
|
||||
try
|
||||
{
|
||||
var n = await Dispatcher.InvokeAsync(() => _viewModel?.Participants.Count ?? -1);
|
||||
StartupTrace.Write($"+5s after init: vm.Participants.Count={n}");
|
||||
}
|
||||
catch (Exception ex) { StartupTrace.Write($"+5s probe THREW: {ex.Message}"); }
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StartupTrace.Write($"OnStartup CATCH: {ex}");
|
||||
try { _loggerFactory?.CreateLogger<App>().LogCritical(ex, "OnStartup failed before main loop"); }
|
||||
catch { /* defensive */ }
|
||||
MessageBox.Show(
|
||||
"TeamsISO failed to start.\n\nDetails: " + ex,
|
||||
"TeamsISO — startup error",
|
||||
MessageBoxButton.OK,
|
||||
MessageBoxImage.Error);
|
||||
Shutdown(1);
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
{
|
||||
try
|
||||
{
|
||||
_trayIcon?.Dispose();
|
||||
if (_controlSurface is not null)
|
||||
await _controlSurface.DisposeAsync();
|
||||
if (_oscBridge is not null)
|
||||
await _oscBridge.DisposeAsync();
|
||||
_viewModel?.Dispose();
|
||||
if (_controller is not null)
|
||||
await _controller.DisposeAsync();
|
||||
_interop?.Dispose();
|
||||
_loggerFactory?.Dispose();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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 |
18
src/TeamsISO.App/Converters/BoolToVisibilityConverter.cs
Normal file
18
src/TeamsISO.App/Converters/BoolToVisibilityConverter.cs
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
using System.Globalization;
|
||||
using System.Windows;
|
||||
using System.Windows.Data;
|
||||
|
||||
namespace TeamsISO.App.Converters;
|
||||
|
||||
[ValueConversion(typeof(bool), typeof(Visibility))]
|
||||
public sealed class BoolToVisibilityConverter : IValueConverter
|
||||
{
|
||||
public Visibility TrueValue { get; set; } = Visibility.Visible;
|
||||
public Visibility FalseValue { get; set; } = Visibility.Collapsed;
|
||||
|
||||
public object Convert(object value, Type targetType, object parameter, CultureInfo culture) =>
|
||||
value is true ? TrueValue : FalseValue;
|
||||
|
||||
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) =>
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
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();
|
||||
}
|
||||
52
src/TeamsISO.App/Converters/EnumDescriptionConverter.cs
Normal file
52
src/TeamsISO.App/Converters/EnumDescriptionConverter.cs
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
using System.Globalization;
|
||||
using System.Windows.Data;
|
||||
using TeamsISO.Engine.Domain;
|
||||
|
||||
namespace TeamsISO.App.Converters;
|
||||
|
||||
/// <summary>
|
||||
/// Renders engine enum values into operator-friendly strings.
|
||||
/// </summary>
|
||||
public sealed class EnumDescriptionConverter : IValueConverter
|
||||
{
|
||||
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) => value switch
|
||||
{
|
||||
TargetFramerate fr => fr switch
|
||||
{
|
||||
TargetFramerate.Fps23_976 => "23.976 fps",
|
||||
TargetFramerate.Fps24 => "24 fps",
|
||||
TargetFramerate.Fps25 => "25 fps",
|
||||
TargetFramerate.Fps29_97 => "29.97 fps",
|
||||
TargetFramerate.Fps30 => "30 fps",
|
||||
TargetFramerate.Fps50 => "50 fps",
|
||||
TargetFramerate.Fps59_94 => "59.94 fps",
|
||||
TargetFramerate.Fps60 => "60 fps",
|
||||
_ => fr.ToString()
|
||||
},
|
||||
TargetResolution r => r switch
|
||||
{
|
||||
TargetResolution.R720p => "720p",
|
||||
TargetResolution.R1080p => "1080p",
|
||||
TargetResolution.R4K => "4K",
|
||||
_ => r.ToString()
|
||||
},
|
||||
AspectMode a => a switch
|
||||
{
|
||||
AspectMode.Pillarbox => "Pillarbox",
|
||||
AspectMode.Letterbox => "Letterbox",
|
||||
AspectMode.Stretch => "Stretch",
|
||||
_ => a.ToString()
|
||||
},
|
||||
AudioMode m => m switch
|
||||
{
|
||||
AudioMode.Auto => "Auto (isolated → mixed fallback)",
|
||||
AudioMode.Isolated => "Isolated",
|
||||
AudioMode.Mixed => "Mixed",
|
||||
_ => m.ToString()
|
||||
},
|
||||
_ => value
|
||||
};
|
||||
|
||||
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,9 @@
|
|||
using System;
|
||||
using System.Linq;
|
||||
using System.Windows;
|
||||
using System.Windows.Input;
|
||||
using TeamsISO.App.Services;
|
||||
using TeamsISO.App.ViewModels;
|
||||
|
||||
namespace TeamsISO.App;
|
||||
|
||||
|
|
@ -7,5 +12,276 @@ public partial class MainWindow : Window
|
|||
public MainWindow()
|
||||
{
|
||||
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()
|
||||
{
|
||||
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,14 +4,85 @@
|
|||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net8.0-windows</TargetFramework>
|
||||
<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>
|
||||
<!-- WPF only builds on Windows. Non-Windows CI skips this project. -->
|
||||
<BuildOnNonWindows Condition="'$(OS)' != 'Windows_NT'">false</BuildOnNonWindows>
|
||||
<AssemblyName>TeamsISO</AssemblyName>
|
||||
<EnableWindowsTargeting>true</EnableWindowsTargeting>
|
||||
<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>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\TeamsISO.Engine\TeamsISO.Engine.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>
|
||||
|
||||
</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
31
src/TeamsISO.App/ViewModels/AlertBannerViewModel.cs
Normal file
31
src/TeamsISO.App/ViewModels/AlertBannerViewModel.cs
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
using TeamsISO.Engine.Domain;
|
||||
|
||||
namespace TeamsISO.App.ViewModels;
|
||||
|
||||
public sealed class AlertBannerViewModel : ObservableObject
|
||||
{
|
||||
private EngineAlert? _current;
|
||||
|
||||
public EngineAlert? Current
|
||||
{
|
||||
get => _current;
|
||||
set
|
||||
{
|
||||
if (SetField(ref _current, value))
|
||||
{
|
||||
OnPropertyChanged(nameof(IsVisible));
|
||||
OnPropertyChanged(nameof(Message));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsVisible => _current is not null;
|
||||
public string Message => _current?.Message ?? string.Empty;
|
||||
|
||||
public RelayCommand DismissCommand { get; }
|
||||
|
||||
public AlertBannerViewModel()
|
||||
{
|
||||
DismissCommand = new RelayCommand(() => Current = null);
|
||||
}
|
||||
}
|
||||
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);
|
||||
632
src/TeamsISO.App/ViewModels/GlobalSettingsViewModel.cs
Normal file
632
src/TeamsISO.App/ViewModels/GlobalSettingsViewModel.cs
Normal file
|
|
@ -0,0 +1,632 @@
|
|||
using System.IO;
|
||||
using System.Windows;
|
||||
using TeamsISO.App.Services;
|
||||
using TeamsISO.Engine.Controller;
|
||||
using TeamsISO.Engine.Domain;
|
||||
|
||||
namespace TeamsISO.App.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// Bindings for the global settings panel: framerate, resolution, aspect, audio,
|
||||
/// NDI groups (discovery + output), and the participant-list hide-Local toggle.
|
||||
/// </summary>
|
||||
public sealed class GlobalSettingsViewModel : ObservableObject
|
||||
{
|
||||
private readonly IIsoController _controller;
|
||||
private readonly ToastViewModel? _toast;
|
||||
private TargetFramerate _framerate;
|
||||
private TargetResolution _resolution;
|
||||
private AspectMode _aspect;
|
||||
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, ToastViewModel? toast = null)
|
||||
{
|
||||
_controller = controller;
|
||||
_toast = toast;
|
||||
var current = controller.GlobalSettings;
|
||||
_framerate = current.Framerate;
|
||||
_resolution = current.Resolution;
|
||||
_aspect = current.Aspect;
|
||||
_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);
|
||||
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<TargetResolution> AvailableResolutions => Enum.GetValues<TargetResolution>();
|
||||
public IEnumerable<AspectMode> AvailableAspectModes => Enum.GetValues<AspectMode>();
|
||||
public IEnumerable<AudioMode> AvailableAudioModes => Enum.GetValues<AudioMode>();
|
||||
|
||||
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>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; }
|
||||
|
||||
/// <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);
|
||||
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 */ }
|
||||
}
|
||||
}
|
||||
742
src/TeamsISO.App/ViewModels/MainViewModel.cs
Normal file
742
src/TeamsISO.App/ViewModels/MainViewModel.cs
Normal file
|
|
@ -0,0 +1,742 @@
|
|||
using System.Collections.ObjectModel;
|
||||
using System.ComponentModel;
|
||||
using System.Reactive.Concurrency;
|
||||
using System.Reactive.Linq;
|
||||
using System.Windows.Data;
|
||||
using System.Windows.Threading;
|
||||
using TeamsISO.App.Services;
|
||||
using TeamsISO.Engine.Controller;
|
||||
using TeamsISO.Engine.Domain;
|
||||
|
||||
namespace TeamsISO.App.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// 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>
|
||||
public sealed partial class MainViewModel : ObservableObject, IDisposable
|
||||
{
|
||||
private readonly IIsoController _controller;
|
||||
private readonly Dispatcher _dispatcher;
|
||||
private readonly IDisposable _participantsSub;
|
||||
private readonly IDisposable _alertsSub;
|
||||
private readonly DispatcherTimer _statsTimer;
|
||||
private readonly Dictionary<Guid, ParticipantViewModel> _byId = new();
|
||||
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();
|
||||
|
||||
/// <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 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
|
||||
{
|
||||
get => _statusText;
|
||||
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)
|
||||
{
|
||||
_controller = controller;
|
||||
_dispatcher = dispatcher;
|
||||
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
|
||||
.Subscribe(snapshot => _dispatcher.InvokeAsync(
|
||||
() => OnParticipantsChanged(snapshot),
|
||||
DispatcherPriority.Background));
|
||||
|
||||
_alertsSub = controller.Alerts
|
||||
.ObserveOn(new SynchronizationContextScheduler(
|
||||
System.Threading.SynchronizationContext.Current ?? new DispatcherSynchronizationContext(_dispatcher)))
|
||||
.Subscribe(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)
|
||||
{
|
||||
StatusText = "Discovering NDI sources…";
|
||||
_engineStartedAt = DateTimeOffset.UtcNow;
|
||||
IsDiscovering = true;
|
||||
await _controller.StartAsync(cancellationToken);
|
||||
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)
|
||||
{
|
||||
var seenIds = new HashSet<Guid>();
|
||||
var hideLocal = Settings.HideLocalSelf;
|
||||
var autoDisable = Settings.AutoDisableOnDeparture;
|
||||
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);
|
||||
if (_byId.TryGetValue(p.Id, out var vm))
|
||||
{
|
||||
var wasOnline = vm.IsOnline;
|
||||
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
|
||||
{
|
||||
vm = new ParticipantViewModel(_controller, p, Toast);
|
||||
_byId[p.Id] = vm;
|
||||
Participants.Add(vm);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove participants no longer present (or now hidden by the filter).
|
||||
for (var i = Participants.Count - 1; i >= 0; i--)
|
||||
{
|
||||
var vm = Participants[i];
|
||||
if (!seenIds.Contains(vm.Id))
|
||||
{
|
||||
_byId.Remove(vm.Id);
|
||||
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()
|
||||
{
|
||||
_statsTimer.Stop();
|
||||
_statsTimer.Tick -= OnStatsTick;
|
||||
_participantsSub.Dispose();
|
||||
_alertsSub.Dispose();
|
||||
}
|
||||
}
|
||||
23
src/TeamsISO.App/ViewModels/ObservableObject.cs
Normal file
23
src/TeamsISO.App/ViewModels/ObservableObject.cs
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
using System.ComponentModel;
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace TeamsISO.App.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// Minimal MVVM base class implementing <see cref="INotifyPropertyChanged"/>.
|
||||
/// </summary>
|
||||
public abstract class ObservableObject : INotifyPropertyChanged
|
||||
{
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
|
||||
protected void OnPropertyChanged([CallerMemberName] string? propertyName = null) =>
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
|
||||
|
||||
protected bool SetField<T>(ref T field, T value, [CallerMemberName] string? propertyName = null)
|
||||
{
|
||||
if (EqualityComparer<T>.Default.Equals(field, value)) return false;
|
||||
field = value;
|
||||
OnPropertyChanged(propertyName);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
575
src/TeamsISO.App/ViewModels/ParticipantViewModel.cs
Normal file
575
src/TeamsISO.App/ViewModels/ParticipantViewModel.cs
Normal file
|
|
@ -0,0 +1,575 @@
|
|||
using System.Windows;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Imaging;
|
||||
using TeamsISO.Engine.Controller;
|
||||
using TeamsISO.Engine.Domain;
|
||||
using TeamsISO.Engine.Pipeline;
|
||||
using IsoHealthStats = TeamsISO.Engine.Domain.IsoHealthStats;
|
||||
|
||||
namespace TeamsISO.App.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// Per-row view model for a participant in the participant list.
|
||||
/// Wraps a domain <see cref="Participant"/> and exposes ISO toggle and naming commands.
|
||||
/// </summary>
|
||||
public sealed class ParticipantViewModel : ObservableObject
|
||||
{
|
||||
private readonly IIsoController _controller;
|
||||
private readonly ToastViewModel? _toast;
|
||||
private Participant _participant;
|
||||
private bool _isEnabled;
|
||||
private bool _isProcessing;
|
||||
private string _customName;
|
||||
|
||||
/// <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;
|
||||
_toast = toast;
|
||||
_participant = participant;
|
||||
_customName = string.Empty;
|
||||
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 string DisplayName => _participant.DisplayName;
|
||||
public string SourceMachine => _participant.CurrentSource?.MachineName ?? "(disconnected)";
|
||||
public string SourceFullName => _participant.CurrentSource?.FullName ?? "(disconnected)";
|
||||
public bool IsOnline => _participant.CurrentSource is not null;
|
||||
|
||||
public bool IsEnabled
|
||||
{
|
||||
get => _isEnabled;
|
||||
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
|
||||
{
|
||||
get => _isProcessing;
|
||||
private set
|
||||
{
|
||||
if (SetField(ref _isProcessing, value))
|
||||
ToggleIsoCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public string CustomName
|
||||
{
|
||||
get => _customName;
|
||||
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; }
|
||||
|
||||
/// <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>
|
||||
public void Update(Participant updated)
|
||||
{
|
||||
_participant = updated;
|
||||
OnPropertyChanged(nameof(DisplayName));
|
||||
OnPropertyChanged(nameof(SourceMachine));
|
||||
OnPropertyChanged(nameof(SourceFullName));
|
||||
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()
|
||||
{
|
||||
IsProcessing = true;
|
||||
try
|
||||
{
|
||||
if (IsEnabled)
|
||||
{
|
||||
await _controller.DisableIsoAsync(Id, CancellationToken.None);
|
||||
IsEnabled = false;
|
||||
}
|
||||
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(
|
||||
Id,
|
||||
resolvedName,
|
||||
recordOverride,
|
||||
CancellationToken.None);
|
||||
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
|
||||
{
|
||||
IsProcessing = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
90
src/TeamsISO.App/ViewModels/RelayCommand.cs
Normal file
90
src/TeamsISO.App/ViewModels/RelayCommand.cs
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
using System.Windows.Input;
|
||||
|
||||
namespace TeamsISO.App.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// Synchronous command that delegates execution to an <see cref="Action"/>.
|
||||
/// </summary>
|
||||
public sealed class RelayCommand : ICommand
|
||||
{
|
||||
private readonly Action _execute;
|
||||
private readonly Func<bool>? _canExecute;
|
||||
|
||||
public RelayCommand(Action execute, Func<bool>? canExecute = null)
|
||||
{
|
||||
_execute = execute;
|
||||
_canExecute = canExecute;
|
||||
}
|
||||
|
||||
public bool CanExecute(object? parameter) => _canExecute?.Invoke() ?? true;
|
||||
public void Execute(object? parameter) => _execute();
|
||||
|
||||
public event EventHandler? CanExecuteChanged;
|
||||
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>
|
||||
/// Async command that suppresses re-entrancy while running.
|
||||
/// </summary>
|
||||
public sealed class AsyncRelayCommand : ICommand
|
||||
{
|
||||
private readonly Func<Task> _execute;
|
||||
private readonly Func<bool>? _canExecute;
|
||||
private bool _isRunning;
|
||||
|
||||
public AsyncRelayCommand(Func<Task> execute, Func<bool>? canExecute = null)
|
||||
{
|
||||
_execute = execute;
|
||||
_canExecute = canExecute;
|
||||
}
|
||||
|
||||
public bool CanExecute(object? parameter) => !_isRunning && (_canExecute?.Invoke() ?? true);
|
||||
|
||||
public async void Execute(object? parameter)
|
||||
{
|
||||
if (_isRunning) return;
|
||||
_isRunning = true;
|
||||
RaiseCanExecuteChanged();
|
||||
try { await _execute(); }
|
||||
finally
|
||||
{
|
||||
_isRunning = false;
|
||||
RaiseCanExecuteChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public event EventHandler? CanExecuteChanged;
|
||||
public void RaiseCanExecuteChanged() => CanExecuteChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
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();
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue