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"/> - -