teamsiso/src/TeamsISO.App/ViewModels/ParticipantViewModel.cs
Zac Gaetano d282e1b0f8
Some checks failed
CI / build-and-test (push) Failing after 30s
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

509 lines
21 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.Windows;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using TeamsISO.Engine.Controller;
using TeamsISO.Engine.Domain;
using TeamsISO.Engine.Pipeline;
using IsoHealthStats = TeamsISO.Engine.Domain.IsoHealthStats;
namespace TeamsISO.App.ViewModels;
/// <summary>
/// Per-row view model for a participant in the participant list.
/// Wraps a domain <see cref="Participant"/> and exposes ISO toggle and naming commands.
/// </summary>
public sealed class ParticipantViewModel : ObservableObject
{
private readonly IIsoController _controller;
private readonly ToastViewModel? _toast;
private Participant _participant;
private bool _isEnabled;
private bool _isProcessing;
private string _customName;
/// <summary>Thumbnail dimensions. 16:9 ratio matches the typical Teams output aspect.</summary>
private const int ThumbnailWidth = 160;
private const int ThumbnailHeight = 90;
/// <summary>
/// Live preview of the most recent processed frame, scaled to <see cref="ThumbnailWidth"/>×
/// <see cref="ThumbnailHeight"/>. Updated by <see cref="UpdateThumbnail"/> on the UI
/// thread, called from MainViewModel's stats tick. Null until the first frame arrives.
/// </summary>
public WriteableBitmap? Thumbnail
{
get => _thumbnail;
private set
{
if (SetField(ref _thumbnail, value))
OnPropertyChanged(nameof(HasThumbnail));
}
}
private WriteableBitmap? _thumbnail;
/// <summary>True when <see cref="Thumbnail"/> is non-null. Bound to Visibility in XAML.</summary>
public bool HasThumbnail => _thumbnail is not null;
/// <summary>
/// True when this participant is currently the loudest among the live
/// set — set by MainViewModel at the 1Hz stats tick. Bound to a cyan
/// border accent on the DataGrid row so operators can spot who's
/// speaking without watching every VU bar individually.
/// </summary>
public bool IsActiveSpeaker
{
get => _isActiveSpeaker;
internal set => SetField(ref _isActiveSpeaker, value);
}
private bool _isActiveSpeaker;
public ParticipantViewModel(IIsoController controller, Participant participant, ToastViewModel? toast = null)
{
_controller = controller;
_toast = toast;
_participant = participant;
_customName = string.Empty;
ToggleIsoCommand = new AsyncRelayCommand(ToggleIsoAsync, () => !_isProcessing);
CopySourceNameCommand = new RelayCommand(() =>
{
try
{
var src = _participant.CurrentSource?.FullName;
if (!string.IsNullOrEmpty(src))
System.Windows.Clipboard.SetText(src);
}
catch
{
// Clipboard occasionally errors when something else has it locked;
// best-effort, no user-visible failure.
}
});
OpenPreviewCommand = new RelayCommand(() =>
{
// Non-modal — operator can open multiple previews at once.
// Owner is the main window so the preview centers nicely and
// closes cleanly when the host exits.
var preview = new PreviewWindow(_controller, Id, DisplayName);
preview.Show();
});
RestartIsoCommand = new AsyncRelayCommand(RestartIsoAsync,
() => _isEnabled && !_isProcessing);
SaveSnapshotCommand = new RelayCommand(SaveSnapshot, () => _isEnabled);
}
/// <summary>
/// Encode the most recent <see cref="IsoPipeline.LatestProcessedFrame"/>
/// as a PNG and write it to <c>%USERPROFILE%\Pictures\TeamsISO\</c>. Used
/// by the participants' context menu for grabbing a stillframe — useful
/// for highlight reels, social posts, bug reports. Best-effort: a no-op
/// + warn-toast if no frame is currently available (pipeline just spun
/// up, or recording isn't enabled). Filename includes participant name
/// + timestamp so back-to-back snapshots don't collide.
/// </summary>
private void SaveSnapshot()
{
try
{
var frame = _controller.GetLatestProcessedFrame(Id);
if (frame is null || frame.Pixels.IsEmpty)
{
_toast?.Warn("No frame available yet — try again in a few seconds");
return;
}
var dir = System.IO.Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.MyPictures),
"TeamsISO");
System.IO.Directory.CreateDirectory(dir);
var safeName = string.Join("_", DisplayName.Split(System.IO.Path.GetInvalidFileNameChars()));
if (string.IsNullOrWhiteSpace(safeName)) safeName = "participant";
var path = System.IO.Path.Combine(dir,
$"{safeName}_{DateTimeOffset.Now:yyyyMMdd_HHmmss}.png");
// ProcessedFrame is BGRA32, top-down. WriteableBitmap with
// Bgra32 pixel format takes the bytes verbatim.
var stride = frame.Width * 4;
var bmp = new System.Windows.Media.Imaging.WriteableBitmap(
frame.Width, frame.Height,
96, 96,
System.Windows.Media.PixelFormats.Bgra32, null);
bmp.WritePixels(
new System.Windows.Int32Rect(0, 0, frame.Width, frame.Height),
frame.Pixels.ToArray(), stride, 0);
using var fs = new System.IO.FileStream(path, System.IO.FileMode.Create);
var encoder = new System.Windows.Media.Imaging.PngBitmapEncoder();
encoder.Frames.Add(System.Windows.Media.Imaging.BitmapFrame.Create(bmp));
encoder.Save(fs);
_toast?.Show($"Saved snapshot: {System.IO.Path.GetFileName(path)}");
}
catch (Exception ex)
{
_toast?.Warn($"Snapshot failed: {ex.Message}");
}
}
/// <summary>
/// Disable + re-enable this pipeline. Brief delay between for the engine
/// to fully tear down before we ask for a fresh sender. The processing
/// flag suppresses the toggle button + restart action while in flight.
/// </summary>
private async Task RestartIsoAsync()
{
if (!IsEnabled) return;
IsProcessing = true;
try
{
await _controller.DisableIsoAsync(Id, CancellationToken.None);
// Short delay so any in-flight NDI sender disposal completes before
// we ask CreateSender for the same name. Empirically 250ms is plenty.
await Task.Delay(250);
var resolvedName = string.IsNullOrWhiteSpace(_customName)
? Services.OutputNameTemplate.Render(
Services.OutputNameTemplate.Get(),
Id,
DisplayName)
: _customName;
bool? recordOverride = _recordToDisk ? null : false;
await _controller.EnableIsoAsync(Id, resolvedName, recordOverride, CancellationToken.None);
// IsEnabled is already true (we never set it false); re-fire the
// change notification so any UI bindings sensitive to a transition
// observe the restart.
OnPropertyChanged(nameof(IsEnabled));
}
finally
{
IsProcessing = false;
}
}
/// <summary>
/// Refresh the preview thumbnail from the engine's most recent processed frame.
/// Must be called on the UI thread (MainViewModel's stats DispatcherTimer is fine).
/// Allocates the WriteableBitmap lazily on the first call so we don't pay for it
/// on participants that never have an ISO enabled. Skips work if the engine has
/// no frame yet (no pipeline, or pipeline still warming up).
/// </summary>
public void UpdateThumbnail(ProcessedFrame? frame)
{
if (frame is null || frame.Pixels.IsEmpty)
{
// Don't clear a previously-rendered thumbnail on transient null reads —
// a brief gap between frames shouldn't visibly blank the preview. The
// Thumbnail is only set to null when the pipeline genuinely stops, which
// we observe by IsEnabled flipping false elsewhere.
return;
}
// Defense in depth: if the engine ever hands us a frame whose pixel buffer
// doesn't match the declared dimensions (would imply an engine bug), don't
// crash the UI on IndexOutOfRangeException — silently skip this update and
// wait for a sane frame.
var expectedBytes = frame.Width * frame.Height * 4;
if (frame.Width <= 0 || frame.Height <= 0 || frame.Pixels.Length < expectedBytes)
return;
if (_thumbnail is null)
{
// 96 DPI matches the WPF default; PixelFormats.Bgra32 matches the
// engine's BGRA pixel layout so the WritePixels call is a memcpy.
// The setter fires PropertyChanged for both Thumbnail and HasThumbnail
// so the DataGrid's Visibility bindings flip in the same change cycle.
Thumbnail = new WriteableBitmap(
ThumbnailWidth, ThumbnailHeight, 96, 96, PixelFormats.Bgra32, null);
}
var thumb = _thumbnail!;
thumb.Lock();
try
{
ScaleNearestNeighborBgra(
src: frame.Pixels.Span,
srcW: frame.Width,
srcH: frame.Height,
dst: thumb.BackBuffer,
dstStride: thumb.BackBufferStride,
dstW: ThumbnailWidth,
dstH: ThumbnailHeight);
thumb.AddDirtyRect(new Int32Rect(0, 0, ThumbnailWidth, ThumbnailHeight));
}
finally
{
thumb.Unlock();
}
}
/// <summary>
/// Inline nearest-neighbor scaler — copies one downsampled BGRA frame into the
/// WriteableBitmap's back buffer. We don't reuse <see cref="ManagedNearestNeighborFrameScaler"/>
/// because it allocates a managed buffer per scale; here we want to write
/// directly into the WriteableBitmap's pinned native memory to avoid a copy.
///
/// The arithmetic is the same: for each destination pixel, compute the source
/// pixel via integer ratio and copy 4 bytes. At 1Hz × 10 thumbnails that's
/// ~144,000 pixel reads per second — negligible CPU.
/// </summary>
private static void ScaleNearestNeighborBgra(
ReadOnlySpan<byte> src, int srcW, int srcH,
IntPtr dst, int dstStride, int dstW, int dstH)
{
// Pre-compute the x-ratio table once per call so the inner loop is just two
// multiplies and a memcpy. Java-style fixed-point would be faster but for
// 160×90 the overhead is irrelevant.
Span<int> srcXFor = stackalloc int[dstW];
for (var x = 0; x < dstW; x++) srcXFor[x] = x * srcW / dstW;
unsafe
{
var dstPtr = (byte*)dst;
var srcStride = srcW * 4;
for (var y = 0; y < dstH; y++)
{
var srcY = y * srcH / dstH;
var srcRow = srcY * srcStride;
var dstRow = y * dstStride;
for (var x = 0; x < dstW; x++)
{
var srcOff = srcRow + srcXFor[x] * 4;
var dstOff = dstRow + x * 4;
dstPtr[dstOff + 0] = src[srcOff + 0];
dstPtr[dstOff + 1] = src[srcOff + 1];
dstPtr[dstOff + 2] = src[srcOff + 2];
dstPtr[dstOff + 3] = src[srcOff + 3];
}
}
}
}
public Guid Id => _participant.Id;
public string DisplayName => _participant.DisplayName;
public string SourceMachine => _participant.CurrentSource?.MachineName ?? "(disconnected)";
public string SourceFullName => _participant.CurrentSource?.FullName ?? "(disconnected)";
public bool IsOnline => _participant.CurrentSource is not null;
public bool IsEnabled
{
get => _isEnabled;
set => SetField(ref _isEnabled, value);
}
/// <summary>
/// When true (default), the operator wants this participant's ISO recorded
/// when the global recording toggle is on. When false, this participant is
/// opted out of recording even with global on. The flag is read at the
/// EnableIsoAsync call so changing it after enabling has no effect on a
/// running pipeline; operator must disable + re-enable to apply.
/// </summary>
public bool RecordToDisk
{
get => _recordToDisk;
set => SetField(ref _recordToDisk, value);
}
private bool _recordToDisk = true;
private long _framesIn;
private long _framesOut;
private long _framesDropped;
private string _incomingResolution = "—";
private string _incomingFps = "—";
/// <summary>Number of frames the receiver has captured so far.</summary>
public long FramesIn { get => _framesIn; set => SetField(ref _framesIn, value); }
/// <summary>Number of frames the sender has emitted so far.</summary>
public long FramesOut { get => _framesOut; set => SetField(ref _framesOut, value); }
/// <summary>Frames dropped by the closest-frame strategy when the receiver outpaces the processor.</summary>
public long FramesDropped { get => _framesDropped; set => SetField(ref _framesDropped, value); }
private string _stateLabel = "—";
private string _stateColor = "Wd.Text.Tertiary";
private double _peakAudioLevel;
private double _displayedAudioLevel;
private DateTimeOffset _lastPeakAt;
/// <summary>
/// Smoothed audio level for display (0.0 to 1.0). Decays toward 0 between
/// peak updates so the VU bar feels alive even when audio is sparse. The
/// raw peak from the engine arrives at the 1Hz stats poll; we interpolate
/// down between polls in the property getter (technically a slight
/// abstraction leak but simpler than wiring another timer).
/// </summary>
public double DisplayedAudioLevel
{
get => _displayedAudioLevel;
private set => SetField(ref _displayedAudioLevel, value);
}
/// <summary>
/// VU-bar fill width as a 0-100 number, suitable for a Width binding on
/// a fixed-size 100-px-wide indicator. Returns the displayed (decayed)
/// audio level scaled to [0, 100]; 0 when no recent audio has been seen.
/// </summary>
public double AudioLevelWidthPercent => Math.Clamp(_displayedAudioLevel * 100, 0, 100);
/// <summary>Human-readable pipeline state ("Receiving", "Error", "—").</summary>
public string StateLabel { get => _stateLabel; set => SetField(ref _stateLabel, value); }
/// <summary>Resource key of the brush to color the state pill with.</summary>
public string StateColor { get => _stateColor; set => SetField(ref _stateColor, value); }
/// <summary>Source resolution as "WxH", or em-dash when no frames have been seen yet.</summary>
public string IncomingResolution { get => _incomingResolution; set => SetField(ref _incomingResolution, value); }
/// <summary>
/// Live incoming framerate as "59.94 fps", or em-dash when fewer than 2 frames
/// have been observed since the pipeline started. Computed in the engine via a
/// 30-frame moving window.
/// </summary>
public string IncomingFps { get => _incomingFps; set => SetField(ref _incomingFps, value); }
/// <summary>Updates the live stats display from a controller-side snapshot.</summary>
public void UpdateStats(IsoHealthStats stats)
{
// Audio level: take the new peak when it's higher than what we're
// currently displaying (instant attack), otherwise decay toward zero
// (slow release). 0.7 multiplier per 1Hz tick = ~half-life of ~1.6s,
// which feels like a real VU meter. When the engine starts feeding
// real PeakAudioLevel values, this code starts working without
// further changes.
if (stats.PeakAudioLevel > _displayedAudioLevel)
{
_displayedAudioLevel = stats.PeakAudioLevel;
_lastPeakAt = DateTimeOffset.UtcNow;
}
else
{
_displayedAudioLevel *= 0.7;
if (_displayedAudioLevel < 0.01) _displayedAudioLevel = 0;
}
_peakAudioLevel = stats.PeakAudioLevel;
OnPropertyChanged(nameof(DisplayedAudioLevel));
OnPropertyChanged(nameof(AudioLevelWidthPercent));
FramesIn = stats.FramesIn;
FramesOut = stats.FramesOut;
FramesDropped = stats.FramesDropped;
IncomingResolution = stats.IncomingWidth > 0 && stats.IncomingHeight > 0
? $"{stats.IncomingWidth}×{stats.IncomingHeight}"
: "—";
IncomingFps = stats.IncomingFps > 0
? $"{stats.IncomingFps:0.0} fps"
: "—";
(StateLabel, StateColor) = stats.State switch
{
IsoState.Receiving => ("LIVE", "Wd.Status.Live"),
IsoState.Sending => ("LIVE", "Wd.Status.Live"),
IsoState.NoSignal => ("NO SIGNAL", "Wd.Status.Warn"),
IsoState.Error => ("ERROR", "Wd.Status.Error"),
IsoState.Idle => (IsEnabled ? "STARTING" : "—", "Wd.Text.Tertiary"),
_ => ("—", "Wd.Text.Tertiary"),
};
}
public bool IsProcessing
{
get => _isProcessing;
private set
{
if (SetField(ref _isProcessing, value))
ToggleIsoCommand.RaiseCanExecuteChanged();
}
}
public string CustomName
{
get => _customName;
set
{
if (SetField(ref _customName, value))
OnPropertyChanged(nameof(OutputName));
}
}
/// <summary>
/// The NDI source name TeamsISO will broadcast this participant as. Prefers
/// the operator's <see cref="CustomName"/> when set; otherwise renders the
/// engine's default template (typically <c>TEAMSISO_{guid}</c>). Bound by
/// the v2 participants table's mono "output name" column.
/// </summary>
public string OutputName =>
string.IsNullOrWhiteSpace(_customName)
? Services.OutputNameTemplate.Render(
Services.OutputNameTemplate.Get(),
_participant.Id,
_participant.DisplayName)
: _customName;
public AsyncRelayCommand ToggleIsoCommand { get; }
/// <summary>Copy the participant's NDI source FullName to the clipboard. Useful for pasting into Studio Monitor.</summary>
public RelayCommand CopySourceNameCommand { get; }
/// <summary>Open a non-modal floating preview window for this participant. Multi-monitor friendly.</summary>
public RelayCommand OpenPreviewCommand { get; }
public RelayCommand SaveSnapshotCommand { get; }
/// <summary>
/// Restart the pipeline for this participant: disable, brief pause, re-enable.
/// Useful when a single feed flakes (drops climb, framerate jitters) without
/// affecting other ISOs. No-op when the pipeline isn't currently enabled.
/// </summary>
public AsyncRelayCommand RestartIsoCommand { get; }
/// <summary>Refreshes the underlying participant data (called when the controller emits an updated list).</summary>
public void Update(Participant updated)
{
_participant = updated;
OnPropertyChanged(nameof(DisplayName));
OnPropertyChanged(nameof(SourceMachine));
OnPropertyChanged(nameof(SourceFullName));
OnPropertyChanged(nameof(IsOnline));
}
private async Task ToggleIsoAsync()
{
IsProcessing = true;
try
{
if (IsEnabled)
{
await _controller.DisableIsoAsync(Id, CancellationToken.None);
IsEnabled = false;
}
else
{
// Resolve the output name: explicit per-participant CustomName
// wins; otherwise expand the operator's template (defaults to
// "TEAMSISO_{guid}" which matches the engine's old hard-coded
// behavior). Passing the rendered name to EnableIsoAsync as
// customName overrides the engine's DefaultOutputName path.
var resolvedName = string.IsNullOrWhiteSpace(_customName)
? Services.OutputNameTemplate.Render(
Services.OutputNameTemplate.Get(),
Id,
DisplayName)
: _customName;
// Per-participant recording opt-out: when RecordToDisk is false,
// pass a false override so the engine doesn't attach a recorder
// even if global recording is on.
bool? recordOverride = _recordToDisk ? null : false;
await _controller.EnableIsoAsync(
Id,
resolvedName,
recordOverride,
CancellationToken.None);
IsEnabled = true;
}
}
finally
{
IsProcessing = false;
}
}
}