diff --git a/src/TeamsISO.App/TeamsISO.App.csproj b/src/TeamsISO.App/TeamsISO.App.csproj
index faa8a54..016e75a 100644
--- a/src/TeamsISO.App/TeamsISO.App.csproj
+++ b/src/TeamsISO.App/TeamsISO.App.csproj
@@ -5,8 +5,9 @@
net8.0-windows
true
TeamsISO.App
-
- false
+ TeamsISO
+ true
+
diff --git a/src/TeamsISO.App/ViewModels/AlertBannerViewModel.cs b/src/TeamsISO.App/ViewModels/AlertBannerViewModel.cs
new file mode 100644
index 0000000..7b373a5
--- /dev/null
+++ b/src/TeamsISO.App/ViewModels/AlertBannerViewModel.cs
@@ -0,0 +1,31 @@
+using TeamsISO.Engine.Domain;
+
+namespace TeamsISO.App.ViewModels;
+
+public sealed class AlertBannerViewModel : ObservableObject
+{
+ private EngineAlert? _current;
+
+ public EngineAlert? Current
+ {
+ get => _current;
+ set
+ {
+ if (SetField(ref _current, value))
+ {
+ OnPropertyChanged(nameof(IsVisible));
+ OnPropertyChanged(nameof(Message));
+ }
+ }
+ }
+
+ public bool IsVisible => _current is not null;
+ public string Message => _current?.Message ?? string.Empty;
+
+ public RelayCommand DismissCommand { get; }
+
+ public AlertBannerViewModel()
+ {
+ DismissCommand = new RelayCommand(() => Current = null);
+ }
+}
diff --git a/src/TeamsISO.App/ViewModels/GlobalSettingsViewModel.cs b/src/TeamsISO.App/ViewModels/GlobalSettingsViewModel.cs
new file mode 100644
index 0000000..e92d320
--- /dev/null
+++ b/src/TeamsISO.App/ViewModels/GlobalSettingsViewModel.cs
@@ -0,0 +1,45 @@
+using TeamsISO.Engine.Controller;
+using TeamsISO.Engine.Domain;
+
+namespace TeamsISO.App.ViewModels;
+
+///
+/// Bindings for the global settings panel: framerate, resolution, aspect, audio.
+///
+public sealed class GlobalSettingsViewModel : ObservableObject
+{
+ private readonly IIsoController _controller;
+ private TargetFramerate _framerate;
+ private TargetResolution _resolution;
+ private AspectMode _aspect;
+ private AudioMode _audio;
+
+ public GlobalSettingsViewModel(IIsoController controller)
+ {
+ _controller = controller;
+ var current = controller.GlobalSettings;
+ _framerate = current.Framerate;
+ _resolution = current.Resolution;
+ _aspect = current.Aspect;
+ _audio = current.Audio;
+ ApplyCommand = new AsyncRelayCommand(ApplyAsync);
+ }
+
+ public IEnumerable AvailableFramerates => Enum.GetValues();
+ public IEnumerable AvailableResolutions => Enum.GetValues();
+ public IEnumerable AvailableAspectModes => Enum.GetValues();
+ public IEnumerable AvailableAudioModes => Enum.GetValues();
+
+ public TargetFramerate Framerate { get => _framerate; set => SetField(ref _framerate, value); }
+ public TargetResolution Resolution { get => _resolution; set => SetField(ref _resolution, value); }
+ public AspectMode Aspect { get => _aspect; set => SetField(ref _aspect, value); }
+ public AudioMode Audio { get => _audio; set => SetField(ref _audio, value); }
+
+ public AsyncRelayCommand ApplyCommand { get; }
+
+ private Task ApplyAsync()
+ {
+ var settings = new FrameProcessingSettings(_framerate, _resolution, _aspect, _audio);
+ return _controller.SetGlobalSettingsAsync(settings, CancellationToken.None);
+ }
+}
diff --git a/src/TeamsISO.App/ViewModels/ObservableObject.cs b/src/TeamsISO.App/ViewModels/ObservableObject.cs
new file mode 100644
index 0000000..2e4e184
--- /dev/null
+++ b/src/TeamsISO.App/ViewModels/ObservableObject.cs
@@ -0,0 +1,23 @@
+using System.ComponentModel;
+using System.Runtime.CompilerServices;
+
+namespace TeamsISO.App.ViewModels;
+
+///
+/// Minimal MVVM base class implementing .
+///
+public abstract class ObservableObject : INotifyPropertyChanged
+{
+ public event PropertyChangedEventHandler? PropertyChanged;
+
+ protected void OnPropertyChanged([CallerMemberName] string? propertyName = null) =>
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
+
+ protected bool SetField(ref T field, T value, [CallerMemberName] string? propertyName = null)
+ {
+ if (EqualityComparer.Default.Equals(field, value)) return false;
+ field = value;
+ OnPropertyChanged(propertyName);
+ return true;
+ }
+}
diff --git a/src/TeamsISO.App/ViewModels/ParticipantViewModel.cs b/src/TeamsISO.App/ViewModels/ParticipantViewModel.cs
new file mode 100644
index 0000000..1d41a0d
--- /dev/null
+++ b/src/TeamsISO.App/ViewModels/ParticipantViewModel.cs
@@ -0,0 +1,90 @@
+using TeamsISO.Engine.Controller;
+using TeamsISO.Engine.Domain;
+
+namespace TeamsISO.App.ViewModels;
+
+///
+/// Per-row view model for a participant in the participant list.
+/// Wraps a domain and exposes ISO toggle and naming commands.
+///
+public sealed class ParticipantViewModel : ObservableObject
+{
+ private readonly IIsoController _controller;
+ private Participant _participant;
+ private bool _isEnabled;
+ private bool _isProcessing;
+ private string _customName;
+
+ public ParticipantViewModel(IIsoController controller, Participant participant)
+ {
+ _controller = controller;
+ _participant = participant;
+ _customName = string.Empty;
+ ToggleIsoCommand = new AsyncRelayCommand(ToggleIsoAsync, () => !_isProcessing);
+ }
+
+ public Guid Id => _participant.Id;
+ public string DisplayName => _participant.DisplayName;
+ public string SourceMachine => _participant.CurrentSource?.MachineName ?? "(disconnected)";
+ public string SourceFullName => _participant.CurrentSource?.FullName ?? "(disconnected)";
+ public bool IsOnline => _participant.CurrentSource is not null;
+
+ public bool IsEnabled
+ {
+ get => _isEnabled;
+ set => SetField(ref _isEnabled, value);
+ }
+
+ public bool IsProcessing
+ {
+ get => _isProcessing;
+ private set
+ {
+ if (SetField(ref _isProcessing, value))
+ ToggleIsoCommand.RaiseCanExecuteChanged();
+ }
+ }
+
+ public string CustomName
+ {
+ get => _customName;
+ set => SetField(ref _customName, value);
+ }
+
+ public AsyncRelayCommand ToggleIsoCommand { get; }
+
+ /// Refreshes the underlying participant data (called when the controller emits an updated list).
+ public void Update(Participant updated)
+ {
+ _participant = updated;
+ OnPropertyChanged(nameof(DisplayName));
+ OnPropertyChanged(nameof(SourceMachine));
+ OnPropertyChanged(nameof(SourceFullName));
+ OnPropertyChanged(nameof(IsOnline));
+ }
+
+ private async Task ToggleIsoAsync()
+ {
+ IsProcessing = true;
+ try
+ {
+ if (IsEnabled)
+ {
+ await _controller.DisableIsoAsync(Id, CancellationToken.None);
+ IsEnabled = false;
+ }
+ else
+ {
+ await _controller.EnableIsoAsync(
+ Id,
+ string.IsNullOrWhiteSpace(_customName) ? null : _customName,
+ CancellationToken.None);
+ IsEnabled = true;
+ }
+ }
+ finally
+ {
+ IsProcessing = false;
+ }
+ }
+}
diff --git a/src/TeamsISO.App/ViewModels/RelayCommand.cs b/src/TeamsISO.App/ViewModels/RelayCommand.cs
new file mode 100644
index 0000000..a859178
--- /dev/null
+++ b/src/TeamsISO.App/ViewModels/RelayCommand.cs
@@ -0,0 +1,58 @@
+using System.Windows.Input;
+
+namespace TeamsISO.App.ViewModels;
+
+///
+/// Synchronous command that delegates execution to an .
+///
+public sealed class RelayCommand : ICommand
+{
+ private readonly Action _execute;
+ private readonly Func? _canExecute;
+
+ public RelayCommand(Action execute, Func? canExecute = null)
+ {
+ _execute = execute;
+ _canExecute = canExecute;
+ }
+
+ public bool CanExecute(object? parameter) => _canExecute?.Invoke() ?? true;
+ public void Execute(object? parameter) => _execute();
+
+ public event EventHandler? CanExecuteChanged;
+ public void RaiseCanExecuteChanged() => CanExecuteChanged?.Invoke(this, EventArgs.Empty);
+}
+
+///
+/// Async command that suppresses re-entrancy while running.
+///
+public sealed class AsyncRelayCommand : ICommand
+{
+ private readonly Func _execute;
+ private readonly Func? _canExecute;
+ private bool _isRunning;
+
+ public AsyncRelayCommand(Func execute, Func? canExecute = null)
+ {
+ _execute = execute;
+ _canExecute = canExecute;
+ }
+
+ public bool CanExecute(object? parameter) => !_isRunning && (_canExecute?.Invoke() ?? true);
+
+ public async void Execute(object? parameter)
+ {
+ if (_isRunning) return;
+ _isRunning = true;
+ RaiseCanExecuteChanged();
+ try { await _execute(); }
+ finally
+ {
+ _isRunning = false;
+ RaiseCanExecuteChanged();
+ }
+ }
+
+ public event EventHandler? CanExecuteChanged;
+ public void RaiseCanExecuteChanged() => CanExecuteChanged?.Invoke(this, EventArgs.Empty);
+}