Files
LlamaCasty/Services/WebView2Manager.cs
T
gramps b862a0fb04 Web source: revert crop measurement to f39ebd9 scrollWidth/Height (nailed right/bottom) — canvas-size render untouched
- ContentBoundsScript back to scrollWidth/scrollHeight extents, kept as a real JS object
  (the JSON.stringify double-encode bug that silently disabled the crop stays fixed).
- Crop again from (0,0), no X/Y offset. The union-of-elements approach (582d1f4) created an
  all-sides gap by including invisible full-canvas wrappers; revert to the proven code.
2026-08-28 11:35:30 -07:00

287 lines
10 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, WebSourceSession> _sessions = new();
public event Action<string, WriteableBitmap>? 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(
"<html><body style='background:transparent;margin:0'></body></html>");
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(
"<html><body style='background:transparent;margin:0'></body></html>");
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);
}
}