Commit graph

137 commits

Author SHA1 Message Date
65d3b78e63 Footer surfaces full LAN URL when control surface is LAN-reachable
Some checks failed
CI / build-and-test (push) Failing after 28s
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.
2026-05-10 14:06:27 -04:00
8e66491e09 Add manual X close to toast notification
Some checks failed
CI / build-and-test (push) Failing after 26s
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).
2026-05-10 14:05:28 -04:00
2c607a70ff Audio peak: high-water mark across UI poll interval
Some checks failed
CI / build-and-test (push) Failing after 31s
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.
2026-05-10 14:04:04 -04:00
d8adb44a8f Wd.Button.Primary: add disabled / pressed / focus states
Some checks failed
CI / build-and-test (push) Has been cancelled
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.
2026-05-10 13:36:27 -04:00
2ae0dc2d62 Style ToolTip for dark theme — replace cream Win98 popup
Some checks failed
CI / build-and-test (push) Failing after 38s
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).
2026-05-10 13:33:35 -04:00
b56e2e12e1 Style ContextMenu / MenuItem to match dark theme
Some checks failed
CI / build-and-test (push) Failing after 29s
Right-click on a participant row in the DataGrid surfaces actions (Toggle ISO / Restart / Open preview / Record / Copy NDI source name) but rendered with WPF's default white-on-grey ContextMenu — looked like a Notepad popup on the dark canvas.

New ContextMenu + MenuItem styles match the rest of the theme: SurfaceElevated background, rounded corners, slim cyan-tinted hover (matches the button hover treatment from the previous pass), monospaced gesture text in Wd.Text.Tertiary, no chevrons on items without submenus.
2026-05-10 13:32:04 -04:00
0e2927e42c Add keyboard focus rings to themed buttons
Some checks failed
CI / build-and-test (push) Failing after 31s
All Wd.Button.* styles set FocusVisualStyle=x:Null which suppresses WPF's default dotted focus rectangle. With no replacement IsKeyboardFocused trigger, tab-navigating gave NO visual cue — accessibility regression.

Match the keyboard-focus visual to the hover visual so mouse and keyboard land on the same affordance:

- Ghost: cyan border on focus (same as hover, minus the bg fill change so focus + hover compound visibly)

- Caption: lifts to Wd.Button.HoverBg

- RailIcon: lifts to Wd.Button.HoverBg + cyan icon

- IsoToggle: 2px cyan border (same as hover; status bg preserved)

Verified building cleanly. Tab-cycle through the IN-CALL bar / header pills / settings tabs now lights the focused control.
2026-05-10 13:30:56 -04:00
2de65f6d10 Restyle ScrollBar to match dark theme
Some checks failed
CI / build-and-test (push) Failing after 29s
Default WPF ScrollBar template renders chunky Win9x line-up/line-down arrow buttons + a 3D thumb that look out of place on the dark canvas. Replace with a slim transparent track + tinted thumb pattern (Edge / VS Code / GitHub style):

- Thumb: BorderStrong by default, lifts to Text.Tertiary on hover, switches to cyan accent while dragging.

- No arrow buttons — track-clicks above/below the thumb still page-scroll via invisible RepeatButtons (preserves PageUp/PageDown behavior).

- Horizontal orientation has its own template via Style.Triggers so it picks the right Track command set (PageLeft/PageRight).

Affects every ScrollViewer in the app — settings panel, presets dialog list, Notes window, About dialog, Help cheat sheet.
2026-05-10 13:29:44 -04:00
c53c7a7768 Wire engine audio peak metering — UI VU bars now animate
Some checks failed
CI / build-and-test (push) Failing after 29s
The DataGrid's per-row audio level bar (in the Live column) was inert because IsoHealthStats.PeakAudioLevel always returned 0.0. Engine work needed: capture NDI audio frames, compute peak amplitude, publish through the existing stats path.

Engine:

- AudioPeakComputer (new): max-abs computation across NDI's FLTP / FLT / PCM s16 sample formats. Pure managed code, fully unit-tested (14 cases — clamping behaviour, edge cases like short.MinValue overflow, totalSamples-vs-buffer mismatch defenses).

- INdiInterop.CaptureAudioPeak (new, default-implemented): polls one audio frame, returns peak in [0,1] or null on timeout. FakeNdiInterop inherits the no-op default; production NdiInteropPInvoke overrides with real FLTP decode through a sibling RecvCaptureV3Audio import + RecvFreeAudioV3.

- NdiNative: AudioFrameV3 struct + audio-only RecvCaptureV3 binding + FreeAudioV3.

- NdiReceiver: spins up a sibling audio-capture loop alongside the existing video loop on the same lifetime. Audio failures are caught + logged but never re-thrown (a misbehaving audio path must never tear down the live video pipeline). Latest peak published via Volatile<long> (BitConverter int64 bits) so UI reads are torn-free across threads.

- IsoPipeline.GetStats: surfaces NdiReceiver.LatestAudioPeak as IsoHealthStats.PeakAudioLevel.

UI:

- ParticipantViewModel.OnStatsTick already had the decay logic (max-of-new-or-decayed-old, 0.7 multiplier) waiting for real values. No UI changes needed.

