Compare commits
No commits in common. "9cb1cc7b3d5ec73db33228995a7feb8021ea78bd" and "778e5163e9c05c590b59699e141cd30f764e810c" have entirely different histories.
9cb1cc7b3d
...
778e5163e9
8 changed files with 28 additions and 210 deletions
|
|
@ -6,68 +6,38 @@
|
||||||
- **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 + brand pass — May 2026** — see "Done since the May 2026 hand-off" below.
|
- **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 & Forgejo release** — WiX v5 MSI scaffold, ARP icon wired, tag-push release workflow that builds + uploads MSI as a release asset.
|
- **Phase D — WiX Installer** — WiX v5 MSI scaffold, Forgejo CI fix (artifact v3 pin), Forgejo release workflow on tag push.
|
||||||
|
|
||||||
## Done since the May 2026 hand-off
|
## Done in May 2026
|
||||||
|
|
||||||
### Engine
|
- Fixed `.sln` path-separator mismatch that broke `.slnf` filters on Windows.
|
||||||
- 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` so the engine starts on installs where the NDI dir isn't on PATH.
|
||||||
- `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 ...`).
|
||||||
- `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`).
|
||||||
- `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.
|
- `--list-sources` diagnostic on `TeamsISO.Console`.
|
||||||
- NDI **groups** end-to-end (discovery + output): `INdiInterop.CreateFinder(string?)` and `CreateSender(string, string?)` populate `p_groups`; `IsoController` threads them through from `EngineConfig.NdiGroups`.
|
- NDI groups end-to-end (discovery + output) so the operator can confine Teams' raw broadcasts to a private group.
|
||||||
- `ParticipantTracker` surfaces `NdiSourceKind.ActiveSpeaker` as a synthetic routable row named "Active Speaker" with a deterministic v5-GUID Id derived from `auto-mix:<machine>`.
|
- Hide-(Local) toggle so the user's own self-preview doesn't pollute the participants list.
|
||||||
- `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.
|
|
||||||
|
|
||||||
### 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.
|
- 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).
|
- WPF rebuilt around Wild Dragon brand × Microsoft Teams flush layout (left rail + chromeless title bar + caption controls + cyan accent + JetBrains Mono).
|
||||||
- Live frame counters in the Source / Live columns (in/out/drops, source resolution, running FPS).
|
- `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).
|
||||||
- Per-pipeline state surfaced in the ISO toggle: `● LIVE` (cyan), `● ERROR` (coral), `● NO SIGNAL` (amber), `…` (processing).
|
- Rolling daily file logging at `%LOCALAPPDATA%\TeamsISO\Logs\` via Serilog.Sinks.File.
|
||||||
- "Stop all ISOs" emergency button at the participants header.
|
- 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.
|
||||||
- Hide-(Local) toggle so the user's own self-preview is filtered from the participants list.
|
- Forgejo CI is green (`actions/upload-artifact` pinned to v3 since Forgejo doesn't support v4).
|
||||||
- Window position / size / state persisted to `%LOCALAPPDATA%\TeamsISO\window.json`, multi-monitor safe.
|
- WiX v5 MSI scaffold + Forgejo release workflow on tag push (windows-latest runner; uploads MSI as both workflow artifact and release asset).
|
||||||
- Tooltips on every interactive control in the settings panel + per-row textbox + ISO toggle.
|
- 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`).
|
||||||
### 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 (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`.
|
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).
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
3. **Toast / inline feedback** for "Apply Changes" and other settings actions (currently silent unless an error occurs).
|
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.
|
||||||
|
|
||||||
4. **Rail "Refresh sources"** affordance — force a discovery rescan, useful right after applying a new transcoder topology when the operator wants Teams to reconnect.
|
4. **Wild Dragon dragon-mark** — the rail logo is a stylized "W" placeholder; swap in the real dragon SVG when available.
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
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.
|
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)".
|
||||||
|
|
||||||
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,29 +545,6 @@
|
||||||
</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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@ 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;
|
||||||
|
|
@ -21,10 +20,9 @@ public sealed class GlobalSettingsViewModel : ObservableObject
|
||||||
private string _outputGroups;
|
private string _outputGroups;
|
||||||
private bool _hideLocalSelf = true;
|
private bool _hideLocalSelf = true;
|
||||||
|
|
||||||
public GlobalSettingsViewModel(IIsoController controller, ToastViewModel? toast = null)
|
public GlobalSettingsViewModel(IIsoController controller)
|
||||||
{
|
{
|
||||||
_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;
|
||||||
|
|
@ -82,8 +80,6 @@ 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()
|
||||||
|
|
@ -125,7 +121,5 @@ 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");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,6 @@ 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
|
||||||
|
|
@ -44,8 +43,7 @@ public sealed class MainViewModel : ObservableObject, IDisposable
|
||||||
{
|
{
|
||||||
_controller = controller;
|
_controller = controller;
|
||||||
_dispatcher = dispatcher;
|
_dispatcher = dispatcher;
|
||||||
Toast = new ToastViewModel(dispatcher);
|
Settings = new GlobalSettingsViewModel(controller);
|
||||||
Settings = new GlobalSettingsViewModel(controller, Toast);
|
|
||||||
|
|
||||||
_participantsSub = controller.Participants
|
_participantsSub = controller.Participants
|
||||||
.ObserveOn(new SynchronizationContextScheduler(
|
.ObserveOn(new SynchronizationContextScheduler(
|
||||||
|
|
|
||||||
|
|
@ -1,68 +0,0 @@
|
||||||
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);
|
HandleRemoved(r.Source);
|
||||||
break;
|
break;
|
||||||
case DiscoveryEvent.Removed r when r.Source.Kind == NdiSourceKind.ActiveSpeaker:
|
case DiscoveryEvent.Removed r when r.Source.Kind == NdiSourceKind.ActiveSpeaker:
|
||||||
HandleAutoMixRemoved(r.Source);
|
HandleRemoved(r.Source);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -125,23 +125,6 @@ public sealed class ParticipantTracker
|
||||||
_recentlyRemoved.Add(new RecentlyRemoved(existing.Id, source.MachineName, now));
|
_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)
|
private void PruneRecentlyRemoved(DateTimeOffset now)
|
||||||
{
|
{
|
||||||
_recentlyRemoved.RemoveAll(rr => now - rr.RemovedAt > _renameWindow);
|
_recentlyRemoved.RemoveAll(rr => now - rr.RemovedAt > _renameWindow);
|
||||||
|
|
|
||||||
|
|
@ -45,15 +45,7 @@ public sealed class IsoPipeline : IAsyncDisposable
|
||||||
private readonly object _frameTimesGate = new();
|
private readonly object _frameTimesGate = new();
|
||||||
|
|
||||||
public Guid ParticipantId { get; }
|
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;
|
public int ConsecutiveFailures => _consecutiveFailures;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
|
||||||
|
|
@ -135,32 +135,4 @@ public class ParticipantTrackerTests
|
||||||
tracker.Participants.Should().HaveCount(1);
|
tracker.Participants.Should().HaveCount(1);
|
||||||
tracker.Participants[0].Id.Should().Be(firstId);
|
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