From 8c441318d871623669d6d0002b62f0afee35257b Mon Sep 17 00:00:00 2001 From: Zac Gaetano Date: Thu, 7 May 2026 15:40:06 +0000 Subject: [PATCH] feat(ui): add MainViewModel with live participants collection and dispatcher marshalling --- src/TeamsISO.App/ViewModels/MainViewModel.cs | 96 ++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 src/TeamsISO.App/ViewModels/MainViewModel.cs diff --git a/src/TeamsISO.App/ViewModels/MainViewModel.cs b/src/TeamsISO.App/ViewModels/MainViewModel.cs new file mode 100644 index 0000000..b7cdfae --- /dev/null +++ b/src/TeamsISO.App/ViewModels/MainViewModel.cs @@ -0,0 +1,96 @@ +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 Dictionary _byId = new(); + private string _statusText = "Starting…"; + + public ObservableCollection Participants { get; } = new(); + public GlobalSettingsViewModel Settings { get; } + public AlertBannerViewModel AlertBanner { get; } = new(); + + 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; + }); + } + + 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(); + foreach (var p in incoming) + { + 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 + 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); + } + } + } + + public void Dispose() + { + _participantsSub.Dispose(); + _alertsSub.Dispose(); + } +}