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; } } }