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>
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.
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).
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.
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.
Adds a small auto-dismissing pill notification at the bottom-center of the participants area: 'Settings saved' on Apply Changes, 'Transcoder topology applied — restart Teams to take effect' after the one-click NDI groups setup. ToastViewModel owns its own DispatcherTimer and resets the dismissal countdown on successive calls, so the most recent message is always the one visible. Hooked into MainViewModel and threaded into GlobalSettingsViewModel via constructor injection.
_NEXT.md rewritten to reflect the May 2026 hardening pass: separates engine / UI / networking / Phase E.1 / diagnostics / CI / tests sections, lists every shipped item, and re-prioritizes the remaining work (Phase E.2-E.3 embedded Teams, code-signing the MSI, refresh-discovery affordance, output thumbnail previews, settings panel UX, auto-disable on departure, operator presets).
Four polish items + a test pass.
1. Inter Variable (rsms/inter v3.19, OFL) is bundled at Assets/Fonts/Inter.ttf (~800 KB) and registered as a WPF Resource. WildDragonTheme.xaml's Wd.Font.Sans now points at pack://application:,,,/Assets/Fonts/#Inter so the typography matches wilddragon.net regardless of whether the user has Inter installed system-wide. Falls back to Segoe UI Variable Display if the resource is missing.
2. 'Stop all ISOs' button at the right of the participants header. Bound to a new MainViewModel.StopAllIsosCommand that snapshots the enabled list, awaits DisableIsoAsync sequentially, and silently swallows per-pipeline failures (best-effort emergency stop). CanExecute gates on whether any ISO is currently enabled.
3. WindowStateStore service persists the main window's Left/Top/Width/Height/State to %LOCALAPPDATA%\\TeamsISO\\window.json on close and restores it on SourceInitialized. Multi-monitor friendly: a saved position with no corner inside any virtual screen is rejected so a disconnected monitor doesn't strand the window off-screen.
4. Two new unit tests cover FrameProcessor's drops + duplicates accounting. 76/76 unit tests pass (was 74).
IsoPipeline now publishes refs to its currently-live NdiReceiver and NdiSender (set by RunInnerPipelineAsync, cleared on exit) so a stats poll from any thread can read FramesCaptured / FramesSent without entangling the pipeline's lifetime with its observer. The receiver's raw-frame channel is wrapped with a TappedChannelWriter so the most recent RawFrame is captured for source-resolution display, again without changing the receiver's contract.
IsoController.GetStats() drops the stub return-Empty and instead reads the live pipeline.GetStats() outside the gate so a slow stats read can't serialize the controller's other operations.
WPF: MainViewModel runs a 1 Hz DispatcherTimer that pulls stats for every participant view-model and pushes them via UpdateStats(). ParticipantViewModel grows three displayable properties — FramesIn, FramesOut, IncomingResolution — bound into the participants DataGrid as a new 'Live' column showing the down/up frame counts and the source resolution underneath the machine name.
Tests: 74/74 unit + 9/9 NDI integration green; the existing round-trip integration test exercises the new wiring at runtime (live receiver/sender refs are set, frames flow, channels close cleanly).
Adopts the design language from Dammyjay93/interface-design: warm Stone neutrals, accent orange (#EA580C), borders-only depth (no shadows), 8px spacing grid, all-caps section labels, mono typography for machine names and timecodes.
Themes/StoneTheme.xaml is the single source of truth for design tokens (color brushes, typography styles, spacing) plus restyled control templates (Button, TextBox, ComboBox, ComboBoxItem, CheckBox, DataGrid + DataGridColumnHeader / DataGridRow / DataGridCell, ScrollBar). MainWindow consumes the tokens and is laid out with a header (title + status pill), section-headed Settings sidebar (Output Format / NDI Network / Display), card-wrapped participant list, and a mono status footer.
Settings sidebar surfaces the new NDI group configuration (discovery + output) and a Hide-(Local) checkbox. The latter filters the user's own self-preview from the participants list at the MainViewModel layer (HideLocalSelf=true by default) so operators don't accidentally route their own preview as an ISO. Apply Changes round-trips both FrameProcessingSettings and NdiGroupSettings through the controller in one go.