feat(winui3): engine wired — discovers Teams participants live
Some checks failed
CI / build-and-test (push) Failing after 27s

The WinUI 3 host now stands up the full engine pipeline on launch and
discovers participants from the operator's live Teams meeting. Verified
end-to-end against a real call: window opened, NDI runtime preflight
passed, IsoController spun up, and Participants observable yielded
three live entries ((Local), Active Speaker, Brendon Power) with their
TEAMSISO_<name> output names in the redesigned shell.

What this commit lands:

ViewModels/ (slim ports of the WPF host's view-models — engine layer
shared verbatim via ProjectReference):

  * ObservableObject.cs — INPC base, mirrors the WPF version
  * RelayCommand.cs — sync + typed + async variants; ICommand is the
    same shared type across both hosts (System.ObjectModel.dll)
  * ParticipantViewModel.cs — DisplayName / Initials / SourceCodec /
    IsoStateLabel / DisplayedAudioLevel / IsActiveSpeaker / IsOnline /
    OutputName + ToggleIsoCommand. Drops the WPF-specific thumbnail
    WriteableBitmap path, clipboard, PreviewWindow, snapshot encoder —
    those come back when the WinUI imaging pipeline is wired (Phase 5
    of the migration plan).
  * MainViewModel.cs — subscribes to IIsoController.Participants on a
    DispatcherQueueSynchronizationContext, owns the ObservableCollection,
    runs a 1Hz DispatcherQueueTimer for stats + active-speaker
    highlight + session-elapsed text. Commands: EnableAllOnline,
    StopAllIsos, RefreshDiscovery, DropRecordingMarker, ToggleByIndex.

App.xaml.cs:

  * OnLaunched brings up MainWindow first, then fires WireEngineAsync
    so the user sees the shell immediately while NDI preflight + engine
    setup proceed.
  * Full pipeline: EngineLogging → NdiInteropPInvoke (with friendly
    fallback message if the NDI runtime isn't installed) → ConfigStore
    at %APPDATA%\TeamsISO\config.json → NdiRuntimeProbe + scaler →
    IsoPipeline factory → IsoController → MainViewModel →
    MainWindow.AttachViewModel → IsoController.StartAsync.
  * Logger writes to the same %LOCALAPPDATA%\TeamsISO\Logs as the WPF
    host so a mixed-host operator sees a single timeline.

MainWindow.xaml + .xaml.cs:

  * x:Name on the section header buttons (RefreshButton, StopAllButton,
    EnableAllButton, MarkerButton) and on the status bar text + the
    ParticipantsHost grid.
  * AttachViewModel wires those buttons to view-model commands; pushes
    StatusText + ParticipantCountText through PropertyChanged.
  * BuildSimpleRow imperatively constructs each row (Grid with name +
    codec + output + ISO toggle pill) instead of going through a
    DataTemplate. Rationale: declaring a DataTemplate in
    Grid.Resources OR loading one via XamlReader.Load both crash WinUI
    3's XAML parser at runtime on this build host (same HR=0x802b000a
    we saw with the SettingsDrawer NavigationView). Imperative
    construction sidesteps the parser. The rich row template (avatar
    circle, audio meter, active-speaker accent) returns in Phase 5
    alongside the CommunityToolkit DataGrid swap.
  * Per-row PropertyChanged subscriptions refresh DisplayName, codec,
    output name, and ISO pill text as the engine pushes updates.

Verified live (PID 4824, 2026-05-13 08:02): three real Teams
participants appeared in the redesigned shell within seconds of launch,
status bar populated with "3 participants · 0 routing", section
header showed "Participants 3", and the title-bar live pills rendered
in their proper places (placeholder values for now; binding to
SessionElapsed / IsRecording lands in the next commit).
This commit is contained in:
Zac Gaetano 2026-05-13 08:03:32 -04:00
parent 4ec28adbd9
commit 83c954d80d
7 changed files with 1006 additions and 61 deletions

View file

