Compare commits
20 commits
9cb1cc7b3d
...
dd7827de82
| Author | SHA1 | Date | |
|---|---|---|---|
| dd7827de82 | |||
| 5c0491e46c | |||
| 46fa0d66a1 | |||
| fdd1d1bbfc | |||
| 832aad6a14 | |||
| 7c7520e2be | |||
| 5958b66bfd | |||
| b49e1abf17 | |||
| 6882e654d5 | |||
| 670813f18e | |||
| 179a44adf5 | |||
| e06120044b | |||
| f73552a6b9 | |||
| b8fe344c58 | |||
| e93b8caae0 | |||
| 83224dbd9b | |||
| b5fcc98d40 | |||
| 34a2f1483c | |||
| 4be5b39022 | |||
| 57c2922d1c |
56 changed files with 8729 additions and 181 deletions
|
|
@ -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
168
CHANGELOG.md
Normal 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
|
||||
85
README.md
85
README.md
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
276
docs/CONTROL-SURFACE.md
Normal 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.
|
||||
74
docs/REAL-TIME-RECORDING.md
Normal file
74
docs/REAL-TIME-RECORDING.md
Normal 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.
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
14
src/TeamsISO.App/GlobalUsings.cs
Normal file
14
src/TeamsISO.App/GlobalUsings.cs
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
// Project-wide using aliases.
|
||||
//
|
||||
// Why: enabling <UseWindowsForms>true</UseWindowsForms> for the system-tray
|
||||
// NotifyIcon brings in System.Windows.Forms.Application and
|
||||
// System.Windows.Forms.MessageBox, both of which collide with their WPF
|
||||
// counterparts (System.Windows.*). Every existing call site was written
|
||||
// for the WPF type. Aliasing globally here is one declaration that keeps
|
||||
// all the call sites compiling without per-file pollution.
|
||||
//
|
||||
// If you ever need the WinForms types, qualify them explicitly as
|
||||
// `System.Windows.Forms.MessageBox` etc.
|
||||
|
||||
global using Application = System.Windows.Application;
|
||||
global using MessageBox = System.Windows.MessageBox;
|
||||
210
src/TeamsISO.App/HelpWindow.xaml
Normal file
210
src/TeamsISO.App/HelpWindow.xaml
Normal 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\<date>\"/>
|
||||
<LineBreak/>
|
||||
<LineBreak/>
|
||||
<Run Text="NDI Access Manager config" Foreground="{DynamicResource Wd.Text.Tertiary}"/>
|
||||
<LineBreak/>
|
||||
<Run Text="%APPDATA%\NDI\ndi-config.v1.json"/>
|
||||
</TextBlock>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Style="{StaticResource Wd.Card}" Padding="16">
|
||||
<StackPanel>
|
||||
<TextBlock Text="EXTERNAL CONTROL"
|
||||
Style="{StaticResource Wd.Text.Caption}"
|
||||
Margin="0,0,0,12"/>
|
||||
<TextBlock Style="{StaticResource Wd.Text.Body}"
|
||||
FontSize="12"
|
||||
Foreground="{DynamicResource Wd.Text.Secondary}"
|
||||
TextWrapping="Wrap"
|
||||
Text="REST API (default :9755) and OSC bridge (default :9000) are off by default. Enable from Settings → DISPLAY. Stream Deck / Companion / TouchOSC can drive ISO toggles, presets, recording, mute/camera/leave, and marker drops. 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>
|
||||
32
src/TeamsISO.App/HelpWindow.xaml.cs
Normal file
32
src/TeamsISO.App/HelpWindow.xaml.cs
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
using System.Diagnostics;
|
||||
using System.Windows;
|
||||
|
||||
namespace TeamsISO.App;
|
||||
|
||||
/// <summary>
|
||||
/// F1 cheat-sheet dialog. Lists keyboard shortcuts, file locations, and a
|
||||
/// link to the public documentation. Same chromeless style as the rest of
|
||||
/// the host's modal dialogs.
|
||||
/// </summary>
|
||||
public partial class HelpWindow : Window
|
||||
{
|
||||
public HelpWindow() => InitializeComponent();
|
||||
|
||||
private void OnClose(object sender, RoutedEventArgs e) => Close();
|
||||
|
||||
private void OnDocsClick(object sender, RoutedEventArgs e)
|
||||
{
|
||||
try
|
||||
{
|
||||
Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = "https://forge.wilddragon.net/zgaetano/teamsiso",
|
||||
UseShellExecute = true,
|
||||
});
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best-effort browser launch
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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\<date>."/>
|
||||
<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}"
|
||||
|
|
|
|||
129
src/TeamsISO.App/NotesWindow.xaml
Normal file
129
src/TeamsISO.App/NotesWindow.xaml
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
<Window x:Class="TeamsISO.App.NotesWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:shell="clr-namespace:System.Windows.Shell;assembly=PresentationFramework"
|
||||
Title="Show notes"
|
||||
Icon="/Assets/teamsiso.ico"
|
||||
Width="540" Height="560"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
WindowStyle="None"
|
||||
ResizeMode="CanResize"
|
||||
Background="{DynamicResource Wd.Canvas}"
|
||||
UseLayoutRounding="True"
|
||||
TextOptions.TextFormattingMode="Ideal"
|
||||
TextOptions.TextRenderingMode="ClearType">
|
||||
|
||||
<shell:WindowChrome.WindowChrome>
|
||||
<shell:WindowChrome
|
||||
CaptionHeight="32"
|
||||
ResizeBorderThickness="6"
|
||||
CornerRadius="0"
|
||||
GlassFrameThickness="0"
|
||||
UseAeroCaptionButtons="False"/>
|
||||
</shell:WindowChrome.WindowChrome>
|
||||
|
||||
<Border BorderBrush="{DynamicResource Wd.Border}" BorderThickness="1">
|
||||
<Grid Margin="24,16,24,20">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- Caption -->
|
||||
<Grid Grid.Row="0">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock Text="SHOW NOTES"
|
||||
Style="{StaticResource Wd.Text.Caption}"
|
||||
VerticalAlignment="Center"/>
|
||||
<Button Grid.Column="1"
|
||||
Style="{StaticResource Wd.Button.CaptionClose}"
|
||||
Click="OnClose"
|
||||
shell:WindowChrome.IsHitTestVisibleInChrome="True">
|
||||
<Path Data="M 0,0 L 10,10 M 10,0 L 0,10"
|
||||
Stroke="{DynamicResource Wd.Text.Primary}"
|
||||
StrokeThickness="1.2"
|
||||
Width="10" Height="10"
|
||||
Stretch="None"/>
|
||||
</Button>
|
||||
</Grid>
|
||||
|
||||
<TextBlock Grid.Row="1"
|
||||
x:Name="DateLine"
|
||||
Style="{StaticResource Wd.Text.Subtle}"
|
||||
Foreground="{DynamicResource Wd.Text.Tertiary}"
|
||||
FontSize="12"
|
||||
Margin="0,12,0,12"/>
|
||||
|
||||
<!-- Notes view -->
|
||||
<Border Grid.Row="2" Style="{StaticResource Wd.Card}" Padding="0">
|
||||
<ScrollViewer x:Name="Scroller"
|
||||
VerticalScrollBarVisibility="Auto"
|
||||
Padding="14,12">
|
||||
<TextBox x:Name="NotesText"
|
||||
IsReadOnly="True"
|
||||
AcceptsReturn="True"
|
||||
Background="Transparent"
|
||||
BorderThickness="0"
|
||||
FontFamily="{StaticResource Wd.Font.Mono}"
|
||||
FontSize="11"
|
||||
Foreground="{DynamicResource Wd.Text.Primary}"
|
||||
TextWrapping="Wrap"/>
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
|
||||
<!-- Inline note input — quick stamping without leaving the dialog -->
|
||||
<Grid Grid.Row="3" Margin="0,12,0,0">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBox Grid.Column="0"
|
||||
x:Name="NewNoteBox"
|
||||
Padding="10,7"
|
||||
FontSize="12"
|
||||
VerticalAlignment="Center"
|
||||
KeyDown="OnNewNoteKey"
|
||||
ToolTip="Type a note and press Enter (or click 'Add'). Lands in today's file with a HH:mm:ss timestamp."/>
|
||||
<Button Grid.Column="1"
|
||||
Style="{StaticResource Wd.Button.Primary}"
|
||||
Content="Add"
|
||||
Click="OnAddNote"
|
||||
Margin="8,0,0,0"
|
||||
Padding="20,8"/>
|
||||
</Grid>
|
||||
|
||||
<!-- Footer -->
|
||||
<Grid Grid.Row="4" Margin="0,12,0,0">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<Button Grid.Column="0"
|
||||
Style="{StaticResource Wd.Button.Ghost}"
|
||||
Content="Open in editor"
|
||||
Click="OnOpenInEditor"
|
||||
Padding="14,8"
|
||||
ToolTip="Launch the notes file in the system default editor."/>
|
||||
<Button Grid.Column="2"
|
||||
Style="{StaticResource Wd.Button.Ghost}"
|
||||
Content="Refresh"
|
||||
Click="OnRefresh"
|
||||
Margin="0,0,8,0"
|
||||
Padding="14,8"/>
|
||||
<Button Grid.Column="3"
|
||||
Style="{StaticResource Wd.Button.Ghost}"
|
||||
Content="Close"
|
||||
Click="OnClose"
|
||||
Padding="20,8"/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Window>
|
||||
137
src/TeamsISO.App/NotesWindow.xaml.cs
Normal file
137
src/TeamsISO.App/NotesWindow.xaml.cs
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Windows;
|
||||
using System.Windows.Threading;
|
||||
using TeamsISO.App.Services;
|
||||
|
||||
namespace TeamsISO.App;
|
||||
|
||||
/// <summary>
|
||||
/// Inline viewer for the daily show-notes file. Reads
|
||||
/// <see cref="NotesService.TodayPath"/> on open and polls every 2s while
|
||||
/// shown so REST/OSC-driven note appends surface live without the operator
|
||||
/// having to click Refresh.
|
||||
///
|
||||
/// We don't allow editing here — the file is intentionally a one-way log
|
||||
/// (operator stamps, post-show review). If someone wants to edit, they
|
||||
/// click "Open in editor" and use Notepad.
|
||||
/// </summary>
|
||||
public partial class NotesWindow : Window
|
||||
{
|
||||
private readonly DispatcherTimer _refreshTimer;
|
||||
private long _lastFileSize = -1;
|
||||
private DateTime _lastFileWrite = DateTime.MinValue;
|
||||
|
||||
public NotesWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
_refreshTimer = new DispatcherTimer
|
||||
{
|
||||
Interval = TimeSpan.FromSeconds(2),
|
||||
};
|
||||
_refreshTimer.Tick += (_, _) => RefreshIfChanged();
|
||||
Loaded += (_, _) =>
|
||||
{
|
||||
DateLine.Text = $"{DateTimeOffset.Now:dddd, MMMM d, yyyy} · {NotesService.TodayPath}";
|
||||
ReloadFromDisk();
|
||||
_refreshTimer.Start();
|
||||
};
|
||||
Closed += (_, _) => _refreshTimer.Stop();
|
||||
}
|
||||
|
||||
private void OnClose(object sender, RoutedEventArgs e) => Close();
|
||||
|
||||
private void OnRefresh(object sender, RoutedEventArgs e) => ReloadFromDisk();
|
||||
|
||||
/// <summary>
|
||||
/// Cheap mtime/size check — only re-reads the file when something changed.
|
||||
/// Saves the textbox a flicker on every 2s tick when no notes are being
|
||||
/// added. Falls through to a full reload if the file got smaller (operator
|
||||
/// might have edited externally).
|
||||
/// </summary>
|
||||
private void RefreshIfChanged()
|
||||
{
|
||||
try
|
||||
{
|
||||
var path = NotesService.TodayPath;
|
||||
if (!File.Exists(path)) return;
|
||||
var info = new FileInfo(path);
|
||||
if (info.Length != _lastFileSize || info.LastWriteTimeUtc != _lastFileWrite)
|
||||
ReloadFromDisk();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Disk hiccups shouldn't stop the timer.
|
||||
}
|
||||
}
|
||||
|
||||
private void ReloadFromDisk()
|
||||
{
|
||||
try
|
||||
{
|
||||
var path = NotesService.TodayPath;
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
NotesText.Text = "No notes yet. Stamp one via the REST or OSC endpoint and refresh.";
|
||||
return;
|
||||
}
|
||||
var info = new FileInfo(path);
|
||||
_lastFileSize = info.Length;
|
||||
_lastFileWrite = info.LastWriteTimeUtc;
|
||||
NotesText.Text = File.ReadAllText(path);
|
||||
// Scroll to bottom so the latest stamp is visible — operators are
|
||||
// typically reading "what just happened" not "what happened first."
|
||||
Dispatcher.BeginInvoke(new Action(() =>
|
||||
{
|
||||
Scroller.ScrollToEnd();
|
||||
}), DispatcherPriority.Background);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
NotesText.Text = "Couldn't read notes file: " + ex.Message;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Append the input box's text to today's notes file via NotesService,
|
||||
/// then clear the box and refresh the view. Bound to the "Add" button +
|
||||
/// Enter key in the input. Empty/whitespace input is a no-op.
|
||||
/// </summary>
|
||||
private void OnAddNote(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var text = NewNoteBox.Text?.Trim();
|
||||
if (string.IsNullOrEmpty(text)) return;
|
||||
if (NotesService.Append(text))
|
||||
{
|
||||
NewNoteBox.Clear();
|
||||
ReloadFromDisk();
|
||||
NewNoteBox.Focus();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Enter key in the input commits the note, same as the Add button.</summary>
|
||||
private void OnNewNoteKey(object sender, System.Windows.Input.KeyEventArgs e)
|
||||
{
|
||||
if (e.Key == System.Windows.Input.Key.Enter)
|
||||
{
|
||||
OnAddNote(sender, e);
|
||||
e.Handled = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnOpenInEditor(object sender, RoutedEventArgs e)
|
||||
{
|
||||
try
|
||||
{
|
||||
Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = NotesService.TodayPath,
|
||||
UseShellExecute = true,
|
||||
});
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best-effort; if no .md handler is registered the OS shows its own dialog.
|
||||
}
|
||||
}
|
||||
}
|
||||
238
src/TeamsISO.App/OnboardingWindow.xaml
Normal file
238
src/TeamsISO.App/OnboardingWindow.xaml
Normal 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>
|
||||
57
src/TeamsISO.App/OnboardingWindow.xaml.cs
Normal file
57
src/TeamsISO.App/OnboardingWindow.xaml.cs
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
using System.IO;
|
||||
using System.Windows;
|
||||
|
||||
namespace TeamsISO.App;
|
||||
|
||||
/// <summary>
|
||||
/// First-launch welcome dialog. Walks the user through the once-per-machine
|
||||
/// setup that's not derivable from the UI alone (NDI runtime install, Teams
|
||||
/// admin permission, transcoder topology) and points them at where logs and
|
||||
/// presets live for later self-service.
|
||||
///
|
||||
/// Suppression is governed by a marker file at
|
||||
/// <c>%LOCALAPPDATA%\TeamsISO\onboarding.flag</c>. The presence of the file —
|
||||
/// regardless of contents — means "don't show again." The user can restore
|
||||
/// the dialog by deleting that file.
|
||||
/// </summary>
|
||||
public partial class OnboardingWindow : Window
|
||||
{
|
||||
private static string FlagPath =>
|
||||
Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"TeamsISO", "onboarding.flag");
|
||||
|
||||
public OnboardingWindow() => InitializeComponent();
|
||||
|
||||
/// <summary>
|
||||
/// Returns true on first launch (and on launches where the user previously
|
||||
/// unchecked "Don't show this again" so the marker file was never created).
|
||||
/// </summary>
|
||||
public static bool ShouldShow()
|
||||
{
|
||||
try { return !File.Exists(FlagPath); }
|
||||
catch { return false; } // permission errors → assume already shown
|
||||
}
|
||||
|
||||
private void OnDismiss(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (SuppressBox.IsChecked == true)
|
||||
{
|
||||
try
|
||||
{
|
||||
var dir = Path.GetDirectoryName(FlagPath);
|
||||
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
|
||||
File.WriteAllText(FlagPath,
|
||||
"Onboarding dialog dismissed at " + DateTimeOffset.UtcNow.ToString("o") + ". " +
|
||||
"Delete this file to see the welcome dialog again on next launch.");
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Disk full / permission denied — show the dialog again next launch
|
||||
// rather than fail noisily.
|
||||
}
|
||||
}
|
||||
DialogResult = true;
|
||||
Close();
|
||||
}
|
||||
}
|
||||
209
src/TeamsISO.App/PresetsDialog.xaml
Normal file
209
src/TeamsISO.App/PresetsDialog.xaml
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
<Window x:Class="TeamsISO.App.PresetsDialog"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:shell="clr-namespace:System.Windows.Shell;assembly=PresentationFramework"
|
||||
Title="Operator presets"
|
||||
Icon="/Assets/teamsiso.ico"
|
||||
Width="460" Height="520"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
WindowStyle="None"
|
||||
ResizeMode="NoResize"
|
||||
Background="{DynamicResource Wd.Canvas}"
|
||||
UseLayoutRounding="True"
|
||||
TextOptions.TextFormattingMode="Ideal"
|
||||
TextOptions.TextRenderingMode="ClearType">
|
||||
|
||||
<shell:WindowChrome.WindowChrome>
|
||||
<shell:WindowChrome
|
||||
CaptionHeight="32"
|
||||
ResizeBorderThickness="0"
|
||||
CornerRadius="0"
|
||||
GlassFrameThickness="0"
|
||||
UseAeroCaptionButtons="False"/>
|
||||
</shell:WindowChrome.WindowChrome>
|
||||
|
||||
<Border BorderBrush="{DynamicResource Wd.Border}" BorderThickness="1">
|
||||
<Grid Margin="24,16,24,20">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- Caption -->
|
||||
<Grid Grid.Row="0">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock Text="OPERATOR PRESETS"
|
||||
Style="{StaticResource Wd.Text.Caption}"
|
||||
VerticalAlignment="Center"/>
|
||||
<Button Grid.Column="1"
|
||||
Style="{StaticResource Wd.Button.CaptionClose}"
|
||||
Click="OnCancel"
|
||||
shell:WindowChrome.IsHitTestVisibleInChrome="True">
|
||||
<Path Data="M 0,0 L 10,10 M 10,0 L 0,10"
|
||||
Stroke="{DynamicResource Wd.Text.Primary}"
|
||||
StrokeThickness="1.2"
|
||||
Width="10" Height="10"
|
||||
Stretch="None"/>
|
||||
</Button>
|
||||
</Grid>
|
||||
|
||||
<TextBlock Grid.Row="1"
|
||||
Text="Save the current ISO assignments as a named preset, or load an existing preset to restore them."
|
||||
Style="{StaticResource Wd.Text.Subtle}"
|
||||
Foreground="{DynamicResource Wd.Text.Tertiary}"
|
||||
FontSize="12"
|
||||
TextWrapping="Wrap"
|
||||
Margin="0,12,0,16"/>
|
||||
|
||||
<!-- Save row: name textbox + Save button -->
|
||||
<Grid Grid.Row="2">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBox x:Name="NameBox"
|
||||
ToolTip="Name for the new preset (or pick an existing one to overwrite)"
|
||||
VerticalContentAlignment="Center"
|
||||
Margin="0,0,12,0"/>
|
||||
<Button Grid.Column="1"
|
||||
Style="{StaticResource Wd.Button.Primary}"
|
||||
Content="Save"
|
||||
Click="OnSave"
|
||||
Padding="20,8"/>
|
||||
</Grid>
|
||||
|
||||
<!-- Existing presets list -->
|
||||
<Border Grid.Row="3"
|
||||
Style="{StaticResource Wd.Card}"
|
||||
Padding="0"
|
||||
Margin="0,16,0,0">
|
||||
<Grid>
|
||||
<ListBox x:Name="PresetsList"
|
||||
Background="Transparent"
|
||||
BorderThickness="0"
|
||||
SelectionMode="Single"
|
||||
ScrollViewer.HorizontalScrollBarVisibility="Disabled"
|
||||
SelectionChanged="OnSelectionChanged">
|
||||
<ListBox.ItemContainerStyle>
|
||||
<Style TargetType="ListBoxItem">
|
||||
<Setter Property="Padding" Value="12,8"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource Wd.Text.Primary}"/>
|
||||
<Setter Property="Background" Value="Transparent"/>
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="ListBoxItem">
|
||||
<Border x:Name="Bd"
|
||||
Background="{TemplateBinding Background}"
|
||||
Padding="{TemplateBinding Padding}"
|
||||
CornerRadius="4"
|
||||
Margin="4,2">
|
||||
<ContentPresenter/>
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource Wd.SurfaceHover}"/>
|
||||
</Trigger>
|
||||
<Trigger Property="IsSelected" Value="True">
|
||||
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource Wd.SurfaceElevated}"/>
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
</ListBox.ItemContainerStyle>
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<StackPanel>
|
||||
<TextBlock Text="{Binding Name}"
|
||||
Style="{StaticResource Wd.Text.Body}"
|
||||
FontWeight="Medium"/>
|
||||
<TextBlock Style="{StaticResource Wd.Text.Mono}"
|
||||
FontSize="10"
|
||||
Foreground="{DynamicResource Wd.Text.Tertiary}">
|
||||
<Run Text="{Binding SavedAtDisplay, Mode=OneWay}"/>
|
||||
<Run Text=" · "/>
|
||||
<Run Text="{Binding AssignmentCount, Mode=OneWay}"/>
|
||||
<Run Text=" assignments"/>
|
||||
</TextBlock>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
|
||||
<!-- Empty-state inside the card -->
|
||||
<TextBlock x:Name="EmptyState"
|
||||
Text="No presets yet. Type a name above and click Save."
|
||||
Style="{StaticResource Wd.Text.Subtle}"
|
||||
Foreground="{DynamicResource Wd.Text.Tertiary}"
|
||||
FontSize="12"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Visibility="Collapsed"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- Footer buttons -->
|
||||
<Grid Grid.Row="4" Margin="0,16,0,0">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<Button Grid.Column="0"
|
||||
Style="{StaticResource Wd.Button.Ghost}"
|
||||
Content="Delete"
|
||||
Click="OnDelete"
|
||||
IsEnabled="False"
|
||||
x:Name="DeleteButton"
|
||||
Padding="14,8"/>
|
||||
<Button Grid.Column="1"
|
||||
Style="{StaticResource Wd.Button.Ghost}"
|
||||
Content="Duplicate"
|
||||
Click="OnDuplicate"
|
||||
IsEnabled="False"
|
||||
x:Name="DuplicateButton"
|
||||
Margin="8,0,0,0"
|
||||
Padding="14,8"
|
||||
ToolTip="Copy the selected preset to a new name. Useful when iterating on variants of a recurring show."/>
|
||||
<Button Grid.Column="2"
|
||||
Style="{StaticResource Wd.Button.Ghost}"
|
||||
Content="Export…"
|
||||
Click="OnExport"
|
||||
Margin="8,0,0,0"
|
||||
Padding="14,8"
|
||||
ToolTip="Save every preset as a single .json bundle. Useful for moving a curated library between machines, or sharing with a colleague."/>
|
||||
<Button Grid.Column="3"
|
||||
Style="{StaticResource Wd.Button.Ghost}"
|
||||
Content="Import…"
|
||||
Click="OnImport"
|
||||
Margin="8,0,0,0"
|
||||
Padding="14,8"
|
||||
ToolTip="Load presets from a .json bundle. Existing presets with the same name are skipped unless you confirm overwrite."/>
|
||||
<Button Grid.Column="5"
|
||||
Style="{StaticResource Wd.Button.Ghost}"
|
||||
Content="Cancel"
|
||||
Click="OnCancel"
|
||||
Margin="0,0,8,0"
|
||||
Padding="14,8"/>
|
||||
<Button Grid.Column="6"
|
||||
Style="{StaticResource Wd.Button.Primary}"
|
||||
Content="Apply"
|
||||
Click="OnApply"
|
||||
IsEnabled="False"
|
||||
x:Name="ApplyButton"
|
||||
Padding="20,8"/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Window>
|
||||
426
src/TeamsISO.App/PresetsDialog.xaml.cs
Normal file
426
src/TeamsISO.App/PresetsDialog.xaml.cs
Normal file
|
|
@ -0,0 +1,426 @@
|
|||
using System.Collections.ObjectModel;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using TeamsISO.App.Services;
|
||||
using TeamsISO.App.ViewModels;
|
||||
using TeamsISO.Engine.Controller;
|
||||
|
||||
namespace TeamsISO.App;
|
||||
|
||||
/// <summary>
|
||||
/// Modal dialog for saving and loading operator presets. Owned by
|
||||
/// <see cref="MainWindow"/>; given a snapshot of the current
|
||||
/// <see cref="ParticipantViewModel"/> list and the
|
||||
/// <see cref="IIsoController"/> so it can re-apply assignments
|
||||
/// (which requires calling EnableIsoAsync/DisableIsoAsync on the engine).
|
||||
/// </summary>
|
||||
public partial class PresetsDialog : Window
|
||||
{
|
||||
private readonly IIsoController _controller;
|
||||
private readonly IReadOnlyList<ParticipantViewModel> _participants;
|
||||
private readonly ToastViewModel? _toast;
|
||||
|
||||
/// <summary>
|
||||
/// Display-side wrapper for an <see cref="OperatorPresetStore.Preset"/>.
|
||||
/// Adds derived presentation-only properties so the ListBox template can
|
||||
/// render without inline converters or value-conversion logic.
|
||||
/// </summary>
|
||||
public sealed class PresetRow
|
||||
{
|
||||
public OperatorPresetStore.Preset Preset { get; }
|
||||
public string Name => Preset.Name;
|
||||
public string SavedAtDisplay => Preset.SavedAt.LocalDateTime.ToString("yyyy-MM-dd HH:mm");
|
||||
public int AssignmentCount => Preset.Assignments.Count(a => a.Enabled);
|
||||
public PresetRow(OperatorPresetStore.Preset preset) => Preset = preset;
|
||||
}
|
||||
|
||||
public ObservableCollection<PresetRow> Rows { get; } = new();
|
||||
|
||||
public PresetsDialog(
|
||||
IIsoController controller,
|
||||
IReadOnlyList<ParticipantViewModel> participants,
|
||||
ToastViewModel? toast = null)
|
||||
{
|
||||
InitializeComponent();
|
||||
_controller = controller;
|
||||
_participants = participants;
|
||||
_toast = toast;
|
||||
PresetsList.ItemsSource = Rows;
|
||||
ReloadPresets();
|
||||
}
|
||||
|
||||
/// <summary>Refresh the ListBox from disk and reflect emptiness in the empty-state TextBlock.</summary>
|
||||
private void ReloadPresets()
|
||||
{
|
||||
Rows.Clear();
|
||||
foreach (var p in OperatorPresetStore.LoadAll().OrderByDescending(p => p.SavedAt))
|
||||
Rows.Add(new PresetRow(p));
|
||||
EmptyState.Visibility = Rows.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
|
||||
UpdateButtonStates();
|
||||
}
|
||||
|
||||
private void UpdateButtonStates()
|
||||
{
|
||||
var hasSelection = PresetsList.SelectedItem is PresetRow;
|
||||
ApplyButton.IsEnabled = hasSelection;
|
||||
DeleteButton.IsEnabled = hasSelection;
|
||||
DuplicateButton.IsEnabled = hasSelection;
|
||||
}
|
||||
|
||||
private void OnSelectionChanged(object sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
if (PresetsList.SelectedItem is PresetRow row)
|
||||
{
|
||||
// Mirror the selected name into the textbox so a re-save overwrites
|
||||
// by default; operator can still type a new name to fork.
|
||||
NameBox.Text = row.Name;
|
||||
}
|
||||
UpdateButtonStates();
|
||||
}
|
||||
|
||||
private void OnSave(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var name = NameBox.Text?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
_toast?.Warn("Enter a name for the preset");
|
||||
NameBox.Focus();
|
||||
return;
|
||||
}
|
||||
|
||||
var assignments = _participants
|
||||
.Select(p => new OperatorPresetStore.Assignment(
|
||||
DisplayName: p.DisplayName,
|
||||
CustomOutputName: string.IsNullOrWhiteSpace(p.CustomName) ? null : p.CustomName,
|
||||
Enabled: p.IsEnabled))
|
||||
.ToList();
|
||||
|
||||
var existing = OperatorPresetStore.Find(name);
|
||||
if (existing is not null)
|
||||
{
|
||||
var confirm = MessageBox.Show(
|
||||
this,
|
||||
$"A preset named \"{name}\" already exists. Overwrite it?",
|
||||
"TeamsISO — Overwrite preset",
|
||||
MessageBoxButton.YesNo,
|
||||
MessageBoxImage.Question,
|
||||
MessageBoxResult.No);
|
||||
if (confirm != MessageBoxResult.Yes) return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
OperatorPresetStore.Save(new OperatorPresetStore.Preset(
|
||||
Name: name,
|
||||
SavedAt: DateTimeOffset.Now,
|
||||
Assignments: assignments));
|
||||
_toast?.Show($"Saved preset \"{name}\"");
|
||||
ReloadPresets();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBox.Show(
|
||||
this,
|
||||
$"Could not save preset.\n\n{ex.Message}",
|
||||
"TeamsISO — Save preset",
|
||||
MessageBoxButton.OK,
|
||||
MessageBoxImage.Warning);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Apply the selected preset: walks the current participants list, matching
|
||||
/// by display name (the only stable join key across meetings — Ids are
|
||||
/// regenerated each meeting). For each match, set the custom output name and
|
||||
/// reconcile its enabled state with the preset by calling EnableIsoAsync /
|
||||
/// DisableIsoAsync as needed. Participants in the preset who aren't in the
|
||||
/// current meeting are silently skipped (and reported in the toast).
|
||||
/// </summary>
|
||||
private async void OnApply(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (PresetsList.SelectedItem is not PresetRow row) return;
|
||||
|
||||
ApplyButton.IsEnabled = false;
|
||||
try
|
||||
{
|
||||
// PresetApplier owns the apply loop — same code path the REST control
|
||||
// surface and auto-apply-on-launch use. Dialog passes null dispatcher
|
||||
// since OnApply already runs on the UI thread.
|
||||
var result = await PresetApplier.ApplyAsync(
|
||||
row.Preset, _participants, _controller, dispatcher: null);
|
||||
|
||||
var summary = result.Skipped > 0
|
||||
? $"Applied \"{row.Name}\" — {result.Changed} change(s); {result.Skipped} not in meeting"
|
||||
: $"Applied \"{row.Name}\" — {result.Changed} change(s)";
|
||||
_toast?.Show(summary);
|
||||
DialogResult = true;
|
||||
Close();
|
||||
}
|
||||
finally
|
||||
{
|
||||
ApplyButton.IsEnabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Duplicate the selected preset under a new name. We auto-suggest
|
||||
/// "<original> (copy)" but pop a tiny input dialog so the operator
|
||||
/// can pick something meaningful. WPF doesn't ship an InputBox; we
|
||||
/// use a quick custom prompt below.
|
||||
/// </summary>
|
||||
private void OnDuplicate(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (PresetsList.SelectedItem is not PresetRow row) return;
|
||||
|
||||
var defaultName = SuggestCopyName(row.Name);
|
||||
var newName = PromptForName("Duplicate preset", "New name:", defaultName);
|
||||
if (string.IsNullOrWhiteSpace(newName)) return;
|
||||
|
||||
try
|
||||
{
|
||||
var existing = OperatorPresetStore.Find(newName);
|
||||
if (existing is not null)
|
||||
{
|
||||
var confirm = MessageBox.Show(
|
||||
this,
|
||||
$"A preset named \"{newName}\" already exists. Overwrite it?",
|
||||
"TeamsISO — Duplicate preset",
|
||||
MessageBoxButton.YesNo,
|
||||
MessageBoxImage.Question,
|
||||
MessageBoxResult.No);
|
||||
if (confirm != MessageBoxResult.Yes) return;
|
||||
}
|
||||
|
||||
// Re-using Save() with a fresh SavedAt timestamp — Save's overwrite
|
||||
// semantics handle the name-collision case cleanly.
|
||||
OperatorPresetStore.Save(new OperatorPresetStore.Preset(
|
||||
Name: newName,
|
||||
SavedAt: DateTimeOffset.Now,
|
||||
Assignments: row.Preset.Assignments));
|
||||
_toast?.Show($"Duplicated to \"{newName}\"");
|
||||
ReloadPresets();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBox.Show(this,
|
||||
$"Could not duplicate preset.\n\n{ex.Message}",
|
||||
"TeamsISO — Duplicate preset",
|
||||
MessageBoxButton.OK,
|
||||
MessageBoxImage.Warning);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Suggest a name for a copy: "Foo" → "Foo (copy)", "Foo (copy)" → "Foo (copy 2)".
|
||||
/// Bumps the digit if the operator iterates from a copy.
|
||||
/// </summary>
|
||||
private static string SuggestCopyName(string original)
|
||||
{
|
||||
if (!original.EndsWith(")", StringComparison.Ordinal))
|
||||
return original + " (copy)";
|
||||
var match = System.Text.RegularExpressions.Regex.Match(original, @" \(copy(?: (\d+))?\)$");
|
||||
if (!match.Success) return original + " (copy)";
|
||||
var n = match.Groups[1].Success && int.TryParse(match.Groups[1].Value, out var parsed) ? parsed + 1 : 2;
|
||||
return original[..(original.Length - match.Length)] + $" (copy {n})";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Quick input dialog for a single string. WPF doesn't ship one, so we
|
||||
/// build a minimal modal here. Keeps the dialog dependency-free.
|
||||
/// </summary>
|
||||
private string? PromptForName(string title, string prompt, string defaultValue)
|
||||
{
|
||||
var dlg = new System.Windows.Window
|
||||
{
|
||||
Title = title,
|
||||
Owner = this,
|
||||
Width = 400,
|
||||
Height = 170,
|
||||
WindowStartupLocation = System.Windows.WindowStartupLocation.CenterOwner,
|
||||
ResizeMode = System.Windows.ResizeMode.NoResize,
|
||||
Background = (System.Windows.Media.Brush)FindResource("Wd.Canvas"),
|
||||
};
|
||||
var stack = new System.Windows.Controls.StackPanel { Margin = new System.Windows.Thickness(20) };
|
||||
stack.Children.Add(new System.Windows.Controls.TextBlock
|
||||
{
|
||||
Text = prompt,
|
||||
Margin = new System.Windows.Thickness(0, 0, 0, 8),
|
||||
Foreground = (System.Windows.Media.Brush)FindResource("Wd.Text.Primary"),
|
||||
});
|
||||
var tb = new System.Windows.Controls.TextBox { Text = defaultValue, Padding = new System.Windows.Thickness(8, 6, 8, 6) };
|
||||
stack.Children.Add(tb);
|
||||
var buttons = new System.Windows.Controls.StackPanel
|
||||
{
|
||||
Orientation = System.Windows.Controls.Orientation.Horizontal,
|
||||
HorizontalAlignment = System.Windows.HorizontalAlignment.Right,
|
||||
Margin = new System.Windows.Thickness(0, 16, 0, 0),
|
||||
};
|
||||
var ok = new System.Windows.Controls.Button { Content = "OK", IsDefault = true, Padding = new System.Windows.Thickness(20, 6, 20, 6), Style = (System.Windows.Style)FindResource("Wd.Button.Primary") };
|
||||
var cancel = new System.Windows.Controls.Button { Content = "Cancel", IsCancel = true, Padding = new System.Windows.Thickness(14, 6, 14, 6), Margin = new System.Windows.Thickness(0, 0, 8, 0), Style = (System.Windows.Style)FindResource("Wd.Button.Ghost") };
|
||||
ok.Click += (_, _) => { dlg.DialogResult = true; dlg.Close(); };
|
||||
buttons.Children.Add(cancel);
|
||||
buttons.Children.Add(ok);
|
||||
stack.Children.Add(buttons);
|
||||
dlg.Content = stack;
|
||||
tb.Focus();
|
||||
tb.SelectAll();
|
||||
var result = dlg.ShowDialog();
|
||||
return result == true ? tb.Text.Trim() : null;
|
||||
}
|
||||
|
||||
private void OnDelete(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (PresetsList.SelectedItem is not PresetRow row) return;
|
||||
|
||||
var confirm = MessageBox.Show(
|
||||
this,
|
||||
$"Delete preset \"{row.Name}\"? This cannot be undone.",
|
||||
"TeamsISO — Delete preset",
|
||||
MessageBoxButton.YesNo,
|
||||
MessageBoxImage.Warning,
|
||||
MessageBoxResult.No);
|
||||
if (confirm != MessageBoxResult.Yes) return;
|
||||
|
||||
try
|
||||
{
|
||||
OperatorPresetStore.Delete(row.Name);
|
||||
_toast?.Show($"Deleted preset \"{row.Name}\"");
|
||||
ReloadPresets();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBox.Show(
|
||||
this,
|
||||
$"Could not delete preset.\n\n{ex.Message}",
|
||||
"TeamsISO — Delete preset",
|
||||
MessageBoxButton.OK,
|
||||
MessageBoxImage.Warning);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnCancel(object sender, RoutedEventArgs e)
|
||||
{
|
||||
DialogResult = false;
|
||||
Close();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Save every preset as a single .json bundle to a path the user picks via
|
||||
/// SaveFileDialog. We use Microsoft.Win32.SaveFileDialog because it doesn't
|
||||
/// drag in WinForms; the WPF host doesn't ship a built-in alternative.
|
||||
/// </summary>
|
||||
private void OnExport(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var dlg = new Microsoft.Win32.SaveFileDialog
|
||||
{
|
||||
Title = "Export TeamsISO presets",
|
||||
FileName = $"teamsiso-presets-{DateTimeOffset.Now:yyyy-MM-dd}.json",
|
||||
Filter = "TeamsISO preset bundle (*.json)|*.json",
|
||||
DefaultExt = "json",
|
||||
};
|
||||
if (dlg.ShowDialog(this) != true) return;
|
||||
|
||||
try
|
||||
{
|
||||
var json = OperatorPresetStore.ExportAllAsJson();
|
||||
System.IO.File.WriteAllText(dlg.FileName, json);
|
||||
_toast?.Show($"Exported {Rows.Count} preset(s)");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBox.Show(this,
|
||||
$"Could not export presets.\n\n{ex.Message}",
|
||||
"TeamsISO — Export presets",
|
||||
MessageBoxButton.OK,
|
||||
MessageBoxImage.Warning);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Load a bundle from a path the user picks. On name collision we ask once
|
||||
/// (covering all collisions) whether to overwrite — a per-preset prompt would
|
||||
/// be exhausting for a 20-preset bundle. The Y/N here drives the overwrite
|
||||
/// flag passed to <see cref="OperatorPresetStore.ImportBundle"/>.
|
||||
/// </summary>
|
||||
private void OnImport(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var dlg = new Microsoft.Win32.OpenFileDialog
|
||||
{
|
||||
Title = "Import TeamsISO presets",
|
||||
Filter = "TeamsISO preset bundle (*.json)|*.json|All files (*.*)|*.*",
|
||||
};
|
||||
if (dlg.ShowDialog(this) != true) return;
|
||||
|
||||
string json;
|
||||
try { json = System.IO.File.ReadAllText(dlg.FileName); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBox.Show(this,
|
||||
$"Could not read the file.\n\n{ex.Message}",
|
||||
"TeamsISO — Import presets",
|
||||
MessageBoxButton.OK,
|
||||
MessageBoxImage.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
// Quick parse to sniff for collisions before asking the operator anything.
|
||||
OperatorPresetStore.Bundle? bundle;
|
||||
try { bundle = System.Text.Json.JsonSerializer.Deserialize<OperatorPresetStore.Bundle>(json); }
|
||||
catch
|
||||
{
|
||||
MessageBox.Show(this,
|
||||
"That file isn't a valid TeamsISO preset bundle.",
|
||||
"TeamsISO — Import presets",
|
||||
MessageBoxButton.OK,
|
||||
MessageBoxImage.Warning);
|
||||
return;
|
||||
}
|
||||
if (bundle is null || bundle.Presets is null || bundle.Presets.Count == 0)
|
||||
{
|
||||
MessageBox.Show(this,
|
||||
"The bundle is empty.",
|
||||
"TeamsISO — Import presets",
|
||||
MessageBoxButton.OK,
|
||||
MessageBoxImage.Information);
|
||||
return;
|
||||
}
|
||||
|
||||
var existingNames = OperatorPresetStore.LoadAll()
|
||||
.Select(p => p.Name)
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
var collisions = bundle.Presets.Count(p => existingNames.Contains(p.Name));
|
||||
|
||||
var overwrite = false;
|
||||
if (collisions > 0)
|
||||
{
|
||||
var choice = MessageBox.Show(
|
||||
this,
|
||||
$"{collisions} preset name(s) in this bundle already exist on this machine.\n\n" +
|
||||
"Yes = overwrite local copies with the bundle's versions.\n" +
|
||||
"No = keep local copies; only import new presets.",
|
||||
"TeamsISO — Import presets",
|
||||
MessageBoxButton.YesNoCancel,
|
||||
MessageBoxImage.Question,
|
||||
MessageBoxResult.No);
|
||||
if (choice == MessageBoxResult.Cancel) return;
|
||||
overwrite = choice == MessageBoxResult.Yes;
|
||||
}
|
||||
|
||||
var result = OperatorPresetStore.ImportBundle(json, overwrite);
|
||||
if (result.Error is not null)
|
||||
{
|
||||
MessageBox.Show(this,
|
||||
$"Import failed.\n\n{result.Error}",
|
||||
"TeamsISO — Import presets",
|
||||
MessageBoxButton.OK,
|
||||
MessageBoxImage.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
var summary = $"Imported — {result.Added} new";
|
||||
if (result.Overwritten > 0) summary += $", {result.Overwritten} overwritten";
|
||||
if (result.Skipped > 0) summary += $", {result.Skipped} skipped";
|
||||
_toast?.Show(summary);
|
||||
ReloadPresets();
|
||||
}
|
||||
}
|
||||
68
src/TeamsISO.App/PreviewWindow.xaml
Normal file
68
src/TeamsISO.App/PreviewWindow.xaml
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
<Window x:Class="TeamsISO.App.PreviewWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:shell="clr-namespace:System.Windows.Shell;assembly=PresentationFramework"
|
||||
Title="Preview"
|
||||
Icon="/Assets/teamsiso.ico"
|
||||
Width="640" Height="400"
|
||||
MinWidth="320" MinHeight="200"
|
||||
Background="Black"
|
||||
WindowStyle="None"
|
||||
ResizeMode="CanResize"
|
||||
UseLayoutRounding="True">
|
||||
|
||||
<shell:WindowChrome.WindowChrome>
|
||||
<shell:WindowChrome
|
||||
CaptionHeight="32"
|
||||
ResizeBorderThickness="6"
|
||||
CornerRadius="0"
|
||||
GlassFrameThickness="0"
|
||||
UseAeroCaptionButtons="False"/>
|
||||
</shell:WindowChrome.WindowChrome>
|
||||
|
||||
<Border BorderBrush="{DynamicResource Wd.Border}" BorderThickness="1">
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- Caption -->
|
||||
<Grid Grid.Row="0" Background="{DynamicResource Wd.Surface}">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock x:Name="TitleText"
|
||||
Text="Preview"
|
||||
Style="{StaticResource Wd.Text.Caption}"
|
||||
Margin="14,0,0,0"
|
||||
VerticalAlignment="Center"/>
|
||||
<TextBlock Grid.Column="1"
|
||||
x:Name="ResolutionText"
|
||||
Style="{StaticResource Wd.Text.Mono}"
|
||||
FontSize="11"
|
||||
Foreground="{DynamicResource Wd.Text.Tertiary}"
|
||||
VerticalAlignment="Center"
|
||||
Margin="0,0,12,0"/>
|
||||
<Button Grid.Column="2"
|
||||
Style="{StaticResource Wd.Button.CaptionClose}"
|
||||
Click="OnClose"
|
||||
shell:WindowChrome.IsHitTestVisibleInChrome="True">
|
||||
<Path Data="M 0,0 L 10,10 M 10,0 L 0,10"
|
||||
Stroke="{DynamicResource Wd.Text.Primary}"
|
||||
StrokeThickness="1.2"
|
||||
Width="10" Height="10"
|
||||
Stretch="None"/>
|
||||
</Button>
|
||||
</Grid>
|
||||
|
||||
<!-- Live preview -->
|
||||
<Image Grid.Row="1"
|
||||
x:Name="PreviewImage"
|
||||
Stretch="Uniform"
|
||||
RenderOptions.BitmapScalingMode="HighQuality"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Window>
|
||||
95
src/TeamsISO.App/PreviewWindow.xaml.cs
Normal file
95
src/TeamsISO.App/PreviewWindow.xaml.cs
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
using System.Windows;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Imaging;
|
||||
using System.Windows.Threading;
|
||||
using TeamsISO.Engine.Controller;
|
||||
using TeamsISO.Engine.Pipeline;
|
||||
|
||||
namespace TeamsISO.App;
|
||||
|
||||
/// <summary>
|
||||
/// Non-modal floating preview window for a single participant. Shows the
|
||||
/// engine's most recent <see cref="ProcessedFrame"/> at a higher refresh
|
||||
/// rate (~20Hz) than the participants DataGrid thumbnails (1Hz). Multi-
|
||||
/// monitor friendly: operator drags it to a second display, leaves the
|
||||
/// main TeamsISO window on the primary.
|
||||
///
|
||||
/// Uses <see cref="WriteableBitmap"/> with <see cref="WriteableBitmap.WritePixels(Int32Rect, IntPtr, int, int)"/>
|
||||
/// — the engine produces full-resolution BGRA frames so we can write them
|
||||
/// straight into the bitmap without scaling. WPF's Image control with
|
||||
/// Stretch=Uniform handles aspect-correct fit to the window size.
|
||||
/// </summary>
|
||||
public partial class PreviewWindow : Window
|
||||
{
|
||||
private readonly IIsoController _controller;
|
||||
private readonly Guid _participantId;
|
||||
private readonly DispatcherTimer _refreshTimer;
|
||||
private WriteableBitmap? _bitmap;
|
||||
private int _lastWidth;
|
||||
private int _lastHeight;
|
||||
|
||||
public PreviewWindow(IIsoController controller, Guid participantId, string displayName)
|
||||
{
|
||||
InitializeComponent();
|
||||
_controller = controller;
|
||||
_participantId = participantId;
|
||||
TitleText.Text = displayName;
|
||||
|
||||
_refreshTimer = new DispatcherTimer(DispatcherPriority.Background, Dispatcher)
|
||||
{
|
||||
// 50ms = 20Hz. High enough for a smooth-feeling preview without
|
||||
// hogging the dispatcher; still cheap because each refresh is just
|
||||
// a memcpy from the engine's last frame into our pinned BackBuffer.
|
||||
Interval = TimeSpan.FromMilliseconds(50),
|
||||
};
|
||||
_refreshTimer.Tick += OnTick;
|
||||
Loaded += (_, _) => _refreshTimer.Start();
|
||||
Closed += (_, _) =>
|
||||
{
|
||||
_refreshTimer.Stop();
|
||||
_refreshTimer.Tick -= OnTick;
|
||||
};
|
||||
}
|
||||
|
||||
private void OnClose(object sender, RoutedEventArgs e) => Close();
|
||||
|
||||
private void OnTick(object? sender, EventArgs e)
|
||||
{
|
||||
ProcessedFrame? frame;
|
||||
try { frame = _controller.GetLatestProcessedFrame(_participantId); }
|
||||
catch { return; }
|
||||
if (frame is null || frame.Pixels.IsEmpty || frame.Width <= 0 || frame.Height <= 0) return;
|
||||
if (frame.Pixels.Length < frame.Width * frame.Height * 4) return;
|
||||
|
||||
// (Re)allocate the WriteableBitmap when the source resolution changes.
|
||||
// FrameProcessor normalizes to a configured target so this happens at
|
||||
// most once per session, but we still defend against switches.
|
||||
if (_bitmap is null || frame.Width != _lastWidth || frame.Height != _lastHeight)
|
||||
{
|
||||
_bitmap = new WriteableBitmap(
|
||||
frame.Width, frame.Height, 96, 96, PixelFormats.Bgra32, null);
|
||||
PreviewImage.Source = _bitmap;
|
||||
_lastWidth = frame.Width;
|
||||
_lastHeight = frame.Height;
|
||||
ResolutionText.Text = $"{frame.Width}×{frame.Height}";
|
||||
}
|
||||
|
||||
// WritePixels takes a buffer + stride + rect. Stride = width * 4 for
|
||||
// BGRA. We slice the ProcessedFrame's ReadOnlyMemory<byte> via .Span
|
||||
// and use the IntPtr overload via MemoryMarshal — but the
|
||||
// byte-array overload is simpler and the compiler picks the right
|
||||
// ToArray-free path because the engine already allocates a fresh
|
||||
// array per frame.
|
||||
unsafe
|
||||
{
|
||||
fixed (byte* p = frame.Pixels.Span)
|
||||
{
|
||||
_bitmap.WritePixels(
|
||||
new Int32Rect(0, 0, frame.Width, frame.Height),
|
||||
(IntPtr)p,
|
||||
frame.Pixels.Length,
|
||||
frame.Width * 4);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
196
src/TeamsISO.App/Services/ControlPanelHtml.cs
Normal file
196
src/TeamsISO.App/Services/ControlPanelHtml.cs
Normal 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;
|
||||
}
|
||||
727
src/TeamsISO.App/Services/ControlSurfaceServer.cs
Normal file
727
src/TeamsISO.App/Services/ControlSurfaceServer.cs
Normal 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&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];
|
||||
}
|
||||
}
|
||||
134
src/TeamsISO.App/Services/DiagnosticsBundle.cs
Normal file
134
src/TeamsISO.App/Services/DiagnosticsBundle.cs
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
|
||||
namespace TeamsISO.App.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Gathers logs + config + presets + version metadata into a single .zip the
|
||||
/// operator can attach to a bug report. Surfaced via the "Export diagnostics"
|
||||
/// button in About.
|
||||
///
|
||||
/// We deliberately do NOT include screenshots or any process/memory dumps —
|
||||
/// that's outside the scope of a v1 support bundle and would raise privacy
|
||||
/// flags. The bundle has only files the user already wrote with their TeamsISO
|
||||
/// usage; nothing here is hidden state.
|
||||
/// </summary>
|
||||
public static class DiagnosticsBundle
|
||||
{
|
||||
/// <summary>
|
||||
/// Build the bundle and return the path it was written to.
|
||||
/// Throws on disk failure — the caller toasts/dialogs.
|
||||
/// </summary>
|
||||
public static string Export()
|
||||
{
|
||||
var ts = DateTimeOffset.Now.ToString("yyyyMMdd-HHmmss");
|
||||
var fileName = $"teamsiso-diagnostics-{ts}.zip";
|
||||
var outDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
||||
var downloads = Path.Combine(outDir, "Downloads");
|
||||
if (!Directory.Exists(downloads)) downloads = outDir; // fall back to ~/
|
||||
var outPath = Path.Combine(downloads, fileName);
|
||||
|
||||
using var fs = new FileStream(outPath, FileMode.Create, FileAccess.Write, FileShare.None);
|
||||
using var zip = new ZipArchive(fs, ZipArchiveMode.Create, leaveOpen: false);
|
||||
|
||||
WriteEnvironmentTxt(zip);
|
||||
TryCopyDirectory(zip, "logs", LogsDirectory);
|
||||
TryCopyFile(zip, "config.json", AppDataPath("config.json"));
|
||||
TryCopyFile(zip, "presets.json", LocalAppDataPath("presets.json"));
|
||||
TryCopyFile(zip, "window.json", LocalAppDataPath("window.json"));
|
||||
TryCopyFile(zip, "ndi-config.v1.json", NdiConfigPath());
|
||||
TryCopyFile(zip, "output-name-template.txt", LocalAppDataPath("output-name-template.txt"));
|
||||
|
||||
return outPath;
|
||||
}
|
||||
|
||||
private static void WriteEnvironmentTxt(ZipArchive zip)
|
||||
{
|
||||
var asm = typeof(DiagnosticsBundle).Assembly;
|
||||
var version = asm.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion
|
||||
?? asm.GetName().Version?.ToString()
|
||||
?? "unknown";
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("TeamsISO diagnostic bundle");
|
||||
sb.AppendLine($"Generated: {DateTimeOffset.Now:o}");
|
||||
sb.AppendLine($"TeamsISO version: {version}");
|
||||
sb.AppendLine($".NET runtime: {Environment.Version}");
|
||||
sb.AppendLine($"OS: {Environment.OSVersion}");
|
||||
sb.AppendLine($"Machine: {Environment.MachineName}");
|
||||
sb.AppendLine($"User: {Environment.UserName}");
|
||||
sb.AppendLine($"Process bits: {(Environment.Is64BitProcess ? "64" : "32")}");
|
||||
sb.AppendLine($"OS bits: {(Environment.Is64BitOperatingSystem ? "64" : "32")}");
|
||||
sb.AppendLine($"Working set: {Environment.WorkingSet / (1024 * 1024)} MB");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("Files included (when present):");
|
||||
sb.AppendLine(" logs/ Serilog rolling daily logs");
|
||||
sb.AppendLine(" config.json Engine settings (framerate, NDI groups, etc.)");
|
||||
sb.AppendLine(" presets.json Saved operator presets");
|
||||
sb.AppendLine(" window.json Last main-window placement");
|
||||
sb.AppendLine(" ndi-config.v1.json NDI Access Manager config (group routing)");
|
||||
sb.AppendLine(" output-name-template.txt NDI source name template override");
|
||||
|
||||
var entry = zip.CreateEntry("environment.txt");
|
||||
using var w = new StreamWriter(entry.Open(), Encoding.UTF8);
|
||||
w.Write(sb.ToString());
|
||||
}
|
||||
|
||||
private static void TryCopyFile(ZipArchive zip, string entryName, string sourcePath)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(sourcePath)) return;
|
||||
zip.CreateEntryFromFile(sourcePath, entryName);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// One missing or locked file shouldn't kill the rest of the bundle.
|
||||
}
|
||||
}
|
||||
|
||||
private static void TryCopyDirectory(ZipArchive zip, string prefix, string sourceDir)
|
||||
{
|
||||
if (!Directory.Exists(sourceDir)) return;
|
||||
try
|
||||
{
|
||||
foreach (var file in Directory.EnumerateFiles(sourceDir, "*", SearchOption.AllDirectories))
|
||||
{
|
||||
try
|
||||
{
|
||||
var rel = Path.GetRelativePath(sourceDir, file).Replace('\\', '/');
|
||||
zip.CreateEntryFromFile(file, $"{prefix}/{rel}");
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Skip locked files (e.g., today's actively-written log).
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Permission denied on the dir as a whole; nothing more to do.
|
||||
}
|
||||
}
|
||||
|
||||
private static string LogsDirectory =>
|
||||
Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"TeamsISO", "Logs");
|
||||
|
||||
private static string LocalAppDataPath(string fileName) =>
|
||||
Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"TeamsISO", fileName);
|
||||
|
||||
private static string AppDataPath(string fileName) =>
|
||||
Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"TeamsISO", fileName);
|
||||
|
||||
private static string NdiConfigPath() =>
|
||||
Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"NDI", "ndi-config.v1.json");
|
||||
}
|
||||
100
src/TeamsISO.App/Services/DiskSpaceWatcher.cs
Normal file
100
src/TeamsISO.App/Services/DiskSpaceWatcher.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
60
src/TeamsISO.App/Services/NotesService.cs
Normal file
60
src/TeamsISO.App/Services/NotesService.cs
Normal 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\<YYYY-MM-DD>.md</c>.
|
||||
/// Operators stamp notes via the REST <c>POST /notes</c> endpoint or the OSC
|
||||
/// <c>/teamsiso/notes "..."</c> address — typically wired to a Stream Deck
|
||||
/// button so a note can be left without leaving the show.
|
||||
///
|
||||
/// We deliberately don't surface the notes inside the WPF UI: the file is
|
||||
/// trivial to open in any editor, and inline note-taking would be a much
|
||||
/// bigger feature (textarea, scrollback, autosave). The endpoint is the
|
||||
/// minimum-viable affordance for live note capture.
|
||||
/// </summary>
|
||||
public static class NotesService
|
||||
{
|
||||
private static readonly object _gate = new();
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
273
src/TeamsISO.App/Services/OperatorPresetStore.cs
Normal file
273
src/TeamsISO.App/Services/OperatorPresetStore.cs
Normal file
|
|
@ -0,0 +1,273 @@
|
|||
using System.IO;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace TeamsISO.App.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Persistent named snapshots of which participants should have ISOs enabled and
|
||||
/// what their custom output names are. Useful for recurring shows: an operator
|
||||
/// can save the assignment they spent 5 minutes setting up, and on the next
|
||||
/// meeting load the same preset and auto-enable everyone whose display name
|
||||
/// matches.
|
||||
///
|
||||
/// Persisted as JSON at <c>%LOCALAPPDATA%\TeamsISO\presets.json</c>. We key by
|
||||
/// participant <see cref="DisplayName"/> rather than <see cref="Guid"/> Id
|
||||
/// because the Id is freshly generated for every meeting (Teams' NDI source
|
||||
/// identity isn't stable across sessions); display name is the operator's
|
||||
/// natural identifier and is what they see in the UI anyway.
|
||||
/// </summary>
|
||||
public static class OperatorPresetStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Test-only override for the presets file path. Tests set this to a temp
|
||||
/// path so they don't pollute the operator's real %LOCALAPPDATA% store.
|
||||
/// Null in production. <see cref="System.Runtime.CompilerServices.InternalsVisibleToAttribute"/>
|
||||
/// in the project file grants the test assembly access.
|
||||
/// </summary>
|
||||
internal static string? PathOverride { get; set; }
|
||||
|
||||
private static string PresetsPath =>
|
||||
PathOverride ?? Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"TeamsISO",
|
||||
"presets.json");
|
||||
|
||||
/// <summary>
|
||||
/// One operator preset: a name, when it was saved, and a list of
|
||||
/// per-participant assignments keyed by display name.
|
||||
/// </summary>
|
||||
public sealed record Preset(
|
||||
string Name,
|
||||
DateTimeOffset SavedAt,
|
||||
IReadOnlyList<Assignment> Assignments);
|
||||
|
||||
/// <summary>
|
||||
/// Single participant's assignment within a preset. Both fields are stable
|
||||
/// across meetings; <see cref="DisplayName"/> is the join key when applying.
|
||||
/// </summary>
|
||||
public sealed record Assignment(
|
||||
string DisplayName,
|
||||
string? CustomOutputName,
|
||||
bool Enabled);
|
||||
|
||||
/// <summary>
|
||||
/// On-disk shape: a list of presets indexed by name. Wrapped in an object so
|
||||
/// we can grow the schema (versioning, defaults, last-used) without breaking
|
||||
/// existing files. <see cref="LastAppliedName"/> + <see cref="AutoApplyOnStartup"/>
|
||||
/// drive the "auto-apply on startup" feature; reading older files (which lack
|
||||
/// these fields) falls back to default values via the records' default ctor.
|
||||
/// </summary>
|
||||
private sealed record File(
|
||||
int Version,
|
||||
IReadOnlyList<Preset> Presets,
|
||||
string? LastAppliedName = null,
|
||||
bool AutoApplyOnStartup = false);
|
||||
|
||||
/// <summary>
|
||||
/// Operator-level preferences that travel inside the same JSON envelope as the
|
||||
/// presets themselves. Currently used for the "auto-apply last preset on launch"
|
||||
/// feature so the host can decide on startup whether to silently re-apply the
|
||||
/// most recent preset and which one to apply.
|
||||
/// </summary>
|
||||
public sealed record StartupPreference(string? LastAppliedName, bool AutoApplyOnStartup);
|
||||
|
||||
/// <summary>Returns all stored presets, oldest first. Empty list if no file exists.</summary>
|
||||
public static IReadOnlyList<Preset> LoadAll() => LoadFile().Presets ?? Array.Empty<Preset>();
|
||||
|
||||
/// <summary>
|
||||
/// Returns the operator's startup preference (which preset, if any, should be
|
||||
/// auto-applied on launch). Defaults to <c>(null, false)</c> when no file exists
|
||||
/// or the file predates the field — older preset.json files deserialize cleanly
|
||||
/// because both fields are optional with default values.
|
||||
/// </summary>
|
||||
public static StartupPreference GetStartupPreference()
|
||||
{
|
||||
var file = LoadFile();
|
||||
return new StartupPreference(file.LastAppliedName, file.AutoApplyOnStartup);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records that <paramref name="name"/> was just successfully applied. Combined
|
||||
/// with <see cref="SetAutoApplyOnStartup"/>, drives the auto-apply-on-launch flow.
|
||||
/// Preserves the rest of the file (presets, AutoApplyOnStartup flag) intact.
|
||||
/// </summary>
|
||||
public static void MarkApplied(string name)
|
||||
{
|
||||
var file = LoadFile();
|
||||
WriteFile(file with { LastAppliedName = name });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Toggles whether the host should auto-apply <see cref="StartupPreference.LastAppliedName"/>
|
||||
/// on next launch. Independent of <see cref="MarkApplied"/> so the operator can flip
|
||||
/// the toggle without losing the most-recent name.
|
||||
/// </summary>
|
||||
public static void SetAutoApplyOnStartup(bool enabled)
|
||||
{
|
||||
var file = LoadFile();
|
||||
WriteFile(file with { AutoApplyOnStartup = enabled });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds (or replaces) a preset by name. Atomic write: writes to a temp file
|
||||
/// then File.Replace so a crash mid-write doesn't corrupt the existing file.
|
||||
/// Preserves <see cref="StartupPreference"/> across writes.
|
||||
/// </summary>
|
||||
public static void Save(Preset preset)
|
||||
{
|
||||
var file = LoadFile();
|
||||
var presets = (file.Presets ?? Array.Empty<Preset>())
|
||||
.Where(p => !string.Equals(p.Name, preset.Name, StringComparison.OrdinalIgnoreCase))
|
||||
.Append(preset)
|
||||
.OrderBy(p => p.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
WriteFile(file with { Presets = presets });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a preset by name. No-op if not present. If the deleted preset was
|
||||
/// the last-applied one, clears that field so we don't try to re-apply a missing
|
||||
/// preset on next launch.
|
||||
/// </summary>
|
||||
public static void Delete(string name)
|
||||
{
|
||||
var file = LoadFile();
|
||||
var existing = file.Presets ?? Array.Empty<Preset>();
|
||||
var remaining = existing
|
||||
.Where(p => !string.Equals(p.Name, name, StringComparison.OrdinalIgnoreCase))
|
||||
.ToArray();
|
||||
if (remaining.Length == existing.Count) return; // not present
|
||||
|
||||
var clearedLastApplied =
|
||||
string.Equals(file.LastAppliedName, name, StringComparison.OrdinalIgnoreCase)
|
||||
? null
|
||||
: file.LastAppliedName;
|
||||
|
||||
WriteFile(file with { Presets = remaining, LastAppliedName = clearedLastApplied });
|
||||
}
|
||||
|
||||
/// <summary>Looks up a preset by name (case-insensitive). Null if not present.</summary>
|
||||
public static Preset? Find(string name) =>
|
||||
LoadAll().FirstOrDefault(p => string.Equals(p.Name, name, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
/// <summary>
|
||||
/// Bundle format for the export/import surface. Wraps the preset list with
|
||||
/// a version stamp + an export timestamp so a future-format-aware importer
|
||||
/// can migrate the data. We deliberately export a flat preset list — not
|
||||
/// the full <see cref="File"/> envelope — because StartupPreference is
|
||||
/// machine-local (operator A's "auto-apply Friday Show" shouldn't follow
|
||||
/// the bundle to operator B's machine).
|
||||
/// </summary>
|
||||
public sealed record Bundle(
|
||||
string Schema,
|
||||
DateTimeOffset ExportedAt,
|
||||
IReadOnlyList<Preset> Presets)
|
||||
{
|
||||
public const string CurrentSchema = "teamsiso-presets-bundle/v1";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serialize every preset to a JSON string suitable for writing to disk.
|
||||
/// The shape is human-readable (WriteIndented) so an operator can diff
|
||||
/// two bundles in their editor.
|
||||
/// </summary>
|
||||
public static string ExportAllAsJson()
|
||||
{
|
||||
var bundle = new Bundle(
|
||||
Schema: Bundle.CurrentSchema,
|
||||
ExportedAt: DateTimeOffset.Now,
|
||||
Presets: LoadAll());
|
||||
return JsonSerializer.Serialize(bundle, new JsonSerializerOptions { WriteIndented = true });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of an import attempt — counts so the UI can toast a clear summary.
|
||||
/// </summary>
|
||||
public sealed record ImportResult(int Added, int Overwritten, int Skipped, string? Error)
|
||||
{
|
||||
public static ImportResult Failed(string error) => new(0, 0, 0, error);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Import a bundle JSON. Per-preset name collision policy is determined by
|
||||
/// <paramref name="overwrite"/>: when true, identically-named presets in the
|
||||
/// bundle replace local ones; when false they're skipped. Returns counts
|
||||
/// so the caller can toast a "added X, overwrote Y, skipped Z" summary.
|
||||
/// </summary>
|
||||
public static ImportResult ImportBundle(string json, bool overwrite)
|
||||
{
|
||||
Bundle? bundle;
|
||||
try
|
||||
{
|
||||
bundle = JsonSerializer.Deserialize<Bundle>(json);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return ImportResult.Failed("Could not parse bundle: " + ex.Message);
|
||||
}
|
||||
if (bundle is null || bundle.Presets is null)
|
||||
return ImportResult.Failed("Bundle was empty or malformed.");
|
||||
|
||||
var existingNames = new HashSet<string>(
|
||||
LoadAll().Select(p => p.Name),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var added = 0;
|
||||
var overwritten = 0;
|
||||
var skipped = 0;
|
||||
|
||||
foreach (var p in bundle.Presets)
|
||||
{
|
||||
if (existingNames.Contains(p.Name))
|
||||
{
|
||||
if (!overwrite) { skipped++; continue; }
|
||||
overwritten++;
|
||||
}
|
||||
else
|
||||
{
|
||||
added++;
|
||||
}
|
||||
try { Save(p); }
|
||||
catch
|
||||
{
|
||||
// One bad preset shouldn't abort the rest. Count as skipped so
|
||||
// the user knows their import wasn't 100% clean.
|
||||
if (overwrite && existingNames.Contains(p.Name)) overwritten--;
|
||||
else added--;
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
|
||||
return new ImportResult(added, overwritten, skipped, null);
|
||||
}
|
||||
|
||||
private static File LoadFile()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!System.IO.File.Exists(PresetsPath))
|
||||
return new File(1, Array.Empty<Preset>());
|
||||
var json = System.IO.File.ReadAllText(PresetsPath);
|
||||
return JsonSerializer.Deserialize<File>(json) ?? new File(1, Array.Empty<Preset>());
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new File(1, Array.Empty<Preset>());
|
||||
}
|
||||
}
|
||||
|
||||
private static void WriteFile(File file)
|
||||
{
|
||||
var dir = Path.GetDirectoryName(PresetsPath);
|
||||
if (!string.IsNullOrEmpty(dir))
|
||||
Directory.CreateDirectory(dir);
|
||||
|
||||
var json = JsonSerializer.Serialize(file, new JsonSerializerOptions { WriteIndented = true });
|
||||
var temp = PresetsPath + ".tmp";
|
||||
System.IO.File.WriteAllText(temp, json);
|
||||
if (System.IO.File.Exists(PresetsPath))
|
||||
System.IO.File.Replace(temp, PresetsPath, destinationBackupFileName: null);
|
||||
else
|
||||
System.IO.File.Move(temp, PresetsPath);
|
||||
}
|
||||
}
|
||||
393
src/TeamsISO.App/Services/OscBridge.cs
Normal file
393
src/TeamsISO.App/Services/OscBridge.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
107
src/TeamsISO.App/Services/OutputNameTemplate.cs
Normal file
107
src/TeamsISO.App/Services/OutputNameTemplate.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
104
src/TeamsISO.App/Services/PresetApplier.cs
Normal file
104
src/TeamsISO.App/Services/PresetApplier.cs
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
using System.Windows.Threading;
|
||||
using TeamsISO.App.ViewModels;
|
||||
using TeamsISO.Engine.Controller;
|
||||
|
||||
namespace TeamsISO.App.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Shared preset-application logic. Originally lived inline in
|
||||
/// <c>PresetsDialog.OnApply</c>; lifted out so the REST control surface
|
||||
/// (<see cref="ControlSurfaceServer"/>) and the auto-apply-on-launch path
|
||||
/// (<see cref="MainViewModel.TryAutoApplyPendingPreset"/>) can call the same
|
||||
/// implementation. Single source of truth for "what does Apply mean."
|
||||
///
|
||||
/// Application proceeds participant-by-participant, matching by display name
|
||||
/// (the only stable join key across meetings since Ids regen each session).
|
||||
/// For each match, the custom output name is updated and IsEnabled is
|
||||
/// reconciled with the preset's value via <see cref="IIsoController.EnableIsoAsync"/>
|
||||
/// / <see cref="IIsoController.DisableIsoAsync"/>. Per-participant failures are
|
||||
/// caught and counted; one bad row never aborts applying the rest.
|
||||
/// </summary>
|
||||
public static class PresetApplier
|
||||
{
|
||||
/// <summary>Result counts from an apply pass.</summary>
|
||||
public sealed record ApplyResult(int Matched, int Changed, int Skipped);
|
||||
|
||||
/// <summary>
|
||||
/// Apply <paramref name="preset"/> to the live <paramref name="participants"/>
|
||||
/// list. <paramref name="dispatcher"/>, when supplied, is used to marshal
|
||||
/// IsEnabled / CustomName property writes onto the UI thread; pass null in
|
||||
/// contexts that already run on the UI thread (e.g. the dialog's button click).
|
||||
/// </summary>
|
||||
public static async Task<ApplyResult> ApplyAsync(
|
||||
Services.OperatorPresetStore.Preset preset,
|
||||
IReadOnlyList<ParticipantViewModel> participants,
|
||||
IIsoController controller,
|
||||
Dispatcher? dispatcher = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Build the lookup once, case-insensitive — Teams display names are
|
||||
// human-typed, so "Jane" and "jane" should match the same row.
|
||||
var byName = preset.Assignments.ToDictionary(
|
||||
a => a.DisplayName,
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var matched = 0;
|
||||
var changed = 0;
|
||||
|
||||
foreach (var p in participants)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (!byName.TryGetValue(p.DisplayName, out var assignment)) continue;
|
||||
matched++;
|
||||
|
||||
await SetOnUiAsync(dispatcher, () => p.CustomName = assignment.CustomOutputName ?? string.Empty);
|
||||
|
||||
if (assignment.Enabled && !p.IsEnabled)
|
||||
{
|
||||
try
|
||||
{
|
||||
await controller.EnableIsoAsync(
|
||||
p.Id,
|
||||
string.IsNullOrWhiteSpace(p.CustomName) ? null : p.CustomName,
|
||||
cancellationToken);
|
||||
await SetOnUiAsync(dispatcher, () => p.IsEnabled = true);
|
||||
changed++;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Per-participant best-effort: the rest still get applied.
|
||||
}
|
||||
}
|
||||
else if (!assignment.Enabled && p.IsEnabled)
|
||||
{
|
||||
try
|
||||
{
|
||||
await controller.DisableIsoAsync(p.Id, cancellationToken);
|
||||
await SetOnUiAsync(dispatcher, () => p.IsEnabled = false);
|
||||
changed++;
|
||||
}
|
||||
catch
|
||||
{
|
||||
/* defensive */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mark applied so auto-apply-on-launch picks the right preset next time.
|
||||
try { Services.OperatorPresetStore.MarkApplied(preset.Name); }
|
||||
catch { /* preference write is best-effort */ }
|
||||
|
||||
var skipped = preset.Assignments.Count - matched;
|
||||
return new ApplyResult(matched, changed, skipped);
|
||||
}
|
||||
|
||||
private static Task SetOnUiAsync(Dispatcher? dispatcher, Action action)
|
||||
{
|
||||
if (dispatcher is null || dispatcher.CheckAccess())
|
||||
{
|
||||
action();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
return dispatcher.InvokeAsync(action).Task;
|
||||
}
|
||||
}
|
||||
140
src/TeamsISO.App/Services/TrayIconHost.cs
Normal file
140
src/TeamsISO.App/Services/TrayIconHost.cs
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
using System.Drawing;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using System.Runtime.Versioning;
|
||||
using System.Windows;
|
||||
using WinForms = System.Windows.Forms;
|
||||
|
||||
namespace TeamsISO.App.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Wraps a WinForms <see cref="WinForms.NotifyIcon"/> so the WPF host can
|
||||
/// minimize-to-tray during long shows. Operators with a Stream Deck setup
|
||||
/// often want TeamsISO running but invisible — the tray icon keeps the
|
||||
/// process alive (and the engine routing live) while the window stays
|
||||
/// hidden.
|
||||
///
|
||||
/// Lifecycle pattern: instantiate from <c>App.OnStartup</c> after the main
|
||||
/// window exists; dispose from <c>App.OnExit</c>. The host hooks the main
|
||||
/// window's <c>StateChanged</c> to detect minimize and toggles
|
||||
/// <c>WindowState.Minimized</c> + <c>ShowInTaskbar=false</c> + <c>Hide()</c>.
|
||||
/// </summary>
|
||||
[SupportedOSPlatform("windows")]
|
||||
public sealed class TrayIconHost : IDisposable
|
||||
{
|
||||
private readonly Window _mainWindow;
|
||||
private readonly WinForms.NotifyIcon _notifyIcon;
|
||||
private bool _enabled;
|
||||
|
||||
public TrayIconHost(Window mainWindow)
|
||||
{
|
||||
_mainWindow = mainWindow;
|
||||
_notifyIcon = new WinForms.NotifyIcon
|
||||
{
|
||||
Text = "TeamsISO",
|
||||
Icon = LoadEmbeddedIcon(),
|
||||
Visible = false,
|
||||
};
|
||||
_notifyIcon.DoubleClick += (_, _) => RestoreFromTray();
|
||||
_notifyIcon.ContextMenuStrip = BuildMenu();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Toggles the minimize-to-tray behavior. When on, minimizing the window
|
||||
/// hides it and shows a tray icon; when off, minimize is normal Windows
|
||||
/// behavior. Read by the operator's checkbox in DISPLAY settings; the
|
||||
/// setting persists via <see cref="UIPreferences"/>.
|
||||
/// </summary>
|
||||
public bool Enabled
|
||||
{
|
||||
get => _enabled;
|
||||
set
|
||||
{
|
||||
if (_enabled == value) return;
|
||||
_enabled = value;
|
||||
if (value)
|
||||
{
|
||||
_mainWindow.StateChanged += OnMainWindowStateChanged;
|
||||
}
|
||||
else
|
||||
{
|
||||
_mainWindow.StateChanged -= OnMainWindowStateChanged;
|
||||
// If we're currently minimized + hidden, restore so the user
|
||||
// doesn't lose the window when they disable the setting.
|
||||
RestoreFromTray();
|
||||
_notifyIcon.Visible = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void OnMainWindowStateChanged(object? sender, EventArgs e)
|
||||
{
|
||||
if (_mainWindow.WindowState != WindowState.Minimized) return;
|
||||
// Hide from taskbar + hide the window, show the tray icon.
|
||||
_mainWindow.ShowInTaskbar = false;
|
||||
_mainWindow.Hide();
|
||||
_notifyIcon.Visible = true;
|
||||
_notifyIcon.ShowBalloonTip(
|
||||
timeout: 1500,
|
||||
tipTitle: "TeamsISO is still running",
|
||||
tipText: "Engine + control surface are live. Double-click the tray icon to restore the window.",
|
||||
tipIcon: WinForms.ToolTipIcon.Info);
|
||||
}
|
||||
|
||||
private void RestoreFromTray()
|
||||
{
|
||||
_mainWindow.Show();
|
||||
_mainWindow.WindowState = WindowState.Normal;
|
||||
_mainWindow.ShowInTaskbar = true;
|
||||
_mainWindow.Activate();
|
||||
_notifyIcon.Visible = false;
|
||||
}
|
||||
|
||||
private WinForms.ContextMenuStrip BuildMenu()
|
||||
{
|
||||
var menu = new WinForms.ContextMenuStrip();
|
||||
menu.Items.Add("Show TeamsISO", null, (_, _) => RestoreFromTray());
|
||||
menu.Items.Add("-");
|
||||
menu.Items.Add("Stop all ISOs", null, (_, _) =>
|
||||
{
|
||||
// Reach into the VM via the main window. Using string-keyed
|
||||
// command lookup would be more decoupled but adds overhead.
|
||||
if (_mainWindow.DataContext is ViewModels.MainViewModel vm
|
||||
&& vm.StopAllIsosCommand.CanExecute(null))
|
||||
{
|
||||
vm.StopAllIsosCommand.Execute(null);
|
||||
}
|
||||
});
|
||||
menu.Items.Add("-");
|
||||
menu.Items.Add("Exit TeamsISO", null, (_, _) => System.Windows.Application.Current.Shutdown());
|
||||
return menu;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Load the bundled teamsiso.ico from this assembly's resources. We use
|
||||
/// the embedded resource rather than the file-system path because the
|
||||
/// app may be run from any CWD (via the MSI install or a developer dotnet run).
|
||||
/// </summary>
|
||||
private static Icon LoadEmbeddedIcon()
|
||||
{
|
||||
try
|
||||
{
|
||||
var asm = Assembly.GetExecutingAssembly();
|
||||
var uri = new Uri("pack://application:,,,/Assets/teamsiso.ico");
|
||||
using var stream = System.Windows.Application.GetResourceStream(uri)?.Stream;
|
||||
if (stream is not null) return new Icon(stream);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Fall through to the OS default
|
||||
}
|
||||
return SystemIcons.Application;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { _mainWindow.StateChanged -= OnMainWindowStateChanged; } catch { /* ignore */ }
|
||||
try { _notifyIcon.Visible = false; } catch { /* ignore */ }
|
||||
try { _notifyIcon.Dispose(); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
74
src/TeamsISO.App/Services/UIPreferences.cs
Normal file
74
src/TeamsISO.App/Services/UIPreferences.cs
Normal 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.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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\<date></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
|
||||
|
|
|
|||
|
|
@ -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 5–10s of NDI discovery starting.
|
||||
// After this deadline we apply with whoever is visible.
|
||||
_pendingPresetDeadline = DateTimeOffset.UtcNow.AddSeconds(30);
|
||||
}
|
||||
}
|
||||
catch { /* preset read failures shouldn't block engine startup */ }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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) =>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"/>/<display-name>/.
|
||||
/// 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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
58
src/TeamsISO.Engine/Pipeline/IRecorderSink.cs
Normal file
58
src/TeamsISO.Engine/Pipeline/IRecorderSink.cs
Normal 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);
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
184
src/TeamsISO.Engine/Pipeline/MediaFoundationRecorderSink.cs
Normal file
184
src/TeamsISO.Engine/Pipeline/MediaFoundationRecorderSink.cs
Normal 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
|
||||
256
src/TeamsISO.Engine/Pipeline/RawBgraRecorderSink.cs
Normal file
256
src/TeamsISO.Engine/Pipeline/RawBgraRecorderSink.cs
Normal 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><outputDir>/<sanitized-display-name>/</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;
|
||||
}
|
||||
}
|
||||
71
src/TeamsISO.Engine/Pipeline/TestPatternGenerator.cs
Normal file
71
src/TeamsISO.Engine/Pipeline/TestPatternGenerator.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
225
src/tests/TeamsISO.App.Tests/Services/OscMessageTests.cs
Normal file
225
src/tests/TeamsISO.App.Tests/Services/OscMessageTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
108
src/tests/TeamsISO.App.Tests/Services/OutputNameTemplateTests.cs
Normal file
108
src/tests/TeamsISO.App.Tests/Services/OutputNameTemplateTests.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
42
src/tests/TeamsISO.App.Tests/TeamsISO.App.Tests.csproj
Normal file
42
src/tests/TeamsISO.App.Tests/TeamsISO.App.Tests.csproj
Normal 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>
|
||||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue