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();
+ }
+}