Web source: off-screen WebView2 capture + layout persistence + transparent bg

Bundle exception (2026-08-27): this lands prior-session work plus today's
transparency fix as one commit per creator instruction.

WebView2Manager (new): off-screen WebView2 control parked at -5000/-5000 in
the window's visual tree, captures via CapturePreviewAsync every 100 ms into
a BGRA8 VideoFrame. Source Width/Height sync to the control via
PropertyChanged. JS transparent-background injection runs on every
NavigationStarting/NavigationCompleted so the page's own CSS does not paint
white over the capture.

Transparency fix: DefaultBackgroundColor = System.Drawing.Color.Transparent
set on the WPF control before EnsureCoreWebView2Async. The WPF SDK doc says
the value is forwarded to the controller on init; the IDL (ICoreWebView2-
Controller2) confirms alpha=0 makes the capture alpha-preserving. The
prior-session 'CoreWebView2.BackgroundColor' path was the wrong property
name and has been replaced.

LayoutStore: WebUri column added (idempotent ALTER TABLE) and round-tripped
on save/load (schema v11).

MainViewModel: WebView2Manager constructed on Loaded; loaded web sources
re-registered; Source.WebUri PropertyChanged forwards to manager.

MainWindow: off-screen host Canvas added to visual tree.

Tests: 3 scale-sync + WebView2_DefaultBackgroundColor_Is_Transparent +
LayoutStorePersistenceTests.WebSource_WebUri_Persists. 234/236 pass
(2 pre-existing failures unchanged).

Scale bug (DPI/physical-pixel mismatch) still open.
This commit is contained in:
2026-08-27 08:52:39 -07:00
parent f81048849a
commit fd6e02b687
10 changed files with 595 additions and 57 deletions
@@ -352,4 +352,40 @@ public class LayoutStorePersistenceTests
try { File.Delete(path); } catch { /* best-effort cleanup */ }
}
}
[Fact]
public void WebSource_WebUri_Persists()
{
var path = Path.Combine(Path.GetTempPath(), $"ytLlive-layout-{Guid.NewGuid():N}.db");
try
{
using var store = new LayoutStore(path);
var scene = new Scene { Name = "Main" };
var source = new Source
{
Name = "Overlay",
Type = SourceType.WebSource,
Width = 640,
Height = 480,
WebUri = "https://widgets.streamelements.com/host/661d8622ff009005bd74b8bc/d4fb46cd-4cc1-44eb-a800-3a415ca6233f/yTZ18ad1MOF3Vzii6kH2z6D0Rxuv7IUnXPl0ExeJM5EAizLk",
};
scene.Elements.Add(source);
store.Save(new[] { scene }, null, null);
var reloaded = store.Load();
var loaded = Assert.Single(reloaded[0].Elements);
Assert.IsType<Source>(loaded);
var loadedSource = (Source)loaded;
Assert.Equal(SourceType.WebSource, loadedSource.Type);
Assert.Equal(640, loadedSource.Width);
Assert.Equal(480, loadedSource.Height);
Assert.Equal("https://widgets.streamelements.com/host/661d8622ff009005bd74b8bc/d4fb46cd-4cc1-44eb-a800-3a415ca6233f/yTZ18ad1MOF3Vzii6kH2z6D0Rxuv7IUnXPl0ExeJM5EAizLk", loadedSource.WebUri);
}
finally
{
SqliteConnection.ClearAllPools();
try { File.Delete(path); } catch { /* best-effort cleanup */ }
}
}
}
+123
View File
@@ -0,0 +1,123 @@
using System.Drawing;
using System.Linq;
using System.Windows.Controls;
using System.Windows.Threading;
using Microsoft.Web.WebView2.Wpf;
using Xunit;
using ytLive.Models;
using ytLive.Services;
namespace ytLive.Tests;
[Collection("RealApp")]
public sealed class WebView2ManagerTests
{
private readonly RealAppHost _app;
public WebView2ManagerTests(RealAppHost app)
{
_app = app;
}
[Fact]
public void WebView2_Control_Size_Tracks_Source_Dimensions()
{
_app.Run(() =>
{
var panel = new Canvas();
var source = new Source
{
Type = SourceType.WebSource,
Width = 640,
Height = 480,
};
using var manager = new WebView2Manager(panel, Dispatcher.CurrentDispatcher);
manager.Register(source);
var webView = panel.Children.OfType<WebView2>().First();
Assert.Equal(640.0, webView.Width);
Assert.Equal(480.0, webView.Height);
source.Width = 1024;
source.Height = 768;
Assert.Equal(1024.0, webView.Width);
Assert.Equal(768.0, webView.Height);
});
}
[Fact]
public void WebView2_Control_Size_Does_Not_Drift_On_Minor_Rounding()
{
_app.Run(() =>
{
var panel = new Canvas();
var source = new Source
{
Type = SourceType.WebSource,
Width = 640,
Height = 480,
};
using var manager = new WebView2Manager(panel, Dispatcher.CurrentDispatcher);
manager.Register(source);
var webView = panel.Children.OfType<WebView2>().First();
source.Width = 640.4;
Assert.Equal(640.0, webView.Width);
source.Height = 480.4;
Assert.Equal(480.0, webView.Height);
});
}
[Fact]
public void WebView2_Control_Size_Minimum_Is_One()
{
_app.Run(() =>
{
var panel = new Canvas();
var source = new Source
{
Type = SourceType.WebSource,
Width = 640,
Height = 480,
};
using var manager = new WebView2Manager(panel, Dispatcher.CurrentDispatcher);
manager.Register(source);
var webView = panel.Children.OfType<WebView2>().First();
source.Width = 0;
source.Height = 0;
Assert.Equal(1.0, webView.Width);
Assert.Equal(1.0, webView.Height);
});
}
[Fact]
public void WebView2_DefaultBackgroundColor_Is_Transparent()
{
_app.Run(() =>
{
var panel = new Canvas();
var source = new Source
{
Type = SourceType.WebSource,
Width = 640,
Height = 480,
};
using var manager = new WebView2Manager(panel, Dispatcher.CurrentDispatcher);
manager.Register(source);
var webView = panel.Children.OfType<WebView2>().First();
Assert.Equal(System.Drawing.Color.Transparent, webView.DefaultBackgroundColor);
});
}
}