Tests: 14 new + 141 existing = 155/155 passing. 0 warnings, 0 errors.
2026-05-10 13:28:26 -04:00
554ab9e570 Make button hover affordances visible on dark canvas
Some checks failed
CI / build-and-test (push) Failing after 27s
Hover state on Wd.Button.Ghost / Caption / RailIcon / IsoToggle was barely visible — the Wd.SurfaceHover fill (#242424) was only 14 luminance points off the canvas (#0A0A0A) and 16 off the cards (#141414), so the hover state read as 'no change' to the user.

Changes:

1. Bumped Wd.SurfaceHover #242424 -> #2A2A2A and Wd.SurfaceActive #2D2D2D -> #363636 across the theme (carries to DataGrid rows etc. with the same too-subtle problem).

2. Added Wd.Button.HoverBg (#33333A) + Wd.Button.PressBg (#3F3F47) — dedicated stronger hover/press fills for buttons specifically. The slight blue tint adds chroma so hover reads obviously distinct from a flat darken.

3. Wd.Button.Ghost hover now also sets BorderBrush=Wd.Accent.Cyan — dual cue (lighter fill + bright cyan border) makes the affordance unmistakable on any surface.

4. Wd.Button.RailIcon hover sets foreground=Wd.Accent.Cyan in addition to the brighter fill (rail buttons have icons, not text — color shift on the icon is the strongest signal).

5. Wd.Button.IsoToggle keeps its status-coded background on hover (LIVE cyan / ERROR coral / NO SIGNAL amber) so at-a-glance state isn't lost; instead bumps to a 2px cyan border for the affordance.

Verified: cleanly built, app launches, hover over Refresh button now has obvious brighter fill + tooltip vs. neighbors at rest.
2026-05-10 13:22:45 -04:00
d9eb02a9af Fix GetLanIPv4 to skip Tailscale/VPN/APIPA addresses
Some checks failed
CI / build-and-test (push) Failing after 26s
On a Windows host with both Ethernet (10.0.0.123) and Tailscale (169.254.83.107 link-local), the original first-hit-wins picker returned the Tailscale address — useless for the headless-host + thin-client scenario the LAN-reachable mode is designed for.

New picker prefers physical NICs (Ethernet/GigabitEthernet/Wireless80211), skips Tunnel-typed virtuals, and ranks: physical-routable > virtual-routable > APIPA. Verified against this host: now returns 10.0.0.123 instead of 169.254.83.107.
2026-05-10 13:11:11 -04:00
6d9407a61f Add LAN-reachable mode to control surface and OSC bridge
Some checks failed
CI / build-and-test (push) Failing after 27s
When the new ControlSurfaceLanReachable preference is on, both the REST/WebSocket control surface and the OSC bridge bind to all interfaces (http://+:port/ via HttpListener wildcard, IPAddress.Any for OSC) instead of loopback. The settings VM persists the toggle, restarts both surfaces when flipped, and surfaces a ControlSurfaceUrl computed from the first non-loopback IPv4 + a Copy button so operators can paste the URL onto a control PC.

Use case: a headless host PC runs Teams + TeamsISO; a thin client on the same LAN drives it via /ui or a Stream Deck. Closed-network deployment, no auth — documented as a trusted-LAN-only mode in docs/CONTROL-SURFACE.md, including the one-time 'netsh http add urlacl url=http://+:9755/ user=Everyone' requirement and the firewall rule.
2026-05-10 10:01:32 -04:00
63bd93d0c2 chore: sweep orphaned files (UpdateChecker, UpdateBanner, TeamsControlBridge, helper scripts)
Some checks failed
CI / build-and-test (push) Failing after 25s
2026-05-10 09:42:29 -04:00
5c0491e46c feat: persist UI prefs + preview window + sort + inline note input 2026-05-10 09:41:34 -04:00
46fa0d66a1 test+feat: App.Tests project + audio VU scaffold + MF recorder stub 2026-05-10 09:41:33 -04:00
fdd1d1bbfc feat(ui): system tray icon + WinForms/WPF namespace disambiguation 2026-05-10 09:41:33 -04:00
832aad6a14 feat(engine+console): SMPTE test-pattern generator + --test-pattern flag 2026-05-10 09:41:33 -04:00
7c7520e2be feat(ui): notes viewer + Stop-All confirm + folder shortcuts + README 2026-05-10 09:41:32 -04:00
b49e1abf17 feat: CLI flags, dynamic status, HTML panel, session timer, notes 2026-05-10 09:41:32 -04:00
6882e654d5 feat: window-scoped keyboard shortcuts + help cheat sheet (F1) 2026-05-10 09:41:31 -04:00
670813f18e feat: disk space watcher + diagnostic bundle export 2026-05-10 09:41:31 -04:00
179a44adf5 feat: custom NDI output name template + enriched status bar 2026-05-10 09:41:31 -04:00
e06120044b feat: recording markers (UI button + REST + OSC + manifest array) 2026-05-10 09:41:30 -04:00
f73552a6b9 feat: preset import / export bundles 2026-05-10 09:41:30 -04:00
b8fe344c58 feat: WebSocket live-state push + OSC bridge 2026-05-10 09:41:30 -04:00
e93b8caae0 feat: in-app preview thumbnails per participant 2026-05-10 09:41:30 -04:00
83224dbd9b feat: REST control surface + lift preset-apply into PresetApplier 2026-05-10 09:41:29 -04:00
b5fcc98d40 feat(ui): crash diagnostics, first-launch welcome, reset-to-defaults 2026-05-10 09:41:29 -04:00
34a2f1483c feat(engine): refresh discovery affordance + idempotent re-Add handling 2026-05-10 09:41:29 -04:00
57c2922d1c feat(ui): auto-disable ISOs when participants leave the meeting 2026-05-10 09:41:28 -04:00
9cb1cc7b3d fix: review findings on the polish + active-speaker batch
Some checks failed
CI / build-and-test (push) Failing after 29s
Two real concerns from the code review on ab07297..b266623:

1. ActiveSpeaker removal poisoned the rename-window heuristic. ParticipantTracker.HandleRemoved appends to _recentlyRemoved keyed by MachineName alone; the next Participant Add on the same machine consulted that list with no kind discrimination, so an active-speaker disappearance immediately followed by a participant joining (very common: Teams renames its outputs as participants enter/leave) would cause the new participant to inherit the auto-mix's deterministic v5 GUID. New HandleAutoMixRemoved deliberately skips _recentlyRemoved — the auto-mix row's identity is already stable via the deterministic Id, so re-add restores it without the rename window.

2. IsoPipeline.State writes were not synchronized. Supervisor loop sets State on its own thread; UI thread reads from GetStats. Without volatility, the JIT could cache the field in a register and the UI would stay stuck on Receiving even after Error. Backing field is now an int read/written via Volatile.Read/Volatile.Write, matching the pattern already used for _liveReceiver / _liveSender / _liveProcessor.

Tests: 79/79 (was 78) — added ParticipantTrackerTests.ActiveSpeakerRemove_DoesNotPoisonRenameWindowForLaterParticipant which would have caught (1).
2026-05-09 09:34:16 -04:00
b2666236ec feat(ui): toast feedback for settings actions; refresh _NEXT.md
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).
2026-05-09 09:30:04 -04:00
778e5163e9 feat(engine): surface Teams Active Speaker as a routable participant
Some checks failed
CI / build-and-test (push) Failing after 28s
ParticipantTracker now accepts NdiSourceKind.ActiveSpeaker (Teams' auto-mix output — legacy 'MACHINE (Teams)' or current 'MACHINE (MS Teams - Active Speaker)') and surfaces it as a synthetic row in the participant list with the display name 'Active Speaker'. The operator can route it to its own normalized ISO via the same toggle every other participant uses, so vMix / OBS / Ross can subscribe to a single clean active-speaker feed.

Stable Id: derived from SHA1 of 'auto-mix:<machine>' formatted as a v5 GUID, so a discovery cycle that re-adds the source doesn't duplicate the row and the operator's ISO assignment stays bound across the rename window.

Tests: 78/78 unit (was 76) — added ParticipantTrackerTests.ActiveSpeaker_AppearsAsSyntheticAutoMixParticipant + ActiveSpeaker_ReAddOnSameMachine_PreservesId. Existing NonParticipantSources_AreIgnored still passes (only ActiveSpeaker is opted in; ScreenShare and Audio are still ignored).
2026-05-09 09:25:45 -04:00
ab072979d8 feat(ui): empty-state, pipeline error/no-signal indicators, JetBrains Mono, tooltips
Some checks failed
CI / build-and-test (push) Failing after 30s
Four polish improvements aimed at production-floor usability.

1. Empty-state placeholder for the participants card. When Participants.Count == 0, the DataGrid is hidden in favor of a friendly 'Waiting for Teams' panel: faded dragon mark, headline, explainer, and a four-item checklist (Teams running? NDI broadcast on? Discovery group correct? Firewall clear?). New CountToVisibilityConverter (with optional 'empty' parameter to invert) drives both the placeholder and the DataGrid visibility from the same Participants.Count source.

2. Per-pipeline error / no-signal surfacing. IsoHealthStats grows an init-only State property populated from IsoPipeline.State. ParticipantViewModel.UpdateStats maps that to a StateLabel ('LIVE' / 'NO SIGNAL' / 'ERROR' / 'STARTING' / '—'). The ISO toggle button gains DataTriggers on StateLabel — coral-tinted '● ERROR' when the supervisor gives up, amber-tinted '● NO SIGNAL' when the slate threshold trips. Operators can see at a glance which pipelines are broken.

3. JetBrains Mono Variable v2.304 (OFL) bundled at Assets/Fonts/JetBrainsMono.ttf. Wd.Font.Mono now points at the embedded font so machine names, timecodes, and stat counters render in JetBrains Mono regardless of system fonts. Falls back to Cascadia Mono / Consolas if the resource is missing.

4. Tooltip pass over every interactive control in the settings panel (framerate / resolution / aspect / audio / discovery group / output group / hide-local checkbox / Apply button / per-row Output Name textbox / per-row ISO toggle). Operators learn affordances on hover instead of by trial and error.

Tests: 76/76 unit + 9/9 NDI integration green.
2026-05-08 19:32:19 -04:00
0c82ac71f0 feat: bundle Inter font, emergency stop button, window persistence + tests
Some checks failed
CI / build-and-test (push) Failing after 27s
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).
2026-05-08 13:59:14 -04:00
ff7e949466 fix(ui): hit-test the dragon button so the About dialog actually opens
Some checks failed
CI / build-and-test (push) Failing after 26s
WindowChrome.CaptionHeight=44 makes the top 44px of the window a drag region; the dragon-mark rail button lives within it. Without shell:WindowChrome.IsHitTestVisibleInChrome=True, clicks were being eaten by the drag handler instead of firing the button.
2026-05-08 13:52:59 -04:00
e8f52a3153 feat: app icon, FPS, drops counter, --version, About dialog, Stop Teams toggle
Six related polish items, all building on tonight's groundwork.

1. App icon: teamsiso.ico generated from dragon-mark.png at 7 sizes (16-256), wired as ApplicationIcon in the WPF csproj, MainWindow.Icon, AboutWindow.Icon, and ARPPRODUCTICON in the WiX MSI. Taskbar / window / Add-Remove-Programs all show the dragon mark now.

2. Running incoming FPS: ring buffer of last 30 frame timestamps in IsoPipeline; ComputeFps() returns moving-average rate. Surfaced on IsoHealthStats.IncomingFps and shown in the Source column of the participants DataGrid as 'WxH · 59.94 fps'. Resets cleanly on every supervisor restart.

3. Drops counter: FrameProcessor.Stats already aggregated FramesDropped (closest-frame strategy when the receiver outpaces the processor) and FramesDuplicated; just plumbed _liveProcessor through IsoPipeline so GetStats() can read them. Exposed in the Live column under the in/out counters as a coral-tinted 'drop N'.

4. Console --version flag: prints engine version (with embedded git SHA), .NET version, OS, NDI runtime banner, expected prefix, exit-code legend, plus a wilddragon.net link. Useful for support tickets.

5. About dialog: chromeless modal with the dragon mark + version / .NET / OS / NDI runtime fields and a link to wilddragon.net. Triggered by clicking the rail logo.

6. Teams launcher Stop toggle: TeamsLauncher gains IsRunning() and StopAll(). The rail's Teams button now toggles — if Teams is up, ask to close all Teams windows via WM_CLOSE; otherwise launch as before. Confirms before stopping so we don't kill the user's call mid-transition.

Tests: 74/74 unit + 9/9 NDI integration green throughout. MSI builds clean and now embeds the dragon icon for ARP.
2026-05-08 13:50:19 -04:00
01ef4250d7 feat(ui): real Wild Dragon mark in rail + automated transcoder topology
All checks were successful
CI / build-and-test (push) Successful in 31s
Two related deliverables addressing the user's morning asks.

1. Branding: Dragon WHITE.png and Wild Dragon Logo WHITE.png from the brand kit are copied into src/TeamsISO.App/Assets/ and registered as <Resource> items in the .csproj. The rail's placeholder 'W' glyph is replaced by the real dragon mark (40x40, HighQuality bitmap scaling) with a 'Wild Dragon' caption underneath.

2. NDI Access Manager automation: NdiAccessManagerConfig service reads/writes %APPDATA%\\NDI\\ndi-config.v1.json, working in JsonNode trees so we don't clobber unrelated keys. ApplyTranscoderTopology() sets groups.send=[teamsiso-input] and groups.recv=[public, teamsiso-input] so all local senders (Teams + anything else) broadcast on the private group while local receivers can still see public sources too. Engine-side, the user's per-pipeline OutputGroups override pushes TeamsISO outputs back onto Public so downstream switchers see clean ISOs.

Atomic write: temp + replace, with timestamped backup of the prior config. ReadCurrentGroups() can be used by future UI to show what's currently configured. RestoreDefaults() reverts.

Settings panel grows an 'Apply transcoder topology' button under the NDI Network section. Click writes the system config, sets the engine's discovery=teamsiso-input / output=public, refreshes the bound text boxes, and pops a dialog with a 'restart Teams' reminder + the backup path.
2026-05-08 07:19:31 -04:00
c08b90b0b2 feat(ui): Launch Teams rail button + spec for embedded-Teams roadmap
All checks were successful
CI / build-and-test (push) Successful in 40s
First step of Phase E.1 from the new spec at docs/superpowers/specs/2026-05-08-embedded-teams-orchestration.md: a third icon in the left rail launches the Microsoft Teams desktop client as a subprocess of TeamsISO so the operator doesn't have to leave the app to start a meeting.

Services/TeamsLauncher tries the ms-teams: URI first, falls back to %LOCALAPPDATA%\\Microsoft\\WindowsApps\\ms-teams.exe (new Teams), then the classic Update.exe handoff. On failure surfaces a friendly MessageBox with the install link.

The spec doc lays out the full three-phase roadmap (launcher -> window orchestration -> in-app meeting controls via Graph API or UIAutomation) and explicitly calls out what's out of scope (replacing Teams' media stack).

_NEXT.md updated to mark Phase D done and queue Phase E + remaining polish items (code-signing, Inter/JetBrains Mono font bundling, real Wild Dragon dragon-mark, drops counter, running-fps display).
2026-05-08 01:05:26 -04:00
16e0a483e2 fix: address review findings on tonight's commits
All checks were successful
CI / build-and-test (push) Successful in 35s
Code review on d14a33a..bab29b0 turned up three real issues, fixed here.

1. EngineLogging.CreateDefault no longer mutates Serilog.Log.Logger. The static set was a belt-and-suspenders attempt to catch any code path that reaches for the singleton, but it doesn't matter (engine code uses ILogger<T>, never Serilog.Log.*) and it raced under xUnit's parallel test execution.

2. IsoPipeline stops holding a RawFrame reference for stats. The receiver-side TappedChannelWriter callback now snapshots only Width/Height into volatile ints — frame's pixel buffer is allowed to GC on its normal schedule and a late stats poll can never resurrect a dropped frame. (Today the buffer is fully managed so a use-after-free wasn't actually possible, but the snapshot pattern is the right ownership shape.)

