Compare commits

..

20 commits

Author SHA1 Message Date
dd7827de82 docs: refresh _NEXT.md after recording + control surface
Some checks failed
CI / build-and-test (push) Failing after 22s
2026-05-10 09:41:34 -04:00
5c0491e46c feat: persist UI prefs + preview window + sort + inline note input 2026-05-10 09:41:34 -04:00
46fa0d66a1 test+feat: App.Tests project + audio VU scaffold + MF recorder stub 2026-05-10 09:41:33 -04:00
fdd1d1bbfc feat(ui): system tray icon + WinForms/WPF namespace disambiguation 2026-05-10 09:41:33 -04:00
832aad6a14 feat(engine+console): SMPTE test-pattern generator + --test-pattern flag 2026-05-10 09:41:33 -04:00
7c7520e2be feat(ui): notes viewer + Stop-All confirm + folder shortcuts + README 2026-05-10 09:41:32 -04:00
5958b66bfd docs: add CHANGELOG.md tracking the May 2026 batch 2026-05-10 09:41:32 -04:00
b49e1abf17 feat: CLI flags, dynamic status, HTML panel, session timer, notes 2026-05-10 09:41:32 -04:00
6882e654d5 feat: window-scoped keyboard shortcuts + help cheat sheet (F1) 2026-05-10 09:41:31 -04:00
670813f18e feat: disk space watcher + diagnostic bundle export 2026-05-10 09:41:31 -04:00
179a44adf5 feat: custom NDI output name template + enriched status bar 2026-05-10 09:41:31 -04:00
e06120044b feat: recording markers (UI button + REST + OSC + manifest array) 2026-05-10 09:41:30 -04:00
f73552a6b9 feat: preset import / export bundles 2026-05-10 09:41:30 -04:00
b8fe344c58 feat: WebSocket live-state push + OSC bridge 2026-05-10 09:41:30 -04:00
e93b8caae0 feat: in-app preview thumbnails per participant 2026-05-10 09:41:30 -04:00
83224dbd9b feat: REST control surface + lift preset-apply into PresetApplier 2026-05-10 09:41:29 -04:00
b5fcc98d40 feat(ui): crash diagnostics, first-launch welcome, reset-to-defaults 2026-05-10 09:41:29 -04:00
34a2f1483c feat(engine): refresh discovery affordance + idempotent re-Add handling 2026-05-10 09:41:29 -04:00
4be5b39022 ci: optional MSI + exe code-signing in release.yml 2026-05-10 09:41:28 -04:00
57c2922d1c feat(ui): auto-disable ISOs when participants leave the meeting 2026-05-10 09:41:28 -04:00
56 changed files with 8729 additions and 181 deletions

View file

@ -35,6 +35,24 @@ jobs:
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
@ -66,6 +84,42 @@ jobs:
--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
@ -82,6 +136,35 @@ jobs:
"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:

168
CHANGELOG.md Normal file
View file

@ -0,0 +1,168 @@
# 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).
## [Unreleased]
### Added — May 2026 feature batch
#### Engine
- NDI Groups: discovery + sender support so Teams' raw broadcasts can be
pinned to a private "teamsiso-input" group while TeamsISO's own
normalized outputs broadcast on Public.
- One-click "Apply transcoder topology" writes `ndi-config.v1.json` so all
Teams broadcasts go to the private group and TeamsISO re-emits on Public.
- `RawBgraRecorderSink` per-output recorder: `IRecorderSink` interface +
raw BGRA stream + `manifest.json` + `convert.cmd` script for FFmpeg
conversion to H.264 MKV.
- Recording markers: `IRecorderSink.AddMarker(label)` fan-out via
`IIsoController.AddRecordingMarker`. Markers land in `manifest.json`
under `markers[]` for post-production chaptering.
- Preview thumbnails: `IsoPipeline.LatestProcessedFrame` published via
`Volatile.Read` so the UI can render 160×90 BGRA thumbnails in the
participants DataGrid at 1Hz.
- Idempotent `ParticipantTracker.HandleAdded`: re-emitting Added for an
already-live source refreshes LastSeen instead of duplicating the row.
Fixes the "click Refresh and rows ghost-duplicate" bug introduced by
the Refresh-Discovery affordance.
- `IIsoController.RefreshDiscovery()` rebuilds the NDI finder on the next
poll tick — useful right after applying a new transcoder topology.
- `IIsoController.AddRecordingMarker(label)` fan-out to every active recorder.
- `IIsoController.SetRecording(enabled, dir)` global recording toggle;
per-participant override via `EnableIsoAsync(...recordOverride...)`.
#### Host (WPF)
- Active Speaker as a synthetic routable participant with deterministic v5
GUID derived from `auto-mix:<machine>`.
- Auto-disable on departure: when a participant's NDI source disappears,
optionally tear down their pipeline.
- Operator presets: chromeless `Presets…` dialog with Save / Apply /
Delete / Duplicate / Export / Import. Persisted at
`%LOCALAPPDATA%\TeamsISO\presets.json`. Bundle format
`teamsiso-presets-bundle/v1` for migration between machines.
- Auto-apply last preset on launch (configurable, off by default).
- `--apply-preset NAME` CLI flag for desktop-shortcut workflows.
- `PresetApplier` — single source of truth for "apply preset to live
participants" used by the dialog, REST surface, and auto-apply path.
- Live preview thumbnails per participant (160×90 BGRA WriteableBitmap).
- Right-click context menu on participant rows: Toggle ISO, Record-this-
participant, Copy NDI source name.
- Live filter input (substring match on display name).
- "Enable all online" + "Stop all ISOs" + "Refresh" header actions.
- Per-participant recording opt-out checkbox (Rec column).
- Custom NDI output name template with `{name}`/`{guid}`/`{machine}`/
`{timestamp}` tokens.
- Phase E.1 — Launcher: rail "Launch / Stop Teams" toggle.
- Phase E.2 — Window orchestration: hide / show Teams windows from the rail.
- Phase E.3 — In-call controls (UIA): Mute, Camera, Share, Leave, Raise hand,
plus PostMessage shortcut forwarding fallback. Candidate names localized
for English / German / Spanish / French / Portuguese / Japanese.
- Crash diagnostics: AppDomain + Dispatcher + TaskScheduler unhandled
exception handlers wired to Serilog.Critical + user-facing dialog.
- First-launch onboarding dialog with 5-step setup checklist.
- About dialog gained "Show welcome", "Check for updates", "Export diagnostics"
buttons.
- Diagnostic bundle export: zips logs + config + presets + version metadata
into `~/Downloads/teamsiso-diagnostics-<ts>.zip` for bug reports.
- Update check: manual via About + auto-on-launch banner (throttled to 24h,
opt-out via flag file at `%LOCALAPPDATA%\TeamsISO\no-update-check.flag`).
- Disk space watcher auto-disables recording at <1GB free.
- Settings panel refactored into OUTPUT / NETWORK / DISPLAY tabs.
- Reset-to-defaults button in OUTPUT tab.
- Enriched footer: REC badge, control-surface badge, session timer (HH:MM:SS
since first ISO went live), dynamic status text ("3/5 ISOs live · 2 recording").
- Window-scoped keyboard shortcuts: F1 (help), Ctrl+M (marker), Ctrl+Shift+S
(stop all), Ctrl+R (refresh discovery).
- F1 help / cheat-sheet dialog.
- `UIPreferences` static persists `HideLocalSelf`, `AutoDisableOnDeparture`,
`ParticipantSort` (JoinOrder / Alphabetical / OnlineFirst) across launches
to `%LOCALAPPDATA%\TeamsISO\ui-prefs.json`.
- Pop-out per-participant preview window (right-click → Open preview…)
refreshes at ~20Hz and is multi-monitor friendly.
- Configurable participant sort order via the DISPLAY tab dropdown.
- Stop-All confirms before tearing down running pipelines (catches
mid-show misclicks).
- About dialog gained "Logs / Recordings / Notes" folder shortcut buttons.
- `NotesWindow` inline viewer for today's show-notes file with 2s polling.
- Duplicate-preset action in the Presets dialog with smart `(copy N)`
name suggestions.
- `--apply-preset NAME` command-line flag for desktop-shortcut workflows.
- New `TeamsISO.App.Tests` net8.0-windows test project. Initial coverage:
`OperatorPresetStoreTests` (round-trip, name collisions, schema, bundle
import/export, garbage-file resilience), `OutputNameTemplateTests` (token
expansion + sanitization), `OscMessageTests` (wire-format parsing of
int/float/string/T/F type tags). Backed by an `InternalsVisibleTo` grant
+ a test-only `OperatorPresetStore.PathOverride` hook.
- `IsoHealthStats.PeakAudioLevel` field + DataGrid VU-bar UI scaffolding.
Engine still emits 0.0 (audio capture is a focused follow-up); the bar's
decay logic is in place so it animates as soon as engine-side audio
parsing lands.
- `MediaFoundationRecorderSink` scaffold under `#if MF_AVAILABLE` for
inline H.264 encoding via Vortice.MediaFoundation. ~10× smaller files
than the raw BGRA recorder. Activation steps documented at
`docs/REAL-TIME-RECORDING.md`.
- System-tray icon + minimize-to-tray toggle. Adds
`<UseWindowsForms>true</UseWindowsForms>` for `NotifyIcon`; the
`TrayIconHost` lives on `App` (process lifetime, not main-window
lifetime). Right-click menu has Show / Stop all ISOs / Exit.
- Built-in NDI test pattern: `TeamsISO.Console --test-pattern` broadcasts
a synthetic 1280×720 30fps source named `TEAMSISO_TEST` showing SMPTE
color bars + a moving sweep band. Verifies NDI runtime, sender
configuration, and downstream discovery without needing Teams running.
Backed by `TestPatternGenerator` in the engine + 4 unit tests covering
buffer size, alpha, color distinctness, and sweep animation.
- Always-toast on participant disconnect, regardless of `AutoDisableOnDeparture`
setting. Distinguishes "ISO torn down" (auto-disable on) from "ISO still
running on slate" (auto-disable off) so operators don't miss a silent
drop mid-show.
- **Restart this ISO** right-click action — disable + brief delay + re-enable
for one participant only. Useful when a single feed flakes without
affecting other ISOs.
- **Roll recording** action: rolls every active recording into a new chunk
(disable + re-enable each pipeline; recorder finalizes its `manifest.json`
and starts a fresh subdirectory). Surfaced via `MainViewModel.RollRecordingCommand`,
REST `POST /recording/roll`, and OSC `/teamsiso/recording/roll`. Useful
for chaptering between show segments.
#### Control surface
- REST API on `127.0.0.1:9755` with endpoints for participant ISO toggle (by
Id or display name), preset apply, refresh discovery, stop-all, recording
on/off, marker drop, notes, and Teams in-call commands. Documented at
`docs/CONTROL-SURFACE.md`.
- WebSocket `/ws` pushes live participant state at 4Hz with snapshot diffing.
- OSC bridge on UDP `127.0.0.1:9000` mirrors the REST vocabulary
(`/teamsiso/iso "Jane" 1`, `/teamsiso/preset "..."`, etc.).
- Embedded HTML control panel at `GET /ui` — phone-friendly remote with
live state and one-click action buttons.
- Show notes service: `POST /notes` and `/teamsiso/notes "..."` append
timestamped lines to `%LOCALAPPDATA%\TeamsISO\Notes\<YYYY-MM-DD>.md`.
#### CI / Release
- Forgejo CI is green; tag-push release workflow builds + tests + publishes
+ builds MSI on a Windows runner and attaches it to the auto-created
release via the REST API.
- Optional MSI + exe code-signing wired into `release.yml` — gated on
`SIGN_CERT_PFX_BASE64` + `SIGN_CERT_PASSWORD` Forgejo Secrets.
### Fixed
- `.slnf` path-separator mismatch (forward slashes for cross-platform).
- NDI native DLL resolution via `NativeLibrary` resolver.
- `ExpectedRuntimeVersionPrefix` updated to NDI 6 banner format.
- `NdiSourceParser` accepts current Teams desktop's `MS Teams - <name>`
brand format.
- ActiveSpeaker source removal no longer poisons the rename-window
heuristic for a Participant joining the same machine within the window.
- `IsoPipeline.State` access synchronized via `Volatile.Read/Write`.
- REST handlers now correctly marshal `ObservableCollection` reads + writes
through the UI dispatcher.
- WebSocket upgrade no longer falls into `res.Close()` finally block (was
killing freshly-upgraded connections).
- `ParticipantViewModel.UpdateThumbnail` defends against malformed frames
(`width*height*4 > Pixels.Length`).
- `HasThumbnail` correctly fires `PropertyChanged` when `Thumbnail`
transitions from null.
[Unreleased]: https://forge.wilddragon.net/zgaetano/teamsiso/compare/v0.1.0...HEAD

View file

@ -1,19 +1,92 @@
# 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 into
a switcher (vMix, OBS, Ross, hardware capture).
## What it does
- **Discovers participants** as Teams broadcasts each one over NDI, surfacing
the operator-friendly display name (handles current "MS Teams - Name"
format and the legacy "(Teams) Name" format).
- **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
output-name template (`TEAMSISO_{name}`, `{guid}`, `{machine}`, `{timestamp}` tokens).
- **Records each ISO to disk** simultaneously — raw BGRA + sidecar manifest.json
+ ffmpeg convert.cmd — so post-production gets a clean per-guest archive.
- **Embeds Teams orchestration**: launch and stop Teams from the rail, hide
Teams' UI windows during a show, drive in-call controls (mute, camera,
share, leave, raise hand) via UIAutomation.
- **Operator presets** save the current per-participant ISO assignment and
custom output names, applicable on next launch automatically.
- **Live preview thumbnails** per participant in the participants table,
plus pop-out floating preview windows (right-click → Open preview…) 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 integration. Self-contained HTML control panel at
[`/ui`](docs/CONTROL-SURFACE.md) for phone-as-controller.
- **Crash diagnostics** wired to a rolling daily Serilog file sink under
`%LOCALAPPDATA%\TeamsISO\Logs\`.
- **Update check** against `forge.wilddragon.net`'s release API — manual or
silent on launch (throttled to 24h).
- **Diagnostic bundle export** zips logs + config + presets for bug reports.
## Status
Pre-1.0. See `docs/superpowers/specs/` for the active spec and `docs/superpowers/plans/` for in-flight implementation plans.
Pre-1.0. The May 2026 batch is feature-complete; v1.0 cut is gated on
code-signing the MSI and a smoke pass against a real Teams meeting.
See `CHANGELOG.md` for the [Unreleased] entry.
## Build
Requires .NET 8 SDK.
Requires .NET 8 SDK on Windows (the `TeamsISO.App` host is `net8.0-windows`
WPF).
dotnet build
dotnet test
dotnet restore TeamsISO.Windows.slnf
dotnet build TeamsISO.Windows.slnf -c Release
dotnet test TeamsISO.Windows.slnf --filter "Category!=ndi&requires!=ndi"
The shipped helper scripts in the repo root automate this:
pwsh -File .\build-and-test.ps1
pwsh -File .\commit-and-push.ps1
## Documentation
- [Control surface API](docs/CONTROL-SURFACE.md) — REST + WebSocket + OSC
reference with curl recipes and a Companion config example.
- [Releasing](docs/RELEASING.md) — tag-push workflow, MSI signing path.
- [Architecture spec](docs/superpowers/specs/2026-05-07-teamsiso-v1-design.md)
— design overview.
- [Embedded Teams orchestration spec](docs/superpowers/specs/2026-05-08-embedded-teams-orchestration.md)
— Phase E roadmap.
## Keyboard shortcuts
| Key | Action |
| --- | --- |
| `F1` | Open help / cheat sheet |
| `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) |
## 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 |
## License

View file

@ -7,7 +7,8 @@
"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.Engine.IntegrationTests/TeamsISO.Engine.IntegrationTests.csproj",
"src/tests/TeamsISO.App.Tests/TeamsISO.App.Tests.csproj"
]
}
}

View file

@ -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

276
docs/CONTROL-SURFACE.md Normal file
View file

@ -0,0 +1,276 @@
# 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. The server binds to `127.0.0.1` only — it is NOT reachable from the LAN.
If you need LAN access (e.g. a Stream Deck on a separate control PC),
front it with `ssh -L 9755:127.0.0.1:9755` or a localhost TCP bridge.
When enabled, the toast confirms `Control surface listening on
http://127.0.0.1:9755/`.
## Authentication
None. The localhost-only bind is the security model. Any process on the
operator's machine can hit these endpoints, which is the same threat model
as a Stream Deck's USB connection.
## 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` only.
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
- **OSC bridge** over UDP 9000 with `/teamsiso/iso {id} {0|1}` etc. — same
command surface, different transport. Adapter sits in front of the REST
handlers.
- **Bidirectional state** via WebSocket — push `participants` updates so
controllers can light a button when an ISO is live without polling.
- **REST apply-preset** — duplicate the dialog's apply logic into
`IIsoController.ApplyPreset(name)` so the `/presets/{name}/apply`
endpoint becomes a real action.

View file

@ -0,0 +1,74 @@
# 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`.
## Activating it
1. **Add the NuGet dependency** to the engine project:
dotnet add src/TeamsISO.Engine package Vortice.MediaFoundation --version 3.6.2
(Pin to a known-good version — Vortice's API surface is stable across
3.6.x but the engine code targets the namespaces in 3.6.x. If a newer
major version changes namespaces, the file may need adjustment.)
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.

View file

@ -36,15 +36,44 @@ The workflow will:
first if it doesn't exist. Pre-release flag is set automatically when the
tag contains `-alpha`, `-beta`, or `-rc`.
## Code signing (TODO)
## Code signing
The `wixproj` has a `SignOutput` property hook but no actual cert wiring. For a
v1.0 release, sign the MSI with an EV cert before publishing:
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.
1. Add a `SIGNING_CERT_BASE64` and `SIGNING_CERT_PASSWORD` to repo Secrets.
2. Decode the cert into the runner's cert store at the start of the workflow.
3. Set `/p:SignOutput=true` on the `dotnet build` of the wixproj and configure
`signtool` invocation (the installer project will need a custom target).
### Enabling signing
Until that lands, downstream users will see the standard Windows SmartScreen
warning on first launch — annoying but not blocking for early adopters.
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.

View file

