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:
+16
-3
@@ -274,6 +274,13 @@ public class LayoutStore : IDisposable
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
if (!columns.Contains("WebUri"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE Source ADD COLUMN WebUri TEXT;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
// Rename legacy "Backdrop" display name to "Background" in existing rows
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
@@ -591,7 +598,8 @@ public class LayoutStore : IDisposable
|
||||
X, Y, Width, Height, Opacity, MonitorIndex, ClipShape, IsMirrored,
|
||||
IsBackground, CaptureKey, ShowDesktop,
|
||||
ChatFontSize, ChatFontColor, ChatBackgroundColor, ChatMaxMessages,
|
||||
ChatShowTimestamps, ChatShowBadges, ChatMemberColor, ChatSuperChatColor
|
||||
ChatShowTimestamps, ChatShowBadges, ChatMemberColor, ChatSuperChatColor,
|
||||
WebUri
|
||||
FROM Source ORDER BY SortOrder
|
||||
""";
|
||||
using var reader = cmd.ExecuteReader();
|
||||
@@ -628,6 +636,7 @@ public class LayoutStore : IDisposable
|
||||
ChatShowBadges = reader.IsDBNull(22) ? true : reader.GetInt32(22) != 0,
|
||||
ChatMemberColor = reader.IsDBNull(23) ? "#e94560" : reader.GetString(23),
|
||||
ChatSuperChatColor = reader.IsDBNull(24) ? "#eab308" : reader.GetString(24),
|
||||
WebUri = reader.IsDBNull(25) ? null : reader.GetString(25),
|
||||
};
|
||||
if (!sourcesByScene.TryGetValue(sceneId, out var list))
|
||||
sourcesByScene[sceneId] = list = new List<Source>();
|
||||
@@ -837,12 +846,14 @@ public class LayoutStore : IDisposable
|
||||
X, Y, Width, Height, Opacity, MonitorIndex,
|
||||
ClipShape, IsMirrored, IsBackground, CaptureKey, ShowDesktop, SortOrder,
|
||||
ChatFontSize, ChatFontColor, ChatBackgroundColor, ChatMaxMessages,
|
||||
ChatShowTimestamps, ChatShowBadges, ChatMemberColor, ChatSuperChatColor)
|
||||
ChatShowTimestamps, ChatShowBadges, ChatMemberColor, ChatSuperChatColor,
|
||||
WebUri)
|
||||
VALUES ($id, $sceneId, $assetId, $type, $name, $isEnabled,
|
||||
$x, $y, $w, $h, $opacity, $monitor,
|
||||
$clip, $mirrored, $isBackground, $captureKey, $showDesktop, $sort,
|
||||
$chatFontSize, $chatFontColor, $chatBgColor, $chatMaxMsgs,
|
||||
$chatTimestamps, $chatBadges, $chatMemberColor, $chatSuperChatColor)
|
||||
$chatTimestamps, $chatBadges, $chatMemberColor, $chatSuperChatColor,
|
||||
$webUri)
|
||||
""";
|
||||
cmd.Transaction = tx;
|
||||
var idP = cmd.Parameters.Add("$id", SqliteType.Text);
|
||||
@@ -871,6 +882,7 @@ public class LayoutStore : IDisposable
|
||||
var chatBadgesP = cmd.Parameters.Add("$chatBadges", SqliteType.Integer);
|
||||
var chatMemberColorP = cmd.Parameters.Add("$chatMemberColor", SqliteType.Text);
|
||||
var chatSuperChatColorP = cmd.Parameters.Add("$chatSuperChatColor", SqliteType.Text);
|
||||
var webUriP = cmd.Parameters.Add("$webUri", SqliteType.Text);
|
||||
|
||||
foreach (var scene in scenes)
|
||||
{
|
||||
@@ -904,6 +916,7 @@ public class LayoutStore : IDisposable
|
||||
chatBadgesP.Value = source.ChatShowBadges ? 1 : 0;
|
||||
chatMemberColorP.Value = source.ChatMemberColor;
|
||||
chatSuperChatColorP.Value = source.ChatSuperChatColor;
|
||||
webUriP.Value = (object?)source.WebUri ?? DBNull.Value;
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
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<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 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;
|
||||
|
||||
var webView = new WebView2
|
||||
{
|
||||
Width = source.Width > 0 ? source.Width : 640,
|
||||
Height = source.Height > 0 ? source.Height : 480,
|
||||
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;
|
||||
|
||||
source.PropertyChanged += (_, e) =>
|
||||
{
|
||||
if (e.PropertyName == nameof(Source.Width) || e.PropertyName == nameof(Source.Height))
|
||||
{
|
||||
var w = Math.Max(1, (int)source.Width);
|
||||
var h = Math.Max(1, (int)source.Height);
|
||||
if (Math.Abs(session.Control.Width - w) > 0.5 ||
|
||||
Math.Abs(session.Control.Height - h) > 0.5)
|
||||
{
|
||||
session.Control.Width = w;
|
||||
session.Control.Height = h;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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 += (_, e) =>
|
||||
{
|
||||
_ = cws.ExecuteScriptAsync(
|
||||
"document.documentElement.style.background='transparent';" +
|
||||
"document.body.style.background='transparent';");
|
||||
};
|
||||
|
||||
cws.NavigationCompleted += (_, _) =>
|
||||
{
|
||||
_ = cws.ExecuteScriptAsync(
|
||||
"document.documentElement.style.background='transparent';" +
|
||||
"document.body.style.background='transparent';");
|
||||
};
|
||||
|
||||
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}"); }
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user