diff --git a/src/TeamsISO.App.WinUI/App.xaml.cs b/src/TeamsISO.App.WinUI/App.xaml.cs
index 01eb562..0827dcb 100644
--- a/src/TeamsISO.App.WinUI/App.xaml.cs
+++ b/src/TeamsISO.App.WinUI/App.xaml.cs
@@ -1,38 +1,107 @@
+using System;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Logging;
+using Microsoft.UI.Dispatching;
using Microsoft.UI.Xaml;
+using TeamsISO.App.WinUI.ViewModels;
using TeamsISO.App.WinUI.Views;
+using TeamsISO.Engine.Controller;
+using TeamsISO.Engine.Interop;
+using TeamsISO.Engine.Logging;
+using TeamsISO.Engine.NdiInterop;
+using TeamsISO.Engine.Persistence;
+using TeamsISO.Engine.Pipeline;
namespace TeamsISO.App.WinUI;
///
-/// WinUI 3 application entry. The full startup pipeline from the WPF host
-/// (NDI runtime preflight, IsoController wiring, single-instance mutex, REST
-/// + OSC bridge, tray icon, crash diagnostics, auto-update banner, onboarding)
-/// will migrate over in subsequent commits — this initial scaffold just brings
-/// up MainWindow so the redesigned shell can be developed and previewed.
-///
-/// The engine layer (TeamsISO.Engine) is unchanged; the WinUI 3 host's
-/// responsibility is binding its view-models to the same controller surface
-/// the WPF host already uses.
+/// WinUI 3 entry — brings up MainWindow and wires the engine pipeline.
///
public partial class App : Application
{
private Window? _mainWindow;
+ private ILoggerFactory? _loggerFactory;
+ private NdiInteropPInvoke? _interop;
+ private IsoController? _controller;
+ private MainViewModel? _viewModel;
public App()
{
InitializeComponent();
}
+ internal Window? MainWindow => _mainWindow;
+ internal MainViewModel? ViewModel => _viewModel;
+
protected override void OnLaunched(LaunchActivatedEventArgs args)
{
_mainWindow = new MainWindow();
_mainWindow.Activate();
+
+ _ = WireEngineAsync();
}
- ///
- /// Exposes the active main window so settings can swap RequestedTheme on
- /// the root element without having to thread the Window reference through
- /// every consumer.
- ///
- internal Window? MainWindow => _mainWindow;
+ private async Task WireEngineAsync()
+ {
+ try
+ {
+ _loggerFactory = EngineLogging.CreateDefault(LogLevel.Information);
+ var logger = _loggerFactory.CreateLogger();
+ logger.LogInformation(
+ "TeamsISO.App.WinUI starting. Build: {Version}. PID: {Pid}.",
+ typeof(App).Assembly.GetName().Version,
+ Environment.ProcessId);
+
+ try
+ {
+ _interop = new NdiInteropPInvoke(_loggerFactory.CreateLogger());
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "NDI runtime preflight failed");
+ return;
+ }
+
+ var configPath = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
+ "TeamsISO", "config.json");
+ var configStore = new ConfigStore(configPath, _loggerFactory.CreateLogger());
+
+ var probe = new NdiRuntimeProbe(_interop, NdiVersion.ExpectedRuntimeVersionPrefix);
+ var scaler = new ManagedNearestNeighborFrameScaler();
+
+ var loggerFactoryRef = _loggerFactory;
+ var interopRef = _interop;
+ IsoPipeline PipelineFactory(IsoPipelineConfig config)
+ {
+ var clock = new PeriodicTimerFrameClock(config.Settings.FramerateHz);
+ return new IsoPipeline(
+ config, interopRef, scaler, clock,
+ ExponentialBackoff.Default,
+ (delay, ct) => Task.Delay(delay, ct),
+ loggerFactoryRef);
+ }
+
+ _controller = new IsoController(
+ _interop, PipelineFactory, configStore, probe, _loggerFactory);
+
+ var dispatcher = DispatcherQueue.GetForCurrentThread();
+ _viewModel = new MainViewModel(_controller, dispatcher);
+
+ if (_mainWindow is MainWindow mw)
+ {
+ mw.AttachViewModel(_viewModel);
+ }
+
+ await _viewModel.InitializeAsync(CancellationToken.None);
+
+ logger.LogInformation("Engine wired. Discovery active. Awaiting Teams participants.");
+ }
+ catch (Exception ex)
+ {
+ System.Diagnostics.Debug.WriteLine($"Engine wiring failed: {ex}");
+ }
+ }
}
diff --git a/src/TeamsISO.App.WinUI/ViewModels/MainViewModel.cs b/src/TeamsISO.App.WinUI/ViewModels/MainViewModel.cs
new file mode 100644
index 0000000..d7aae9b
--- /dev/null
+++ b/src/TeamsISO.App.WinUI/ViewModels/MainViewModel.cs
@@ -0,0 +1,282 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Linq;
+using System.Reactive.Concurrency;
+using System.Reactive.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.UI.Dispatching;
+using TeamsISO.Engine.Controller;
+using TeamsISO.Engine.Domain;
+
+namespace TeamsISO.App.WinUI.ViewModels;
+
+///
+/// Top-level view-model for the WinUI 3 host. Subscribes to
+/// 's observables and marshals updates onto
+/// the WinUI 3 dispatcher queue (the WinUI equivalent of WPF Dispatcher).
+///
+/// Slim version of the WPF host's MainViewModel — drops the WPF-specific
+/// ICollectionView filter/sort, preset auto-apply, snapshot dialog, and
+/// help/notes window orchestration. Those land in subsequent commits.
+///
+public sealed class MainViewModel : ObservableObject, IDisposable
+{
+ private readonly IIsoController _controller;
+ private readonly DispatcherQueue _dispatcher;
+ private readonly DispatcherQueueTimer _statsTimer;
+ private readonly IDisposable _participantsSub;
+ private readonly IDisposable _alertsSub;
+ private readonly Dictionary _byId = new();
+ private string _statusText = "Starting…";
+ private bool _isSessionActive;
+ private DateTimeOffset? _sessionStartedAt;
+ private string _sessionElapsed = "00:00:00";
+ private int _activeRecordingCount;
+ private string _participantCountText = "0";
+
+ public ObservableCollection Participants { get; } = new();
+
+ public string StatusText
+ {
+ get => _statusText;
+ private set => SetField(ref _statusText, value);
+ }
+
+ public string ParticipantCountText
+ {
+ get => _participantCountText;
+ private set => SetField(ref _participantCountText, value);
+ }
+
+ public bool IsSessionActive
+ {
+ get => _isSessionActive;
+ private set => SetField(ref _isSessionActive, value);
+ }
+
+ public string SessionElapsed
+ {
+ get => _sessionElapsed;
+ private set => SetField(ref _sessionElapsed, value);
+ }
+
+ public int ActiveRecordingCount
+ {
+ get => _activeRecordingCount;
+ private set => SetField(ref _activeRecordingCount, value);
+ }
+
+ public bool IsRecording => ActiveRecordingCount > 0;
+
+ public AsyncRelayCommand EnableAllOnlineCommand { get; }
+ public AsyncRelayCommand StopAllIsosCommand { get; }
+ public RelayCommand RefreshDiscoveryCommand { get; }
+ public RelayCommand DropRecordingMarkerCommand { get; }
+ public RelayCommand ToggleByIndexCommand { get; }
+
+ public MainViewModel(IIsoController controller, DispatcherQueue dispatcher)
+ {
+ _controller = controller;
+ _dispatcher = dispatcher;
+
+ // Subscribe to engine observables. ObserveOn the WinUI dispatcher
+ // queue's SynchronizationContext so handlers run on the UI thread
+ // and SetField is safe.
+ var ctx = new DispatcherQueueSynchronizationContext(_dispatcher);
+ _participantsSub = controller.Participants
+ .ObserveOn(new SynchronizationContextScheduler(ctx))
+ .Subscribe(OnParticipantsChanged);
+
+ _alertsSub = controller.Alerts
+ .ObserveOn(new SynchronizationContextScheduler(ctx))
+ .Subscribe(alert =>
+ {
+ StatusText = $"Alert · {alert.Message}";
+ });
+
+ // 1 Hz stats tick — pulls live frame counters off the engine and
+ // pushes them into per-participant view-models. Runs on the WinUI
+ // dispatcher so SetField is safe.
+ _statsTimer = _dispatcher.CreateTimer();
+ _statsTimer.Interval = TimeSpan.FromSeconds(1);
+ _statsTimer.Tick += OnStatsTick;
+ _statsTimer.Start();
+
+ EnableAllOnlineCommand = new AsyncRelayCommand(
+ EnableAllOnlineAsync,
+ () => Participants.Any(p => p.IsOnline && !p.IsEnabled));
+
+ StopAllIsosCommand = new AsyncRelayCommand(
+ StopAllIsosAsync,
+ () => Participants.Any(p => p.IsEnabled));
+
+ RefreshDiscoveryCommand = new RelayCommand(() =>
+ {
+ _controller.RefreshDiscovery();
+ StatusText = "Refreshing NDI discovery…";
+ });
+
+ DropRecordingMarkerCommand = new RelayCommand(() =>
+ {
+ var label = "Marker @ " + DateTimeOffset.Now.ToString("HH:mm:ss");
+ _controller.AddRecordingMarker(label);
+ StatusText = $"Marker dropped: {label}";
+ });
+
+ ToggleByIndexCommand = new RelayCommand(s =>
+ {
+ if (!int.TryParse(s, out var idx) || idx < 1 || idx > 9) return;
+ var i = 0;
+ foreach (var p in Participants)
+ {
+ if (++i == idx)
+ {
+ if (p.ToggleIsoCommand.CanExecute(null))
+ p.ToggleIsoCommand.Execute(null);
+ break;
+ }
+ }
+ });
+ }
+
+ ///
+ /// Kick off the engine controller's StartAsync. Awaited by App.OnLaunched
+ /// so the participants observable starts firing as soon as discovery's up.
+ ///
+ public Task InitializeAsync(CancellationToken ct) => _controller.StartAsync(ct);
+
+ private void OnParticipantsChanged(IReadOnlyList snapshot)
+ {
+ // Reconcile: add new, update existing, remove gone. Identity is by Guid.
+ var present = new HashSet();
+ foreach (var p in snapshot)
+ {
+ present.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);
+ }
+ }
+ for (var i = Participants.Count - 1; i >= 0; i--)
+ {
+ var vm = Participants[i];
+ if (!present.Contains(vm.Id))
+ {
+ _byId.Remove(vm.Id);
+ Participants.RemoveAt(i);
+ }
+ }
+
+ ParticipantCountText = Participants.Count.ToString();
+ StatusText = Participants.Count == 0
+ ? "Waiting for Teams participants…"
+ : $"{Participants.Count} participants · {Participants.Count(p => p.IsEnabled)} routing";
+
+ EnableAllOnlineCommand.RaiseCanExecuteChanged();
+ StopAllIsosCommand.RaiseCanExecuteChanged();
+ }
+
+ private void OnStatsTick(DispatcherQueueTimer sender, object args)
+ {
+ // Pull stats + update active-speaker highlight.
+ foreach (var vm in Participants)
+ {
+ try
+ {
+ var stats = _controller.GetStats(vm.Id);
+ vm.UpdateStats(stats);
+ }
+ catch
+ {
+ // Stats are advisory; transient read failures shouldn't
+ // tear down the timer.
+ }
+ }
+
+ // Active speaker: loudest among the enabled, threshold 0.05 to
+ // prevent flicker between near-silent participants.
+ ParticipantViewModel? loudest = null;
+ double loudestLevel = 0.05;
+ foreach (var p in Participants)
+ {
+ if (!p.IsEnabled) continue;
+ if (p.DisplayedAudioLevel > loudestLevel)
+ {
+ loudest = p;
+ loudestLevel = p.DisplayedAudioLevel;
+ }
+ }
+ foreach (var p in Participants)
+ {
+ var shouldHighlight = ReferenceEquals(p, loudest);
+ if (p.IsActiveSpeaker != shouldHighlight)
+ p.IsActiveSpeaker = shouldHighlight;
+ }
+
+ // Session timer + recording count.
+ var enabledCount = Participants.Count(p => p.IsEnabled);
+ if (enabledCount > 0 && !IsSessionActive)
+ {
+ IsSessionActive = true;
+ _sessionStartedAt = DateTimeOffset.UtcNow;
+ }
+ else if (enabledCount == 0 && IsSessionActive)
+ {
+ IsSessionActive = false;
+ _sessionStartedAt = null;
+ }
+ if (_sessionStartedAt is { } start)
+ {
+ var elapsed = DateTimeOffset.UtcNow - start;
+ SessionElapsed = elapsed.ToString(@"hh\:mm\:ss");
+ }
+
+ ActiveRecordingCount = _controller.RecordingEnabled ? enabledCount : 0;
+ OnPropertyChanged(nameof(IsRecording));
+
+ EnableAllOnlineCommand.RaiseCanExecuteChanged();
+ StopAllIsosCommand.RaiseCanExecuteChanged();
+ }
+
+ private async Task EnableAllOnlineAsync()
+ {
+ foreach (var p in Participants.Where(p => p.IsOnline && !p.IsEnabled).ToArray())
+ {
+ if (p.ToggleIsoCommand.CanExecute(null))
+ {
+ p.ToggleIsoCommand.Execute(null);
+ await Task.Delay(50); // small gap so we don't hammer the engine
+ }
+ }
+ }
+
+ private async Task StopAllIsosAsync()
+ {
+ foreach (var p in Participants.Where(p => p.IsEnabled).ToArray())
+ {
+ try
+ {
+ await _controller.DisableIsoAsync(p.Id, CancellationToken.None);
+ }
+ catch
+ {
+ // Continue with remaining; we'll re-sync on the next observable tick.
+ }
+ }
+ }
+
+ public void Dispose()
+ {
+ _statsTimer.Stop();
+ _participantsSub.Dispose();
+ _alertsSub.Dispose();
+ }
+}
diff --git a/src/TeamsISO.App.WinUI/ViewModels/ObservableObject.cs b/src/TeamsISO.App.WinUI/ViewModels/ObservableObject.cs
new file mode 100644
index 0000000..c4faaf8
--- /dev/null
+++ b/src/TeamsISO.App.WinUI/ViewModels/ObservableObject.cs
@@ -0,0 +1,26 @@
+using System.Collections.Generic;
+using System.ComponentModel;
+using System.Runtime.CompilerServices;
+
+namespace TeamsISO.App.WinUI.ViewModels;
+
+///
+/// Minimal MVVM base implementing .
+/// Mirrors the WPF host's ObservableObject so view-model code reads the
+/// same across hosts.
+///
+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.WinUI/ViewModels/ParticipantViewModel.cs b/src/TeamsISO.App.WinUI/ViewModels/ParticipantViewModel.cs
new file mode 100644
index 0000000..a2db339
--- /dev/null
+++ b/src/TeamsISO.App.WinUI/ViewModels/ParticipantViewModel.cs
@@ -0,0 +1,209 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using TeamsISO.Engine.Controller;
+using TeamsISO.Engine.Domain;
+
+namespace TeamsISO.App.WinUI.ViewModels;
+
+///
+/// Slim per-row view model for the WinUI 3 host. Drops the WPF-specific
+/// surfaces (thumbnail WriteableBitmap, clipboard, PreviewWindow, snapshot
+/// PNG encoder) that don't translate without a separate composition path.
+/// The essential operator-facing properties stay: display name, source
+/// codec, signal state, audio level, ISO toggle.
+///
+/// Thumbnails and per-row snapshot/preview come back in a follow-up
+/// commit alongside Microsoft.UI.Xaml.Media.Imaging.WriteableBitmap +
+/// SoftwareBitmap encoding.
+///
+public sealed class ParticipantViewModel : ObservableObject
+{
+ private readonly IIsoController _controller;
+ private Participant _participant;
+ private bool _isEnabled;
+ private bool _isProcessing;
+ private bool _isActiveSpeaker;
+ private double _displayedAudioLevel;
+ private string _stateLabel = "—";
+ private string _outputName = string.Empty;
+ private string _sourceCodec = string.Empty;
+
+ public ParticipantViewModel(IIsoController controller, Participant participant)
+ {
+ _controller = controller;
+ _participant = participant;
+ ToggleIsoCommand = new AsyncRelayCommand(ToggleIsoAsync, () => !_isProcessing);
+ RefreshFromParticipant();
+ }
+
+ public Guid Id => _participant.Id;
+
+ public string DisplayName => string.IsNullOrWhiteSpace(_participant.DisplayName)
+ ? _participant.Id.ToString().Substring(0, 8)
+ : _participant.DisplayName;
+
+ public string Initials
+ {
+ get
+ {
+ var name = DisplayName;
+ if (string.IsNullOrWhiteSpace(name)) return "??";
+ var parts = name.Split(' ', StringSplitOptions.RemoveEmptyEntries);
+ if (parts.Length == 0) return name.Substring(0, Math.Min(2, name.Length)).ToUpperInvariant();
+ if (parts.Length == 1) return parts[0].Substring(0, Math.Min(2, parts[0].Length)).ToUpperInvariant();
+ return (parts[0][0].ToString() + parts[^1][0]).ToUpperInvariant();
+ }
+ }
+
+ public string SourceCodec
+ {
+ get => _sourceCodec;
+ private set => SetField(ref _sourceCodec, value);
+ }
+
+ public bool IsOnline => _participant.CurrentSource is not null;
+
+ public bool IsEnabled
+ {
+ get => _isEnabled;
+ private set
+ {
+ if (SetField(ref _isEnabled, value))
+ {
+ OnPropertyChanged(nameof(IsoStateLabel));
+ }
+ }
+ }
+
+ public bool IsProcessing
+ {
+ get => _isProcessing;
+ private set
+ {
+ if (SetField(ref _isProcessing, value))
+ ToggleIsoCommand.RaiseCanExecuteChanged();
+ }
+ }
+
+ public bool IsActiveSpeaker
+ {
+ get => _isActiveSpeaker;
+ internal set => SetField(ref _isActiveSpeaker, value);
+ }
+
+ public double DisplayedAudioLevel
+ {
+ get => _displayedAudioLevel;
+ private set => SetField(ref _displayedAudioLevel, value);
+ }
+
+ /// Display label for the ISO pill: LIVE / OFF / ERROR / NO SIGNAL / STARTING.
+ public string IsoStateLabel => _stateLabel;
+
+ ///
+ /// Output name resolved from the operator's template. For now, a
+ /// deterministic TEAMSISO_{firstname_lowercase} fallback to keep
+ /// the demo running without the OutputNameTemplate plumbing.
+ ///
+ public string OutputName
+ {
+ get => _outputName;
+ private set => SetField(ref _outputName, value);
+ }
+
+ public AsyncRelayCommand ToggleIsoCommand { get; }
+
+ /// Engine emits an updated Participant — refresh derived fields.
+ public void Update(Participant updated)
+ {
+ _participant = updated;
+ RefreshFromParticipant();
+ OnPropertyChanged(nameof(DisplayName));
+ OnPropertyChanged(nameof(Initials));
+ OnPropertyChanged(nameof(IsOnline));
+ OnPropertyChanged(nameof(OutputName));
+ }
+
+ private void RefreshFromParticipant()
+ {
+ // Output name: TEAMSISO_.
+ // Mirrors the engine's default template loosely. Will replace
+ // with the operator's template once OutputNameTemplate moves
+ // into the engine layer (currently it's WPF-host-side).
+ var name = DisplayName.Replace(' ', '_').ToLowerInvariant();
+ OutputName = "TEAMSISO_" + name;
+
+ // Source codec line: "MS Teams · WxH · fps". When no source is
+ // attached yet, show a placeholder.
+ var src = _participant.CurrentSource;
+ SourceCodec = src is null
+ ? "Offline"
+ : "MS Teams · awaiting frame";
+ }
+
+ ///
+ /// Called from MainViewModel's 1Hz stats tick. Updates audio level
+ /// with attack/decay, state label from the engine's IsoHealthStats.
+ ///
+ public void UpdateStats(IsoHealthStats stats)
+ {
+ if (stats.PeakAudioLevel > _displayedAudioLevel)
+ {
+ _displayedAudioLevel = stats.PeakAudioLevel;
+ }
+ else
+ {
+ _displayedAudioLevel *= 0.7;
+ if (_displayedAudioLevel < 0.01) _displayedAudioLevel = 0;
+ }
+ OnPropertyChanged(nameof(DisplayedAudioLevel));
+
+ var newLabel = stats.State switch
+ {
+ IsoState.Receiving => "LIVE",
+ IsoState.Sending => "LIVE",
+ IsoState.NoSignal => "NO SIGNAL",
+ IsoState.Error => "ERROR",
+ IsoState.Idle => IsEnabled ? "STARTING" : "OFF",
+ _ => "OFF",
+ };
+
+ if (stats.IncomingWidth > 0 && stats.IncomingHeight > 0)
+ {
+ SourceCodec = $"MS Teams · {stats.IncomingWidth}×{stats.IncomingHeight} · {stats.IncomingFps:0} fps";
+ }
+
+ if (_stateLabel != newLabel)
+ {
+ _stateLabel = newLabel;
+ OnPropertyChanged(nameof(IsoStateLabel));
+ }
+ }
+
+ private async Task ToggleIsoAsync()
+ {
+ IsProcessing = true;
+ try
+ {
+ if (IsEnabled)
+ {
+ await _controller.DisableIsoAsync(Id, CancellationToken.None);
+ IsEnabled = false;
+ }
+ else
+ {
+ await _controller.EnableIsoAsync(Id, OutputName, CancellationToken.None);
+ IsEnabled = true;
+ }
+ }
+ catch
+ {
+ // Failures surface in the engine alerts stream; don't crash the UI here.
+ }
+ finally
+ {
+ IsProcessing = false;
+ }
+ }
+}
diff --git a/src/TeamsISO.App.WinUI/ViewModels/RelayCommand.cs b/src/TeamsISO.App.WinUI/ViewModels/RelayCommand.cs
new file mode 100644
index 0000000..a7951df
--- /dev/null
+++ b/src/TeamsISO.App.WinUI/ViewModels/RelayCommand.cs
@@ -0,0 +1,86 @@
+using System;
+using System.Threading.Tasks;
+using System.Windows.Input;
+
+namespace TeamsISO.App.WinUI.ViewModels;
+
+///
+/// Synchronous command — same shape as the WPF host's RelayCommand.
+/// System.Windows.Input.ICommand is the shared base type across both
+/// hosts (lives in System.ObjectModel.dll on .NET 8), so no rewrite
+/// is needed beyond the namespace.
+///
+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);
+}
+
+/// Typed-parameter variant for hotkey-driven commands (NumPad 1-9).
+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(Convert(parameter)) ?? true;
+ public void Execute(object? parameter) => _execute(Convert(parameter));
+ public event EventHandler? CanExecuteChanged;
+ public void RaiseCanExecuteChanged() => CanExecuteChanged?.Invoke(this, EventArgs.Empty);
+
+ private static TValue Convert(object? value)
+ {
+ if (value is null) return default!;
+ if (value is TValue typed) return typed;
+ try { return (TValue)System.Convert.ChangeType(value, typeof(TValue)); }
+ catch { return default!; }
+ }
+}
+
+/// Async command that suppresses re-entrancy while in flight.
+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);
+}
diff --git a/src/TeamsISO.App.WinUI/Views/MainWindow.xaml b/src/TeamsISO.App.WinUI/Views/MainWindow.xaml
index 0dfcb1a..b5b22c1 100644
--- a/src/TeamsISO.App.WinUI/Views/MainWindow.xaml
+++ b/src/TeamsISO.App.WinUI/Views/MainWindow.xaml
@@ -292,15 +292,17 @@
PlaceholderText="Filter"
Width="200"
VerticalAlignment="Center"/>
-
-
-
+
@@ -317,7 +319,7 @@
git history of this file shows the binding-heavy version
we'll re-introduce once the engine is wired in.
-->
-
+
-