Some checks failed
CI / build-and-test (push) Failing after 30s
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.
898 lines
39 KiB
C#
898 lines
39 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.
|
||
/// </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 5–10s 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();
|
||
}
|
||
}
|