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