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

991 lines
43 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.
/// </summary>
public sealed 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…";
// Auto-apply-last-preset bookkeeping. Set on InitializeAsync from disk;
// cleared once we successfully apply (so we don't re-apply when the
// participant list later mutates). The grace deadline gives Teams enough
// time to publish all initial sources after engine start before we attempt
// the apply — applying before everyone's visible would partially-restore
// the routing and silently drop assignments for late-appearing participants.
private string? _pendingPresetName;
private DateTimeOffset _pendingPresetDeadline;
private bool _pendingPresetApplied;
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>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();
_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;
});
// 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();
});
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(() =>
{
// Trim + handle the operator pasting whitespace around the URL.
var url = (_joinMeetingUrl ?? string.Empty).Trim();
if (string.IsNullOrEmpty(url)) return;
if (TeamsLauncher.TryJoinMeeting(url, out var error))
{
Toast.Show("Joining Teams meeting…");
JoinMeetingUrl = string.Empty;
// If the operator has auto-hide on, kick off the hide watcher
// so the Teams meeting window goes away as soon as it renders.
if (Settings.AutoHideTeamsWindows)
_ = TeamsLauncher.AutoHideAfterLaunchAsync();
}
else
{
Toast.Warn($"Could not join: {error}");
}
});
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");
}
/// <summary>
/// Wraps a <see cref="TeamsControlBridge"/> invocation in a RelayCommand that
/// translates the result to a user-visible toast. Centralizes the toast wording
/// so the four control commands stay consistent.
/// </summary>
private RelayCommand MakeTeamsCommand(string label, Func<TeamsControlBridge.InvokeResult> invoke, string successMessage)
{
return new RelayCommand(() =>
{
switch (invoke())
{
case TeamsControlBridge.InvokeResult.Invoked:
Toast.Show(successMessage);
break;
case TeamsControlBridge.InvokeResult.TeamsNotRunning:
Toast.Warn("Teams isn't running.");
break;
case TeamsControlBridge.InvokeResult.ControlNotFound:
Toast.Warn($"{label} control not found — are you in a call?");
break;
case TeamsControlBridge.InvokeResult.InvokeFailed:
Toast.Warn($"{label} button found but disabled.");
break;
}
});
}
/// <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>
// RollRecordingAsync removed — recording feature axed.
/// <summary>
/// Enable ISOs for every online + non-enabled participant in parallel-ish
/// (sequential await, but each individual EnableIsoAsync is fast). Tolerates
/// per-participant failures so one bad source doesn't abort the rest.
/// </summary>
private async Task EnableAllOnlineAsync()
{
var candidates = Participants.Where(p => p.IsOnline && !p.IsEnabled).ToArray();
var enabled = 0;
foreach (var p in candidates)
{
try
{
var resolvedName = string.IsNullOrWhiteSpace(p.CustomName)
? Services.OutputNameTemplate.Render(
Services.OutputNameTemplate.Get(),
p.Id,
p.DisplayName)
: p.CustomName;
// 3-arg overload (no recordOverride) — recording surface axed,
// so the engine's per-pipeline recorder sink stays unattached.
await _controller.EnableIsoAsync(p.Id, resolvedName, CancellationToken.None);
p.IsEnabled = true;
enabled++;
}
catch
{
// Per-participant best-effort — one bad source shouldn't abort the bulk operation.
}
}
Toast.Show(enabled == 0
? "No participants to enable"
: $"Enabled {enabled} ISO(s)");
}
private async Task StopAllIsosAsync()
{
// Snapshot first so the collection doesn't mutate while we iterate.
var enabled = Participants.Where(p => p.IsEnabled).ToArray();
if (enabled.Length == 0)
{
Toast.Show("No ISOs to stop");
return;
}
// Confirm before tearing down — this button is an "emergency stop" but
// mis-clicks during a show are easy. The dialog cost is negligible
// (one Enter press) and the regret cost is huge (yanking 5 ISOs mid-
// broadcast). Default selection is No so accidental hits cancel.
var confirm = System.Windows.MessageBox.Show(
$"Stop {enabled.Length} running ISO(s)?\n\nThis tears down every active pipeline immediately.",
"TeamsISO — Stop all ISOs",
System.Windows.MessageBoxButton.YesNo,
System.Windows.MessageBoxImage.Warning,
System.Windows.MessageBoxResult.No);
if (confirm != System.Windows.MessageBoxResult.Yes) return;
foreach (var p in enabled)
{
try { await _controller.DisableIsoAsync(p.Id, CancellationToken.None); }
catch { /* defensive */ }
p.IsEnabled = false;
}
Toast.Show($"Stopped {enabled.Length} ISO(s)");
}
/// <summary>
/// Save a PNG of every currently-enabled participant's latest processed
/// frame to a timestamped subdirectory under
/// %USERPROFILE%\Pictures\TeamsISO\. One folder per Snapshot All click
/// so back-to-back clicks don't comingle. Useful for end-of-meeting
/// archives, recapping who showed up, etc.
/// </summary>
private void SnapshotAll()
{
var enabled = Participants.Where(p => p.IsEnabled).ToArray();
if (enabled.Length == 0)
{
Toast.Warn("No enabled participants to snapshot");
return;
}
var rootDir = System.IO.Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.MyPictures),
"TeamsISO",
$"snapshots-{DateTimeOffset.Now:yyyyMMdd_HHmmss}");
try
{
System.IO.Directory.CreateDirectory(rootDir);
}
catch (Exception ex)
{
Toast.Warn($"Couldn't create snapshot dir: {ex.Message}");
return;
}
var saved = 0;
var failed = 0;
foreach (var p in enabled)
{
try
{
var frame = _controller.GetLatestProcessedFrame(p.Id);
if (frame is null || frame.Pixels.IsEmpty) { failed++; continue; }
var safeName = string.Join("_", p.DisplayName.Split(System.IO.Path.GetInvalidFileNameChars()));
if (string.IsNullOrWhiteSpace(safeName)) safeName = "participant";
var path = System.IO.Path.Combine(rootDir, $"{safeName}.png");
var stride = frame.Width * 4;
var bmp = new System.Windows.Media.Imaging.WriteableBitmap(
frame.Width, frame.Height, 96, 96,
System.Windows.Media.PixelFormats.Bgra32, null);
bmp.WritePixels(
new System.Windows.Int32Rect(0, 0, frame.Width, frame.Height),
frame.Pixels.ToArray(), stride, 0);
using var fs = new System.IO.FileStream(path, System.IO.FileMode.Create);
var encoder = new System.Windows.Media.Imaging.PngBitmapEncoder();
encoder.Frames.Add(System.Windows.Media.Imaging.BitmapFrame.Create(bmp));
encoder.Save(fs);
saved++;
}
catch { failed++; }
}
Toast.Show(failed > 0
? $"Saved {saved} snapshot(s) ({failed} failed) to Pictures\\TeamsISO\\{System.IO.Path.GetFileName(rootDir)}"
: $"Saved {saved} snapshot(s) to Pictures\\TeamsISO\\{System.IO.Path.GetFileName(rootDir)}");
}
// FormatBytes removed — its only caller was the recording free-space footer
// label, which went away with the rest of the recording surface.
/// <summary>
/// Pull the meaningful "meeting title" out of Teams' raw window title.
/// Teams uses formats like:
/// "Weekly Standup | Microsoft Teams"
/// "Meeting with Alice | Microsoft Teams"
/// "Microsoft Teams" (no meeting, just the app)
/// Strip the trailing " | Microsoft Teams" so the IN-CALL pill stays
/// short and readable. Truncate beyond 50 chars so a long meeting
/// subject doesn't push the rest of the IN-CALL bar off screen.
/// </summary>
internal static string ExtractMeetingTitle(string windowTitle)
{
if (string.IsNullOrWhiteSpace(windowTitle)) return string.Empty;
var t = windowTitle.Trim();
// Common separator patterns Teams uses across locales.
foreach (var sep in new[] { " | Microsoft Teams", " | Teams", " - Microsoft Teams", " - Teams" })
{
var idx = t.IndexOf(sep, StringComparison.Ordinal);
if (idx > 0) { t = t.Substring(0, idx).Trim(); break; }
}
// If after stripping we're left with just "Microsoft Teams" the
// window has no meeting context — return empty so the pill stays
// at "IN CALL" without a stale title.
if (t.Equals("Microsoft Teams", StringComparison.OrdinalIgnoreCase)) return string.Empty;
if (t.Length > 50) t = t.Substring(0, 47) + "…";
return t;
}
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. We probe by looking
// for the Leave button in Teams' automation tree (present iff in a
// call) and surface the result as a status pill in the IN-CALL bar.
// Offloaded to a Task so a slow UIA call doesn't stall the UI tick;
// the property update is dispatched back here on next tick.
try
{
var teamsRunning = TeamsLauncher.IsRunning();
if (!teamsRunning)
{
TeamsMeetingState = string.Empty;
IsTeamsInCall = false;
}
else
{
// Fire the UIA probe off-thread — it walks the full descendant
// tree of every Teams window and can take 50-200ms in a busy
// call. We can tolerate one-tick latency on the displayed
// state much more easily than a UI hiccup.
_ = Task.Run(() =>
{
try
{
// Single UIA traversal returns all three signals — in-call,
// muted, camera-off — so we don't pay for three walks of
// the same descendant tree at 1Hz.
var snap = TeamsControlBridge.DetectCallState();
var inCall = snap.IsInCall;
var title = inCall ? ExtractMeetingTitle(TeamsLauncher.GetActiveWindowTitle()) : null;
_dispatcher.InvokeAsync(() =>
{
IsTeamsInCall = inCall;
TeamsMeetingState = inCall
? (string.IsNullOrEmpty(title) ? "IN CALL" : $"IN CALL · {title}")
: "READY";
// Mute / camera state — only meaningful in-call.
IsLocalMuted = inCall && (snap.IsMuted ?? false);
IsLocalCameraOff = inCall && (snap.IsCameraOff ?? false);
// Auto-record-on-call hook removed alongside recording feature.
});
}
catch { /* UIA flakiness shouldn't crash the stats tick */ }
});
}
}
catch { /* defensive — probe failures must never break the tick */ }
// 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.
try
{
var pref = Services.OperatorPresetStore.GetStartupPreference();
if (pref.AutoApplyOnStartup && !string.IsNullOrEmpty(pref.LastAppliedName))
{
_pendingPresetName = pref.LastAppliedName;
// 30s grace window is generous: Teams typically advertises all
// existing participants within 510s of NDI discovery starting.
// After this deadline we apply with whoever is visible.
_pendingPresetDeadline = DateTimeOffset.UtcNow.AddSeconds(30);
}
}
catch { /* preset read failures shouldn't block engine startup */ }
}
/// <summary>
/// CLI override for the launch-time auto-apply. Called from App.OnStartup
/// after parsing <c>--apply-preset</c>. Sets the same pending-preset state
/// that the user-toggled auto-apply path uses, so a single trigger flow
/// covers both. Wins over the persisted preference (operator's CLI intent
/// is more recent than what's on disk).
/// </summary>
public void RequestApplyPresetOnStartup(string presetName)
{
_pendingPresetName = presetName;
_pendingPresetDeadline = DateTimeOffset.UtcNow.AddSeconds(30);
_pendingPresetApplied = false;
}
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();
}
}
/// <summary>
/// Attempts to apply <see cref="_pendingPresetName"/> if either every preset
/// assignment matches a live participant, or the grace deadline has passed.
/// Idempotent — repeat calls without state change are no-ops; once we fire we
/// flag <c>_pendingPresetApplied</c> so subsequent participant churn doesn't
/// trigger a second apply. Failures (missing preset on disk, preset that no
/// longer matches anyone) are swallowed: the operator can always re-apply
/// manually via the dialog. Delegates to <see cref="PresetApplier.ApplyAsync"/>
/// for the actual reconciliation so the dialog, REST surface, and this auto-
/// apply path all share a single implementation.
/// </summary>
private void TryAutoApplyPendingPreset()
{
Services.OperatorPresetStore.Preset? preset;
try { preset = Services.OperatorPresetStore.Find(_pendingPresetName!); }
catch { preset = null; }
if (preset is null)
{
_pendingPresetApplied = true; // give up; nothing on disk to apply
return;
}
var liveNames = new HashSet<string>(
Participants.Select(p => p.DisplayName),
StringComparer.OrdinalIgnoreCase);
var allPresent = preset.Assignments.All(a => liveNames.Contains(a.DisplayName));
if (!allPresent && DateTimeOffset.UtcNow < _pendingPresetDeadline)
return; // wait for the rest of the meeting to populate
_pendingPresetApplied = true;
var captured = preset;
// Snapshot the participants list since we're about to await on a worker
// thread; the live ObservableCollection isn't safe to enumerate from
// outside the dispatcher.
var snapshot = Participants.ToList();
_ = Task.Run(async () =>
{
var result = await PresetApplier.ApplyAsync(
captured, snapshot, _controller, _dispatcher);
await _dispatcher.InvokeAsync(() =>
Toast.Show($"Auto-applied preset \"{captured.Name}\" — {result.Matched} participant(s)"));
});
}
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();
}
}