3. App.xaml.cs's ComponentDispatcher.ThreadFilterMessage subscription now lives in a field and is unsubscribed in OnExit. Mutex release is gated on a new _ownsSingleInstanceMutex flag so the 'lost the race; shut down silently' path doesn't accidentally try to release a handle it never owned.

Plus a load-bearing comment in NdiInteropPInvoke.CreateFinder explaining why we free the UTF-8 group buffers right after the native call returns — same lifetime contract Phase B-2's CreateReceiver / CreateSender have always relied on; if it's wrong, those would fail too. The loopback discovery integration test would catch a regression.

Tests: 74/74 unit + 9/9 NDI integration green.
2026-05-08 01:01:00 -04:00
bab29b02ab feat(ui): chromeless title bar with custom caption controls
All checks were successful
CI / build-and-test (push) Successful in 37s
MainWindow drops the standard Windows title bar (WindowStyle=None + WindowChrome with CaptionHeight=44, ResizeBorderThickness=6, UseAeroCaptionButtons=False) and draws its own minimize / maximize-restore / close buttons inline in the existing header strip. The custom buttons opt into shell:WindowChrome.IsHitTestVisibleInChrome=True so clicks fire on them rather than starting a window drag.

Result: the entire top of the window is now ours, matching the Microsoft Teams desktop client's flush header look. The 'TeamsISO + by Wild Dragon' branding sits at the same baseline as the engine-status pill and the caption controls, and dragging anywhere not occupied by an interactive widget moves the window.

