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; /// /// Elapsed time since recording started this session, formatted as /// "MM:SS" (or "HH:MM:SS" past an hour). Empty when nothing is /// recording. Resets when all recordings stop, restarts on the next /// rec-on transition. Useful for operators tracking "how long has the /// show been rolling". /// public string RecordingElapsed { get => _recordingElapsed; private set => SetField(ref _recordingElapsed, value); } private string _recordingElapsed = string.Empty; private DateTimeOffset? _recordingStartedAt; /// 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)"); } /// /// Pull the meaningful "meeting title" out of Teams' raw window title. /// Teams uses formats like: /// "Weekly Standup | Microsoft Teams" /// "Meeting with Alice | Microsoft Teams" /// "Microsoft Teams" (no meeting, just the app) /// Strip the trailing " | Microsoft Teams" so the IN-CALL pill stays /// short and readable. Truncate beyond 50 chars so a long meeting /// subject doesn't push the rest of the IN-CALL bar off screen. /// internal static string ExtractMeetingTitle(string windowTitle) { if (string.IsNullOrWhiteSpace(windowTitle)) return string.Empty; var t = windowTitle.Trim(); // Common separator patterns Teams uses across locales. foreach (var sep in new[] { " | Microsoft Teams", " | Teams", " - Microsoft Teams", " - Teams" }) { var idx = t.IndexOf(sep, StringComparison.Ordinal); if (idx > 0) { t = t.Substring(0, idx).Trim(); break; } } // If after stripping we're left with just "Microsoft Teams" the // window has no meeting context — return empty so the pill stays // at "IN CALL" without a stale title. if (t.Equals("Microsoft Teams", StringComparison.OrdinalIgnoreCase)) return string.Empty; if (t.Length > 50) t = t.Substring(0, 47) + "…"; return t; } 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; // Recording session timer — independent of the global session timer // since recording can start AFTER the meeting begins (or vice versa) // and operators want to know exactly how long the archive copy has // been rolling. Resets to null when all recordings stop, so the // next rec-on transition starts the timer from 00:00. if (IsRecording) { _recordingStartedAt ??= DateTimeOffset.UtcNow; var recElapsed = DateTimeOffset.UtcNow - _recordingStartedAt.Value; RecordingElapsed = recElapsed.TotalHours >= 1 ? $"{(int)recElapsed.TotalHours:D2}:{recElapsed.Minutes:D2}:{recElapsed.Seconds:D2}" : $"{recElapsed.Minutes:D2}:{recElapsed.Seconds:D2}"; } else { _recordingStartedAt = null; RecordingElapsed = string.Empty; } // 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(); var title = inCall ? ExtractMeetingTitle(TeamsLauncher.GetActiveWindowTitle()) : null; _dispatcher.InvokeAsync(() => { IsTeamsInCall = inCall; TeamsMeetingState = inCall ? (string.IsNullOrEmpty(title) ? "IN CALL" : $"IN CALL · {title}") : "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, Toast); _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(); } }