2026-05-07 11:40:06 -04:00
|
|
|
using System.Collections.ObjectModel;
|
|
|
|
|
using System.Reactive.Concurrency;
|
|
|
|
|
using System.Reactive.Linq;
|
|
|
|
|
using System.Windows.Threading;
|
|
|
|
|
using TeamsISO.Engine.Controller;
|
|
|
|
|
using TeamsISO.Engine.Domain;
|
|
|
|
|
|
|
|
|
|
namespace TeamsISO.App.ViewModels;
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Top-level view model for the main window. Owns the live collection of <see cref="ParticipantViewModel"/>,
|
|
|
|
|
/// the global settings panel, and the alert banner. Subscribes to <see cref="IIsoController"/>'s observables
|
|
|
|
|
/// and marshals updates onto the UI dispatcher.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public sealed class MainViewModel : ObservableObject, IDisposable
|
|
|
|
|
{
|
|
|
|
|
private readonly IIsoController _controller;
|
|
|
|
|
private readonly Dispatcher _dispatcher;
|
|
|
|
|
private readonly IDisposable _participantsSub;
|
|
|
|
|
private readonly IDisposable _alertsSub;
|
2026-05-08 00:52:44 -04:00
|
|
|
private readonly DispatcherTimer _statsTimer;
|
2026-05-07 11:40:06 -04:00
|
|
|
private readonly Dictionary<Guid, ParticipantViewModel> _byId = new();
|
|
|
|
|
private string _statusText = "Starting…";
|
|
|
|
|
|
|
|
|
|
public ObservableCollection<ParticipantViewModel> Participants { get; } = new();
|
|
|
|
|
public GlobalSettingsViewModel Settings { get; }
|
|
|
|
|
public AlertBannerViewModel AlertBanner { get; } = new();
|
|
|
|
|
|
2026-05-08 13:59:14 -04:00
|
|
|
/// <summary>
|
|
|
|
|
/// Emergency-stop: disable every running ISO. Bound to a small "Stop all" affordance
|
|
|
|
|
/// near the participants header so an operator can kill all outputs in a single click
|
|
|
|
|
/// when something goes sideways during a live show.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public AsyncRelayCommand StopAllIsosCommand { get; }
|
|
|
|
|
|
2026-05-07 11:40:06 -04:00
|
|
|
public string StatusText
|
|
|
|
|
{
|
|
|
|
|
get => _statusText;
|
|
|
|
|
set => SetField(ref _statusText, value);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public MainViewModel(IIsoController controller, Dispatcher dispatcher)
|
|
|
|
|
{
|
|
|
|
|
_controller = controller;
|
|
|
|
|
_dispatcher = dispatcher;
|
|
|
|
|
Settings = new GlobalSettingsViewModel(controller);
|
|
|
|
|
|
|
|
|
|
_participantsSub = controller.Participants
|
|
|
|
|
.ObserveOn(new SynchronizationContextScheduler(
|
|
|
|
|
System.Threading.SynchronizationContext.Current ?? new DispatcherSynchronizationContext(_dispatcher)))
|
|
|
|
|
.Subscribe(OnParticipantsChanged);
|
|
|
|
|
|
|
|
|
|
_alertsSub = controller.Alerts
|
|
|
|
|
.ObserveOn(new SynchronizationContextScheduler(
|
|
|
|
|
System.Threading.SynchronizationContext.Current ?? new DispatcherSynchronizationContext(_dispatcher)))
|
|
|
|
|
.Subscribe(alert =>
|
|
|
|
|
{
|
|
|
|
|
AlertBanner.Current = alert;
|
|
|
|
|
});
|
2026-05-08 00:52:44 -04:00
|
|
|
|
|
|
|
|
// 1 Hz stats poll — pull live frame counters from each running pipeline and
|
|
|
|
|
// push them onto the per-participant view models. Cheap (just reads volatile
|
|
|
|
|
// fields on the engine side) and runs on the UI dispatcher so SetField is safe.
|
|
|
|
|
_statsTimer = new DispatcherTimer(DispatcherPriority.Background, _dispatcher)
|
|
|
|
|
{
|
|
|
|
|
Interval = TimeSpan.FromSeconds(1),
|
|
|
|
|
};
|
|
|
|
|
_statsTimer.Tick += OnStatsTick;
|
|
|
|
|
_statsTimer.Start();
|
2026-05-08 13:59:14 -04:00
|
|
|
|
|
|
|
|
StopAllIsosCommand = new AsyncRelayCommand(StopAllIsosAsync, () => Participants.Any(p => p.IsEnabled));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Issues DisableIsoAsync for every participant whose ISO is currently enabled.
|
|
|
|
|
/// Each disable is awaited sequentially so we don't try to tear down N pipelines
|
|
|
|
|
/// in parallel and trip channel-completion races; for ~10 participants this is
|
|
|
|
|
/// still sub-second total. Failures are swallowed (best-effort emergency stop).
|
|
|
|
|
/// </summary>
|
|
|
|
|
private async Task StopAllIsosAsync()
|
|
|
|
|
{
|
|
|
|
|
// Snapshot first so the collection doesn't mutate while we iterate.
|
|
|
|
|
var enabled = Participants.Where(p => p.IsEnabled).ToArray();
|
|
|
|
|
foreach (var p in enabled)
|
|
|
|
|
{
|
|
|
|
|
try { await _controller.DisableIsoAsync(p.Id, CancellationToken.None); }
|
|
|
|
|
catch { /* defensive */ }
|
|
|
|
|
p.IsEnabled = false;
|
|
|
|
|
}
|
2026-05-08 00:52:44 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void OnStatsTick(object? sender, EventArgs e)
|
|
|
|
|
{
|
|
|
|
|
foreach (var vm in Participants)
|
|
|
|
|
{
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
var stats = _controller.GetStats(vm.Id);
|
|
|
|
|
vm.UpdateStats(stats);
|
|
|
|
|
}
|
|
|
|
|
catch
|
|
|
|
|
{
|
|
|
|
|
// Stats are advisory; never let a transient read failure
|
|
|
|
|
// tear down the timer or surface an error to the user.
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-07 11:40:06 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async Task InitializeAsync(CancellationToken cancellationToken)
|
|
|
|
|
{
|
|
|
|
|
StatusText = "Discovering NDI sources…";
|
|
|
|
|
await _controller.StartAsync(cancellationToken);
|
|
|
|
|
StatusText = $"Engine running at {_controller.GlobalSettings.FramerateHz:F2} fps target.";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void OnParticipantsChanged(IReadOnlyList<Participant> incoming)
|
|
|
|
|
{
|
|
|
|
|
var seenIds = new HashSet<Guid>();
|
feat(ui): rebuild MainWindow with Stone-theme design system
Adopts the design language from Dammyjay93/interface-design: warm Stone neutrals, accent orange (#EA580C), borders-only depth (no shadows), 8px spacing grid, all-caps section labels, mono typography for machine names and timecodes.
Themes/StoneTheme.xaml is the single source of truth for design tokens (color brushes, typography styles, spacing) plus restyled control templates (Button, TextBox, ComboBox, ComboBoxItem, CheckBox, DataGrid + DataGridColumnHeader / DataGridRow / DataGridCell, ScrollBar). MainWindow consumes the tokens and is laid out with a header (title + status pill), section-headed Settings sidebar (Output Format / NDI Network / Display), card-wrapped participant list, and a mono status footer.
Settings sidebar surfaces the new NDI group configuration (discovery + output) and a Hide-(Local) checkbox. The latter filters the user's own self-preview from the participants list at the MainViewModel layer (HideLocalSelf=true by default) so operators don't accidentally route their own preview as an ISO. Apply Changes round-trips both FrameProcessingSettings and NdiGroupSettings through the controller in one go.
2026-05-07 23:58:02 -04:00
|
|
|
var hideLocal = Settings.HideLocalSelf;
|
2026-05-07 11:40:06 -04:00
|
|
|
foreach (var p in incoming)
|
|
|
|
|
{
|
feat(ui): rebuild MainWindow with Stone-theme design system
Adopts the design language from Dammyjay93/interface-design: warm Stone neutrals, accent orange (#EA580C), borders-only depth (no shadows), 8px spacing grid, all-caps section labels, mono typography for machine names and timecodes.
Themes/StoneTheme.xaml is the single source of truth for design tokens (color brushes, typography styles, spacing) plus restyled control templates (Button, TextBox, ComboBox, ComboBoxItem, CheckBox, DataGrid + DataGridColumnHeader / DataGridRow / DataGridCell, ScrollBar). MainWindow consumes the tokens and is laid out with a header (title + status pill), section-headed Settings sidebar (Output Format / NDI Network / Display), card-wrapped participant list, and a mono status footer.
Settings sidebar surfaces the new NDI group configuration (discovery + output) and a Hide-(Local) checkbox. The latter filters the user's own self-preview from the participants list at the MainViewModel layer (HideLocalSelf=true by default) so operators don't accidentally route their own preview as an ISO. Apply Changes round-trips both FrameProcessingSettings and NdiGroupSettings through the controller in one go.
2026-05-07 23:58:02 -04:00
|
|
|
// The new Teams client emits a "(Local)" pseudo-participant for the user's
|
|
|
|
|
// own preview — operators rarely want it as a routable ISO. Suppress when
|
|
|
|
|
// HideLocalSelf is on (default).
|
|
|
|
|
if (hideLocal && IsLocalSelf(p)) continue;
|
|
|
|
|
|
2026-05-07 11:40:06 -04:00
|
|
|
seenIds.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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
feat(ui): rebuild MainWindow with Stone-theme design system
Adopts the design language from Dammyjay93/interface-design: warm Stone neutrals, accent orange (#EA580C), borders-only depth (no shadows), 8px spacing grid, all-caps section labels, mono typography for machine names and timecodes.
Themes/StoneTheme.xaml is the single source of truth for design tokens (color brushes, typography styles, spacing) plus restyled control templates (Button, TextBox, ComboBox, ComboBoxItem, CheckBox, DataGrid + DataGridColumnHeader / DataGridRow / DataGridCell, ScrollBar). MainWindow consumes the tokens and is laid out with a header (title + status pill), section-headed Settings sidebar (Output Format / NDI Network / Display), card-wrapped participant list, and a mono status footer.
Settings sidebar surfaces the new NDI group configuration (discovery + output) and a Hide-(Local) checkbox. The latter filters the user's own self-preview from the participants list at the MainViewModel layer (HideLocalSelf=true by default) so operators don't accidentally route their own preview as an ISO. Apply Changes round-trips both FrameProcessingSettings and NdiGroupSettings through the controller in one go.
2026-05-07 23:58:02 -04:00
|
|
|
// Remove participants no longer present (or now hidden by the filter).
|
2026-05-07 11:40:06 -04:00
|
|
|
for (var i = Participants.Count - 1; i >= 0; i--)
|
|
|
|
|
{
|
|
|
|
|
var vm = Participants[i];
|
|
|
|
|
if (!seenIds.Contains(vm.Id))
|
|
|
|
|
{
|
|
|
|
|
_byId.Remove(vm.Id);
|
|
|
|
|
Participants.RemoveAt(i);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
feat(ui): rebuild MainWindow with Stone-theme design system
Adopts the design language from Dammyjay93/interface-design: warm Stone neutrals, accent orange (#EA580C), borders-only depth (no shadows), 8px spacing grid, all-caps section labels, mono typography for machine names and timecodes.
Themes/StoneTheme.xaml is the single source of truth for design tokens (color brushes, typography styles, spacing) plus restyled control templates (Button, TextBox, ComboBox, ComboBoxItem, CheckBox, DataGrid + DataGridColumnHeader / DataGridRow / DataGridCell, ScrollBar). MainWindow consumes the tokens and is laid out with a header (title + status pill), section-headed Settings sidebar (Output Format / NDI Network / Display), card-wrapped participant list, and a mono status footer.
Settings sidebar surfaces the new NDI group configuration (discovery + output) and a Hide-(Local) checkbox. The latter filters the user's own self-preview from the participants list at the MainViewModel layer (HideLocalSelf=true by default) so operators don't accidentally route their own preview as an ISO. Apply Changes round-trips both FrameProcessingSettings and NdiGroupSettings through the controller in one go.
2026-05-07 23:58:02 -04:00
|
|
|
private static bool IsLocalSelf(Participant p) =>
|
|
|
|
|
string.Equals(p.DisplayName, "(Local)", StringComparison.Ordinal);
|
|
|
|
|
|
2026-05-07 11:40:06 -04:00
|
|
|
public void Dispose()
|
|
|
|
|
{
|
2026-05-08 00:52:44 -04:00
|
|
|
_statsTimer.Stop();
|
|
|
|
|
_statsTimer.Tick -= OnStatsTick;
|
2026-05-07 11:40:06 -04:00
|
|
|
_participantsSub.Dispose();
|
|
|
|
|
_alertsSub.Dispose();
|
|
|
|
|
}
|
|
|
|
|
}
|