using System; using System.Drawing; using System.IO; 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; } 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 ONCE at a stable viewport — the size the // element was created at (snapshot, never tracked, so dragging the box // never reflows/truncates the page and no scrollbars appear). The // captured texture is then stretched by the display (Stretch=Fill) into // whatever box the user draws, so the frame always bounds content with // zero dead space. Geometry-agnostic: any widget, any box. var webView = new WebView2 { Width = Math.Max(1, source.Width), Height = Math.Max(1, source.Height), 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}"); } } 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; var wb = new WriteableBitmap(pixW, pixH, 96, 96, PixelFormats.Bgra32, null); wb.WritePixels(new Int32Rect(0, 0, pixW, pixH), pixels, stride, 0); wb.Freeze(); session.LatestFrame = new VideoFrame(pixW, pixH, pixels); _ = _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); } }