dragon-iso/src/TeamsISO.App/ViewModels/MainViewModel.cs
Zac Gaetano acc569dd24
Some checks failed
CI / build-and-test (push) Failing after 30s
Onboarding step + Open /ui button + recording duration in footer
Three small UX wins:

1. Onboarding gained step 5 ('Run Teams headless') and step 6 ('Drive from another machine') so new operators discover the auto-launch/auto-hide + LAN-reachable workflows. Existing 'where things live' step renumbered to 7.

2. Settings → DISPLAY → Control surface URL row gains an Open button next to Copy that fires the URL into the default browser via Process.Start with UseShellExecute. Operators previewing how the embedded /ui control panel looks on a phone/tablet no longer need to copy-paste manually.

3. Recording badge in footer now shows 'REC 3 · 12:45' instead of just 'REC 3'. RecordingElapsed VM property maintains a separate timer from the session timer because recording can start AFTER the meeting begins; operators tracking 'how long has the archive copy been rolling' need that distinct duration.
2026-05-10 21:05:30 -04:00

898 lines
39 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)
{
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;
// JoinOrder: leave SortDescriptions empty.
}
}
/// <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; }
/// <summary>
/// Drop a timestamped marker into every active recording. Bound to a button
/// in the IN-CALL bar; eventually wireable to a global hotkey. The marker
/// label is auto-generated as "Marker @ HH:mm:ss" — operators who want
/// custom labels can edit manifest.json after the fact.
/// </summary>
public RelayCommand DropRecordingMarkerCommand { get; }
/// <summary>F1 binding — opens the help / cheat-sheet dialog.</summary>
public RelayCommand ShowHelpCommand { get; }
/// <summary>Opens the inline notes viewer for today's show-notes file.</summary>
public RelayCommand ShowNotesCommand { get; }
/// <summary>
/// Roll-recording: disable + re-enable every currently-recording pipeline,
/// starting a fresh recording chunk in a new subdirectory. Operator-friendly
/// chaptering between show segments without losing already-recorded footage
/// (the previous chunk is finalized on disable, the next chunk starts clean).
/// </summary>
public AsyncRelayCommand RollRecordingCommand { get; }
/// <summary>Join a Teams meeting from a pasted URL — see <see cref="JoinMeetingUrl"/>.</summary>
public RelayCommand JoinMeetingCommand { 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);
}
/// <summary>
/// Recording status badge — true when at least one ISO is being recorded.
/// Polled at the existing 1Hz stats tick rather than via a dedicated change
/// event, since recording state shifts on enable/disable transitions and
/// the stats poll already reads each pipeline's state.
/// </summary>
public bool IsRecording
{
get => _isRecording;
private set => SetField(ref _isRecording, value);
}
private bool _isRecording;
/// <summary>Number of pipelines currently writing to the recorder.</summary>
public int ActiveRecordingCount
{
get => _activeRecordingCount;
private set => SetField(ref _activeRecordingCount, value);
}
private int _activeRecordingCount;
/// <summary>
/// Elapsed time since recording started this session, formatted as
/// "MM:SS" (or "HH:MM:SS" past an hour). Empty when nothing is
/// recording. Resets when all recordings stop, restarts on the next
/// rec-on transition. Useful for operators tracking "how long has the
/// show been rolling".
/// </summary>
public string RecordingElapsed
{
get => _recordingElapsed;
private set => SetField(ref _recordingElapsed, value);
}
private string _recordingElapsed = string.Empty;
private DateTimeOffset? _recordingStartedAt;
/// <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>
/// 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…");
});
DropRecordingMarkerCommand = new RelayCommand(() =>
{
var label = "Marker @ " + DateTimeOffset.Now.ToString("HH:mm:ss");
_controller.AddRecordingMarker(label);
Toast.Show($"Marker dropped: {label}");
});
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
});
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}");
}
});
RollRecordingCommand = new AsyncRelayCommand(RollRecordingAsync,
() => _controller.RecordingEnabled && Participants.Any(p => p.IsEnabled));
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>
/// <summary>
/// Roll every active recording into a new chunk: disable + re-enable every
/// pipeline that's currently running. The recorder finalizes its
/// manifest.json on disable and a fresh subdirectory is created on the
/// next enable (RawBgraRecorderSink uses the participant display name +
/// the timestamp template so consecutive rolls don't collide on disk).
///
/// Per-participant best-effort: one bad pipeline doesn't abort the rest.
/// </summary>
private async Task RollRecordingAsync()
{
var enabled = Participants.Where(p => p.IsEnabled).ToArray();
if (enabled.Length == 0)
{
Toast.Show("No active ISOs to roll");
return;
}
var rolled = 0;
foreach (var p in enabled)
{
try
{
await _controller.DisableIsoAsync(p.Id, CancellationToken.None);
await Task.Delay(150);
var resolvedName = string.IsNullOrWhiteSpace(p.CustomName)
? Services.OutputNameTemplate.Render(
Services.OutputNameTemplate.Get(),
p.Id,
p.DisplayName)
: p.CustomName;
bool? recordOverride = p.RecordToDisk ? null : false;
await _controller.EnableIsoAsync(p.Id, resolvedName, recordOverride, CancellationToken.None);
rolled++;
}
catch
{
// Per-pipeline best-effort
}
}
Toast.Show($"Rolled {rolled} recording(s) into a new chunk");
}
/// <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;
bool? recordOverride = p.RecordToDisk ? null : false;
await _controller.EnableIsoAsync(p.Id, resolvedName, recordOverride, 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>
/// 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.
}
}
// 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);
ActiveRecordingCount = _controller.RecordingEnabled ? enabledCount : 0;
IsRecording = ActiveRecordingCount > 0;
// Recording session timer — independent of the global session timer
// since recording can start AFTER the meeting begins (or vice versa)
// and operators want to know exactly how long the archive copy has
// been rolling. Resets to null when all recordings stop, so the
// next rec-on transition starts the timer from 00:00.
if (IsRecording)
{
_recordingStartedAt ??= DateTimeOffset.UtcNow;
var recElapsed = DateTimeOffset.UtcNow - _recordingStartedAt.Value;
RecordingElapsed = recElapsed.TotalHours >= 1
? $"{(int)recElapsed.TotalHours:D2}:{recElapsed.Minutes:D2}:{recElapsed.Seconds:D2}"
: $"{recElapsed.Minutes:D2}:{recElapsed.Seconds:D2}";
}
else
{
_recordingStartedAt = null;
RecordingElapsed = string.Empty;
}
// 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 if (ActiveRecordingCount > 0 && ActiveRecordingCount != enabledCount)
{
StatusText = $"{enabledCount}/{totalParticipants} ISOs live · {ActiveRecordingCount} recording";
}
else if (ActiveRecordingCount > 0)
{
StatusText = $"{enabledCount}/{totalParticipants} ISOs live · all recording";
}
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
{
var inCall = TeamsControlBridge.IsInCall();
var title = inCall ? ExtractMeetingTitle(TeamsLauncher.GetActiveWindowTitle()) : null;
_dispatcher.InvokeAsync(() =>
{
IsTeamsInCall = inCall;
TeamsMeetingState = inCall
? (string.IsNullOrEmpty(title) ? "IN CALL" : $"IN CALL · {title}")
: "READY";
});
}
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);
_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();
}
}