Caption-button styles in the theme: 46x32 hover-tinted, with the close button turning the Windows 11 #C42B1C red on hover. Maximize-button glyph swaps between the single-rectangle and overlapping-rectangles variants on StateChanged.

Drive-by: ParticipantViewModel.{FramesIn,FramesOut,IncomingResolution} setters dropped from private to public so {Run Text=...} bindings (which default to TwoWay on Run) can attach without WPF throwing 'cannot work on read-only property'.
2026-05-08 00:55:57 -04:00
9c231118de feat(stats): wire IsoHealthStats end-to-end and surface live counters in UI
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).
2026-05-08 00:52:44 -04:00
1d85396a90 feat(logging): rolling file sink under %LOCALAPPDATA%\\TeamsISO\\Logs
Adds Serilog.Sinks.File to TeamsISO.Engine and a new EngineLogging.CreateDefault() factory that writes to BOTH the existing console sink and a rolling daily file at %LOCALAPPDATA%\\TeamsISO\\Logs\\teamsiso<date>.log. The WPF host (TeamsISO.exe is a WinExe with no console attached at runtime) now uses CreateDefault so support has something to ask for when users file an issue. The Console build keeps using CreateConsole — stdout is the right surface there and shell redirection beats a competing on-disk sink.

Files roll daily, cap at 10 MB before mid-day rollover, and only the most recent 14 are retained. Disk flush interval is 250 ms so a tail -f from another tool sees lines promptly. Path is announced via the first log line on every startup.

