Files
LlamaCasty/Services/WebView2Manager.cs
T
gramps a5b9952cbd Web source: crop to the WIDGET rect (visible-element union excluding full-viewport wrappers) — kills the ghost-boundary dead space
- scrollWidth/scrollHeight returns the whole 1920x1080 canvas for widgets whose body/background
  spans it, so the crop kept massive transparent margins and the content floated in the Fill box.
- ContentBoundsScript now unions every visible element's getBoundingClientRect, excluding elements
  that span >=98% of the viewport (bodies, full-canvas background layers); full-bleed widgets fall
  back to the full rect. Crop taken AT the rect's top-left (l,t) — content pins to bitmap (0,0).
- Web Image explicitly Margin=0 + Left/Top alignment so the frame and widget coincide at the origin.
- Canvas-size render untouched.
2026-08-28 11:45:31 -07:00

322 lines
12 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 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(
"<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 true widget rect for the crop. scrollWidth/scrollHeight is useless for
// widgets whose body/background spans the full 1920×1080 canvas — it returns
// the whole viewport, so the crop keeps massive transparent margins and the
// rendered bitmap floats inside the Fill-stretched box ("ghost boundary").
// Fix: union the getBoundingClientRect of every visible element, EXCLUDING
// elements that span (nearly) the full viewport — bodies and full-canvas
// background/overlay layers are layout wrappers, not widget content. If the
// exclusion empties the set, the widget IS full-bleed: use the full rect.
// Returned as a real JS object (NOT JSON.stringify — ExecuteScriptAsync
// JSON-encodes the result, so stringifying would double-encode and the
// parse would throw, silently disabling the crop).
private const string ContentBoundsScript =
"(function(){" +
"var vw=window.innerWidth||1,vh=window.innerHeight||1;" +
"var all=[];var body=document.body;" +
"if(body){var br=body.getBoundingClientRect();if(br.width||br.height)all.push(br);" +
"var els=body.querySelectorAll('*');" +
"for(var i=0;i<els.length;i++){" +
"var st=getComputedStyle(els[i]);" +
"if(st&&st.display==='none')continue;" +
"var r=els[i].getBoundingClientRect();" +
"if(r.width||r.height)all.push(r);" +
"}}" +
"if(!all.length)return null;" +
"var content=[];" +
"for(var j=0;j<all.length;j++){" +
"var rr=all[j];" +
"if(rr.width>=vw*0.98&&rr.height>=vh*0.98)continue;" +
"content.push(rr);" +
"}" +
"var use=content.length?content:all;" +
"var l=use[0].left,t=use[0].top,rt=use[0].right,bt=use[0].bottom;" +
"for(var k=1;k<use.length;k++){" +
"l=Math.min(l,use[k].left);" +
"t=Math.min(t,use[k].top);" +
"rt=Math.max(rt,use[k].right);" +
"bt=Math.max(bt,use[k].bottom);" +
"}" +
"l=Math.max(0,l);t=Math.max(0,t);" +
"return {l:l,t:t,w:Math.max(1,Math.min(vw-l,rt-l))," +
"h:Math.max(1,Math.min(vh-t,bt-t)),vw:vw,vh:vh};" +
"})()";
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.ContentCssX = root.GetProperty("l").GetDouble();
session.ContentCssY = root.GetProperty("t").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 cropX = 0, cropY = 0, 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;
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);
}
}