using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Reactive.Concurrency;
using System.Reactive.Linq;
using System.Windows.Data;
using System.Windows.Threading;
using TeamsISO.App.Services;
using TeamsISO.Engine.Controller;
using TeamsISO.Engine.Domain;
namespace TeamsISO.App.ViewModels;
///
/// Top-level view model for the main window. Owns the live collection of ,
/// the global settings panel, and the alert banner. Subscribes to 's observables
/// and marshals updates onto the UI dispatcher.
///
public sealed class MainViewModel : ObservableObject, IDisposable
{
private readonly IIsoController _controller;
private readonly Dispatcher _dispatcher;
private readonly IDisposable _participantsSub;
private readonly IDisposable _alertsSub;
private readonly DispatcherTimer _statsTimer;
private readonly Dictionary _byId = new();
private string _statusText = "Starting…";
// Auto-apply-last-preset bookkeeping. Set on InitializeAsync from disk;
// cleared once we successfully apply (so we don't re-apply when the
// participant list later mutates). The grace deadline gives Teams enough
// time to publish all initial sources after engine start before we attempt
// the apply — applying before everyone's visible would partially-restore
// the routing and silently drop assignments for late-appearing participants.
private string? _pendingPresetName;
private DateTimeOffset _pendingPresetDeadline;
private bool _pendingPresetApplied;
public ObservableCollection Participants { get; } = new();
///
/// Filter-backed view over . The DataGrid binds
/// to this rather than the raw collection so the operator's filter text
/// hides non-matching rows without mutating the underlying observable
/// (which would break IsoController's identity tracking).
///
public ICollectionView ParticipantsView { get; }
private string _participantFilter = string.Empty;
///
/// Apply the operator's saved sort preference to .
/// JoinOrder = no SortDescriptions (whatever order participants are added in);
/// Alphabetical = ascending by DisplayName; OnlineFirst = IsOnline desc then
/// DisplayName asc. Called on construction and from .
///
private void ApplySortFromPrefs()
{
var prefs = Services.UIPreferences.Load();
SetSortMode(prefs.ParticipantSort);
}
///
/// Re-applies the sort descriptions on the ParticipantsView. Called from the
/// settings panel when the operator picks a different sort mode.
///
public void SetSortMode(Services.UIPreferences.SortMode mode)
{
ParticipantsView.SortDescriptions.Clear();
switch (mode)
{
case Services.UIPreferences.SortMode.Alphabetical:
ParticipantsView.SortDescriptions.Add(new SortDescription(
nameof(ParticipantViewModel.DisplayName), ListSortDirection.Ascending));
break;
case Services.UIPreferences.SortMode.OnlineFirst:
ParticipantsView.SortDescriptions.Add(new SortDescription(
nameof(ParticipantViewModel.IsOnline), ListSortDirection.Descending));
ParticipantsView.SortDescriptions.Add(new SortDescription(
nameof(ParticipantViewModel.DisplayName), ListSortDirection.Ascending));
break;
// JoinOrder: leave SortDescriptions empty.
}
}
///
/// Live filter substring. Empty = show everyone. Matched case-insensitively
/// against display name. Setter refreshes the view immediately so the
/// DataGrid reflows as the operator types.
///
public string ParticipantFilter
{
get => _participantFilter;
set
{
if (SetField(ref _participantFilter, value))
ParticipantsView.Refresh();
}
}
public GlobalSettingsViewModel Settings { get; }
public AlertBannerViewModel AlertBanner { get; } = new();
public ToastViewModel Toast { get; }
public UpdateBannerViewModel UpdateBanner { get; } = new();
///
/// Engine-side controller. Exposed so the PresetsDialog (a Window, not a VM)
/// can re-issue EnableIsoAsync / DisableIsoAsync when applying a preset
/// without us having to plumb a per-action command through the participant
/// view-models from the dialog's XAML.
///
internal IIsoController Controller => _controller;
///
/// Emergency-stop: disable every running ISO. Bound to a small "Stop all" affordance
/// near the participants header so an operator can kill all outputs in a single click
/// when something goes sideways during a live show.
///
public AsyncRelayCommand StopAllIsosCommand { get; }
///
/// Bulk-enable: turn on ISOs for every online participant whose pipeline isn't
/// already running. Useful for "everyone joined, hit one button, every route goes
/// live." Skips offline rows (no source) and rows already enabled.
///
public AsyncRelayCommand EnableAllOnlineCommand { get; }
///
/// Force NDI discovery to rebuild its finder. Surfaced as a small "Refresh" pill
/// next to the participants header — useful right after Apply Transcoder Topology
/// or when Teams restarts mid-session and stale TTLs are masking new sources.
///
public RelayCommand RefreshDiscoveryCommand { get; }
// ════════════════════════════════════════════════════════════════════════
// Phase E.3 — In-call controls. Each command drives a UIAutomation lookup
// against Teams' window tree and reports a toast on outcome. Best-effort:
// a control-not-found result toasts a hint rather than throwing, since
// Teams isn't always in a call (the buttons only appear in-call).
// ════════════════════════════════════════════════════════════════════════
public RelayCommand ToggleMuteCommand { get; }
public RelayCommand ToggleCameraCommand { get; }
public RelayCommand LeaveCallCommand { get; }
public RelayCommand OpenShareTrayCommand { get; }
///
/// Drop a timestamped marker into every active recording. Bound to a button
/// in the IN-CALL bar; eventually wireable to a global hotkey. The marker
/// label is auto-generated as "Marker @ HH:mm:ss" — operators who want
/// custom labels can edit manifest.json after the fact.
///
public RelayCommand DropRecordingMarkerCommand { get; }
/// F1 binding — opens the help / cheat-sheet dialog.
public RelayCommand ShowHelpCommand { get; }
/// Opens the inline notes viewer for today's show-notes file.
public RelayCommand ShowNotesCommand { get; }
///
/// Roll-recording: disable + re-enable every currently-recording pipeline,
/// starting a fresh recording chunk in a new subdirectory. Operator-friendly
/// chaptering between show segments without losing already-recorded footage
/// (the previous chunk is finalized on disable, the next chunk starts clean).
///
public AsyncRelayCommand RollRecordingCommand { get; }
/// Join a Teams meeting from a pasted URL — see .
public RelayCommand JoinMeetingCommand { get; }
///
/// Two-way bound to the quick-join input. Whatever the operator pastes
/// gets handed to when the
/// Join button fires. Cleared on success so the field is ready for the
/// next paste.
///
public string JoinMeetingUrl
{
get => _joinMeetingUrl;
set => SetField(ref _joinMeetingUrl, value);
}
private string _joinMeetingUrl = string.Empty;
public string StatusText
{
get => _statusText;
set => SetField(ref _statusText, value);
}
///
/// Recording status badge — true when at least one ISO is being recorded.
/// Polled at the existing 1Hz stats tick rather than via a dedicated change
/// event, since recording state shifts on enable/disable transitions and
/// the stats poll already reads each pipeline's state.
///
public bool IsRecording
{
get => _isRecording;
private set => SetField(ref _isRecording, value);
}
private bool _isRecording;
/// Number of pipelines currently writing to the recorder.
public int ActiveRecordingCount
{
get => _activeRecordingCount;
private set => SetField(ref _activeRecordingCount, value);
}
private int _activeRecordingCount;
/// True when the REST control surface (or OSC bridge, or both) is listening.
public bool IsControlSurfaceRunning
{
get => _isControlSurfaceRunning;
private set => SetField(ref _isControlSurfaceRunning, value);
}
private bool _isControlSurfaceRunning;
/// Human-readable string for the control-surface tooltip ("REST :9755 + OSC :9000").
public string ControlSurfaceText
{
get => _controlSurfaceText;
private set => SetField(ref _controlSurfaceText, value);
}
private string _controlSurfaceText = string.Empty;
///
/// "IN CALL" when Teams is in an active meeting; "READY" when Teams is
/// running but not in a call; empty when Teams isn't running. Surfaced
/// as a status pill in the IN-CALL bar so operators with auto-hide on
/// can see Teams' state without restoring its window.
///
public string TeamsMeetingState
{
get => _teamsMeetingState;
private set
{
if (SetField(ref _teamsMeetingState, value))
OnPropertyChanged(nameof(HasTeamsState));
}
}
private string _teamsMeetingState = string.Empty;
/// True when Teams is currently in a call (Leave button present in UIA tree).
public bool IsTeamsInCall
{
get => _isTeamsInCall;
private set => SetField(ref _isTeamsInCall, value);
}
private bool _isTeamsInCall;
/// True when is non-empty. Used to gate visibility of the IN-CALL bar status pill via the existing BoolToVis converter.
public bool HasTeamsState => !string.IsNullOrEmpty(_teamsMeetingState);
///
/// Elapsed time since the first ISO went live, formatted "HH:mm:ss". Empty
/// when nothing's running. Useful for operators tracking show length.
/// Resets when all ISOs go offline (next time one comes back, the timer
/// starts from 00:00:00 again).
///
public string SessionElapsed
{
get => _sessionElapsed;
private set => SetField(ref _sessionElapsed, value);
}
private string _sessionElapsed = string.Empty;
public bool IsSessionActive
{
get => _isSessionActive;
private set => SetField(ref _isSessionActive, value);
}
private bool _isSessionActive;
private DateTimeOffset? _sessionStartedAt;
public MainViewModel(IIsoController controller, Dispatcher dispatcher)
{
_controller = controller;
_dispatcher = dispatcher;
Toast = new ToastViewModel(dispatcher);
Settings = new GlobalSettingsViewModel(controller, Toast);
// Set up the filter-aware view AFTER Participants is non-null. The
// CollectionView binds to the live collection; Filter callback runs
// each time Refresh() is called or the collection mutates.
ParticipantsView = CollectionViewSource.GetDefaultView(Participants);
ParticipantsView.Filter = obj =>
{
if (string.IsNullOrEmpty(_participantFilter)) return true;
return obj is ParticipantViewModel p &&
p.DisplayName.Contains(_participantFilter, StringComparison.OrdinalIgnoreCase);
};
// Apply the operator's saved sort preference, if any.
ApplySortFromPrefs();
_participantsSub = controller.Participants
.ObserveOn(new SynchronizationContextScheduler(
System.Threading.SynchronizationContext.Current ?? new DispatcherSynchronizationContext(_dispatcher)))
.Subscribe(OnParticipantsChanged);
_alertsSub = controller.Alerts
.ObserveOn(new SynchronizationContextScheduler(
System.Threading.SynchronizationContext.Current ?? new DispatcherSynchronizationContext(_dispatcher)))
.Subscribe(alert =>
{
AlertBanner.Current = alert;
});
// 1 Hz stats poll — pull live frame counters from each running pipeline and
// push them onto the per-participant view models. Cheap (just reads volatile
// fields on the engine side) and runs on the UI dispatcher so SetField is safe.
_statsTimer = new DispatcherTimer(DispatcherPriority.Background, _dispatcher)
{
Interval = TimeSpan.FromSeconds(1),
};
_statsTimer.Tick += OnStatsTick;
_statsTimer.Start();
StopAllIsosCommand = new AsyncRelayCommand(StopAllIsosAsync, () => Participants.Any(p => p.IsEnabled));
EnableAllOnlineCommand = new AsyncRelayCommand(EnableAllOnlineAsync,
() => Participants.Any(p => p.IsOnline && !p.IsEnabled));
RefreshDiscoveryCommand = new RelayCommand(() =>
{
_controller.RefreshDiscovery();
Toast.Show("Refreshing NDI discovery…");
});
DropRecordingMarkerCommand = new RelayCommand(() =>
{
var label = "Marker @ " + DateTimeOffset.Now.ToString("HH:mm:ss");
_controller.AddRecordingMarker(label);
Toast.Show($"Marker dropped: {label}");
});
ShowHelpCommand = new RelayCommand(() =>
{
// Showing a Window from a VM violates strict MVVM, but TeamsISO doesn't
// ship a navigation service and a HelpWindow is purely a UI concern.
// Owner is set so the dialog centers and inherits z-order.
var help = new HelpWindow { Owner = System.Windows.Application.Current?.MainWindow };
help.ShowDialog();
});
ShowNotesCommand = new RelayCommand(() =>
{
var notes = new NotesWindow { Owner = System.Windows.Application.Current?.MainWindow };
notes.Show(); // non-modal so operators can stamp + read alongside the show
});
JoinMeetingCommand = new RelayCommand(() =>
{
// Trim + handle the operator pasting whitespace around the URL.
var url = (_joinMeetingUrl ?? string.Empty).Trim();
if (string.IsNullOrEmpty(url)) return;
if (TeamsLauncher.TryJoinMeeting(url, out var error))
{
Toast.Show("Joining Teams meeting…");
JoinMeetingUrl = string.Empty;
// If the operator has auto-hide on, kick off the hide watcher
// so the Teams meeting window goes away as soon as it renders.
if (Settings.AutoHideTeamsWindows)
_ = TeamsLauncher.AutoHideAfterLaunchAsync();
}
else
{
Toast.Warn($"Could not join: {error}");
}
});
RollRecordingCommand = new AsyncRelayCommand(RollRecordingAsync,
() => _controller.RecordingEnabled && Participants.Any(p => p.IsEnabled));
ToggleMuteCommand = MakeTeamsCommand(
label: "Mute",
invoke: TeamsControlBridge.ToggleMute,
successMessage: "Toggled mute");
ToggleCameraCommand = MakeTeamsCommand(
label: "Camera",
invoke: TeamsControlBridge.ToggleCamera,
successMessage: "Toggled camera");
LeaveCallCommand = MakeTeamsCommand(
label: "Leave",
invoke: TeamsControlBridge.LeaveCall,
successMessage: "Left the call");
OpenShareTrayCommand = MakeTeamsCommand(
label: "Share",
invoke: TeamsControlBridge.OpenShareTray,
successMessage: "Opened share tray");
}
///
/// Wraps a invocation in a RelayCommand that
/// translates the result to a user-visible toast. Centralizes the toast wording
/// so the four control commands stay consistent.
///
private RelayCommand MakeTeamsCommand(string label, Func invoke, string successMessage)
{
return new RelayCommand(() =>
{
switch (invoke())
{
case TeamsControlBridge.InvokeResult.Invoked:
Toast.Show(successMessage);
break;
case TeamsControlBridge.InvokeResult.TeamsNotRunning:
Toast.Warn("Teams isn't running.");
break;
case TeamsControlBridge.InvokeResult.ControlNotFound:
Toast.Warn($"{label} control not found — are you in a call?");
break;
case TeamsControlBridge.InvokeResult.InvokeFailed:
Toast.Warn($"{label} button found but disabled.");
break;
}
});
}
///
/// Issues DisableIsoAsync for every participant whose ISO is currently enabled.
/// Each disable is awaited sequentially so we don't try to tear down N pipelines
/// in parallel and trip channel-completion races; for ~10 participants this is
/// still sub-second total. Failures are swallowed (best-effort emergency stop).
///
///
/// Roll every active recording into a new chunk: disable + re-enable every
/// pipeline that's currently running. The recorder finalizes its
/// manifest.json on disable and a fresh subdirectory is created on the
/// next enable (RawBgraRecorderSink uses the participant display name +
/// the timestamp template so consecutive rolls don't collide on disk).
///
/// Per-participant best-effort: one bad pipeline doesn't abort the rest.
///
private async Task RollRecordingAsync()
{
var enabled = Participants.Where(p => p.IsEnabled).ToArray();
if (enabled.Length == 0)
{
Toast.Show("No active ISOs to roll");
return;
}
var rolled = 0;
foreach (var p in enabled)
{
try
{
await _controller.DisableIsoAsync(p.Id, CancellationToken.None);
await Task.Delay(150);
var resolvedName = string.IsNullOrWhiteSpace(p.CustomName)
? Services.OutputNameTemplate.Render(
Services.OutputNameTemplate.Get(),
p.Id,
p.DisplayName)
: p.CustomName;
bool? recordOverride = p.RecordToDisk ? null : false;
await _controller.EnableIsoAsync(p.Id, resolvedName, recordOverride, CancellationToken.None);
rolled++;
}
catch
{
// Per-pipeline best-effort
}
}
Toast.Show($"Rolled {rolled} recording(s) into a new chunk");
}
///
/// Enable ISOs for every online + non-enabled participant in parallel-ish
/// (sequential await, but each individual EnableIsoAsync is fast). Tolerates
/// per-participant failures so one bad source doesn't abort the rest.
///
private async Task EnableAllOnlineAsync()
{
var candidates = Participants.Where(p => p.IsOnline && !p.IsEnabled).ToArray();
var enabled = 0;
foreach (var p in candidates)
{
try
{
var resolvedName = string.IsNullOrWhiteSpace(p.CustomName)
? Services.OutputNameTemplate.Render(
Services.OutputNameTemplate.Get(),
p.Id,
p.DisplayName)
: p.CustomName;
bool? recordOverride = p.RecordToDisk ? null : false;
await _controller.EnableIsoAsync(p.Id, resolvedName, recordOverride, CancellationToken.None);
p.IsEnabled = true;
enabled++;
}
catch
{
// Per-participant best-effort — one bad source shouldn't abort the bulk operation.
}
}
Toast.Show(enabled == 0
? "No participants to enable"
: $"Enabled {enabled} ISO(s)");
}
private async Task StopAllIsosAsync()
{
// Snapshot first so the collection doesn't mutate while we iterate.
var enabled = Participants.Where(p => p.IsEnabled).ToArray();
if (enabled.Length == 0)
{
Toast.Show("No ISOs to stop");
return;
}
// Confirm before tearing down — this button is an "emergency stop" but
// mis-clicks during a show are easy. The dialog cost is negligible
// (one Enter press) and the regret cost is huge (yanking 5 ISOs mid-
// broadcast). Default selection is No so accidental hits cancel.
var confirm = System.Windows.MessageBox.Show(
$"Stop {enabled.Length} running ISO(s)?\n\nThis tears down every active pipeline immediately.",
"TeamsISO — Stop all ISOs",
System.Windows.MessageBoxButton.YesNo,
System.Windows.MessageBoxImage.Warning,
System.Windows.MessageBoxResult.No);
if (confirm != System.Windows.MessageBoxResult.Yes) return;
foreach (var p in enabled)
{
try { await _controller.DisableIsoAsync(p.Id, CancellationToken.None); }
catch { /* defensive */ }
p.IsEnabled = false;
}
Toast.Show($"Stopped {enabled.Length} ISO(s)");
}
private void OnStatsTick(object? sender, EventArgs e)
{
foreach (var vm in Participants)
{
try
{
var stats = _controller.GetStats(vm.Id);
vm.UpdateStats(stats);
// Refresh preview thumbnail from the engine's most recent
// processed frame. Returns null if no pipeline is running for
// this participant; UpdateThumbnail short-circuits in that
// case, leaving the previous frame in place rather than
// visibly blanking when the pipeline restarts.
vm.UpdateThumbnail(_controller.GetLatestProcessedFrame(vm.Id));
}
catch
{
// Stats are advisory; never let a transient read failure
// tear down the timer or surface an error to the user.
}
}
// Update footer badges. Recording count is "ISOs that have a recorder
// attached" — _controller.RecordingEnabled tells us the global toggle,
// but the actual recorder count = number of running pipelines while
// that toggle was on (transient enables can mean fewer recorders than
// running pipelines). Approximate by ANDing global toggle + running
// ISO count; close enough for an at-a-glance footer.
var totalParticipants = Participants.Count;
var enabledCount = Participants.Count(p => p.IsEnabled);
ActiveRecordingCount = _controller.RecordingEnabled ? enabledCount : 0;
IsRecording = ActiveRecordingCount > 0;
// 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:
// "the show started when the first feed went live."
if (enabledCount > 0)
{
_sessionStartedAt ??= DateTimeOffset.UtcNow;
var elapsed = DateTimeOffset.UtcNow - _sessionStartedAt.Value;
SessionElapsed = elapsed.TotalHours >= 1
? $"{(int)elapsed.TotalHours:D2}:{elapsed.Minutes:D2}:{elapsed.Seconds:D2}"
: $"{elapsed.Minutes:D2}:{elapsed.Seconds:D2}";
IsSessionActive = true;
}
else if (_sessionStartedAt is not null)
{
_sessionStartedAt = null;
SessionElapsed = string.Empty;
IsSessionActive = false;
}
// Dynamic status text — replaces the static "Engine running at X fps"
// once ISOs are live. The framerate target is still implicit (the user
// set it in OUTPUT settings; surfacing it constantly steals footer
// real estate from more-actionable info).
if (totalParticipants == 0)
{
StatusText = "Discovering NDI sources…";
}
else if (enabledCount == 0)
{
StatusText = totalParticipants == 1
? "1 participant visible"
: $"{totalParticipants} participants visible";
}
else if (ActiveRecordingCount > 0 && ActiveRecordingCount != enabledCount)
{
StatusText = $"{enabledCount}/{totalParticipants} ISOs live · {ActiveRecordingCount} recording";
}
else if (ActiveRecordingCount > 0)
{
StatusText = $"{enabledCount}/{totalParticipants} ISOs live · all recording";
}
else
{
StatusText = $"{enabledCount}/{totalParticipants} ISOs live";
}
// Teams meeting state — UIA traversal at 1Hz. We probe by looking
// for the Leave button in Teams' automation tree (present iff in a
// call) and surface the result as a status pill in the IN-CALL bar.
// Offloaded to a Task so a slow UIA call doesn't stall the UI tick;
// the property update is dispatched back here on next tick.
try
{
var teamsRunning = TeamsLauncher.IsRunning();
if (!teamsRunning)
{
TeamsMeetingState = string.Empty;
IsTeamsInCall = false;
}
else
{
// Fire the UIA probe off-thread — it walks the full descendant
// tree of every Teams window and can take 50-200ms in a busy
// call. We can tolerate one-tick latency on the displayed
// state much more easily than a UI hiccup.
_ = Task.Run(() =>
{
try
{
var inCall = TeamsControlBridge.IsInCall();
_dispatcher.InvokeAsync(() =>
{
IsTeamsInCall = inCall;
TeamsMeetingState = inCall ? "IN CALL" : "READY";
});
}
catch { /* UIA flakiness shouldn't crash the stats tick */ }
});
}
}
catch { /* defensive — probe failures must never break the tick */ }
// Control-surface state — peek at App's owned services.
var app = System.Windows.Application.Current as App;
var rest = app?.ControlSurface?.IsRunning ?? false;
var osc = app?.OscBridge?.IsRunning ?? false;
IsControlSurfaceRunning = rest || osc;
// When LAN-reachable mode is on, the footer text shows the routable
// URL instead of just the port — operators setting up a thin client
// shouldn't have to open Settings to find what to type into a
// browser. We trust the Settings VM's ControlSurfaceLanReachable
// boolean since that's where the toggle is persisted.
var lanMode = rest && (app?.ControlSurface?.BoundToLan ?? false);
var lanHost = lanMode ? Settings.ControlSurfaceUrl.Replace("/ui", "") : null;
ControlSurfaceText = (rest, osc) switch
{
(true, true) when lanMode => $"{lanHost} + OSC :{app!.OscBridge!.Port}",
(true, false) when lanMode => lanHost!,
(true, true) => $"REST :{app!.ControlSurface!.Port} + OSC :{app.OscBridge!.Port}",
(true, false) => $"REST :{app!.ControlSurface!.Port}",
(false, true) => $"OSC :{app!.OscBridge!.Port}",
_ => string.Empty,
};
}
public async Task InitializeAsync(CancellationToken cancellationToken)
{
StatusText = "Discovering NDI sources…";
await _controller.StartAsync(cancellationToken);
StatusText = $"Engine running at {_controller.GlobalSettings.FramerateHz:F2} fps target.";
// Auto-apply last preset bookkeeping. We don't apply here — participants
// haven't been discovered yet — instead we record the intent and let
// OnParticipantsChanged trigger the apply once the meeting has populated.
try
{
var pref = Services.OperatorPresetStore.GetStartupPreference();
if (pref.AutoApplyOnStartup && !string.IsNullOrEmpty(pref.LastAppliedName))
{
_pendingPresetName = pref.LastAppliedName;
// 30s grace window is generous: Teams typically advertises all
// existing participants within 5–10s of NDI discovery starting.
// After this deadline we apply with whoever is visible.
_pendingPresetDeadline = DateTimeOffset.UtcNow.AddSeconds(30);
}
}
catch { /* preset read failures shouldn't block engine startup */ }
}
///
/// CLI override for the launch-time auto-apply. Called from App.OnStartup
/// after parsing --apply-preset. Sets the same pending-preset state
/// that the user-toggled auto-apply path uses, so a single trigger flow
/// covers both. Wins over the persisted preference (operator's CLI intent
/// is more recent than what's on disk).
///
public void RequestApplyPresetOnStartup(string presetName)
{
_pendingPresetName = presetName;
_pendingPresetDeadline = DateTimeOffset.UtcNow.AddSeconds(30);
_pendingPresetApplied = false;
}
private void OnParticipantsChanged(IReadOnlyList incoming)
{
var seenIds = new HashSet();
var hideLocal = Settings.HideLocalSelf;
var autoDisable = Settings.AutoDisableOnDeparture;
foreach (var p in incoming)
{
// The new Teams client emits a "(Local)" pseudo-participant for the user's
// own preview — operators rarely want it as a routable ISO. Suppress when
// HideLocalSelf is on (default).
if (hideLocal && IsLocalSelf(p)) continue;
seenIds.Add(p.Id);
if (_byId.TryGetValue(p.Id, out var vm))
{
var wasOnline = vm.IsOnline;
vm.Update(p);
// Departure: source went from non-null to null. Always toast so the
// operator notices, even when AutoDisableOnDeparture is off — the
// ISO might still be "running" but emitting a slate frame, which
// looks fine in TeamsISO's UI but is broken downstream.
if (wasOnline && !vm.IsOnline && vm.IsEnabled)
{
if (autoDisable)
{
var captured = vm;
_ = Task.Run(async () =>
{
try { await _controller.DisableIsoAsync(captured.Id, CancellationToken.None); }
catch { /* defensive */ }
await _dispatcher.InvokeAsync(() =>
{
captured.IsEnabled = false;
Toast.Show($"Auto-disabled ISO: {captured.DisplayName} left the meeting");
});
});
}
else
{
// ISO stays running on a slate frame; warn the operator so
// they can decide whether to disable manually.
Toast.Warn($"{vm.DisplayName} disconnected — ISO still running on slate");
}
}
}
else
{
vm = new ParticipantViewModel(_controller, p);
_byId[p.Id] = vm;
Participants.Add(vm);
}
}
// Remove participants no longer present (or now hidden by the filter).
for (var i = Participants.Count - 1; i >= 0; i--)
{
var vm = Participants[i];
if (!seenIds.Contains(vm.Id))
{
_byId.Remove(vm.Id);
Participants.RemoveAt(i);
}
}
// Auto-apply-last-preset, second half: once participants populate, kick the
// apply. We fire it under either of two conditions: (a) every display name
// referenced by the preset is present (best case — the meeting is fully
// populated, no skipped assignments), or (b) the grace deadline has passed
// (give up waiting and apply with whoever's online).
if (_pendingPresetName is not null && !_pendingPresetApplied)
{
TryAutoApplyPendingPreset();
}
}
///
/// Attempts to apply if either every preset
/// assignment matches a live participant, or the grace deadline has passed.
/// Idempotent — repeat calls without state change are no-ops; once we fire we
/// flag _pendingPresetApplied so subsequent participant churn doesn't
/// trigger a second apply. Failures (missing preset on disk, preset that no
/// longer matches anyone) are swallowed: the operator can always re-apply
/// manually via the dialog. Delegates to
/// for the actual reconciliation so the dialog, REST surface, and this auto-
/// apply path all share a single implementation.
///
private void TryAutoApplyPendingPreset()
{
Services.OperatorPresetStore.Preset? preset;
try { preset = Services.OperatorPresetStore.Find(_pendingPresetName!); }
catch { preset = null; }
if (preset is null)
{
_pendingPresetApplied = true; // give up; nothing on disk to apply
return;
}
var liveNames = new HashSet(
Participants.Select(p => p.DisplayName),
StringComparer.OrdinalIgnoreCase);
var allPresent = preset.Assignments.All(a => liveNames.Contains(a.DisplayName));
if (!allPresent && DateTimeOffset.UtcNow < _pendingPresetDeadline)
return; // wait for the rest of the meeting to populate
_pendingPresetApplied = true;
var captured = preset;
// Snapshot the participants list since we're about to await on a worker
// thread; the live ObservableCollection isn't safe to enumerate from
// outside the dispatcher.
var snapshot = Participants.ToList();
_ = Task.Run(async () =>
{
var result = await PresetApplier.ApplyAsync(
captured, snapshot, _controller, _dispatcher);
await _dispatcher.InvokeAsync(() =>
Toast.Show($"Auto-applied preset \"{captured.Name}\" — {result.Matched} participant(s)"));
});
}
private static bool IsLocalSelf(Participant p) =>
string.Equals(p.DisplayName, "(Local)", StringComparison.Ordinal);
public void Dispose()
{
_statsTimer.Stop();
_statsTimer.Tick -= OnStatsTick;
_participantsSub.Dispose();
_alertsSub.Dispose();
}
}