diff --git a/src/TeamsISO.App/PreviewWindow.xaml b/src/TeamsISO.App/PreviewWindow.xaml new file mode 100644 index 0000000..8d390ab --- /dev/null +++ b/src/TeamsISO.App/PreviewWindow.xaml @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/TeamsISO.App/PreviewWindow.xaml.cs b/src/TeamsISO.App/PreviewWindow.xaml.cs new file mode 100644 index 0000000..f04727d --- /dev/null +++ b/src/TeamsISO.App/PreviewWindow.xaml.cs @@ -0,0 +1,95 @@ +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; + +/// +/// Non-modal floating preview window for a single participant. Shows the +/// engine's most recent 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 with +/// — 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. +/// +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 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); + } + } + } +}