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();
|
feat(ui): toast feedback for settings actions; refresh _NEXT.md
Adds a small auto-dismissing pill notification at the bottom-center of the participants area: 'Settings saved' on Apply Changes, 'Transcoder topology applied — restart Teams to take effect' after the one-click NDI groups setup. ToastViewModel owns its own DispatcherTimer and resets the dismissal countdown on successive calls, so the most recent message is always the one visible. Hooked into MainViewModel and threaded into GlobalSettingsViewModel via constructor injection.
_NEXT.md rewritten to reflect the May 2026 hardening pass: separates engine / UI / networking / Phase E.1 / diagnostics / CI / tests sections, lists every shipped item, and re-prioritizes the remaining work (Phase E.2-E.3 embedded Teams, code-signing the MSI, refresh-discovery affordance, output thumbnail previews, settings panel UX, auto-disable on departure, operator presets).
2026-05-09 09:30:04 -04:00
|
|
|
public ToastViewModel Toast { get; }
|
2026-05-07 11:40:06 -04:00
|
|
|
|
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;
|
feat(ui): toast feedback for settings actions; refresh _NEXT.md
Adds a small auto-dismissing pill notification at the bottom-center of the participants area: 'Settings saved' on Apply Changes, 'Transcoder topology applied — restart Teams to take effect' after the one-click NDI groups setup. ToastViewModel owns its own DispatcherTimer and resets the dismissal countdown on successive calls, so the most recent message is always the one visible. Hooked into MainViewModel and threaded into GlobalSettingsViewModel via constructor injection.
_NEXT.md rewritten to reflect the May 2026 hardening pass: separates engine / UI / networking / Phase E.1 / diagnostics / CI / tests sections, lists every shipped item, and re-prioritizes the remaining work (Phase E.2-E.3 embedded Teams, code-signing the MSI, refresh-discovery affordance, output thumbnail previews, settings panel UX, auto-disable on departure, operator presets).
2026-05-09 09:30:04 -04:00
|
|
|
Toast = new ToastViewModel(dispatcher);
|
|
|
|
|
Settings = new GlobalSettingsViewModel(controller, Toast);
|
2026-05-07 11:40:06 -04:00
|
|
|
|
|
|
|
|
_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();
|
|
|
|
|
}
|
|
|
|
|
}
|