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. /// /// Split across partial files by responsibility: /// • MainViewModel.cs — fields, properties, constructor (wires commands), OnStatsTick, Dispose /// • MainViewModel.TeamsCommands.cs — Mute / Cam / Leave / Share + Join URL + Teams meeting-state poll /// • MainViewModel.PresetCommands.cs — auto-apply-last-preset path /// • MainViewModel.BulkCommands.cs — Stop all / Enable all / Snapshot all /// public sealed partial 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…"; // _pendingPresetName / Deadline / Applied + the auto-apply path // moved to MainViewModel.PresetCommands.cs. 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) { _currentSortMode = 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; case Services.UIPreferences.SortMode.LoudestFirst: // Sort by the displayed audio level (which already includes the // decay envelope) so participants don't snap-reorder on every // tiny audio frame. ParticipantsView.Refresh() at the stats // tick re-evaluates the sort with the latest values. ParticipantsView.SortDescriptions.Add(new SortDescription( nameof(ParticipantViewModel.DisplayedAudioLevel), ListSortDirection.Descending)); ParticipantsView.SortDescriptions.Add(new SortDescription( nameof(ParticipantViewModel.DisplayName), ListSortDirection.Ascending)); break; // JoinOrder: leave SortDescriptions empty. } } private Services.UIPreferences.SortMode _currentSortMode = Services.UIPreferences.SortMode.JoinOrder; /// /// 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; } // Recording-marker and roll-recording commands removed — recording feature axed. /// F1 binding — opens the help / cheat-sheet dialog. public RelayCommand ShowHelpCommand { get; } /// /// Ctrl+T binding — cycles dark ↔ light theme via ThemeManager. /// Persists the operator's choice through UIPreferences.Theme. /// The v2 header surfaces this as a click affordance too; the /// command exists once so both bindings reach the same path. /// public RelayCommand ToggleThemeCommand { get; } /// /// Ctrl+K binding — opens the v2 command palette. The actual window /// open call lives in (view-side concern); /// this command delegates through an Action callback the view sets /// after construction so the VM stays unaware of WPF Window types. /// public RelayCommand OpenCommandPaletteCommand { get; } private Action? _openCommandPalette; /// /// Wire the view's palette-opening callback. Called by MainWindow's /// constructor right after DataContext is set. Idempotent — second /// call replaces the first. /// public void RegisterCommandPaletteOpener(Action openPalette) => _openCommandPalette = openPalette; /// Opens the inline notes viewer for today's show-notes file. public RelayCommand ShowNotesCommand { get; } /// Join a Teams meeting from a pasted URL — see . public RelayCommand JoinMeetingCommand { get; } /// Save a PNG snapshot of every enabled participant's current frame. public RelayCommand SnapshotAllCommand { get; } /// /// Toggle the ISO for the Nth visible participant (1-based, matches the /// numpad layout). Used by the NumPad1..NumPad9 hotkeys; resolves /// against ParticipantsView so the index matches what the operator /// sees in the current sort + filter. /// public RelayCommand ToggleByIndexCommand { 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 properties (IsRecording, ActiveRecordingCount, // RecordingElapsed, RecordingFreeSpace, IsLowDiskSpace) removed when the // recording feature was axed. /// /// Total visible participants — feeds the v2 transport strip's "PART N" /// readout. Updated on every 1Hz stats tick alongside . /// public int ParticipantCount { get => _participantCount; private set => SetField(ref _participantCount, value); } private int _participantCount; /// /// Currently-enabled (live) ISO count — feeds the v2 transport strip's /// "LIVE N" readout. The number is cyan-tinted when non-zero to draw /// the operator's eye to active state. /// public int LiveCount { get => _liveCount; private set => SetField(ref _liveCount, value); } private int _liveCount; /// 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); /// True when the local user's mic is muted in the active Teams call. public bool IsLocalMuted { get => _isLocalMuted; private set => SetField(ref _isLocalMuted, value); } private bool _isLocalMuted; /// True when the local user's camera is off in the active Teams call. public bool IsLocalCameraOff { get => _isLocalCameraOff; private set => SetField(ref _isLocalCameraOff, value); } private bool _isLocalCameraOff; /// /// 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(); // Subscribe directly (no ObserveOn) and marshal to the UI thread inside // the callback via Dispatcher.InvokeAsync. The previous ObserveOn( // SynchronizationContextScheduler) path captured SynchronizationContext // .Current at subscribe time — fragile in WPF startup ordering, where // the UI thread's SyncContext can be in a transitional state during // App.OnStartup and the captured context never pumps subsequent // OnNext calls. Direct subscribe + explicit dispatcher marshal is the // pattern proven by Console.Program.cs (engine emits, consumer marshals). _participantsSub = controller.Participants .Subscribe(snapshot => _dispatcher.InvokeAsync( () => OnParticipantsChanged(snapshot), DispatcherPriority.Background)); _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…"); }); ToggleThemeCommand = new RelayCommand(() => { // ThemeManager.Toggle persists the new preference to UIPreferences // and fires the resource-dictionary swap on the dispatcher thread. Services.ThemeManager.Current.Toggle(); }); OpenCommandPaletteCommand = new RelayCommand(() => _openCommandPalette?.Invoke()); 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 }); SnapshotAllCommand = new RelayCommand(SnapshotAll, () => Participants.Any(p => p.IsEnabled)); ToggleByIndexCommand = new RelayCommand(s => { // Numpad / digit hotkeys pass "1".."9" as a string. Resolve // against the filtered/sorted view so the index matches what // the operator sees on screen, not the underlying storage order. if (!int.TryParse(s, out var idx) || idx < 1 || idx > 9) return; var i = 0; foreach (var item in ParticipantsView) { if (item is not ParticipantViewModel p) continue; if (++i == idx) { if (p.ToggleIsoCommand.CanExecute(null)) p.ToggleIsoCommand.Execute(null); break; } } }); JoinMeetingCommand = new RelayCommand(JoinPastedMeeting); 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"); } // Body methods extracted to themed partial files: // MainViewModel.BulkCommands.cs — EnableAllOnlineAsync, StopAllIsosAsync, SnapshotAll // MainViewModel.TeamsCommands.cs — MakeTeamsCommand, JoinPastedMeeting, // ExtractMeetingTitle, PollTeamsMeetingState // MainViewModel.PresetCommands.cs — RequestApplyPresetOnStartup, // LoadPendingPresetFromPreferences, // TryAutoApplyPendingPreset 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. } } // Active-speaker highlight: find the loudest enabled participant // and mark their IsActiveSpeaker flag. Only one row at a time; // ties broken by enumeration order (first one wins). Threshold of // 0.05 prevents constant flicker between near-silent participants // when nobody's really speaking. ParticipantViewModel? loudest = null; double loudestLevel = 0.05; foreach (var p in Participants) { if (!p.IsEnabled) continue; if (p.DisplayedAudioLevel > loudestLevel) { loudest = p; loudestLevel = p.DisplayedAudioLevel; } } foreach (var p in Participants) { var shouldHighlight = ReferenceEquals(p, loudest); if (p.IsActiveSpeaker != shouldHighlight) p.IsActiveSpeaker = shouldHighlight; } // If sort mode is LoudestFirst, refresh the view so the new audio // peaks re-evaluate the sort. Skipped for the other sort modes // since their keys (name, online state) don't change every tick — // no need to pay the Refresh cost. if (_currentSortMode == Services.UIPreferences.SortMode.LoudestFirst) { try { ParticipantsView.Refresh(); } catch { /* defensive — Refresh occasionally throws on collection mutations */ } } // 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); // Recording-elapsed timer + disk-free polling removed alongside the rest // of the recording surface. // Expose counts as VM properties for the v2 transport-strip binding. // The strip's "PART 4 · LIVE 2" reads these — pushing them on the // 1Hz tick keeps the cost off the per-frame UI path. ParticipantCount = totalParticipants; LiveCount = enabledCount; // 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 { StatusText = $"{enabledCount}/{totalParticipants} ISOs live"; } // Teams meeting state — UIA traversal at 1Hz; off-thread so a slow // UIA call doesn't stall the UI tick. Implementation in // MainViewModel.TeamsCommands.cs. PollTeamsMeetingState(); // 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. Implementation in // MainViewModel.PresetCommands.cs. LoadPendingPresetFromPreferences(); } 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(); } } 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(); } }