Two unit tests gate the wiring: AllLoggers_WriteToFile (verifies both typed and named CreateLogger() reach the file) and LogsAtBelowMinimumLevel_AreSuppressed (regression guard for level filtering). 74/74 unit tests pass (was 72).

Also adds a startup breadcrumb log line in App.OnStartup carrying the build version + PID so we can correlate a user's log file with a specific commit.
2026-05-08 00:47:25 -04:00
9891f2444d test(ndi): end-to-end pipeline round-trip with framerate normalization
Synthesizes a 640x360 cyan BGRA source named like a Teams participant, runs the production IsoPipeline against it (NdiReceiver -> FrameProcessor -> ManagedNearestNeighborFrameScaler -> NdiSender, all backed by NdiInteropPInvoke), connects a receiver to the resulting TEAMSISO_* output, and asserts that captured frames come back at the configured 1920x1080 target. Closes the loop on the receive/scale/emit chain that was previously only unit-tested in isolation against fakes.

If this test ever goes red we have a regression in something that actually matters: the runtime resolver, the parser-free pipeline construction path, the frame channel back-pressure, the scaler's pillarbox math, the sender clock, or the receiver-from-loopback path. Pinned at requires=ndi so default CI skips it; runs locally in ~780ms.
2026-05-08 00:39:23 -04:00
0b24fbb529 test(ndi): seed requires=ndi integration tests against real NDI runtime
Replaces the previously-skipped placeholder with 8 integration tests that exercise the production P/Invoke shim against the installed NDI 6 runtime: runtime version probe + prefix assertion (catches future SDK rebrandings), finder lifecycle on default + custom groups (incl. whitespace tolerance + multi-group), sender lifecycle on default + custom groups, and a loopback-discovery test that creates a uniquely-named sender and asserts a same-process finder sees it within 5 s.

