dragon-iso/src/TeamsISO.App/ViewModels/MainViewModel.cs

700 lines
31 KiB
C#
Raw Normal View History

using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Reactive.Concurrency;
using System.Reactive.Linq;
using System.Windows.Data;
using System.Windows.Threading;
using TeamsISO.App.Services;
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.
refactor(viewmodels): split MainViewModel into themed partial classes MainViewModel.cs was 1017 lines and 45KB — most of it was bulk-operation loops, Teams UIA plumbing, and the auto-apply-last-preset state machine sitting on top of the actual MainViewModel surface (constructor, props, OnStatsTick). Splits the class via partial-class into themed siblings: * MainViewModel.cs (was 1017L → now 699L) — fields, properties, constructor that wires every Command, OnStatsTick + Dispose. This remains the thin aggregator. * MainViewModel.TeamsCommands.cs (130L, new) — MakeTeamsCommand helper, JoinPastedMeeting (body of JoinMeetingCommand), ExtractMeetingTitle (already-tested static), PollTeamsMeetingState (the 1Hz UIA probe formerly inlined in OnStatsTick). * MainViewModel.PresetCommands.cs (108L, new) — RequestApplyPresetOnStartup (CLI hook), LoadPendingPresetFromPreferences (called by InitializeAsync), TryAutoApplyPendingPreset (the reconcile step), and the _pendingPreset* private-field set that backs the path. * MainViewModel.BulkCommands.cs (149L, new) — EnableAllOnlineAsync, StopAllIsosAsync (with the default-No confirmation dialog), SnapshotAll. RecordingCommands.cs from the original punch list is intentionally absent — the recording surface was axed at 1d1ce6a; what remains here is bulk-state ops across the participants collection (note in the file header). Why partial-class instead of helper-services or composed objects: every extracted method touches the same private dispatcher / controller / participants / toast state. Composing would require either passing those references in (verbose call sites) or extracting them to a shared private context object (boilerplate). Partial gives us file-level separation without spreading the state contract. ExtractMeetingTitle stays internal-static so the existing MeetingTitleExtractionTests (10 cases) keep finding it. Build clean; 56 App + 104 Engine tests still pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 19:31:49 -04:00
///
/// Split across partial files by responsibility:
/// • <c>MainViewModel.cs</c> — fields, properties, constructor (wires commands), OnStatsTick, Dispose
/// • <c>MainViewModel.TeamsCommands.cs</c> — Mute / Cam / Leave / Share + Join URL + Teams meeting-state poll
/// • <c>MainViewModel.PresetCommands.cs</c> — auto-apply-last-preset path
/// • <c>MainViewModel.BulkCommands.cs</c> — Stop all / Enable all / Snapshot all
/// </summary>
refactor(viewmodels): split MainViewModel into themed partial classes MainViewModel.cs was 1017 lines and 45KB — most of it was bulk-operation loops, Teams UIA plumbing, and the auto-apply-last-preset state machine sitting on top of the actual MainViewModel surface (constructor, props, OnStatsTick). Splits the class via partial-class into themed siblings: * MainViewModel.cs (was 1017L → now 699L) — fields, properties, constructor that wires every Command, OnStatsTick + Dispose. This remains the thin aggregator. * MainViewModel.TeamsCommands.cs (130L, new) — MakeTeamsCommand helper, JoinPastedMeeting (body of JoinMeetingCommand), ExtractMeetingTitle (already-tested static), PollTeamsMeetingState (the 1Hz UIA probe formerly inlined in OnStatsTick). * MainViewModel.PresetCommands.cs (108L, new) — RequestApplyPresetOnStartup (CLI hook), LoadPendingPresetFromPreferences (called by InitializeAsync), TryAutoApplyPendingPreset (the reconcile step), and the _pendingPreset* private-field set that backs the path. * MainViewModel.BulkCommands.cs (149L, new) — EnableAllOnlineAsync, StopAllIsosAsync (with the default-No confirmation dialog), SnapshotAll. RecordingCommands.cs from the original punch list is intentionally absent — the recording surface was axed at 1d1ce6a; what remains here is bulk-state ops across the participants collection (note in the file header). Why partial-class instead of helper-services or composed objects: every extracted method touches the same private dispatcher / controller / participants / toast state. Composing would require either passing those references in (verbose call sites) or extracting them to a shared private context object (boilerplate). Partial gives us file-level separation without spreading the state contract. ExtractMeetingTitle stays internal-static so the existing MeetingTitleExtractionTests (10 cases) keep finding it. Build clean; 56 App + 104 Engine tests still pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 19:31:49 -04:00
public sealed partial class MainViewModel : ObservableObject, IDisposable
{
private readonly IIsoController _controller;
private readonly Dispatcher _dispatcher;
private readonly IDisposable _participantsSub;
private readonly IDisposable _alertsSub;
private readonly DispatcherTimer _statsTimer;
private readonly Dictionary<Guid, ParticipantViewModel> _byId = new();
private string _statusText = "Starting…";
refactor(viewmodels): split MainViewModel into themed partial classes MainViewModel.cs was 1017 lines and 45KB — most of it was bulk-operation loops, Teams UIA plumbing, and the auto-apply-last-preset state machine sitting on top of the actual MainViewModel surface (constructor, props, OnStatsTick). Splits the class via partial-class into themed siblings: * MainViewModel.cs (was 1017L → now 699L) — fields, properties, constructor that wires every Command, OnStatsTick + Dispose. This remains the thin aggregator. * MainViewModel.TeamsCommands.cs (130L, new) — MakeTeamsCommand helper, JoinPastedMeeting (body of JoinMeetingCommand), ExtractMeetingTitle (already-tested static), PollTeamsMeetingState (the 1Hz UIA probe formerly inlined in OnStatsTick). * MainViewModel.PresetCommands.cs (108L, new) — RequestApplyPresetOnStartup (CLI hook), LoadPendingPresetFromPreferences (called by InitializeAsync), TryAutoApplyPendingPreset (the reconcile step), and the _pendingPreset* private-field set that backs the path. * MainViewModel.BulkCommands.cs (149L, new) — EnableAllOnlineAsync, StopAllIsosAsync (with the default-No confirmation dialog), SnapshotAll. RecordingCommands.cs from the original punch list is intentionally absent — the recording surface was axed at 1d1ce6a; what remains here is bulk-state ops across the participants collection (note in the file header). Why partial-class instead of helper-services or composed objects: every extracted method touches the same private dispatcher / controller / participants / toast state. Composing would require either passing those references in (verbose call sites) or extracting them to a shared private context object (boilerplate). Partial gives us file-level separation without spreading the state contract. ExtractMeetingTitle stays internal-static so the existing MeetingTitleExtractionTests (10 cases) keep finding it. Build clean; 56 App + 104 Engine tests still pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 19:31:49 -04:00
// _pendingPresetName / Deadline / Applied + the auto-apply path
// moved to MainViewModel.PresetCommands.cs.
public ObservableCollection<ParticipantViewModel> Participants { get; } = new();
/// <summary>
/// Filter-backed view over <see cref="Participants"/>. The DataGrid binds
/// to this rather than the raw collection so the operator's filter text
/// hides non-matching rows without mutating the underlying observable
/// (which would break IsoController's identity tracking).
/// </summary>
public ICollectionView ParticipantsView { get; }
private string _participantFilter = string.Empty;
/// <summary>
/// Apply the operator's saved sort preference to <see cref="ParticipantsView"/>.
/// JoinOrder = no SortDescriptions (whatever order participants are added in);
/// Alphabetical = ascending by DisplayName; OnlineFirst = IsOnline desc then
/// DisplayName asc. Called on construction and from <see cref="SetSortMode"/>.
/// </summary>
private void ApplySortFromPrefs()
{
var prefs = Services.UIPreferences.Load();
SetSortMode(prefs.ParticipantSort);
}
/// <summary>
/// Re-applies the sort descriptions on the ParticipantsView. Called from the
/// settings panel when the operator picks a different sort mode.
/// </summary>
public void SetSortMode(Services.UIPreferences.SortMode mode)
{
_currentSortMode = mode;
ParticipantsView.SortDescriptions.Clear();
switch (mode)
{
case Services.UIPreferences.SortMode.Alphabetical:
ParticipantsView.SortDescriptions.Add(new SortDescription(
nameof(ParticipantViewModel.DisplayName), ListSortDirection.Ascending));
break;
case Services.UIPreferences.SortMode.OnlineFirst:
ParticipantsView.SortDescriptions.Add(new SortDescription(
nameof(ParticipantViewModel.IsOnline), ListSortDirection.Descending));
ParticipantsView.SortDescriptions.Add(new SortDescription(
nameof(ParticipantViewModel.DisplayName), ListSortDirection.Ascending));
break;
case Services.UIPreferences.SortMode.LoudestFirst:
// Sort by the displayed audio level (which already includes the
// decay envelope) so participants don't snap-reorder on every
// tiny audio frame. ParticipantsView.Refresh() at the stats
// tick re-evaluates the sort with the latest values.
ParticipantsView.SortDescriptions.Add(new SortDescription(
nameof(ParticipantViewModel.DisplayedAudioLevel), ListSortDirection.Descending));
ParticipantsView.SortDescriptions.Add(new SortDescription(
nameof(ParticipantViewModel.DisplayName), ListSortDirection.Ascending));
break;
// JoinOrder: leave SortDescriptions empty.
}
}
private Services.UIPreferences.SortMode _currentSortMode = Services.UIPreferences.SortMode.JoinOrder;
/// <summary>
/// Live filter substring. Empty = show everyone. Matched case-insensitively
/// against display name. Setter refreshes the view immediately so the
/// DataGrid reflows as the operator types.
/// </summary>
public string ParticipantFilter
{
get => _participantFilter;
set
{
if (SetField(ref _participantFilter, value))
ParticipantsView.Refresh();
}
}
public GlobalSettingsViewModel Settings { get; }
public AlertBannerViewModel AlertBanner { get; } = new();
public ToastViewModel Toast { get; }
public UpdateBannerViewModel UpdateBanner { get; } = new();
/// <summary>
/// Engine-side controller. Exposed so the PresetsDialog (a Window, not a VM)
/// can re-issue EnableIsoAsync / DisableIsoAsync when applying a preset
/// without us having to plumb a per-action command through the participant
/// view-models from the dialog's XAML.
/// </summary>
internal IIsoController Controller => _controller;
/// <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; }
/// <summary>
/// Bulk-enable: turn on ISOs for every online participant whose pipeline isn't
/// already running. Useful for "everyone joined, hit one button, every route goes
/// live." Skips offline rows (no source) and rows already enabled.
/// </summary>
public AsyncRelayCommand EnableAllOnlineCommand { get; }
/// <summary>
/// Force NDI discovery to rebuild its finder. Surfaced as a small "Refresh" pill
/// next to the participants header — useful right after Apply Transcoder Topology
/// or when Teams restarts mid-session and stale TTLs are masking new sources.
/// </summary>
public RelayCommand RefreshDiscoveryCommand { get; }
// ════════════════════════════════════════════════════════════════════════
// Phase E.3 — In-call controls. Each command drives a UIAutomation lookup
// against Teams' window tree and reports a toast on outcome. Best-effort:
// a control-not-found result toasts a hint rather than throwing, since
// Teams isn't always in a call (the buttons only appear in-call).
// ════════════════════════════════════════════════════════════════════════
public RelayCommand ToggleMuteCommand { get; }
public RelayCommand ToggleCameraCommand { get; }
public RelayCommand LeaveCallCommand { get; }
public RelayCommand OpenShareTrayCommand { get; }
// Recording-marker and roll-recording commands removed — recording feature axed.
/// <summary>F1 binding — opens the help / cheat-sheet dialog.</summary>
public RelayCommand ShowHelpCommand { get; }
/// <summary>
/// Ctrl+T binding — cycles dark ↔ light theme via ThemeManager.
/// Persists the operator's choice through UIPreferences.Theme.
/// The v2 header surfaces this as a click affordance too; the
/// command exists once so both bindings reach the same path.
/// </summary>
public RelayCommand ToggleThemeCommand { get; }
/// <summary>
/// Ctrl+K binding — opens the v2 command palette. The actual window
/// open call lives in <see cref="MainWindow"/> (view-side concern);
/// this command delegates through an Action callback the view sets
/// after construction so the VM stays unaware of WPF Window types.
/// </summary>
public RelayCommand OpenCommandPaletteCommand { get; }
private Action? _openCommandPalette;
/// <summary>
/// Wire the view's palette-opening callback. Called by MainWindow's
/// constructor right after DataContext is set. Idempotent — second
/// call replaces the first.
/// </summary>
public void RegisterCommandPaletteOpener(Action openPalette) =>
_openCommandPalette = openPalette;
/// <summary>Opens the inline notes viewer for today's show-notes file.</summary>
public RelayCommand ShowNotesCommand { get; }
/// <summary>Join a Teams meeting from a pasted URL — see <see cref="JoinMeetingUrl"/>.</summary>
public RelayCommand JoinMeetingCommand { get; }
/// <summary>Save a PNG snapshot of every enabled participant's current frame.</summary>
public RelayCommand SnapshotAllCommand { get; }
/// <summary>
/// Toggle the ISO for the Nth visible participant (1-based, matches the
/// numpad layout). Used by the NumPad1..NumPad9 hotkeys; resolves
/// against ParticipantsView so the index matches what the operator
/// sees in the current sort + filter.
/// </summary>
public RelayCommand<string> ToggleByIndexCommand { get; }
/// <summary>
/// Two-way bound to the quick-join input. Whatever the operator pastes
/// gets handed to <see cref="TeamsLauncher.TryJoinMeeting"/> when the
/// Join button fires. Cleared on success so the field is ready for the
/// next paste.
/// </summary>
public string JoinMeetingUrl
{
get => _joinMeetingUrl;
set => SetField(ref _joinMeetingUrl, value);
}
private string _joinMeetingUrl = string.Empty;
public string StatusText
{
get => _statusText;
set => SetField(ref _statusText, value);
}
// Recording-status properties (IsRecording, ActiveRecordingCount,
// RecordingElapsed, RecordingFreeSpace, IsLowDiskSpace) removed when the
// recording feature was axed.
/// <summary>
/// Total visible participants — feeds the v2 transport strip's "PART N"
/// readout. Updated on every 1Hz stats tick alongside <see cref="LiveCount"/>.
/// </summary>
public int ParticipantCount
{
get => _participantCount;
private set => SetField(ref _participantCount, value);
}
private int _participantCount;
/// <summary>
/// Currently-enabled (live) ISO count — feeds the v2 transport strip's
/// "LIVE N" readout. The number is cyan-tinted when non-zero to draw
/// the operator's eye to active state.
/// </summary>
public int LiveCount
{
get => _liveCount;
private set => SetField(ref _liveCount, value);
}
private int _liveCount;
/// <summary>True when the REST control surface (or OSC bridge, or both) is listening.</summary>
public bool IsControlSurfaceRunning
{
get => _isControlSurfaceRunning;
private set => SetField(ref _isControlSurfaceRunning, value);
}
private bool _isControlSurfaceRunning;
/// <summary>Human-readable string for the control-surface tooltip ("REST :9755 + OSC :9000").</summary>
public string ControlSurfaceText
{
get => _controlSurfaceText;
private set => SetField(ref _controlSurfaceText, value);
}
private string _controlSurfaceText = string.Empty;
/// <summary>
/// "IN CALL" when Teams is in an active meeting; "READY" when Teams is
/// running but not in a call; empty when Teams isn't running. Surfaced
/// as a status pill in the IN-CALL bar so operators with auto-hide on
/// can see Teams' state without restoring its window.
/// </summary>
public string TeamsMeetingState
{
get => _teamsMeetingState;
private set
{
if (SetField(ref _teamsMeetingState, value))
OnPropertyChanged(nameof(HasTeamsState));
}
}
private string _teamsMeetingState = string.Empty;
/// <summary>True when Teams is currently in a call (Leave button present in UIA tree).</summary>
public bool IsTeamsInCall
{
get => _isTeamsInCall;
private set => SetField(ref _isTeamsInCall, value);
}
private bool _isTeamsInCall;
/// <summary>True when <see cref="TeamsMeetingState"/> is non-empty. Used to gate visibility of the IN-CALL bar status pill via the existing BoolToVis converter.</summary>
public bool HasTeamsState => !string.IsNullOrEmpty(_teamsMeetingState);
/// <summary>True when the local user's mic is muted in the active Teams call.</summary>
public bool IsLocalMuted
{
get => _isLocalMuted;
private set => SetField(ref _isLocalMuted, value);
}
private bool _isLocalMuted;
/// <summary>True when the local user's camera is off in the active Teams call.</summary>
public bool IsLocalCameraOff
{
get => _isLocalCameraOff;
private set => SetField(ref _isLocalCameraOff, value);
}
private bool _isLocalCameraOff;
/// <summary>
/// Elapsed time since the first ISO went live, formatted "HH:mm:ss". Empty
/// when nothing's running. Useful for operators tracking show length.
/// Resets when all ISOs go offline (next time one comes back, the timer
/// starts from 00:00:00 again).
/// </summary>
public string SessionElapsed
{
get => _sessionElapsed;
private set => SetField(ref _sessionElapsed, value);
}
private string _sessionElapsed = string.Empty;
public bool IsSessionActive
{
get => _isSessionActive;
private set => SetField(ref _isSessionActive, value);
}
private bool _isSessionActive;
private DateTimeOffset? _sessionStartedAt;
public MainViewModel(IIsoController controller, Dispatcher dispatcher)
{
_controller = controller;
_dispatcher = dispatcher;
Toast = new ToastViewModel(dispatcher);
Settings = new GlobalSettingsViewModel(controller, Toast);
// Set up the filter-aware view AFTER Participants is non-null. The
// CollectionView binds to the live collection; Filter callback runs
// each time Refresh() is called or the collection mutates.
ParticipantsView = CollectionViewSource.GetDefaultView(Participants);
ParticipantsView.Filter = obj =>
{
if (string.IsNullOrEmpty(_participantFilter)) return true;
return obj is ParticipantViewModel p &&
p.DisplayName.Contains(_participantFilter, StringComparison.OrdinalIgnoreCase);
};
// Apply the operator's saved sort preference, if any.
ApplySortFromPrefs();
// Subscribe directly (no ObserveOn) and marshal to the UI thread inside
// the callback via Dispatcher.InvokeAsync. The previous ObserveOn(
// SynchronizationContextScheduler) path captured SynchronizationContext
// .Current at subscribe time — fragile in WPF startup ordering, where
// the UI thread's SyncContext can be in a transitional state during
// App.OnStartup and the captured context never pumps subsequent
// OnNext calls. Direct subscribe + explicit dispatcher marshal is the
// pattern proven by Console.Program.cs (engine emits, consumer marshals).
_participantsSub = controller.Participants
.Subscribe(snapshot => _dispatcher.InvokeAsync(
() => OnParticipantsChanged(snapshot),
DispatcherPriority.Background));
_alertsSub = controller.Alerts
.ObserveOn(new SynchronizationContextScheduler(
System.Threading.SynchronizationContext.Current ?? new DispatcherSynchronizationContext(_dispatcher)))
.Subscribe(alert =>
{
AlertBanner.Current = alert;
});
// 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();
StopAllIsosCommand = new AsyncRelayCommand(StopAllIsosAsync, () => Participants.Any(p => p.IsEnabled));
EnableAllOnlineCommand = new AsyncRelayCommand(EnableAllOnlineAsync,
() => Participants.Any(p => p.IsOnline && !p.IsEnabled));
RefreshDiscoveryCommand = new RelayCommand(() =>
{
_controller.RefreshDiscovery();
Toast.Show("Refreshing NDI discovery…");
});
ToggleThemeCommand = new RelayCommand(() =>
{
// ThemeManager.Toggle persists the new preference to UIPreferences
// and fires the resource-dictionary swap on the dispatcher thread.
Services.ThemeManager.Current.Toggle();
});
OpenCommandPaletteCommand = new RelayCommand(() => _openCommandPalette?.Invoke());
ShowHelpCommand = new RelayCommand(() =>
{
// Showing a Window from a VM violates strict MVVM, but TeamsISO doesn't
// ship a navigation service and a HelpWindow is purely a UI concern.
// Owner is set so the dialog centers and inherits z-order.
var help = new HelpWindow { Owner = System.Windows.Application.Current?.MainWindow };
help.ShowDialog();
});
ShowNotesCommand = new RelayCommand(() =>
{
var notes = new NotesWindow { Owner = System.Windows.Application.Current?.MainWindow };
notes.Show(); // non-modal so operators can stamp + read alongside the show
});
SnapshotAllCommand = new RelayCommand(SnapshotAll, () => Participants.Any(p => p.IsEnabled));
ToggleByIndexCommand = new RelayCommand<string>(s =>
{
// Numpad / digit hotkeys pass "1".."9" as a string. Resolve
// against the filtered/sorted view so the index matches what
// the operator sees on screen, not the underlying storage order.
if (!int.TryParse(s, out var idx) || idx < 1 || idx > 9) return;
var i = 0;
foreach (var item in ParticipantsView)
{
if (item is not ParticipantViewModel p) continue;
if (++i == idx)
{
if (p.ToggleIsoCommand.CanExecute(null))
p.ToggleIsoCommand.Execute(null);
break;
}
}
});
refactor(viewmodels): split MainViewModel into themed partial classes MainViewModel.cs was 1017 lines and 45KB — most of it was bulk-operation loops, Teams UIA plumbing, and the auto-apply-last-preset state machine sitting on top of the actual MainViewModel surface (constructor, props, OnStatsTick). Splits the class via partial-class into themed siblings: * MainViewModel.cs (was 1017L → now 699L) — fields, properties, constructor that wires every Command, OnStatsTick + Dispose. This remains the thin aggregator. * MainViewModel.TeamsCommands.cs (130L, new) — MakeTeamsCommand helper, JoinPastedMeeting (body of JoinMeetingCommand), ExtractMeetingTitle (already-tested static), PollTeamsMeetingState (the 1Hz UIA probe formerly inlined in OnStatsTick). * MainViewModel.PresetCommands.cs (108L, new) — RequestApplyPresetOnStartup (CLI hook), LoadPendingPresetFromPreferences (called by InitializeAsync), TryAutoApplyPendingPreset (the reconcile step), and the _pendingPreset* private-field set that backs the path. * MainViewModel.BulkCommands.cs (149L, new) — EnableAllOnlineAsync, StopAllIsosAsync (with the default-No confirmation dialog), SnapshotAll. RecordingCommands.cs from the original punch list is intentionally absent — the recording surface was axed at 1d1ce6a; what remains here is bulk-state ops across the participants collection (note in the file header). Why partial-class instead of helper-services or composed objects: every extracted method touches the same private dispatcher / controller / participants / toast state. Composing would require either passing those references in (verbose call sites) or extracting them to a shared private context object (boilerplate). Partial gives us file-level separation without spreading the state contract. ExtractMeetingTitle stays internal-static so the existing MeetingTitleExtractionTests (10 cases) keep finding it. Build clean; 56 App + 104 Engine tests still pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 19:31:49 -04:00
JoinMeetingCommand = new RelayCommand(JoinPastedMeeting);
ToggleMuteCommand = MakeTeamsCommand(
label: "Mute",
invoke: TeamsControlBridge.ToggleMute,
successMessage: "Toggled mute");
ToggleCameraCommand = MakeTeamsCommand(
label: "Camera",
invoke: TeamsControlBridge.ToggleCamera,
successMessage: "Toggled camera");
LeaveCallCommand = MakeTeamsCommand(
label: "Leave",
invoke: TeamsControlBridge.LeaveCall,
successMessage: "Left the call");
OpenShareTrayCommand = MakeTeamsCommand(
label: "Share",
invoke: TeamsControlBridge.OpenShareTray,
successMessage: "Opened share tray");
}
refactor(viewmodels): split MainViewModel into themed partial classes MainViewModel.cs was 1017 lines and 45KB — most of it was bulk-operation loops, Teams UIA plumbing, and the auto-apply-last-preset state machine sitting on top of the actual MainViewModel surface (constructor, props, OnStatsTick). Splits the class via partial-class into themed siblings: * MainViewModel.cs (was 1017L → now 699L) — fields, properties, constructor that wires every Command, OnStatsTick + Dispose. This remains the thin aggregator. * MainViewModel.TeamsCommands.cs (130L, new) — MakeTeamsCommand helper, JoinPastedMeeting (body of JoinMeetingCommand), ExtractMeetingTitle (already-tested static), PollTeamsMeetingState (the 1Hz UIA probe formerly inlined in OnStatsTick). * MainViewModel.PresetCommands.cs (108L, new) — RequestApplyPresetOnStartup (CLI hook), LoadPendingPresetFromPreferences (called by InitializeAsync), TryAutoApplyPendingPreset (the reconcile step), and the _pendingPreset* private-field set that backs the path. * MainViewModel.BulkCommands.cs (149L, new) — EnableAllOnlineAsync, StopAllIsosAsync (with the default-No confirmation dialog), SnapshotAll. RecordingCommands.cs from the original punch list is intentionally absent — the recording surface was axed at 1d1ce6a; what remains here is bulk-state ops across the participants collection (note in the file header). Why partial-class instead of helper-services or composed objects: every extracted method touches the same private dispatcher / controller / participants / toast state. Composing would require either passing those references in (verbose call sites) or extracting them to a shared private context object (boilerplate). Partial gives us file-level separation without spreading the state contract. ExtractMeetingTitle stays internal-static so the existing MeetingTitleExtractionTests (10 cases) keep finding it. Build clean; 56 App + 104 Engine tests still pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 19:31:49 -04:00
// Body methods extracted to themed partial files:
// MainViewModel.BulkCommands.cs — EnableAllOnlineAsync, StopAllIsosAsync, SnapshotAll
// MainViewModel.TeamsCommands.cs — MakeTeamsCommand, JoinPastedMeeting,
// ExtractMeetingTitle, PollTeamsMeetingState
// MainViewModel.PresetCommands.cs — RequestApplyPresetOnStartup,
// LoadPendingPresetFromPreferences,
// TryAutoApplyPendingPreset
private void OnStatsTick(object? sender, EventArgs e)
{
foreach (var vm in Participants)
{
try
{
var stats = _controller.GetStats(vm.Id);
vm.UpdateStats(stats);
// Refresh preview thumbnail from the engine's most recent
// processed frame. Returns null if no pipeline is running for
// this participant; UpdateThumbnail short-circuits in that
// case, leaving the previous frame in place rather than
// visibly blanking when the pipeline restarts.
vm.UpdateThumbnail(_controller.GetLatestProcessedFrame(vm.Id));
}
catch
{
// Stats are advisory; never let a transient read failure
// tear down the timer or surface an error to the user.
}
}
// Active-speaker highlight: find the loudest enabled participant
// and mark their IsActiveSpeaker flag. Only one row at a time;
// ties broken by enumeration order (first one wins). Threshold of
// 0.05 prevents constant flicker between near-silent participants
// when nobody's really speaking.
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;
}
// If sort mode is LoudestFirst, refresh the view so the new audio
// peaks re-evaluate the sort. Skipped for the other sort modes
// since their keys (name, online state) don't change every tick —
// no need to pay the Refresh cost.
if (_currentSortMode == Services.UIPreferences.SortMode.LoudestFirst)
{
try { ParticipantsView.Refresh(); }
catch { /* defensive — Refresh occasionally throws on collection mutations */ }
}
// Update footer badges. Recording count is "ISOs that have a recorder
// attached" — _controller.RecordingEnabled tells us the global toggle,
// but the actual recorder count = number of running pipelines while
// that toggle was on (transient enables can mean fewer recorders than
// running pipelines). Approximate by ANDing global toggle + running
// ISO count; close enough for an at-a-glance footer.
var totalParticipants = Participants.Count;
var enabledCount = Participants.Count(p => p.IsEnabled);
// Recording-elapsed timer + disk-free polling removed alongside the rest
// of the recording surface.
// Expose counts as VM properties for the v2 transport-strip binding.
// The strip's "PART 4 · LIVE 2" reads these — pushing them on the
// 1Hz tick keeps the cost off the per-frame UI path.
ParticipantCount = totalParticipants;
LiveCount = enabledCount;
// Session timer — start on first ISO going live, reset when none are
// live anymore. Subsequent enables after a full-zero gap restart the
// timer rather than resuming, which is the operator's mental model:
// "the show started when the first feed went live."
if (enabledCount > 0)
{
_sessionStartedAt ??= DateTimeOffset.UtcNow;
var elapsed = DateTimeOffset.UtcNow - _sessionStartedAt.Value;
SessionElapsed = elapsed.TotalHours >= 1
? $"{(int)elapsed.TotalHours:D2}:{elapsed.Minutes:D2}:{elapsed.Seconds:D2}"
: $"{elapsed.Minutes:D2}:{elapsed.Seconds:D2}";
IsSessionActive = true;
}
else if (_sessionStartedAt is not null)
{
_sessionStartedAt = null;
SessionElapsed = string.Empty;
IsSessionActive = false;
}
// Dynamic status text — replaces the static "Engine running at X fps"
// once ISOs are live. The framerate target is still implicit (the user
// set it in OUTPUT settings; surfacing it constantly steals footer
// real estate from more-actionable info).
if (totalParticipants == 0)
{
StatusText = "Discovering NDI sources…";
}
else if (enabledCount == 0)
{
StatusText = totalParticipants == 1
? "1 participant visible"
: $"{totalParticipants} participants visible";
}
else
{
StatusText = $"{enabledCount}/{totalParticipants} ISOs live";
}
refactor(viewmodels): split MainViewModel into themed partial classes MainViewModel.cs was 1017 lines and 45KB — most of it was bulk-operation loops, Teams UIA plumbing, and the auto-apply-last-preset state machine sitting on top of the actual MainViewModel surface (constructor, props, OnStatsTick). Splits the class via partial-class into themed siblings: * MainViewModel.cs (was 1017L → now 699L) — fields, properties, constructor that wires every Command, OnStatsTick + Dispose. This remains the thin aggregator. * MainViewModel.TeamsCommands.cs (130L, new) — MakeTeamsCommand helper, JoinPastedMeeting (body of JoinMeetingCommand), ExtractMeetingTitle (already-tested static), PollTeamsMeetingState (the 1Hz UIA probe formerly inlined in OnStatsTick). * MainViewModel.PresetCommands.cs (108L, new) — RequestApplyPresetOnStartup (CLI hook), LoadPendingPresetFromPreferences (called by InitializeAsync), TryAutoApplyPendingPreset (the reconcile step), and the _pendingPreset* private-field set that backs the path. * MainViewModel.BulkCommands.cs (149L, new) — EnableAllOnlineAsync, StopAllIsosAsync (with the default-No confirmation dialog), SnapshotAll. RecordingCommands.cs from the original punch list is intentionally absent — the recording surface was axed at 1d1ce6a; what remains here is bulk-state ops across the participants collection (note in the file header). Why partial-class instead of helper-services or composed objects: every extracted method touches the same private dispatcher / controller / participants / toast state. Composing would require either passing those references in (verbose call sites) or extracting them to a shared private context object (boilerplate). Partial gives us file-level separation without spreading the state contract. ExtractMeetingTitle stays internal-static so the existing MeetingTitleExtractionTests (10 cases) keep finding it. Build clean; 56 App + 104 Engine tests still pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 19:31:49 -04:00
// Teams meeting state — UIA traversal at 1Hz; off-thread so a slow
// UIA call doesn't stall the UI tick. Implementation in
// MainViewModel.TeamsCommands.cs.
PollTeamsMeetingState();
// Control-surface state — peek at App's owned services.
var app = System.Windows.Application.Current as App;
var rest = app?.ControlSurface?.IsRunning ?? false;
var osc = app?.OscBridge?.IsRunning ?? false;
IsControlSurfaceRunning = rest || osc;
// When LAN-reachable mode is on, the footer text shows the routable
// URL instead of just the port — operators setting up a thin client
// shouldn't have to open Settings to find what to type into a
// browser. We trust the Settings VM's ControlSurfaceLanReachable
// boolean since that's where the toggle is persisted.
var lanMode = rest && (app?.ControlSurface?.BoundToLan ?? false);
var lanHost = lanMode ? Settings.ControlSurfaceUrl.Replace("/ui", "") : null;
ControlSurfaceText = (rest, osc) switch
{
(true, true) when lanMode => $"{lanHost} + OSC :{app!.OscBridge!.Port}",
(true, false) when lanMode => lanHost!,
(true, true) => $"REST :{app!.ControlSurface!.Port} + OSC :{app.OscBridge!.Port}",
(true, false) => $"REST :{app!.ControlSurface!.Port}",
(false, true) => $"OSC :{app!.OscBridge!.Port}",
_ => string.Empty,
};
}
public async Task InitializeAsync(CancellationToken cancellationToken)
{
StatusText = "Discovering NDI sources…";
await _controller.StartAsync(cancellationToken);
StatusText = $"Engine running at {_controller.GlobalSettings.FramerateHz:F2} fps target.";
refactor(viewmodels): split MainViewModel into themed partial classes MainViewModel.cs was 1017 lines and 45KB — most of it was bulk-operation loops, Teams UIA plumbing, and the auto-apply-last-preset state machine sitting on top of the actual MainViewModel surface (constructor, props, OnStatsTick). Splits the class via partial-class into themed siblings: * MainViewModel.cs (was 1017L → now 699L) — fields, properties, constructor that wires every Command, OnStatsTick + Dispose. This remains the thin aggregator. * MainViewModel.TeamsCommands.cs (130L, new) — MakeTeamsCommand helper, JoinPastedMeeting (body of JoinMeetingCommand), ExtractMeetingTitle (already-tested static), PollTeamsMeetingState (the 1Hz UIA probe formerly inlined in OnStatsTick). * MainViewModel.PresetCommands.cs (108L, new) — RequestApplyPresetOnStartup (CLI hook), LoadPendingPresetFromPreferences (called by InitializeAsync), TryAutoApplyPendingPreset (the reconcile step), and the _pendingPreset* private-field set that backs the path. * MainViewModel.BulkCommands.cs (149L, new) — EnableAllOnlineAsync, StopAllIsosAsync (with the default-No confirmation dialog), SnapshotAll. RecordingCommands.cs from the original punch list is intentionally absent — the recording surface was axed at 1d1ce6a; what remains here is bulk-state ops across the participants collection (note in the file header). Why partial-class instead of helper-services or composed objects: every extracted method touches the same private dispatcher / controller / participants / toast state. Composing would require either passing those references in (verbose call sites) or extracting them to a shared private context object (boilerplate). Partial gives us file-level separation without spreading the state contract. ExtractMeetingTitle stays internal-static so the existing MeetingTitleExtractionTests (10 cases) keep finding it. Build clean; 56 App + 104 Engine tests still pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 19:31:49 -04:00
// Auto-apply last preset bookkeeping. We don't apply here —
// participants haven't been discovered yet — instead we record
// the intent and let OnParticipantsChanged trigger the apply
// once the meeting has populated. Implementation in
// MainViewModel.PresetCommands.cs.
LoadPendingPresetFromPreferences();
}
private void OnParticipantsChanged(IReadOnlyList<Participant> incoming)
{
var seenIds = new HashSet<Guid>();
var hideLocal = Settings.HideLocalSelf;
var autoDisable = Settings.AutoDisableOnDeparture;
foreach (var p in incoming)
{
// 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;
seenIds.Add(p.Id);
if (_byId.TryGetValue(p.Id, out var vm))
{
var wasOnline = vm.IsOnline;
vm.Update(p);
// Departure: source went from non-null to null. Always toast so the
// operator notices, even when AutoDisableOnDeparture is off — the
// ISO might still be "running" but emitting a slate frame, which
// looks fine in TeamsISO's UI but is broken downstream.
if (wasOnline && !vm.IsOnline && vm.IsEnabled)
{
if (autoDisable)
{
var captured = vm;
_ = Task.Run(async () =>
{
try { await _controller.DisableIsoAsync(captured.Id, CancellationToken.None); }
catch { /* defensive */ }
await _dispatcher.InvokeAsync(() =>
{
captured.IsEnabled = false;
Toast.Show($"Auto-disabled ISO: {captured.DisplayName} left the meeting");
});
});
}
else
{
// ISO stays running on a slate frame; warn the operator so
// they can decide whether to disable manually.
Toast.Warn($"{vm.DisplayName} disconnected — ISO still running on slate");
}
}
}
else
{
vm = new ParticipantViewModel(_controller, p, Toast);
_byId[p.Id] = vm;
Participants.Add(vm);
}
}
// Remove participants no longer present (or now hidden by the filter).
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);
}
}
// Auto-apply-last-preset, second half: once participants populate, kick the
// apply. We fire it under either of two conditions: (a) every display name
// referenced by the preset is present (best case — the meeting is fully
// populated, no skipped assignments), or (b) the grace deadline has passed
// (give up waiting and apply with whoever's online).
if (_pendingPresetName is not null && !_pendingPresetApplied)
{
TryAutoApplyPendingPreset();
}
}
private static bool IsLocalSelf(Participant p) =>
string.Equals(p.DisplayName, "(Local)", StringComparison.Ordinal);
public void Dispose()
{
_statsTimer.Stop();
_statsTimer.Tick -= OnStatsTick;
_participantsSub.Dispose();
_alertsSub.Dispose();
}
}