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; } 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; /// /// 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 }); 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"; } // 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; ControlSurfaceText = (rest, osc) switch { (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(); } }