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 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. // This restores f39ebd9's measurement (scrollWidth/Height), which the // creator confirmed nailed the right and bottom of the box. The result is // returned as a real JS object, NOT JSON.stringify — ExecuteScriptAsync // JSON-encodes the result, so stringifying would double-encode and the // JSON parse would throw, silently disabling the crop. private const string ContentBoundsScript = "({w:document.documentElement.scrollWidth||document.body.scrollWidth||0," + "h:document.documentElement.scrollHeight||document.body.scrollHeight||0," + "vw:window.innerWidth||1,vh:window.innerHeight||1})"; private async Task QueryContentBoundsAsync(string sourceId) { if (!_sessions.TryGetValue(sourceId, out var session) || session.Disposed) return; try { if (session.Control.CoreWebView2 is not { } cws) return; var json = await cws.ExecuteScriptAsync(ContentBoundsScript); if (string.IsNullOrEmpty(json) || json == "null") return; using var doc = JsonDocument.Parse(json); var root = doc.RootElement; session.ViewportCssW = root.GetProperty("vw").GetDouble(); session.ViewportCssH = root.GetProperty("vh").GetDouble(); session.ContentCssW = root.GetProperty("w").GetDouble(); session.ContentCssH = root.GetProperty("h").GetDouble(); _ = CaptureFrame(sourceId); } catch (Exception ex) { AppLog.Write($"WebView2Manager: content bounds query failed: {ex.Message}"); } } 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 pixels = new byte[pixW * pixH * 4]; formatted.CopyPixels(pixels, pixW * 4, 0); var stride = pixW * 4; int cropW = pixW, cropH = pixH; if (session.ViewportCssW >= 1 && session.ViewportCssH >= 1 && session.ContentCssW >= 1 && session.ContentCssH >= 1) { var sx = pixW / session.ViewportCssW; var sy = pixH / session.ViewportCssH; cropW = (int)Math.Clamp(Math.Round(session.ContentCssW * sx), 1, pixW); cropH = (int)Math.Clamp(Math.Round(session.ContentCssH * sy), 1, pixH); } var outPixels = pixels; if (cropW != pixW || cropH != pixH) { var dstStride = cropW * 4; outPixels = new byte[dstStride * cropH]; for (var r = 0; r < cropH; r++) Buffer.BlockCopy(pixels, r * stride, 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); } }