2026-05-07 11:40:06 -04:00
using System.Collections.ObjectModel ;
2026-05-10 09:41:28 -04:00
using System.ComponentModel ;
2026-05-07 11:40:06 -04:00
using System.Reactive.Concurrency ;
using System.Reactive.Linq ;
2026-05-10 09:41:28 -04:00
using System.Windows.Data ;
2026-05-07 11:40:06 -04:00
using System.Windows.Threading ;
2026-05-10 09:41:28 -04:00
using TeamsISO.App.Services ;
2026-05-07 11:40:06 -04:00
using TeamsISO.Engine.Controller ;
using TeamsISO.Engine.Domain ;
namespace TeamsISO.App.ViewModels ;
/// <summary>
/// Top-level view model for the main window. Owns the live collection of <see cref="ParticipantViewModel"/>,
/// the global settings panel, and the alert banner. Subscribes to <see cref="IIsoController"/>'s observables
/// and marshals updates onto the UI dispatcher.
refactor(viewmodels): split MainViewModel into themed partial classes
MainViewModel.cs was 1017 lines and 45KB — most of it was bulk-operation
loops, Teams UIA plumbing, and the auto-apply-last-preset state machine
sitting on top of the actual MainViewModel surface (constructor, props,
OnStatsTick). Splits the class via partial-class into themed siblings:
* MainViewModel.cs (was 1017L → now 699L) — fields, properties,
constructor that wires every Command, OnStatsTick + Dispose. This
remains the thin aggregator.
* MainViewModel.TeamsCommands.cs (130L, new) — MakeTeamsCommand helper,
JoinPastedMeeting (body of JoinMeetingCommand), ExtractMeetingTitle
(already-tested static), PollTeamsMeetingState (the 1Hz UIA probe
formerly inlined in OnStatsTick).
* MainViewModel.PresetCommands.cs (108L, new) —
RequestApplyPresetOnStartup (CLI hook), LoadPendingPresetFromPreferences
(called by InitializeAsync), TryAutoApplyPendingPreset (the reconcile
step), and the _pendingPreset* private-field set that backs the path.
* MainViewModel.BulkCommands.cs (149L, new) — EnableAllOnlineAsync,
StopAllIsosAsync (with the default-No confirmation dialog),
SnapshotAll. RecordingCommands.cs from the original punch list is
intentionally absent — the recording surface was axed at 1d1ce6a;
what remains here is bulk-state ops across the participants
collection (note in the file header).
Why partial-class instead of helper-services or composed objects: every
extracted method touches the same private dispatcher / controller /
participants / toast state. Composing would require either passing
those references in (verbose call sites) or extracting them to a
shared private context object (boilerplate). Partial gives us
file-level separation without spreading the state contract.
ExtractMeetingTitle stays internal-static so the existing
MeetingTitleExtractionTests (10 cases) keep finding it. Build clean;
56 App + 104 Engine tests still pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 19:31:49 -04:00
///
/// Split across partial files by responsibility:
/// • <c>MainViewModel.cs</c> — fields, properties, constructor (wires commands), OnStatsTick, Dispose
/// • <c>MainViewModel.TeamsCommands.cs</c> — Mute / Cam / Leave / Share + Join URL + Teams meeting-state poll
/// • <c>MainViewModel.PresetCommands.cs</c> — auto-apply-last-preset path
/// • <c>MainViewModel.BulkCommands.cs</c> — Stop all / Enable all / Snapshot all
2026-05-07 11:40:06 -04:00
/// </summary>
refactor(viewmodels): split MainViewModel into themed partial classes
MainViewModel.cs was 1017 lines and 45KB — most of it was bulk-operation
loops, Teams UIA plumbing, and the auto-apply-last-preset state machine
sitting on top of the actual MainViewModel surface (constructor, props,
OnStatsTick). Splits the class via partial-class into themed siblings:
* MainViewModel.cs (was 1017L → now 699L) — fields, properties,
constructor that wires every Command, OnStatsTick + Dispose. This
remains the thin aggregator.
* MainViewModel.TeamsCommands.cs (130L, new) — MakeTeamsCommand helper,
JoinPastedMeeting (body of JoinMeetingCommand), ExtractMeetingTitle
(already-tested static), PollTeamsMeetingState (the 1Hz UIA probe
formerly inlined in OnStatsTick).
* MainViewModel.PresetCommands.cs (108L, new) —
RequestApplyPresetOnStartup (CLI hook), LoadPendingPresetFromPreferences
(called by InitializeAsync), TryAutoApplyPendingPreset (the reconcile
step), and the _pendingPreset* private-field set that backs the path.
* MainViewModel.BulkCommands.cs (149L, new) — EnableAllOnlineAsync,
StopAllIsosAsync (with the default-No confirmation dialog),
SnapshotAll. RecordingCommands.cs from the original punch list is
intentionally absent — the recording surface was axed at 1d1ce6a;
what remains here is bulk-state ops across the participants
collection (note in the file header).
Why partial-class instead of helper-services or composed objects: every
extracted method touches the same private dispatcher / controller /
participants / toast state. Composing would require either passing
those references in (verbose call sites) or extracting them to a
shared private context object (boilerplate). Partial gives us
file-level separation without spreading the state contract.
ExtractMeetingTitle stays internal-static so the existing
MeetingTitleExtractionTests (10 cases) keep finding it. Build clean;
56 App + 104 Engine tests still pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 19:31:49 -04:00
public sealed partial class MainViewModel : ObservableObject , IDisposable
2026-05-07 11:40:06 -04:00
{
private readonly IIsoController _controller ;
private readonly Dispatcher _dispatcher ;
private readonly IDisposable _participantsSub ;
private readonly IDisposable _alertsSub ;
2026-05-08 00:52:44 -04:00
private readonly DispatcherTimer _statsTimer ;
2026-05-07 11:40:06 -04:00
private readonly Dictionary < Guid , ParticipantViewModel > _byId = new ( ) ;
private string _statusText = "Starting…" ;
refactor(viewmodels): split MainViewModel into themed partial classes
MainViewModel.cs was 1017 lines and 45KB — most of it was bulk-operation
loops, Teams UIA plumbing, and the auto-apply-last-preset state machine
sitting on top of the actual MainViewModel surface (constructor, props,
OnStatsTick). Splits the class via partial-class into themed siblings:
* MainViewModel.cs (was 1017L → now 699L) — fields, properties,
constructor that wires every Command, OnStatsTick + Dispose. This
remains the thin aggregator.
* MainViewModel.TeamsCommands.cs (130L, new) — MakeTeamsCommand helper,
JoinPastedMeeting (body of JoinMeetingCommand), ExtractMeetingTitle
(already-tested static), PollTeamsMeetingState (the 1Hz UIA probe
formerly inlined in OnStatsTick).
* MainViewModel.PresetCommands.cs (108L, new) —
RequestApplyPresetOnStartup (CLI hook), LoadPendingPresetFromPreferences
(called by InitializeAsync), TryAutoApplyPendingPreset (the reconcile
step), and the _pendingPreset* private-field set that backs the path.
* MainViewModel.BulkCommands.cs (149L, new) — EnableAllOnlineAsync,
StopAllIsosAsync (with the default-No confirmation dialog),
SnapshotAll. RecordingCommands.cs from the original punch list is
intentionally absent — the recording surface was axed at 1d1ce6a;
what remains here is bulk-state ops across the participants
collection (note in the file header).
Why partial-class instead of helper-services or composed objects: every
extracted method touches the same private dispatcher / controller /
participants / toast state. Composing would require either passing
those references in (verbose call sites) or extracting them to a
shared private context object (boilerplate). Partial gives us
file-level separation without spreading the state contract.
ExtractMeetingTitle stays internal-static so the existing
MeetingTitleExtractionTests (10 cases) keep finding it. Build clean;
56 App + 104 Engine tests still pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 19:31:49 -04:00
// _pendingPresetName / Deadline / Applied + the auto-apply path
// moved to MainViewModel.PresetCommands.cs.
2026-05-10 09:41:28 -04:00
2026-05-07 11:40:06 -04:00
public ObservableCollection < ParticipantViewModel > Participants { get ; } = new ( ) ;
2026-05-10 09:41:28 -04:00
/// <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 )
{
2026-05-10 21:21:45 -04:00
_currentSortMode = mode ;
2026-05-10 09:41:28 -04:00
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 ;
2026-05-10 21:21:45 -04:00
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 ;
2026-05-10 09:41:28 -04:00
// JoinOrder: leave SortDescriptions empty.
}
}
2026-05-10 21:21:45 -04:00
private Services . UIPreferences . SortMode _currentSortMode = Services . UIPreferences . SortMode . JoinOrder ;
2026-05-10 09:41:28 -04:00
/// <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 ( ) ;
}
}
2026-05-07 11:40:06 -04:00
public GlobalSettingsViewModel Settings { get ; }
public AlertBannerViewModel AlertBanner { get ; } = new ( ) ;
feat(ui): toast feedback for settings actions; refresh _NEXT.md
Adds a small auto-dismissing pill notification at the bottom-center of the participants area: 'Settings saved' on Apply Changes, 'Transcoder topology applied — restart Teams to take effect' after the one-click NDI groups setup. ToastViewModel owns its own DispatcherTimer and resets the dismissal countdown on successive calls, so the most recent message is always the one visible. Hooked into MainViewModel and threaded into GlobalSettingsViewModel via constructor injection.
_NEXT.md rewritten to reflect the May 2026 hardening pass: separates engine / UI / networking / Phase E.1 / diagnostics / CI / tests sections, lists every shipped item, and re-prioritizes the remaining work (Phase E.2-E.3 embedded Teams, code-signing the MSI, refresh-discovery affordance, output thumbnail previews, settings panel UX, auto-disable on departure, operator presets).
2026-05-09 09:30:04 -04:00
public ToastViewModel Toast { get ; }
2026-05-10 09:41:28 -04:00
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 ;
2026-05-07 11:40:06 -04:00
2026-05-08 13:59:14 -04:00
/// <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 ; }
2026-05-10 09:41:28 -04:00
/// <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 ; }
2026-05-14 06:02:40 -04:00
// Recording-marker and roll-recording commands removed — recording feature axed.
2026-05-10 09:41:28 -04:00
/// <summary>F1 binding — opens the help / cheat-sheet dialog.</summary>
public RelayCommand ShowHelpCommand { get ; }
2026-05-14 12:46:24 -04:00
/// <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 ; }
feat(wpf): v2 task 39+40 - studio table redesign + Ctrl+K command palette
Task 39: 5-column participants table - state LED, name+codec caption, 5-bar audio meter, mono output name, ISO pill. Row height 52, full-row active-speaker tint (no left stripe). New converter LevelThresholdConverter, OutputName property on ParticipantViewModel.
Task 40: Ctrl+K / Ctrl+P command palette - chromeless centered floating window, fuzzy Contains match across Label/Category/Keywords, arrow nav, Enter invoke, Esc close. Quick/Teams/Network/App categories cover top operator verbs and theme switching.
Also: log startup exceptions to Serilog before the modal MessageBox fires - much better triage signal than user-pasted dialog text.
2026-05-15 11:15:00 -04:00
/// <summary>
/// Ctrl+K binding — opens the v2 command palette. The actual window
/// open call lives in <see cref="MainWindow"/> (view-side concern);
/// this command delegates through an Action callback the view sets
/// after construction so the VM stays unaware of WPF Window types.
/// </summary>
public RelayCommand OpenCommandPaletteCommand { get ; }
private Action ? _openCommandPalette ;
/// <summary>
/// Wire the view's palette-opening callback. Called by MainWindow's
/// constructor right after DataContext is set. Idempotent — second
/// call replaces the first.
/// </summary>
public void RegisterCommandPaletteOpener ( Action openPalette ) = >
_openCommandPalette = openPalette ;
2026-05-10 09:41:28 -04:00
/// <summary>Opens the inline notes viewer for today's show-notes file.</summary>
public RelayCommand ShowNotesCommand { get ; }
2026-05-10 20:45:04 -04:00
/// <summary>Join a Teams meeting from a pasted URL — see <see cref="JoinMeetingUrl"/>.</summary>
public RelayCommand JoinMeetingCommand { get ; }
2026-05-10 21:23:49 -04:00
/// <summary>Save a PNG snapshot of every enabled participant's current frame.</summary>
public RelayCommand SnapshotAllCommand { get ; }
2026-05-10 21:26:37 -04:00
/// <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 ; }
2026-05-10 20:45:04 -04:00
/// <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 ;
2026-05-07 11:40:06 -04:00
public string StatusText
{
get = > _statusText ;
set = > SetField ( ref _statusText , value ) ;
}
2026-05-14 06:02:40 -04:00
// Recording-status properties (IsRecording, ActiveRecordingCount,
// RecordingElapsed, RecordingFreeSpace, IsLowDiskSpace) removed when the
// recording feature was axed.
2026-05-10 21:19:34 -04:00
2026-05-14 12:46:24 -04:00
/// <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 ;
2026-05-10 09:41:28 -04:00
/// <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 ;
2026-05-10 20:42:57 -04:00
/// <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 ) ;
2026-05-10 21:17:19 -04:00
/// <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 ;
2026-05-10 09:41:28 -04:00
/// <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 ;
2026-05-07 11:40:06 -04:00
public MainViewModel ( IIsoController controller , Dispatcher dispatcher )
{
_controller = controller ;
_dispatcher = dispatcher ;
feat(ui): toast feedback for settings actions; refresh _NEXT.md
Adds a small auto-dismissing pill notification at the bottom-center of the participants area: 'Settings saved' on Apply Changes, 'Transcoder topology applied — restart Teams to take effect' after the one-click NDI groups setup. ToastViewModel owns its own DispatcherTimer and resets the dismissal countdown on successive calls, so the most recent message is always the one visible. Hooked into MainViewModel and threaded into GlobalSettingsViewModel via constructor injection.
_NEXT.md rewritten to reflect the May 2026 hardening pass: separates engine / UI / networking / Phase E.1 / diagnostics / CI / tests sections, lists every shipped item, and re-prioritizes the remaining work (Phase E.2-E.3 embedded Teams, code-signing the MSI, refresh-discovery affordance, output thumbnail previews, settings panel UX, auto-disable on departure, operator presets).
2026-05-09 09:30:04 -04:00
Toast = new ToastViewModel ( dispatcher ) ;
Settings = new GlobalSettingsViewModel ( controller , Toast ) ;
2026-05-07 11:40:06 -04:00
2026-05-10 09:41:28 -04:00
// 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 ( ) ;
2026-05-15 14:27:17 -04:00
// Subscribe directly (no ObserveOn) and marshal to the UI thread inside
// the callback via Dispatcher.InvokeAsync. The previous ObserveOn(
// SynchronizationContextScheduler) path captured SynchronizationContext
// .Current at subscribe time — fragile in WPF startup ordering, where
// the UI thread's SyncContext can be in a transitional state during
// App.OnStartup and the captured context never pumps subsequent
// OnNext calls. Direct subscribe + explicit dispatcher marshal is the
// pattern proven by Console.Program.cs (engine emits, consumer marshals).
2026-05-07 11:40:06 -04:00
_participantsSub = controller . Participants
2026-05-15 14:27:17 -04:00
. Subscribe ( snapshot = > _dispatcher . InvokeAsync (
( ) = > OnParticipantsChanged ( snapshot ) ,
DispatcherPriority . Background ) ) ;
2026-05-07 11:40:06 -04:00
_alertsSub = controller . Alerts
. ObserveOn ( new SynchronizationContextScheduler (
System . Threading . SynchronizationContext . Current ? ? new DispatcherSynchronizationContext ( _dispatcher ) ) )
. Subscribe ( alert = >
{
AlertBanner . Current = alert ;
} ) ;
2026-05-08 00:52:44 -04:00
// 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 ( ) ;
2026-05-08 13:59:14 -04:00
StopAllIsosCommand = new AsyncRelayCommand ( StopAllIsosAsync , ( ) = > Participants . Any ( p = > p . IsEnabled ) ) ;
2026-05-10 09:41:28 -04:00
EnableAllOnlineCommand = new AsyncRelayCommand ( EnableAllOnlineAsync ,
( ) = > Participants . Any ( p = > p . IsOnline & & ! p . IsEnabled ) ) ;
RefreshDiscoveryCommand = new RelayCommand ( ( ) = >
{
_controller . RefreshDiscovery ( ) ;
Toast . Show ( "Refreshing NDI discovery…" ) ;
} ) ;
2026-05-14 12:46:24 -04:00
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 ( ) ;
} ) ;
feat(wpf): v2 task 39+40 - studio table redesign + Ctrl+K command palette
Task 39: 5-column participants table - state LED, name+codec caption, 5-bar audio meter, mono output name, ISO pill. Row height 52, full-row active-speaker tint (no left stripe). New converter LevelThresholdConverter, OutputName property on ParticipantViewModel.
Task 40: Ctrl+K / Ctrl+P command palette - chromeless centered floating window, fuzzy Contains match across Label/Category/Keywords, arrow nav, Enter invoke, Esc close. Quick/Teams/Network/App categories cover top operator verbs and theme switching.
Also: log startup exceptions to Serilog before the modal MessageBox fires - much better triage signal than user-pasted dialog text.
2026-05-15 11:15:00 -04:00
OpenCommandPaletteCommand = new RelayCommand ( ( ) = > _openCommandPalette ? . Invoke ( ) ) ;
2026-05-10 09:41:28 -04:00
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
} ) ;
2026-05-10 21:23:49 -04:00
SnapshotAllCommand = new RelayCommand ( SnapshotAll , ( ) = > Participants . Any ( p = > p . IsEnabled ) ) ;
2026-05-10 21:26:37 -04:00
ToggleByIndexCommand = new RelayCommand < string > ( s = >
{
// Numpad / digit hotkeys pass "1".."9" as a string. Resolve
// against the filtered/sorted view so the index matches what
// the operator sees on screen, not the underlying storage order.
if ( ! int . TryParse ( s , out var idx ) | | idx < 1 | | idx > 9 ) return ;
var i = 0 ;
foreach ( var item in ParticipantsView )
{
if ( item is not ParticipantViewModel p ) continue ;
if ( + + i = = idx )
{
if ( p . ToggleIsoCommand . CanExecute ( null ) )
p . ToggleIsoCommand . Execute ( null ) ;
break ;
}
}
} ) ;
refactor(viewmodels): split MainViewModel into themed partial classes
MainViewModel.cs was 1017 lines and 45KB — most of it was bulk-operation
loops, Teams UIA plumbing, and the auto-apply-last-preset state machine
sitting on top of the actual MainViewModel surface (constructor, props,
OnStatsTick). Splits the class via partial-class into themed siblings:
* MainViewModel.cs (was 1017L → now 699L) — fields, properties,
constructor that wires every Command, OnStatsTick + Dispose. This
remains the thin aggregator.
* MainViewModel.TeamsCommands.cs (130L, new) — MakeTeamsCommand helper,
JoinPastedMeeting (body of JoinMeetingCommand), ExtractMeetingTitle
(already-tested static), PollTeamsMeetingState (the 1Hz UIA probe
formerly inlined in OnStatsTick).
* MainViewModel.PresetCommands.cs (108L, new) —
RequestApplyPresetOnStartup (CLI hook), LoadPendingPresetFromPreferences
(called by InitializeAsync), TryAutoApplyPendingPreset (the reconcile
step), and the _pendingPreset* private-field set that backs the path.
* MainViewModel.BulkCommands.cs (149L, new) — EnableAllOnlineAsync,
StopAllIsosAsync (with the default-No confirmation dialog),
SnapshotAll. RecordingCommands.cs from the original punch list is
intentionally absent — the recording surface was axed at 1d1ce6a;
what remains here is bulk-state ops across the participants
collection (note in the file header).
Why partial-class instead of helper-services or composed objects: every
extracted method touches the same private dispatcher / controller /
participants / toast state. Composing would require either passing
those references in (verbose call sites) or extracting them to a
shared private context object (boilerplate). Partial gives us
file-level separation without spreading the state contract.
ExtractMeetingTitle stays internal-static so the existing
MeetingTitleExtractionTests (10 cases) keep finding it. Build clean;
56 App + 104 Engine tests still pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 19:31:49 -04:00
JoinMeetingCommand = new RelayCommand ( JoinPastedMeeting ) ;
2026-05-10 20:45:04 -04:00
2026-05-10 09:41:28 -04:00
ToggleMuteCommand = MakeTeamsCommand (
label : "Mute" ,
invoke : TeamsControlBridge . ToggleMute ,
successMessage : "Toggled mute" ) ;
ToggleCameraCommand = MakeTeamsCommand (
label : "Camera" ,
invoke : TeamsControlBridge . ToggleCamera ,
successMessage : "Toggled camera" ) ;
LeaveCallCommand = MakeTeamsCommand (
label : "Leave" ,
invoke : TeamsControlBridge . LeaveCall ,
successMessage : "Left the call" ) ;
OpenShareTrayCommand = MakeTeamsCommand (
label : "Share" ,
invoke : TeamsControlBridge . OpenShareTray ,
successMessage : "Opened share tray" ) ;
}
refactor(viewmodels): split MainViewModel into themed partial classes
MainViewModel.cs was 1017 lines and 45KB — most of it was bulk-operation
loops, Teams UIA plumbing, and the auto-apply-last-preset state machine
sitting on top of the actual MainViewModel surface (constructor, props,
OnStatsTick). Splits the class via partial-class into themed siblings:
* MainViewModel.cs (was 1017L → now 699L) — fields, properties,
constructor that wires every Command, OnStatsTick + Dispose. This
remains the thin aggregator.
* MainViewModel.TeamsCommands.cs (130L, new) — MakeTeamsCommand helper,
JoinPastedMeeting (body of JoinMeetingCommand), ExtractMeetingTitle
(already-tested static), PollTeamsMeetingState (the 1Hz UIA probe
formerly inlined in OnStatsTick).
* MainViewModel.PresetCommands.cs (108L, new) —
RequestApplyPresetOnStartup (CLI hook), LoadPendingPresetFromPreferences
(called by InitializeAsync), TryAutoApplyPendingPreset (the reconcile
step), and the _pendingPreset* private-field set that backs the path.
* MainViewModel.BulkCommands.cs (149L, new) — EnableAllOnlineAsync,
StopAllIsosAsync (with the default-No confirmation dialog),
SnapshotAll. RecordingCommands.cs from the original punch list is
intentionally absent — the recording surface was axed at 1d1ce6a;
what remains here is bulk-state ops across the participants
collection (note in the file header).
Why partial-class instead of helper-services or composed objects: every
extracted method touches the same private dispatcher / controller /
participants / toast state. Composing would require either passing
those references in (verbose call sites) or extracting them to a
shared private context object (boilerplate). Partial gives us
file-level separation without spreading the state contract.
ExtractMeetingTitle stays internal-static so the existing
MeetingTitleExtractionTests (10 cases) keep finding it. Build clean;
56 App + 104 Engine tests still pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 19:31:49 -04:00
// Body methods extracted to themed partial files:
// MainViewModel.BulkCommands.cs — EnableAllOnlineAsync, StopAllIsosAsync, SnapshotAll
// MainViewModel.TeamsCommands.cs — MakeTeamsCommand, JoinPastedMeeting,
// ExtractMeetingTitle, PollTeamsMeetingState
// MainViewModel.PresetCommands.cs — RequestApplyPresetOnStartup,
// LoadPendingPresetFromPreferences,
// TryAutoApplyPendingPreset
2026-05-10 20:47:43 -04:00
2026-05-08 00:52:44 -04:00
private void OnStatsTick ( object? sender , EventArgs e )
{
foreach ( var vm in Participants )
{
try
{
var stats = _controller . GetStats ( vm . Id ) ;
vm . UpdateStats ( stats ) ;
2026-05-10 09:41:28 -04:00
// 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 ) ) ;
2026-05-08 00:52:44 -04:00
}
catch
{
// Stats are advisory; never let a transient read failure
// tear down the timer or surface an error to the user.
}
}
2026-05-10 09:41:28 -04:00
2026-05-10 21:28:09 -04:00
// 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 ;
}
2026-05-10 21:21:45 -04:00
// 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 */ }
}
2026-05-10 09:41:28 -04:00
// 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 ) ;
2026-05-14 06:02:40 -04:00
// Recording-elapsed timer + disk-free polling removed alongside the rest
// of the recording surface.
2026-05-10 21:05:30 -04:00
2026-05-14 12:46:24 -04:00
// 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 ;
2026-05-10 09:41:28 -04:00
// Session timer — start on first ISO going live, reset when none are
// live anymore. Subsequent enables after a full-zero gap restart the
// timer rather than resuming, which is the operator's mental model:
// "the show started when the first feed went live."
if ( enabledCount > 0 )
{
_sessionStartedAt ? ? = DateTimeOffset . UtcNow ;
var elapsed = DateTimeOffset . UtcNow - _sessionStartedAt . Value ;
SessionElapsed = elapsed . TotalHours > = 1
? $"{(int)elapsed.TotalHours:D2}:{elapsed.Minutes:D2}:{elapsed.Seconds:D2}"
: $"{elapsed.Minutes:D2}:{elapsed.Seconds:D2}" ;
IsSessionActive = true ;
}
else if ( _sessionStartedAt is not null )
{
_sessionStartedAt = null ;
SessionElapsed = string . Empty ;
IsSessionActive = false ;
}
// Dynamic status text — replaces the static "Engine running at X fps"
// once ISOs are live. The framerate target is still implicit (the user
// set it in OUTPUT settings; surfacing it constantly steals footer
// real estate from more-actionable info).
if ( totalParticipants = = 0 )
{
StatusText = "Discovering NDI sources…" ;
}
else if ( enabledCount = = 0 )
{
StatusText = totalParticipants = = 1
? "1 participant visible"
: $"{totalParticipants} participants visible" ;
}
else
{
StatusText = $"{enabledCount}/{totalParticipants} ISOs live" ;
}
refactor(viewmodels): split MainViewModel into themed partial classes
MainViewModel.cs was 1017 lines and 45KB — most of it was bulk-operation
loops, Teams UIA plumbing, and the auto-apply-last-preset state machine
sitting on top of the actual MainViewModel surface (constructor, props,
OnStatsTick). Splits the class via partial-class into themed siblings:
* MainViewModel.cs (was 1017L → now 699L) — fields, properties,
constructor that wires every Command, OnStatsTick + Dispose. This
remains the thin aggregator.
* MainViewModel.TeamsCommands.cs (130L, new) — MakeTeamsCommand helper,
JoinPastedMeeting (body of JoinMeetingCommand), ExtractMeetingTitle
(already-tested static), PollTeamsMeetingState (the 1Hz UIA probe
formerly inlined in OnStatsTick).
* MainViewModel.PresetCommands.cs (108L, new) —
RequestApplyPresetOnStartup (CLI hook), LoadPendingPresetFromPreferences
(called by InitializeAsync), TryAutoApplyPendingPreset (the reconcile
step), and the _pendingPreset* private-field set that backs the path.
* MainViewModel.BulkCommands.cs (149L, new) — EnableAllOnlineAsync,
StopAllIsosAsync (with the default-No confirmation dialog),
SnapshotAll. RecordingCommands.cs from the original punch list is
intentionally absent — the recording surface was axed at 1d1ce6a;
what remains here is bulk-state ops across the participants
collection (note in the file header).
Why partial-class instead of helper-services or composed objects: every
extracted method touches the same private dispatcher / controller /
participants / toast state. Composing would require either passing
those references in (verbose call sites) or extracting them to a
shared private context object (boilerplate). Partial gives us
file-level separation without spreading the state contract.
ExtractMeetingTitle stays internal-static so the existing
MeetingTitleExtractionTests (10 cases) keep finding it. Build clean;
56 App + 104 Engine tests still pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 19:31:49 -04:00
// Teams meeting state — UIA traversal at 1Hz; off-thread so a slow
// UIA call doesn't stall the UI tick. Implementation in
// MainViewModel.TeamsCommands.cs.
PollTeamsMeetingState ( ) ;
2026-05-10 20:42:57 -04:00
2026-05-10 09:41:28 -04:00
// 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 ;
2026-05-10 14:06:27 -04:00
// 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 ;
2026-05-10 09:41:28 -04:00
ControlSurfaceText = ( rest , osc ) switch
{
2026-05-10 14:06:27 -04:00
( true , true ) when lanMode = > $"{lanHost} + OSC :{app!.OscBridge!.Port}" ,
( true , false ) when lanMode = > lanHost ! ,
2026-05-10 09:41:28 -04:00
( true , true ) = > $"REST :{app!.ControlSurface!.Port} + OSC :{app.OscBridge!.Port}" ,
( true , false ) = > $"REST :{app!.ControlSurface!.Port}" ,
( false , true ) = > $"OSC :{app!.OscBridge!.Port}" ,
_ = > string . Empty ,
} ;
2026-05-07 11:40:06 -04:00
}
public async Task InitializeAsync ( CancellationToken cancellationToken )
{
StatusText = "Discovering NDI sources…" ;
await _controller . StartAsync ( cancellationToken ) ;
StatusText = $"Engine running at {_controller.GlobalSettings.FramerateHz:F2} fps target." ;
2026-05-10 09:41:28 -04:00
refactor(viewmodels): split MainViewModel into themed partial classes
MainViewModel.cs was 1017 lines and 45KB — most of it was bulk-operation
loops, Teams UIA plumbing, and the auto-apply-last-preset state machine
sitting on top of the actual MainViewModel surface (constructor, props,
OnStatsTick). Splits the class via partial-class into themed siblings:
* MainViewModel.cs (was 1017L → now 699L) — fields, properties,
constructor that wires every Command, OnStatsTick + Dispose. This
remains the thin aggregator.
* MainViewModel.TeamsCommands.cs (130L, new) — MakeTeamsCommand helper,
JoinPastedMeeting (body of JoinMeetingCommand), ExtractMeetingTitle
(already-tested static), PollTeamsMeetingState (the 1Hz UIA probe
formerly inlined in OnStatsTick).
* MainViewModel.PresetCommands.cs (108L, new) —
RequestApplyPresetOnStartup (CLI hook), LoadPendingPresetFromPreferences
(called by InitializeAsync), TryAutoApplyPendingPreset (the reconcile
step), and the _pendingPreset* private-field set that backs the path.
* MainViewModel.BulkCommands.cs (149L, new) — EnableAllOnlineAsync,
StopAllIsosAsync (with the default-No confirmation dialog),
SnapshotAll. RecordingCommands.cs from the original punch list is
intentionally absent — the recording surface was axed at 1d1ce6a;
what remains here is bulk-state ops across the participants
collection (note in the file header).
Why partial-class instead of helper-services or composed objects: every
extracted method touches the same private dispatcher / controller /
participants / toast state. Composing would require either passing
those references in (verbose call sites) or extracting them to a
shared private context object (boilerplate). Partial gives us
file-level separation without spreading the state contract.
ExtractMeetingTitle stays internal-static so the existing
MeetingTitleExtractionTests (10 cases) keep finding it. Build clean;
56 App + 104 Engine tests still pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 19:31:49 -04:00
// Auto-apply last preset bookkeeping. We don't apply here —
// participants haven't been discovered yet — instead we record
// the intent and let OnParticipantsChanged trigger the apply
// once the meeting has populated. Implementation in
// MainViewModel.PresetCommands.cs.
LoadPendingPresetFromPreferences ( ) ;
2026-05-07 11:40:06 -04:00
}
private void OnParticipantsChanged ( IReadOnlyList < Participant > incoming )
{
var seenIds = new HashSet < Guid > ( ) ;
feat(ui): rebuild MainWindow with Stone-theme design system
Adopts the design language from Dammyjay93/interface-design: warm Stone neutrals, accent orange (#EA580C), borders-only depth (no shadows), 8px spacing grid, all-caps section labels, mono typography for machine names and timecodes.
Themes/StoneTheme.xaml is the single source of truth for design tokens (color brushes, typography styles, spacing) plus restyled control templates (Button, TextBox, ComboBox, ComboBoxItem, CheckBox, DataGrid + DataGridColumnHeader / DataGridRow / DataGridCell, ScrollBar). MainWindow consumes the tokens and is laid out with a header (title + status pill), section-headed Settings sidebar (Output Format / NDI Network / Display), card-wrapped participant list, and a mono status footer.
Settings sidebar surfaces the new NDI group configuration (discovery + output) and a Hide-(Local) checkbox. The latter filters the user's own self-preview from the participants list at the MainViewModel layer (HideLocalSelf=true by default) so operators don't accidentally route their own preview as an ISO. Apply Changes round-trips both FrameProcessingSettings and NdiGroupSettings through the controller in one go.
2026-05-07 23:58:02 -04:00
var hideLocal = Settings . HideLocalSelf ;
2026-05-10 09:41:28 -04:00
var autoDisable = Settings . AutoDisableOnDeparture ;
2026-05-07 11:40:06 -04:00
foreach ( var p in incoming )
{
feat(ui): rebuild MainWindow with Stone-theme design system
Adopts the design language from Dammyjay93/interface-design: warm Stone neutrals, accent orange (#EA580C), borders-only depth (no shadows), 8px spacing grid, all-caps section labels, mono typography for machine names and timecodes.
Themes/StoneTheme.xaml is the single source of truth for design tokens (color brushes, typography styles, spacing) plus restyled control templates (Button, TextBox, ComboBox, ComboBoxItem, CheckBox, DataGrid + DataGridColumnHeader / DataGridRow / DataGridCell, ScrollBar). MainWindow consumes the tokens and is laid out with a header (title + status pill), section-headed Settings sidebar (Output Format / NDI Network / Display), card-wrapped participant list, and a mono status footer.
Settings sidebar surfaces the new NDI group configuration (discovery + output) and a Hide-(Local) checkbox. The latter filters the user's own self-preview from the participants list at the MainViewModel layer (HideLocalSelf=true by default) so operators don't accidentally route their own preview as an ISO. Apply Changes round-trips both FrameProcessingSettings and NdiGroupSettings through the controller in one go.
2026-05-07 23:58:02 -04:00
// 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 ;
2026-05-07 11:40:06 -04:00
seenIds . Add ( p . Id ) ;
if ( _byId . TryGetValue ( p . Id , out var vm ) )
{
2026-05-10 09:41:28 -04:00
var wasOnline = vm . IsOnline ;
2026-05-07 11:40:06 -04:00
vm . Update ( p ) ;
2026-05-10 09:41:28 -04:00
// 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" ) ;
}
}
2026-05-07 11:40:06 -04:00
}
else
{
2026-05-10 21:08:40 -04:00
vm = new ParticipantViewModel ( _controller , p , Toast ) ;
2026-05-07 11:40:06 -04:00
_byId [ p . Id ] = vm ;
Participants . Add ( vm ) ;
}
}
feat(ui): rebuild MainWindow with Stone-theme design system
Adopts the design language from Dammyjay93/interface-design: warm Stone neutrals, accent orange (#EA580C), borders-only depth (no shadows), 8px spacing grid, all-caps section labels, mono typography for machine names and timecodes.
Themes/StoneTheme.xaml is the single source of truth for design tokens (color brushes, typography styles, spacing) plus restyled control templates (Button, TextBox, ComboBox, ComboBoxItem, CheckBox, DataGrid + DataGridColumnHeader / DataGridRow / DataGridCell, ScrollBar). MainWindow consumes the tokens and is laid out with a header (title + status pill), section-headed Settings sidebar (Output Format / NDI Network / Display), card-wrapped participant list, and a mono status footer.
Settings sidebar surfaces the new NDI group configuration (discovery + output) and a Hide-(Local) checkbox. The latter filters the user's own self-preview from the participants list at the MainViewModel layer (HideLocalSelf=true by default) so operators don't accidentally route their own preview as an ISO. Apply Changes round-trips both FrameProcessingSettings and NdiGroupSettings through the controller in one go.
2026-05-07 23:58:02 -04:00
// Remove participants no longer present (or now hidden by the filter).
2026-05-07 11:40:06 -04:00
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 ) ;
}
}
2026-05-10 09:41:28 -04:00
// 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 ( ) ;
}
}
feat(ui): rebuild MainWindow with Stone-theme design system
Adopts the design language from Dammyjay93/interface-design: warm Stone neutrals, accent orange (#EA580C), borders-only depth (no shadows), 8px spacing grid, all-caps section labels, mono typography for machine names and timecodes.
Themes/StoneTheme.xaml is the single source of truth for design tokens (color brushes, typography styles, spacing) plus restyled control templates (Button, TextBox, ComboBox, ComboBoxItem, CheckBox, DataGrid + DataGridColumnHeader / DataGridRow / DataGridCell, ScrollBar). MainWindow consumes the tokens and is laid out with a header (title + status pill), section-headed Settings sidebar (Output Format / NDI Network / Display), card-wrapped participant list, and a mono status footer.
Settings sidebar surfaces the new NDI group configuration (discovery + output) and a Hide-(Local) checkbox. The latter filters the user's own self-preview from the participants list at the MainViewModel layer (HideLocalSelf=true by default) so operators don't accidentally route their own preview as an ISO. Apply Changes round-trips both FrameProcessingSettings and NdiGroupSettings through the controller in one go.
2026-05-07 23:58:02 -04:00
private static bool IsLocalSelf ( Participant p ) = >
string . Equals ( p . DisplayName , "(Local)" , StringComparison . Ordinal ) ;
2026-05-07 11:40:06 -04:00
public void Dispose ( )
{
2026-05-08 00:52:44 -04:00
_statsTimer . Stop ( ) ;
_statsTimer . Tick - = OnStatsTick ;
2026-05-07 11:40:06 -04:00
_participantsSub . Dispose ( ) ;
_alertsSub . Dispose ( ) ;
}
}