using System.Collections.ObjectModel; using System.Reactive.Concurrency; using System.Reactive.Linq; using System.Windows.Threading; 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…"; public ObservableCollection Participants { get; } = new(); public GlobalSettingsViewModel Settings { get; } public AlertBannerViewModel AlertBanner { get; } = new(); /// /// 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; } public string StatusText { get => _statusText; set => SetField(ref _statusText, value); } public MainViewModel(IIsoController controller, Dispatcher dispatcher) { _controller = controller; _dispatcher = dispatcher; Settings = new GlobalSettingsViewModel(controller); _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)); } /// /// 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). /// private async Task StopAllIsosAsync() { // Snapshot first so the collection doesn't mutate while we iterate. var enabled = Participants.Where(p => p.IsEnabled).ToArray(); foreach (var p in enabled) { try { await _controller.DisableIsoAsync(p.Id, CancellationToken.None); } catch { /* defensive */ } p.IsEnabled = false; } } private void OnStatsTick(object? sender, EventArgs e) { foreach (var vm in Participants) { try { var stats = _controller.GetStats(vm.Id); vm.UpdateStats(stats); } catch { // Stats are advisory; never let a transient read failure // tear down the timer or surface an error to the user. } } } public async Task InitializeAsync(CancellationToken cancellationToken) { StatusText = "Discovering NDI sources…"; await _controller.StartAsync(cancellationToken); StatusText = $"Engine running at {_controller.GlobalSettings.FramerateHz:F2} fps target."; } private void OnParticipantsChanged(IReadOnlyList incoming) { var seenIds = new HashSet(); var hideLocal = Settings.HideLocalSelf; 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)) { vm.Update(p); } 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); } } } 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(); } }