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