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>
699 lines
31 KiB
C#
699 lines
31 KiB
C#
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.
|
|
///
|
|
/// 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>
|
|
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…";
|
|
|
|
// _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;
|
|
}
|
|
}
|
|
});
|
|
|
|
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");
|
|
}
|
|
|
|
// 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";
|
|
}
|
|
|
|
// 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.";
|
|
|
|
// 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();
|
|
}
|
|
}
|