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 double ViewportCssW; public double ViewportCssH; public double ContentCssX; public double ContentCssY; public double ContentCssW; public double ContentCssH; public bool Disposed; public bool Initialized; } 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 cropped to // the widget's actual content bounds (QueryContentBoundsAsync) and the // display (Stretch=Fill) maps it flush under the box, anchored at (0,0). 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';"); _ = QueryContentBoundsAsync(source.Id); _ = 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 page's real content rect (scrollWidth/Height) can be smaller than the // viewport for widgets that don't fill it — the leftover area is dead space // that must not stretch across the selection box. Crop the capture to the // content bounds (anchored at 0,0) so the box shows exactly the widget. // The widget's ACTUAL visible content bounds — the union of every visible // body rectangle (getBoundingClientRect). This is the real widget rect, // excluding the page's own margins/dead space, with its top-left offset. // A real object is returned (NOT JSON.stringify — ExecuteScriptAsync // JSON-encodes the result, which would double-encode a string). private const string ContentBoundsScript = "(function(){" + "var rs=[];var body=document.body;" + "if(body){var br=body.getBoundingClientRect();if(br.width||br.height)rs.push(br);}" + "var els=document.querySelectorAll('body *');" + "for(var i=0;i= 1 && session.ViewportCssH >= 1 && session.ContentCssW >= 1 && session.ContentCssH >= 1) { var sx = pixW / session.ViewportCssW; var sy = pixH / session.ViewportCssH; cropX = (int)Math.Clamp(Math.Round(session.ContentCssX * sx), 0, pixW - 1); cropY = (int)Math.Clamp(Math.Round(session.ContentCssY * sy), 0, pixH - 1); cropW = (int)Math.Clamp(Math.Round(session.ContentCssW * sx), 1, pixW - cropX); cropH = (int)Math.Clamp(Math.Round(session.ContentCssH * sy), 1, pixH - cropY); } var outPixels = pixels; if (cropW != pixW || cropH != pixH || cropX != 0 || cropY != 0) { var dstStride = cropW * 4; outPixels = new byte[dstStride * cropH]; for (var r = 0; r < cropH; r++) Buffer.BlockCopy(pixels, (cropY + r) * stride + cropX * 4, outPixels, r * dstStride, dstStride); } var wb = new WriteableBitmap(cropW, cropH, 96, 96, PixelFormats.Bgra32, null); wb.WritePixels(new Int32Rect(0, 0, cropW, cropH), outPixels, cropW * 4, 0); wb.Freeze(); session.LatestFrame = new VideoFrame(cropW, cropH, outPixels); _ = _dispatcher.BeginInvoke(() => { PreviewBitmapChanged?.Invoke(sourceId, wb); }); } 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); } }