diff --git a/installer/Package.wxs b/installer/Package.wxs
index 79de7cd..46b3de9 100644
--- a/installer/Package.wxs
+++ b/installer/Package.wxs
@@ -40,6 +40,7 @@
+
@@ -112,8 +113,9 @@
@@ -135,6 +137,29 @@
+
+
+
+
+
+
+
+
+
+ shape brief's empty-states section.
+
+ Two visual flavors gated by IsDiscovering (the VM holds
+ it true for ~8s after engine start, false thereafter):
+ - IsDiscovering=true → "Scanning for NDI sources…"
+ (neutral; cold-start can take
+ 1-3s for mDNS to settle)
+ - IsDiscovering=false → the explanatory empty state
+ ("open teams and start a
+ meeting") + Refresh CTA
+ This stops operators from staring at a "broken-looking"
+ empty table during the first second of every launch. -->
-
-
+
+
+
+
+
+
+
+
+
+
diff --git a/src/TeamsISO.App/ViewModels/MainViewModel.cs b/src/TeamsISO.App/ViewModels/MainViewModel.cs
index bf2616d..43dd856 100644
--- a/src/TeamsISO.App/ViewModels/MainViewModel.cs
+++ b/src/TeamsISO.App/ViewModels/MainViewModel.cs
@@ -31,6 +31,19 @@ public sealed partial class MainViewModel : ObservableObject, IDisposable
private readonly Dictionary _byId = new();
private string _statusText = "Starting…";
+ ///
+ /// Wall-clock at which kicked the engine. Used to
+ /// gate the "Scanning for NDI sources…" placeholder so it shows for a few
+ /// seconds after launch even when ParticipantCount == 0 (the bleak
+ /// "no ndi sources yet" empty state was being shown immediately and
+ /// operators assumed the app was broken before discovery had a chance to fire).
+ /// Null until InitializeAsync runs.
+ ///
+ private DateTimeOffset? _engineStartedAt;
+
+ /// How long after engine start to keep showing "Scanning…" instead of the empty-state copy.
+ private static readonly TimeSpan DiscoveryGracePeriod = TimeSpan.FromSeconds(8);
+
// _pendingPresetName / Deadline / Applied + the auto-apply path
// moved to MainViewModel.PresetCommands.cs.
@@ -233,6 +246,21 @@ public sealed partial class MainViewModel : ObservableObject, IDisposable
}
private int _participantCount;
+ ///
+ /// True for the first after engine start.
+ /// The XAML uses this to swap the empty-state placeholder from the bleak
+ /// "no ndi sources yet — open teams and start a meeting" copy (which reads
+ /// as broken to operators who just launched into an active meeting) to a
+ /// neutral "Scanning for NDI sources…" status while NDI Find resolves
+ /// mDNS responses. Always false once participants populate.
+ ///
+ public bool IsDiscovering
+ {
+ get => _isDiscovering;
+ private set => SetField(ref _isDiscovering, value);
+ }
+ private bool _isDiscovering;
+
///
/// Currently-enabled (live) ISO count — feeds the v2 transport strip's
/// "LIVE N" readout. The number is cyan-tinted when non-zero to draw
@@ -530,6 +558,19 @@ public sealed partial class MainViewModel : ObservableObject, IDisposable
ParticipantCount = totalParticipants;
LiveCount = enabledCount;
+ // IsDiscovering gates the "Scanning for NDI sources…" placeholder.
+ // True for DiscoveryGracePeriod after engine start AS LONG AS we
+ // haven't seen any participants yet; once anything arrives we drop
+ // out of the discovering state immediately (back to the OK path).
+ if (totalParticipants == 0 && _engineStartedAt is { } startedAt)
+ {
+ IsDiscovering = DateTimeOffset.UtcNow - startedAt < DiscoveryGracePeriod;
+ }
+ else if (IsDiscovering)
+ {
+ IsDiscovering = false;
+ }
+
// Session timer — start on first ISO going live, reset when none are
// live anymore. Subsequent enables after a full-zero gap restart the
// timer rather than resuming, which is the operator's mental model:
@@ -600,6 +641,8 @@ public sealed partial class MainViewModel : ObservableObject, IDisposable
public async Task InitializeAsync(CancellationToken cancellationToken)
{
StatusText = "Discovering NDI sources…";
+ _engineStartedAt = DateTimeOffset.UtcNow;
+ IsDiscovering = true;
await _controller.StartAsync(cancellationToken);
StatusText = $"Engine running at {_controller.GlobalSettings.FramerateHz:F2} fps target.";
diff --git a/src/TeamsISO.Engine/Discovery/NdiDiscoveryService.cs b/src/TeamsISO.Engine/Discovery/NdiDiscoveryService.cs
index d60ce94..e4d34bc 100644
--- a/src/TeamsISO.Engine/Discovery/NdiDiscoveryService.cs
+++ b/src/TeamsISO.Engine/Discovery/NdiDiscoveryService.cs
@@ -77,30 +77,82 @@ public sealed class NdiDiscoveryService
/// Long-running poll loop. Cancel the token to stop.
public async Task RunAsync(TimeSpan pollInterval, CancellationToken cancellationToken)
{
- using var timer = new PeriodicTimer(pollInterval);
+ // Wrap the whole method in try/finally so _finder is always disposed,
+ // including the cancellation paths inside the fast-poll loop.
try
{
- while (await timer.WaitForNextTickAsync(cancellationToken))
+ // First poll happens IMMEDIATELY — PeriodicTimer.WaitForNextTickAsync
+ // waits the full interval before its first tick, which created a
+ // noticeable cold-start window where the UI showed "no ndi sources yet"
+ // for ~500ms (or whatever the interval is) before discovery had a chance
+ // to fire. Operators launching into a meeting that was already broadcasting
+ // saw an empty table and assumed it was broken. Poll once up front to
+ // pull whatever the NDI runtime has already cached, then settle into
+ // the regular poll cadence.
+ try { PollOnce(); }
+ catch (Exception ex) { _logger.LogWarning(ex, "Initial discovery poll failed; falling through to timer loop."); }
+
+ // Aggressive poll cadence for the first ~3 seconds so cold-start mDNS
+ // resolution surfaces quickly (mDNS responses can lag the initial socket
+ // setup by 200-1500ms depending on the network and the responder); after
+ // that ramp down to the operator-configured pollInterval.
+ var fastInterval = TimeSpan.FromMilliseconds(200);
+ var startedAt = DateTimeOffset.UtcNow;
+ var rampToFullAfter = TimeSpan.FromSeconds(3);
+
+ using (var fastTimer = new PeriodicTimer(fastInterval))
{
- if (Interlocked.Exchange(ref _refreshRequested, 0) == 1)
+ try
{
- try
+ while (DateTimeOffset.UtcNow - startedAt < rampToFullAfter)
{
- _logger.LogInformation("Rebuilding NDI finder on operator request.");
- _finder.Dispose();
- _finder = _interop.CreateFinder(_discoveryGroups);
- _previous.Clear();
- }
- catch (Exception ex)
- {
- _logger.LogWarning(ex, "Finder refresh failed; continuing with existing finder.");
+ if (!await fastTimer.WaitForNextTickAsync(cancellationToken)) break;
+ if (Interlocked.Exchange(ref _refreshRequested, 0) == 1)
+ {
+ try
+ {
+ _logger.LogInformation("Rebuilding NDI finder on operator request.");
+ _finder.Dispose();
+ _finder = _interop.CreateFinder(_discoveryGroups);
+ _previous.Clear();
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Finder refresh failed; continuing with existing finder.");
+ }
+ }
+ try { PollOnce(); }
+ catch (Exception ex) { _logger.LogWarning(ex, "Discovery poll failed; will retry on next tick."); }
}
}
- try { PollOnce(); }
- catch (Exception ex) { _logger.LogWarning(ex, "Discovery poll failed; will retry on next tick."); }
+ catch (OperationCanceledException) { return; }
}
+
+ using var timer = new PeriodicTimer(pollInterval);
+ try
+ {
+ while (await timer.WaitForNextTickAsync(cancellationToken))
+ {
+ if (Interlocked.Exchange(ref _refreshRequested, 0) == 1)
+ {
+ try
+ {
+ _logger.LogInformation("Rebuilding NDI finder on operator request.");
+ _finder.Dispose();
+ _finder = _interop.CreateFinder(_discoveryGroups);
+ _previous.Clear();
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Finder refresh failed; continuing with existing finder.");
+ }
+ }
+ try { PollOnce(); }
+ catch (Exception ex) { _logger.LogWarning(ex, "Discovery poll failed; will retry on next tick."); }
+ }
+ }
+ catch (OperationCanceledException) { /* expected */ }
}
- catch (OperationCanceledException) { /* expected */ }
finally
{
_finder.Dispose();