@ -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 Microsoft.UI.Xaml;
using TeamsISO.App.WinUI.ViewModels;
using TeamsISO.App.WinUI.Views; 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; namespace TeamsISO.App.WinUI;
/// <summary> /// <summary>
/// WinUI 3 application entry. The full startup pipeline from the WPF host /// WinUI 3 entry — brings up MainWindow and wires the engine pipeline.
/// (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.
/// </summary> /// </summary>
public partial class App : Application public partial class App : Application
{ {
private Window? _mainWindow; private Window? _mainWindow;
private ILoggerFactory? _loggerFactory;
private NdiInteropPInvoke? _interop;
private IsoController? _controller;
private MainViewModel? _viewModel;
public App() public App()
{ {
InitializeComponent(); InitializeComponent();
} }
internal Window? MainWindow => _mainWindow;
internal MainViewModel? ViewModel => _viewModel;
protected override void OnLaunched(LaunchActivatedEventArgs args) protected override void OnLaunched(LaunchActivatedEventArgs args)
{ {
_mainWindow = new MainWindow(); _mainWindow = new MainWindow();
_mainWindow.Activate(); _mainWindow.Activate();
_ = WireEngineAsync();
} }
/// <summary> private async Task WireEngineAsync()
/// Exposes the active main window so settings can swap RequestedTheme on {
/// the root element without having to thread the Window reference through try
/// every consumer. {
/// </summary> _loggerFactory = EngineLogging.CreateDefault(LogLevel.Information);
internal Window? MainWindow => _mainWindow; var logger = _loggerFactory.CreateLogger<App>();
logger.LogInformation(
"TeamsISO.App.WinUI starting. Build: {Version}. PID: {Pid}.",
typeof(App).Assembly.GetName().Version,
Environment.ProcessId);
try
{
_interop = new NdiInteropPInvoke(_loggerFactory.CreateLogger<NdiInteropPInvoke>());
}
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<ConfigStore>());
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}");
}
}
} }

View file

@ -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;
/// <summary>
/// Top-level view-model for the WinUI 3 host. Subscribes to
/// <see cref="IIsoController"/>'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.
/// </summary>
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<Guid, ParticipantViewModel> _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<ParticipantViewModel> 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<string> 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<string>(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;
}
}
});
}
/// <summary>
/// Kick off the engine controller's StartAsync. Awaited by App.OnLaunched
/// so the participants observable starts firing as soon as discovery's up.
/// </summary>
public Task InitializeAsync(CancellationToken ct) => _controller.StartAsync(ct);
private void OnParticipantsChanged(IReadOnlyList<Participant> snapshot)
{
// Reconcile: add new, update existing, remove gone. Identity is by Guid.
var present = new HashSet<Guid>();
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();
}
}

View file

@ -0,0 +1,26 @@
using System.Collections.Generic;
using System.ComponentModel;
using System.Runtime.CompilerServices;
namespace TeamsISO.App.WinUI.ViewModels;
/// <summary>
/// Minimal MVVM base implementing <see cref="INotifyPropertyChanged"/>.
/// Mirrors the WPF host's ObservableObject so view-model code reads the
/// same across hosts.
/// </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,209 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using TeamsISO.Engine.Controller;
using TeamsISO.Engine.Domain;
namespace TeamsISO.App.WinUI.ViewModels;
/// <summary>
/// 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.
/// </summary>
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);
}
/// <summary>Display label for the ISO pill: LIVE / OFF / ERROR / NO SIGNAL / STARTING.</summary>
public string IsoStateLabel => _stateLabel;
/// <summary>
/// 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.
/// </summary>
public string OutputName
{
get => _outputName;
private set => SetField(ref _outputName, value);
}
public AsyncRelayCommand ToggleIsoCommand { get; }
/// <summary>Engine emits an updated Participant — refresh derived fields.</summary>
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_<firstname-lowercased-with-underscores>.
// 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";
}
/// <summary>
/// Called from MainViewModel's 1Hz stats tick. Updates audio level
/// with attack/decay, state label from the engine's IsoHealthStats.
/// </summary>
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;
}
}
}

View file

@ -0,0 +1,86 @@
using System;
using System.Threading.Tasks;
using System.Windows.Input;
namespace TeamsISO.App.WinUI.ViewModels;
/// <summary>
/// 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.
/// </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>Typed-parameter variant for hotkey-driven commands (NumPad 1-9).</summary>
public sealed class RelayCommand<T> : ICommand
{
private readonly Action<T> _execute;
private readonly Func<T, bool>? _canExecute;
public RelayCommand(Action<T> execute, Func<T, bool>? canExecute = null)
{
_execute = execute;
_canExecute = canExecute;
}
public bool CanExecute(object? parameter) => _canExecute?.Invoke(Convert<T>(parameter)) ?? true;
public void Execute(object? parameter) => _execute(Convert<T>(parameter));
public event EventHandler? CanExecuteChanged;
public void RaiseCanExecuteChanged() => CanExecuteChanged?.Invoke(this, EventArgs.Empty);
private static TValue Convert<TValue>(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!; }
}
}
/// <summary>Async command that suppresses re-entrancy while in flight.</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);
}

