using System; using System.Diagnostics; 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(); private Task? _environmentTask; private int _captureIntervalMs = 200; // idle pace; recording drops it to ~30Hz via SetCaptureInterval public event Action? PreviewBitmapChanged; private sealed class WebSourceSession { public required WebView2 Control { get; init; } public required CaptureScheduler Scheduler { get; init; } public VideoFrame? LatestFrame; public bool Disposed; public bool Initialized; // Buffer reuse (2026-09-04): 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). // CanvasScratch is reused while the size holds. The frame handed to the // compositor is always the FULL canvas (transparent margins reveal layers // beneath); the crop is only for the preview WriteableBitmap. Epoch is // monotonic per canvas generation (identity-keyed consumers see each frame). public byte[]? CanvasScratch; public readonly byte[]?[] OutRing = new byte[8][]; public int OutRingNext; public long Epoch; public WriteableBitmap? PreviewBitmap; public bool DebugPngWritten; public int WidgetDumpRemaining; public int TelemetryCaptureCount; 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 scheduler = new CaptureScheduler( _dispatcher, _captureIntervalMs, () => CaptureFrame(source.Id)); var session = new WebSourceSession { Control = webView, Scheduler = scheduler, }; _sessions[source.Id] = session; _ = InitializeAsync(source, session); } /// Changes the capture cadence for every session. Recording runs at /// ~30Hz (33ms) — the practical PNG-capture ceiling — idle drops back to 200ms so /// the preview alone doesn't burn a core decoding full-HD captures. public void SetCaptureInterval(int milliseconds) { _captureIntervalMs = milliseconds; foreach (var session in _sessions.Values) if (!session.Disposed) session.Scheduler.SetInterval(milliseconds); } 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; } // Injected BEFORE the document is parsed (AddScriptToExecuteOnDocumentCreatedAsync) so // the widget page's own CSS/scripts cannot repaint html/body opaque — the reason the // late NavigationStarting/NavigationCompleted ExecuteScriptAsync injection lost the // fight (page styles ran first). CapturePreviewAsync "will always honor a webpage's // background content" (MicrosoftEdge/WebView2Feedback specs/BackgroundColor.md), so a // transparent capture REQUIRES the transparency to be in place before the page paints. // // The OLD inline `element.style.background='transparent'` form lost to any page CSS: // a widget that paints a background-COLOR on a container (or html/body with stronger // specificity) after connect re-opaques the capture → the "black opaque box" in the // recording while the early frames were transparent. This is a solved problem in the // overlay ecosystem — OBS browser sources use a Custom CSS override and the decade- // validated formula for arbitrary pages is a `!important` background-color wipe // (OBS Forums 2016 "body { background-color: rgba(0,0,0,0) !important }", // https://obsproject.com/forum/threads/translucent-transparent-browser-source.59549/, // and the div-level variant for stubborn widgets, woahtech.com OBS custom-CSS guide). // A `