Compare commits
2 commits
778e5163e9
...
9cb1cc7b3d
| Author | SHA1 | Date | |
|---|---|---|---|
| 9cb1cc7b3d | |||
| b2666236ec |
8 changed files with 210 additions and 28 deletions
|
|
@ -6,38 +6,68 @@
|
|||
- **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 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.
|
||||
- **Phase D — WiX Installer** — WiX v5 MSI scaffold, Forgejo CI fix (artifact v3 pin), Forgejo release workflow on tag push.
|
||||
- **Hardening + brand pass — May 2026** — see "Done since the May 2026 hand-off" below.
|
||||
- **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.
|
||||
- `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.
|
||||
- `NdiVersion.ExpectedRuntimeVersionPrefix` updated to match the shipping NDI 6 banner format (`NDI SDK WIN64 ...`).
|
||||
- `NdiSourceParser` accepts current Teams desktop's `MS Teams - <name>` brand format (plus legacy `Teams` and defensive `Microsoft Teams`).
|
||||
- `--list-sources` diagnostic on `TeamsISO.Console`.
|
||||
- NDI groups end-to-end (discovery + output) so the operator can confine Teams' raw broadcasts to a private group.
|
||||
- Hide-(Local) toggle so the user's own self-preview doesn't pollute the participants list.
|
||||
- Single-instance enforcement via per-user named Mutex with broadcast bring-to-front.
|
||||
- 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).
|
||||
### Engine
|
||||
- Forward-slash project paths in `TeamsISO.sln` so `.slnf` filters work on Windows MSBuild.
|
||||
- `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.
|
||||
- `NdiVersion.ExpectedRuntimeVersionPrefix` updated to match the shipping NDI 6 banner format (`NDI SDK WIN64 …`).
|
||||
- `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): `INdiInterop.CreateFinder(string?)` and `CreateSender(string, string?)` populate `p_groups`; `IsoController` threads them through from `EngineConfig.NdiGroups`.
|
||||
- `ParticipantTracker` surfaces `NdiSourceKind.ActiveSpeaker` as a synthetic routable row named "Active Speaker" with a deterministic v5-GUID Id derived from `auto-mix:<machine>`.
|
||||
- `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`.
|
||||
- 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).
|
||||
- WiX v5 MSI scaffold + Forgejo release workflow on tag push (windows-latest runner; uploads MSI as both workflow artifact and release asset).
|
||||
- Code-review pass + fixes (no static `Serilog.Log.Logger` mutation, frame-dimension snapshot instead of holding a `RawFrame` ref, ComponentDispatcher unsubscribe, Mutex-ownership flag).
|
||||
- "Launch Teams" rail button (Phase E.1 starter, see `docs/superpowers/specs/2026-05-08-embedded-teams-orchestration.md`).
|
||||
|
||||
### UI
|
||||
- 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.
|
||||
- Inter Variable + JetBrains Mono Variable bundled as `<Resource>` so typography matches wilddragon.net regardless of system fonts.
|
||||
- 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
|
||||
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -545,6 +545,29 @@
|
|||
</DataGrid>
|
||||
</Grid>
|
||||
</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>
|
||||
</DockPanel>
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ namespace TeamsISO.App.ViewModels;
|
|||
public sealed class GlobalSettingsViewModel : ObservableObject
|
||||
{
|
||||
private readonly IIsoController _controller;
|
||||
private readonly ToastViewModel? _toast;
|
||||
private TargetFramerate _framerate;
|
||||
private TargetResolution _resolution;
|
||||
private AspectMode _aspect;
|
||||
|
|
@ -20,9 +21,10 @@ public sealed class GlobalSettingsViewModel : ObservableObject
|
|||
private string _outputGroups;
|
||||
private bool _hideLocalSelf = true;
|
||||
|
||||
public GlobalSettingsViewModel(IIsoController controller)
|
||||
public GlobalSettingsViewModel(IIsoController controller, ToastViewModel? toast = null)
|
||||
{
|
||||
_controller = controller;
|
||||
_toast = toast;
|
||||
var current = controller.GlobalSettings;
|
||||
_framerate = current.Framerate;
|
||||
_resolution = current.Resolution;
|
||||
|
|
@ -80,6 +82,8 @@ public sealed class GlobalSettingsViewModel : ObservableObject
|
|||
DiscoveryGroups: string.IsNullOrWhiteSpace(_discoveryGroups) ? null : _discoveryGroups.Trim(),
|
||||
OutputGroups: string.IsNullOrWhiteSpace(_outputGroups) ? null : _outputGroups.Trim());
|
||||
await _controller.SetGroupSettingsAsync(groups, CancellationToken.None);
|
||||
|
||||
_toast?.Show("Settings saved");
|
||||
}
|
||||
|
||||
private async Task ApplyTranscoderTopologyAsync()
|
||||
|
|
@ -121,5 +125,7 @@ public sealed class GlobalSettingsViewModel : ObservableObject
|
|||
"TeamsISO — Apply transcoder topology",
|
||||
MessageBoxButton.OK,
|
||||
MessageBoxImage.Information);
|
||||
|
||||
_toast?.Show("Transcoder topology applied — restart Teams to take effect");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ public sealed class MainViewModel : ObservableObject, IDisposable
|
|||
public ObservableCollection<ParticipantViewModel> Participants { get; } = new();
|
||||
public GlobalSettingsViewModel Settings { get; }
|
||||
public AlertBannerViewModel AlertBanner { get; } = new();
|
||||
public ToastViewModel Toast { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 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;
|
||||
_dispatcher = dispatcher;
|
||||
Settings = new GlobalSettingsViewModel(controller);
|
||||
Toast = new ToastViewModel(dispatcher);
|
||||
Settings = new GlobalSettingsViewModel(controller, Toast);
|
||||
|
||||
_participantsSub = controller.Participants
|
||||
.ObserveOn(new SynchronizationContextScheduler(
|
||||
|
|
|
|||
68
src/TeamsISO.App/ViewModels/ToastViewModel.cs
Normal file
68
src/TeamsISO.App/ViewModels/ToastViewModel.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -38,7 +38,7 @@ public sealed class ParticipantTracker
|
|||
HandleRemoved(r.Source);
|
||||
break;
|
||||
case DiscoveryEvent.Removed r when r.Source.Kind == NdiSourceKind.ActiveSpeaker:
|
||||
HandleRemoved(r.Source);
|
||||
HandleAutoMixRemoved(r.Source);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
|
@ -125,6 +125,23 @@ public sealed class ParticipantTracker
|
|||
_recentlyRemoved.Add(new RecentlyRemoved(existing.Id, source.MachineName, now));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes the synthetic auto-mix row's CurrentSource. Crucially does NOT add to
|
||||
/// <see cref="_recentlyRemoved"/> — the auto-mix row's identity is already stable
|
||||
/// via the deterministic v5 GUID, so re-add restores it without needing the
|
||||
/// rename-window heuristic, and we must not let an active-speaker disappearance
|
||||
/// poison the rename matcher for a Participant joining the same machine within
|
||||
/// the window.
|
||||
/// </summary>
|
||||
private void HandleAutoMixRemoved(NdiSource source)
|
||||
{
|
||||
var stableId = DeterministicGuid("auto-mix:" + source.MachineName);
|
||||
var existing = _participants.FirstOrDefault(p => p.Id == stableId);
|
||||
if (existing is null) return;
|
||||
existing.CurrentSource = null;
|
||||
existing.LastSeen = _now();
|
||||
}
|
||||
|
||||
private void PruneRecentlyRemoved(DateTimeOffset now)
|
||||
{
|
||||
_recentlyRemoved.RemoveAll(rr => now - rr.RemovedAt > _renameWindow);
|
||||
|
|
|
|||
|
|
@ -45,7 +45,15 @@ public sealed class IsoPipeline : IAsyncDisposable
|
|||
private readonly object _frameTimesGate = new();
|
||||
|
||||
public Guid ParticipantId { get; }
|
||||
public IsoState State { get; private set; } = IsoState.Idle;
|
||||
|
||||
// Backing field for State, accessed via Volatile.Read/Write so the supervisor
|
||||
// loop's writes are observed promptly by the UI thread's stats poll.
|
||||
private int _state = (int)IsoState.Idle;
|
||||
public IsoState State
|
||||
{
|
||||
get => (IsoState)Volatile.Read(ref _state);
|
||||
private set => Volatile.Write(ref _state, (int)value);
|
||||
}
|
||||
public int ConsecutiveFailures => _consecutiveFailures;
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -135,4 +135,32 @@ public class ParticipantTrackerTests
|
|||
tracker.Participants.Should().HaveCount(1);
|
||||
tracker.Participants[0].Id.Should().Be(firstId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ActiveSpeakerRemove_DoesNotPoisonRenameWindowForLaterParticipant()
|
||||
{
|
||||
// Regression: the rename-window heuristic matches by MachineName alone, so a
|
||||
// disappearing ActiveSpeaker source on a machine could cause the next
|
||||
// Participant joining that same machine to inherit the auto-mix's GUID, AND
|
||||
// the auto-mix row would be renamed to the participant's display name.
|
||||
// HandleAutoMixRemoved deliberately skips _recentlyRemoved.
|
||||
var tracker = new ParticipantTracker(TimeSpan.FromSeconds(5), () => T0);
|
||||
var autoMix = new NdiSource("WOOGLIN (MS Teams - Active Speaker)", "WOOGLIN", NdiSourceKind.ActiveSpeaker, null);
|
||||
var jane = new NdiSource("WOOGLIN (MS Teams - Jane)", "WOOGLIN", NdiSourceKind.Participant, "Jane");
|
||||
|
||||
tracker.Apply(new DiscoveryEvent.Added(autoMix));
|
||||
var autoMixId = tracker.Participants[0].Id;
|
||||
tracker.Apply(new DiscoveryEvent.Removed(autoMix));
|
||||
tracker.Apply(new DiscoveryEvent.Added(jane));
|
||||
|
||||
// Two distinct rows: the auto-mix (offline, no source) and Jane (a brand-new participant).
|
||||
tracker.Participants.Should().HaveCount(2);
|
||||
var jp = tracker.Participants.Single(p => p.DisplayName == "Jane");
|
||||
jp.Id.Should().NotBe(autoMixId,
|
||||
because: "Jane is a fresh Participant, not a renamed auto-mix");
|
||||
var asRow = tracker.Participants.Single(p => p.DisplayName == "Active Speaker");
|
||||
asRow.Id.Should().Be(autoMixId);
|
||||
asRow.CurrentSource.Should().BeNull(
|
||||
because: "the auto-mix source went away and hasn't been re-added");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue