feat(winui3): engine wired — discovers Teams participants live
Some checks failed
CI / build-and-test (push) Failing after 27s
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:
parent
4ec28adbd9
commit
83c954d80d
7 changed files with 1006 additions and 61 deletions
|
|
@ -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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
282
src/TeamsISO.App.WinUI/ViewModels/MainViewModel.cs
Normal file
282
src/TeamsISO.App.WinUI/ViewModels/MainViewModel.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
26
src/TeamsISO.App.WinUI/ViewModels/ObservableObject.cs
Normal file
26
src/TeamsISO.App.WinUI/ViewModels/ObservableObject.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
209
src/TeamsISO.App.WinUI/ViewModels/ParticipantViewModel.cs
Normal file
209
src/TeamsISO.App.WinUI/ViewModels/ParticipantViewModel.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
86
src/TeamsISO.App.WinUI/ViewModels/RelayCommand.cs
Normal file
86
src/TeamsISO.App.WinUI/ViewModels/RelayCommand.cs
Normal 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);
|
||||||
|
}
|
||||||
|
|
@ -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="" FontSize="14"/>
|
<FontIcon Glyph="" 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}"
|
||||||
|
|
|
||||||
|
|
@ -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 ? "" : "";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue