95 lines
3.8 KiB
C#
95 lines
3.8 KiB
C#
using System.Windows;
|
||
using System.Windows.Media;
|
||
using System.Windows.Media.Imaging;
|
||
using System.Windows.Threading;
|
||
using TeamsISO.Engine.Controller;
|
||
using TeamsISO.Engine.Pipeline;
|
||
|
||
namespace TeamsISO.App;
|
||
|
||
/// <summary>
|
||
/// Non-modal floating preview window for a single participant. Shows the
|
||
/// engine's most recent <see cref="ProcessedFrame"/> at a higher refresh
|
||
/// rate (~20Hz) than the participants DataGrid thumbnails (1Hz). Multi-
|
||
/// monitor friendly: operator drags it to a second display, leaves the
|
||
/// main TeamsISO window on the primary.
|
||
///
|
||
/// Uses <see cref="WriteableBitmap"/> with <see cref="WriteableBitmap.WritePixels(Int32Rect, IntPtr, int, int)"/>
|
||
/// — the engine produces full-resolution BGRA frames so we can write them
|
||
/// straight into the bitmap without scaling. WPF's Image control with
|
||
/// Stretch=Uniform handles aspect-correct fit to the window size.
|
||
/// </summary>
|
||
public partial class PreviewWindow : Window
|
||
{
|
||
private readonly IIsoController _controller;
|
||
private readonly Guid _participantId;
|
||
private readonly DispatcherTimer _refreshTimer;
|
||
private WriteableBitmap? _bitmap;
|
||
private int _lastWidth;
|
||
private int _lastHeight;
|
||
|
||
public PreviewWindow(IIsoController controller, Guid participantId, string displayName)
|
||
{
|
||
InitializeComponent();
|
||
_controller = controller;
|
||
_participantId = participantId;
|
||
TitleText.Text = displayName;
|
||
|
||
_refreshTimer = new DispatcherTimer(DispatcherPriority.Background, Dispatcher)
|
||
{
|
||
// 50ms = 20Hz. High enough for a smooth-feeling preview without
|
||
// hogging the dispatcher; still cheap because each refresh is just
|
||
// a memcpy from the engine's last frame into our pinned BackBuffer.
|
||
Interval = TimeSpan.FromMilliseconds(50),
|
||
};
|
||
_refreshTimer.Tick += OnTick;
|
||
Loaded += (_, _) => _refreshTimer.Start();
|
||
Closed += (_, _) =>
|
||
{
|
||
_refreshTimer.Stop();
|
||
_refreshTimer.Tick -= OnTick;
|
||
};
|
||
}
|
||
|
||
private void OnClose(object sender, RoutedEventArgs e) => Close();
|
||
|
||
private void OnTick(object? sender, EventArgs e)
|
||
{
|
||
ProcessedFrame? frame;
|
||
try { frame = _controller.GetLatestProcessedFrame(_participantId); }
|
||
catch { return; }
|
||
if (frame is null || frame.Pixels.IsEmpty || frame.Width <= 0 || frame.Height <= 0) return;
|
||
if (frame.Pixels.Length < frame.Width * frame.Height * 4) return;
|
||
|
||
// (Re)allocate the WriteableBitmap when the source resolution changes.
|
||
// FrameProcessor normalizes to a configured target so this happens at
|
||
// most once per session, but we still defend against switches.
|
||
if (_bitmap is null || frame.Width != _lastWidth || frame.Height != _lastHeight)
|
||
{
|
||
_bitmap = new WriteableBitmap(
|
||
frame.Width, frame.Height, 96, 96, PixelFormats.Bgra32, null);
|
||
PreviewImage.Source = _bitmap;
|
||
_lastWidth = frame.Width;
|
||
_lastHeight = frame.Height;
|
||
ResolutionText.Text = $"{frame.Width}×{frame.Height}";
|
||
}
|
||
|
||
// WritePixels takes a buffer + stride + rect. Stride = width * 4 for
|
||
// BGRA. We slice the ProcessedFrame's ReadOnlyMemory<byte> via .Span
|
||
// and use the IntPtr overload via MemoryMarshal — but the
|
||
// byte-array overload is simpler and the compiler picks the right
|
||
// ToArray-free path because the engine already allocates a fresh
|
||
// array per frame.
|
||
unsafe
|
||
{
|
||
fixed (byte* p = frame.Pixels.Span)
|
||
{
|
||
_bitmap.WritePixels(
|
||
new Int32Rect(0, 0, frame.Width, frame.Height),
|
||
(IntPtr)p,
|
||
frame.Pixels.Length,
|
||
frame.Width * 4);
|
||
}
|
||
}
|
||
}
|
||
}
|