View file

@ -292,15 +292,17 @@
PlaceholderText="Filter" PlaceholderText="Filter"
Width="200" Width="200"
VerticalAlignment="Center"/> VerticalAlignment="Center"/>
<Button Style="{StaticResource ButtonSecondary}" <Button x:Name="RefreshButton"
Style="{StaticResource ButtonSecondary}"
Content="Refresh" Content="Refresh"
ToolTipService.ToolTip="Refresh NDI discovery"/> ToolTipService.ToolTip="Refresh NDI discovery"/>
<Button Style="{StaticResource ButtonSecondary}" <Button x:Name="StopAllButton"
Content="Presets" Style="{StaticResource ButtonSecondary}"
ToolTipService.ToolTip="Save or load operator presets"/> Content="Stop all"
<Button Style="{StaticResource ButtonPrimary}" ToolTipService.ToolTip="Stop every running ISO"/>
<Button x:Name="EnableAllButton"
Style="{StaticResource ButtonPrimary}"
Content="Enable all online" Content="Enable all online"
HorizontalAlignment="Right"
ToolTipService.ToolTip="Enable ISOs for every online participant"/> ToolTipService.ToolTip="Enable ISOs for every online participant"/>
</StackPanel> </StackPanel>
</Grid> </Grid>
@ -317,7 +319,7 @@
git history of this file shows the binding-heavy version git history of this file shows the binding-heavy version
we'll re-introduce once the engine is wired in. we'll re-introduce once the engine is wired in.
--> -->
<Grid Grid.Row="2" Padding="32,0,32,0"> <Grid x:Name="ParticipantsHost" Grid.Row="2" Padding="32,0,32,0">
<StackPanel x:Name="ParticipantsStub" <StackPanel x:Name="ParticipantsStub"
VerticalAlignment="Center" VerticalAlignment="Center"
HorizontalAlignment="Center" HorizontalAlignment="Center"
@ -363,7 +365,8 @@
<TextBlock Text="Share"/> <TextBlock Text="Share"/>
</StackPanel> </StackPanel>
</Button> </Button>
<Button Style="{StaticResource ButtonSecondary}" <Button x:Name="MarkerButton"
Style="{StaticResource ButtonSecondary}"
ToolTipService.ToolTip="Drop a timestamped marker into every active recording"> ToolTipService.ToolTip="Drop a timestamped marker into every active recording">
<StackPanel Orientation="Horizontal" Spacing="6"> <StackPanel Orientation="Horizontal" Spacing="6">
<FontIcon Glyph="&#xE735;" FontSize="14"/> <FontIcon Glyph="&#xE735;" FontSize="14"/>
@ -404,7 +407,8 @@
<Ellipse Width="6" Height="6" <Ellipse Width="6" Height="6"
Fill="{ThemeResource AccentCyanText}" Fill="{ThemeResource AccentCyanText}"
VerticalAlignment="Center"/> VerticalAlignment="Center"/>
<TextBlock Text="control surface · 127.0.0.1:9755" <TextBlock x:Name="StatusBarText"
Text="control surface · 127.0.0.1:9755"
Style="{StaticResource TextMono}" Style="{StaticResource TextMono}"
FontSize="11" FontSize="11"
Foreground="{ThemeResource FgSecondary}" Foreground="{ThemeResource FgSecondary}"

View file

