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).
This commit is contained in:
Zac Gaetano 2026-05-09 09:30:04 -04:00
parent 778e5163e9
commit b2666236ec
5 changed files with 155 additions and 26 deletions

View file

@ -6,38 +6,68 @@
- **Phase B-1 — Pipeline Orchestration** (tag: `phase-b-1-complete`) — NdiReceiver, NdiSender, ExponentialBackoff, NdiRuntimeProbe, IsoPipeline supervisor, IsoController. - **Phase B-1 — Pipeline Orchestration** (tag: `phase-b-1-complete`) — NdiReceiver, NdiSender, ExponentialBackoff, NdiRuntimeProbe, IsoPipeline supervisor, IsoController.
- **Phase B-2 — Real NDI Interop** (tag: `phase-b-2-complete`) — `NdiInteropPInvoke` against NDI 6 SDK, managed BGRA scaler, `TeamsISO.Console` headless smoke runner, `NdiVersion` constants. - **Phase B-2 — Real NDI Interop** (tag: `phase-b-2-complete`) — `NdiInteropPInvoke` against NDI 6 SDK, managed BGRA scaler, `TeamsISO.Console` headless smoke runner, `NdiVersion` constants.
- **Phase C — WPF UI** (tag: `phase-c-complete`) — MVVM helpers, ParticipantViewModel, GlobalSettingsViewModel, AlertBannerViewModel, MainViewModel, MainWindow XAML with participants DataGrid + settings sidebar + alert banner, App.xaml DI bootstrap. - **Phase C — WPF UI** (tag: `phase-c-complete`) — MVVM helpers, ParticipantViewModel, GlobalSettingsViewModel, AlertBannerViewModel, MainViewModel, MainWindow XAML with participants DataGrid + settings sidebar + alert banner, App.xaml DI bootstrap.
- **Hardening — May 2026** (no tag) — see "Done in May 2026" below; covers the bug-fix triad that turned this from "won't even start" into "discovers real Teams participants in a real meeting", plus integration coverage and the brand/UX rebuild. - **Hardening + brand pass — May 2026** — see "Done since the May 2026 hand-off" below.
- **Phase D — WiX Installer** — WiX v5 MSI scaffold, Forgejo CI fix (artifact v3 pin), Forgejo release workflow on tag push. - **Phase D — WiX Installer & Forgejo release** — WiX v5 MSI scaffold, ARP icon wired, tag-push release workflow that builds + uploads MSI as a release asset.
## Done in May 2026 ## Done since the May 2026 hand-off
- Fixed `.sln` path-separator mismatch that broke `.slnf` filters on Windows. ### Engine
- `NdiNativeLibraryResolver` resolves `Processing.NDI.Lib.x64.dll` via `NDI_RUNTIME_DIR_V6` so the engine starts on installs where the NDI dir isn't on PATH. - Forward-slash project paths in `TeamsISO.sln` so `.slnf` filters work on Windows MSBuild.
- `NdiVersion.ExpectedRuntimeVersionPrefix` updated to match the shipping NDI 6 banner format (`NDI SDK WIN64 ...`). - `NdiNativeLibraryResolver` resolves `Processing.NDI.Lib.x64.dll` via `NDI_RUNTIME_DIR_V6` (with V5 / V4 fallbacks), so the engine starts on installs where the NDI dir isn't on PATH.
- `NdiSourceParser` accepts current Teams desktop's `MS Teams - <name>` brand format (plus legacy `Teams` and defensive `Microsoft Teams`). - `NdiVersion.ExpectedRuntimeVersionPrefix` updated to match the shipping NDI 6 banner format (`NDI SDK WIN64 …`).
- `--list-sources` diagnostic on `TeamsISO.Console`. - `NdiSourceParser` accepts current Teams desktop's `MS Teams - <name>` brand format (plus legacy `Teams` and defensive `Microsoft Teams`); reserved suffixes (`Active Speaker`, `Audio`, `Audio Mix`, `Screen Share`) are recognized in both legacy and dash-prefixed forms.
- NDI groups end-to-end (discovery + output) so the operator can confine Teams' raw broadcasts to a private group. - NDI **groups** end-to-end (discovery + output): `INdiInterop.CreateFinder(string?)` and `CreateSender(string, string?)` populate `p_groups`; `IsoController` threads them through from `EngineConfig.NdiGroups`.
- Hide-(Local) toggle so the user's own self-preview doesn't pollute the participants list. - `ParticipantTracker` surfaces `NdiSourceKind.ActiveSpeaker` as a synthetic routable row named "Active Speaker" with a deterministic v5-GUID Id derived from `auto-mix:<machine>`.
- Single-instance enforcement via per-user named Mutex with broadcast bring-to-front. - `IsoHealthStats` wired end-to-end: live receiver/sender/processor refs published from the inner pipeline, frame counters / source resolution / running FPS (30-frame moving window) / drops + duplicates / pipeline state surfaced via `IsoController.GetStats`.
- WPF rebuilt around Wild Dragon brand × Microsoft Teams flush layout (left rail + chromeless title bar + caption controls + cyan accent + JetBrains Mono).
- `IsoHealthStats` wired end-to-end: live receiver/sender refs published from the inner pipeline, frame counters and source resolution displayed in a `Live` column on the participants DataGrid (1 Hz polled).
- Rolling daily file logging at `%LOCALAPPDATA%\TeamsISO\Logs\` via Serilog.Sinks.File. - Rolling daily file logging at `%LOCALAPPDATA%\TeamsISO\Logs\` via Serilog.Sinks.File.
- Real-NDI integration test tier (`requires=ndi`): runtime probe, finder/sender lifecycle on default + custom groups, loopback discovery, and a full pipeline frame round-trip that asserts 1920×1080 normalization.
- Forgejo CI is green (`actions/upload-artifact` pinned to v3 since Forgejo doesn't support v4). ### UI
- WiX v5 MSI scaffold + Forgejo release workflow on tag push (windows-latest runner; uploads MSI as both workflow artifact and release asset). - WPF rebuilt around Wild Dragon brand × Microsoft Teams flush layout — left rail with real dragon-mark logo (clickable → About dialog), chromeless title bar with custom min/max/close caption controls, cyan accent.
- Code-review pass + fixes (no static `Serilog.Log.Logger` mutation, frame-dimension snapshot instead of holding a `RawFrame` ref, ComponentDispatcher unsubscribe, Mutex-ownership flag). - Inter Variable + JetBrains Mono Variable bundled as `<Resource>` so typography matches wilddragon.net regardless of system fonts.
- "Launch Teams" rail button (Phase E.1 starter, see `docs/superpowers/specs/2026-05-08-embedded-teams-orchestration.md`). - App icon `teamsiso.ico` (7 sizes) on taskbar / window / About / WiX MSI ARP.
- Single-instance enforcement via per-user named Mutex with broadcast bring-to-front.
- Empty-state placeholder when no Teams sources are visible (faded dragon + checklist).
- Live frame counters in the Source / Live columns (in/out/drops, source resolution, running FPS).
- Per-pipeline state surfaced in the ISO toggle: `● LIVE` (cyan), `● ERROR` (coral), `● NO SIGNAL` (amber), `…` (processing).
- "Stop all ISOs" emergency button at the participants header.
- Hide-(Local) toggle so the user's own self-preview is filtered from the participants list.
- Window position / size / state persisted to `%LOCALAPPDATA%\TeamsISO\window.json`, multi-monitor safe.
- Tooltips on every interactive control in the settings panel + per-row textbox + ISO toggle.
### Networking automation
- One-click **transcoder topology** button in Settings: writes `%APPDATA%\NDI\ndi-config.v1.json` so all local senders broadcast on `teamsiso-input` and local receivers see both `public` + `teamsiso-input`. Engine settings auto-flip to receive-from `teamsiso-input` and emit-on `public`. Atomic write with timestamped backup of the prior config.
### Phase E.1 starter (embedded Teams)
- Spec at `docs/superpowers/specs/2026-05-08-embedded-teams-orchestration.md` (three-phase rollout: launcher → window orchestration → in-app meeting controls).
- Rail "Launch / Stop Teams" toggle: launches via `ms-teams:` URI / `ms-teams.exe` / classic `Update.exe --processStart`, asks to confirm `WM_CLOSE` of all running Teams windows when toggled while Teams is up.
### Diagnostics
- `TeamsISO.Console --list-sources` enumerates raw NDI source names visible to the local finder for ~5 seconds; debugging tool for setup issues.
- `TeamsISO.Console --version` prints engine version + build SHA + .NET + OS + NDI runtime banner + exit-code legend, for support tickets.
- About dialog inside the WPF host with the same info.
### CI / Release / Docs
- Forgejo CI is green: `actions/upload-artifact@v3` (Forgejo doesn't support v4 yet).
- `.forgejo/workflows/release.yml`: tag-push (`v*.*.*`) builds + tests + publishes + builds the MSI on a Windows runner and attaches it to the auto-created Forgejo release via the REST API.
- `docs/RELEASING.md` walks through cutting a release and flags the code-signing TODO.
### Tests
- 78 unit tests passing; 9 NDI integration tests gated behind `--filter requires=ndi` (runtime probe, finder + sender lifecycle on default and custom groups, loopback discovery, full pipeline frame round-trip asserting 1080p normalization).
## Next ## Next
1. **Phase E — Embedded Teams orchestration** — see the spec at `docs/superpowers/specs/2026-05-08-embedded-teams-orchestration.md`. Three-phase rollout: launcher → window orchestration → in-app meeting controls. Phase E.1 partially shipped (launcher). 1. **Phase E — Embedded Teams orchestration (continued).** E.2 (window orchestration: hide Teams' main window once launched, forward keyboard shortcuts via SendInput) and E.3 (Microsoft Graph or UIAutomation in-call controls) per the spec at `docs/superpowers/specs/2026-05-08-embedded-teams-orchestration.md`.
2. **Code-signing the MSI**`installer/TeamsISO.Installer.wixproj` has a `SignOutput` hook but no cert. For a v1.0 release, wire `signtool` invocation from a release-only CI secret. Until then SmartScreen will warn on first launch. 2. **Code-signing the MSI.** `installer/TeamsISO.Installer.wixproj` has a `SignOutput` property hook but no cert; for v1.0 wire a `signtool` invocation from a CI secret. SmartScreen will warn on first launch until that lands.
3. **Bundle Inter / JetBrains Mono fonts** so the typography doesn't depend on Windows fallbacks. Today the WPF UI uses Segoe UI Variable Display + Cascadia Mono as fallbacks. 3. **Toast / inline feedback** for "Apply Changes" and other settings actions (currently silent unless an error occurs).
4. **Wild Dragon dragon-mark** — the rail logo is a stylized "W" placeholder; swap in the real dragon SVG when available. 4. **Rail "Refresh sources"** affordance — force a discovery rescan, useful right after applying a new transcoder topology when the operator wants Teams to reconnect.
5. **Optional polish before v1.0** — running incoming-fps display (today the field on `IsoHealthStats` is 0), per-pipeline output thumbnail previews, MaterialDesignThemes-equivalent transitions, system health (CPU/network) meters in the rail. 5. **Output thumbnail previews** in the participant DataGrid — small live-frame previews of each ISO output. Complex (needs WPF WriteableBitmap from the Pipeline's last ProcessedFrame); deferred until v1.5.
6. **Drops counter on `IsoHealthStats`**`FrameProcessor` doesn't currently surface drops or duplicates; wire those through so the Live column can show "↓ N (dropped M)". 6. **Settings panel UX tightening.** It's getting long; consider an Accordion or tabs for OUTPUT FORMAT / NDI NETWORK / DISPLAY rather than one scrolling stack.
7. **Auto-disable on participant departure (configurable).** Today an ISO stays "enabled" if its participant leaves; the source goes null. Optional toggle: tear down the pipeline automatically when a participant has been gone past the rename window.
8. **Operator presets.** "Save current ISO assignments to a named preset" + "Load preset on next launch" so an operator with a recurring show doesn't have to re-toggle every meeting.

View file

@ -545,6 +545,29 @@
</DataGrid> </DataGrid>
</Grid> </Grid>
</Border> </Border>
<!-- Toast overlay: bottom-center, auto-dismissing transient notification. -->
<Border Grid.Row="2"
VerticalAlignment="Bottom"
HorizontalAlignment="Center"
Margin="0,0,0,16"
Background="{DynamicResource Wd.SurfaceElevated}"
BorderBrush="{DynamicResource Wd.BorderStrong}"
BorderThickness="1"
CornerRadius="999"
Padding="14,8"
Visibility="{Binding Toast.IsVisible, Converter={StaticResource BoolToVis}}">
<StackPanel Orientation="Horizontal">
<Ellipse Width="8" Height="8"
Fill="{DynamicResource Wd.Accent.Cyan}"
VerticalAlignment="Center"/>
<TextBlock Text="{Binding Toast.Message}"
Style="{StaticResource Wd.Text.Body}"
FontSize="12"
Margin="10,0,0,0"
VerticalAlignment="Center"/>
</StackPanel>
</Border>
</Grid> </Grid>
</DockPanel> </DockPanel>

View file

@ -12,6 +12,7 @@ namespace TeamsISO.App.ViewModels;
public sealed class GlobalSettingsViewModel : ObservableObject public sealed class GlobalSettingsViewModel : ObservableObject
{ {
private readonly IIsoController _controller; private readonly IIsoController _controller;
private readonly ToastViewModel? _toast;
private TargetFramerate _framerate; private TargetFramerate _framerate;
private TargetResolution _resolution; private TargetResolution _resolution;
private AspectMode _aspect; private AspectMode _aspect;
@ -20,9 +21,10 @@ public sealed class GlobalSettingsViewModel : ObservableObject
private string _outputGroups; private string _outputGroups;
private bool _hideLocalSelf = true; private bool _hideLocalSelf = true;
public GlobalSettingsViewModel(IIsoController controller) public GlobalSettingsViewModel(IIsoController controller, ToastViewModel? toast = null)
{ {
_controller = controller; _controller = controller;
_toast = toast;
var current = controller.GlobalSettings; var current = controller.GlobalSettings;
_framerate = current.Framerate; _framerate = current.Framerate;
_resolution = current.Resolution; _resolution = current.Resolution;
@ -80,6 +82,8 @@ public sealed class GlobalSettingsViewModel : ObservableObject
DiscoveryGroups: string.IsNullOrWhiteSpace(_discoveryGroups) ? null : _discoveryGroups.Trim(), DiscoveryGroups: string.IsNullOrWhiteSpace(_discoveryGroups) ? null : _discoveryGroups.Trim(),
OutputGroups: string.IsNullOrWhiteSpace(_outputGroups) ? null : _outputGroups.Trim()); OutputGroups: string.IsNullOrWhiteSpace(_outputGroups) ? null : _outputGroups.Trim());
await _controller.SetGroupSettingsAsync(groups, CancellationToken.None); await _controller.SetGroupSettingsAsync(groups, CancellationToken.None);
_toast?.Show("Settings saved");
} }
private async Task ApplyTranscoderTopologyAsync() private async Task ApplyTranscoderTopologyAsync()
@ -121,5 +125,7 @@ public sealed class GlobalSettingsViewModel : ObservableObject
"TeamsISO — Apply transcoder topology", "TeamsISO — Apply transcoder topology",
MessageBoxButton.OK, MessageBoxButton.OK,
MessageBoxImage.Information); MessageBoxImage.Information);
_toast?.Show("Transcoder topology applied — restart Teams to take effect");
} }
} }

View file

@ -25,6 +25,7 @@ public sealed class MainViewModel : ObservableObject, IDisposable
public ObservableCollection<ParticipantViewModel> Participants { get; } = new(); public ObservableCollection<ParticipantViewModel> Participants { get; } = new();
public GlobalSettingsViewModel Settings { get; } public GlobalSettingsViewModel Settings { get; }
public AlertBannerViewModel AlertBanner { get; } = new(); public AlertBannerViewModel AlertBanner { get; } = new();
public ToastViewModel Toast { get; }
/// <summary> /// <summary>
/// Emergency-stop: disable every running ISO. Bound to a small "Stop all" affordance /// Emergency-stop: disable every running ISO. Bound to a small "Stop all" affordance
@ -43,7 +44,8 @@ public sealed class MainViewModel : ObservableObject, IDisposable
{ {
_controller = controller; _controller = controller;
_dispatcher = dispatcher; _dispatcher = dispatcher;
Settings = new GlobalSettingsViewModel(controller); Toast = new ToastViewModel(dispatcher);
Settings = new GlobalSettingsViewModel(controller, Toast);
_participantsSub = controller.Participants _participantsSub = controller.Participants
.ObserveOn(new SynchronizationContextScheduler( .ObserveOn(new SynchronizationContextScheduler(

View file

@ -0,0 +1,68 @@
using System.Windows.Threading;
namespace TeamsISO.App.ViewModels;
/// <summary>
/// Lightweight transient-notification view-model. The main view holds a single
/// instance bound to a small overlay at the bottom of the content area.
/// <see cref="Show"/> displays a message for a fixed duration before auto-hiding;
/// successive Show calls reset the timer instead of stacking, so the user always
/// sees the most recent action's feedback.
/// </summary>
public sealed class ToastViewModel : ObservableObject
{
private readonly DispatcherTimer _hideTimer;
private string _message = string.Empty;
private bool _isVisible;
private string _accent = "Wd.Accent.Cyan";
public ToastViewModel(Dispatcher dispatcher)
{
_hideTimer = new DispatcherTimer(DispatcherPriority.Background, dispatcher)
{
Interval = TimeSpan.FromSeconds(3),
};
_hideTimer.Tick += (_, _) =>
{
_hideTimer.Stop();
IsVisible = false;
};
}
public string Message
{
get => _message;
private set => SetField(ref _message, value);
}
public bool IsVisible
{
get => _isVisible;
private set => SetField(ref _isVisible, value);
}
/// <summary>
/// Brush resource key for the accent dot. "Wd.Accent.Cyan" for success-style
/// (default), "Wd.Accent.Coral" for warnings.
/// </summary>
public string Accent
{
get => _accent;
private set => SetField(ref _accent, value);
}
/// <summary>Show a success-style toast for ~3 seconds.</summary>
public void Show(string message) => ShowImpl(message, "Wd.Accent.Cyan");
/// <summary>Show a warning-style toast (coral accent) for ~3 seconds.</summary>
public void Warn(string message) => ShowImpl(message, "Wd.Accent.Coral");
private void ShowImpl(string message, string accentKey)
{
Message = message;
Accent = accentKey;
IsVisible = true;
_hideTimer.Stop();
_hideTimer.Start();
}
}