@ -33,13 +33,55 @@
- Hide-(Local) toggle so the user's own self-preview is filtered from the participants list.
- Window position / size / state persisted to `%LOCALAPPDATA%\TeamsISO\window.json`, multi-monitor safe.
- Tooltips on every interactive control in the settings panel + per-row textbox + ISO toggle.
- Toast feedback for settings actions (Apply / Apply Transcoder Topology / Stop All / Auto-disable).
- **Auto-disable on participant departure** (configurable, off by default): when a participant's NDI source disappears the engine tears down their pipeline; the toggle lives in `DISPLAY` settings.
- **Operator presets**: chromeless `Presets…` dialog from the participants header. Saves the current per-participant `IsEnabled` + `CustomName` set keyed by display name to `%LOCALAPPDATA%\TeamsISO\presets.json` (atomic write, schema-versioned). Apply walks the live participants and reconciles via `EnableIsoAsync` / `DisableIsoAsync`; participants in the preset who aren't in the current meeting are reported in the toast.
- **Auto-apply last preset on launch**: opt-in checkbox in `DISPLAY` settings. After the operator's first manual Apply, every subsequent TeamsISO launch silently re-applies the same preset once participants populate (30-second grace window before applying with whoever's online). State lives in `presets.json` next to the preset list.
- **Refresh discovery** affordance: header pill that rebuilds the underlying NDI finder on the next poll tick. `IIsoController.RefreshDiscovery` flips a flag the discovery loop honors before the next tick — old finder disposed, new finder created, seen-set cleared so all currently-visible sources re-fire as Added. `ParticipantTracker.HandleAdded` is idempotent: re-emitting the same FullName refreshes LastSeen rather than minting a duplicate row.
- **Settings tabs**: the settings sidebar is now a TabControl with `OUTPUT` / `NETWORK` / `DISPLAY` tabs and a single Apply Changes button below. Underline-on-active tab style lives in `WildDragonTheme.xaml` (`Wd.TabControl` + `Wd.TabItem`).
- **Crash diagnostics**: `App.OnStartup` wires `AppDomain.UnhandledException`, `Application.DispatcherUnhandledException`, and `TaskScheduler.UnobservedTaskException` into a unified Serilog.Critical log line + user-facing dialog that points at the log directory. Dispatcher exceptions are marked `Handled = true` so a single bad UI thunk doesn't take the app down; AppDomain crashes are terminal but at least the user gets the log path before exit.
- **First-launch onboarding**: chromeless welcome dialog walks users through the once-per-machine setup (NDI runtime, Teams admin permission, transcoder topology, presets, log location). Suppressed after dismissal via marker file at `%LOCALAPPDATA%\TeamsISO\onboarding.flag`. Re-openable from the About dialog via "Show welcome" button.
- **Reset output to defaults**: ghost button at the bottom of the OUTPUT settings tab restores framerate / resolution / aspect / audio to `FrameProcessingSettings.Default` after confirmation. Doesn't touch NDI groups (sticky per-machine) or display toggles.
- **Per-output recording**: `IRecorderSink` interface + `RawBgraRecorderSink` implementation. When the operator enables "Record ISOs to disk" in the DISPLAY tab, each newly-enabled ISO writes its normalized output to `<chosen-dir>/<participant>/video.bgra` plus a sidecar `manifest.json` (width / height / fps / frame counts) and a `convert.cmd` one-liner that pipes the raw stream into FFmpeg to produce a final H.264 `output.mkv`. Recorder runs on its own bounded queue (240-frame `DropOldest` buffer) so disk pressure never blocks the live ISO; recorder failures are caught and ignored at the channel-write layer for the same reason. Already-running ISOs are not retroactively captured — operator disables + re-enables to start recording. Recording can be wired to a real-time H.264 encoder later via Vortice.MediaFoundation; the `IRecorderSink` interface is designed to swap implementations without touching the pipeline.
- **REST control surface**: `ControlSurfaceServer``System.Net.HttpListener` on `127.0.0.1:9755` (configurable). Endpoints for participant ISO toggle (by Id or display name), refresh discovery, stop-all, recording on/off, preset apply, and Teams in-call commands (mute / camera / share / leave / raise-hand). Off by default; toggle in the DISPLAY tab. Bitfocus Companion / Stream Deck plugins / OSC bridges drive it. Documented at `docs/CONTROL-SURFACE.md`.
- **PresetApplier**: extracted from `PresetsDialog.OnApply`. Single source of truth for "apply this preset to live participants" — used by the dialog, by `MainViewModel.TryAutoApplyPendingPreset` (auto-apply on launch), and by the REST `POST /presets/{name}/apply` endpoint. Marshals UI-bound writes (CustomName / IsEnabled) through an optional Dispatcher so off-thread callers don't crash WPF.
- **In-app preview thumbnails**: 160×90 WriteableBitmap per participant, fed from the engine's most recent `ProcessedFrame` at the existing 1Hz stats tick. Inline nearest-neighbor scaler in `ParticipantViewModel.UpdateThumbnail` writes directly into the bitmap's pinned BackBuffer (unsafe block, `<AllowUnsafeBlocks>true</AllowUnsafeBlocks>` in the .csproj) for ~10× perf vs. going through Span<byte>. Falls back to a `—` placeholder card when no pipeline is running. New "Preview" column in the participants DataGrid.
- **WebSocket live state push**: `ws://127.0.0.1:9755/ws` — clients connect, receive a participants snapshot immediately, and get fresh snapshots within 250ms whenever state changes. Snapshot diffing on JSON string keeps the wire quiet during steady-state. Used by Stream Deck / Companion buttons that want to light up when an ISO goes LIVE without polling.
- **OSC bridge over UDP**: `OscBridge` listens on `127.0.0.1:9000` (TouchOSC's default). Same command vocabulary as the REST endpoints — `/teamsiso/iso "Jane" 1`, `/teamsiso/preset "Friday Show"`, `/teamsiso/teams/mute`, etc. Minimal OSC 1.0 parser (int / float / string / T / F type tags; no bundles). TouchOSC layouts and Companion's Generic OSC surface can both drive it directly.
- **Manual update check**: "Check for updates" button in the About dialog. Asks `forge.wilddragon.net/api/v1/repos/zgaetano/teamsiso/releases?limit=1`, compares the newest tag's SemVer to the running version, prompts to open the releases page if newer. Manual only — no background polling for v1 so a long-running show doesn't get interrupted by a surprise installer.
- **Auto-update banner on launch**: opt-in (default on) silent check throttled to once per 24h via `%LOCALAPPDATA%\TeamsISO\last-update-check.txt`. When a newer release is found, a non-modal banner appears above the body with "Get update" / "Dismiss" buttons. Suppression via flag file at `no-update-check.flag` for fleets that prefer central rollout. New `UpdateBannerViewModel` distinct from the engine alert banner.
- **Preset import / export**: Export / Import buttons in the Presets dialog footer, backed by `OperatorPresetStore.ExportAllAsJson` / `ImportBundle`. Bundle format is `teamsiso-presets-bundle/v1` JSON. On name collision the importer asks once (Overwrite/Keep/Cancel) rather than per-preset; deliberately doesn't include the operator's `LastAppliedName` / `AutoApplyOnStartup` since those are machine-local.
- **Recording markers**: `IRecorderSink.AddMarker(label)` plus `IIsoController.AddRecordingMarker(label)` fan-out to every active recorder. Surfaced via "Marker" button in the IN-CALL bar (auto-labels with timestamp), `POST /recording/marker` in the REST surface, and `/teamsiso/recording/marker "Label"` in OSC. Markers land in `manifest.json` under `markers[]` with `offsetMs` + `label` fields for post-production chaptering.
- **Custom NDI output name template**: `OutputNameTemplate` static helper persisted to `output-name-template.txt` with `{name}` / `{guid}` / `{machine}` / `{timestamp}` tokens. Default `TEAMSISO_{guid}` preserves the engine's hard-coded behavior; operator can switch to `TEAMSISO_{name}` for human-readable downstream switcher names. UI editor in the NETWORK settings tab.
- **Enriched footer status bar**: rec badge (coral dot + count) when at least one ISO is being recorded; control-surface badge (cyan dot + "REST :9755 + OSC :9000") when those services are running. Computed at the existing 1Hz stats tick from `IIsoController.RecordingEnabled` × running pipeline count and `App.ControlSurface.IsRunning` / `App.OscBridge.IsRunning`.
- **Disk space watcher**: `DiskSpaceWatcher` polls the recording drive every 5s while recording is on. Coral toast at <10GB free; auto-disables recording at <1GB so an unattended long show doesn't crash the host on disk-full.
- **Diagnostic bundle export**: "Export diagnostics" button in About zips logs + config + presets + window state + version metadata into a `teamsiso-diagnostics-<ts>.zip` in `~/Downloads`. Excludes screenshots / memory dumps; only files the user already wrote.
- **Per-participant recording opt-out**: new `Rec` column in the DataGrid lets the operator choose which ISOs get recorded when global recording is on. `IIsoController.EnableIsoAsync` gained an optional `bool? recordOverride` parameter — null = follow global flag, true = force on, false = force off.
- **Window-scoped keyboard shortcuts**: F1 (help), Ctrl+M (drop marker), Ctrl+Shift+S (stop all), Ctrl+R (refresh discovery). InputBindings on MainWindow → MainViewModel commands; F1 opens the new `HelpWindow` cheat sheet.
- **Help cheat sheet**: chromeless `HelpWindow` lists keyboard shortcuts, file locations (`%LOCALAPPDATA%\TeamsISO\Logs\`, `%APPDATA%\TeamsISO\config.json`, etc.), and links to the public docs. Reduces support friction.
- **Bulk enable**: header `Enable all` button (green dot) enables ISOs for every online + non-enabled participant. Per-participant best-effort with a count toast.
- **Live participant filter**: textbox above the DataGrid filters by display-name substring as you type. Backed by an `ICollectionView` Filter callback so the underlying `ObservableCollection` isn't mutated (preserving identity-tracking).
- **Right-click context menu** on participant rows: Toggle ISO, toggle Record-this-participant, Copy NDI source name to clipboard. Uses the existing per-row commands so the menu is just another binding surface.
- **CLI: `--apply-preset NAME`**: launch-time flag that auto-applies the named preset once participants populate. Same code path as the persisted auto-apply preference. Useful for `Friday Show.lnk` desktop shortcuts that drive recurring routings.
- **Dynamic status text**: footer's center text now reads "3/5 ISOs live · 2 recording" once routing starts, instead of the static "Engine running at X fps target." Composed in `OnStatsTick` from running participant + recording counts.
- **Embedded HTML control panel** at `GET /ui`: self-contained ~6KB page with WebSocket-driven live state and buttons for the common control actions. Open in a phone or second-monitor browser to drive TeamsISO without context-switching from the show. No external dependencies, no build step.
- **Session timer** in footer: shows `MM:SS` (or `HH:MM:SS` past an hour) elapsed since the first ISO went live this session. Resets when all ISOs go offline. Green dot indicator for at-a-glance status.
- **Show notes service**: `POST /notes` and `/teamsiso/notes "..."` (OSC) append timestamped lines to `%LOCALAPPDATA%\TeamsISO\Notes\<YYYY-MM-DD>.md`. Operators wire a Stream Deck button to drop notes during a live show without leaving the production app. Markdown format renders cleanly in any editor.
- **NotesWindow inline viewer**: chromeless dialog that displays today's notes file with 2s polling so REST/OSC-driven appends surface live. "Notes" button in the IN-CALL bar.
- **Duplicate-preset action**: "Duplicate" footer button in the Presets dialog. Custom inline prompt suggests `<original> (copy)` / `(copy 2)` / etc. names.
- **CHANGELOG.md**: project-wide changelog following keep-a-changelog format. Captures the full May 2026 batch under `[Unreleased]`.
- **README rewrite**: top-level README now lists what TeamsISO does, build instructions, doc links, keyboard shortcuts table, file-locations table.
- **Confirm-before-Stop-All**: stop-all button now requires Yes confirmation, preventing accidental mid-show clicks. Default-No so Enter cancels.
### Networking automation
- One-click **transcoder topology** button in Settings: writes `%APPDATA%\NDI\ndi-config.v1.json` so all local senders broadcast on `teamsiso-input` and local receivers see both `public` + `teamsiso-input`. Engine settings auto-flip to receive-from `teamsiso-input` and emit-on `public`. Atomic write with timestamped backup of the prior config.
### Phase E.1 starter (embedded Teams)
- Spec at `docs/superpowers/specs/2026-05-08-embedded-teams-orchestration.md` (three-phase rollout: launcher → window orchestration → in-app meeting controls).
- Rail "Launch / Stop Teams" toggle: launches via `ms-teams:` URI / `ms-teams.exe` / classic `Update.exe --processStart`, asks to confirm `WM_CLOSE` of all running Teams windows when toggled while Teams is up.
### Phase E — embedded Teams orchestration
Spec at `docs/superpowers/specs/2026-05-08-embedded-teams-orchestration.md`. All three sub-phases shipped in May 2026:
- **E.1 — Launcher.** Rail "Launch / Stop Teams" toggle: launches via `ms-teams:` URI → `ms-teams.exe` → classic `Update.exe --processStart`, asks to confirm `WM_CLOSE` of all running Teams windows when toggled while Teams is up.
- **E.2 — Window orchestration.** Rail eye-icon button hides every visible top-level Teams window via `EnumWindows` + `ShowWindow(SW_HIDE)`. Click again to restore + foreground. Lets the operator drive Teams from TeamsISO without ever seeing the Teams UI.
- **E.3 — In-call controls.** UIAutomation-driven Mute / Camera / Share / Leave buttons in a new `IN-CALL` card at the top of the participants area. `TeamsControlBridge` walks Teams' automation tree by candidate Name list (`Mute`, `Unmute`, `Microphone`, `Toggle mute` …) and tries Invoke or Toggle pattern. Tolerant lookup: when a Teams update renames a button we extend the candidate list, no crash. Toasts reflect the four outcomes (Invoked / TeamsNotRunning / ControlNotFound / InvokeFailed). Bridge also exposes (UI-not-yet-wired) `ToggleRaiseHand`, `ToggleChat`, `OpenBackgroundEffects`. Candidate name lists localized for English/German/Spanish/French/Portuguese/Japanese — all locales matched in a single pass; the first match wins.
- **PostMessage shortcut forwarding fallback.** `TeamsLauncher.SendShortcut(modifiers, vk)` posts WM_KEYDOWN/UP to the most-recently-used hidden Teams HWND. Best-effort — modern WebView2-hosted Teams sometimes ignores synthesized key messages at the app-shortcut layer; UIA is preferred when a button exists for the action.
### Diagnostics
- `TeamsISO.Console --list-sources` enumerates raw NDI source names visible to the local finder for ~5 seconds; debugging tool for setup issues.
@ -49,25 +91,19 @@
### CI / Release / Docs
- Forgejo CI is green: `actions/upload-artifact@v3` (Forgejo doesn't support v4 yet).
- `.forgejo/workflows/release.yml`: tag-push (`v*.*.*`) builds + tests + publishes + builds the MSI on a Windows runner and attaches it to the auto-created Forgejo release via the REST API.
- `docs/RELEASING.md` walks through cutting a release and flags the code-signing TODO.
- **Optional code-signing** wired into `release.yml`: when `SIGN_CERT_PFX_BASE64` + `SIGN_CERT_PASSWORD` Forgejo secrets are set, the workflow signs both `TeamsISO.exe` (before MSI build) and the MSI (after) with SHA-256 + RFC 3161 timestamp. Skipped silently when the cert isn't configured. `docs/RELEASING.md` documents the OV vs EV trade-offs and the Azure Trusted Signing migration path.
### Tests
- 78 unit tests passing; 9 NDI integration tests gated behind `--filter requires=ndi` (runtime probe, finder + sender lifecycle on default and custom groups, loopback discovery, full pipeline frame round-trip asserting 1080p normalization).
## Next
1. **Phase E — Embedded Teams orchestration (continued).** E.2 (window orchestration: hide Teams' main window once launched, forward keyboard shortcuts via SendInput) and E.3 (Microsoft Graph or UIAutomation in-call controls) per the spec at `docs/superpowers/specs/2026-05-08-embedded-teams-orchestration.md`.
1. **Smoke-test on real Teams.** Most of May's work hasn't run against a live meeting yet: the UIA in-call commands (mute / camera / share / leave) need their candidate-Name lists validated against the current Teams build, and the auto-apply-on-launch flow needs a real recurring meeting to confirm the 30-second grace window is right. Pin the AutomationIds for buttons we find — Name-based lookup is a starting point, AutomationId is what survives Teams UI updates.
2. **Code-signing the MSI.** `installer/TeamsISO.Installer.wixproj` has a `SignOutput` property hook but no cert; for v1.0 wire a `signtool` invocation from a CI secret. SmartScreen will warn on first launch until that lands.
2. **Acquire a code-signing cert.** Pipeline is wired (see "CI / Release / Docs" above); just needs `SIGN_CERT_PFX_BASE64` + `SIGN_CERT_PASSWORD` set in Forgejo Secrets. OV cert (~$200/yr) gets us signed but SmartScreen builds reputation slowly; EV cert (~$300/yr, hardware token) is SmartScreen-trusted immediately. Azure Trusted Signing is the cloud-native path if a token-on-runner is fiddly.
3. **Toast / inline feedback** for "Apply Changes" and other settings actions (currently silent unless an error occurs).
3. **Activate `MediaFoundationRecorderSink`.** Scaffold + activation docs at `docs/REAL-TIME-RECORDING.md` ship in this batch (gated behind `MF_AVAILABLE` build symbol). To enable: `dotnet add Vortice.MediaFoundation`, define `MF_AVAILABLE`, swap one line in `IsoController.EnableIsoAsync`. Rough ~10× disk-pressure reduction.
4. **Rail "Refresh sources"** affordance — force a discovery rescan, useful right after applying a new transcoder topology when the operator wants Teams to reconnect.
4. **Wire engine audio capture.** The UI's audio level VU bar (in the Live column) is in place but inert — `IsoHealthStats.PeakAudioLevel` always reads 0.0. Engine work needed: extend `INdiInterop.CaptureFrame` to also surface audio frames, parse based on FourCC (FLTp, PCMs16, etc), compute peak per pipeline tick, publish through `IsoPipeline.GetStats`. Once that's done the UI bar starts animating with no further changes.
5. **Output thumbnail previews** in the participant DataGrid — small live-frame previews of each ISO output. Complex (needs WPF WriteableBitmap from the Pipeline's last ProcessedFrame); deferred until v1.5.
6. **Settings panel UX tightening.** It's getting long; consider an Accordion or tabs for OUTPUT FORMAT / NDI NETWORK / DISPLAY rather than one scrolling stack.
7. **Auto-disable on participant departure (configurable).** Today an ISO stays "enabled" if its participant leaves; the source goes null. Optional toggle: tear down the pipeline automatically when a participant has been gone past the rename window.
8. **Operator presets.** "Save current ISO assignments to a named preset" + "Load preset on next launch" so an operator with a recurring show doesn't have to re-toggle every meeting.
5. **Forward Teams keyboard shortcuts via SendInput.** Phase E.2 hides the Teams window but doesn't forward Ctrl+Shift+M / Ctrl+Shift+O / Ctrl+Shift+H to it. UIA covers mute/camera/share/leave/raise-hand/chat/background already; SendInput would let us pass arbitrary global hotkeys through to a hidden Teams for actions UIA can't reach. Lower priority now that UIA covers the core actions.

View file

@ -136,6 +136,29 @@
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="Recordings"
Click="OnOpenRecordings"
Padding="14,6"
Margin="0,0,8,0"
ToolTip="Open the configured recording directory in Explorer (defaults to %USERPROFILE%\Videos\TeamsISO)"/>
<Button Style="{StaticResource Wd.Button.Ghost}"
Content="Notes"
Click="OnOpenNotes"
Padding="14,6"
ToolTip="Open %LOCALAPPDATA%\TeamsISO\Notes in Explorer"/>
</StackPanel>
</StackPanel>
<!-- Footer -->
@ -159,11 +182,31 @@
</Hyperlink>
<Run Text=" · © Wild Dragon LLC"/>
</TextBlock>
<Button Grid.Column="1"
Style="{StaticResource Wd.Button.Ghost}"
Content="Close"
Click="OnClose"
MinWidth="80"/>
<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>

View file

@ -1,8 +1,10 @@
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;
@ -48,6 +50,157 @@ public partial class AboutWindow : Window
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"));
private void OnOpenRecordings(object sender, RoutedEventArgs e)
{
// Default to the user-Videos folder. Operator can navigate into the
// current session's date subfolder from there. We don't reach into
// the engine for the live recording path because exposing the
// controller through App would be a wider plumbing change for a
// shortcut button.
OpenInExplorer(Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.MyVideos),
"TeamsISO"));
}
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

View file

@ -12,6 +12,9 @@ using TeamsISO.Engine.NdiInterop;
using TeamsISO.Engine.Persistence;
using TeamsISO.Engine.Pipeline;
// Application + MessageBox aliases live in GlobalUsings.cs (project-wide).
// Don't redeclare here — Roslyn errors with CS1537 on duplicate alias.
namespace TeamsISO.App;
public partial class App : Application
@ -32,6 +35,23 @@ public partial class App : Application
private NdiInteropPInvoke? _interop;
private IsoController? _controller;
private MainViewModel? _viewModel;
private TeamsISO.App.Services.ControlSurfaceServer? _controlSurface;
private TeamsISO.App.Services.OscBridge? _oscBridge;
private TeamsISO.App.Services.DiskSpaceWatcher? _diskSpaceWatcher;
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);
@ -45,6 +65,17 @@ public partial class App : Application
{
base.OnStartup(e);
// Crash diagnostics — wire the three exception channels WPF leaves open by
// default to a single handler that logs Fatal to Serilog (which has the
// rolling-daily file sink 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 will take it from here.
AppDomain.CurrentDomain.UnhandledException += OnAppDomainUnhandled;
DispatcherUnhandledException += OnDispatcherUnhandled;
System.Threading.Tasks.TaskScheduler.UnobservedTaskException += OnUnobservedTaskException;
// Single-instance gate: if another TeamsISO is already running for this user,
// broadcast the bring-to-front message and exit silently. This prevents the
// NDI/config contention seen during testing where two finders, two senders
@ -138,7 +169,82 @@ public partial class App : Application
window.Show();
MainWindow = window;
// REST control surface for Stream Deck / Companion. Off by default —
// operators turn it on via the DISPLAY tab. When the toggle flips,
// GlobalSettingsViewModel reaches into App.Current to start/stop it.
_controlSurface = new TeamsISO.App.Services.ControlSurfaceServer(
_controller,
() => _viewModel,
_loggerFactory.CreateLogger<TeamsISO.App.Services.ControlSurfaceServer>());
_oscBridge = new TeamsISO.App.Services.OscBridge(
_controller,
() => _viewModel,
_loggerFactory.CreateLogger<TeamsISO.App.Services.OscBridge>());
// Disk space watcher: polls the recording drive every 5s while
// recording is on. Auto-disables recording at <1GB free so an
// unattended long show doesn't crash the host on disk-full.
_diskSpaceWatcher = new TeamsISO.App.Services.DiskSpaceWatcher(
_controller, _viewModel.Toast, Dispatcher);
// Tray icon host. Disabled by default; the settings VM flips
// Enabled when the operator toggles the DISPLAY checkbox. Hosting
// it from App ensures the icon's lifetime matches the process,
// not the main window (which gets hidden during minimize-to-tray).
_trayIcon = new TeamsISO.App.Services.TrayIconHost(window)
{
Enabled = _viewModel.Settings.MinimizeToTray,
};
// First-launch onboarding. The dialog explains the once-per-machine
// setup (NDI runtime, Teams admin permission, transcoder topology)
// that the UI alone can't communicate clearly. Suppressed after the
// user dismisses it with the checkbox checked. We show it AFTER the
// main window so the dialog has a sensible Owner for centering and
// z-order.
if (OnboardingWindow.ShouldShow())
{
try
{
var onboarding = new OnboardingWindow { Owner = window };
onboarding.ShowDialog();
}
catch
{
// Defensive: an onboarding-dialog failure should never block startup.
}
}
// Parse CLI args BEFORE InitializeAsync so any --apply-preset request
// overrides the persisted auto-apply preference cleanly.
ApplyCommandLineArgs(e.Args);
await _viewModel.InitializeAsync(CancellationToken.None);
// 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.
if (Services.UpdateChecker.LaunchCheckEnabled)
{
_ = Task.Run(async () =>
{
try
{
var result = await Services.UpdateChecker.CheckIfDueAsync(TimeSpan.FromHours(24));
if (result?.Status == Services.UpdateChecker.UpdateStatus.UpdateAvailable
&& !string.IsNullOrEmpty(result.LatestTag)
&& !string.IsNullOrEmpty(result.CurrentVersion))
{
await Dispatcher.InvokeAsync(() =>
_viewModel.UpdateBanner.Show(result.CurrentVersion!, result.LatestTag!));
}
}
catch (Exception ex)
{
logger.LogDebug(ex, "Background update check failed");
}
});
}
}
catch (Exception ex)
{
@ -151,10 +257,116 @@ public partial class App : Application
}
}
/// <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");
/// <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;
}
}
}
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:
// there's nothing useful we can 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.
}
}
protected override async void OnExit(ExitEventArgs e)
{
try
{
_trayIcon?.Dispose();
_diskSpaceWatcher?.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();

View 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;

View file

@ -0,0 +1,210 @@
<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,0"/>
<TextBlock Grid.Row="3" Grid.Column="1"
Text="Refresh NDI discovery (rebuild finder)"
Style="{StaticResource Wd.Text.Body}"
FontSize="12"/>
</Grid>
</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\&lt;date&gt;\"/>
<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. See docs/CONTROL-SURFACE.md for the full vocabulary."
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>

View 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
}
}
}

View file

@ -33,11 +33,29 @@
<Window.Resources>
<conv:BoolToVisibilityConverter x:Key="BoolToVis"/>
<conv:BoolToVisibilityConverter x:Key="InvertBool"
TrueValue="Collapsed"
FalseValue="Visible"/>
<conv:EnumDescriptionConverter x:Key="EnumDesc"/>
<conv:InitialsConverter x:Key="Initials"/>
<conv:CountToVisibilityConverter x:Key="CountToVis"/>
</Window.Resources>
<!--
Window-scoped keyboard shortcuts. Bound to view-model commands where
available; F1 routes to a code-behind handler that opens HelpWindow
(the help dialog is a host concern, not a VM concern). Window-scoped
means these only fire when TeamsISO is the foreground window — using
RegisterHotKey for global shortcuts would be a v2 concern (operators
rarely want a Stream Deck and a global hotkey for the same action).
-->
<Window.InputBindings>
<KeyBinding Key="F1" Command="{Binding ShowHelpCommand}"/>
<KeyBinding Key="M" Modifiers="Ctrl" Command="{Binding DropRecordingMarkerCommand}"/>
<KeyBinding Key="S" Modifiers="Ctrl+Shift" Command="{Binding StopAllIsosCommand}"/>
<KeyBinding Key="R" Modifiers="Ctrl" Command="{Binding RefreshDiscoveryCommand}"/>
</Window.InputBindings>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="72"/> <!-- Left rail -->
@ -120,6 +138,22 @@
Stretch="Uniform"/>
</Button>
<!-- Nav: Hide / Show Teams windows. Phase E.2 of the embedded-Teams roadmap.
Toggles every visible top-level Teams window between SW_HIDE and SW_SHOW
so the operator can keep TeamsISO foregrounded without alt-tabbing. -->
<Button DockPanel.Dock="Top"
Style="{StaticResource Wd.Button.RailIcon}"
Click="OnToggleTeamsWindowClick"
ToolTip="Hide / show Microsoft Teams windows (keeps Teams running but moves it out of the way)">
<!-- Eye icon (open / closed handled by the click toggling Teams visibility, not the icon) -->
<Path Data="M 1,11 C 5,4 17,4 21,11 C 17,18 5,18 1,11 Z M 11,8 A 3,3 0 1 1 11,14 A 3,3 0 1 1 11,8 Z"
Stroke="{DynamicResource Wd.Text.Secondary}"
StrokeThickness="1.4"
Fill="Transparent"
Width="22" Height="22"
Stretch="Uniform"/>
</Button>
<!-- Nav: Settings (placeholder — opens panel on right; not toggled in this build) -->
<Button DockPanel.Dock="Top"
Style="{StaticResource Wd.Button.RailIcon}"
@ -277,6 +311,9 @@
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Text="WOOGLIN"
Style="{StaticResource Wd.Text.Mono}"
@ -288,7 +325,67 @@
Foreground="{DynamicResource Wd.Text.Tertiary}"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
<TextBlock Grid.Column="2"
<!-- Session timer — green dot + elapsed when at least one ISO is live -->
<StackPanel Grid.Column="2"
Orientation="Horizontal"
VerticalAlignment="Center"
Margin="0,0,16,0"
Visibility="{Binding IsSessionActive, Converter={StaticResource BoolToVis}}"
ToolTip="Elapsed time since the first ISO went live this session.">
<Border Width="8" Height="8"
CornerRadius="4"
Background="{DynamicResource Wd.Status.Live}"
VerticalAlignment="Center"
Margin="0,0,6,0"/>
<TextBlock Text="{Binding SessionElapsed}"
Style="{StaticResource Wd.Text.Mono}"
FontSize="11"
Foreground="{DynamicResource Wd.Status.Live}"
VerticalAlignment="Center"/>
</StackPanel>
<!-- Recording badge — coral dot + count when recording is on -->
<StackPanel Grid.Column="3"
Orientation="Horizontal"
VerticalAlignment="Center"
Margin="0,0,16,0"
Visibility="{Binding IsRecording, Converter={StaticResource BoolToVis}}"
ToolTip="One or more ISOs are being recorded to disk.">
<Border Width="8" Height="8"
CornerRadius="4"
Background="{DynamicResource Wd.Accent.Coral}"
VerticalAlignment="Center"
Margin="0,0,6,0"/>
<TextBlock Style="{StaticResource Wd.Text.Mono}"
FontSize="11"
Foreground="{DynamicResource Wd.Accent.Coral}"
VerticalAlignment="Center">
<Run Text="REC "/>
<Run Text="{Binding ActiveRecordingCount, Mode=OneWay}"/>
</TextBlock>
</StackPanel>
<!-- Control surface badge — cyan dot + REST/OSC string when active -->
<StackPanel Grid.Column="4"
Orientation="Horizontal"
VerticalAlignment="Center"
Margin="0,0,16,0"
Visibility="{Binding IsControlSurfaceRunning, Converter={StaticResource BoolToVis}}"
ToolTip="External control surface is listening on localhost. See docs/CONTROL-SURFACE.md.">
<Border Width="8" Height="8"
CornerRadius="4"
Background="{DynamicResource Wd.Accent.Cyan}"
VerticalAlignment="Center"
Margin="0,0,6,0"/>
<TextBlock Text="{Binding ControlSurfaceText}"
Style="{StaticResource Wd.Text.Mono}"
FontSize="11"
Foreground="{DynamicResource Wd.Accent.Cyan}"
VerticalAlignment="Center"/>
</StackPanel>
<TextBlock Grid.Column="5"
Text="wilddragon.net · 1.0.0-alpha"
Style="{StaticResource Wd.Text.Mono}"
Foreground="{DynamicResource Wd.Text.Tertiary}"
@ -299,13 +396,198 @@
<!-- Body -->
<Grid Margin="32,28,32,28">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<!--
Update-available banner. Hidden by default; shown by the launch-time
UpdateChecker if a newer release tag exists on Forgejo. "Get update"
opens the releases page; "Dismiss" hides until next launch.
-->
<Border Grid.Row="0"
Style="{StaticResource Wd.Card}"
Padding="14,10"
Margin="0,0,0,14"
Background="{DynamicResource Wd.Accent.CyanMuted}"
BorderBrush="{DynamicResource Wd.Accent.Cyan}"
Visibility="{Binding UpdateBanner.IsVisible, Converter={StaticResource BoolToVis}}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<Border Grid.Column="0"
Width="8" Height="8"
CornerRadius="4"
Background="{DynamicResource Wd.Accent.Cyan}"
VerticalAlignment="Center"
Margin="0,0,12,0"/>
<TextBlock Grid.Column="1"
Text="{Binding UpdateBanner.Message}"
Style="{StaticResource Wd.Text.Body}"
FontSize="12"
VerticalAlignment="Center"
Foreground="{DynamicResource Wd.Text.Primary}"/>
<Button Grid.Column="2"
Style="{StaticResource Wd.Button.Ghost}"
Content="Get update"
Command="{Binding UpdateBanner.OpenReleasePageCommand}"
Padding="14,4"
Margin="0,0,8,0"
ToolTip="Open the Forgejo releases page in your browser to download the new MSI."/>
<Button Grid.Column="3"
Style="{StaticResource Wd.Button.Ghost}"
Content="Dismiss"
Command="{Binding UpdateBanner.DismissCommand}"
Padding="14,4"
ToolTip="Hide this banner. It'll reappear on next launch if the update is still available."/>
</Grid>
</Border>
<!--
Phase E.3 in-call control bar. Drives Microsoft Teams' UI via
UIAutomation so the operator can mute, toggle camera, share, or
leave without alt-tabbing to Teams (especially useful when the
Teams windows are hidden via the rail toggle). Buttons toast the
result; if Teams isn't in a call, the underlying buttons aren't
in the automation tree so the toast says so.
-->
<Border Grid.Row="1"
Style="{StaticResource Wd.Card}"
Padding="14,10"
Margin="0,0,0,18">
<StackPanel Orientation="Horizontal" HorizontalAlignment="Left">
<TextBlock Text="IN-CALL"
Style="{StaticResource Wd.Text.Caption}"
VerticalAlignment="Center"
Margin="0,0,16,0"/>
<Button Style="{StaticResource Wd.Button.Ghost}"
Command="{Binding ToggleMuteCommand}"
Padding="14,6"
Margin="0,0,8,0"
ToolTip="Toggle Microsoft Teams microphone mute">
<StackPanel Orientation="Horizontal">
<Path Data="M 5,2 L 9,2 L 9,8 A 2,2 0 0 1 5,8 Z M 3,8 A 4,4 0 0 0 11,8 M 7,12 L 7,15 M 5,15 L 9,15"
Stroke="{DynamicResource Wd.Text.Primary}"
StrokeThickness="1.4"
Fill="Transparent"
Width="14" Height="16"
Stretch="None"
VerticalAlignment="Center"
Margin="0,0,8,0"/>
<TextBlock Text="Mute"
Style="{StaticResource Wd.Text.Body}"
FontSize="12"
VerticalAlignment="Center"/>
</StackPanel>
</Button>
<Button Style="{StaticResource Wd.Button.Ghost}"
Command="{Binding ToggleCameraCommand}"
Padding="14,6"
Margin="0,0,8,0"
ToolTip="Toggle Microsoft Teams camera">
<StackPanel Orientation="Horizontal">
<Path Data="M 1,3 L 11,3 L 11,11 L 1,11 Z M 11,5 L 15,3 L 15,11 L 11,9 Z"
Stroke="{DynamicResource Wd.Text.Primary}"
StrokeThickness="1.4"
Fill="Transparent"
Width="16" Height="14"
Stretch="None"
VerticalAlignment="Center"
Margin="0,0,8,0"/>
<TextBlock Text="Camera"
Style="{StaticResource Wd.Text.Body}"
FontSize="12"
VerticalAlignment="Center"/>
</StackPanel>
</Button>
<Button Style="{StaticResource Wd.Button.Ghost}"
Command="{Binding OpenShareTrayCommand}"
Padding="14,6"
Margin="0,0,8,0"
ToolTip="Open the Microsoft Teams share tray">
<StackPanel Orientation="Horizontal">
<Path Data="M 8,1 L 8,10 M 4,5 L 8,1 L 12,5 M 1,12 L 1,15 L 15,15 L 15,12"
Stroke="{DynamicResource Wd.Text.Primary}"
StrokeThickness="1.4"
Fill="Transparent"
Width="16" Height="16"
Stretch="None"
VerticalAlignment="Center"
Margin="0,0,8,0"/>
<TextBlock Text="Share"
Style="{StaticResource Wd.Text.Body}"
FontSize="12"
VerticalAlignment="Center"/>
</StackPanel>
</Button>
<Button Style="{StaticResource Wd.Button.Ghost}"
Command="{Binding DropRecordingMarkerCommand}"
Padding="14,6"
Margin="0,0,8,0"
ToolTip="Drop a timestamped marker into every active recording. Useful for chaptering in post — 'guest answer starts here', 'highlight clip', etc. Markers land in each recording's manifest.json.">
<StackPanel Orientation="Horizontal">
<Path Data="M 4,1 L 4,15 M 4,1 L 13,1 L 11,4 L 13,7 L 4,7"
Stroke="{DynamicResource Wd.Text.Primary}"
StrokeThickness="1.4"
Fill="Transparent"
Width="14" Height="16"
Stretch="None"
VerticalAlignment="Center"
Margin="0,0,8,0"/>
<TextBlock Text="Marker"
Style="{StaticResource Wd.Text.Body}"
FontSize="12"
VerticalAlignment="Center"/>
</StackPanel>
</Button>
<Button Style="{StaticResource Wd.Button.Ghost}"
Command="{Binding ShowNotesCommand}"
Padding="14,6"
Margin="0,0,8,0"
ToolTip="Open the show-notes viewer for today. Notes can be appended via the REST or OSC notes endpoint or by typing in this dialog's editor link.">
<StackPanel Orientation="Horizontal">
<Path Data="M 2,1 L 12,1 L 12,14 L 2,14 Z M 4,4 L 10,4 M 4,7 L 10,7 M 4,10 L 8,10"
Stroke="{DynamicResource Wd.Text.Primary}"
StrokeThickness="1.2"
Fill="Transparent"
Width="14" Height="15"
Stretch="None"
VerticalAlignment="Center"
Margin="0,0,8,0"/>
<TextBlock Text="Notes"
Style="{StaticResource Wd.Text.Body}"
FontSize="12"
VerticalAlignment="Center"/>
</StackPanel>
</Button>
<Button Style="{StaticResource Wd.Button.Ghost}"
Command="{Binding LeaveCallCommand}"
Padding="14,6"
ToolTip="Leave the Microsoft Teams call">
<StackPanel Orientation="Horizontal">
<Border Width="8" Height="8"
CornerRadius="4"
Background="{DynamicResource Wd.Accent.Coral}"
VerticalAlignment="Center"
Margin="0,0,8,0"/>
<TextBlock Text="Leave"
Style="{StaticResource Wd.Text.Body}"
FontSize="12"
VerticalAlignment="Center"/>
</StackPanel>
</Button>
</StackPanel>
</Border>
<!-- Section header -->
<Grid Grid.Row="0">
<Grid Grid.Row="2">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
@ -323,34 +605,104 @@
</Border>
</StackPanel>
<!-- Emergency stop: tears down every running ISO in one click. -->
<Button Grid.Column="1"
Style="{StaticResource Wd.Button.Ghost}"
Command="{Binding StopAllIsosCommand}"
VerticalAlignment="Center"
ToolTip="Disable every running ISO immediately">
<StackPanel Orientation="Horizontal">
<Border Width="8" Height="8"
CornerRadius="4"
Background="{DynamicResource Wd.Accent.Coral}"
VerticalAlignment="Center"
Margin="0,0,8,0"/>
<TextBlock Text="Stop all ISOs"
Style="{StaticResource Wd.Text.Body}"
FontSize="12"
VerticalAlignment="Center"/>
</StackPanel>
</Button>
<!-- Header actions: Refresh discovery, Presets dialog, emergency stop. -->
<StackPanel Grid.Column="1"
Orientation="Horizontal"
VerticalAlignment="Center">
<Button Style="{StaticResource Wd.Button.Ghost}"
Command="{Binding EnableAllOnlineCommand}"
VerticalAlignment="Center"
Margin="0,0,8,0"
ToolTip="Enable ISOs for every online participant who isn't already routing. Useful at show-start when everyone has just joined.">
<StackPanel Orientation="Horizontal">
<Border Width="8" Height="8"
CornerRadius="4"
Background="{DynamicResource Wd.Status.Live}"
VerticalAlignment="Center"
Margin="0,0,8,0"/>
<TextBlock Text="Enable all"
Style="{StaticResource Wd.Text.Body}"
FontSize="12"
VerticalAlignment="Center"/>
</StackPanel>
</Button>
<Button Style="{StaticResource Wd.Button.Ghost}"
Command="{Binding RefreshDiscoveryCommand}"
VerticalAlignment="Center"
Margin="0,0,8,0"
ToolTip="Force NDI discovery to rebuild — useful right after applying a new transcoder topology or after Teams restarts.">
<StackPanel Orientation="Horizontal">
<Path Data="M 5,1 A 4,4 0 1 1 1.5,5 M 5,1 L 5,3 L 7,3"
Stroke="{DynamicResource Wd.Text.Primary}"
StrokeThickness="1.2"
Width="10" Height="10"
Stretch="None"
VerticalAlignment="Center"
Margin="0,0,8,0"/>
<TextBlock Text="Refresh"
Style="{StaticResource Wd.Text.Body}"
FontSize="12"
VerticalAlignment="Center"/>
</StackPanel>
</Button>
<Button Style="{StaticResource Wd.Button.Ghost}"
Click="OnPresetsClick"
VerticalAlignment="Center"
Margin="0,0,8,0"
ToolTip="Save or load named ISO assignment snapshots for recurring shows">
<StackPanel Orientation="Horizontal">
<Border Width="8" Height="8"
CornerRadius="4"
Background="{DynamicResource Wd.Accent.Cyan}"
VerticalAlignment="Center"
Margin="0,0,8,0"/>
<TextBlock Text="Presets"
Style="{StaticResource Wd.Text.Body}"
FontSize="12"
VerticalAlignment="Center"/>
</StackPanel>
</Button>
<Button Style="{StaticResource Wd.Button.Ghost}"
Command="{Binding StopAllIsosCommand}"
VerticalAlignment="Center"
ToolTip="Disable every running ISO immediately">
<StackPanel Orientation="Horizontal">
<Border Width="8" Height="8"
CornerRadius="4"
Background="{DynamicResource Wd.Accent.Coral}"
VerticalAlignment="Center"
Margin="0,0,8,0"/>
<TextBlock Text="Stop all ISOs"
Style="{StaticResource Wd.Text.Body}"
FontSize="12"
VerticalAlignment="Center"/>
</StackPanel>
</Button>
</StackPanel>
</Grid>
<TextBlock Grid.Row="1"
Text="Toggle a participant's ISO to spin up an isolated, normalized NDI output."
Style="{StaticResource Wd.Text.Subtle}"
Foreground="{DynamicResource Wd.Text.Tertiary}"
Margin="0,6,0,18"/>
<Grid Grid.Row="3" Margin="0,6,0,18">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0"
Text="Toggle a participant's ISO to spin up an isolated, normalized NDI output."
Style="{StaticResource Wd.Text.Subtle}"
Foreground="{DynamicResource Wd.Text.Tertiary}"
VerticalAlignment="Center"/>
<!-- Live filter input — case-insensitive substring match on display name -->
<TextBox Grid.Column="1"
Text="{Binding ParticipantFilter, UpdateSourceTrigger=PropertyChanged}"
MinWidth="200"
Padding="10,6"
FontSize="12"
VerticalAlignment="Center"
ToolTip="Filter the participants list as you type. Case-insensitive substring match against display name."/>
</Grid>
<!-- Participants card -->
<Border Grid.Row="2"
<Border Grid.Row="4"
Style="{StaticResource Wd.Card}"
Padding="0">
<Grid>
@ -421,14 +773,80 @@
</StackPanel>
<!-- DataGrid: visible only when Participants.Count > 0. -->
<DataGrid ItemsSource="{Binding Participants}"
<DataGrid ItemsSource="{Binding ParticipantsView}"
Visibility="{Binding Participants.Count, Converter={StaticResource CountToVis}}"
Margin="6,4,6,4">
<DataGrid.RowStyle>
<Style TargetType="DataGridRow">
<Setter Property="ContextMenu">
<Setter.Value>
<ContextMenu>
<MenuItem Header="Toggle ISO" Command="{Binding ToggleIsoCommand}"/>
<MenuItem Header="Restart this ISO"
Command="{Binding RestartIsoCommand}"
ToolTip="Disable + re-enable this pipeline only. Useful when a single feed flakes (drops climb, framerate jitters) without affecting other ISOs."/>
<MenuItem Header="Open preview…"
Command="{Binding OpenPreviewCommand}"
ToolTip="Pop out a floating live-preview window. Drag to a second monitor for full-screen monitoring."/>
<MenuItem Header="Record this participant"
IsCheckable="True"
IsChecked="{Binding RecordToDisk}"/>
<Separator/>
<MenuItem Header="Copy NDI source name"
Command="{Binding CopySourceNameCommand}"
ToolTip="{Binding SourceFullName}"/>
</ContextMenu>
</Setter.Value>
</Setter>
</Style>
</DataGrid.RowStyle>
<DataGrid.Columns>
<!--
Live preview thumbnail. 160×90 (16:9) WriteableBitmap fed
from the engine's most recent ProcessedFrame at the 1Hz
stats tick. Falls back to the avatar initials when no
pipeline is running (Thumbnail is null).
-->
<DataGridTemplateColumn Header="Preview" Width="120">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<Grid VerticalAlignment="Center" HorizontalAlignment="Center">
<!-- Placeholder shown when no thumbnail yet -->
<Border Width="100" Height="56"
Background="{DynamicResource Wd.SurfaceElevated}"
BorderBrush="{DynamicResource Wd.Border}"
BorderThickness="1"
CornerRadius="4"
Visibility="{Binding HasThumbnail, Converter={StaticResource InvertBool}}">
<TextBlock Text="—"
Foreground="{DynamicResource Wd.Text.Tertiary}"
FontFamily="{StaticResource Wd.Font.Mono}"
FontSize="11"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</Border>
<!-- Live thumbnail. Stretch=Uniform preserves aspect; clip via Border for rounded corners -->
<Border Width="100" Height="56"
BorderBrush="{DynamicResource Wd.Border}"
BorderThickness="1"
CornerRadius="4"
ClipToBounds="True"
Visibility="{Binding HasThumbnail, Converter={StaticResource BoolToVis}}">
<Image Source="{Binding Thumbnail}"
Stretch="UniformToFill"
RenderOptions.BitmapScalingMode="LowQuality"/>
</Border>
</Grid>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<DataGridTemplateColumn Header="Display Name" Width="2*">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<StackPanel Orientation="Horizontal"
VerticalAlignment="Center"
ToolTip="{Binding SourceFullName}">
<Border Style="{StaticResource Wd.Avatar}">
<TextBlock Text="{Binding DisplayName, Converter={StaticResource Initials}}"
Foreground="{DynamicResource Wd.Accent.Cyan}"
@ -466,7 +884,7 @@
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<DataGridTemplateColumn Header="Live" Width="120">
<DataGridTemplateColumn Header="Live" Width="140">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<StackPanel VerticalAlignment="Center">
@ -483,6 +901,22 @@
<Run Text="drop "/>
<Run Text="{Binding FramesDropped, Mode=OneWay}"/>
</TextBlock>
<!-- VU bar — width-bound to AudioLevelWidthPercent. The
outer Border is the 100-pixel-wide track; the inner
Border is the level fill. When the engine doesn't
yet feed PeakAudioLevel (current behavior), the bar
stays at 0 width. ToolTip explains the empty state. -->
<Border Width="100" Height="4"
HorizontalAlignment="Left"
Margin="0,4,0,0"
Background="{DynamicResource Wd.SurfaceElevated}"
CornerRadius="2"
ToolTip="Live audio peak. Engine audio capture is a follow-up; the bar will animate once that ships.">
<Border HorizontalAlignment="Left"
Width="{Binding AudioLevelWidthPercent, Mode=OneWay}"
Background="{DynamicResource Wd.Status.Live}"
CornerRadius="2"/>
</Border>
</StackPanel>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
@ -500,6 +934,23 @@
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<!--
Per-participant recording opt-out. When global recording is on,
only participants with this checkbox checked get a recorder when
their ISO starts. Read at EnableIsoAsync time so toggling on a
running pipeline has no effect — operator must disable + re-enable.
-->
<DataGridTemplateColumn Header="Rec" Width="60">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<CheckBox IsChecked="{Binding RecordToDisk}"
HorizontalAlignment="Center"
VerticalAlignment="Center"
ToolTip="When on, this participant's ISO is recorded to disk while global recording is enabled. Toggle while ISO is OFF — changes don't apply to a running pipeline."/>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<DataGridTemplateColumn Header="ISO" Width="130">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
@ -590,127 +1041,259 @@
Foreground="{DynamicResource Wd.Text.Tertiary}"
Margin="0,0,0,22"/>
<!-- Output Format -->
<TextBlock Text="OUTPUT FORMAT" Style="{StaticResource Wd.Text.Caption}"/>
<!--
Settings panel is grouped into three tabs (Output, Network, Display)
rather than one long scroll. Apply Changes lives outside the tabs so
it commits all three sections at once — the binding paths span groups
anyway (framerate + group strings + display toggles all roundtrip
through the same Settings.ApplyCommand).
-->
<TabControl Style="{StaticResource Wd.TabControl}">
<TextBlock Text="Target Framerate"
Style="{StaticResource Wd.Text.Subtle}"
Margin="0,10,0,4"/>
<ComboBox ItemsSource="{Binding Settings.AvailableFramerates}"
SelectedItem="{Binding Settings.Framerate}"
ToolTip="The framerate every ISO output is normalized to. Click Apply to commit.">
<ComboBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding Converter={StaticResource EnumDesc}}"/>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<!-- ──────────────── Output ──────────────── -->
<TabItem Header="OUTPUT" Style="{StaticResource Wd.TabItem}">
<StackPanel>
<TextBlock Text="Target Framerate"
Style="{StaticResource Wd.Text.Subtle}"
Margin="0,0,0,4"/>
<ComboBox ItemsSource="{Binding Settings.AvailableFramerates}"
SelectedItem="{Binding Settings.Framerate}"
ToolTip="The framerate every ISO output is normalized to. Click Apply to commit.">
<ComboBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding Converter={StaticResource EnumDesc}}"/>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<TextBlock Text="Target Resolution"
Style="{StaticResource Wd.Text.Subtle}"
Margin="0,12,0,4"/>
<ComboBox ItemsSource="{Binding Settings.AvailableResolutions}"
SelectedItem="{Binding Settings.Resolution}"
ToolTip="Output resolution. Source frames smaller than this are scaled up; larger frames are scaled down.">
<ComboBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding Converter={StaticResource EnumDesc}}"/>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<TextBlock Text="Target Resolution"
Style="{StaticResource Wd.Text.Subtle}"
Margin="0,12,0,4"/>
<ComboBox ItemsSource="{Binding Settings.AvailableResolutions}"
SelectedItem="{Binding Settings.Resolution}"
ToolTip="Output resolution. Source frames smaller than this are scaled up; larger frames are scaled down.">
<ComboBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding Converter={StaticResource EnumDesc}}"/>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<TextBlock Text="Aspect Mode"
Style="{StaticResource Wd.Text.Subtle}"
Margin="0,12,0,4"/>
<ComboBox ItemsSource="{Binding Settings.AvailableAspectModes}"
SelectedItem="{Binding Settings.Aspect}"
ToolTip="How to fit non-matching aspect ratios. Pillarbox = bars on the sides; Letterbox = bars top/bottom; Stretch = distort to fill.">
<ComboBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding Converter={StaticResource EnumDesc}}"/>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<TextBlock Text="Aspect Mode"
Style="{StaticResource Wd.Text.Subtle}"
Margin="0,12,0,4"/>
<ComboBox ItemsSource="{Binding Settings.AvailableAspectModes}"
SelectedItem="{Binding Settings.Aspect}"
ToolTip="How to fit non-matching aspect ratios. Pillarbox = bars on the sides; Letterbox = bars top/bottom; Stretch = distort to fill.">
<ComboBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding Converter={StaticResource EnumDesc}}"/>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<TextBlock Text="Audio Mode"
Style="{StaticResource Wd.Text.Subtle}"
Margin="0,12,0,4"/>
<ComboBox ItemsSource="{Binding Settings.AvailableAudioModes}"
SelectedItem="{Binding Settings.Audio}"
ToolTip="Audio routing for ISO outputs. Auto = isolated when available, fall back to mixed; Isolated = participant audio only; Mixed = full meeting mix.">
<ComboBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding Converter={StaticResource EnumDesc}}"/>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<TextBlock Text="Audio Mode"
Style="{StaticResource Wd.Text.Subtle}"
Margin="0,12,0,4"/>
<ComboBox ItemsSource="{Binding Settings.AvailableAudioModes}"
SelectedItem="{Binding Settings.Audio}"
ToolTip="Audio routing for ISO outputs. Auto = isolated when available, fall back to mixed; Isolated = participant audio only; Mixed = full meeting mix.">
<ComboBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding Converter={StaticResource EnumDesc}}"/>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<!-- NDI Network -->
<TextBlock Text="NDI NETWORK"
Style="{StaticResource Wd.Text.Caption}"
Margin="0,28,0,0"/>
<Button Style="{StaticResource Wd.Button.Ghost}"
Content="Reset to defaults"
Command="{Binding Settings.ResetOutputDefaultsCommand}"
HorizontalAlignment="Stretch"
Margin="0,16,0,0"
Padding="0,8"
ToolTip="Restore framerate, resolution, aspect and audio to TeamsISO defaults. Doesn't touch NDI groups or display toggles."/>
</StackPanel>
</TabItem>
<TextBlock Text="Discovery group"
Style="{StaticResource Wd.Text.Subtle}"
Margin="0,10,0,4"/>
<TextBox Text="{Binding Settings.DiscoveryGroups, UpdateSourceTrigger=PropertyChanged}"
ToolTip="Comma-separated NDI group names the engine subscribes to. Empty = Public (default)."/>
<TextBlock Text="Receive sources from this group. Empty = Public."
Style="{StaticResource Wd.Text.Body}"
FontSize="11"
Foreground="{DynamicResource Wd.Text.Tertiary}"
Margin="0,4,0,0"/>
<!-- ──────────────── Network ──────────────── -->
<TabItem Header="NETWORK" Style="{StaticResource Wd.TabItem}">
<StackPanel>
<TextBlock Text="Discovery group"
Style="{StaticResource Wd.Text.Subtle}"
Margin="0,0,0,4"/>
<TextBox Text="{Binding Settings.DiscoveryGroups, UpdateSourceTrigger=PropertyChanged}"
ToolTip="Comma-separated NDI group names the engine subscribes to. Empty = Public (default)."/>
<TextBlock Text="Receive sources from this group. Empty = Public."
Style="{StaticResource Wd.Text.Body}"
FontSize="11"
Foreground="{DynamicResource Wd.Text.Tertiary}"
Margin="0,4,0,0"/>
<TextBlock Text="Output group"
Style="{StaticResource Wd.Text.Subtle}"
Margin="0,12,0,4"/>
<TextBox Text="{Binding Settings.OutputGroups, UpdateSourceTrigger=PropertyChanged}"
ToolTip="Comma-separated NDI group(s) TeamsISO's normalized ISO outputs broadcast on. Empty = Public."/>
<TextBlock Text="Broadcast TeamsISO outputs on this group. Empty = Public."
Style="{StaticResource Wd.Text.Body}"
FontSize="11"
Foreground="{DynamicResource Wd.Text.Tertiary}"
Margin="0,4,0,0"/>
<TextBlock Text="Output group"
Style="{StaticResource Wd.Text.Subtle}"
Margin="0,12,0,4"/>
<TextBox Text="{Binding Settings.OutputGroups, UpdateSourceTrigger=PropertyChanged}"
ToolTip="Comma-separated NDI group(s) TeamsISO's normalized ISO outputs broadcast on. Empty = Public."/>
<TextBlock Text="Broadcast TeamsISO outputs on this group. Empty = Public."
Style="{StaticResource Wd.Text.Body}"
FontSize="11"
Foreground="{DynamicResource Wd.Text.Tertiary}"
Margin="0,4,0,0"/>
<Border Background="{DynamicResource Wd.SurfaceElevated}"
BorderBrush="{DynamicResource Wd.Border}"
BorderThickness="1"
CornerRadius="{StaticResource Radius.M}"
Padding="12,10"
Margin="0,12,0,0">
<TextBlock Style="{StaticResource Wd.Text.Body}"
FontSize="11"
Foreground="{DynamicResource Wd.Text.Secondary}"
TextWrapping="Wrap"
Text="Group changes apply on next launch — running pipelines aren't restarted to avoid orphaning live ISOs."/>
</Border>
<Border Background="{DynamicResource Wd.SurfaceElevated}"
BorderBrush="{DynamicResource Wd.Border}"
BorderThickness="1"
CornerRadius="{StaticResource Radius.M}"
Padding="12,10"
Margin="0,12,0,0">
<TextBlock Style="{StaticResource Wd.Text.Body}"
FontSize="11"
Foreground="{DynamicResource Wd.Text.Secondary}"
TextWrapping="Wrap"
Text="Group changes apply on next launch — running pipelines aren't restarted to avoid orphaning live ISOs."/>
</Border>
<!-- One-click transcoder topology setup. Writes the system-wide
NDI config so Teams broadcasts on a private group, then sets
the engine to consume from that group and re-emit on Public. -->
<Button Style="{StaticResource Wd.Button.Ghost}"
Content="Apply transcoder topology"
Command="{Binding Settings.ApplyTranscoderTopologyCommand}"
HorizontalAlignment="Stretch"
Margin="0,12,0,0"
Padding="0,9"/>
<TextBlock Style="{StaticResource Wd.Text.Body}"
FontSize="11"
Foreground="{DynamicResource Wd.Text.Tertiary}"
TextWrapping="Wrap"
Margin="0,4,0,0"
Text="Pins Teams' raw NDI broadcasts to a private 'teamsiso-input' group so they don't pollute the Public network. Restart Teams after applying."/>
<!-- One-click transcoder topology setup. Writes the system-wide
NDI config so Teams broadcasts on a private group, then sets
the engine to consume from that group and re-emit on Public. -->
<Button Style="{StaticResource Wd.Button.Ghost}"
Content="Apply transcoder topology"
Command="{Binding Settings.ApplyTranscoderTopologyCommand}"
HorizontalAlignment="Stretch"
Margin="0,12,0,0"
Padding="0,9"/>
<TextBlock Style="{StaticResource Wd.Text.Body}"
FontSize="11"
Foreground="{DynamicResource Wd.Text.Tertiary}"
TextWrapping="Wrap"
Margin="0,4,0,0"
Text="Pins Teams' raw NDI broadcasts to a private 'teamsiso-input' group so they don't pollute the Public network. Restart Teams after applying."/>
<!-- Display -->
<TextBlock Text="DISPLAY"
Style="{StaticResource Wd.Text.Caption}"
Margin="0,28,0,0"/>
<TextBlock Text="Output name template"
Style="{StaticResource Wd.Text.Subtle}"
Margin="0,16,0,4"/>
<TextBox Text="{Binding Settings.OutputNameTemplate, UpdateSourceTrigger=LostFocus}"
FontFamily="{StaticResource Wd.Font.Mono}"
FontSize="11"
ToolTip="Template applied to NDI output names when no per-participant Custom Name is set. Tokens: {name} = display name, {guid} = first-8 of Id, {machine} = PC name, {timestamp} = yyyyMMdd_HHmmss."/>
<TextBlock Style="{StaticResource Wd.Text.Body}"
FontSize="11"
Foreground="{DynamicResource Wd.Text.Tertiary}"
TextWrapping="Wrap"
Margin="0,4,0,0"
Text="Tokens: {name}, {guid}, {machine}, {timestamp}. Default: TEAMSISO_{guid}. Use TEAMSISO_{name} for human-readable downstream switcher names."/>
</StackPanel>
</TabItem>
<CheckBox Content="Hide my self-preview from participants"
IsChecked="{Binding Settings.HideLocalSelf}"
ToolTip="Filters out the '(Local)' source — your own preview that Teams broadcasts on the same machine — so you don't accidentally route it as an ISO."
Margin="0,10,0,0"/>
<!-- ──────────────── Display ──────────────── -->
<TabItem Header="DISPLAY" Style="{StaticResource Wd.TabItem}">
<StackPanel>
<CheckBox Content="Hide my self-preview from participants"
IsChecked="{Binding Settings.HideLocalSelf}"
ToolTip="Filters out the '(Local)' source — your own preview that Teams broadcasts on the same machine — so you don't accidentally route it as an ISO."/>
<CheckBox Content="Auto-disable ISOs when a participant leaves"
IsChecked="{Binding Settings.AutoDisableOnDeparture}"
ToolTip="When checked, departing participants automatically have their ISO pipelines torn down. Off by default so transient drops don't lose your routing."
Margin="0,10,0,0"/>
<CheckBox Content="Auto-apply last preset on launch"
IsChecked="{Binding Settings.AutoApplyLastPreset}"
ToolTip="On launch, automatically re-apply the most recently applied operator preset once participants populate. Useful for recurring shows where the same routing should be restored every session."
Margin="0,10,0,0"/>
<TextBlock Text="Sort participants by"
Style="{StaticResource Wd.Text.Subtle}"
Margin="0,12,0,4"/>
<ComboBox ItemsSource="{Binding Settings.AvailableSortModes}"
SelectedItem="{Binding Settings.ParticipantSort}"
ToolTip="JoinOrder = whatever order Teams emits sources (default). Alphabetical = stable across meetings, good for Stream Deck button assignments. OnlineFirst = online participants float to the top."/>
<CheckBox Content="Minimize to system tray"
IsChecked="{Binding Settings.MinimizeToTray}"
Margin="0,12,0,0"
ToolTip="When checked, minimizing the window hides it and shows a tray icon. Useful for long unattended shows. Double-click the tray icon to restore."/>
<Separator Margin="0,16,0,8"/>
<CheckBox Content="Record ISOs to disk"
IsChecked="{Binding Settings.RecordIsosToDisk}"
ToolTip="When checked, each newly-enabled ISO writes raw BGRA frames + manifest.json + convert.cmd to its own subdirectory. Run convert.cmd to produce H.264 .mkv via FFmpeg. Recording starts on the next ISO enable; already-running ISOs aren't retroactively captured."/>
<TextBlock Text="Output directory"
Style="{StaticResource Wd.Text.Subtle}"
Margin="0,12,0,4"
IsEnabled="{Binding Settings.RecordIsosToDisk}"/>
<TextBox Text="{Binding Settings.RecordingDirectory, UpdateSourceTrigger=PropertyChanged}"
IsEnabled="{Binding Settings.RecordIsosToDisk}"
ToolTip="Root directory for recordings. Each participant gets a subdirectory under this path. Default: %USERPROFILE%\Videos\TeamsISO\&lt;date&gt;."/>
<TextBlock Text="Recordings live under this path. Each ISO writes raw BGRA + manifest.json. Double-click the convert.cmd inside to produce a final H.264 .mkv."
Style="{StaticResource Wd.Text.Body}"
FontSize="11"
Foreground="{DynamicResource Wd.Text.Tertiary}"
TextWrapping="Wrap"
Margin="0,4,0,0"/>
<Separator Margin="0,16,0,8"/>
<CheckBox Content="Control surface (Stream Deck / Companion)"
IsChecked="{Binding Settings.ControlSurfaceEnabled}"
ToolTip="Start a localhost-only HTTP server so external controllers (Bitfocus Companion, Stream Deck plugins, Bome MIDI Translator) can drive TeamsISO. Bound to 127.0.0.1; not reachable from LAN."/>
<Grid Margin="0,8,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0"
Text="Port"
Style="{StaticResource Wd.Text.Subtle}"
VerticalAlignment="Center"
Margin="0,0,12,0"/>
<TextBox Grid.Column="1"
Text="{Binding Settings.ControlSurfacePort, UpdateSourceTrigger=LostFocus}"
ToolTip="Listening port for the REST control surface. Default 9755."/>
</Grid>
<TextBlock Style="{StaticResource Wd.Text.Body}"
FontSize="11"
Foreground="{DynamicResource Wd.Text.Tertiary}"
TextWrapping="Wrap"
Margin="0,4,0,0"
Text="Endpoints: GET /participants, POST /participants/{id}/iso, /presets/{name}/apply, /presets/refresh-discovery, /presets/stop-all, /teams/mute|camera|leave|share|raise-hand, /recording. WebSocket /ws pushes live state. See docs/CONTROL-SURFACE.md."/>
<CheckBox Content="OSC bridge (UDP)"
IsChecked="{Binding Settings.OscBridgeEnabled}"
Margin="0,12,0,0"
ToolTip="UDP listener that speaks the same command surface as REST. /teamsiso/iso 'Jane' 1, /teamsiso/preset 'Friday Show', /teamsiso/teams/mute, etc. Bound to 127.0.0.1 only."/>
<Grid Margin="0,8,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0"
Text="OSC port"
Style="{StaticResource Wd.Text.Subtle}"
VerticalAlignment="Center"
Margin="0,0,12,0"/>
<TextBox Grid.Column="1"
Text="{Binding Settings.OscBridgePort, UpdateSourceTrigger=LostFocus}"
ToolTip="UDP port for the OSC bridge. Default 9000 (TouchOSC's default)."/>
</Grid>
<Separator Margin="0,16,0,8"/>
<CheckBox Content="Check for updates on launch"
IsChecked="{Binding Settings.UpdateCheckOnLaunch}"
ToolTip="On startup, ask forge.wilddragon.net whether a newer release exists. Throttled to once per 24h. If newer, a banner appears at the top of the participants area; click 'Get update' to open the releases page in your browser."/>
</StackPanel>
</TabItem>
</TabControl>
<!--
Apply Changes commits OUTPUT + NETWORK fields together. DISPLAY-tab
toggles persist on each click (HideLocalSelf is in-memory only;
AutoDisableOnDeparture / AutoApplyLastPreset write through to disk
from their setters), so they don't strictly need this button — but
keeping it visible from any tab gives the user a single confirm.
-->
<Button Style="{StaticResource Wd.Button.Primary}"
Content="Apply Changes"
Command="{Binding Settings.ApplyCommand}"

View 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>

View 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.
}
}
}

View file

@ -0,0 +1,238 @@
<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 — 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="5"
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>

View 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();
}
}

View 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>

View 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
/// "&lt;original&gt; (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();
}
}

View 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>

View 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);
}
}
}
}

View file

@ -0,0 +1,196 @@
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. Just enough to give operators a phone-friendly
/// remote that connects via WebSocket to <c>/ws</c> and posts to the
/// existing REST endpoints.
///
/// Visual language matches the WPF host: dark canvas, cyan accent, mono
/// font for codey labels. Keeping the styling minimal so a future iteration
/// can swap in a fancier UI without breaking operator workflows that already
/// bookmark the URL.
/// </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;
--text: #f5f5f5;
--text-2: #a3a3a3;
--text-3: #6b6b6b;
--cyan: #97edf0;
--cyan-mute: #1b3537;
--coral: #fb819c;
--green: #4ade80;
}
* { 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: 720px; margin: 0 auto; }
h1 {
font-size: 13px; letter-spacing: 0.12em; font-weight: 600;
text-transform: uppercase; color: var(--text-3); margin: 0 0 18px;
}
.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; }
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;
}
button:hover { background: #242424; }
button.primary { background: var(--cyan-mute); color: var(--cyan); border-color: var(--cyan); }
button.danger { background: #3a1922; color: var(--coral); border-color: var(--coral); }
button.live { background: var(--cyan-mute); color: var(--cyan); border-color: var(--cyan); }
.dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
.dot.cyan { background: var(--cyan); }
.dot.coral { background: var(--coral); }
.dot.green { background: var(--green); }
.dot.gray { background: var(--text-3); }
.name { font-weight: 500; }
.sub { color: var(--text-3); font-size: 11px;
font-family: 'JetBrains Mono', 'Cascadia Mono', monospace; }
.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: 16px; text-align: center; }
details summary { cursor: pointer; color: var(--text-2); font-size: 12px; }
details summary::marker { color: var(--text-3); }
.global-actions { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; }
@media (min-width: 480px) { .global-actions { grid-template-columns: repeat(4, 1fr); } }
</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'>
<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='post(""/recording/marker"")'>Marker</button>
<button onclick='dropNote()'>Note</button>
<button onclick='post(""/presets/refresh-discovery"")'>Refresh</button>
<button class='danger' onclick='post(""/presets/stop-all"")'>Stop all</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');
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); }
await fetch(path, opts);
} catch (e) { console.warn(e); }
}
function dropNote() {
const text = prompt('Note (will be timestamped in the day file):');
if (text && text.trim()) post('/notes', { text: text.trim() });
}
function render(participants) {
if (!participants || participants.length === 0) {
list.innerHTML = ""<div class='card empty'>No participants visible. Discover or invite into a Teams meeting.</div>"";
count.textContent = '';
return;
}
const live = participants.filter(p => p.isEnabled).length;
count.textContent = live + ' / ' + participants.length + ' live';
list.innerHTML = '';
for (const p of participants) {
const row = document.createElement('div');
row.className = 'card';
const dotColor = p.isEnabled ? 'cyan' : (p.isOnline ? 'gray' : 'coral');
row.innerHTML =
""<div class='row'>"" +
""<span class='dot "" + dotColor + ""'></span>"" +
""<div class='grow'>"" +
""<div class='name'></div>"" +
""<div class='sub'></div>"" +
""</div>"" +
""<button class='"" + (p.isEnabled ? 'live' : '') + ""'></button>"" +
""</div>"";
row.querySelector('.name').textContent = p.displayName;
row.querySelector('.sub').textContent =
(p.stateLabel || (p.isOnline ? 'online' : 'offline')) +
(p.customName ? ' · ' + p.customName : '');
const btn = row.querySelector('button');
btn.textContent = p.isEnabled ? ' LIVE' : 'Enable';
btn.onclick = () => post('/participants/iso', {
displayName: p.displayName,
enabled: !p.isEnabled,
});
list.appendChild(row);
}
}
function connect() {
setConn('gray', 'connecting');
const ws = new WebSocket(
(location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/ws');
ws.onopen = () => setConn('green', 'live');
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();
</script>
</body>
</html>
";
public static string Get() => Html;
}

View file

@ -0,0 +1,727 @@
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;
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&amp;customName=Host).
/// This is friendly to Companion's "URL with query string" mode.
/// </summary>
public sealed 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>
/// 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 a
/// different port, stop + restart on the new one.
/// </summary>
public void Start(int port)
{
if (IsRunning && Port == port) return;
Stop();
Port = port;
_listener = new HttpListener();
_listener.Prefixes.Add($"http://127.0.0.1:{port}/");
try
{
_listener.Start();
}
catch (HttpListenerException ex)
{
_logger?.LogWarning(ex, "Could not start control surface on port {Port}.", 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 http://127.0.0.1:{Port}/ (REST + ws)", port);
}
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;
}
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"),
("POST", "/recording") => SetRecording(body, req.QueryString),
("POST", "/recording/marker") => DropMarker(body, req.QueryString),
("POST", "/recording/roll") => await RollRecordingAsync(),
("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 ───────────────────────────────────────────────────────
private object GetServerInfo()
{
// Best-effort engine snapshot — wrapped in try/catch 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,
},
recording = new
{
enabled = _controller.RecordingEnabled,
directory = _controller.RecordingDirectory,
},
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 /recording (body: enabled + directory)",
"POST /recording/marker (body: label)",
"POST /notes (body: text)",
},
};
}
private static T? TryRead<T>(Func<T> reader) where T : class
{
try { return reader(); }
catch { return null; }
}
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 list = dispatcher.Invoke(() => 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 new { participants = list };
}
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 object InvokeTeams(Func<TeamsControlBridge.InvokeResult> invoke, string action)
{
var result = invoke();
return new
{
ok = result == TeamsControlBridge.InvokeResult.Invoked,
action,
result = result.ToString(),
};
}
private object SetRecording(JsonElement body, System.Collections.Specialized.NameValueCollection query)
{
var enabled = TryGetBool(body, query, "enabled") ?? false;
var directory = TryGetString(body, query, "directory");
_controller.SetRecording(enabled, directory);
return new { ok = true, recording = new { enabled, directory } };
}
private object DropMarker(JsonElement body, System.Collections.Specialized.NameValueCollection query)
{
var label = TryGetString(body, query, "label")
?? "Marker @ " + DateTimeOffset.Now.ToString("HH:mm:ss");
_controller.AddRecordingMarker(label);
return new { ok = true, action = "marker", label };
}
private object AppendNote(JsonElement body, System.Collections.Specialized.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 };
}
/// <summary>
/// Roll every active recording into a new chunk. Same code path as the UI's
/// RollRecordingCommand — disable + brief delay + re-enable each pipeline.
/// We marshal the participants snapshot through the dispatcher because
/// ObservableCollection isn't safe to enumerate from the request thread.
/// </summary>
private async Task<object> RollRecordingAsync()
{
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 enabled = await dispatcher.InvokeAsync(() =>
vm.Participants.Where(p => p.IsEnabled).ToArray());
var rolled = 0;
foreach (var p in enabled)
{
try
{
await _controller.DisableIsoAsync(p.Id, CancellationToken.None);
await Task.Delay(150);
var nameToUse = string.IsNullOrWhiteSpace(p.CustomName)
? OutputNameTemplate.Render(OutputNameTemplate.Get(), p.Id, p.DisplayName)
: p.CustomName;
bool? recordOverride = p.RecordToDisk ? null : false;
await _controller.EnableIsoAsync(p.Id, nameToUse, recordOverride, CancellationToken.None);
rolled++;
}
catch { /* per-pipeline best-effort */ }
}
return new { ok = true, action = "roll-recording", rolled };
}
private async Task<object> ToggleIsoByIdAsync(string path, JsonElement body, System.Collections.Specialized.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, System.Collections.Specialized.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, System.Collections.Specialized.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 };
}
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,
};
}
[SuppressMessage("Performance", "CA1822", Justification = "Method group used in switch arm.")]
private object NotFound() => new { error = "not found" };
// ─── WebSocket push ─────────────────────────────────────────────────
/// <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);
}
// ─── 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];
}
}

View 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");
}

View file

@ -0,0 +1,100 @@
using System.IO;
using System.Windows.Threading;
using TeamsISO.App.ViewModels;
using TeamsISO.Engine.Controller;
namespace TeamsISO.App.Services;
/// <summary>
/// Polls the recording drive's free space every few seconds while recording is
/// on. Surfaces a coral warning toast at the soft threshold, and at the hard
/// threshold auto-disables recording so a long unattended show doesn't fill
/// the disk and crash the host's logger and write paths.
///
/// Lifecycle is tied to the MainViewModel — instantiate after the VM is wired,
/// dispose with the host.
/// </summary>
public sealed class DiskSpaceWatcher : IDisposable
{
/// <summary>Below this, toast a warning each tick.</summary>
public static readonly long SoftWarnBytes = 10L * 1024 * 1024 * 1024; // 10 GB
/// <summary>Below this, auto-disable recording to save the show.</summary>
public static readonly long HardStopBytes = 1L * 1024 * 1024 * 1024; // 1 GB
private readonly IIsoController _controller;
private readonly ToastViewModel _toast;
private readonly DispatcherTimer _timer;
private DateTimeOffset _lastWarnAt = DateTimeOffset.MinValue;
public DiskSpaceWatcher(IIsoController controller, ToastViewModel toast, Dispatcher dispatcher)
{
_controller = controller;
_toast = toast;
_timer = new DispatcherTimer(DispatcherPriority.Background, dispatcher)
{
Interval = TimeSpan.FromSeconds(5),
};
_timer.Tick += OnTick;
_timer.Start();
}
private void OnTick(object? sender, EventArgs e)
{
if (!_controller.RecordingEnabled) return;
var dir = _controller.RecordingDirectory;
if (string.IsNullOrEmpty(dir)) return;
long freeBytes;
try
{
// DriveInfo wants a drive letter / mount root. Walk up the path
// until we hit a directory that exists (the recording dir might
// not have been created yet by the first ISO).
var probe = dir;
while (!Directory.Exists(probe))
{
var parent = Path.GetDirectoryName(probe);
if (string.IsNullOrEmpty(parent) || parent == probe) break;
probe = parent;
}
if (!Directory.Exists(probe)) return;
var drive = new DriveInfo(Path.GetPathRoot(probe) ?? probe);
freeBytes = drive.AvailableFreeSpace;
}
catch
{
// If the drive query fails (network share dropped, weird path),
// skip this tick rather than spam the toast with errors.
return;
}
if (freeBytes < HardStopBytes)
{
_controller.SetRecording(false, dir);
_toast.Warn($"Recording AUTO-STOPPED — drive has only {FormatBytes(freeBytes)} free");
_lastWarnAt = DateTimeOffset.UtcNow;
}
else if (freeBytes < SoftWarnBytes)
{
// Throttle the soft warning so we don't toast every 5s for the
// last hour of disk space.
if (DateTimeOffset.UtcNow - _lastWarnAt < TimeSpan.FromMinutes(2)) return;
_toast.Warn($"Recording drive has {FormatBytes(freeBytes)} free — winding down soon");
_lastWarnAt = DateTimeOffset.UtcNow;
}
}
private static string FormatBytes(long bytes)
{
if (bytes >= 1L << 30) return $"{bytes / (double)(1L << 30):F1} GB";
if (bytes >= 1L << 20) return $"{bytes / (double)(1L << 20):F0} MB";
return $"{bytes} bytes";
}
public void Dispose()
{
_timer.Stop();
_timer.Tick -= OnTick;
}
}

View file

@ -0,0 +1,60 @@
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\&lt;YYYY-MM-DD&gt;.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();
private static string NotesDirectory =>
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;
}
}
}

View 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);
}
}

View file

@ -0,0 +1,393 @@
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;
public OscBridge(
IIsoController controller,
Func<MainViewModel?> viewModel,
ILogger<OscBridge>? logger = null)
{
_controller = controller;
_viewModel = viewModel;
_logger = logger;
}
public void Start(int port)
{
if (IsRunning && Port == port) return;
Stop();
Port = port;
try
{
// Bind to loopback only — same threat model as the REST surface.
_udp = new UdpClient(new IPEndPoint(IPAddress.Loopback, port));
}
catch (SocketException ex)
{
_logger?.LogWarning(ex, "Could not bind OSC bridge to udp://127.0.0.1:{Port}.", port);
_udp = null;
return;
}
_cts = new CancellationTokenSource();
_receiveTask = Task.Run(() => ReceiveLoopAsync(_cts.Token));
IsRunning = true;
_logger?.LogInformation("OSC bridge listening on udp://127.0.0.1:{Port}/", 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);
}
}
}
private 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;
case "/teamsiso/recording": SetRecording(msg); return;
case "/teamsiso/recording/marker": DropMarker(msg); return;
case "/teamsiso/recording/roll": await RollRecordingAsync(); return;
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);
}
}
private void SetRecording(OscMessage msg)
{
var enabled = msg.GetBoolArg(0) ?? false;
// OSC doesn't carry a directory string in this minimal protocol; let the
// recording directory remain whatever the UI / REST surface set last.
_controller.SetRecording(enabled, _controller.RecordingDirectory);
}
private void DropMarker(OscMessage msg)
{
var label = msg.GetStringArg(0) ?? "Marker @ " + DateTimeOffset.Now.ToString("HH:mm:ss");
_controller.AddRecordingMarker(label);
}
private static void AppendNote(OscMessage msg)
{
var text = msg.GetStringArg(0);
if (!string.IsNullOrWhiteSpace(text)) NotesService.Append(text);
}
/// <summary>Roll every active recording into a new chunk. Same path as REST /recording/roll.</summary>
private async Task RollRecordingAsync()
{
var vm = _viewModel();
var dispatcher = System.Windows.Application.Current?.Dispatcher;
if (vm is null || 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);
await Task.Delay(150);
var nameToUse = string.IsNullOrWhiteSpace(p.CustomName)
? OutputNameTemplate.Render(OutputNameTemplate.Get(), p.Id, p.DisplayName)
: p.CustomName;
bool? recordOverride = p.RecordToDisk ? null : false;
await _controller.EnableIsoAsync(p.Id, nameToUse, recordOverride, CancellationToken.None);
}
catch { /* per-pipeline best-effort */ }
}
}
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);
}
}

View file

@ -0,0 +1,107 @@
using System.IO;
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>"TEAMSISO_{guid}"</c> matches the original
/// hard-coded <c>DefaultOutputName</c> in <c>IsoController</c>; operators
/// can switch to <c>"TEAMSISO_{name}"</c> for human-readable output names
/// (recommended for downstream switchers that key on name patterns), or
/// <c>"TEAMSISO_{machine}_{name}"</c> when multiple TeamsISO machines feed
/// the same NDI network.
///
/// 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>
///
/// Persisted to <c>%LOCALAPPDATA%\TeamsISO\output-name-template.txt</c>.
/// </summary>
public static class OutputNameTemplate
{
public const string DefaultTemplate = "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.
return SanitizeForNdi(result);
}
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();
}
}

View 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;
}
}

View 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 */ }
}
}

View file

@ -0,0 +1,74 @@
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).
/// </summary>
public enum SortMode { JoinOrder, Alphabetical, OnlineFirst }
/// <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);
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.
}
}
}

View file

@ -4,10 +4,22 @@
<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>
<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>
@ -15,6 +27,18 @@
<ProjectReference Include="..\TeamsISO.Engine.NdiInterop\TeamsISO.Engine.NdiInterop.csproj" />
</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>
<!-- Wild Dragon brand assets — embedded as resources so the published binary is self-contained. -->
<ItemGroup>
<Resource Include="Assets\dragon-mark.png" />

View file

@ -1,3 +1,4 @@
using System.IO;
using System.Windows;
using TeamsISO.App.Services;
using TeamsISO.Engine.Controller;
@ -20,6 +21,18 @@ public sealed class GlobalSettingsViewModel : ObservableObject
private string _discoveryGroups;
private string _outputGroups;
private bool _hideLocalSelf = true;
private bool _autoDisableOnDeparture = false;
private bool _autoApplyLastPreset;
private bool _recordIsosToDisk;
private string _recordingDirectory = string.Empty;
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;
public GlobalSettingsViewModel(IIsoController controller, ToastViewModel? toast = null)
{
@ -35,8 +48,54 @@ public sealed class GlobalSettingsViewModel : ObservableObject
_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;
// 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 */ }
// Default recording directory: %USERPROFILE%\Videos\TeamsISO\<today's date>.
// Operator can override via the textbox. Date in the path keeps recordings
// from a long-running show day organized without us having to scan + rotate.
_recordingDirectory = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.MyVideos),
"TeamsISO",
DateTimeOffset.Now.ToString("yyyy-MM-dd"));
_recordIsosToDisk = controller.RecordingEnabled;
if (!string.IsNullOrEmpty(controller.RecordingDirectory))
_recordingDirectory = controller.RecordingDirectory;
ApplyCommand = new AsyncRelayCommand(ApplyAsync);
ApplyTranscoderTopologyCommand = new AsyncRelayCommand(ApplyTranscoderTopologyAsync);
ResetOutputDefaultsCommand = new RelayCommand(ResetOutputDefaults);
}
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>();
@ -59,11 +118,277 @@ public sealed class GlobalSettingsViewModel : ObservableObject
/// 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 => SetField(ref _hideLocalSelf, value); }
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() =>
UIPreferences.Save(new UIPreferences.Prefs(
HideLocalSelf: _hideLocalSelf,
AutoDisableOnDeparture: _autoDisableOnDeparture,
ParticipantSort: _participantSort,
MinimizeToTray: _minimizeToTray));
/// <summary>
/// Record each newly-enabled ISO's normalized output to disk under
/// <see cref="RecordingDirectory"/>. Already-running ISOs are not retroactively
/// recorded — the operator should disable + re-enable them. Outputs raw BGRA
/// + manifest.json + convert.cmd; running convert.cmd produces a final
/// H.264 .mkv via FFmpeg.
/// </summary>
public bool RecordIsosToDisk
{
get => _recordIsosToDisk;
set
{
if (SetField(ref _recordIsosToDisk, value))
{
_controller.SetRecording(value, _recordingDirectory);
_toast?.Show(value
? "Recording on — newly-enabled ISOs will write to disk"
: "Recording off");
}
}
}
/// <summary>
/// Output root for recorder files. Each ISO writes a subdirectory keyed by
/// participant display name. Default: <c>%USERPROFILE%\Videos\TeamsISO\&lt;date&gt;</c>.
/// </summary>
public string RecordingDirectory
{
get => _recordingDirectory;
set
{
if (SetField(ref _recordingDirectory, value) && _recordIsosToDisk)
_controller.SetRecording(true, value);
}
}
/// <summary>
/// REST control surface (localhost:port) — Stream Deck / Companion / OSC bridges
/// can hit it. Off by default; bound to 127.0.0.1 so LAN access requires explicit
/// reconfiguration. Toggling reaches into App's owned ControlSurfaceServer.
/// </summary>
public bool ControlSurfaceEnabled
{
get => _controlSurfaceEnabled;
set
{
if (!SetField(ref _controlSurfaceEnabled, value)) return;
var srv = (Application.Current as App)?.ControlSurface;
if (srv is null) return;
if (value) srv.Start(_controlSurfacePort);
else srv.Stop();
_toast?.Show(value
? $"Control surface listening on http://127.0.0.1:{_controlSurfacePort}/"
: "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;
if (!_controlSurfaceEnabled) return;
var srv = (Application.Current as App)?.ControlSurface;
srv?.Start(value); // Start is idempotent + handles port change
_toast?.Show($"Control surface restarted on http://127.0.0.1:{value}/");
}
}
/// <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);
else bridge.Stop();
_toast?.Show(value
? $"OSC bridge listening on udp://127.0.0.1:{_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);
_toast?.Show($"OSC bridge restarted on udp://127.0.0.1:{value}/");
}
}
/// <summary>
/// Output-name template applied when the operator enables an ISO without
/// a per-participant CustomName. Default <c>"TEAMSISO_{guid}"</c> matches
/// the engine's hard-coded behavior; switch to <c>"TEAMSISO_{name}"</c>
/// for human-readable NDI source names. 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>
/// 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

View file

@ -1,7 +1,10 @@
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;
@ -22,10 +25,88 @@ public sealed class MainViewModel : ObservableObject, IDisposable
private readonly Dictionary<Guid, ParticipantViewModel> _byId = new();
private string _statusText = "Starting…";
// Auto-apply-last-preset bookkeeping. 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;
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)
{
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;
// JoinOrder: leave SortDescriptions empty.
}
}
/// <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
@ -34,12 +115,117 @@ public sealed class MainViewModel : ObservableObject, IDisposable
/// </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; }
/// <summary>
/// Drop a timestamped marker into every active recording. Bound to a button
/// in the IN-CALL bar; eventually wireable to a global hotkey. The marker
/// label is auto-generated as "Marker @ HH:mm:ss" — operators who want
/// custom labels can edit manifest.json after the fact.
/// </summary>
public RelayCommand DropRecordingMarkerCommand { get; }
/// <summary>F1 binding — opens the help / cheat-sheet dialog.</summary>
public RelayCommand ShowHelpCommand { get; }
/// <summary>Opens the inline notes viewer for today's show-notes file.</summary>
public RelayCommand ShowNotesCommand { get; }
/// <summary>
/// Roll-recording: disable + re-enable every currently-recording pipeline,
/// starting a fresh recording chunk in a new subdirectory. Operator-friendly
/// chaptering between show segments without losing already-recorded footage
/// (the previous chunk is finalized on disable, the next chunk starts clean).
/// </summary>
public AsyncRelayCommand RollRecordingCommand { get; }
public string StatusText
{
get => _statusText;
set => SetField(ref _statusText, value);
}
/// <summary>
/// Recording status badge — true when at least one ISO is being recorded.
/// Polled at the existing 1Hz stats tick rather than via a dedicated change
/// event, since recording state shifts on enable/disable transitions and
/// the stats poll already reads each pipeline's state.
/// </summary>
public bool IsRecording
{
get => _isRecording;
private set => SetField(ref _isRecording, value);
}
private bool _isRecording;
/// <summary>Number of pipelines currently writing to the recorder.</summary>
public int ActiveRecordingCount
{
get => _activeRecordingCount;
private set => SetField(ref _activeRecordingCount, value);
}
private int _activeRecordingCount;
/// <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>
/// 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;
@ -47,6 +233,19 @@ public sealed class MainViewModel : ObservableObject, IDisposable
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();
_participantsSub = controller.Participants
.ObserveOn(new SynchronizationContextScheduler(
System.Threading.SynchronizationContext.Current ?? new DispatcherSynchronizationContext(_dispatcher)))
@ -71,6 +270,82 @@ public sealed class MainViewModel : ObservableObject, IDisposable
_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…");
});
DropRecordingMarkerCommand = new RelayCommand(() =>
{
var label = "Marker @ " + DateTimeOffset.Now.ToString("HH:mm:ss");
_controller.AddRecordingMarker(label);
Toast.Show($"Marker dropped: {label}");
});
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
});
RollRecordingCommand = new AsyncRelayCommand(RollRecordingAsync,
() => _controller.RecordingEnabled && Participants.Any(p => p.IsEnabled));
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");
}
/// <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>
@ -79,16 +354,110 @@ public sealed class MainViewModel : ObservableObject, IDisposable
/// in parallel and trip channel-completion races; for ~10 participants this is
/// still sub-second total. Failures are swallowed (best-effort emergency stop).
/// </summary>
/// <summary>
/// Roll every active recording into a new chunk: disable + re-enable every
/// pipeline that's currently running. The recorder finalizes its
/// manifest.json on disable and a fresh subdirectory is created on the
/// next enable (RawBgraRecorderSink uses the participant display name +
/// the timestamp template so consecutive rolls don't collide on disk).
///
/// Per-participant best-effort: one bad pipeline doesn't abort the rest.
/// </summary>
private async Task RollRecordingAsync()
{
var enabled = Participants.Where(p => p.IsEnabled).ToArray();
if (enabled.Length == 0)
{
Toast.Show("No active ISOs to roll");
return;
}
var rolled = 0;
foreach (var p in enabled)
{
try
{
await _controller.DisableIsoAsync(p.Id, CancellationToken.None);
await Task.Delay(150);
var resolvedName = string.IsNullOrWhiteSpace(p.CustomName)
? Services.OutputNameTemplate.Render(
Services.OutputNameTemplate.Get(),
p.Id,
p.DisplayName)
: p.CustomName;
bool? recordOverride = p.RecordToDisk ? null : false;
await _controller.EnableIsoAsync(p.Id, resolvedName, recordOverride, CancellationToken.None);
rolled++;
}
catch
{
// Per-pipeline best-effort
}
}
Toast.Show($"Rolled {rolled} recording(s) into a new chunk");
}
/// <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)
? Services.OutputNameTemplate.Render(
Services.OutputNameTemplate.Get(),
p.Id,
p.DisplayName)
: p.CustomName;
bool? recordOverride = p.RecordToDisk ? null : false;
await _controller.EnableIsoAsync(p.Id, resolvedName, recordOverride, 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)");
}
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;
}
// Confirm before tearing down — this button is an "emergency stop" but
// mis-clicks during a show are easy. The dialog cost is negligible
// (one Enter press) and the regret cost is huge (yanking 5 ISOs mid-
// broadcast). Default selection is No so accidental hits cancel.
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)");
}
private void OnStatsTick(object? sender, EventArgs e)
@ -99,6 +468,12 @@ public sealed class MainViewModel : ObservableObject, IDisposable
{
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
{
@ -106,6 +481,77 @@ public sealed class MainViewModel : ObservableObject, IDisposable
// tear down the timer or surface an error to the user.
}
}
// 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);
ActiveRecordingCount = _controller.RecordingEnabled ? enabledCount : 0;
IsRecording = ActiveRecordingCount > 0;
// 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 if (ActiveRecordingCount > 0 && ActiveRecordingCount != enabledCount)
{
StatusText = $"{enabledCount}/{totalParticipants} ISOs live · {ActiveRecordingCount} recording";
}
else if (ActiveRecordingCount > 0)
{
StatusText = $"{enabledCount}/{totalParticipants} ISOs live · all recording";
}
else
{
StatusText = $"{enabledCount}/{totalParticipants} ISOs live";
}
// 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;
ControlSurfaceText = (rest, osc) switch
{
(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)
@ -113,12 +559,44 @@ public sealed class MainViewModel : ObservableObject, IDisposable
StatusText = "Discovering NDI sources…";
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.
try
{
var pref = Services.OperatorPresetStore.GetStartupPreference();
if (pref.AutoApplyOnStartup && !string.IsNullOrEmpty(pref.LastAppliedName))
{
_pendingPresetName = pref.LastAppliedName;
// 30s grace window is generous: Teams typically advertises all
// existing participants within 510s 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>
/// 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;
}
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
@ -129,7 +607,35 @@ public sealed class MainViewModel : ObservableObject, IDisposable
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
{
@ -149,6 +655,60 @@ public sealed class MainViewModel : ObservableObject, IDisposable
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();
}
}
/// <summary>
/// Attempts to apply <see cref="_pendingPresetName"/> 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()
{
Services.OperatorPresetStore.Preset? preset;
try { preset = Services.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)"));
});
}
private static bool IsLocalSelf(Participant p) =>

View file

@ -1,5 +1,9 @@
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;
@ -16,12 +20,193 @@ public sealed class ParticipantViewModel : ObservableObject
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;
public ParticipantViewModel(IIsoController controller, Participant participant)
{
_controller = controller;
_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);
}
/// <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;
@ -36,6 +221,20 @@ public sealed class ParticipantViewModel : ObservableObject
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;
@ -53,6 +252,29 @@ public sealed class ParticipantViewModel : ObservableObject
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); }
@ -73,6 +295,26 @@ public sealed class ParticipantViewModel : ObservableObject
/// <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;
@ -111,6 +353,19 @@ public sealed class ParticipantViewModel : ObservableObject
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; }
/// <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)
{
@ -133,9 +388,25 @@ public sealed class ParticipantViewModel : ObservableObject
}
else
{
// Resolve the output name: explicit per-participant CustomName
// wins; otherwise expand the operator's template (defaults to
// "TEAMSISO_{guid}" which matches the engine's old hard-coded
// behavior). 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,
string.IsNullOrWhiteSpace(_customName) ? null : _customName,
resolvedName,
recordOverride,
CancellationToken.None);
IsEnabled = true;
}

View file

@ -26,6 +26,10 @@ using SysConsole = System.Console;
/// teamsiso-console --list-sources # diagnostic: print every raw NDI source string visible
/// on the network for ~5s, then exit. Useful for debugging
/// why expected Teams sources aren't being classified.
/// teamsiso-console --test-pattern # broadcast a synthetic NDI source named TEAMSISO_TEST
/// with SMPTE color bars + sweep band. Useful for
/// verifying NDI runtime + discovery without Teams running,
/// and for demoing the product. Runs until Ctrl+C.
/// teamsiso-console --version # print engine version, NDI runtime version, exit codes,
/// then exit 0. Useful for support requests.
/// </summary>
@ -35,6 +39,7 @@ public static class Program
{
var enableAll = args.Contains("--enable-all", StringComparer.OrdinalIgnoreCase);
var listSources = args.Contains("--list-sources", StringComparer.OrdinalIgnoreCase);
var testPattern = args.Contains("--test-pattern", StringComparer.OrdinalIgnoreCase);
var version = args.Contains("--version", StringComparer.OrdinalIgnoreCase)
|| args.Contains("-v", StringComparer.OrdinalIgnoreCase);
@ -68,6 +73,11 @@ public static class Program
return await RunListSourcesAsync(interop, logger);
}
if (testPattern)
{
return await RunTestPatternAsync(interop, logger);
}
var configPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"TeamsISO", "config.json");
@ -224,4 +234,64 @@ public static class Program
interop.Dispose();
return 0;
}
/// <summary>
/// Test-pattern mode: spins up an NDI sender named TEAMSISO_TEST that
/// broadcasts SMPTE color bars at 1280×720 30fps. Useful for verifying
/// the NDI runtime + groups + discovery without needing Teams running.
/// Open Studio Monitor on the same network and you should see the source.
/// Runs until Ctrl+C.
/// </summary>
[SupportedOSPlatform("windows")]
private static async Task<int> RunTestPatternAsync(NdiInteropPInvoke interop, ILogger logger)
{
const int width = 1280;
const int height = 720;
const double fps = 30.0;
const string outputName = "TEAMSISO_TEST";
logger.LogInformation("Starting test pattern '{Name}' at {W}×{H}@{Fps} fps. Press Ctrl+C to stop.",
outputName, width, height, fps);
using var sender = interop.CreateSender(outputName, groups: null);
using var cts = new CancellationTokenSource();
SysConsole.CancelKeyPress += (_, e) => { e.Cancel = true; cts.Cancel(); };
var frameInterval = TimeSpan.FromSeconds(1.0 / fps);
long frameNumber = 0;
var deadline = DateTime.UtcNow + frameInterval;
try
{
while (!cts.IsCancellationRequested)
{
var frame = TestPatternGenerator.Render(width, height, frameNumber, DateTime.UtcNow.Ticks);
interop.SendFrame(sender, frame);
frameNumber++;
if (frameNumber % (long)fps == 0)
{
// Heartbeat once per second so the operator can confirm
// the loop is alive without flooding the console.
logger.LogInformation("Sent {Count} frames", frameNumber);
}
// Sleep until the next frame deadline. If we fell behind, just
// skip the wait and emit immediately — no point queueing.
var wait = deadline - DateTime.UtcNow;
if (wait > TimeSpan.Zero)
{
try { await Task.Delay(wait, cts.Token); }
catch (OperationCanceledException) { break; }
}
deadline += frameInterval;
}
}
catch (Exception ex)
{
logger.LogError(ex, "Test pattern loop crashed.");
return 3;
}
logger.LogInformation("Test pattern stopped. Sent {Count} frames total.", frameNumber);
return 0;
}
}

View file

@ -26,9 +26,26 @@ public interface IIsoController : IAsyncDisposable
/// <summary>Returns the latest <see cref="IsoHealthStats"/> for a given participant's ISO, or empty if none.</summary>
IsoHealthStats GetStats(Guid participantId);
/// <summary>
/// Returns the most recent processed frame for the given participant's pipeline,
/// or null if no pipeline is running or no frame has been processed yet. Used by
/// the WPF host to render in-app preview thumbnails. The returned <see cref="Pipeline.ProcessedFrame"/>
/// is immutable; callers may copy / scale freely.
/// </summary>
Pipeline.ProcessedFrame? GetLatestProcessedFrame(Guid participantId);
/// <summary>Enables an ISO pipeline for the given participant.</summary>
Task EnableIsoAsync(Guid participantId, string? customName, CancellationToken cancellationToken);
/// <summary>
/// Enable an ISO pipeline with a per-call override for whether this specific
/// pipeline gets a recorder attached. <paramref name="recordOverride"/> null
/// means "follow the global <see cref="RecordingEnabled"/> flag" (the
/// default behavior); true forces a recorder; false forces no recorder.
/// Used by the UI to give the operator per-participant recording opt-out.
/// </summary>
Task EnableIsoAsync(Guid participantId, string? customName, bool? recordOverride, CancellationToken cancellationToken);
/// <summary>Disables and tears down the pipeline for the given participant.</summary>
Task DisableIsoAsync(Guid participantId, CancellationToken cancellationToken);
@ -40,4 +57,34 @@ public interface IIsoController : IAsyncDisposable
/// restart — rebuilding finder/sender handles mid-flight would orphan running pipelines.
/// </summary>
Task SetGroupSettingsAsync(NdiGroupSettings groupSettings, CancellationToken cancellationToken);
/// <summary>
/// Forces NDI discovery to rebuild its finder on the next poll tick, then re-emits all
/// currently-visible sources as freshly-added. Useful right after applying a new
/// transcoder topology when Teams or other senders need to be re-detected on the new
/// group, without having to restart the whole process.
/// </summary>
void RefreshDiscovery();
/// <summary>
/// Per-output recording on/off. When enabled, each subsequently-started ISO writes
/// its normalized output to <paramref name="outputDirectory"/>/&lt;display-name&gt;/.
/// Already-running ISOs are not retroactively recorded (would require restarting
/// their pipelines, which can hiccup live output) — the operator should disable +
/// re-enable a participant to start recording it.
/// </summary>
void SetRecording(bool enabled, string? outputDirectory);
/// <summary>True if <see cref="SetRecording"/> has been called with enabled=true.</summary>
bool RecordingEnabled { get; }
/// <summary>The output directory configured by the most recent <see cref="SetRecording"/> call.</summary>
string? RecordingDirectory { get; }
/// <summary>
/// Drop a timestamped marker into every currently-recording pipeline. No-op
/// if no pipelines are recording. Markers land in each recording's
/// manifest.json under the <c>markers[]</c> array.
/// </summary>
void AddRecordingMarker(string label);
}

View file

@ -21,14 +21,20 @@ public sealed class IsoController : IIsoController
private readonly Func<IsoPipelineConfig, IsoPipeline> _pipelineFactory;
private readonly ConfigStore _configStore;
private readonly NdiRuntimeProbe _runtimeProbe;
private readonly ILoggerFactory _loggerFactory;
private readonly ILogger<IsoController> _logger;
private readonly TimeSpan _renameWindow;
private readonly TimeSpan _discoveryInterval;
private bool _recordingEnabled;
private string? _recordingDirectory;
private readonly ParticipantTracker _tracker;
private readonly Channel<DiscoveryEvent> _discoveryChannel;
private readonly NdiDiscoveryService _discovery;
private readonly Dictionary<Guid, IsoPipeline> _pipelines = new();
// Parallel map of active recorders keyed by participant id, for the
// marker-drop API which needs to fan out to every running recorder.
private readonly Dictionary<Guid, Pipeline.IRecorderSink> _recorders = new();
private readonly BehaviorSubject<IReadOnlyList<Participant>> _participants =
new(Array.Empty<Participant>());
private readonly Subject<EngineAlert> _alerts = new();
@ -59,6 +65,7 @@ public sealed class IsoController : IIsoController
_pipelineFactory = pipelineFactory;
_configStore = configStore;
_runtimeProbe = runtimeProbe;
_loggerFactory = loggerFactory;
_logger = loggerFactory.CreateLogger<IsoController>();
_renameWindow = renameWindow ?? TimeSpan.FromSeconds(5);
_discoveryInterval = discoveryInterval ?? TimeSpan.FromMilliseconds(500);
@ -108,7 +115,21 @@ public sealed class IsoController : IIsoController
return pipeline.GetStats();
}
public async Task EnableIsoAsync(Guid participantId, string? customName, CancellationToken cancellationToken)
public Pipeline.ProcessedFrame? GetLatestProcessedFrame(Guid participantId)
{
IsoPipeline? pipeline;
lock (_gate)
{
if (!_pipelines.TryGetValue(participantId, out pipeline))
return null;
}
return pipeline.LatestProcessedFrame;
}
public Task EnableIsoAsync(Guid participantId, string? customName, CancellationToken cancellationToken) =>
EnableIsoAsync(participantId, customName, recordOverride: null, cancellationToken);
public async Task EnableIsoAsync(Guid participantId, string? customName, bool? recordOverride, CancellationToken cancellationToken)
{
Participant? p;
lock (_gate)
@ -122,18 +143,55 @@ public sealed class IsoController : IIsoController
var output = customName ?? DefaultOutputName(participantId);
string? outputGroups;
FrameProcessingSettings settingsSnapshot;
bool recordingEnabled;
string? recordingDirectory;
lock (_gate)
{
outputGroups = _groupSettings.OutputGroups;
settingsSnapshot = _settings;
recordingEnabled = _recordingEnabled;
recordingDirectory = _recordingDirectory;
}
// Per-call override beats global toggle. recordOverride=true forces a
// recorder even when global recording is off (useful for "record just
// this one"); recordOverride=false suppresses the recorder when global
// is on (operator opt-out per participant).
var shouldRecord = recordOverride ?? recordingEnabled;
IRecorderSink? recorder = null;
if (shouldRecord)
{
// Per-pipeline recorder instance — each ISO writes to its own
// subdirectory keyed by display name. Wrapping in try/catch so a
// recorder construction failure (no logger, weird platform) never
// takes down EnableIsoAsync.
try
{
recorder = new RawBgraRecorderSink(_loggerFactory.CreateLogger<RawBgraRecorderSink>());
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Could not construct recorder for participant {Id}; ISO will run without recording.", participantId);
}
}
var config = new IsoPipelineConfig(participantId, p.CurrentSource.FullName, output, settingsSnapshot)
{
OutputGroups = outputGroups,
Recorder = recorder,
// Pass the directory whenever we have a recorder, regardless of the
// global flag — the per-call override may have forced one even when
// global recording is off.
RecordingOutputDirectory = recorder is not null ? recordingDirectory : null,
RecorderDisplayName = p.DisplayName,
};
var pipeline = _pipelineFactory(config);
lock (_gate) _pipelines[participantId] = pipeline;
lock (_gate)
{
_pipelines[participantId] = pipeline;
if (recorder is not null) _recorders[participantId] = recorder;
}
await pipeline.StartAsync();
await PersistAssignmentsAsync(cancellationToken);
}
@ -144,6 +202,9 @@ public sealed class IsoController : IIsoController
lock (_gate)
{
if (!_pipelines.Remove(participantId, out pipeline)) return;
// Pipeline.DisposeAsync calls IRecorderSink.Close internally via
// its supervisor finally; we just drop our parallel reference here.
_recorders.Remove(participantId);
}
await pipeline.StopAsync();
await pipeline.DisposeAsync();
@ -165,9 +226,54 @@ public sealed class IsoController : IIsoController
public Task SetGroupSettingsAsync(NdiGroupSettings groupSettings, CancellationToken cancellationToken)
{
lock (_gate) _groupSettings = groupSettings;
// Push the new discovery-groups string into the live discovery service so that the
// next operator-initiated Refresh picks them up. We don't auto-refresh here because
// mid-flight rebuilds are cheap but visible (sources blink) and the Settings panel
// tells the operator to use the explicit Refresh button.
_discovery.UpdateDiscoveryGroups(groupSettings.DiscoveryGroups);
return PersistAssignmentsAsync(cancellationToken);
}
/// <summary>
/// Forces the discovery service to rebuild its NDI finder on the next poll tick.
/// Implemented as a non-blocking flag set so the controller method returns instantly;
/// the actual rebuild + re-emit happens on the dedicated discovery loop.
/// </summary>
public void RefreshDiscovery()
{
_discovery.RequestRefresh();
_logger.LogInformation("Discovery refresh requested.");
}
public void SetRecording(bool enabled, string? outputDirectory)
{
lock (_gate)
{
_recordingEnabled = enabled;
_recordingDirectory = outputDirectory;
}
_logger.LogInformation(
"Recording {State} (output: {Dir})",
enabled ? "ENABLED" : "DISABLED",
outputDirectory ?? "(unset)");
}
public bool RecordingEnabled { get { lock (_gate) return _recordingEnabled; } }
public string? RecordingDirectory { get { lock (_gate) return _recordingDirectory; } }
public void AddRecordingMarker(string label)
{
Pipeline.IRecorderSink[] snapshot;
lock (_gate) snapshot = _recorders.Values.ToArray();
foreach (var rec in snapshot)
{
try { rec.AddMarker(label); }
catch { /* per-recorder defensive */ }
}
if (snapshot.Length > 0)
_logger.LogInformation("Marker dropped on {Count} active recording(s): {Label}", snapshot.Length, label);
}
private Task PersistAssignmentsAsync(CancellationToken cancellationToken)
{
try

View file

@ -14,8 +14,10 @@ public sealed class NdiDiscoveryService
private readonly INdiInterop _interop;
private readonly ChannelWriter<DiscoveryEvent> _writer;
private readonly ILogger<NdiDiscoveryService> _logger;
private readonly NdiFindHandle _finder;
private NdiFindHandle _finder;
private readonly HashSet<string> _previous = new();
private string? _discoveryGroups;
private int _refreshRequested;
public NdiDiscoveryService(
INdiInterop interop,
@ -26,9 +28,22 @@ public sealed class NdiDiscoveryService
_interop = interop;
_writer = writer;
_logger = logger;
_discoveryGroups = discoveryGroups;
_finder = interop.CreateFinder(discoveryGroups);
}
/// <summary>
/// Request that the next poll tick rebuild the underlying NDI finder. Useful right
/// after the operator changes discovery groups or applies a new transcoder topology
/// — without this, the finder is bound to the groups it was created with and never
/// sees new sources from the just-configured group. Honored on the next
/// <see cref="RunAsync"/> tick: the old finder is disposed, a fresh one is created,
/// and the seen-set is cleared so all currently-visible sources re-fire as
/// <see cref="DiscoveryEvent.Added"/>. Cheap (idempotent) — extra Refresh calls
/// while a refresh is already pending are coalesced.
/// </summary>
public void RequestRefresh() => Interlocked.Exchange(ref _refreshRequested, 1);
/// <summary>
/// Runs a single poll cycle. Public for unit testing; production uses <see cref="RunAsync"/>.
/// </summary>
@ -67,6 +82,20 @@ public sealed class NdiDiscoveryService
{
while (await timer.WaitForNextTickAsync(cancellationToken))
{
if (Interlocked.Exchange(ref _refreshRequested, 0) == 1)
{
try
{
_logger.LogInformation("Rebuilding NDI finder on operator request.");
_finder.Dispose();
_finder = _interop.CreateFinder(_discoveryGroups);
_previous.Clear();
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Finder refresh failed; continuing with existing finder.");
}
}
try { PollOnce(); }
catch (Exception ex) { _logger.LogWarning(ex, "Discovery poll failed; will retry on next tick."); }
}
@ -77,4 +106,11 @@ public sealed class NdiDiscoveryService
_finder.Dispose();
}
}
/// <summary>
/// Updates the cached discovery-groups string used by future finder rebuilds.
/// Call <see cref="RequestRefresh"/> after this to actually pick up the change.
/// </summary>
public void UpdateDiscoveryGroups(string? discoveryGroups) =>
_discoveryGroups = discoveryGroups;
}

View file

@ -48,6 +48,19 @@ public sealed class ParticipantTracker
var now = _now();
PruneRecentlyRemoved(now);
// Idempotency guard: if the same FullName is already tracked (because the
// operator hit Refresh and discovery is re-emitting everything), refresh
// LastSeen + DisplayName in place instead of minting a duplicate row.
var alreadyLive = _participants.FirstOrDefault(p =>
p.CurrentSource is not null && p.CurrentSource.FullName == source.FullName);
if (alreadyLive is not null)
{
alreadyLive.DisplayName = source.DisplayName!;
alreadyLive.CurrentSource = source;
alreadyLive.LastSeen = now;
return;
}
var match = _recentlyRemoved.FirstOrDefault(rr => rr.MachineName == source.MachineName);
if (match is not null)
{

View file

@ -18,4 +18,18 @@ public sealed record IsoHealthStats(
/// running; otherwise reflects the supervisor's current view.
/// </summary>
public IsoState State { get; init; } = IsoState.Idle;
/// <summary>
/// Most recent peak audio level seen on this pipeline's incoming stream,
/// in the range [0.0, 1.0]. 0.0 means silence (or no audio capture yet);
/// 1.0 is full-scale clip. The UI typically displays this as a decaying
/// VU bar in the participants DataGrid.
///
/// Currently always 0.0 — the engine's NDI receiver doesn't capture audio
/// frames yet (video-only). The field exists so the UI scaffolding is
/// in place; the audio capture path is a focused engine follow-up that
/// adds <c>NdiReceiver</c> audio-frame handling, peak computation, and
/// surfaces the value through <c>IsoPipeline.GetStats</c>.
/// </summary>
public double PeakAudioLevel { get; init; }
}

View file

@ -0,0 +1,58 @@
namespace TeamsISO.Engine.Pipeline;
/// <summary>
/// Tap point for per-ISO output recording. Each <see cref="IsoPipeline"/> can be
/// wired with one recorder; when present, every <see cref="ProcessedFrame"/> that
/// flows from <see cref="FrameProcessor"/> to <see cref="NdiSender"/> is also fed
/// to the recorder for persistence to disk.
///
/// Lifecycle:
/// 1. <see cref="Open"/> is called once when the pipeline starts (or restarts).
/// 2. <see cref="WriteFrame"/> is called for every processed frame, in order.
/// 3. <see cref="Close"/> is called once when the pipeline stops or fails.
///
/// Implementations must be tolerant of out-of-order calls (Close before Open,
/// double Close, WriteFrame after Close) — the supervisor's restart logic can
/// race in unusual ways. The simplest correct implementation is to track an
/// <c>_isOpen</c> flag and short-circuit when not open.
/// </summary>
public interface IRecorderSink : IAsyncDisposable
{
/// <summary>
/// Open the underlying file/encoder and prepare to receive frames. Width/Height
/// match the pipeline's normalized output resolution; FPS is the target framerate
/// (incoming frames may arrive with timing jitter, but the recorder writes at the
/// nominal rate for downstream playback consistency).
/// </summary>
/// <param name="participantDisplayName">Used to derive the output filename.</param>
/// <param name="outputDirectory">Directory under which the recording is created.</param>
/// <param name="width">Frame width in pixels.</param>
/// <param name="height">Frame height in pixels.</param>
/// <param name="fps">Nominal framerate.</param>
void Open(string participantDisplayName, string outputDirectory, int width, int height, double fps);
/// <summary>
/// Write one processed frame. Implementations should not block — if encoding is
/// expensive, queue the frame to a worker thread and return promptly. Returning
/// false means the recorder dropped the frame (disk full, queue overflow); the
/// pipeline carries on regardless so a recorder failure never kills the live ISO.
/// </summary>
bool WriteFrame(ProcessedFrame frame);
/// <summary>
/// Flush and finalize the output. Idempotent.
/// </summary>
void Close();
/// <summary>True between successful <see cref="Open"/> and <see cref="Close"/>.</summary>
bool IsRecording { get; }
/// <summary>
/// Drop a timestamped marker into the recording. Used by the operator to
/// chapter a recording in real time — "host intro starts here", "guest
/// answer", etc. — so post-production can jump to the right moment without
/// scrubbing through the raw stream. The label is free-form; an empty
/// label means "unnamed marker." No-op when not recording.
/// </summary>
void AddMarker(string label);
}

View file

@ -1,3 +1,4 @@
using System.IO;
using System.Threading.Channels;
using Microsoft.Extensions.Logging;
using TeamsISO.Engine.Domain;
@ -35,6 +36,21 @@ public sealed class IsoPipeline : IAsyncDisposable
private int _lastHeight;
private DateTimeOffset? _lastReceivedAt;
// Most recent ProcessedFrame, for the UI thumbnail. We hold a reference (not a
// copy) — the FrameProcessor allocates new arrays per frame so the captured
// ReadOnlyMemory<byte> stays valid until GC reclaims it. UI consumers read
// this lazily at ~1Hz; transient null reads (between supervisor restarts)
// are handled at the call site.
private ProcessedFrame? _latestProcessedFrame;
/// <summary>
/// The most recent <see cref="ProcessedFrame"/> emitted by the inner pipeline,
/// or null if no frame has been processed yet (or the pipeline has stopped).
/// Safe to read from any thread; reading the reference is atomic on .NET, and
/// ProcessedFrame itself is immutable once constructed.
/// </summary>
public ProcessedFrame? LatestProcessedFrame => Volatile.Read(ref _latestProcessedFrame);
// Ring buffer of the last 30 incoming-frame timestamps for live fps display.
// Updated on the receiver's capture thread (single writer) and read by the UI
// poll thread (single reader); we use a lock for the snapshot path because
@ -191,6 +207,7 @@ public sealed class IsoPipeline : IAsyncDisposable
Volatile.Write(ref _liveReceiver, null);
Volatile.Write(ref _liveSender, null);
Volatile.Write(ref _liveProcessor, null);
Volatile.Write(ref _latestProcessedFrame, null);
ResetFrameTimestamps();
},
onFrame: frame =>
@ -203,6 +220,13 @@ public sealed class IsoPipeline : IAsyncDisposable
var nowTicks = DateTimeOffset.UtcNow.UtcTicks;
_lastReceivedAt = new DateTimeOffset(nowTicks, TimeSpan.Zero);
RecordFrameTimestamp(nowTicks);
},
onProcessedFrame: frame =>
{
// Hold the most recent processed frame for the UI preview thumbnail.
// We replace the reference atomically; the previous frame becomes GC-eligible
// once the renderer's last copy is done with it.
Volatile.Write(ref _latestProcessedFrame, frame);
});
}
@ -302,7 +326,8 @@ public sealed class IsoPipeline : IAsyncDisposable
CancellationToken ct,
Action<NdiReceiver, NdiSender, FrameProcessor>? onLive = null,
Action? onClear = null,
Action<RawFrame>? onFrame = null)
Action<RawFrame>? onFrame = null,
Action<ProcessedFrame>? onProcessedFrame = null)
{
var rawChannel = Channel.CreateBounded<RawFrame>(new BoundedChannelOptions(config.RawChannelCapacity)
{
@ -323,6 +348,29 @@ public sealed class IsoPipeline : IAsyncDisposable
? rawChannel.Writer
: new TappedChannelWriter<RawFrame>(rawChannel.Writer, onFrame);
// Tap the processed-frame stream for the optional recorder. We open the
// recorder lazily on the first frame so we know the actual output
// dimensions (FrameProcessingSettings carries an enum, not concrete pixel
// counts; the FrameProcessor resolves the enum to width/height when it
// first scales, and that's what gets emitted in the ProcessedFrame).
ChannelWriter<ProcessedFrame> processedWriter = config.Recorder is null
? processedChannel.Writer
: new RecordingChannelWriter(
processedChannel.Writer,
config.Recorder,
config.RecordingOutputDirectory ?? Path.GetTempPath(),
config.RecorderDisplayName ?? config.OutputName,
config.Settings.FramerateHz);
// Tap again so the host can read the most recent processed frame for the
// UI preview thumbnail. The tap fires AFTER the recorder write, so a
// recorder failure doesn't suppress the preview update; both observers
// are independent.
if (onProcessedFrame is not null)
{
processedWriter = new TappedChannelWriter<ProcessedFrame>(processedWriter, onProcessedFrame);
}
using var receiver = new NdiReceiver(
interop, config.SourceName, rawWriter, loggerFactory.CreateLogger<NdiReceiver>());
using var sender = new NdiSender(
@ -331,7 +379,7 @@ public sealed class IsoPipeline : IAsyncDisposable
var processor = new FrameProcessor(
config.Settings, scaler, new SolidFrameRenderer(),
frameClock, rawChannel.Reader, processedChannel.Writer,
frameClock, rawChannel.Reader, processedWriter,
config.SlateThreshold, loggerFactory.CreateLogger<FrameProcessor>());
onLive?.Invoke(receiver, sender, processor);
@ -348,6 +396,9 @@ public sealed class IsoPipeline : IAsyncDisposable
{
rawChannel.Writer.TryComplete();
processedChannel.Writer.TryComplete();
// Recorder owns its own writer task with bounded queue; closing here
// flushes pending frames and finalizes manifest.json + convert.cmd.
try { config.Recorder?.Close(); } catch { /* defensive */ }
onClear?.Invoke();
}
}
@ -372,6 +423,70 @@ public sealed class IsoPipeline : IAsyncDisposable
public override bool TryComplete(Exception? error = null) => _inner.TryComplete(error);
}
/// <summary>
/// Channel-writer wrapper that feeds every successfully-written
/// <see cref="ProcessedFrame"/> to an <see cref="IRecorderSink"/>. Opens the
/// recorder lazily on the first frame (so we know the actual width/height —
/// the FrameProcessor resolves the resolution enum to concrete dimensions
/// only after the first scale, and that's what shows up in the ProcessedFrame).
/// </summary>
private sealed class RecordingChannelWriter : ChannelWriter<ProcessedFrame>
{
private readonly ChannelWriter<ProcessedFrame> _inner;
private readonly IRecorderSink _recorder;
private readonly string _outputDirectory;
private readonly string _displayName;
private readonly double _fps;
private bool _opened;
public RecordingChannelWriter(
ChannelWriter<ProcessedFrame> inner,
IRecorderSink recorder,
string outputDirectory,
string displayName,
double fps)
{
_inner = inner;
_recorder = recorder;
_outputDirectory = outputDirectory;
_displayName = displayName;
_fps = fps;
}
public override bool TryWrite(ProcessedFrame item)
{
if (!_inner.TryWrite(item)) return false;
// Lazy-open after the first successful write so we have concrete
// dimensions. We deliberately try-catch here: a recorder failure
// (disk full, permission denied) must NOT prevent the live ISO from
// continuing — the user's downstream switcher is the production
// surface, the recording is the archive copy.
try
{
if (!_opened)
{
_recorder.Open(_displayName, _outputDirectory, item.Width, item.Height, _fps);
_opened = true;
}
_recorder.WriteFrame(item);
}
catch
{
// defensive: recorder errors never propagate to the live path
}
return true;
}
public override ValueTask<bool> WaitToWriteAsync(CancellationToken ct = default)
=> _inner.WaitToWriteAsync(ct);
public override bool TryComplete(Exception? error = null)
{
try { _recorder.Close(); } catch { /* defensive */ }
return _inner.TryComplete(error);
}
}
private static async Task ProcessorLoopAsync(FrameProcessor processor, IFrameClock clock, CancellationToken ct)
{
while (!ct.IsCancellationRequested)

View file

@ -0,0 +1,184 @@
// Real-time H.264 recorder using Windows Media Foundation's SinkWriter.
// Gated behind MF_AVAILABLE because activating it requires:
//
// 1. `dotnet add src/TeamsISO.Engine package Vortice.MediaFoundation --version 3.6.x`
// 2. Add `<DefineConstants>$(DefineConstants);MF_AVAILABLE</DefineConstants>`
// to `TeamsISO.Engine.csproj`
// 3. Swap the `RawBgraRecorderSink` instantiation in `IsoController.EnableIsoAsync`
// for `MediaFoundationRecorderSink`
//
// This file is here so the swap is one line + a NuGet add when an operator is
// ready to trade off the dependency for ~10× smaller recordings (1080p60 raw
// BGRA = ~500 MB/s; H.264 at the same input ~50 MB/s).
//
// We write to .mp4 (H.264 + AAC) rather than .mkv because Media Foundation's
// MFCreateSinkWriterFromURL recognizes .mp4 natively; .mkv would need a
// custom container or FFmpeg post-pass.
#if MF_AVAILABLE
using System.IO;
using Microsoft.Extensions.Logging;
using Vortice.MediaFoundation;
using static Vortice.MediaFoundation.MediaFactory;
namespace TeamsISO.Engine.Pipeline;
/// <summary>
/// <see cref="IRecorderSink"/> that encodes incoming <see cref="ProcessedFrame"/>
/// stream to H.264 in an .mp4 container via Windows Media Foundation's
/// SinkWriter API. Inline encoder — no subprocess, no FFmpeg, no extra disk
/// passes.
///
/// Lifecycle matches the contract documented on <see cref="IRecorderSink"/>:
/// Open creates the SinkWriter and configures input + output media types;
/// WriteFrame queues a frame to a worker thread; Close flushes + finalizes.
///
/// Pixel format: input is BGRA32 from the engine, output is NV12 H.264. The
/// MF transform pipeline will color-convert internally; we just declare the
/// input type as MFVideoFormat_RGB32 (which on little-endian hardware is the
/// same byte layout as BGRA) and let MF figure it out.
///
/// Threading: SinkWriter is thread-safe IF we call BeginWriting + WriteSample
/// + Finalize from the same thread. We use a bounded channel + dedicated
/// writer task to serialize calls.
/// </summary>
public sealed class MediaFoundationRecorderSink : IRecorderSink
{
private readonly ILogger<MediaFoundationRecorderSink>? _logger;
private IMFSinkWriter? _writer;
private int _streamIndex;
private long _frameTicks;
private long _ticksPerFrame;
private string? _outputPath;
private DateTimeOffset _startedAt;
private long _framesWritten;
private long _framesDropped;
public bool IsRecording { get; private set; }
public MediaFoundationRecorderSink(ILogger<MediaFoundationRecorderSink>? logger = null) => _logger = logger;
public void Open(string participantDisplayName, string outputDirectory, int width, int height, double fps)
{
if (IsRecording) return;
var safeName = SanitizeForFileName(participantDisplayName);
var dir = Path.Combine(outputDirectory, safeName);
Directory.CreateDirectory(dir);
_outputPath = Path.Combine(dir, "output.mp4");
MFStartup(MFVersion.Version, 0);
var attrs = MFCreateAttributes(1);
attrs.SetUINT32(MediaFactory.MF_LOW_LATENCY, 1);
_writer = MFCreateSinkWriterFromURL(_outputPath, null, attrs);
// Output type — H.264 baseline, target bitrate scaled by resolution.
var outType = MFCreateMediaType();
outType.MajorType = MediaTypeGuids.Video;
outType.SubType = VideoFormatGuids.H264;
outType.AvgBitrate = (uint)(width * height * fps * 0.07); // ~0.07 bits/pixel — decent quality
outType.Set(MediaTypeAttributeKeys.InterlaceMode, (uint)VideoInterlaceMode.Progressive);
outType.Set(MediaTypeAttributeKeys.FrameSize, ((long)width << 32) | (uint)height);
outType.Set(MediaTypeAttributeKeys.FrameRate, ((long)(fps * 1000) << 32) | 1000U);
outType.Set(MediaTypeAttributeKeys.PixelAspectRatio, ((long)1 << 32) | 1U);
_streamIndex = _writer.AddStream(outType);
// Input type — BGRA32. MF will internally convert to NV12 for the H.264 encoder.
var inType = MFCreateMediaType();
inType.MajorType = MediaTypeGuids.Video;
inType.SubType = VideoFormatGuids.RGB32;
inType.Set(MediaTypeAttributeKeys.InterlaceMode, (uint)VideoInterlaceMode.Progressive);
inType.Set(MediaTypeAttributeKeys.FrameSize, ((long)width << 32) | (uint)height);
inType.Set(MediaTypeAttributeKeys.FrameRate, ((long)(fps * 1000) << 32) | 1000U);
inType.Set(MediaTypeAttributeKeys.PixelAspectRatio, ((long)1 << 32) | 1U);
_writer.SetInputMediaType(_streamIndex, inType, null);
_writer.BeginWriting();
_ticksPerFrame = (long)(10_000_000.0 / fps); // 100ns ticks per frame
_frameTicks = 0;
_startedAt = DateTimeOffset.Now;
_framesWritten = 0;
_framesDropped = 0;
IsRecording = true;
_logger?.LogInformation("MF recorder open: {Path} ({W}×{H}@{Fps:F2})", _outputPath, width, height, fps);
}
public bool WriteFrame(ProcessedFrame frame)
{
if (!IsRecording || _writer is null) return false;
try
{
// Wrap the BGRA buffer in an IMFMediaBuffer + IMFSample.
var buffer = MFCreateMemoryBuffer(frame.Pixels.Length);
var locked = buffer.Lock();
try
{
System.Runtime.InteropServices.Marshal.Copy(
frame.Pixels.ToArray(), 0, locked.DataPointer, frame.Pixels.Length);
}
finally { buffer.Unlock(); }
buffer.CurrentLength = frame.Pixels.Length;
var sample = MFCreateSample();
sample.AddBuffer(buffer);
sample.SampleTime = _frameTicks;
sample.SampleDuration = _ticksPerFrame;
_writer.WriteSample(_streamIndex, sample);
_frameTicks += _ticksPerFrame;
Interlocked.Increment(ref _framesWritten);
return true;
}
catch (Exception ex)
{
_logger?.LogWarning(ex, "MF WriteSample failed; dropping frame.");
Interlocked.Increment(ref _framesDropped);
return false;
}
}
public void AddMarker(string label)
{
// Markers aren't a Media Foundation primitive. We write a sidecar
// markers.txt with the offset + label so the operator can use it
// post-recording to chapter the .mp4 (e.g. via mp4chaps).
if (!IsRecording || _outputPath is null) return;
try
{
var markersPath = Path.Combine(Path.GetDirectoryName(_outputPath)!, "markers.txt");
var offsetMs = (DateTimeOffset.Now - _startedAt).TotalMilliseconds;
File.AppendAllText(markersPath, $"{offsetMs:F0}ms\t{label}{Environment.NewLine}");
}
catch { /* defensive */ }
}
public void Close()
{
if (!IsRecording) return;
IsRecording = false;
try { _writer?.Finalize_(); } catch { /* best-effort */ }
try { _writer?.Dispose(); } catch { /* defensive */ }
_writer = null;
try { MFShutdown(); } catch { /* idempotent */ }
_logger?.LogInformation("MF recorder closed: {Path} ({Frames} frames, {Dropped} dropped)",
_outputPath, _framesWritten, _framesDropped);
}
public async ValueTask DisposeAsync()
{
Close();
await Task.CompletedTask;
}
private static string SanitizeForFileName(string name)
{
if (string.IsNullOrWhiteSpace(name)) return "participant";
var invalid = Path.GetInvalidFileNameChars();
var clean = new string(name.Where(c => !invalid.Contains(c) && c != '.').ToArray()).Trim();
return string.IsNullOrEmpty(clean) ? "participant" : clean;
}
}
#endif

View file

@ -0,0 +1,256 @@
using System.IO;
using System.Text.Json;
using System.Threading.Channels;
using Microsoft.Extensions.Logging;
namespace TeamsISO.Engine.Pipeline;
/// <summary>
/// Concrete <see cref="IRecorderSink"/> that writes the processed BGRA stream to
/// disk along with a sidecar JSON manifest and an <c>ffmpeg.cmd</c> conversion
/// script. We deliberately do NOT depend on Media Foundation, libx264, or any
/// other native encoder for v1: the engine stays self-contained, and operators
/// who want H.264 .mkv output can run the generated <c>ffmpeg.cmd</c> after the
/// recording finishes (FFmpeg on PATH is a common operator install).
///
/// Files produced under <c>&lt;outputDir&gt;/&lt;sanitized-display-name&gt;/</c>:
/// <list type="bullet">
/// <item><c>video.bgra</c> — raw concatenated frames at <c>width*height*4</c> bytes each.</item>
/// <item><c>manifest.json</c> — width, height, fps, format, started/ended timestamps,
/// frame count. Lets a post-processor reconstruct timing without parsing the .bgra.</item>
/// <item><c>convert.cmd</c> — one-liner that pipes the .bgra into ffmpeg to produce
/// a final <c>output.mkv</c> at H.264. Operators just double-click.</item>
/// </list>
///
/// Disk pressure: BGRA at 1080p60 is ~500 MB/s, at 720p30 it's ~88 MB/s. A 30-min
/// recording at the default 720p30 takes ~150 GB. Operators should record at the
/// lowest acceptable resolution / framerate, or enable recording only on the
/// participants they intend to keep. A future <c>MediaFoundationRecorderSink</c>
/// would compress in-process and reduce this 10× — see _NEXT.md.
///
/// Threading: writes are serialized through a single bounded channel so the
/// pipeline's processor thread never blocks on disk I/O. If the disk can't keep
/// up, frames are dropped (and the manifest's drop counter increments) so the
/// live ISO output is never delayed.
/// </summary>
public sealed class RawBgraRecorderSink : IRecorderSink
{
private readonly ILogger<RawBgraRecorderSink>? _logger;
private readonly Channel<ProcessedFrame> _queue;
private CancellationTokenSource? _writerCts;
private Task? _writerTask;
private FileStream? _videoStream;
private string? _recordingDir;
private int _width;
private int _height;
private double _fps;
private long _framesWritten;
private long _framesDropped;
private DateTimeOffset _startedAt;
private string _displayName = string.Empty;
// Operator-dropped markers, recorded in wall-clock order. We accumulate them
// in memory during the recording and write to manifest.json on Close. Lock
// is required because AddMarker can be called from the UI thread while the
// writer task drains video frames in the background.
private readonly List<MarkerEntry> _markers = new();
private readonly object _markersGate = new();
private sealed record MarkerEntry(double OffsetMs, string Label);
public bool IsRecording { get; private set; }
public RawBgraRecorderSink(ILogger<RawBgraRecorderSink>? logger = null)
{
_logger = logger;
// Bounded queue with DropOldest: if the disk falls behind, lose the
// oldest frames and keep recording — better than blocking the pipeline.
// 240 frames ≈ 4 seconds @ 60 fps; gives us a buffer for transient
// disk hiccups without unbounded RAM growth on a stuck volume.
_queue = Channel.CreateBounded<ProcessedFrame>(new BoundedChannelOptions(240)
{
FullMode = BoundedChannelFullMode.DropOldest,
SingleReader = true,
SingleWriter = false, // pipeline thread + close() can both write
});
}
public void Open(string participantDisplayName, string outputDirectory, int width, int height, double fps)
{
if (IsRecording) return;
_width = width;
_height = height;
_fps = fps;
_startedAt = DateTimeOffset.Now;
_framesWritten = 0;
_framesDropped = 0;
_displayName = participantDisplayName;
var safeName = SanitizeForFileName(participantDisplayName);
_recordingDir = Path.Combine(outputDirectory, safeName);
Directory.CreateDirectory(_recordingDir);
var videoPath = Path.Combine(_recordingDir, "video.bgra");
// Pre-allocate via FileStream with sequential-scan hint; the writer thread
// appends. Buffer size tuned to one full frame so writes are aligned and
// we don't fragment on common allocators.
var bufferSize = Math.Max(width * height * 4, 64 * 1024);
_videoStream = new FileStream(
videoPath,
FileMode.Create, FileAccess.Write, FileShare.Read,
bufferSize: bufferSize,
FileOptions.SequentialScan);
_writerCts = new CancellationTokenSource();
_writerTask = Task.Run(() => WriterLoopAsync(_writerCts.Token));
IsRecording = true;
_logger?.LogInformation(
"Recorder open: {Path} ({W}x{H}@{Fps:F2}fps)",
_recordingDir, width, height, fps);
}
public bool WriteFrame(ProcessedFrame frame)
{
if (!IsRecording) return false;
if (_queue.Writer.TryWrite(frame)) return true;
Interlocked.Increment(ref _framesDropped);
return false;
}
public void AddMarker(string label)
{
if (!IsRecording) return;
var offsetMs = (DateTimeOffset.Now - _startedAt).TotalMilliseconds;
lock (_markersGate)
{
_markers.Add(new MarkerEntry(offsetMs, label ?? string.Empty));
}
}
public void Close()
{
if (!IsRecording) return;
IsRecording = false;
// Mark the writer queue complete; the writer task drains remaining frames.
_queue.Writer.TryComplete();
try { _writerTask?.Wait(TimeSpan.FromSeconds(5)); }
catch { /* defensive: writer task may have already faulted */ }
_writerCts?.Cancel();
_writerCts?.Dispose();
_writerCts = null;
_writerTask = null;
try { _videoStream?.Flush(); _videoStream?.Dispose(); }
catch { /* defensive */ }
_videoStream = null;
if (_recordingDir is not null)
{
TryWriteManifest();
TryWriteFfmpegScript();
}
_logger?.LogInformation(
"Recorder closed: {Frames} written, {Dropped} dropped to {Path}",
_framesWritten, _framesDropped, _recordingDir);
}
public async ValueTask DisposeAsync()
{
Close();
await Task.CompletedTask;
}
private async Task WriterLoopAsync(CancellationToken ct)
{
try
{
await foreach (var frame in _queue.Reader.ReadAllAsync(ct))
{
if (_videoStream is null) break;
try
{
// ProcessedFrame.Pixels is a ReadOnlyMemory<byte>; FileStream.Write
// accepts ReadOnlySpan<byte> so we can write without an extra copy.
_videoStream.Write(frame.Pixels.Span);
Interlocked.Increment(ref _framesWritten);
}
catch (Exception ex)
{
_logger?.LogWarning(ex, "Recorder write failed; will count as dropped.");
Interlocked.Increment(ref _framesDropped);
}
}
}
catch (OperationCanceledException) { /* expected */ }
}
private void TryWriteManifest()
{
try
{
MarkerEntry[] markersSnapshot;
lock (_markersGate) { markersSnapshot = _markers.ToArray(); }
var manifest = new
{
schema = "teamsiso-recorder/v1",
participantDisplayName = _displayName,
width = _width,
height = _height,
fps = _fps,
pixelFormat = "BGRA",
bytesPerFrame = _width * _height * 4,
startedAt = _startedAt.ToString("o"),
endedAt = DateTimeOffset.Now.ToString("o"),
framesWritten = Interlocked.Read(ref _framesWritten),
framesDropped = Interlocked.Read(ref _framesDropped),
markers = markersSnapshot.Select(m => new { offsetMs = m.OffsetMs, label = m.Label }).ToArray(),
};
var json = JsonSerializer.Serialize(manifest, new JsonSerializerOptions { WriteIndented = true });
File.WriteAllText(Path.Combine(_recordingDir!, "manifest.json"), json);
}
catch (Exception ex)
{
_logger?.LogWarning(ex, "Failed to write recorder manifest.");
}
}
private void TryWriteFfmpegScript()
{
try
{
// Operators rarely have FFmpeg's exact rawvideo-to-MKV recipe memorized;
// ship a one-liner so they can convert by double-clicking. Quotes around
// the input filename so paths with spaces still work; -y auto-overwrites
// an existing output.mkv if the operator runs the script twice.
var script =
"@echo off\r\n" +
"REM Convert raw BGRA recording to H.264 MKV. Requires FFmpeg on PATH (download from ffmpeg.org).\r\n" +
$"ffmpeg -y -f rawvideo -pix_fmt bgra -s {_width}x{_height} -r {_fps:F2} -i video.bgra " +
"-c:v libx264 -preset medium -crf 18 -pix_fmt yuv420p output.mkv\r\n" +
"if errorlevel 1 (echo FFmpeg failed. Is it installed and on PATH?) else (echo Wrote output.mkv)\r\n" +
"pause\r\n";
File.WriteAllText(Path.Combine(_recordingDir!, "convert.cmd"), script);
}
catch (Exception ex)
{
_logger?.LogWarning(ex, "Failed to write recorder convert.cmd.");
}
}
/// <summary>
/// Strip characters that would make the display name an invalid Windows
/// filename (or path-traversal vector). Empty or all-stripped names fall
/// back to "participant" so we always have a usable directory name.
/// </summary>
private static string SanitizeForFileName(string name)
{
if (string.IsNullOrWhiteSpace(name)) return "participant";
var invalid = Path.GetInvalidFileNameChars();
var clean = new string(name.Where(c => !invalid.Contains(c) && c != '.').ToArray()).Trim();
return string.IsNullOrEmpty(clean) ? "participant" : clean;
}
}

View file

@ -0,0 +1,71 @@
namespace TeamsISO.Engine.Pipeline;
/// <summary>
/// Generates synthetic BGRA test-pattern frames for diagnosing NDI setups
/// without a real Teams meeting. Produces SMPTE-style color bars with a
/// moving sweep band and a small frame-counter readout in the corner so
/// the operator can visually confirm frames are flowing in real time.
///
/// Static stateless API — caller bumps the frame counter each tick. Each
/// call allocates a fresh BGRA byte[] so the caller can hand it straight
/// to <see cref="NdiSender"/> via a <see cref="ProcessedFrame"/> wrapper
/// without holding shared buffers.
///
/// Used by <c>TeamsISO.Console --test-pattern</c>; could also be wired
/// into the WPF host as a "synthetic source" toggle for demoing.
/// </summary>
public static class TestPatternGenerator
{
/// <summary>
/// SMPTE 75% color-bar palette in BGRA order (high-amplitude bars).
/// Eight bars, each occupying 1/8 of the frame width.
/// </summary>
private static readonly (byte B, byte G, byte R)[] BarColors = new[]
{
((byte)191, (byte)191, (byte)191), // 75% white
((byte)0, (byte)191, (byte)191), // 75% yellow
((byte)191, (byte)191, (byte)0), // 75% cyan
((byte)0, (byte)191, (byte)0), // 75% green
((byte)191, (byte)0, (byte)191), // 75% magenta
((byte)0, (byte)0, (byte)191), // 75% red
((byte)191, (byte)0, (byte)0), // 75% blue
((byte)0, (byte)0, (byte)0), // black
};
/// <summary>
/// Render one BGRA frame at the given dimensions. <paramref name="frameNumber"/>
/// drives the sweep-band animation so consecutive calls produce visibly
/// changing output (the sweep moves down by 2 rows per frame, wrapping).
/// </summary>
public static ProcessedFrame Render(int width, int height, long frameNumber, long timestampTicks)
{
var pixels = new byte[width * height * 4];
var barWidth = width / BarColors.Length;
var sweepRow = (int)((frameNumber * 2) % height);
for (var y = 0; y < height; y++)
{
// Pre-compute row offset and sweep highlight delta for this row.
// The sweep is a 4-row-tall lighter band that animates down the frame.
var isSweep = Math.Abs(y - sweepRow) < 4;
for (var x = 0; x < width; x++)
{
var barIdx = Math.Min(x / barWidth, BarColors.Length - 1);
var (b, g, r) = BarColors[barIdx];
if (isSweep)
{
// Brighten the bar 32 BGR units (clamped) — visible moving band.
b = (byte)Math.Min(255, b + 32);
g = (byte)Math.Min(255, g + 32);
r = (byte)Math.Min(255, r + 32);
}
var off = (y * width + x) * 4;
pixels[off + 0] = b;
pixels[off + 1] = g;
pixels[off + 2] = r;
pixels[off + 3] = 0xFF;
}
}
return new ProcessedFrame(width, height, timestampTicks, pixels, PixelFormat.Bgra);
}
}

View file

@ -0,0 +1,254 @@
using System.IO;
using FluentAssertions;
using TeamsISO.App.Services;
namespace TeamsISO.App.Tests.Services;
/// <summary>
/// Unit tests for <see cref="OperatorPresetStore"/>. Each test redirects the
/// store's file path to a per-test temp path via the internal
/// <c>PathOverride</c> hook so the operator's real
/// <c>%LOCALAPPDATA%\TeamsISO\presets.json</c> is never touched.
///
/// IDisposable on the test class cleans up the temp path after each test.
/// We don't use [Collection] because each test's path is per-test-unique
/// (Path.GetTempFileName) so parallel xUnit execution can't collide.
/// </summary>
public sealed class OperatorPresetStoreTests : IDisposable
{
private readonly string _tempPath;
public OperatorPresetStoreTests()
{
// Path.GetTempFileName creates a 0-byte file we don't want; we want the
// path to start non-existent. So generate a unique name in the temp dir
// without creating it, and let OperatorPresetStore.Save() create as needed.
_tempPath = Path.Combine(
Path.GetTempPath(),
$"teamsiso-presets-test-{Guid.NewGuid():N}.json");
OperatorPresetStore.PathOverride = _tempPath;
}
public void Dispose()
{
OperatorPresetStore.PathOverride = null;
try { if (File.Exists(_tempPath)) File.Delete(_tempPath); }
catch { /* best-effort cleanup */ }
}
[Fact]
public void LoadAll_NoFile_ReturnsEmpty()
{
OperatorPresetStore.LoadAll().Should().BeEmpty();
}
[Fact]
public void Save_ThenLoadAll_RoundTrips()
{
var preset = MakePreset("Friday Show", ("Jane", true), ("Bob", false));
OperatorPresetStore.Save(preset);
var all = OperatorPresetStore.LoadAll();
all.Should().HaveCount(1);
var loaded = all[0];
loaded.Name.Should().Be("Friday Show");
loaded.Assignments.Should().HaveCount(2);
loaded.Assignments.Should().ContainSingle(a => a.DisplayName == "Jane" && a.Enabled);
loaded.Assignments.Should().ContainSingle(a => a.DisplayName == "Bob" && !a.Enabled);
}
[Fact]
public void Save_SameNameTwice_OverwritesNotDuplicates()
{
OperatorPresetStore.Save(MakePreset("ShowA", ("Jane", true)));
OperatorPresetStore.Save(MakePreset("ShowA", ("Bob", true)));
var all = OperatorPresetStore.LoadAll();
all.Should().HaveCount(1);
all[0].Assignments.Should().HaveCount(1);
all[0].Assignments[0].DisplayName.Should().Be("Bob");
}
[Fact]
public void Save_NameMatchIsCaseInsensitive()
{
OperatorPresetStore.Save(MakePreset("Friday Show", ("Jane", true)));
OperatorPresetStore.Save(MakePreset("friday SHOW", ("Bob", true)));
OperatorPresetStore.LoadAll().Should().HaveCount(1, because: "preset names dedupe case-insensitively");
}
[Fact]
public void Find_LocatesByCaseInsensitiveName()
{
OperatorPresetStore.Save(MakePreset("Friday Show", ("Jane", true)));
OperatorPresetStore.Find("friday show").Should().NotBeNull();
OperatorPresetStore.Find("FRIDAY SHOW").Should().NotBeNull();
OperatorPresetStore.Find("Saturday Show").Should().BeNull();
}
[Fact]
public void Delete_RemovesPreset()
{
OperatorPresetStore.Save(MakePreset("ShowA", ("Jane", true)));
OperatorPresetStore.Save(MakePreset("ShowB", ("Bob", true)));
OperatorPresetStore.Delete("ShowA");
var all = OperatorPresetStore.LoadAll();
all.Should().HaveCount(1);
all[0].Name.Should().Be("ShowB");
}
[Fact]
public void Delete_MissingName_IsNoOp()
{
OperatorPresetStore.Save(MakePreset("ShowA", ("Jane", true)));
OperatorPresetStore.Delete("Nonexistent");
OperatorPresetStore.LoadAll().Should().HaveCount(1, because: "deleting a missing name shouldn't touch existing presets");
}
[Fact]
public void Delete_PreservesStartupPreference_WhenDifferentNameDeleted()
{
OperatorPresetStore.Save(MakePreset("ShowA", ("Jane", true)));
OperatorPresetStore.Save(MakePreset("ShowB", ("Bob", true)));
OperatorPresetStore.MarkApplied("ShowA");
OperatorPresetStore.SetAutoApplyOnStartup(true);
OperatorPresetStore.Delete("ShowB");
var pref = OperatorPresetStore.GetStartupPreference();
pref.LastAppliedName.Should().Be("ShowA");
pref.AutoApplyOnStartup.Should().BeTrue();
}
[Fact]
public void Delete_ClearsLastAppliedName_WhenAppliedPresetDeleted()
{
OperatorPresetStore.Save(MakePreset("ShowA", ("Jane", true)));
OperatorPresetStore.MarkApplied("ShowA");
OperatorPresetStore.Delete("ShowA");
var pref = OperatorPresetStore.GetStartupPreference();
pref.LastAppliedName.Should().BeNull(
because: "deleting the last-applied preset should clear it so we don't try to re-apply a missing preset on next launch");
}
[Fact]
public void GetStartupPreference_DefaultsToFalseAndNull_WhenNoFile()
{
var pref = OperatorPresetStore.GetStartupPreference();
pref.LastAppliedName.Should().BeNull();
pref.AutoApplyOnStartup.Should().BeFalse();
}
[Fact]
public void MarkApplied_PersistsAcrossSaves()
{
OperatorPresetStore.Save(MakePreset("ShowA", ("Jane", true)));
OperatorPresetStore.MarkApplied("ShowA");
OperatorPresetStore.Save(MakePreset("ShowB", ("Bob", true)));
OperatorPresetStore.GetStartupPreference().LastAppliedName.Should().Be("ShowA",
because: "Saving a different preset must not clear the last-applied marker");
}
[Fact]
public void SetAutoApplyOnStartup_PersistsAcrossSaves()
{
OperatorPresetStore.SetAutoApplyOnStartup(true);
OperatorPresetStore.Save(MakePreset("ShowA", ("Jane", true)));
OperatorPresetStore.GetStartupPreference().AutoApplyOnStartup.Should().BeTrue();
}
[Fact]
public void ExportImportRoundTrip_AddsAllPresetsOnEmptyTarget()
{
OperatorPresetStore.Save(MakePreset("ShowA", ("Jane", true)));
OperatorPresetStore.Save(MakePreset("ShowB", ("Bob", false)));
var bundle = OperatorPresetStore.ExportAllAsJson();
// Wipe and re-import.
File.Delete(_tempPath);
OperatorPresetStore.LoadAll().Should().BeEmpty();
var result = OperatorPresetStore.ImportBundle(bundle, overwrite: false);
result.Added.Should().Be(2);
result.Overwritten.Should().Be(0);
result.Skipped.Should().Be(0);
result.Error.Should().BeNull();
OperatorPresetStore.LoadAll().Should().HaveCount(2);
}
[Fact]
public void Import_OverwriteFalse_SkipsCollisions()
{
OperatorPresetStore.Save(MakePreset("ShowA", ("Jane", true)));
var bundle = OperatorPresetStore.ExportAllAsJson();
// Modify in-memory: change Jane → Bob, then import without overwrite.
OperatorPresetStore.Save(MakePreset("ShowA", ("Bob", true)));
var result = OperatorPresetStore.ImportBundle(bundle, overwrite: false);
result.Added.Should().Be(0);
result.Skipped.Should().Be(1, because: "ShowA already exists locally and overwrite is off");
var afterImport = OperatorPresetStore.LoadAll().Single();
afterImport.Assignments.Single().DisplayName.Should().Be("Bob",
because: "skip means the local Bob version stays");
}
[Fact]
public void Import_OverwriteTrue_ReplacesCollisions()
{
OperatorPresetStore.Save(MakePreset("ShowA", ("Jane", true)));
var bundle = OperatorPresetStore.ExportAllAsJson();
OperatorPresetStore.Save(MakePreset("ShowA", ("Bob", true)));
var result = OperatorPresetStore.ImportBundle(bundle, overwrite: true);
result.Overwritten.Should().Be(1);
result.Skipped.Should().Be(0);
OperatorPresetStore.LoadAll().Single().Assignments.Single().DisplayName.Should().Be("Jane",
because: "overwrite means the bundle's Jane version wins");
}
[Fact]
public void Import_MalformedJson_ReturnsErrorAndPreservesState()
{
OperatorPresetStore.Save(MakePreset("ShowA", ("Jane", true)));
var before = OperatorPresetStore.LoadAll().Count;
var result = OperatorPresetStore.ImportBundle("not actually json", overwrite: true);
result.Error.Should().NotBeNull();
OperatorPresetStore.LoadAll().Count.Should().Be(before, because: "a malformed import must not delete existing data");
}
[Fact]
public void LoadAll_GarbageInFile_ReturnsEmpty()
{
// Pre-populate the file with garbage to simulate a corruption / partial-write scenario.
Directory.CreateDirectory(Path.GetDirectoryName(_tempPath)!);
File.WriteAllText(_tempPath, "not valid json {{{");
OperatorPresetStore.LoadAll().Should().BeEmpty(
because: "we degrade gracefully on corrupt files rather than crash the host");
}
private static OperatorPresetStore.Preset MakePreset(string name, params (string DisplayName, bool Enabled)[] assignments) =>
new(
Name: name,
SavedAt: DateTimeOffset.UnixEpoch,
Assignments: assignments
.Select(a => new OperatorPresetStore.Assignment(a.DisplayName, CustomOutputName: null, a.Enabled))
.ToArray());
}

View file

@ -0,0 +1,225 @@
using System.Text;
using FluentAssertions;
using TeamsISO.App.Services;
namespace TeamsISO.App.Tests.Services;
/// <summary>
/// Tests for the minimal OSC 1.0 parser inside <see cref="OscBridge"/>.
/// We construct packets byte-by-byte so the tests verify the wire-format
/// parsing exactly: 4-byte address + null-terminated + padded, type tag
/// starting with ',', big-endian int/float arg encoding.
/// </summary>
public class OscMessageTests
{
[Fact]
public void TryParse_TooShortPacket_ReturnsNull()
{
OscMessage.TryParse(new byte[] { 0x01, 0x02 }).Should().BeNull();
}
[Fact]
public void TryParse_BundleMarker_ReturnsNull()
{
// Bundles start with "#bundle\0..." — we don't support them.
var bytes = Encoding.ASCII.GetBytes("#bundle\0");
OscMessage.TryParse(bytes).Should().BeNull();
}
[Fact]
public void TryParse_AddressOnly_ParsesAddress()
{
// "/foo\0\0\0\0" — 4-char address + null + 3 pad = 8 bytes
var bytes = OscPacket("/foo");
var msg = OscMessage.TryParse(bytes);
msg.Should().NotBeNull();
msg!.Address.Should().Be("/foo");
}
[Fact]
public void TryParse_AddressMustStartWithSlash()
{
var bytes = OscPacket("foo");
OscMessage.TryParse(bytes).Should().BeNull();
}
[Fact]
public void TryParse_StringArg_RoundTrips()
{
var bytes = OscPacket("/teamsiso/iso", ",s", "Jane");
var msg = OscMessage.TryParse(bytes);
msg.Should().NotBeNull();
msg!.Address.Should().Be("/teamsiso/iso");
msg.GetStringArg(0).Should().Be("Jane");
}
[Fact]
public void TryParse_IntArg_BigEndianInteger()
{
var bytes = OscPacket("/x", ",i", 42);
var msg = OscMessage.TryParse(bytes);
msg.Should().NotBeNull();
msg!.GetBoolArg(0).Should().BeTrue(because: "non-zero int reads as true");
}
[Fact]
public void TryParse_IntArg_Zero_ReadsAsFalse()
{
var bytes = OscPacket("/x", ",i", 0);
var msg = OscMessage.TryParse(bytes);
msg.Should().NotBeNull();
msg!.GetBoolArg(0).Should().BeFalse();
}
[Fact]
public void TryParse_TBoolArg_ReadsAsTrue()
{
// T type tag is 0-arg (the value is the type itself).
var bytes = OscPacket("/x", ",T");
var msg = OscMessage.TryParse(bytes);
msg.Should().NotBeNull();
msg!.GetBoolArg(0).Should().BeTrue();
}
[Fact]
public void TryParse_FBoolArg_ReadsAsFalse()
{
var bytes = OscPacket("/x", ",F");
var msg = OscMessage.TryParse(bytes);
msg.Should().NotBeNull();
msg!.GetBoolArg(0).Should().BeFalse();
}
[Fact]
public void TryParse_StringArg_BoolHeuristic_True()
{
var bytes = OscPacket("/x", ",s", "true");
var msg = OscMessage.TryParse(bytes);
msg!.GetBoolArg(0).Should().BeTrue();
}
[Fact]
public void TryParse_StringArg_BoolHeuristic_OneIsTrue()
{
var bytes = OscPacket("/x", ",s", "1");
var msg = OscMessage.TryParse(bytes);
msg!.GetBoolArg(0).Should().BeTrue();
}
[Fact]
public void TryParse_StringArg_BoolHeuristic_NotMatching_IsFalse()
{
var bytes = OscPacket("/x", ",s", "no");
var msg = OscMessage.TryParse(bytes);
msg!.GetBoolArg(0).Should().BeFalse();
}
[Fact]
public void TryParse_StringPlusInt_BothParse()
{
var bytes = OscPacket("/teamsiso/iso", ",si", "Jane", 1);
var msg = OscMessage.TryParse(bytes);
msg.Should().NotBeNull();
msg!.GetStringArg(0).Should().Be("Jane");
msg.GetBoolArg(1).Should().BeTrue();
}
[Fact]
public void TryParse_UnknownTypeTag_ReturnsNull()
{
// 'b' (blob) isn't supported; bail rather than mis-align subsequent args.
var bytes = OscPacket("/x", ",b");
OscMessage.TryParse(bytes).Should().BeNull();
}
[Fact]
public void TryParse_MissingTypeTagComma_ReturnsNull()
{
// Type tag must start with ','. "ix" has no leading comma — invalid.
var bytes = OscPacket("/x", "ix");
OscMessage.TryParse(bytes).Should().BeNull();
}
[Fact]
public void GetStringArg_OutOfRange_ReturnsNull()
{
var bytes = OscPacket("/x", ",s", "Jane");
var msg = OscMessage.TryParse(bytes);
msg!.GetStringArg(5).Should().BeNull();
}
[Fact]
public void GetBoolArg_OutOfRange_ReturnsNull()
{
var bytes = OscPacket("/x", ",T");
var msg = OscMessage.TryParse(bytes);
msg!.GetBoolArg(5).Should().BeNull();
}
/// <summary>
/// Build an OSC packet by hand. Address is null-terminated + 4-byte padded;
/// optional type tag + args follow. Args must match the tag in count + type.
/// Supported tags here for arg emission: 'i' (int32 big-endian), 's'
/// (null-padded string), 'f' (float32 BE), 'T' / 'F' (no payload).
///
/// For tags the helper doesn't recognize (e.g. 'b' blob, or a deliberately
/// malformed tag without leading comma), we just emit the address + tag
/// bytes and skip arg emission. The test caller is responsible for asking
/// the parser to reject the result; the helper isn't a validator.
/// </summary>
private static byte[] OscPacket(string address, string? typeTag = null, params object[] args)
{
var ms = new MemoryStream();
WriteOscString(ms, address);
if (typeTag is null) return ms.ToArray();
WriteOscString(ms, typeTag);
// Only emit args when the tag string starts with ',' (a valid OSC type
// tag); malformed tags get no arg payload, which is what the tests
// exercising rejection paths want.
if (!typeTag.StartsWith(',')) return ms.ToArray();
var argIdx = 0;
for (var i = 1; i < typeTag.Length; i++)
{
switch (typeTag[i])
{
case 'i': WriteInt32BE(ms, (int)args[argIdx++]); break;
case 'f': WriteFloat32BE(ms, (float)args[argIdx++]); break;
case 's': WriteOscString(ms, (string)args[argIdx++]); break;
case 'T': case 'F': break; // no payload
default:
// Unknown tag — emit no payload. Mirrors how a hostile sender
// might construct a malformed packet; the parser should reject.
break;
}
}
return ms.ToArray();
}
private static void WriteOscString(MemoryStream ms, string s)
{
var bytes = Encoding.ASCII.GetBytes(s);
ms.Write(bytes, 0, bytes.Length);
ms.WriteByte(0); // null terminator
// Pad to 4-byte boundary (the +1 above for the null is included in count).
var written = bytes.Length + 1;
var pad = (4 - written % 4) % 4;
for (var i = 0; i < pad; i++) ms.WriteByte(0);
}
private static void WriteInt32BE(MemoryStream ms, int v)
{
ms.WriteByte((byte)(v >> 24));
ms.WriteByte((byte)(v >> 16));
ms.WriteByte((byte)(v >> 8));
ms.WriteByte((byte)v);
}
private static void WriteFloat32BE(MemoryStream ms, float v)
{
var bytes = BitConverter.GetBytes(v);
if (BitConverter.IsLittleEndian) Array.Reverse(bytes);
ms.Write(bytes, 0, 4);
}
}

View file

@ -0,0 +1,108 @@
using FluentAssertions;
using TeamsISO.App.Services;
namespace TeamsISO.App.Tests.Services;
/// <summary>
/// Token-expansion + sanitization tests for <see cref="OutputNameTemplate"/>.
/// We don't touch <see cref="OutputNameTemplate.Get"/> / <see cref="OutputNameTemplate.Set"/>
/// here because those round-trip through %LOCALAPPDATA% file IO; the file-IO
/// path is exercised at integration test time. The token expander is pure and
/// easy to cover.
/// </summary>
public class OutputNameTemplateTests
{
private static readonly Guid TestId = new("11223344-5566-7788-99aa-bbccddeeff00");
[Fact]
public void Render_DefaultTemplate_ProducesGuidPrefix()
{
var name = OutputNameTemplate.Render(OutputNameTemplate.DefaultTemplate, TestId, "Jane");
// Default is "TEAMSISO_{guid}" → first 8 hex of TestId, uppercase.
name.Should().Be("TEAMSISO_11223344");
}
[Fact]
public void Render_NameToken_UsesSanitizedDisplayName()
{
var name = OutputNameTemplate.Render("TEAMSISO_{name}", TestId, "Jane Doe");
name.Should().Be("TEAMSISO_Jane_Doe", because: "spaces become underscores in the sanitizer");
}
[Fact]
public void Render_NameToken_StripsSpecialCharacters()
{
// NDI accepts more chars than we allow, but we keep it conservative
// (alphanumeric + underscore + hyphen + period). Anything else is dropped
// or converted to underscore (whitespace).
var name = OutputNameTemplate.Render("{name}", TestId, "Jane (PM) — Lead!");
// Expect: "Jane_PM__Lead" — parens dropped, em-dash dropped, exclamation dropped.
// Whitespace runs collapse into adjacent underscores.
name.Should().NotContain("(");
name.Should().NotContain(")");
name.Should().NotContain("—");
name.Should().NotContain("!");
name.Should().Contain("Jane");
name.Should().Contain("Lead");
}
[Fact]
public void Render_GuidToken_IsUppercaseFirst8()
{
var name = OutputNameTemplate.Render("{guid}", TestId, "Jane");
name.Should().Be("11223344");
}
[Fact]
public void Render_MachineToken_UsesEnvironmentMachineName()
{
var name = OutputNameTemplate.Render("{machine}", TestId, "Jane");
// Sanitization may transform spaces in machine names, so just assert non-empty
// and that it contains the machine name's alphanumeric-ish chars.
name.Should().NotBeNullOrEmpty();
// MachineName itself is sanitized in render — equality check would be brittle.
}
[Fact]
public void Render_TimestampToken_HasExpectedShape()
{
var name = OutputNameTemplate.Render("session_{timestamp}", TestId, "Jane");
// yyyyMMdd_HHmmss is 15 chars + underscore separator = 16.
// Combined with "session_" prefix → length should be at least 23.
name.Should().StartWith("session_");
name.Length.Should().BeGreaterThan("session_".Length + 14);
}
[Fact]
public void Render_MultipleTokens_AllExpand()
{
var name = OutputNameTemplate.Render("{name}_{guid}_{machine}", TestId, "Jane");
name.Should().StartWith("Jane_11223344_");
name.Should().NotContain("{");
name.Should().NotContain("}");
}
[Fact]
public void Render_TemplateWithNoTokens_PassesThrough()
{
var name = OutputNameTemplate.Render("STATIC_NAME", TestId, "Jane");
name.Should().Be("STATIC_NAME");
}
[Fact]
public void Render_EmptyDisplayName_DegradesToEmptyToken()
{
var name = OutputNameTemplate.Render("PFX_{name}", TestId, "");
name.Should().Be("PFX_");
}
[Theory]
[InlineData("Jane123")]
[InlineData("Jane-Doe")]
[InlineData("Jane.PM")]
public void Render_AllowedCharactersPreserved(string displayName)
{
var name = OutputNameTemplate.Render("{name}", TestId, displayName);
name.Should().Be(displayName, because: "alphanumeric, underscore, hyphen, period are all valid NDI chars");
}
}

View file

@ -0,0 +1,42 @@
<Project Sdk="Microsoft.NET.Sdk">
<!--
Test project for the WPF host's pure-logic services (OperatorPresetStore,
OutputNameTemplate, NotesService, OSC parser). Targets net8.0-windows
because TeamsISO.App is net8.0-windows + WinExe — a pure net8.0 test
project can't reference it.
We DON'T reference WPF or System.Windows here — the tests cover services
that are intentionally framework-free even though they live in the host
assembly. Future test cases that touch WPF types (e.g. WriteableBitmap)
would need <UseWPF>true</UseWPF> added.
-->
<PropertyGroup>
<TargetFramework>net8.0-windows</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.0">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="xunit" Version="2.5.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\TeamsISO.App\TeamsISO.App.csproj" />
</ItemGroup>
</Project>

View file

@ -136,6 +136,32 @@ public class ParticipantTrackerTests
tracker.Participants[0].Id.Should().Be(firstId);
}
[Fact]
public void Added_SameSourceTwice_IsIdempotent_DoesNotDuplicate()
{
// Regression for the discovery-refresh path: when the operator clicks "Refresh"
// we deliberately re-emit Added events for sources that were already known
// (clearing the discovery service's seen-set so it re-fires everything coming
// back from the rebuilt finder). The tracker must coalesce these — minting a
// new Guid would orphan the operator's running ISO and visibly duplicate the
// row in the participants list.
var time = T0;
var tracker = new ParticipantTracker(TimeSpan.FromSeconds(5), () => time);
var jane = Source("PC1", "Jane");
tracker.Apply(new DiscoveryEvent.Added(jane));
var originalId = tracker.Participants[0].Id;
time = T0.AddSeconds(2);
tracker.Apply(new DiscoveryEvent.Added(jane));
tracker.Participants.Should().HaveCount(1);
tracker.Participants[0].Id.Should().Be(originalId,
because: "re-emitting Added for the still-live source must not mint a fresh Id");
tracker.Participants[0].LastSeen.Should().Be(time,
because: "the second Add should refresh LastSeen");
}
[Fact]
public void ActiveSpeakerRemove_DoesNotPoisonRenameWindowForLaterParticipant()
{

View file

@ -0,0 +1,82 @@
using FluentAssertions;
using TeamsISO.Engine.Pipeline;
namespace TeamsISO.Engine.Tests.Pipeline;
public class TestPatternGeneratorTests
{
[Theory]
[InlineData(1280, 720)]
[InlineData(1920, 1080)]
[InlineData(640, 480)]
public void Render_ProducesBgraBufferOfExpectedSize(int width, int height)
{
var frame = TestPatternGenerator.Render(width, height, frameNumber: 0, timestampTicks: 0);
frame.Width.Should().Be(width);
frame.Height.Should().Be(height);
frame.Pixels.Length.Should().Be(width * height * 4);
frame.Format.Should().Be(PixelFormat.Bgra);
}
[Fact]
public void Render_AlphaChannelIsFullyOpaque()
{
var frame = TestPatternGenerator.Render(640, 480, frameNumber: 0, timestampTicks: 0);
// Spot-check alpha = 0xFF at four corners.
var px = frame.Pixels.Span;
px[3].Should().Be(0xFF);
px[(640 - 1) * 4 + 3].Should().Be(0xFF);
px[(479 * 640) * 4 + 3].Should().Be(0xFF);
px[(479 * 640 + 639) * 4 + 3].Should().Be(0xFF);
}
[Fact]
public void Render_BarsAreColorful()
{
// The eight color bars should produce eight distinct (R,G,B) triplets
// when sampled at the middle of each bar's column.
var frame = TestPatternGenerator.Render(800, 100, frameNumber: 0, timestampTicks: 0);
var px = frame.Pixels.Span;
var midRow = 50;
var barWidth = 800 / 8;
var seen = new HashSet<(byte, byte, byte)>();
for (var bar = 0; bar < 8; bar++)
{
var x = bar * barWidth + barWidth / 2;
var off = (midRow * 800 + x) * 4;
seen.Add((px[off], px[off + 1], px[off + 2]));
}
seen.Count.Should().Be(8, because: "the 8 SMPTE bars must be visually distinguishable");
}
[Fact]
public void Render_DifferentFrameNumbers_ProduceDifferentSweepRows()
{
// The sweep band moves down by 2 rows per frame. Two frames separated by
// 100 should differ at the sweep rows.
var frame0 = TestPatternGenerator.Render(640, 480, frameNumber: 0, timestampTicks: 0);
var frame100 = TestPatternGenerator.Render(640, 480, frameNumber: 100, timestampTicks: 0);
// frame0's sweep row is 0 (and ±4 rows). frame100's sweep row is 200.
// Compare pixel row 200 — frame100 should be brighter at that row.
var px0 = frame0.Pixels.Span;
var px100 = frame100.Pixels.Span;
var row = 200;
var samples = 0;
for (var x = 0; x < 640; x++)
{
var off = (row * 640 + x) * 4;
// Most non-black bars: brighter sample at frame100 means the sweep is there.
var sum0 = px0[off] + px0[off + 1] + px0[off + 2];
var sum100 = px100[off] + px100[off + 1] + px100[off + 2];
if (sum100 > sum0) samples++;
}
// Allow some bars to be the black bar (where +32 still tops out at 32);
// most bars should brighten with the sweep.
samples.Should().BeGreaterThan(640 / 2,
because: "the sweep band brightens most pixels at row 200 in frame 100");
}
}