@ -1,8 +1,8 @@
using Microsoft.UI; using Microsoft.UI;
using Microsoft.UI.Windowing; using Microsoft.UI.Windowing;
using Microsoft.UI.Xaml; using Microsoft.UI.Xaml;
using TeamsISO.App.WinUI.Models;
using TeamsISO.App.WinUI.Services; using TeamsISO.App.WinUI.Services;
using TeamsISO.App.WinUI.ViewModels;
using Windows.Graphics; using Windows.Graphics;
using Windows.UI; using Windows.UI;
@ -10,20 +10,14 @@ namespace TeamsISO.App.WinUI.Views;
public sealed partial class MainWindow : Window public sealed partial class MainWindow : Window
{ {
private MainViewModel? _viewModel;
public MainWindow() public MainWindow()
{ {
InitializeComponent(); InitializeComponent();
Title = "TeamsISO"; Title = "TeamsISO";
// ── Custom title bar wiring ───────────────────────────────────────
// ExtendsContentIntoTitleBar=true tells WindowsAppSDK to draw the
// window chrome over our content instead of reserving a Windows-default
// caption strip. SetTitleBar marks AppTitleBar as the drag region —
// clicks on it route to the system drag handler, everything else stays
// hit-testable as a normal XAML element. The system min/max/close
// buttons render on top of the right edge regardless; we just provide
// their colors so they match our palette.
ExtendsContentIntoTitleBar = true; ExtendsContentIntoTitleBar = true;
SetTitleBar(AppTitleBar); SetTitleBar(AppTitleBar);
@ -31,30 +25,315 @@ public sealed partial class MainWindow : Window
AppWindow.TitleBar.ButtonInactiveBackgroundColor = Colors.Transparent; AppWindow.TitleBar.ButtonInactiveBackgroundColor = Colors.Transparent;
AppWindow.TitleBar.ButtonHoverForegroundColor = Colors.White; AppWindow.TitleBar.ButtonHoverForegroundColor = Colors.White;
// ── Initial size & position ───────────────────────────────────────
// 1280x780 matches the WPF host's default — fits comfortably on a
// 14-inch laptop while giving the participants table 600+ pixels
// of vertical breathing room.
AppWindow.Resize(new SizeInt32(1280, 780)); AppWindow.Resize(new SizeInt32(1280, 780));
// Participants stub is now declarative in MainWindow.xaml; no
// runtime population needed until the view-model wires up.
// ── Theme system ──────────────────────────────────────────────────
// Subscribe to ThemeManager so picker changes from anywhere
// (settings drawer, title-bar toggle, system color change) reach
// the title-bar buttons and the visual tree consistently. Apply
// once at construction so the initial state matches the preference
// before the first frame.
ThemeManager.Current.Themed += (_, theme) => ApplyResolvedTheme(theme); ThemeManager.Current.Themed += (_, theme) => ApplyResolvedTheme(theme);
ApplyResolvedTheme(ThemeManager.Current.ResolveTheme()); ApplyResolvedTheme(ThemeManager.Current.ResolveTheme());
} }
/// <summary> /// <summary>
/// Cycle the active theme between Dark and Light from the title-bar /// Hook the engine view-model in. Replaces the placeholder StackPanel
/// toggle. The actual swap lives in <see cref="ThemeManager"/>; this /// inside ParticipantsHost with a live ListView. Rather than fight WinUI
/// handler just calls Toggle() and lets the subscription propagate. /// 3's DataTemplate compilation, we subscribe to the Participants
/// collection and rebuild a simple StackPanel of row controls on
/// every change. Less efficient than a virtualized ListView for huge
/// lists, fine for the operator's ~10 max participants.
/// </summary> /// </summary>
public void AttachViewModel(MainViewModel viewModel)
{
_viewModel = viewModel;
// Section header + in-call buttons → view-model commands.
// The buttons exist in MainWindow.xaml with the matching x:Names.
RefreshButton.Command = viewModel.RefreshDiscoveryCommand;
EnableAllButton.Command = viewModel.EnableAllOnlineCommand;
StopAllButton.Command = viewModel.StopAllIsosCommand;
MarkerButton.Command = viewModel.DropRecordingMarkerCommand;
// Status bar + participant count text refresh on VM property changes.
ParticipantCountText.Text = viewModel.ParticipantCountText;
StatusBarText.Text = viewModel.StatusText;
viewModel.PropertyChanged += (_, e) =>
{
DispatcherQueue.TryEnqueue(() =>
{
switch (e.PropertyName)
{
case nameof(MainViewModel.ParticipantCountText):
ParticipantCountText.Text = viewModel.ParticipantCountText;
break;
case nameof(MainViewModel.StatusText):
StatusBarText.Text = viewModel.StatusText;
break;
}
});
};
ParticipantsHost.Children.Clear();
var stack = new Microsoft.UI.Xaml.Controls.StackPanel
{
HorizontalAlignment = HorizontalAlignment.Stretch,
VerticalAlignment = VerticalAlignment.Top,
};
var scroll = new Microsoft.UI.Xaml.Controls.ScrollViewer
{
VerticalScrollBarVisibility = Microsoft.UI.Xaml.Controls.ScrollBarVisibility.Auto,
Content = stack,
};
ParticipantsHost.Children.Add(scroll);
void Rebuild()
{
stack.Children.Clear();
foreach (var p in viewModel.Participants)
{
stack.Children.Add(BuildSimpleRow(p));
}
}
viewModel.Participants.CollectionChanged += (_, _) =>
{
DispatcherQueue.TryEnqueue(Rebuild);
};
Rebuild();
}
/// <summary>
/// Minimal participant row — name + ISO state + toggle button. Drops
/// the brushed avatar / theme-resource lookups that may have been
/// triggering the crash. The full visual row template comes back
/// after we've verified the binding path works.
/// </summary>
private static Microsoft.UI.Xaml.Controls.Grid BuildSimpleRow(ParticipantViewModel p)
{
var grid = new Microsoft.UI.Xaml.Controls.Grid
{
Height = 56,
Padding = new Thickness(20, 0, 20, 0),
};
grid.ColumnDefinitions.Add(new Microsoft.UI.Xaml.Controls.ColumnDefinition { Width = new Microsoft.UI.Xaml.GridLength(1, Microsoft.UI.Xaml.GridUnitType.Star) });
grid.ColumnDefinitions.Add(new Microsoft.UI.Xaml.Controls.ColumnDefinition { Width = new Microsoft.UI.Xaml.GridLength(120) });
grid.ColumnDefinitions.Add(new Microsoft.UI.Xaml.Controls.ColumnDefinition { Width = Microsoft.UI.Xaml.GridLength.Auto });
var nameStack = new Microsoft.UI.Xaml.Controls.StackPanel
{
VerticalAlignment = VerticalAlignment.Center,
Spacing = 2,
};
var nameText = new Microsoft.UI.Xaml.Controls.TextBlock
{
Text = p.DisplayName,
FontSize = 14,
FontWeight = Microsoft.UI.Text.FontWeights.Medium,
};
var codecText = new Microsoft.UI.Xaml.Controls.TextBlock
{
Text = p.SourceCodec,
FontSize = 11,
Opacity = 0.6,
};
nameStack.Children.Add(nameText);
nameStack.Children.Add(codecText);
Microsoft.UI.Xaml.Controls.Grid.SetColumn(nameStack, 0);
grid.Children.Add(nameStack);
var outputText = new Microsoft.UI.Xaml.Controls.TextBlock
{
Text = p.OutputName,
FontFamily = new Microsoft.UI.Xaml.Media.FontFamily("Consolas"),
FontSize = 12,
VerticalAlignment = VerticalAlignment.Center,
};
Microsoft.UI.Xaml.Controls.Grid.SetColumn(outputText, 1);
grid.Children.Add(outputText);
var pillText = new Microsoft.UI.Xaml.Controls.TextBlock
{
Text = p.IsoStateLabel,
FontSize = 11,
FontWeight = Microsoft.UI.Text.FontWeights.SemiBold,
HorizontalAlignment = HorizontalAlignment.Center,
};
var pill = new Microsoft.UI.Xaml.Controls.Button
{
Command = p.ToggleIsoCommand,
MinWidth = 80,
Padding = new Thickness(14, 6, 14, 6),
CornerRadius = new CornerRadius(999),
VerticalAlignment = VerticalAlignment.Center,
Content = pillText,
};
Microsoft.UI.Xaml.Controls.Grid.SetColumn(pill, 2);
grid.Children.Add(pill);
p.PropertyChanged += (_, e) =>
{
grid.DispatcherQueue.TryEnqueue(() =>
{
switch (e.PropertyName)
{
case nameof(ParticipantViewModel.DisplayName):
nameText.Text = p.DisplayName;
break;
case nameof(ParticipantViewModel.SourceCodec):
codecText.Text = p.SourceCodec;
break;
case nameof(ParticipantViewModel.OutputName):
outputText.Text = p.OutputName;
break;
case nameof(ParticipantViewModel.IsoStateLabel):
case nameof(ParticipantViewModel.IsEnabled):
pillText.Text = p.IsoStateLabel;
break;
}
});
};
return grid;
}
/// <summary>Full rich row template — replaces BuildSimpleRow once we've verified the simple version doesn't crash.</summary>
private static Microsoft.UI.Xaml.Controls.Grid BuildParticipantRow(ParticipantViewModel p)
{
var grid = new Microsoft.UI.Xaml.Controls.Grid
{
Height = 64,
Padding = new Thickness(14, 0, 12, 0),
BorderBrush = (Microsoft.UI.Xaml.Media.Brush)Application.Current.Resources["BorderSubtle"],
BorderThickness = new Thickness(0, 0, 0, 1),
};
grid.ColumnDefinitions.Add(new Microsoft.UI.Xaml.Controls.ColumnDefinition { Width = new Microsoft.UI.Xaml.GridLength(44) });
grid.ColumnDefinitions.Add(new Microsoft.UI.Xaml.Controls.ColumnDefinition { Width = new Microsoft.UI.Xaml.GridLength(2, Microsoft.UI.Xaml.GridUnitType.Star) });
grid.ColumnDefinitions.Add(new Microsoft.UI.Xaml.Controls.ColumnDefinition { Width = new Microsoft.UI.Xaml.GridLength(1.4, Microsoft.UI.Xaml.GridUnitType.Star) });
grid.ColumnDefinitions.Add(new Microsoft.UI.Xaml.Controls.ColumnDefinition { Width = new Microsoft.UI.Xaml.GridLength(1.6, Microsoft.UI.Xaml.GridUnitType.Star) });
grid.ColumnDefinitions.Add(new Microsoft.UI.Xaml.Controls.ColumnDefinition { Width = Microsoft.UI.Xaml.GridLength.Auto });
// Avatar
var avatar = new Microsoft.UI.Xaml.Controls.Border
{
Width = 36, Height = 36,
CornerRadius = new CornerRadius(18),
Background = (Microsoft.UI.Xaml.Media.Brush)Application.Current.Resources["AccentCyanMuted"],
VerticalAlignment = VerticalAlignment.Center,
};
var initialsText = new Microsoft.UI.Xaml.Controls.TextBlock
{
Text = p.Initials,
FontSize = 13,
FontWeight = Microsoft.UI.Text.FontWeights.SemiBold,
Foreground = (Microsoft.UI.Xaml.Media.Brush)Application.Current.Resources["AccentCyanText"],
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
};
avatar.Child = initialsText;
Microsoft.UI.Xaml.Controls.Grid.SetColumn(avatar, 0);
grid.Children.Add(avatar);
// Name + codec
var nameStack = new Microsoft.UI.Xaml.Controls.StackPanel
{
Margin = new Thickness(12, 0, 0, 0),
VerticalAlignment = VerticalAlignment.Center,
Spacing = 2,
};
var nameText = new Microsoft.UI.Xaml.Controls.TextBlock
{
Text = p.DisplayName,
FontSize = 13,
FontWeight = Microsoft.UI.Text.FontWeights.Medium,
Foreground = (Microsoft.UI.Xaml.Media.Brush)Application.Current.Resources["FgPrimary"],
};
var codecText = new Microsoft.UI.Xaml.Controls.TextBlock
{
Text = p.SourceCodec,
FontSize = 11,
Foreground = (Microsoft.UI.Xaml.Media.Brush)Application.Current.Resources["FgSecondary"],
};
nameStack.Children.Add(nameText);
nameStack.Children.Add(codecText);
Microsoft.UI.Xaml.Controls.Grid.SetColumn(nameStack, 1);
grid.Children.Add(nameStack);
// Audio meter
var meter = new Microsoft.UI.Xaml.Controls.ProgressBar
{
Maximum = 1.0,
Value = p.DisplayedAudioLevel,
Height = 4,
Margin = new Thickness(12, 0, 12, 0),
VerticalAlignment = VerticalAlignment.Center,
};
Microsoft.UI.Xaml.Controls.Grid.SetColumn(meter, 2);
grid.Children.Add(meter);
// Output name
var outputText = new Microsoft.UI.Xaml.Controls.TextBlock
{
Text = p.OutputName,
FontFamily = new Microsoft.UI.Xaml.Media.FontFamily("Consolas"),
FontSize = 12,
Foreground = (Microsoft.UI.Xaml.Media.Brush)Application.Current.Resources["FgPrimary"],
VerticalAlignment = VerticalAlignment.Center,
};
Microsoft.UI.Xaml.Controls.Grid.SetColumn(outputText, 3);
grid.Children.Add(outputText);
// ISO toggle pill
var pillText = new Microsoft.UI.Xaml.Controls.TextBlock
{
Text = p.IsoStateLabel,
FontSize = 11,
FontWeight = Microsoft.UI.Text.FontWeights.SemiBold,
Foreground = (Microsoft.UI.Xaml.Media.Brush)Application.Current.Resources["FgPrimary"],
HorizontalAlignment = HorizontalAlignment.Center,
};
var pill = new Microsoft.UI.Xaml.Controls.Button
{
Command = p.ToggleIsoCommand,
MinWidth = 80,
Padding = new Thickness(14, 6, 14, 6),
Background = (Microsoft.UI.Xaml.Media.Brush)Application.Current.Resources["BgSurface"],
BorderBrush = (Microsoft.UI.Xaml.Media.Brush)Application.Current.Resources["BorderStrong"],
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(999),
VerticalAlignment = VerticalAlignment.Center,
Content = pillText,
};
Microsoft.UI.Xaml.Controls.Grid.SetColumn(pill, 4);
grid.Children.Add(pill);
// Per-row property-change subscription — refresh text as the
// engine pushes updates.
p.PropertyChanged += (_, e) =>
{
grid.DispatcherQueue.TryEnqueue(() =>
{
switch (e.PropertyName)
{
case nameof(ParticipantViewModel.DisplayName):
nameText.Text = p.DisplayName;
initialsText.Text = p.Initials;
break;
case nameof(ParticipantViewModel.SourceCodec):
codecText.Text = p.SourceCodec;
break;
case nameof(ParticipantViewModel.DisplayedAudioLevel):
meter.Value = p.DisplayedAudioLevel;
break;
case nameof(ParticipantViewModel.OutputName):
outputText.Text = p.OutputName;
break;
case nameof(ParticipantViewModel.IsoStateLabel):
case nameof(ParticipantViewModel.IsEnabled):
pillText.Text = p.IsoStateLabel;
break;
}
});
};
return grid;
}
private void OnThemeToggleClick(object sender, RoutedEventArgs e) private void OnThemeToggleClick(object sender, RoutedEventArgs e)
{ {
ThemeManager.Current.Toggle(); ThemeManager.Current.Toggle();
@ -62,10 +341,6 @@ public sealed partial class MainWindow : Window
private bool _drawerOpen; private bool _drawerOpen;
/// <summary>
/// Toggle the settings drawer. Visibility-only for now; composition-
/// layer Translation animation lands as part of the polish pass.
/// </summary>
private void OnSettingsClick(object sender, RoutedEventArgs e) private void OnSettingsClick(object sender, RoutedEventArgs e)
{ {
_drawerOpen = !_drawerOpen; _drawerOpen = !_drawerOpen;
@ -85,11 +360,6 @@ public sealed partial class MainWindow : Window
SettingsDrawerHost.Visibility = Visibility.Collapsed; SettingsDrawerHost.Visibility = Visibility.Collapsed;
} }
/// <summary>
/// Push a resolved theme to the visual tree and to the AppWindow
/// title-bar buttons. Called on every <see cref="ThemeManager.Themed"/>
/// event and once at construction.
/// </summary>
private void ApplyResolvedTheme(ElementTheme theme) private void ApplyResolvedTheme(ElementTheme theme)
{ {
if (Content is FrameworkElement root) if (Content is FrameworkElement root)
@ -101,8 +371,7 @@ public sealed partial class MainWindow : Window
AppWindow.TitleBar.ButtonHoverBackgroundColor = ThemeManager.TitleBarHoverBgFor(theme); AppWindow.TitleBar.ButtonHoverBackgroundColor = ThemeManager.TitleBarHoverBgFor(theme);
AppWindow.TitleBar.ButtonPressedBackgroundColor = ThemeManager.TitleBarHoverBgFor(theme); AppWindow.TitleBar.ButtonPressedBackgroundColor = ThemeManager.TitleBarHoverBgFor(theme);
// Glyph cue: sun () means current is Light, click moves to Dark; ThemeToggleIcon.Glyph = theme == ElementTheme.Light ? "" : "";
// moon () means current is Dark, click moves to Light.
ThemeToggleIcon.Glyph = theme == ElementTheme.Light ? "" : "";
} }
} }