2026-05-10 09:41:30 -04:00
|
|
|
|
using System.Windows;
|
|
|
|
|
|
using System.Windows.Media;
|
|
|
|
|
|
using System.Windows.Media.Imaging;
|
2026-05-07 11:39:46 -04:00
|
|
|
|
using TeamsISO.Engine.Controller;
|
|
|
|
|
|
using TeamsISO.Engine.Domain;
|
2026-05-10 09:41:30 -04:00
|
|
|
|
using TeamsISO.Engine.Pipeline;
|
2026-05-08 00:52:44 -04:00
|
|
|
|
using IsoHealthStats = TeamsISO.Engine.Domain.IsoHealthStats;
|
2026-05-07 11:39:46 -04:00
|
|
|
|
|
|
|
|
|
|
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;
|
2026-05-10 21:08:40 -04:00
|
|
|
|
private readonly ToastViewModel? _toast;
|
2026-05-07 11:39:46 -04:00
|
|
|
|
private Participant _participant;
|
|
|
|
|
|
private bool _isEnabled;
|
|
|
|
|
|
private bool _isProcessing;
|
|
|
|
|
|
private string _customName;
|
|
|
|
|
|
|
2026-05-10 09:41:30 -04:00
|
|
|
|
/// <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;
|
|
|
|
|
|
|
2026-05-10 21:28:09 -04:00
|
|
|
|
/// <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;
|
|
|
|
|
|
|
2026-05-10 21:08:40 -04:00
|
|
|
|
public ParticipantViewModel(IIsoController controller, Participant participant, ToastViewModel? toast = null)
|
2026-05-07 11:39:46 -04:00
|
|
|
|
{
|
|
|
|
|
|
_controller = controller;
|
2026-05-10 21:08:40 -04:00
|
|
|
|
_toast = toast;
|
2026-05-07 11:39:46 -04:00
|
|
|
|
_participant = participant;
|
|
|
|
|
|
_customName = string.Empty;
|
|
|
|
|
|
ToggleIsoCommand = new AsyncRelayCommand(ToggleIsoAsync, () => !_isProcessing);
|
2026-05-10 09:41:30 -04:00
|
|
|
|
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);
|
2026-05-10 21:08:40 -04:00
|
|
|
|
|
|
|
|
|
|
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}");
|
|
|
|
|
|
}
|
2026-05-10 09:41:30 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// <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];
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-05-07 11:39:46 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-10 09:41:30 -04:00
|
|
|
|
/// <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;
|
|
|
|
|
|
|
2026-05-08 00:52:44 -04:00
|
|
|
|
private long _framesIn;
|
|
|
|
|
|
private long _framesOut;
|
feat: app icon, FPS, drops counter, --version, About dialog, Stop Teams toggle
Six related polish items, all building on tonight's groundwork.
1. App icon: teamsiso.ico generated from dragon-mark.png at 7 sizes (16-256), wired as ApplicationIcon in the WPF csproj, MainWindow.Icon, AboutWindow.Icon, and ARPPRODUCTICON in the WiX MSI. Taskbar / window / Add-Remove-Programs all show the dragon mark now.
2. Running incoming FPS: ring buffer of last 30 frame timestamps in IsoPipeline; ComputeFps() returns moving-average rate. Surfaced on IsoHealthStats.IncomingFps and shown in the Source column of the participants DataGrid as 'WxH · 59.94 fps'. Resets cleanly on every supervisor restart.
3. Drops counter: FrameProcessor.Stats already aggregated FramesDropped (closest-frame strategy when the receiver outpaces the processor) and FramesDuplicated; just plumbed _liveProcessor through IsoPipeline so GetStats() can read them. Exposed in the Live column under the in/out counters as a coral-tinted 'drop N'.
4. Console --version flag: prints engine version (with embedded git SHA), .NET version, OS, NDI runtime banner, expected prefix, exit-code legend, plus a wilddragon.net link. Useful for support tickets.
5. About dialog: chromeless modal with the dragon mark + version / .NET / OS / NDI runtime fields and a link to wilddragon.net. Triggered by clicking the rail logo.
6. Teams launcher Stop toggle: TeamsLauncher gains IsRunning() and StopAll(). The rail's Teams button now toggles — if Teams is up, ask to close all Teams windows via WM_CLOSE; otherwise launch as before. Confirms before stopping so we don't kill the user's call mid-transition.
Tests: 74/74 unit + 9/9 NDI integration green throughout. MSI builds clean and now embeds the dragon icon for ARP.
2026-05-08 13:50:19 -04:00
|
|
|
|
private long _framesDropped;
|
2026-05-08 00:52:44 -04:00
|
|
|
|
private string _incomingResolution = "—";
|
feat: app icon, FPS, drops counter, --version, About dialog, Stop Teams toggle
Six related polish items, all building on tonight's groundwork.
1. App icon: teamsiso.ico generated from dragon-mark.png at 7 sizes (16-256), wired as ApplicationIcon in the WPF csproj, MainWindow.Icon, AboutWindow.Icon, and ARPPRODUCTICON in the WiX MSI. Taskbar / window / Add-Remove-Programs all show the dragon mark now.
2. Running incoming FPS: ring buffer of last 30 frame timestamps in IsoPipeline; ComputeFps() returns moving-average rate. Surfaced on IsoHealthStats.IncomingFps and shown in the Source column of the participants DataGrid as 'WxH · 59.94 fps'. Resets cleanly on every supervisor restart.
3. Drops counter: FrameProcessor.Stats already aggregated FramesDropped (closest-frame strategy when the receiver outpaces the processor) and FramesDuplicated; just plumbed _liveProcessor through IsoPipeline so GetStats() can read them. Exposed in the Live column under the in/out counters as a coral-tinted 'drop N'.
4. Console --version flag: prints engine version (with embedded git SHA), .NET version, OS, NDI runtime banner, expected prefix, exit-code legend, plus a wilddragon.net link. Useful for support tickets.
5. About dialog: chromeless modal with the dragon mark + version / .NET / OS / NDI runtime fields and a link to wilddragon.net. Triggered by clicking the rail logo.
6. Teams launcher Stop toggle: TeamsLauncher gains IsRunning() and StopAll(). The rail's Teams button now toggles — if Teams is up, ask to close all Teams windows via WM_CLOSE; otherwise launch as before. Confirms before stopping so we don't kill the user's call mid-transition.
Tests: 74/74 unit + 9/9 NDI integration green throughout. MSI builds clean and now embeds the dragon icon for ARP.
2026-05-08 13:50:19 -04:00
|
|
|
|
private string _incomingFps = "—";
|
2026-05-08 00:52:44 -04:00
|
|
|
|
|
|
|
|
|
|
/// <summary>Number of frames the receiver has captured so far.</summary>
|
2026-05-08 00:55:57 -04:00
|
|
|
|
public long FramesIn { get => _framesIn; set => SetField(ref _framesIn, value); }
|
2026-05-08 00:52:44 -04:00
|
|
|
|
|
|
|
|
|
|
/// <summary>Number of frames the sender has emitted so far.</summary>
|
2026-05-08 00:55:57 -04:00
|
|
|
|
public long FramesOut { get => _framesOut; set => SetField(ref _framesOut, value); }
|
2026-05-08 00:52:44 -04:00
|
|
|
|
|
feat: app icon, FPS, drops counter, --version, About dialog, Stop Teams toggle
Six related polish items, all building on tonight's groundwork.
1. App icon: teamsiso.ico generated from dragon-mark.png at 7 sizes (16-256), wired as ApplicationIcon in the WPF csproj, MainWindow.Icon, AboutWindow.Icon, and ARPPRODUCTICON in the WiX MSI. Taskbar / window / Add-Remove-Programs all show the dragon mark now.
2. Running incoming FPS: ring buffer of last 30 frame timestamps in IsoPipeline; ComputeFps() returns moving-average rate. Surfaced on IsoHealthStats.IncomingFps and shown in the Source column of the participants DataGrid as 'WxH · 59.94 fps'. Resets cleanly on every supervisor restart.
3. Drops counter: FrameProcessor.Stats already aggregated FramesDropped (closest-frame strategy when the receiver outpaces the processor) and FramesDuplicated; just plumbed _liveProcessor through IsoPipeline so GetStats() can read them. Exposed in the Live column under the in/out counters as a coral-tinted 'drop N'.
4. Console --version flag: prints engine version (with embedded git SHA), .NET version, OS, NDI runtime banner, expected prefix, exit-code legend, plus a wilddragon.net link. Useful for support tickets.
5. About dialog: chromeless modal with the dragon mark + version / .NET / OS / NDI runtime fields and a link to wilddragon.net. Triggered by clicking the rail logo.
6. Teams launcher Stop toggle: TeamsLauncher gains IsRunning() and StopAll(). The rail's Teams button now toggles — if Teams is up, ask to close all Teams windows via WM_CLOSE; otherwise launch as before. Confirms before stopping so we don't kill the user's call mid-transition.
Tests: 74/74 unit + 9/9 NDI integration green throughout. MSI builds clean and now embeds the dragon icon for ARP.
2026-05-08 13:50:19 -04:00
|
|
|
|
/// <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); }
|
|
|
|
|
|
|
feat(ui): empty-state, pipeline error/no-signal indicators, JetBrains Mono, tooltips
Four polish improvements aimed at production-floor usability.
1. Empty-state placeholder for the participants card. When Participants.Count == 0, the DataGrid is hidden in favor of a friendly 'Waiting for Teams' panel: faded dragon mark, headline, explainer, and a four-item checklist (Teams running? NDI broadcast on? Discovery group correct? Firewall clear?). New CountToVisibilityConverter (with optional 'empty' parameter to invert) drives both the placeholder and the DataGrid visibility from the same Participants.Count source.
2. Per-pipeline error / no-signal surfacing. IsoHealthStats grows an init-only State property populated from IsoPipeline.State. ParticipantViewModel.UpdateStats maps that to a StateLabel ('LIVE' / 'NO SIGNAL' / 'ERROR' / 'STARTING' / '—'). The ISO toggle button gains DataTriggers on StateLabel — coral-tinted '● ERROR' when the supervisor gives up, amber-tinted '● NO SIGNAL' when the slate threshold trips. Operators can see at a glance which pipelines are broken.
3. JetBrains Mono Variable v2.304 (OFL) bundled at Assets/Fonts/JetBrainsMono.ttf. Wd.Font.Mono now points at the embedded font so machine names, timecodes, and stat counters render in JetBrains Mono regardless of system fonts. Falls back to Cascadia Mono / Consolas if the resource is missing.
4. Tooltip pass over every interactive control in the settings panel (framerate / resolution / aspect / audio / discovery group / output group / hide-local checkbox / Apply button / per-row Output Name textbox / per-row ISO toggle). Operators learn affordances on hover instead of by trial and error.
Tests: 76/76 unit + 9/9 NDI integration green.
2026-05-08 19:32:19 -04:00
|
|
|
|
private string _stateLabel = "—";
|
|
|
|
|
|
private string _stateColor = "Wd.Text.Tertiary";
|
2026-05-10 09:41:30 -04:00
|
|
|
|
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);
|
feat(ui): empty-state, pipeline error/no-signal indicators, JetBrains Mono, tooltips
Four polish improvements aimed at production-floor usability.
1. Empty-state placeholder for the participants card. When Participants.Count == 0, the DataGrid is hidden in favor of a friendly 'Waiting for Teams' panel: faded dragon mark, headline, explainer, and a four-item checklist (Teams running? NDI broadcast on? Discovery group correct? Firewall clear?). New CountToVisibilityConverter (with optional 'empty' parameter to invert) drives both the placeholder and the DataGrid visibility from the same Participants.Count source.
2. Per-pipeline error / no-signal surfacing. IsoHealthStats grows an init-only State property populated from IsoPipeline.State. ParticipantViewModel.UpdateStats maps that to a StateLabel ('LIVE' / 'NO SIGNAL' / 'ERROR' / 'STARTING' / '—'). The ISO toggle button gains DataTriggers on StateLabel — coral-tinted '● ERROR' when the supervisor gives up, amber-tinted '● NO SIGNAL' when the slate threshold trips. Operators can see at a glance which pipelines are broken.
3. JetBrains Mono Variable v2.304 (OFL) bundled at Assets/Fonts/JetBrainsMono.ttf. Wd.Font.Mono now points at the embedded font so machine names, timecodes, and stat counters render in JetBrains Mono regardless of system fonts. Falls back to Cascadia Mono / Consolas if the resource is missing.
4. Tooltip pass over every interactive control in the settings panel (framerate / resolution / aspect / audio / discovery group / output group / hide-local checkbox / Apply button / per-row Output Name textbox / per-row ISO toggle). Operators learn affordances on hover instead of by trial and error.
Tests: 76/76 unit + 9/9 NDI integration green.
2026-05-08 19:32:19 -04:00
|
|
|
|
|
|
|
|
|
|
/// <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); }
|
|
|
|
|
|
|
2026-05-08 00:52:44 -04:00
|
|
|
|
/// <summary>Source resolution as "WxH", or em-dash when no frames have been seen yet.</summary>
|
2026-05-08 00:55:57 -04:00
|
|
|
|
public string IncomingResolution { get => _incomingResolution; set => SetField(ref _incomingResolution, value); }
|
2026-05-08 00:52:44 -04:00
|
|
|
|
|
feat: app icon, FPS, drops counter, --version, About dialog, Stop Teams toggle
Six related polish items, all building on tonight's groundwork.
1. App icon: teamsiso.ico generated from dragon-mark.png at 7 sizes (16-256), wired as ApplicationIcon in the WPF csproj, MainWindow.Icon, AboutWindow.Icon, and ARPPRODUCTICON in the WiX MSI. Taskbar / window / Add-Remove-Programs all show the dragon mark now.
2. Running incoming FPS: ring buffer of last 30 frame timestamps in IsoPipeline; ComputeFps() returns moving-average rate. Surfaced on IsoHealthStats.IncomingFps and shown in the Source column of the participants DataGrid as 'WxH · 59.94 fps'. Resets cleanly on every supervisor restart.
3. Drops counter: FrameProcessor.Stats already aggregated FramesDropped (closest-frame strategy when the receiver outpaces the processor) and FramesDuplicated; just plumbed _liveProcessor through IsoPipeline so GetStats() can read them. Exposed in the Live column under the in/out counters as a coral-tinted 'drop N'.
4. Console --version flag: prints engine version (with embedded git SHA), .NET version, OS, NDI runtime banner, expected prefix, exit-code legend, plus a wilddragon.net link. Useful for support tickets.
5. About dialog: chromeless modal with the dragon mark + version / .NET / OS / NDI runtime fields and a link to wilddragon.net. Triggered by clicking the rail logo.
6. Teams launcher Stop toggle: TeamsLauncher gains IsRunning() and StopAll(). The rail's Teams button now toggles — if Teams is up, ask to close all Teams windows via WM_CLOSE; otherwise launch as before. Confirms before stopping so we don't kill the user's call mid-transition.
Tests: 74/74 unit + 9/9 NDI integration green throughout. MSI builds clean and now embeds the dragon icon for ARP.
2026-05-08 13:50:19 -04:00
|
|
|
|
/// <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); }
|
|
|
|
|
|
|
2026-05-08 00:52:44 -04:00
|
|
|
|
/// <summary>Updates the live stats display from a controller-side snapshot.</summary>
|
|
|
|
|
|
public void UpdateStats(IsoHealthStats stats)
|
|
|
|
|
|
{
|
2026-05-10 09:41:30 -04:00
|
|
|
|
// 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));
|
|
|
|
|
|
|
2026-05-08 00:52:44 -04:00
|
|
|
|
FramesIn = stats.FramesIn;
|
|
|
|
|
|
FramesOut = stats.FramesOut;
|
feat: app icon, FPS, drops counter, --version, About dialog, Stop Teams toggle
Six related polish items, all building on tonight's groundwork.
1. App icon: teamsiso.ico generated from dragon-mark.png at 7 sizes (16-256), wired as ApplicationIcon in the WPF csproj, MainWindow.Icon, AboutWindow.Icon, and ARPPRODUCTICON in the WiX MSI. Taskbar / window / Add-Remove-Programs all show the dragon mark now.
2. Running incoming FPS: ring buffer of last 30 frame timestamps in IsoPipeline; ComputeFps() returns moving-average rate. Surfaced on IsoHealthStats.IncomingFps and shown in the Source column of the participants DataGrid as 'WxH · 59.94 fps'. Resets cleanly on every supervisor restart.
3. Drops counter: FrameProcessor.Stats already aggregated FramesDropped (closest-frame strategy when the receiver outpaces the processor) and FramesDuplicated; just plumbed _liveProcessor through IsoPipeline so GetStats() can read them. Exposed in the Live column under the in/out counters as a coral-tinted 'drop N'.
4. Console --version flag: prints engine version (with embedded git SHA), .NET version, OS, NDI runtime banner, expected prefix, exit-code legend, plus a wilddragon.net link. Useful for support tickets.
5. About dialog: chromeless modal with the dragon mark + version / .NET / OS / NDI runtime fields and a link to wilddragon.net. Triggered by clicking the rail logo.
6. Teams launcher Stop toggle: TeamsLauncher gains IsRunning() and StopAll(). The rail's Teams button now toggles — if Teams is up, ask to close all Teams windows via WM_CLOSE; otherwise launch as before. Confirms before stopping so we don't kill the user's call mid-transition.
Tests: 74/74 unit + 9/9 NDI integration green throughout. MSI builds clean and now embeds the dragon icon for ARP.
2026-05-08 13:50:19 -04:00
|
|
|
|
FramesDropped = stats.FramesDropped;
|
2026-05-08 00:52:44 -04:00
|
|
|
|
IncomingResolution = stats.IncomingWidth > 0 && stats.IncomingHeight > 0
|
|
|
|
|
|
? $"{stats.IncomingWidth}×{stats.IncomingHeight}"
|
|
|
|
|
|
: "—";
|
feat: app icon, FPS, drops counter, --version, About dialog, Stop Teams toggle
Six related polish items, all building on tonight's groundwork.
1. App icon: teamsiso.ico generated from dragon-mark.png at 7 sizes (16-256), wired as ApplicationIcon in the WPF csproj, MainWindow.Icon, AboutWindow.Icon, and ARPPRODUCTICON in the WiX MSI. Taskbar / window / Add-Remove-Programs all show the dragon mark now.
2. Running incoming FPS: ring buffer of last 30 frame timestamps in IsoPipeline; ComputeFps() returns moving-average rate. Surfaced on IsoHealthStats.IncomingFps and shown in the Source column of the participants DataGrid as 'WxH · 59.94 fps'. Resets cleanly on every supervisor restart.
3. Drops counter: FrameProcessor.Stats already aggregated FramesDropped (closest-frame strategy when the receiver outpaces the processor) and FramesDuplicated; just plumbed _liveProcessor through IsoPipeline so GetStats() can read them. Exposed in the Live column under the in/out counters as a coral-tinted 'drop N'.
4. Console --version flag: prints engine version (with embedded git SHA), .NET version, OS, NDI runtime banner, expected prefix, exit-code legend, plus a wilddragon.net link. Useful for support tickets.
5. About dialog: chromeless modal with the dragon mark + version / .NET / OS / NDI runtime fields and a link to wilddragon.net. Triggered by clicking the rail logo.
6. Teams launcher Stop toggle: TeamsLauncher gains IsRunning() and StopAll(). The rail's Teams button now toggles — if Teams is up, ask to close all Teams windows via WM_CLOSE; otherwise launch as before. Confirms before stopping so we don't kill the user's call mid-transition.
Tests: 74/74 unit + 9/9 NDI integration green throughout. MSI builds clean and now embeds the dragon icon for ARP.
2026-05-08 13:50:19 -04:00
|
|
|
|
IncomingFps = stats.IncomingFps > 0
|
|
|
|
|
|
? $"{stats.IncomingFps:0.0} fps"
|
|
|
|
|
|
: "—";
|
feat(ui): empty-state, pipeline error/no-signal indicators, JetBrains Mono, tooltips
Four polish improvements aimed at production-floor usability.
1. Empty-state placeholder for the participants card. When Participants.Count == 0, the DataGrid is hidden in favor of a friendly 'Waiting for Teams' panel: faded dragon mark, headline, explainer, and a four-item checklist (Teams running? NDI broadcast on? Discovery group correct? Firewall clear?). New CountToVisibilityConverter (with optional 'empty' parameter to invert) drives both the placeholder and the DataGrid visibility from the same Participants.Count source.
2. Per-pipeline error / no-signal surfacing. IsoHealthStats grows an init-only State property populated from IsoPipeline.State. ParticipantViewModel.UpdateStats maps that to a StateLabel ('LIVE' / 'NO SIGNAL' / 'ERROR' / 'STARTING' / '—'). The ISO toggle button gains DataTriggers on StateLabel — coral-tinted '● ERROR' when the supervisor gives up, amber-tinted '● NO SIGNAL' when the slate threshold trips. Operators can see at a glance which pipelines are broken.
3. JetBrains Mono Variable v2.304 (OFL) bundled at Assets/Fonts/JetBrainsMono.ttf. Wd.Font.Mono now points at the embedded font so machine names, timecodes, and stat counters render in JetBrains Mono regardless of system fonts. Falls back to Cascadia Mono / Consolas if the resource is missing.
4. Tooltip pass over every interactive control in the settings panel (framerate / resolution / aspect / audio / discovery group / output group / hide-local checkbox / Apply button / per-row Output Name textbox / per-row ISO toggle). Operators learn affordances on hover instead of by trial and error.
Tests: 76/76 unit + 9/9 NDI integration green.
2026-05-08 19:32:19 -04:00
|
|
|
|
(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"),
|
|
|
|
|
|
};
|
2026-05-08 00:52:44 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-07 11:39:46 -04:00
|
|
|
|
public bool IsProcessing
|
|
|
|
|
|
{
|
|
|
|
|
|
get => _isProcessing;
|
|
|
|
|
|
private set
|
|
|
|
|
|
{
|
|
|
|
|
|
if (SetField(ref _isProcessing, value))
|
|
|
|
|
|
ToggleIsoCommand.RaiseCanExecuteChanged();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
public string CustomName
|
|
|
|
|
|
{
|
|
|
|
|
|
get => _customName;
|
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
|
|
|
|
set
|
|
|
|
|
|
{
|
|
|
|
|
|
if (SetField(ref _customName, value))
|
|
|
|
|
|
OnPropertyChanged(nameof(OutputName));
|
|
|
|
|
|
}
|
2026-05-07 11:39:46 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
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>
|
|
|
|
|
|
/// 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;
|
|
|
|
|
|
|
2026-05-07 11:39:46 -04:00
|
|
|
|
public AsyncRelayCommand ToggleIsoCommand { get; }
|
|
|
|
|
|
|
2026-05-10 09:41:30 -04:00
|
|
|
|
/// <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; }
|
2026-05-10 21:08:40 -04:00
|
|
|
|
public RelayCommand SaveSnapshotCommand { get; }
|
2026-05-10 09:41:30 -04:00
|
|
|
|
|
|
|
|
|
|
/// <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; }
|
|
|
|
|
|
|
2026-05-07 11:39:46 -04:00
|
|
|
|
/// <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
|
|
|
|
|
|
{
|
2026-05-10 09:41:30 -04:00
|
|
|
|
// 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;
|
2026-05-07 11:39:46 -04:00
|
|
|
|
await _controller.EnableIsoAsync(
|
|
|
|
|
|
Id,
|
2026-05-10 09:41:30 -04:00
|
|
|
|
resolvedName,
|
|
|
|
|
|
recordOverride,
|
2026-05-07 11:39:46 -04:00
|
|
|
|
CancellationToken.None);
|
|
|
|
|
|
IsEnabled = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
finally
|
|
|
|
|
|
{
|
|
|
|
|
|
IsProcessing = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|