App.xaml.cs was 461 lines / 21KB and conflated four concerns: process-
level lifecycle (mutex / message pump filter / shutdown), engine bootstrap
(NDI runtime / IsoController / view model construction), crash handling
(three exception channels + log directory + dialog), and the background
update-checker kickoff.
Splits via partial-class into themed sibling files:
* App.xaml.cs (was 461L → now 219L) — class skeleton, fields, internal
property accessors, Win32 P/Invoke surface, OnStartup as a wiring
pipeline that calls the bootstrap steps in order, OnExit, CLI parser.
* App.Bootstrap.cs (250L, new) — linear startup steps:
TryAcquireSingleInstance, TryBootstrapNdiInterop, BootstrapEngine,
ConstructAndShowMainWindow, BootstrapControlSurfaceServices,
BootstrapTrayIcon, TryShowOnboarding, TryAutoLaunchTeams. Each
returns a signal (bool / window ref) when OnStartup needs it to
decide whether to continue.
* App.CrashHandlers.cs (93L, new) — OnAppDomainUnhandled,
OnDispatcherUnhandled, OnUnobservedTaskException, TryLogFatal,
TryShowCrashDialog, LogDirectory.
* App.UpdateCheckBootstrap.cs (42L, new) — StartBackgroundUpdateCheck
(24h-throttled, fire-and-forget).
OnStartup's body is now a 30-ish-line procedure that names each step,
which is what the original was trying to be. Comments inline the
"happened before, kept here for reason X" notes (theme.Apply before
window show; CLI args parsed before InitializeAsync). Behavior is
unchanged — Shutdown codes, error paths, and the side-effect order are
all preserved.
Build clean (0 warnings, 0 errors); 56 + 104 tests still pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
MainViewModel.cs was 1017 lines and 45KB — most of it was bulk-operation
loops, Teams UIA plumbing, and the auto-apply-last-preset state machine
sitting on top of the actual MainViewModel surface (constructor, props,
OnStatsTick). Splits the class via partial-class into themed siblings:
* MainViewModel.cs (was 1017L → now 699L) — fields, properties,
constructor that wires every Command, OnStatsTick + Dispose. This
remains the thin aggregator.
* MainViewModel.TeamsCommands.cs (130L, new) — MakeTeamsCommand helper,
JoinPastedMeeting (body of JoinMeetingCommand), ExtractMeetingTitle
(already-tested static), PollTeamsMeetingState (the 1Hz UIA probe
formerly inlined in OnStatsTick).
* MainViewModel.PresetCommands.cs (108L, new) —
RequestApplyPresetOnStartup (CLI hook), LoadPendingPresetFromPreferences
(called by InitializeAsync), TryAutoApplyPendingPreset (the reconcile
step), and the _pendingPreset* private-field set that backs the path.
* MainViewModel.BulkCommands.cs (149L, new) — EnableAllOnlineAsync,
StopAllIsosAsync (with the default-No confirmation dialog),
SnapshotAll. RecordingCommands.cs from the original punch list is
intentionally absent — the recording surface was axed at 1d1ce6a;
what remains here is bulk-state ops across the participants
collection (note in the file header).
Why partial-class instead of helper-services or composed objects: every
extracted method touches the same private dispatcher / controller /
participants / toast state. Composing would require either passing
those references in (verbose call sites) or extracting them to a
shared private context object (boilerplate). Partial gives us
file-level separation without spreading the state contract.
ExtractMeetingTitle stays internal-static so the existing
MeetingTitleExtractionTests (10 cases) keep finding it. Build clean;
56 App + 104 Engine tests still pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Walks the v2 polish punch list against MainWindow.
- Theme button tooltip is now "Theme (System / Dark / Light)" per the
v2 shape brief, replacing the previous "Toggle theme (Ctrl+T)".
- Participants table column widths match spec: Output 130px (was 150),
ISO pill 100px (was 110). The 24px state LED, 110px audio meter, and
52px row height already matched. The 106px Preview thumbnail column
and 32px gear-button column are intentional deviations (live thumbs
were restored at 4944de5; per-ISO override gear added at the same
time) and are now called out in the column-spec comment so a future
reader doesn't try to "fix" them.
- Empty-state placeholder finally renders when ParticipantCount == 0:
mono sentence "no ndi sources yet — open teams and start a meeting"
+ a tertiary Refresh discovery button — exactly the copy specified
by the shape brief's empty-states section. CountToVisibilityConverter
is now declared in MainWindow.Resources (it shipped as a class but
was never registered).
- OnClosing wraps WindowStateStore.Save in a try/catch so a serialization
or filesystem fault on shutdown can never block the window from
closing. Save itself already swallows its own IO errors; this is
defense-in-depth for anything that escapes.
- MessageBox copy in MainWindow.xaml.cs (Hide/show Teams, Launch Teams,
Stop Teams) moves to Properties/Strings.resx + a hand-written
Properties/Strings.Designer.cs accessor. ResourceManager reads it by
basename "TeamsISO.App.Properties.Strings"; LogicalName is set on the
EmbeddedResource so the manifest name is predictable regardless of
how MSBuild would otherwise compute it. Future-localization seam.
OnLaunchTeamsRightClick's confirmation dialog is intentional — it
guards a destructive mid-show action — and the code-behind comment
now says so; the palette also offers Stop Teams as the keyboard
surface, so the right-click affordance isn't the only one.
Build clean (0 warnings, 0 errors); 160 tests still pass (56 App +
104 Engine, Category!=ndi&requires!=ndi filter).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Fix TeamsISO.Windows.slnf — drop the dangling
src/TeamsISO.App.WinUI/TeamsISO.App.WinUI.csproj entry whose project
doesn't exist in the .sln (broke the build on main).
- Archive the abandoned WinUI 3 artifacts under docs/archive/:
* 2026-05-12-winui3-migration.md (the nine-phase migration plan)
* TeamsISO.App.WinUI.Probe/ (the bootstrap diagnostic console)
* work-log-2026-05-12-winui3.md (the overnight session log)
- README — drop the "in-flight WinUI 3 replatform" status block;
state that the v2 redesign landed in WPF and link the shape brief.
Keyboard shortcuts table picks up Ctrl+K, Ctrl+T, and the digit
hotkeys that already shipped.
- CHANGELOG — replace the WinUI-3-flavoured "Ground-up GUI redesign"
block with a v2 Studio Terminal entry that names Task 39 + Task 40
as landed. De-dupe the May 2026 batch: the second "Quick-join Teams
meeting from URL", "IN-CALL bar surfaces Teams meeting state", and
"Auto-launch Teams + auto-hide windows" bullets were verbatim repeats
of earlier entries; kept the first occurrence.
- NEXT_STEPS.md — rewrite to reflect that Task 39 (participants table
v2) and Task 40 (Ctrl+K palette) both shipped; v1.0 cut is now
gated only on MSI signing + real-meeting smoke pass.
- DESIGN.md — small WPF-isms: WinUI 3 composition layer →
WPF's; Segoe Fluent Icons phrased without the "WinUI 3's
bundled" qualifier; migration boundary rephrased to "rewrites
MainWindow.xaml + Themes/*" instead of "everything in Views/".
- .gitignore — ignore the .claude/ session metadata dir so it doesn't
show up as untracked on every dev checkout.
Build + tests verified before commit: 0 errors, 0 warnings; 160 tests
pass (56 App + 104 Engine, filter Category!=ndi&requires!=ndi).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Engine: IsoAssignment record gets optional Override (FrameProcessingSettings?). IsoController hydrates _overrides dict from config.json on startup, uses override at EnableIsoAsync, persists with assignment, exposes GetIsoOverride + SetIsoOverrideAsync. SetIsoOverrideAsync hot-swaps a running pipeline (Disable + 150ms delay + Enable) when the override changes.
REST: POST /participants/{id}/override (body: framerate/resolution/aspect/audio enum strings, all optional, missing fall back to globals); DELETE /participants/{id}/override clears. GET /participants now includes per-row effective {framerate, resolution, aspect, audio, isOverride} plus top-level globals block.
Web /ui: per-card collapsible override panel with four selects + Apply / Clear. OVR pill + cyan inset edge mark overridden rows. Open-panel state survives WS re-renders.
Desktop: per-row gear column in the v2 DataGrid opens IsoOverrideDialog (420x360) with four combos. Clear button removes the override.
Thumbnail endpoint switched from WPF JpegBitmapEncoder (NREs from non-UI HttpListener threads) to pure-managed 32bpp BMP encoder. Nearest-neighbor downscale to 192-wide. /participants/{id}/thumbnail.bmp; legacy .jpg URL still works.
Known limitation: ParticipantTracker regenerates IDs for display-name-keyed participants across process restarts, orphaning the persisted override. Override works within a session; cross-restart persistence is best-effort until the tracker is taught to use stable keys. Filed as task 43.
REST additions: GET /topology returns mode (hidden/public/unknown) + sender/receiver group lists. POST /topology/apply confines local senders to teamsiso-input + receivers to public+teamsiso-input. POST /topology/restore returns both to public defaults.
GET /participants/{id}/thumbnail.jpg encodes the latest engine ProcessedFrame as a 192-wide JPEG. 404 when no pipeline is running. Used by the /ui control panel for live preview tiles.
Settings: ControlSurfaceEnabled now persists across sessions via UIPreferences and auto-starts the server on app launch when previously enabled.
/ui control panel rebuilt: live thumbnail per row, topology toggle card with Hide/Restore buttons, removed dead recording marker button, larger layout (920px), participant rows in single card with hover affordances.
96x54 thumbnail (16:9) fed from the engine's most recent ProcessedFrame. Em-dash placeholder when no pipeline is running. Same pattern as v1 - lifted Image binding to Thumbnail with HasThumbnail visibility flip. Sits between the state LED and the name+codec caption.
The .ObserveOn(SynchronizationContextScheduler(SyncContext.Current)) path captured a synchronization context at subscribe time that didn't pump subsequent OnNext emissions in WPF startup, leaving the Participants collection empty even though the engine's discovery was firing. Console probe confirmed engine sees Teams sources; only the GUI consumer was broken.
Switched to direct Subscribe + Dispatcher.InvokeAsync inside the callback (same pattern proven by Console.Program.cs). Subscribe-time context capture is gone; every emission marshals to the UI thread on its own.
Two visual upgrades on the participant rows:
* The ISO state pill now flips background / border / text colors
based on the engine's reported state — green for LIVE, coral for
ERROR, amber for STARTING / NO SIGNAL, neutral surface for OFF.
Brushes pulled from the ThemeResource ramp (StatusLiveBg /
StatusLive / AccentCoralBg / AccentCoral / StatusWarnBg /
StatusWarn / BgSurface / BorderStrong). Mirrors the WPF host's
IsoToggle data-trigger behavior but built imperatively.
* The active-speaker left accent — a 3px cyan border at the row's
left edge — appears when MainViewModel's 1Hz stats tick marks the
loudest participant. Hidden by default; flips Visibility on
PropertyChanged(IsActiveSpeaker).
Row layout extended to accommodate: column 0 = 3px accent strip,
column 1 = 20px spacer, column 2 = name + codec (1*), column 3 =
output name (140px fixed), column 4 = ISO pill (auto).
ApplyIsoPillStyling is the brush-mapping helper — called once at row
construct and again on every IsoStateLabel / IsEnabled change. The
brush keys all resolve via Application.Current.Resources rather than
ThemeResource markup since the row is constructed imperatively (no
XAML to apply ThemeResource markup against).
Verified end-to-end: dotnet build clean, app launches with 3 live
participants in the row, all pills showing OFF (neutral surface +
strong border). Once a participant goes through OFF → STARTING (amber)
→ LIVE (green), the pill colors will update on the 1Hz stats tick.
Services/UIPreferences.cs — mirror of the WPF host's UIPreferences,
sharing %LOCALAPPDATA%\TeamsISO\ui-prefs.json on disk. Adds a Theme
field ("System" / "Dark" / "Light") that the WPF host's UIPreferences
will pick up when its theme system lands (JSON deserialization is
forward-compatible — extra fields are ignored, missing fields fall
back to defaults).
ThemeManager hydration:
* Constructor reads UIPreferences.Theme on first .Current access.
* Defaults to "System" when the file is missing, the value is
invalid, or load throws (defensive — ThemeManager.Current is a
static singleton, a throw would break theme resolution app-wide).
ThemeManager.Set persistence:
* Calls UIPreferences.SetTheme(preference) which does a read-modify-
write of the JSON (so other fields aren't trampled).
* Persistence is best-effort wrapped in try/catch — disk full,
permission denied, etc. fall through and the in-memory state still
holds for the session.
End-to-end now: title-bar sun/moon toggle → ThemeManager.Toggle →
.Set("Dark"/"Light") → JSON write → next launch reads the preference
and applies before the first frame. Operator's theme choice survives
across launches and across host swaps once the WPF host learns the
field.
Adds the operator's shortcut surface to the WinUI 3 host via
KeyboardAccelerator attached to the window's content root:
* F1 — open the keyboard-cheat-sheet HelpDialog as a ContentDialog.
* Ctrl+M — drop a recording marker (invokes
MainViewModel.DropRecordingMarkerCommand, which fans out to every
active recorder via IIsoController.AddRecordingMarker).
* Ctrl+Shift+S — panic stop (invokes StopAllIsosCommand).
* Ctrl+R — refresh NDI discovery.
* 1-9 + NumPad 1-9 — toggle ISO for the Nth visible participant
(invokes ToggleByIndexCommand with the digit as the parameter).
* Esc — dismiss the settings drawer when open.
Mirrors the WPF host's <Window.InputBindings> verbatim so the
operator's muscle memory transfers across hosts.
Wire-up note: WinUI 3 KeyboardAccelerator uses
TypedEventHandler<KeyboardAccelerator, KeyboardAcceleratorInvokedEvent
Args>, not System.EventHandler<T> like XAML islands suggest in some
docs. The Bind local fn takes the correct type explicitly so the
compiler doesn't trip on the conversion.
Verified: dotnet build clean, app launches and accelerators register
without crashing the XAML parser.
In-call control bar now drives the live Teams app via UIAutomation:
* Mute button → TeamsControlBridge.ToggleMute()
* Camera button → TeamsControlBridge.ToggleCamera()
* Share button → TeamsControlBridge.OpenShareTray()
* Leave button → TeamsControlBridge.LeaveCall()
Each button reports the result through the status bar (Invoked /
Teams-not-running / Control-not-visible / Invoke-failed).
Rail buttons also wired:
* Launch / surface Teams → TeamsLauncher.IsRunning()/TryLaunch()/ShowWindows()
* Hide / show Teams windows → TeamsLauncher.HideWindows()/ShowWindows()
with a _teamsHidden flag tracking the toggle state
The Marker button was already command-bound to MainViewModel.DropRecording
MarkerCommand (which fans out to IIsoController.AddRecordingMarker), so
the only thing that wasn't covered before is the Teams-side stuff.
Implementation notes:
* Services/TeamsControlBridge.cs and Services/TeamsLauncher.cs are
copied verbatim from src/TeamsISO.App/Services/ with only the
namespace adjusted (TeamsISO.App.Services → TeamsISO.App.WinUI.
Services). Neither file has WPF-specific dependencies — they use
System.Windows.Automation (UIAutomationClient) which works
identically across WPF and WinUI 3 builds. Duplication is
acceptable migration debt; the long-term plan is to lift these
into a shared TeamsISO.App.Shared library once both hosts
stabilize.
* DescribeBridgeResult maps the InvokeResult enum to operator-tone
status text so a failing mute reads "Mute failed — control not
visible (not in a call?)" instead of an opaque "ControlNotFound".
The in-call bar now does what the WPF host's in-call bar does, minus
the MUTED / CAM OFF state pills (those would need a 1Hz UIA poll of
the Teams call state — wire-up to come).
The WinUI 3 host now stands up the full engine pipeline on launch and
discovers participants from the operator's live Teams meeting. Verified
end-to-end against a real call: window opened, NDI runtime preflight
passed, IsoController spun up, and Participants observable yielded
three live entries ((Local), Active Speaker, Brendon Power) with their
TEAMSISO_<name> output names in the redesigned shell.
What this commit lands:
ViewModels/ (slim ports of the WPF host's view-models — engine layer
shared verbatim via ProjectReference):
* ObservableObject.cs — INPC base, mirrors the WPF version
* RelayCommand.cs — sync + typed + async variants; ICommand is the
same shared type across both hosts (System.ObjectModel.dll)
* ParticipantViewModel.cs — DisplayName / Initials / SourceCodec /
IsoStateLabel / DisplayedAudioLevel / IsActiveSpeaker / IsOnline /
OutputName + ToggleIsoCommand. Drops the WPF-specific thumbnail
WriteableBitmap path, clipboard, PreviewWindow, snapshot encoder —
those come back when the WinUI imaging pipeline is wired (Phase 5
of the migration plan).
* MainViewModel.cs — subscribes to IIsoController.Participants on a
DispatcherQueueSynchronizationContext, owns the ObservableCollection,
runs a 1Hz DispatcherQueueTimer for stats + active-speaker
highlight + session-elapsed text. Commands: EnableAllOnline,
StopAllIsos, RefreshDiscovery, DropRecordingMarker, ToggleByIndex.
App.xaml.cs:
* OnLaunched brings up MainWindow first, then fires WireEngineAsync
so the user sees the shell immediately while NDI preflight + engine
setup proceed.
* Full pipeline: EngineLogging → NdiInteropPInvoke (with friendly
fallback message if the NDI runtime isn't installed) → ConfigStore
at %APPDATA%\TeamsISO\config.json → NdiRuntimeProbe + scaler →
IsoPipeline factory → IsoController → MainViewModel →
MainWindow.AttachViewModel → IsoController.StartAsync.
* Logger writes to the same %LOCALAPPDATA%\TeamsISO\Logs as the WPF
host so a mixed-host operator sees a single timeline.
MainWindow.xaml + .xaml.cs:
* x:Name on the section header buttons (RefreshButton, StopAllButton,
EnableAllButton, MarkerButton) and on the status bar text + the
ParticipantsHost grid.
* AttachViewModel wires those buttons to view-model commands; pushes
StatusText + ParticipantCountText through PropertyChanged.
* BuildSimpleRow imperatively constructs each row (Grid with name +
codec + output + ISO toggle pill) instead of going through a
DataTemplate. Rationale: declaring a DataTemplate in
Grid.Resources OR loading one via XamlReader.Load both crash WinUI
3's XAML parser at runtime on this build host (same HR=0x802b000a
we saw with the SettingsDrawer NavigationView). Imperative
construction sidesteps the parser. The rich row template (avatar
circle, audio meter, active-speaker accent) returns in Phase 5
alongside the CommunityToolkit DataGrid swap.
* Per-row PropertyChanged subscriptions refresh DisplayName, codec,
output name, and ISO pill text as the engine pushes updates.
Verified live (PID 4824, 2026-05-13 08:02): three real Teams
participants appeared in the redesigned shell within seconds of launch,
status bar populated with "3 participants · 0 routing", section
header showed "Participants 3", and the title-bar live pills rendered
in their proper places (placeholder values for now; binding to
SessionElapsed / IsRecording lands in the next commit).
Replaces the NavigationView in SettingsDrawer with a simpler
StackPanel of tab buttons built imperatively at runtime. The
NavigationView's resource-dictionary expansion (or its default
template loading) was crashing the XAML parser at SettingsDrawer's
InitializeComponent on WinUI 3 1.8.
New shape:
- `TabStrip` StackPanel populated in BuildTabStrip() with five
Tertiary-styled Button instances. Selection updates the foreground
to AccentCyanText for the active tab and FgSecondary for the rest.
- `TabContent` ScrollViewer remains; RebuildTabContent(key) clears
and rebuilds via the same helpers as before (SettingHeader,
SettingRow, SettingNote, AccentSwatch).
- Each tab's content moved into its own helper method
(BuildAppearanceTab / Routing / Display / Control / Advanced) so
the switch in the old OnTabSelectionChanged disappears.
MainWindow re-hosts the drawer at Grid.Row=0, RowSpan=4, right-
aligned, 400px wide, Visibility=Collapsed. OnSettingsClick toggles
visibility. Verified: dotnet build + run launches cleanly, and the
window stays alive (PID confirmed via Get-Process).
This closes Phase 6 (secondary windows) for the drawer specifically.
The Help, About, and Onboarding dialogs are ContentDialogs that
don't host inline so they should be straightforward to wire to
their respective triggers (F1 / About button / first launch) in
Phase 7.
Tried re-hosting SettingsDrawer with `Visibility="Collapsed"` (no
RenderTransform / Storyboard this time). Still crashes the XAML parser
at startup with the same HR 0x802b000a.
Narrows the suspect: the crash is inside SettingsDrawer.xaml's
InitializeComponent, not in MainWindow.xaml's hosting of it. Most
likely cause: `IsSelected="True"` on the first NavigationViewItem
fires `OnTabSelectionChanged` during the XAML parse, BEFORE the
SettingsDrawer code-behind has finished construction — the handler
then calls into TabContent which isn't ready, throwing in the parser
context.
Two fixes to try next session:
1. Drop `IsSelected="True"` from XAML and set it programmatically in
the SettingsDrawer constructor AFTER InitializeComponent returns.
2. Verify the OnTabSelectionChanged signature for WinUI 3 1.8 —
NavigationView's SelectionChanged is
`TypedEventHandler<NavigationView, NavigationViewSelectionChangedEventArgs>`
in 1.8 (might be different from the 1.6 SDK signature I wrote
against).
For now, the MainWindow's OnSettingsClick is a no-op stub. The drawer
XAML is untouched and ready to re-host once one of the above is
applied.
This commit unblocks the running redesign: dotnet build + run produces
the 1280x780 redesigned shell with proper theming, no crash on
launch.
Two screenshots captured from the live TeamsISO.App.WinUI .exe at
1280×780, one per theme. Both prove the redesign renders end-to-end
on Windows 11 with WindowsAppSDK 1.8 and no view-model wiring yet:
* docs/preview/winui3-mainwindow-light.png — App.Current.RequestedTheme
set to Light via ThemeManager. Wild Dragon "W" mark renders as cyan
(#0E7C82) on cyan-muted (#E6F8F9) tile per the light-mode accent
split from DESIGN.md. All other rail icons render at FgSecondary
(#4A4B50) for AA contrast.
* docs/preview/winui3-mainwindow-dark.png — same render, dark theme.
Wild Dragon mark uses the airy #97EDF0 cyan on the deeper
cyan-muted (#1B3537) tile. Rail icons + section text at FgPrimary
(#F4F4F6).
ThemeManager default reverted to "System" (the screenshot for dark
mode was taken with the default temporarily set to "Dark", then
reverted before this commit). The light-mode screenshot is what runs
when the OS app-mode is light, which is what happened on this build
host tonight.
These are the artifacts to point at when stakeholders ask "what does
the redesign look like in practice?" — they are the WinUI 3 .exe, not
the HTML preview.
Removing the inline-hosted SettingsDrawer (and its accompanying
Storyboard resources targeting TranslateTransform.X) unblocks the
launch. The WinUI 3 host now opens, paints, and stays alive. Verified
via screenshot:
* 64px left rail with Wild Dragon "W" brand mark + participants /
Teams / hide-Teams / settings / engine-status puck buttons (Segoe
Fluent Icons throughout, uniform stroke)
* 44px custom title bar with the live pills inline (live · session
timer · REC count · disk free) and a theme toggle to the left of
the system min/max/close
* Section header: "Participants 4" + filter input + Refresh +
Presets + the single cyan Primary CTA "Enable all online"
* Participants list placeholder ("View-model wiring queued for the
next session") in the hero row — real DataGrid + bindings land in
Phase 4/5 of the migration plan
* Conditional in-call control bar: Muted (destructive coral) +
Camera/Share/Marker (Secondary) + Leave (destructive coral) +
overflow kebab
* Slim status bar: control-surface URL + keyboard shortcut hints
* Rendered in LIGHT THEME on first run (matched the OS app-mode
setting via ThemeManager.ResolveTheme), confirming the
ThemeDictionary swap works end-to-end
Two open suspects causing the SettingsDrawer host to crash WinUI 3's
XAML parser with HR=0x802b000a (XAML_E_PARSER_GENERAL_ERROR):
* RenderTransform with a x:Name'd TranslateTransform — WinUI 3
might not allow naming transforms inside RenderTransform the way
WPF does
* Storyboard.TargetName pointing at the named transform — WinUI 3
Storyboards have stricter resolution
The drawer XAML itself (Views/SettingsDrawer.xaml + .cs) is unchanged
and ships alongside this commit. Re-host it in MainWindow.xaml once
the parse error is triaged (likely fix: replace TranslateTransform.X
animation with the AppWindow composition API or use the
CompositionTarget approach instead of a Storyboard).
The migration plan's Phase 3 is now substantially CLOSED — the
WindowsAppSDK activation blocker is resolved (1.8 DDLM swap). Next
session opens with Phase 4 (view-model wiring) plus the SettingsDrawer
re-host triage.
Two big findings from a custom MddBootstrapInitialize2 P/Invoke probe
this session:
1. The original WinUI 3 activation failure ("this application could not
be started") was MDD_E_BOOTSTRAP_INITIALIZE_DDLM_NOT_FOUND (HR
0x80670016). The framework package Microsoft.WindowsAppRuntime.1.6
was installed, but the Dynamic Dependency Lifetime Manager sibling
(MicrosoftCorporationII.WinAppRuntime.Main.1.6) wasn't. This machine
has Main.1.5 and Main.1.8 packages but no Main.1.6, so bootstrap for
1.6 fails.
2. Switching the WindowsAppSDK NuGet to 1.8.250916003 + the bootstrap
major.minor to 0x00010008 in Program.cs gets past activation. The
.exe now launches and Bootstrap.TryInitialize returns S_OK. The 1.8
DDLM is present and the runtime spins up.
Also lands `src/TeamsISO.App.WinUI.Probe/`, a tiny console diagnostic
that calls MddBootstrapInitialize2 directly via P/Invoke (bypassing the
full WindowsAppSDK NuGet to avoid the MRT/PRI MSBuild tasks that need
VS's AppxPackage tooling installed). The probe prints the HResult and a
human-readable description; use it to triage WindowsAppSDK activation
on any deployment target:
dotnet run --project src/TeamsISO.App.WinUI.Probe
A SECOND ISSUE surfaces after activation: the .exe crashes 1 second
after launch with 0xC000027B inside Microsoft.UI.Xaml.dll, sub-code
0x802b000a (XAML_E_PARSER_GENERAL_ERROR). The participants ItemsRepeater
with {Binding ...} markup is suspect (WinUI 3 prefers x:Bind with
x:DataType, and Visibility="{Binding bool}" needs a converter). The
ItemsRepeater is stubbed out to a plain "Participants list renders here"
TextBlock placeholder for now; same crash recurs, so the XAML issue is
elsewhere — likely in Controls.xaml (one of CharacterSpacing /
TextCaption / etc. unsupported), in App.xaml's MergedDictionary chain,
or in MainWindow.xaml's Storyboard target.
Triaging the XAML parse error is the next session's first action. The
sub-code 0x802b000a will help (search WindowsAppSDK source for the
matching XAML parser error). The migration plan in
docs/superpowers/plans/2026-05-12-winui3-migration.md is updated.
Build remains clean.
Adds a StripWindowsDesktopAppFromRuntimeConfig MSBuild target that
runs after GenerateBuildRuntimeConfigurationFiles and rewrites
TeamsISO.runtimeconfig.json to drop the implicit
Microsoft.WindowsDesktop.App framework reference. The .NET 8 SDK adds
that framework for any -windows TFM, but WinUI 3 doesn't use it and
including it in the runtimeconfig contributes to the broken
unpackaged activation path (one of the three suspects in the migration
plan's Phase 3).
Build log confirms the strip on every build:
Stripped Microsoft.WindowsDesktop.App from .../TeamsISO.runtimeconfig.json
Verified the runtimeconfig now reads:
{ "name": "Microsoft.NETCore.App", "version": "8.0.0" }
(no WindowsDesktop.App entry)
This didn't resolve the activation dialog on its own, but it's a
required step for any unpackaged WinUI 3 build and the next debugging
session can rule it out as a contributing cause.
Hosts SettingsDrawer in the main content grid as a fixed-width 400px
panel positioned at the right edge, with a TranslateTransform pre-set
to X=400 so it starts off-screen. The rail's settings icon triggers a
220ms ease-out-quart slide-in storyboard; CloseRequested (or the
drawer's Esc/close button) triggers a 180ms ease-in slide-out.
IsHitTestVisible toggles in sync so the off-screen drawer doesn't
intercept clicks on the participants list.
This is the structural commitment from DESIGN.md: settings live in a
right-drawer (not a permanent 380px panel), the participants table
reclaims full width when settings aren't being edited.
Builds clean.
Followup: tab content currently rebuilds imperatively in code-behind;
wire it to a SettingsViewModel mirror of the WPF host's
GlobalSettingsViewModel once the view-model migration starts (Phase 4
of the migration plan).
Adds <WindowsAppSdkUndockedRegFreeWinRTInitialize>false</...> with a
comment chain that traces the runtime activation failure investigation
to the next maintainer:
1. WindowsAppSDK's UndockedRegFreeWinRTCommon.targets only auto-enables
the ModuleInitializer when WindowsAppSDKSelfContained=true.
2. Without it, framework-dependent unpackaged builds need our own
explicit Bootstrap.TryInitialize call (Program.cs already does this).
3. WITH it, the bundled auto-init P/Invokes Microsoft.WindowsAppRuntime.dll
during module load — but the runtime DLL lives in the framework MSIX
package, not the output dir, and Bootstrap hasn't yet added the
framework dir to the DLL search path. The P/Invoke fails and the
.exe dies before Main runs.
Setting the property to false explicitly suppresses the early P/Invoke
so our Program.Main + Bootstrap.TryInitialize can sequence correctly.
This didn't fix activation on this build host though — the .exe still
shows "this application could not be started." Strong suspicion: the
managed assembly references Microsoft.WinUI.dll which itself has
DllImport-style dependencies the .NET host probes during assembly load.
Recommended next steps (not done overnight to avoid further blind
swings): attach a debugger to TeamsISO.exe before Main runs (windbg
sxe ld for the runtime DLL, or VS 'Just My Code: off' attach), capture
the CLR fusion log, or try a known-good Microsoft WinUI 3 template
side-by-side to isolate whether the issue is project or machine.
Build remains clean. WPF host unaffected.
Builds out the secondary surfaces of the redesigned WinUI 3 host.
ThemeManager (Services/ThemeManager.cs)
Single-source-of-truth for the active theme. Holds the user preference
(System / Dark / Light), resolves it to ElementTheme at request, and
raises a Themed event when it changes so the MainWindow can push the
AppWindow title-bar button colors. Uses Windows.UI.ViewManagement
UISettings to follow the OS app-mode when preference is System.
Persistence to UIPreferences lands in the engine-wiring commit.
MainWindow theme wiring
Replaces the per-handler theme toggle with a ThemeManager subscription:
click the title-bar sun/moon -> Toggle() -> Themed event ->
ApplyResolvedTheme on the visual tree + the title-bar buttons. Glyph
cue: sun = "current is Light, click to Dark"; moon = "current is Dark,
click to Light." Initial state applied at construction so the first
frame matches the preference.
SettingsDrawer (Views/SettingsDrawer.xaml + .cs)
UserControl that slides in from the right over the participants table.
56px header, NavigationView with five tabs (Appearance, Routing,
Display, Control, Advanced), footer with Reset-to-defaults +
Apply/Close. Appearance tab has the theme tri-state picker (System /
Dark / Light radio group) and an "Accent peek" row showing the four
brand accents (cyan / coral / live / warn) as swatches so the
operator can verify Wild Dragon brand is respected on a light desk.
CloseRequested event signals the host to collapse the drawer.
HelpDialog (Views/HelpDialog.xaml + .cs)
ContentDialog with the keyboard shortcut cheat sheet, grouped by
category (Global / Participants / Look / Control surface). 540px max
height with scroll, mono-spaced shortcut labels at left, body text at
right. Replaces the WPF host's HelpWindow at parity.
AboutDialog (Views/AboutDialog.xaml + .cs)
ContentDialog with the Wild Dragon mark, version + host + engine +
brand info as label/value rows, and three quick action buttons
(open logs folder, open recordings, check for updates). Mirrors the
WPF host's AboutWindow.
OnboardingDialog (Views/OnboardingDialog.xaml + .cs)
Three numbered steps (Install NDI Runtime / Enable Teams NDI / Pick
transcoder topology), no carousel, operator-tone copy ("Don't show
this again" defaults checked). PrimaryButtonText "Get started",
SecondaryButtonText "Skip" so the dialog is skippable from the first
frame as the PRODUCT.md anti-references demand.
Build clean: dotnet build TeamsISO.App.WinUI -c Debug -> 0 / 0.
Next: wire the drawer's CloseRequested into MainWindow (so the settings
icon actually opens / collapses the drawer), then attack the runtime
activation blocker (Phase 3 of the migration plan).
Locks RuntimeIdentifier=win-x64 so MSBuild flattens the WindowsAppSDK
native runtime files (Microsoft.WindowsAppRuntime.Bootstrap.dll +
WebView2Loader.dll, both in runtimes/win-x64/native/) directly alongside
TeamsISO.exe at build time, instead of leaving them in a runtimes/
subfolder where the loader can't find them at activation.
Also defers app.manifest from build pending the bootstrapper hardening
follow-up. WinUI 3 emits its own manifest with the DPI awareness +
supportedOS GUIDs that match what we want; reintroducing ours alongside
that needs a uap:VisualElements merge.
Build is clean. Activation still blocked on a separate WinUI 3
unpackaged-launch issue that doesn't reach Main(); diagnostics
captured in COREHOST_TRACE=1 confirm .NET host loads correctly through
CoreCLR.dll, the failure is downstream in the WinUI / WindowsAppSDK
activation path.
The WPF host remains the running build until the activation issue is
resolved.
Lands the approved shape brief as the WinUI 3 MainWindow:
* 64px left rail with brand mark, primary nav (participants), Teams
launch / hide / settings buttons, and the engine-status puck at the
bottom. All five rail buttons use Segoe Fluent Icons glyphs at a
uniform 20px optical size; no more bespoke <Path Data> shapes with
inconsistent stroke weights.
* 44px custom title bar via ExtendsContentIntoTitleBar +
SetTitleBar(AppTitleBar). The drag region absorbs the three live-state
pills inline (session timer 'live * 00:14:32', REC count + elapsed,
disk free) and a slim sun/moon theme-toggle button to the left of the
system Min/Max/Close controls. System buttons inherit ButtonForeground
Color etc. from AppWindow.TitleBar so they match palette in both
themes.
* Section header with 'Participants * count' display, filter input,
Refresh + Presets (Secondary buttons), and 'Enable all online' as
the single cyan Primary button - finally a real button hierarchy
instead of seven indistinguishable ghost buttons.
* Participants list rendered as ItemsRepeater + DataTemplate for now;
the CommunityToolkit DataGrid migration follows in a separate commit.
Row template at 64px height with: 3px cyan left border for active
speaker, avatar with initials in cyan-muted circle, name + codec line,
signal lock state with dot, audio meter via ProgressBar, output name
in JetBrains Mono, ISO state pill (LIVE/OFF/ERROR) at right.
* Conditional in-call control bar below the table: Mute / Camera /
Share / Marker / Leave + overflow kebab. Muted state binds the
destructive coral treatment to the Mute button; Leave is also
destructive (coral border + text); everything else is Secondary.
Tight 8px spacing keeps the bar dense without crowding.
* Slim 32px status bar at the bottom: control-surface URL on the left
(cyan dot indicator), keyboard-shortcut hints on the right in
tertiary mono. Replaces the WPF host's six-column footer.
Implementation notes:
* MockParticipant model populates the table with representative data
(Maya / Daniel / Aicha / Sam, one as active speaker) until the
ParticipantViewModel binding migrates over from the WPF host.
* Custom Program.cs takes ownership of Main from the XAML compiler
(DISABLE_XAML_GENERATED_MAIN). Calls Bootstrap.TryInitialize(0x00010006)
before Application.Start so the unpackaged .exe can locate the
WindowsAppSDK 1.6 framework MSIX at launch. Shutdown is paired in
a finally block.
* Theme toggle in code-behind flips Window.Content.RequestedTheme
between Dark and Light. {ThemeResource} bindings auto-swap across
the visual tree; system title-bar buttons (outside the XAML tree)
get color updates inline so they stay readable in both modes.
* app.manifest deferred from build - the framework-emitted manifest
covers DPI awareness and supportedOS GUIDs; reintroducing our own
goes in the next commit alongside the bootstrapper hardening.
Known issue: the unpackaged .exe currently fails to activate on this
build host with 'this application could not be started' before Main
runs. Build is clean; published output runs the same way. Diagnosing
the activation failure is the next session's first task (likely the
runtimeconfig.json including Microsoft.WindowsDesktop.App which WinUI 3
doesn't want, or a missing CRT redistributable). The WPF host remains
the running build until that's resolved.
dotnet build TeamsISO.Windows.slnf -c Debug: 0 warnings, 0 errors.
First step of the WinUI 3 replatform per the approved redesign brief.
The new project coexists with the existing src/TeamsISO.App (WPF) so the
WPF host keeps building and shipping while the WinUI 3 redesign lands
incrementally. Once the WinUI 3 build is feature-complete and tested
against a real Teams meeting, the WPF project is retired.
Scaffold contents:
* src/TeamsISO.App.WinUI/TeamsISO.App.WinUI.csproj
Windows App SDK 1.6 LTS (250602001), unpackaged mode
(WindowsPackageType=None) so the existing MSI installer keeps working.
Target framework net8.0-windows10.0.19041.0, min platform 10.0.17763.0
to preserve Win10 1809+ compatibility for working broadcast hardware.
Pins WindowsSdkPackageVersion=10.0.19041.38 so .NET SDK 8.0.301 builds
cleanly without an SDK upgrade on the build host.
* src/TeamsISO.App.WinUI/app.manifest
PerMonitorV2 DPI awareness + gdiScaling for crisp text on high-DPI
broadcast monitors. asInvoker trust level (control surface :9755 and
OSC :9000 bind to 127.0.0.1, no admin needed).
* App.xaml + App.xaml.cs
Minimal startup: brings up MainWindow. The full pipeline (NDI runtime
preflight, IsoController wiring, single-instance mutex, REST + OSC
bridge, tray icon, crash diagnostics, auto-update banner, onboarding)
migrates in subsequent commits.
* Themes/Tokens.xaml
Wild Dragon design tokens as ThemeDictionary entries (Default = Dark,
Light). Colors as Color resources, Brushes paired per theme so
{ThemeResource} auto-swaps when RequestedTheme flips — no app restart,
no flicker. Spacing/radii/typography tokens are theme-agnostic at the
outer level. Light palette maintains brand recognition via cyan-tinted
off-whites (#FAFAFB canvas, #F0F1F3 rail) rather than pure white, and
splits cyan into accent.cyan.surface (#97EDF0, works in both modes
because text on top is near-black) and accent.cyan.text (#97EDF0 dark
/ #0E7C82 light) so captions and inline labels keep AA contrast.
* Themes/Controls.xaml
Button hierarchy with real commitments: Primary (cyan fill, one per
surface), Secondary (transparent bordered), Tertiary (text only),
Destructive (coral border + text), Caption (titlebar), RailIcon.
Typographic ramp (Display / Title / Heading / Body / Subtle / Caption
/ Mono) at the DESIGN.md 1.25 ratio.
* CommunityToolkit.WinUI.UI.Controls.DataGrid 7.1.2 referenced for the
participants table migration. (Toolkit 8.x dropped DataGrid; 7.x is
the only currently-maintained free option for WinUI 3.)
* Inter.ttf + JetBrainsMono.ttf + dragon-mark.png + teamsiso.ico copied
from the WPF project's Assets/ so the WinUI 3 host is self-contained.
* TeamsISO.sln + TeamsISO.Windows.slnf updated to include the new
project. The .slnf paths switch to backslash form so MSBuild can match
them against the .sln's canonical path representation.
Verified: dotnet build TeamsISO.Windows.slnf -c Debug succeeds with 0
warnings and 0 errors for all 8 projects (WPF host, WinUI 3 host, engine,
NDI interop, console, three test projects).
Visual cue for who's currently speaking — operators don't need to watch every VU bar. MainViewModel.OnStatsTick scans enabled participants once per tick, picks the loudest above a 0.05 floor (anti-flicker threshold), sets IsActiveSpeaker on the winner and clears on everyone else. DataGridRow DataTrigger swaps in a 3px cyan-accent left border + CyanMuted background tint when IsActiveSpeaker is true.
Plays well with sort modes: LoudestFirst makes the highlighted row always the topmost; other sort modes leave the row position alone, just paints the indicator.
Fast keyboard-driven ISO routing for operators with one hand on the keyboard during a show. Both NumPad1..9 and top-row 1..9 bind to ToggleByIndexCommand which resolves against the filtered+sorted ParticipantsView — index matches what's on screen, not the underlying storage order.
Press a digit again to toggle off. Plays nice with sort modes: LoudestFirst means '1' is always whoever's loudest right now; Alphabetical lets you build muscle memory for recurring guests.
Implementation:
- New generic RelayCommand<T> in RelayCommand.cs so XAML CommandParameter strings convert to the action's T (int / string / etc.).
- ToggleByIndexCommand on MainViewModel iterates ParticipantsView, finds the Nth ParticipantViewModel, fires its ToggleIsoCommand if CanExecute.
- 18 KeyBindings (9 NumPad + 9 D1-D9) in MainWindow.xaml's Window.InputBindings.
- F1 help cheat sheet updated to mention the new range.
Header button 'Snapshot all' fires SnapshotAllCommand which iterates every enabled participant, grabs the latest ProcessedFrame, encodes as PNG into a fresh timestamped subfolder under %USERPROFILE%\\Pictures\\TeamsISO\\snapshots-yyyyMMdd_HHmmss\\. One folder per click so back-to-back snapshot sessions don't comingle.
Reuses the per-participant snapshot path established earlier — same WriteableBitmap(Bgra32) → PngBitmapEncoder pipeline. Reports saved + failed counts in the toast so the operator knows if anything was missed (typical failure: pipeline still warming up, no frame yet).
Adds a fourth participant sort mode: LoudestFirst, sorts by DisplayedAudioLevel descending so the current active speaker bubbles to the top of the DataGrid. Operators reacting to who's talking can see the active speaker without scanning the list.
Refresh-on-tick (1Hz) only fires when LoudestFirst is active — other sort modes don't change keys every tick so they skip the cost. ParticipantViewModel.DisplayedAudioLevel already has a decay envelope (max-of-new-or-decayed-old at 0.7 per tick), which prevents jittery reorder on every audio frame.
Persisted via the existing UIPreferences.ParticipantSort enum (new value tacked onto the end so older ui-prefs.json files default to JoinOrder cleanly).
Operators recording long shows previously had to open File Explorer to check disk pressure. New '· 245 GB free' indicator next to the REC badge polls DriveInfo on the recording drive at the existing 1Hz stats tick. Coral tint kicks in below 10GB; existing DiskSpaceWatcher still auto-disables recording at 1GB as a hard safety net.
FormatBytes helper produces footer-readable strings: '1.2 TB' / '245 GB' (no decimal for 100+ GB to avoid clutter) / '8.4 GB' (decimal for the low-warning case) / '450 MB'.
Polling is wrapped in try/catch — network paths occasionally throw, and disk-space display is a comfort feature, not a critical signal.
Operators with auto-hide Teams couldn't tell if they were muted or had their camera off — needed to restore Teams just to check. New coral pills in the IN-CALL bar surface the local-user state, populated from a single UIA traversal that also drives the IN-CALL pill (so the cost stays at one walk per stats tick, not three).
Detection: TeamsControlBridge.DetectCallState returns a CallStateSnapshot with IsInCall + IsMuted + IsCameraOff. The Mute and Camera buttons toggle their UIA Name between 'Mute'/'Unmute' and 'Turn camera off'/'Turn camera on' depending on state; check the more-specific candidate (unmute / turn camera on) first to avoid false positives from substring matching.
Localized for EN / DE / ES / FR / PT / JA — same locale list the candidate-name arrays already cover. Pills visible only when both in-call AND the corresponding state is true; once you unmute, the pill vanishes within ~1s (next stats tick).
Reparents Teams' main top-level window into a TeamsISO-owned host via Win32 SetParent + window-style stripping. Operator gets Teams visually INSIDE TeamsISO instead of as a separate window — completes the 'Teams runs within this app' direction the user asked for after auto-hide.
Strictly opt-in (DISPLAY tab → 'Embed Teams window (experimental)'). Modern Teams runs WebView2 in its main window; WebView2 is sensitive to parent changes and may render glitches or refuse focus. If so, operator unticks and falls back to auto-hide mode.
Implementation:
- TeamsLauncher.EmbedTeamsInto(hostHwnd, w, h): finds Teams' main window (longest-title heuristic — same as GetActiveWindowTitle), saves original parent + WS_STYLE, SetParents into host, strips WS_CAPTION + WS_THICKFRAME + WS_BORDER + WS_DLGFRAME + WS_POPUP, adds WS_CHILD, MoveWindow to fit.
- TeamsLauncher.RestoreEmbed(): SetParent back to desktop + restore saved window styles. Idempotent — safe to call on shutdown even if nothing was embedded.
- TeamsLauncher.ResizeEmbedded(w, h): MoveWindow to new dimensions; called from host SizeChanged event.
- New TeamsEmbedWindow chromeless host with an EXPERIMENTAL pill in the caption. Loaded → grab HwndSource from EmbedHost Border → call EmbedTeamsInto. SizeChanged → ResizeEmbedded. Closed → RestoreEmbed (in try/finally so a crash can't leave Teams orphaned). Friendly fallback messages if no Teams window exists or HWND grab fails.
- Settings → DISPLAY → checkbox + 'Open embed window' button (gated by the checkbox). Persisted via EmbedTeamsWindow on UIPreferences.
New AutoRecordOnCall preference (DISPLAY tab). When checked, recording auto-flips ON the moment Teams transitions into a call (UIA Leave button appears in tree), and auto-flips OFF when the call ends.
Completes the unattended-show story: with Launch + AutoHide + AutoRecord all ticked, the operator launches TeamsISO and walks away — Teams runs invisibly, recording begins/ends with the meeting, ISOs route, all done. Toast surfaces each transition so they know what's happening if they glance at the screen.
Implementation: transition detection lives in the existing UIA-probe code in OnStatsTick. previousInCall != inCall gate prevents the auto-toggle from re-firing on every poll. Direct call to _controller.SetRecording + Settings.RecordIsosToDisk = ... so the existing recording infrastructure handles the rest. Toast for visibility, swallow-on-error so a recording config issue can't break the IN-CALL pill update path.
New context-menu action grabs the latest ProcessedFrame from IIsoController.GetLatestProcessedFrame and encodes it as a PNG under %USERPROFILE%\\Pictures\\TeamsISO\\. Filename includes participant display name + timestamp so back-to-back snapshots don't collide.
Encoding path: WriteableBitmap(Bgra32) wraps the frame's pixel buffer verbatim (engine output is already top-down BGRA32), PngBitmapEncoder writes it. No re-encoding losses. Toast tells the operator where the file landed.
Best-effort: if no frame is available yet (just-spun-up pipeline), warns rather than throws. Useful for highlight reels, social posts, attaching to bug reports.
ParticipantViewModel gained an optional ToastViewModel constructor parameter so snapshot feedback surfaces in the existing toast. Wiring updated at the one call site in MainViewModel.
Three small UX wins:
1. Onboarding gained step 5 ('Run Teams headless') and step 6 ('Drive from another machine') so new operators discover the auto-launch/auto-hide + LAN-reachable workflows. Existing 'where things live' step renumbered to 7.
2. Settings → DISPLAY → Control surface URL row gains an Open button next to Copy that fires the URL into the default browser via Process.Start with UseShellExecute. Operators previewing how the embedded /ui control panel looks on a phone/tablet no longer need to copy-paste manually.
3. Recording badge in footer now shows 'REC 3 · 12:45' instead of just 'REC 3'. RecordingElapsed VM property maintains a separate timer from the session timer because recording can start AFTER the meeting begins; operators tracking 'how long has the archive copy been rolling' need that distinct duration.
The IN-CALL pill now reads 'IN CALL · Weekly Standup' (or 'IN CALL' if Teams' window doesn't expose a meeting title), so operators using auto-hide know WHICH meeting they're in without restoring the Teams window.
Implementation: TeamsLauncher.GetActiveWindowTitle uses EnumWindows + GetWindowTextW to read every Teams top-level window title (hidden windows too — title bar text is accessible even with SW_HIDE), picks the longest as a heuristic for 'most informative' (Teams creates several windows per process; the call window has the meaningful title). MainViewModel.ExtractMeetingTitle strips the ' | Microsoft Teams' / ' - Microsoft Teams' suffix variations and clamps overly long titles to 50 chars with an ellipsis.
10 new unit tests for ExtractMeetingTitle covering: standard formats with both separators, bare 'Microsoft Teams' (returns empty so the pill stays at 'IN CALL'), long-title truncation, outer-whitespace trimming, unrecognized formats passing through.
169/169 tests passing.
Adds a small URL input + Join button to the IN-CALL bar. Operators paste a https://teams.microsoft.com/l/meetup-join/... or msteams:/l/meetup-join/... link, click Join, and Teams launches into the meeting in one shot. Eliminates the open-Teams → Calendar → find meeting → click join dance — operators get meeting links from email/Outlook and can now join straight from TeamsISO.
TeamsLauncher.TryJoinMeeting validates the URL targets Teams (only http(s) URLs containing teams.microsoft.com / teams.live.com, or msteams: deep-links — won't shell-exec arbitrary clipboard contents). On success, integrates with AutoHideTeamsWindows so the Teams meeting window briefly appears then vanishes; operator is in the call, driving routing from TeamsISO.
VM-side: MainViewModel.JoinMeetingCommand + JoinMeetingUrl two-way bound. Field clears on success; warn-toast on failure with the specific reason (empty / not-a-teams-url / launch-failed).
Operators using auto-hide Teams couldn't tell whether they were in a meeting without restoring the Teams window. New status pill in the IN-CALL bar header shows:
• empty when Teams isn't running
• 'READY' (gray dot) when Teams is running but not in a call
• 'IN CALL' (cyan dot) when Teams is in an active meeting
Detection: TeamsControlBridge.IsInCall() walks Teams' UIA tree looking for the Leave / Hang-up button. Present iff in a call — works across Teams versions because Teams only exposes the Leave control while a call is active. Same candidate-name list the LeaveCall command uses, with localized strings for EN/DE/ES/FR/PT/JA already in place.
Polled at the existing 1Hz stats tick. UIA traversal can take 50-200ms in a busy call, so the probe runs off-thread; the property update is dispatched back via _dispatcher.InvokeAsync. Failure paths swallow exceptions — a flaky UIA call must never crash the stats timer.
159/159 tests passing, 0 warnings, 0 errors.
Added Vortice.MediaFoundation 3.6.2 NuGet package to TeamsISO.Engine so the scaffold compiles when MF_AVAILABLE is defined. However: the scaffold (May 9) was written against an older Vortice surface and the 3.6.2 API has materially changed:
- MFVersion not on MediaFactory, MF_LOW_LATENCY moved
- IMFAttributes.SetUINT32 replaced with generic Set
- IMFMediaType.MajorType / SubType / AvgBitrate property setters → SetGUID(MFAttributeKeys.MajorType, ...) etc.
- VideoFormatGuids.RGB32 renamed (likely Rgb32)
- IMFMediaBuffer.Lock signature changed (explicit out IntPtr / out int / out int)
- IMFSinkWriter.Finalize_ renamed
Leaving MF_AVAILABLE undefined for now so the build stays clean. NuGet ref stays so a porter doesn't need to re-add. docs/REAL-TIME-RECORDING.md updated with the deferred status + the specific API gaps to port.
Two new persisted preferences in DISPLAY settings, paired to give operators the 'launch TeamsISO, never see Teams' experience the user asked for:
- LaunchTeamsOnStartup: TeamsISO auto-starts Teams in the background each launch (fire-and-forget background task in App.OnStartup, after the main window has materialized so a slow Teams launch doesn't delay the UI).
- AutoHideTeamsWindows: as soon as Teams' windows materialize after launch, hide them. New TeamsLauncher.AutoHideAfterLaunchAsync runs a polling loop (250ms / up to 15s) that catches the splash, main window, and any follow-up panels Teams opens. Teams takes 2-5s to render its main window and the splash arrives separately, so a one-shot hide right after launch wouldn't be enough.
When TeamsISO starts and Teams is already running (from a prior session), the auto-hide path still fires so the 'I only see TeamsISO' rule applies even when Teams was launched externally.
Operator drives everything through the IN-CALL bar (mute / camera / share / leave / marker) + participants DataGrid (ISO routing). Eye-toggle in the rail still restores Teams windows on demand.
Both toggles default to off — opt-in. Persisted via UIPreferences so they survive process restart.
Two user-reported bugs:
1) CheckBox content was clipping in the 380px settings panel ('Control surface (Stream Deck / Companion / w...' / 'LAN-reachable (allow other machines on yo...'). The Wd.CheckBox template used a horizontal StackPanel which doesn't bound child width, so long Content strings ran off the column without wrapping. Replaced StackPanel with a Grid (Auto + *) and injected a TextBlock style with TextWrapping=Wrap into the ContentPresenter resources — when WPF auto-wraps a string Content in a TextBlock, the resource lookup gives it Wrap.
2) The rail Launch Teams button ambushed operators: clicking with Teams already running (which is common when the eye-toggle has hidden Teams' windows) opened a 'Close all Teams windows now?' dialog. Operators expect Launch to mean 'show me Teams', not 'stop Teams'. Split the actions:
- Left-click: Teams not running → launch; Teams hidden → restore + foreground; Teams visible → bring to front. Always idempotent-progressive.
- Right-click: ask to stop Teams (preserves the kill path for those who want it).
TeamsLauncher.TryLaunch now collects per-attempt errors instead of swallowing them — a real failure surfaces 'ms-teams: URI → <reason>' / 'AppsFolder shell → <reason>' / 'classic Update.exe → not found at <path>' so 'No Teams found' isn't a black box.
Also added a 2nd path: explorer.exe shell:appsFolder\\\\MSTeams_8wekyb3d8bbwe!MSTeams (AppX activation via the OS's own Start-menu verb) as a fallback if the URI handler is misconfigured. Removed the broken bare-stub call to %LOCALAPPDATA%\\\\Microsoft\\\\WindowsApps\\\\ms-teams.exe — that's a 0-byte AppX placeholder that never worked outside an AppX context.
F1 help dialog's EXTERNAL CONTROL section now points operators at the LAN-reachable toggle for headless-host scenarios. Operators learn about the closed-network requirement + the netsh urlacl one-shot before they go looking for it.
When LAN-reachable mode is on, the footer's control-surface badge now shows the full http://<lan-ip>:<port> instead of just :<port>. Operators setting up a thin client can read the URL straight off the host PC's footer without having to open Settings → DISPLAY → Copy URL.
Reverts to the existing 'REST :9755 + OSC :9000' compact form when bound to localhost only — no point spelling out 127.0.0.1 since by definition only the host can reach it.
Toast was auto-dismiss-only (3s timer). Operators running a live show want to clear visual clutter without waiting — added a small X button to the right of the message that calls ToastViewModel.DismissCommand (stops timer + hides immediately).
Implementation: ToastViewModel gained a DismissCommand RelayCommand and a Hide() helper. MainWindow toast overlay gained a 20x20 button bound to the command, custom inline template (rounded transparent bg, hover lifts to Wd.Button.HoverBg).
The audio capture loop runs at ~50Hz publishing every buffer's peak via overwrite; the UI stats poll reads at 1Hz. With overwrite semantics the UI sees one of every ~50 audio frames per second — loud transients between reads were invisible to the VU meter.
New design: NdiReceiver maintains an atomic high-water mark, max-updated on each audio frame via CompareExchange CAS loop. IsoPipeline.GetStats now calls ConsumeAudioPeak() which atomically reads + resets to 0, so the next UI tick reflects the loudest sample seen in the next 1s window.
Added PeekAudioPeak() for non-consuming reads (e.g. external diagnostics dashboards that poll faster than the UI).
FakeNdiInterop gained a ReceiverAudioPeaks queue + CaptureAudioPeak override so tests can drive the audio path. 4 new tests in NdiReceiverTests cover: empty case, single-frame consume+reset, max-hold across 3 frames, no-frame leaves high-water mark untouched. 104 + 46 + 9 = 159/159 passing.
Primary button (Apply Changes, Save Preset, Confirm-Stop-All) had only an IsMouseOver trigger. Disabled state looked identical to enabled — confusing for the most-frequently-disabled button in the app.
- Disabled: drops to Wd.Accent.CyanMuted at 70% opacity + Text.Disabled foreground.
- Pressed: 85% opacity for the brief tap.
- IsKeyboardFocused: matches the hover treatment so tab-cycling lights it.
Default WPF ToolTip is cream-on-black with a thin 3D border — looks like a Win98 popup on the dark canvas. The app has dozens of tooltips on settings controls, header pills, IN-CALL bar, and per-row toggles — every one was previously rendering as the OS default.
New style: SurfaceElevated background, BorderStrong border, rounded 6px corner, monospace-friendly text wrapping at 320px so a verbose explanation doesn't stretch across the whole monitor.
Implementation note: ContentPresenter doesn't accept TextBlock.TextWrapping as an attached property — used a templated TextBlock bound directly to Content instead (every tooltip in the app passes a plain string).