using System; using System.Drawing; using System.IO; using System.Text.Json; using System.Windows; using System.Windows.Controls; using System.Windows.Media; using System.Windows.Media.Imaging; using System.Windows.Threading; using Microsoft.Web.WebView2.Core; using Microsoft.Web.WebView2.Wpf; using ytLive.Helpers; using ytLive.Models; namespace ytLive.Services; public sealed class WebView2Manager : IDisposable { private readonly Panel _hostPanel; private readonly Dispatcher _dispatcher; private readonly Dictionary _sessions = new(); public event Action? PreviewBitmapChanged; private sealed class WebSourceSession { public required WebView2 Control { get; init; } public required DispatcherTimer CaptureTimer { get; init; } public VideoFrame? LatestFrame; public bool Disposed; public bool Initialized; // Buffer reuse (2026-09-04, same lesson as the capture/camera rings): every // capture tick used to mint a fresh 8.3MB canvas array + a fresh crop array // (~83MB/s of LOH at 10Hz → gen2 pauses surfaced as the pump's 35-40ms // "worst render" spikes). Canvas scratch is reused while the size holds; // the frame handed to the compositor rotates an 8-deep ring with a monotonic // Epoch (identity-keyed consumers must see each generation). The preview // WriteableBitmap is recreated only when the cropped size changes. public byte[]? CanvasScratch; public readonly byte[]?[] OutRing = new byte[8][]; public int OutRingNext; public long Epoch; public WriteableBitmap? PreviewBitmap; public byte[] RentOutBuffer(int size) { for (var tries = 0; tries < OutRing.Length; tries++) { var idx = (OutRingNext + tries) % OutRing.Length; var buf = OutRing[idx]; if (buf != null && buf.Length == size) { OutRingNext = (idx + 1) % OutRing.Length; return buf; } } var slot = OutRingNext; OutRingNext = (slot + 1) % OutRing.Length; var fresh = new byte[size]; OutRing[slot] = fresh; return fresh; } } public WebView2Manager(Panel hostPanel, Dispatcher dispatcher) { _hostPanel = hostPanel; _dispatcher = dispatcher; } public void Register(Source source) { if (_sessions.ContainsKey(source.Id)) return; // OBS model: the page renders at the MASTER CANVAS size (1920×1080), // stable, never tracked. Full-bleed widgets (designed for the canvas) // render fully instead of being clipped to a small box-sized viewport; // dragging the box never reflows the page. The capture is tightly // cropped to the widget's ALPHA bounding box (FindContentBounds) and // the display (Stretch=Fill) maps it flush under the box, anchored at // (0,0) — no DOM query, so no timing fragility; worst case is the // full frame, which is the confirmed-good rendering. var webView = new WebView2 { Width = 1920, Height = 1080, DefaultBackgroundColor = System.Drawing.Color.Transparent, Visibility = System.Windows.Visibility.Visible, }; Canvas.SetLeft(webView, -5000); Canvas.SetTop(webView, -5000); _hostPanel.Children.Add(webView); var timer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(100), }; var session = new WebSourceSession { Control = webView, CaptureTimer = timer, }; _sessions[source.Id] = session; timer.Tick += (_, _) => { if (!session.Disposed && session.Initialized) _ = CaptureFrame(source.Id); }; _ = InitializeAsync(source, session); } public void Unregister(string sourceId) => RemoveSession(sourceId); public void OnWebUriChanged(Source source) { if (!_sessions.TryGetValue(source.Id, out var session)) return; if (session.Disposed || !session.Initialized) return; if (string.IsNullOrEmpty(source.WebUri)) { session.LatestFrame = null; session.Control.CoreWebView2?.NavigateToString( ""); return; } Navigate(source.WebUri, session); } public VideoFrame? GetLatestFrame(string sourceId) { return _sessions.TryGetValue(sourceId, out var session) ? session.LatestFrame : null; } private async Task InitializeAsync(Source source, WebSourceSession session) { try { await session.Control.EnsureCoreWebView2Async(); var cws = session.Control.CoreWebView2!; cws.NavigationStarting += (_, _) => { _ = cws.ExecuteScriptAsync( "document.documentElement.style.background='transparent';" + "document.documentElement.style.overflow='hidden';" + "document.body.style.background='transparent';" + "document.body.style.overflow='hidden';"); }; cws.NavigationCompleted += (_, _) => { _ = cws.ExecuteScriptAsync( "document.documentElement.style.background='transparent';" + "document.documentElement.style.overflow='hidden';" + "document.body.style.background='transparent';" + "document.body.style.overflow='hidden';"); _ = CaptureFrame(source.Id); }; session.Initialized = true; session.Control.CoreWebView2.NavigateToString( ""); if (!string.IsNullOrEmpty(source.WebUri)) Navigate(source.WebUri, session); session.CaptureTimer.Start(); } catch (Exception ex) { AppLog.Write($"WebView2Manager: init failed for '{source.Name}': {ex.Message}"); RemoveSession(source.Id); } } private void Navigate(string url, WebSourceSession session) { try { session.Control.CoreWebView2?.Navigate(url); } catch (Exception ex) { AppLog.Write($"WebView2Manager: navigate failed: {ex.Message}"); } } // The widget's extent is measured from the captured frame itself: the // bounding box of non-transparent pixels (the background is injected // transparent, so the alpha channel IS the widget). This is immune to // DOM timing (fonts/images/animations), needs no JS round-trip, and // CANNOT break rendering: if the widget fills the canvas the box equals // the full frame (identical to the confirmed-good uncropped display); if // it floats in transparent margin, the crop maps it flush under the box. internal static (int X, int Y, int W, int H) FindContentBounds(byte[] pixels, int pixW, int pixH, int stride) { int minX = pixW, minY = pixH, maxX = -1, maxY = -1; for (var y = 0; y < pixH; y++) { var row = y * stride; for (var x = 0; x < pixW; x++) { if (pixels[row + x * 4 + 3] == 0) continue; if (x < minX) minX = x; if (x > maxX) maxX = x; if (y < minY) minY = y; if (y > maxY) maxY = y; } } if (maxX < minX || maxY < minY) return (0, 0, pixW, pixH); return (minX, minY, maxX - minX + 1, maxY - minY + 1); } private async Task CaptureFrame(string sourceId) { if (!_sessions.TryGetValue(sourceId, out var session) || session.Disposed) return; try { var webView = session.Control; if (webView.CoreWebView2 == null) return; using var ms = new MemoryStream(); await webView.CoreWebView2.CapturePreviewAsync( CoreWebView2CapturePreviewImageFormat.Png, ms); ms.Position = 0; var bitmap = new BitmapImage(); bitmap.BeginInit(); bitmap.StreamSource = ms; bitmap.CacheOption = BitmapCacheOption.OnLoad; bitmap.EndInit(); bitmap.Freeze(); var formatted = new FormatConvertedBitmap(bitmap, PixelFormats.Bgra32, null, 0); var pixW = formatted.PixelWidth; var pixH = formatted.PixelHeight; if (pixW < 1 || pixH < 1) return; var need = pixW * pixH * 4; var pixels = session.CanvasScratch is { } scratch && scratch.Length >= need ? scratch : (session.CanvasScratch = new byte[need]); formatted.CopyPixels(pixels, pixW * 4, 0); var stride = pixW * 4; var (cropX, cropY, cropW, cropH) = FindContentBounds(pixels, pixW, pixH, stride); var outPixels = session.RentOutBuffer(cropW * cropH * 4); if (cropW == pixW && cropH == pixH && cropX == 0 && cropY == 0) Buffer.BlockCopy(pixels, 0, outPixels, 0, outPixels.Length); else for (var r = 0; r < cropH; r++) Buffer.BlockCopy(pixels, (cropY + r) * stride + cropX * 4, outPixels, r * cropW * 4, cropW * 4); if (session.PreviewBitmap == null || session.PreviewBitmap.PixelWidth != cropW || session.PreviewBitmap.PixelHeight != cropH) session.PreviewBitmap = new WriteableBitmap(cropW, cropH, 96, 96, PixelFormats.Bgra32, null); session.PreviewBitmap.WritePixels(new Int32Rect(0, 0, cropW, cropH), outPixels, cropW * 4, 0); session.LatestFrame = new VideoFrame(cropW, cropH, outPixels) { Epoch = ++session.Epoch }; // The preview bitmap is now MUTABLE and reused (CameraManager precedent): // WPF re-renders it after WritePixels; handlers must not Freeze it. var preview = session.PreviewBitmap; _ = _dispatcher.BeginInvoke(() => { PreviewBitmapChanged?.Invoke(sourceId, preview); }); } catch (ObjectDisposedException) { } catch (InvalidOperationException) { } catch (Exception ex) { AppLog.Write($"WebView2Manager: capture failed for '{sourceId}': {ex.Message}"); } } private void RemoveSession(string sourceId) { if (!_sessions.TryGetValue(sourceId, out var session)) return; session.Disposed = true; session.CaptureTimer.Stop(); _hostPanel.Children.Remove(session.Control); try { session.Control.Dispose(); } catch { } _sessions.Remove(sourceId); } public void Dispose() { foreach (var id in _sessions.Keys.ToList()) RemoveSession(id); } }