feat(ui): add MVVM helpers and per-component view models (Participant, GlobalSettings, AlertBanner)
Some checks failed
CI / build-and-test (push) Has been cancelled

This commit is contained in:
Zac Gaetano 2026-05-07 15:39:46 +00:00
parent 2c6fbdf861
commit fbb73bcf04
6 changed files with 250 additions and 2 deletions

View file

@ -5,8 +5,9 @@
<TargetFramework>net8.0-windows</TargetFramework>
<UseWPF>true</UseWPF>
<RootNamespace>TeamsISO.App</RootNamespace>
<!-- WPF only builds on Windows. Non-Windows CI skips this project. -->
<BuildOnNonWindows Condition="'$(OS)' != 'Windows_NT'">false</BuildOnNonWindows>
<AssemblyName>TeamsISO</AssemblyName>
<EnableWindowsTargeting>true</EnableWindowsTargeting>
<ApplicationIcon></ApplicationIcon>
</PropertyGroup>
<ItemGroup>

View file

@ -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);
}
}

View file

@ -0,0 +1,45 @@
using TeamsISO.Engine.Controller;
using TeamsISO.Engine.Domain;
namespace TeamsISO.App.ViewModels;
/// <summary>
/// Bindings for the global settings panel: framerate, resolution, aspect, audio.
/// </summary>
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<TargetFramerate> AvailableFramerates => Enum.GetValues<TargetFramerate>();
public IEnumerable<TargetResolution> AvailableResolutions => Enum.GetValues<TargetResolution>();
public IEnumerable<AspectMode> AvailableAspectModes => Enum.GetValues<AspectMode>();
public IEnumerable<AudioMode> AvailableAudioModes => Enum.GetValues<AudioMode>();
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);
}
}

View file

@ -0,0 +1,23 @@
using System.ComponentModel;
using System.Runtime.CompilerServices;
namespace TeamsISO.App.ViewModels;
/// <summary>
/// Minimal MVVM base class implementing <see cref="INotifyPropertyChanged"/>.
/// </summary>
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<T>(ref T field, T value, [CallerMemberName] string? propertyName = null)
{
if (EqualityComparer<T>.Default.Equals(field, value)) return false;
field = value;
OnPropertyChanged(propertyName);
return true;
}
}

View file

@ -0,0 +1,90 @@
using TeamsISO.Engine.Controller;
using TeamsISO.Engine.Domain;
namespace TeamsISO.App.ViewModels;
/// <summary>
/// Per-row view model for a participant in the participant list.
/// Wraps a domain <see cref="Participant"/> and exposes ISO toggle and naming commands.
/// </summary>
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; }
/// <summary>Refreshes the underlying participant data (called when the controller emits an updated list).</summary>
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;
}
}
}

View file

@ -0,0 +1,58 @@
using System.Windows.Input;
namespace TeamsISO.App.ViewModels;
/// <summary>
/// Synchronous command that delegates execution to an <see cref="Action"/>.
/// </summary>
public sealed class RelayCommand : ICommand
{
private readonly Action _execute;
private readonly Func<bool>? _canExecute;
public RelayCommand(Action execute, Func<bool>? 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);
}
/// <summary>
/// Async command that suppresses re-entrancy while running.
/// </summary>
public sealed class AsyncRelayCommand : ICommand
{
private readonly Func<Task> _execute;
private readonly Func<bool>? _canExecute;
private bool _isRunning;
public AsyncRelayCommand(Func<Task> execute, Func<bool>? 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);
}