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;
///
/// Per-row view model for a participant in the participant list.
/// Wraps a domain and exposes ISO toggle and naming commands.
///
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;
/// Thumbnail dimensions. 16:9 ratio matches the typical Teams output aspect.
private const int ThumbnailWidth = 160;
private const int ThumbnailHeight = 90;
///
/// Live preview of the most recent processed frame, scaled to ×
/// . Updated by on the UI
/// thread, called from MainViewModel's stats tick. Null until the first frame arrives.
///
public WriteableBitmap? Thumbnail
{
get => _thumbnail;
private set
{
if (SetField(ref _thumbnail, value))
OnPropertyChanged(nameof(HasThumbnail));
}
}
private WriteableBitmap? _thumbnail;
/// True when is non-null. Bound to Visibility in XAML.
public bool HasThumbnail => _thumbnail is not null;
///
/// 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.
///
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);
}
///
/// Encode the most recent
/// as a PNG and write it to %USERPROFILE%\Pictures\TeamsISO\. 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.
///
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}");
}
}
///
/// 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.
///
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;
}
}
///
/// 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).
///
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();
}
}
///
/// Inline nearest-neighbor scaler — copies one downsampled BGRA frame into the
/// WriteableBitmap's back buffer. We don't reuse
/// 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.
///
private static void ScaleNearestNeighborBgra(
ReadOnlySpan 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 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);
}
///
/// 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.
///
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 = "—";
/// Number of frames the receiver has captured so far.
public long FramesIn { get => _framesIn; set => SetField(ref _framesIn, value); }
/// Number of frames the sender has emitted so far.
public long FramesOut { get => _framesOut; set => SetField(ref _framesOut, value); }
/// Frames dropped by the closest-frame strategy when the receiver outpaces the processor.
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;
///
/// 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).
///
public double DisplayedAudioLevel
{
get => _displayedAudioLevel;
private set => SetField(ref _displayedAudioLevel, value);
}
///
/// 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.
///
public double AudioLevelWidthPercent => Math.Clamp(_displayedAudioLevel * 100, 0, 100);
/// Human-readable pipeline state ("Receiving", "Error", "—").
public string StateLabel { get => _stateLabel; set => SetField(ref _stateLabel, value); }
/// Resource key of the brush to color the state pill with.
public string StateColor { get => _stateColor; set => SetField(ref _stateColor, value); }
/// Source resolution as "WxH", or em-dash when no frames have been seen yet.
public string IncomingResolution { get => _incomingResolution; set => SetField(ref _incomingResolution, value); }
///
/// 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.
///
public string IncomingFps { get => _incomingFps; set => SetField(ref _incomingFps, value); }
/// Updates the live stats display from a controller-side snapshot.
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));
}
}
///
/// The NDI source name TeamsISO will broadcast this participant as. Prefers
/// the operator's when set; otherwise renders the
/// engine's default template (typically TEAMSISO_{guid}). Bound by
/// the v2 participants table's mono "output name" column.
///
public string OutputName =>
string.IsNullOrWhiteSpace(_customName)
? Services.OutputNameTemplate.Render(
Services.OutputNameTemplate.Get(),
_participant.Id,
_participant.DisplayName)
: _customName;
public AsyncRelayCommand ToggleIsoCommand { get; }
/// Copy the participant's NDI source FullName to the clipboard. Useful for pasting into Studio Monitor.
public RelayCommand CopySourceNameCommand { get; }
/// Open a non-modal floating preview window for this participant. Multi-monitor friendly.
public RelayCommand OpenPreviewCommand { get; }
public RelayCommand SaveSnapshotCommand { get; }
///
/// 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.
///
public AsyncRelayCommand RestartIsoCommand { get; }
/// Refreshes the underlying participant data (called when the controller emits an updated list).
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;
}
}
}