All marked [Trait('requires', 'ndi')] so the existing CI filter (Category!=ndi&requires!=ndi) excludes them. Run locally with: dotnet test --filter requires=ndi. Today: 8/8 pass against NDI 6.2 on Windows 11.
2026-05-08 00:11:01 -04:00
6cac486fbe feat(ui): rebrand to Wild Dragon + Microsoft Teams layout
Some checks failed
CI / build-and-test (push) Failing after 33s
Replaces the Stone theme with Wild Dragon branding (canvas #0A0A0A, accent cyan #97EDF0, secondary #9AE0FD, coral alert #FB819C — sourced from wilddragon.net) and reorganizes MainWindow into a Microsoft Teams-style three-column layout: a 72px left rail (logo + Participants/Settings nav + engine-status indicator), a center content area (header + participants card), and a right settings panel.

Adds InitialsConverter so participant avatars render real initials (Brendon Power -> 'BP', '(Local)' -> 'L') instead of a generic glyph. Drops the obsolete StoneTheme.xaml; the project now ships exactly one theme dictionary.

Typography: Inter (with Segoe UI Variable Display fallback) for the sans stack, JetBrains Mono (Cascadia Mono fallback) for machine names and timecodes — matching the wilddragon.net site.

Verified live against the running Teams meeting: app launches, participant 'Brendon Power' displays with avatar, settings panel surfaces NDI groups + Hide-Local toggle, engine status pill shows green/live.
2026-05-08 00:08:39 -04:00
53c06a9af9 feat(ui): single-instance enforcement via per-user named mutex
Two simultaneous TeamsISO processes contend over the NDI runtime, the same default sender names, and %APPDATA%\\TeamsISO\\config.json — observed during testing when launchers / shortcuts produced duplicate windows. Add a Local namespace per-user-keyed mutex (Local\\WildDragon.TeamsISO.SingleInstance.<username>) at startup; if a second instance can't claim it, broadcast a registered window message ('WildDragon.TeamsISO.BringToFront') and Shutdown(0). The running instance subscribes to that message via ComponentDispatcher.ThreadFilterMessage and surfaces its main window when received.

Per-user keying lets two different Windows users on the same machine each run their own TeamsISO. Mutex is released and disposed on OnExit.

Verified: Start-Process the exe twice in a row -> only one process remains, with the original window surfaced.
2026-05-07 23:59:47 -04:00
b542d01835 feat(ui): rebuild MainWindow with Stone-theme design system
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.
2026-05-07 23:58:02 -04:00
909237f454 feat(ndi): plumb NDI groups (discovery + output) through the engine
Adds an NdiGroupSettings record carrying optional comma-separated NDI group lists for the finder and the senders. Extends INdiInterop.CreateFinder / CreateSender with optional groups arguments and populates NDIlib_find_create_t.p_groups and NDIlib_send_create_t.p_groups via P/Invoke. IsoController reads the settings on construction, threads DiscoveryGroups into NdiDiscoveryService and OutputGroups into IsoPipelineConfig, and exposes SetGroupSettingsAsync for runtime updates (group changes apply on next process restart so live pipelines aren't orphaned).

This unblocks the 'transcoder' topology where Teams broadcasts NDI on a private group (e.g. teamsiso-input) and TeamsISO re-emits clean normalized streams on Public — keeping raw, wrong-framerate Teams sources off the production network.

EngineConfig schema is JSON-back-compat: existing config.json files (no NdiGroups field) deserialize with NdiGroups=null and load as NdiGroupSettings.Default. UI surface for these settings comes in a follow-up.

Tests: 72/72 passing (was 69) — added IsoController coverage that group settings are read from ConfigStore on startup, passed to the finder, threaded into per-pipeline config, and round-trip through SetGroupSettingsAsync/Save/Load.
2026-05-07 23:48:49 -04:00
fa8d2a8fad feat(console): add --list-sources diagnostic flag
Some checks failed
CI / build-and-test (push) Failing after 27s
Enumerates every raw NDI source string visible to the local NDI finder for ~5 seconds and prints unique ones, then exits. Bypasses NdiSourceParser so it surfaces sources that the parser would otherwise reject — useful for confirming the on-the-wire format Teams (or any other producer) is actually emitting on a given setup.

Used directly to diagnose the Teams 'MS Teams' vs. 'Teams' brand-prefix mismatch fixed in the previous commit; left in as a permanent debug tool for future setup issues.
2026-05-07 23:33:44 -04:00
ca124540a7 fix(parser): accept 'MS Teams' brand prefix from current Teams NDI broadcasts
The new Microsoft Teams desktop client (observed against a live meeting on Teams 26106.1906.4665.7308) emits NDI source strings of the form

    WOOGLIN (MS Teams - Brendon Power)

    WOOGLIN (MS Teams - (Local))

    WOOGLIN (MS Teams - Active Speaker)

rather than the legacy 'MACHINE (Teams - ...)' shape NdiSourceParser was written to. As a result every Teams source was rejected and TeamsISO showed zero participants in real meetings.

Refactor the parser to recognize 'Teams', 'MS Teams', and (defensively) 'Microsoft Teams' as brand prefixes — longest first so 'MS Teams' isn't shadowed. Also recognize reserved suffix tokens after a dash ('Active Speaker' / 'Audio' / 'Audio Mix' / 'Screen Share') so the new active-speaker output is correctly classified as ActiveSpeaker rather than misread as a participant named 'Active Speaker'.

Tests: kept all legacy cases, added MS Teams + Microsoft Teams variants and the new dash-prefixed reserved-suffix cases. 69/69 unit tests passing; verified end-to-end against a live Teams meeting where TeamsISO.exe now shows '(Local)' and 'Brendon Power' in the Participants DataGrid.
2026-05-07 23:33:43 -04:00
d90ebb826f fix(ndi): match the NDI 6 WIN64 runtime banner in version probe
Some checks failed
CI / build-and-test (push) Failing after 26s
Shipping NDI 6 reports its version as a build banner of the form

    NDI SDK WIN64 13:07:00 Jun  2 2025 6.2.0.3

not the 'NDI SDK for Windows v6.x.x.x' format the prefix constant assumed. As a result NdiRuntimeProbe raised a spurious mismatch alert on every supported install. Update ExpectedRuntimeVersionPrefix to 'NDI SDK WIN64' which is the stable architecture token in the new banner; the trailing four-part version remains available for a stricter major-version check if that becomes useful.
2026-05-07 15:15:03 -04:00
d14a33a0a3 fix(ndi): resolve Processing.NDI.Lib.x64 via NDI_RUNTIME_DIR_V6 env var
The NDI 6 installer sets NDI_RUNTIME_DIR_V6 (and V5/V4 for back-compat) but does not always add the runtime directory to PATH, so a default DllImport(Processing.NDI.Lib.x64) failed with 0x8007007E (DllNotFoundException) on otherwise correctly installed machines, killing both TeamsISO.exe and TeamsISO.Console at preflight.

Add NdiNativeLibraryResolver, registered from NdiNative's static ctor, that resolves the DLL by trying NDI_RUNTIME_DIR_V6 / V5 / V4 in order, NativeLibrary.Load-ing the file from disk before the runtime falls back to its default search algorithm. Static-ctor registration (rather than [ModuleInitializer]) sidesteps CA2255 under TreatWarningsAsErrors and still guarantees the resolver is in place before the first P/Invoke fires.
2026-05-07 15:14:54 -04:00
e3321ff279 feat(ui): wire DI bootstrap in App.xaml.cs and add Windows solution filter
Some checks failed
CI / build-and-test (push) Failing after 36s
2026-05-07 15:41:58 +00:00
d64b110550 feat(ui): add MainWindow XAML with participants DataGrid, settings sidebar, alert banner
Some checks failed
CI / build-and-test (push) Failing after 34s
2026-05-07 15:40:49 +00:00
8c441318d8 feat(ui): add MainViewModel with live participants collection and dispatcher marshalling
Some checks failed
CI / build-and-test (push) Failing after 34s
2026-05-07 15:40:06 +00:00
fbb73bcf04 feat(ui): add MVVM helpers and per-component view models (Participant, GlobalSettings, AlertBanner)
Some checks failed
CI / build-and-test (push) Has been cancelled
2026-05-07 15:39:46 +00:00
368920734d fix(console): disambiguate System.Console from TeamsISO.Console namespace
Some checks failed
CI / build-and-test (push) Has been cancelled
2026-05-07 15:38:02 +00:00
b2dafb7aed feat(console): add TeamsISO.Console headless smoke runner
Some checks failed
CI / build-and-test (push) Has been cancelled
2026-05-07 15:37:44 +00:00
88841780af feat(pipeline): add managed BGRA nearest-neighbor scaler with aspect modes
Some checks failed
CI / build-and-test (push) Failing after 27s
2026-05-07 15:37:07 +00:00
af37b4d9e1 refactor(interop): NdiRuntimeProbe now matches by prefix to handle NDI runtime version strings
Some checks failed
CI / build-and-test (push) Failing after 24s
2026-05-07 15:36:26 +00:00
da5818b690 feat(interop): add NdiInteropPInvoke production INdiInterop implementation
Some checks failed
CI / build-and-test (push) Failing after 26s
2026-05-07 15:35:59 +00:00
90b0951a42 fix(interop): rename create-settings structs to avoid name collision with P/Invoke methods
Some checks failed
CI / build-and-test (push) Failing after 26s
2026-05-07 15:35:15 +00:00
6f09ca35ef feat(interop): add NDI 6 native bindings, handle types, and version constants
Some checks failed
CI / build-and-test (push) Has been cancelled
2026-05-07 15:34:54 +00:00
cd5e852a30 feat(controller): add IIsoController and IsoController implementation
Some checks failed
CI / build-and-test (push) Has been cancelled
2026-05-07 15:28:27 +00:00
49b6dfb9ed feat(pipeline): add IsoPipeline with lifecycle and restart supervisor
Some checks failed
CI / build-and-test (push) Failing after 29s
2026-05-07 15:26:54 +00:00
e318514202 feat(interop): add NdiRuntimeProbe with version-mismatch result
Some checks failed
CI / build-and-test (push) Failing after 27s
2026-05-07 15:24:31 +00:00
798a5abd64 feat(pipeline): add ExponentialBackoff policy
Some checks failed
CI / build-and-test (push) Has been cancelled
2026-05-07 15:24:13 +00:00
aecbda674d feat(pipeline): add NdiSender with channel-based input
Some checks failed
CI / build-and-test (push) Has been cancelled
2026-05-07 15:23:51 +00:00
ead5e79935 feat(pipeline): add NdiReceiver with channel-based output
Some checks failed
CI / build-and-test (push) Has been cancelled
2026-05-07 15:23:26 +00:00
38f7db888e feat(domain): default global framerate to 59.94p (user's primary production target)
Some checks failed
CI / build-and-test (push) Failing after 25s
2026-05-07 15:21:58 +00:00
27dc0f90c7 feat(logging): add EngineLogging.CreateConsole helper
Some checks failed
CI / build-and-test (push) Has been cancelled
2026-05-07 15:16:17 +00:00
5c039025fd feat(pipeline): add FrameProcessor with closest-frame timing and slate fallback
Some checks failed
CI / build-and-test (push) Failing after 24s
2026-05-07 15:15:19 +00:00
970f04861d feat(pipeline): add SolidFrameRenderer slate and IFrameScaler/PassthroughFrameScaler
Some checks failed
CI / build-and-test (push) Failing after 25s
2026-05-07 15:14:37 +00:00
1b280e3e77 feat(discovery): add NdiDiscoveryService with diff-based event emission
Some checks failed
CI / build-and-test (push) Failing after 22s
2026-05-07 15:14:15 +00:00
cef9018b6d feat(discovery): add ParticipantTracker with rename heuristic
Some checks failed
CI / build-and-test (push) Failing after 22s
2026-05-07 15:13:42 +00:00
c07a668672 test(fakes): add FakeNdiInterop and FakeFrameClock; feat(discovery): add DiscoveryEvent
Some checks failed
CI / build-and-test (push) Failing after 24s
2026-05-07 15:13:00 +00:00
f562303b47 feat(pipeline,interop): add RawFrame, ProcessedFrame, IFrameClock and INdiInterop test seam
Some checks failed
CI / build-and-test (push) Has been cancelled
2026-05-07 15:12:36 +00:00
3f8b5f1a7b feat(persistence): add ConfigStore with atomic JSON writes and corruption-safe load
Some checks failed
CI / build-and-test (push) Failing after 22s
2026-05-07 15:12:01 +00:00
464f559576 feat(domain): add Participant, IsoAssignment, IsoOutput, IsoHealthStats, FrameProcessingSettings, EngineConfig, EngineAlert
Some checks failed
CI / build-and-test (push) Failing after 22s
2026-05-07 15:11:32 +00:00
aaf3184a8e feat(discovery): add NdiSource record and Teams source string parser
Some checks failed
CI / build-and-test (push) Failing after 22s
2026-05-07 15:11:00 +00:00
b07e3e78e0 feat(domain): add core enums (NdiSourceKind, IsoState, AspectMode, AudioMode, TargetFramerate, TargetResolution)
Some checks failed
CI / build-and-test (push) Failing after 23s
2026-05-07 15:10:29 +00:00
f21e818b28 chore: scaffold WPF app and integration test projects
- TeamsISO.App: hand-rolled net8.0-windows WPF csproj since the WPF
  template isn't shipped on linux-arm64 .NET SDK; UI is a placeholder
  for Phase C.
- TeamsISO.Engine.IntegrationTests: cross-platform xunit project with a
  skipped scaffold fact tagged [Trait("requires", "ndi")] for Phase B.
- TeamsISO.Linux.slnf: solution filter for non-Windows CI that excludes
  the WPF project (which can only build on Windows).
2026-05-07 15:09:56 +00:00
d0f05263af test(engine): scaffold TeamsISO.Engine.Tests xUnit project 2026-05-07 15:08:38 +00:00
14cbc25b99 feat(interop): scaffold TeamsISO.Engine.NdiInterop project 2026-05-07 15:08:21 +00:00
f9ab6fe0e7 feat(engine): scaffold TeamsISO.Engine class library 2026-05-07 15:08:11 +00:00
c355e9dc4c chore: add empty TeamsISO solution 2026-05-07 15:07:58 +00:00