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