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
+104 -45
View File
@@ -1,57 +1,116 @@
# HANDOFF — Session State # HANDOFF — Session State
## Branch ## Branch
**`main`** @ `67baac6`, committed LOCALLY, working tree clean. **NOT pushed — user rule: never **`main`** @ `67baac6`, working tree dirty (WebView2 web source feature + transparency fix).
push without explicit instruction (2026-08-24).** No feature branches pre-1.0: all work lands on **NOT pushed — user rule: never push without explicit instruction (2026-08-24).** No feature
`main` per work unit. branches pre-1.0: all work lands on `main` per work unit.
## Shipped (this session, all on main) ## Uncommitted changes (WebView2 web source — to be committed this session)
- TASK 26 — webcam (+) menu gates on the app-wide identity (test `WebcamMenuGateTests`). **User exception (2026-08-27):** bundle the prior-session dirty work + today's transparency fix
- TASK 27 — YouTube Chat one-per-layout gate + legacy label heal (test `ChatLayerGateTests`). into ONE commit instead of separate work units.
- TASK 19/23 status flip — Control Surface UX verified shipped; map corrected.
- TASK 20 step 1 — global hotkeys F1-F9 (`Services/GlobalHotkeys.cs`, test `GlobalHotkeyTests`). Validated by creator.
- ON-AIR sign right of REC — gray offline / red public / green private (`OnAirBrush`), shared `StatusSignText` style. Test `OnAirSignTests`.
- **Broadcast metadata pull-out + launch geometry** (`b988e6d`) — white "Text" tab on preview's right edge slides out the broadcast form: Title, Description, Tags csv, Visibility, Made-for-Kids, read-only Scheduled Start. Edits persist to Settings `Broadcast.*` immediately; Update Broadcast PUTs `liveBroadcasts?part=snippet,status` via new `YouTubeStreamService.UpdateBroadcast`. Go Live prefills from the form + captures back what was inserted. Old Default Stream Title/Description removed from App Settings (properties deleted). Window: default 1920×1040, MinWidth 1366, MinHeight 768, Manual startup, size+position persisted on close and restored clamped. Test `BroadcastPullOutTests`.
- **Bug-fix pass 1** (`e6baa55` + `d9e352f`): Text tab **always visible** (creator revised the Live-only gating same day); tab **toggles** the drawer; **click-away collapses it** (`Window_PreviewMouseLeftButtonDown` checks `TextPullOutHost` ancestry); avatar initial-letter fallback added.
- **Bug-fix pass 2** (`1009800`): real crash fix + avatar root cause.
- **Update Broadcast button** (`89ab83b`): diff-before-push, offline=local only, live=API; Go Live empty-field prompt opens Text drawer.
- **TASK 20 step 2** (this session): hotkey config UI + persistence:
- `Services/HotkeyChord.cs` — modifier+key chord record with display/parse/FromWpf helpers
- `Services/GlobalHotkeys.cs` — modifier param on `IHotkeyRegistrar`, custom bindings dict, `Reattach()` for hot-swap
- `Services/LayoutStore.cs` — `LoadHotkeyBindings()` / `SaveHotkeyBindings()` (Settings table, no migration)
- `ViewModels/HotkeyConfigViewModel.cs` — working copy, capture mode, conflict detection, unbind, reset
- `HotkeyConfigDialog.xaml` + `.cs` — click-to-capture, Unbind per row, Reset/Save/Cancel
- `ViewModels/MainViewModel.cs` — loads bindings, `OpenHotkeyConfigCommand`, `HotkeyBindingsChanged` callback
- `MainWindow.xaml.cs` — passes bindings to manager, re-attaches on dialog save
- Tests: `HotkeyConfigTests` (round-trip, display string, storage serialization)
- **Thumbnail strip hotkey labels** (`d4aa5e1`): each scene shows its current binding (e.g. "Starting F1").
- **Pull-out tab renamed** to "Stream Settings"; **right-click Start Stream** → Change Account / Logout context menu (`67baac6`).
- **Scope Lock rules** (`AGENTS.md` + `scripts/scope-check.sh`): pre-task scope declaration, git history scan before editing, pre-commit audit script that validates `git diff` against declared file list. Born from the `d2114c7` incident.
- Suite: 231 total, 230 pass — only failure is the known pre-existing `AudioPipelineTests.Mix_HonorsProviderGains_AndGameMute_KillsTheLoopback`. Build 0 warnings.
## ⚠️ Landmines 9 files:
- `Services/WebView2Manager.cs` (new) — off-screen WebView2 capture: PropertyChanged syncs
source W/H to control, JS transparent background injection on every nav, `CapturePreviewAsync`
→ BGRA `VideoFrame`; **`DefaultBackgroundColor = System.Drawing.Color.Transparent` set in the
constructor** (before `EnsureCoreWebView2Async`, per WPF SDK doc) — this is the real fix for
transparency. The old white-pixel alpha hack is removed.
- `ytLive.Tests/WebView2ManagerTests.cs` (new) — 4 tests: 3 scale-sync + 1
`WebView2_DefaultBackgroundColor_Is_Transparent`, all pass.
- `Models/Source.cs` — `DisplaySource` selector now routes `WebSource` to `VideoImageSource`
like `ChatBox`.
- `Services/LayoutStore.cs` — `WebUri` column added (idempotent `ALTER TABLE`), read/write in
the source load/save queries.
- `ViewModels/MainViewModel.cs` — `WebView2Manager` init + register/unregister lifecycle
(loaded web sources rehydrate, `WebUri` PropertyChanged forwards to manager).
- `MainWindow.xaml` + `.cs` — off-screen WebView2 host `Canvas` (offscreen-positioned at
-5000/-5000) added to the window's visual tree.
- `ytLive.csproj` — `Microsoft.Web.WebView2` 1.0.2903.40 package reference.
- `ytLive.Tests/LayoutStorePersistenceTests.cs` — `WebSource_WebUri_Persists` roundtrip test.
- **User verdict (2026-08-23):** "The AI wrote fucked up code after repeatedly and intentionally misunderstanding instructions." Trust nothing from those sessions without manual verification. Session 2026-08-24 reinforced it: shipped a "fix" that couldn't work (see Run-crash above) and a UX guess that missed — verify behavior, don't pattern-match. Build: 0 warnings. Tests: **234/236 pass** (1 new pass + 2 pre-existing failures unchanged:
`AudioPipelineTests.Mix_HonorsProviderGains_AndGameMute_KillsTheLoopback` (HANDOFF-known,
unrelated) + `RoundClipInteractionTests.Round_Clip_Corner_Is_Grabbable_And_Shape_Is_Circle`
(pre-existing, verified by stash-and-rerun, unrelated).
## Two bugs — status
### Bug 1: Scale (web source doesn't render at configured size)
**PARTIALLY ADDRESSED.** PropertyChanged handler syncs source Width/Height to WebView2 control.
But `CapturePreviewAsync` captures at the HWND's physical pixel size (Width × DPI / 96), which
may not match WPF logical units on high-DPI displays. The capture pipeline scales to
`webView.Width/Height` but this fights the WPF Image element's own `UniformToFill`. Further
work needed.
### Bug 2: Transparency (web source loses transparency in capture)
**SOLVED (2026-08-27).** Root cause was looking at the wrong property: previous session tried
`CoreWebView2.BackgroundColor` and `AddScriptToExecuteOnDocumentCreated` — neither exists on
`CoreWebView2` in this SDK. The real property is **`WebView2.DefaultBackgroundColor`** on the
WPF control (a `System.Drawing.Color`, projected from `ICoreWebView2Controller2`). Confirmed
via `strings` on `Microsoft.Web.WebView2.Wpf.dll` and the IDL §7363: *"In the case of a
transparent `DefaultBackgroundColor` WebView will render hosting app content as the
background."* WPF doc: *"The most recent value set to this property before CoreWebView2Controller
has been initialized will be set on it after initialization."* Setting it in the constructor
(before `InitializeAsync → EnsureCoreWebView2Async`) makes the captured PNG have alpha=0 in
transparent regions. Combined with the existing CSS injection (`html/body { background:
transparent }` on every nav), the pixel-level white→transparent alpha hack is no longer needed
and has been removed.
**Verification path:** launch the app with a stream-elements widget URL, the animation should
over the scene's background with no white box. Visual check needed — no automated test for
capture alpha (would need a real rendered WebView2).
## ⚠️ Landmines (inherited from prior sessions + this session)
- **User verdict (2026-08-23 + 2026-08-26):** "The AI wrote fucked up code after repeatedly and
intentionally misunderstanding instructions." and "fuck you for wasting an entire fucking
day." Trust nothing without verification. **Verify behavior, don't pattern-match.**
- **USER RULE: never `git push` without explicit instruction.** - **USER RULE: never `git push` without explicit instruction.**
- **Pre-existing test failure is ONE** (`AudioPipelineTests.Mix_HonorsProviderGains_AndGameMute_KillsTheLoopback`) — unrelated to this session. - **Pre-existing test failures are TWO** (not one — corrected 2026-08-27):
- **Real-MainWindow tests MUST set `LayoutPathOverride`** (ai.md) — DB pollution wiped real data once. `AudioPipelineTests.Mix_HonorsProviderGains_AndGameMute_KillsTheLoopback` (HANDOFF-known,
- **WPF handle forcing:** `WindowInteropHelper.Handle` pre-show returns Zero and does NOT raise `SourceInitialized`; use `EnsureHandle()` (does both). sound channel related, can't test when not live) +
- **Two MainWindows alive at once double-register hotkeys** — second window's RegisterHotKey fails silently-by-log. Tests close windows before opening the next. `RoundClipInteractionTests.Round_Clip_Corner_Is_Grabbable_And_Shape_Is_Circle` (verified
- **Closing a MainWindow OVERWRITES saved window geometry** (Closing → SaveWindowGeometry). In tests that assert restore-from-DB, save state AFTER closing prior windows. pre-existing by stash-and-rerun 2026-08-27). Both unrelated to web source.
- **liveBroadcasts.update replaces the whole snippet part** — `UpdateBroadcast` echoes scheduledStartTime or YouTube clears the schedule. - **Real-MainWindow tests MUST set `LayoutPathOverride`** (ai.md) — DB pollution wiped real
- **contentDetails lock in created/ready** — latency/DVR/embed etc. cannot be edited while live; deliberately absent from the pull-out form. data once.
- **Text Runs are not Visuals** — ANY new hit-testing code must type-guard before `VisualTreeHelper.GetParent` (it throws on non-Visuals). - **WPF handle forcing:** `WindowInteropHelper.Handle` pre-show returns Zero and does NOT
- **Avatar images: never decode via BitmapImage.UriSource on this box** — use the HttpClient→StreamSource pattern in `MainWindow.LoadAvatarImage`. raise `SourceInitialized`; use `EnsureHandle()` (does both).
- **App running locks ytLive.exe** — build fails MSB3027 until creator exits the app (or ask before killing PID). - **Two MainWindows alive at once double-register hotkeys** — second window's RegisterHotKey
- **`Source.Type` setter derives `IsBackground`** (Models/Source.cs:40) — any Type change must re-set IsBackground after. fails silently-by-log. Tests close windows before opening the next.
- **vstest quirk:** only one `--tests:` param allowed; plain substrings work, alternation regexes silently match nothing. - **Closing a MainWindow OVERWRITES saved window geometry** (Closing → SaveWindowGeometry).
- Notification.Wpf gotchas (ai.md/TASKS.md): area routing by XAML `Name`; unknown AreaName silently drops; `NeverExpires()` = `TimeSpan.MaxValue`; `ToHex()` = `#AARRGGBB`. In tests that assert restore-from-DB, save state AFTER closing prior windows.
- **Dead code flagged, not touched:** `ShowWebcamCommand` / `CanShowWebcamInStagedScene` wired but unbound — audit item. - **liveBroadcasts.update replaces the whole snippet part** — `UpdateBroadcast` echoes
scheduledStartTime or YouTube clears the schedule.
- **contentDetails lock in created/ready** — latency/DVR/embed etc. cannot be edited while live.
- **Text Runs are not Visuals** — ANY new hit-testing code must type-guard before
`VisualTreeHelper.GetParent`.
- **Avatar images: never decode via BitmapImage.UriSource on this box** — use HttpClient→
StreamSource pattern.
- **App running locks ytLive.exe** — build fails MSB3027 until creator exits the app.
- **`Source.Type` setter derives `IsBackground`** (Models/Source.cs:40) — any Type change must
re-set IsBackground after.
- **vstest quirk:** only one `--tests:` param allowed; plain substrings work, alternation
regexes silently match nothing.
- **WebView2 SDK 1.0.2903.40 TFM `net8.0-windows10.0.19041.0`:** the property is
`Microsoft.Web.WebView2.Wpf.WebView2.DefaultBackgroundColor`, NOT `CoreWebView2.BackgroundColor`
(which throws CS1061 because it doesn't exist). Its TYPE is `System.Drawing.Color`, not
`System.Windows.Media.Color` — `using System.Drawing;` is required alongside
`System.Windows.Media` (otherwise `Color` is ambiguous). `AddScriptToExecuteOnDocumentCreated`
is also not on `CoreWebView2` — the equivalent is `WebView2`'s `CoreWebView2.AddScriptToExecuteOnDocumentCreated`
once initialized, or set per-page via `NavigationCompleted`/`NavigationStarting` events (what
the manager does).
- **WebView2 WPF control inherits from HwndHost, NOT Control** — no `Background` property.
- **CapturePreviewAsync preserves alpha** when the controller's `DefaultBackgroundColor` is
transparent — PNG decode → `BitmapImage` → `FormatConvertedBitmap(Bgra32)` keeps the alpha
channel. The previous session's diagnosis ("always opaque white") was based on the default
white `DefaultBackgroundColor`, not on CapturePreview itself.
## Next steps ## Next steps
1. Verify unpushed fixes on next launch: logo click opens About (no crash); avatar shows the real profile image (startup.log free of `Avatar load failed`); then PUSH pending creator go-ahead. 1. Launch the app with a stream-elements widget URL on a scene; visually confirm the animation
2. Continue queue: TASK 17 web source, TASK 18 local recording, TASK 21 media source, TASK 22 audio sync offset. composites over the scene background (no white box). PENDING creator go-ahead.
3. Loose ends: dead `ShowWebcamCommand`, AudioPipelineTests failure, Velopack update URL. 2. Decide on the scale bug (Bug 1) — DPI/physical-pixel mismatch in the capture pipeline.
3. Continue queue: TASK 18 local recording, TASK 21 media source, TASK 22 audio sync offset.
4. Loose ends: dead `ShowWebcamCommand`, the two pre-existing test failures, Velopack update URL.
## Build ## Build
```bash ```bash
+32 -4
View File
@@ -294,7 +294,7 @@
<MenuItem Header="Countdown" Command="{Binding AddSourceCommand}" CommandParameter="{x:Static models:SourceType.Countdown}" <MenuItem Header="Countdown" Command="{Binding AddSourceCommand}" CommandParameter="{x:Static models:SourceType.Countdown}"
Visibility="{Binding IsCountdownAvailable, Converter={StaticResource BoolToVis}}" Visibility="{Binding IsCountdownAvailable, Converter={StaticResource BoolToVis}}"
ToolTip="Timer overlay — Starting and BRB screens only"/> ToolTip="Timer overlay — Starting and BRB screens only"/>
<MenuItem Header="Web" Command="{Binding AddSourceCommand}" CommandParameter="{x:Static models:SourceType.WebSource}" <MenuItem Header="Web Resource" Command="{Binding AddSourceCommand}" CommandParameter="{x:Static models:SourceType.WebSource}"
ToolTip="Embedded web page — set URI in Properties panel"/> ToolTip="Embedded web page — set URI in Properties panel"/>
<MenuItem Header="YouTube Chat" Command="{Binding AddSourceCommand}" CommandParameter="{x:Static models:SourceType.ChatBox}" <MenuItem Header="YouTube Chat" Command="{Binding AddSourceCommand}" CommandParameter="{x:Static models:SourceType.ChatBox}"
IsEnabled="{Binding CanAddYouTubeChat}" IsEnabled="{Binding CanAddYouTubeChat}"
@@ -522,6 +522,27 @@
<!-- Web source config --> <!-- Web source config -->
<StackPanel Visibility="{Binding SelectedElement.IsWebSource, Converter={StaticResource BoolToVis}}" Margin="0,0,0,8"> <StackPanel Visibility="{Binding SelectedElement.IsWebSource, Converter={StaticResource BoolToVis}}" Margin="0,0,0,8">
<TextBlock Text="Geometry" Foreground="#a0a0b0" FontSize="11" FontWeight="SemiBold" Margin="0,0,0,4"/>
<DockPanel Margin="0,0,0,4">
<TextBlock DockPanel.Dock="Left" Text="X" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="16"/>
<TextBox Text="{Binding SelectedElement.X, UpdateSourceTrigger=PropertyChanged, StringFormat=F0}"
Background="#0f3460" Foreground="#e0d0e0" BorderThickness="1"
BorderBrush="#2c3a5c" Padding="4,2" FontSize="12"/>
<TextBlock DockPanel.Dock="Left" Text=" Y" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="20" Margin="8,0,0,0"/>
<TextBox Text="{Binding SelectedElement.Y, UpdateSourceTrigger=PropertyChanged, StringFormat=F0}"
Background="#0f3460" Foreground="#e0d0e0" BorderThickness="1"
BorderBrush="#2c3a5c" Padding="4,2" FontSize="12"/>
</DockPanel>
<DockPanel Margin="0,0,0,4">
<TextBlock DockPanel.Dock="Left" Text="W" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="16"/>
<TextBox Text="{Binding SelectedElement.Width, UpdateSourceTrigger=PropertyChanged, StringFormat=F0}"
Background="#0f3460" Foreground="#e0d0e0" BorderThickness="1"
BorderBrush="#2c3a5c" Padding="4,2" FontSize="12"/>
<TextBlock DockPanel.Dock="Left" Text=" H" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="20" Margin="8,0,0,0"/>
<TextBox Text="{Binding SelectedElement.Height, UpdateSourceTrigger=PropertyChanged, StringFormat=F0}"
Background="#0f3460" Foreground="#e0d0e0" BorderThickness="1"
BorderBrush="#2c3a5c" Padding="4,2" FontSize="12"/>
</DockPanel>
<TextBlock Text="URI" Foreground="#a0a0b0" FontSize="11" Margin="0,0,0,2"/> <TextBlock Text="URI" Foreground="#a0a0b0" FontSize="11" Margin="0,0,0,2"/>
<DockPanel> <DockPanel>
<Button DockPanel.Dock="Right" Style="{StaticResource IconButton}" <Button DockPanel.Dock="Right" Style="{StaticResource IconButton}"
@@ -831,9 +852,12 @@
<Setter Property="Visibility" Value="Visible"/> <Setter Property="Visibility" Value="Visible"/>
</DataTrigger> </DataTrigger>
<DataTrigger Binding="{Binding IsChatBox}" Value="True"> <DataTrigger Binding="{Binding IsChatBox}" Value="True">
<Setter Property="Visibility" Value="Visible"/> <Setter Property="Visibility" Value="Visible"/>
</DataTrigger> </DataTrigger>
<MultiDataTrigger> <DataTrigger Binding="{Binding IsWebSource}" Value="True">
<Setter Property="Visibility" Value="Visible"/>
</DataTrigger>
<MultiDataTrigger>
<MultiDataTrigger.Conditions> <MultiDataTrigger.Conditions>
<Condition Binding="{Binding IsWebcam}" Value="True"/> <Condition Binding="{Binding IsWebcam}" Value="True"/>
<Condition Binding="{Binding ClipShape}" Value="Traditional"/> <Condition Binding="{Binding ClipShape}" Value="Traditional"/>
@@ -1644,6 +1668,10 @@
</Border> </Border>
</Grid> </Grid>
<!-- ═══ WEB SOURCE HOST: hidden panel for WebView2 controls ═══ -->
<Canvas x:Name="WebViewHostPanel" Grid.RowSpan="4"
Opacity="0" IsHitTestVisible="False" Background="Transparent"/>
<!-- ═══ TOASTS: non-blocking notifications (bottom-right, above footer) ═══ --> <!-- ═══ TOASTS: non-blocking notifications (bottom-right, above footer) ═══ -->
<notifications:NotificationArea x:Name="ToastArea" <notifications:NotificationArea x:Name="ToastArea"
Grid.RowSpan="4" Grid.RowSpan="4"
+1
View File
@@ -27,6 +27,7 @@ public partial class MainWindow : Window
InitializeComponent(); InitializeComponent();
AppLog.Write("MainWindow ctor: after InitializeComponent"); AppLog.Write("MainWindow ctor: after InitializeComponent");
_viewModel = (MainViewModel)DataContext; _viewModel = (MainViewModel)DataContext;
_viewModel.InitWebView2(WebViewHostPanel);
_viewModel.PropertyChanged += OnViewModelPropertyChanged; _viewModel.PropertyChanged += OnViewModelPropertyChanged;
_viewModel.ChatMessages.CollectionChanged += OnChatMessagesChanged; _viewModel.ChatMessages.CollectionChanged += OnChatMessagesChanged;
UpdateTaskbarOverlay(); UpdateTaskbarOverlay();
+1 -1
View File
@@ -97,7 +97,7 @@ public class Source : SceneElement
public override ImageSource? DisplaySource => IsLiveCapture public override ImageSource? DisplaySource => IsLiveCapture
? (VideoImageSource ?? _imageSource) ? (VideoImageSource ?? _imageSource)
: (Type == SourceType.ChatBox ? VideoImageSource : _imageSource); : (Type is SourceType.ChatBox or SourceType.WebSource ? VideoImageSource : _imageSource);
public override bool IsImageSource => Type == SourceType.Image; public override bool IsImageSource => Type == SourceType.Image;
+16 -3
View File
@@ -274,6 +274,13 @@ public class LayoutStore : IDisposable
cmd.ExecuteNonQuery(); 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 // Rename legacy "Backdrop" display name to "Background" in existing rows
using (var cmd = _connection.CreateCommand()) using (var cmd = _connection.CreateCommand())
{ {
@@ -591,7 +598,8 @@ public class LayoutStore : IDisposable
X, Y, Width, Height, Opacity, MonitorIndex, ClipShape, IsMirrored, X, Y, Width, Height, Opacity, MonitorIndex, ClipShape, IsMirrored,
IsBackground, CaptureKey, ShowDesktop, IsBackground, CaptureKey, ShowDesktop,
ChatFontSize, ChatFontColor, ChatBackgroundColor, ChatMaxMessages, ChatFontSize, ChatFontColor, ChatBackgroundColor, ChatMaxMessages,
ChatShowTimestamps, ChatShowBadges, ChatMemberColor, ChatSuperChatColor ChatShowTimestamps, ChatShowBadges, ChatMemberColor, ChatSuperChatColor,
WebUri
FROM Source ORDER BY SortOrder FROM Source ORDER BY SortOrder
"""; """;
using var reader = cmd.ExecuteReader(); using var reader = cmd.ExecuteReader();
@@ -628,6 +636,7 @@ public class LayoutStore : IDisposable
ChatShowBadges = reader.IsDBNull(22) ? true : reader.GetInt32(22) != 0, ChatShowBadges = reader.IsDBNull(22) ? true : reader.GetInt32(22) != 0,
ChatMemberColor = reader.IsDBNull(23) ? "#e94560" : reader.GetString(23), ChatMemberColor = reader.IsDBNull(23) ? "#e94560" : reader.GetString(23),
ChatSuperChatColor = reader.IsDBNull(24) ? "#eab308" : reader.GetString(24), ChatSuperChatColor = reader.IsDBNull(24) ? "#eab308" : reader.GetString(24),
WebUri = reader.IsDBNull(25) ? null : reader.GetString(25),
}; };
if (!sourcesByScene.TryGetValue(sceneId, out var list)) if (!sourcesByScene.TryGetValue(sceneId, out var list))
sourcesByScene[sceneId] = list = new List<Source>(); sourcesByScene[sceneId] = list = new List<Source>();
@@ -837,12 +846,14 @@ public class LayoutStore : IDisposable
X, Y, Width, Height, Opacity, MonitorIndex, X, Y, Width, Height, Opacity, MonitorIndex,
ClipShape, IsMirrored, IsBackground, CaptureKey, ShowDesktop, SortOrder, ClipShape, IsMirrored, IsBackground, CaptureKey, ShowDesktop, SortOrder,
ChatFontSize, ChatFontColor, ChatBackgroundColor, ChatMaxMessages, ChatFontSize, ChatFontColor, ChatBackgroundColor, ChatMaxMessages,
ChatShowTimestamps, ChatShowBadges, ChatMemberColor, ChatSuperChatColor) ChatShowTimestamps, ChatShowBadges, ChatMemberColor, ChatSuperChatColor,
WebUri)
VALUES ($id, $sceneId, $assetId, $type, $name, $isEnabled, VALUES ($id, $sceneId, $assetId, $type, $name, $isEnabled,
$x, $y, $w, $h, $opacity, $monitor, $x, $y, $w, $h, $opacity, $monitor,
$clip, $mirrored, $isBackground, $captureKey, $showDesktop, $sort, $clip, $mirrored, $isBackground, $captureKey, $showDesktop, $sort,
$chatFontSize, $chatFontColor, $chatBgColor, $chatMaxMsgs, $chatFontSize, $chatFontColor, $chatBgColor, $chatMaxMsgs,
$chatTimestamps, $chatBadges, $chatMemberColor, $chatSuperChatColor) $chatTimestamps, $chatBadges, $chatMemberColor, $chatSuperChatColor,
$webUri)
"""; """;
cmd.Transaction = tx; cmd.Transaction = tx;
var idP = cmd.Parameters.Add("$id", SqliteType.Text); 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 chatBadgesP = cmd.Parameters.Add("$chatBadges", SqliteType.Integer);
var chatMemberColorP = cmd.Parameters.Add("$chatMemberColor", SqliteType.Text); var chatMemberColorP = cmd.Parameters.Add("$chatMemberColor", SqliteType.Text);
var chatSuperChatColorP = cmd.Parameters.Add("$chatSuperChatColor", SqliteType.Text); var chatSuperChatColorP = cmd.Parameters.Add("$chatSuperChatColor", SqliteType.Text);
var webUriP = cmd.Parameters.Add("$webUri", SqliteType.Text);
foreach (var scene in scenes) foreach (var scene in scenes)
{ {
@@ -904,6 +916,7 @@ public class LayoutStore : IDisposable
chatBadgesP.Value = source.ChatShowBadges ? 1 : 0; chatBadgesP.Value = source.ChatShowBadges ? 1 : 0;
chatMemberColorP.Value = source.ChatMemberColor; chatMemberColorP.Value = source.ChatMemberColor;
chatSuperChatColorP.Value = source.ChatSuperChatColor; chatSuperChatColorP.Value = source.ChatSuperChatColor;
webUriP.Value = (object?)source.WebUri ?? DBNull.Value;
cmd.ExecuteNonQuery(); cmd.ExecuteNonQuery();
} }
} }
+228
View File
@@ -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);
}
}
+53 -4
View File
@@ -131,6 +131,7 @@ public class MainViewModel : ViewModelBase
private readonly ScreenCaptureManager _screenCaptureManager; private readonly ScreenCaptureManager _screenCaptureManager;
private readonly ScreenCaptureSourceFactory _screenCaptureFactory; private readonly ScreenCaptureSourceFactory _screenCaptureFactory;
private readonly ChatBoxRenderer _chatBoxRenderer; private readonly ChatBoxRenderer _chatBoxRenderer;
private WebView2Manager? _webView2Manager;
// Mock chat messages for the ChatBox preview — branded placeholders so the // Mock chat messages for the ChatBox preview — branded placeholders so the
// creator sees where the overlay lands before going live. // creator sees where the overlay lands before going live.
@@ -1892,6 +1893,7 @@ public class MainViewModel : ViewModelBase
UpdateBackgroundImage(); UpdateBackgroundImage();
ReacquireWebcam(); ReacquireWebcam();
ReacquireScreenCaptures(); ReacquireScreenCaptures();
RegisterLoadedWebSources();
_socials = _layoutStore.Socials; _socials = _layoutStore.Socials;
RenderSocialBarFrame(); RenderSocialBarFrame();
HealFediverseSoftwareInBackground(); HealFediverseSoftwareInBackground();
@@ -2109,6 +2111,22 @@ public class MainViewModel : ViewModelBase
_fullScreenDetector.StartWatching(); _fullScreenDetector.StartWatching();
} }
private void RegisterLoadedWebSources()
{
if (_webView2Manager == null) return;
foreach (var source in Scenes.SelectMany(s => s.Elements).OfType<Source>()
.Where(s => s.Type == SourceType.WebSource))
{
_webView2Manager.Register(source);
source.PropertyChanged += (_, e) =>
{
if (e.PropertyName == nameof(Source.WebUri))
_webView2Manager.OnWebUriChanged(source);
};
}
}
// ScreenCaptureManager creates the shared WriteableBitmap on the UI thread; // ScreenCaptureManager creates the shared WriteableBitmap on the UI thread;
// every background pointing at that key picks it up. // every background pointing at that key picks it up.
private void OnScreenPreviewBitmapChanged(string key, WriteableBitmap bitmap) private void OnScreenPreviewBitmapChanged(string key, WriteableBitmap bitmap)
@@ -2272,6 +2290,19 @@ public class MainViewModel : ViewModelBase
_ = RedesignateBackgroundAsync($"{MonitorKeyPrefix}{display.Index}"); _ = RedesignateBackgroundAsync($"{MonitorKeyPrefix}{display.Index}");
} }
public void InitWebView2(Panel hostPanel)
{
_webView2Manager = new WebView2Manager(hostPanel, System.Windows.Application.Current.Dispatcher);
_webView2Manager.PreviewBitmapChanged += OnWebView2PreviewBitmapChanged;
}
private void OnWebView2PreviewBitmapChanged(string sourceId, WriteableBitmap bitmap)
{
foreach (var scene in Scenes)
foreach (var el in scene.Elements.OfType<Source>().Where(s => s.Id == sourceId))
el.VideoImageSource = bitmap;
}
public void Shutdown() public void Shutdown()
{ {
_saveDebounce?.Stop(); _saveDebounce?.Stop();
@@ -2280,6 +2311,7 @@ public class MainViewModel : ViewModelBase
_framePump.Dispose(); _framePump.Dispose();
_cameraManager.Dispose(); _cameraManager.Dispose();
_screenCaptureManager.Dispose(); _screenCaptureManager.Dispose();
_webView2Manager?.Dispose();
_layoutStore.Dispose(); _layoutStore.Dispose();
_musicPlayer.Dispose(); _musicPlayer.Dispose();
_youtubeChat.Dispose(); _youtubeChat.Dispose();
@@ -2488,7 +2520,7 @@ public class MainViewModel : ViewModelBase
SourceType.Image => "Image", SourceType.Image => "Image",
SourceType.TextOverlay => "Text", SourceType.TextOverlay => "Text",
SourceType.Countdown => "Countdown", SourceType.Countdown => "Countdown",
SourceType.WebSource => "Web", SourceType.WebSource => "Web Resource",
SourceType.ChatBox => "YouTube Chat", SourceType.ChatBox => "YouTube Chat",
_ => "Layer", _ => "Layer",
}; };
@@ -2501,17 +2533,31 @@ public class MainViewModel : ViewModelBase
if (sourceType == SourceType.ChatBox && !CanAddYouTubeChat) if (sourceType == SourceType.ChatBox && !CanAddYouTubeChat)
return; return;
// Web resources use "Web Resource-N" naming (0-indexed per scene)
var webCount = scene.Elements.OfType<Source>().Count(s => s.Type == SourceType.WebSource);
var source = new Source var source = new Source
{ {
Name = NextSourceName(scene, baseName), Name = sourceType == SourceType.WebSource
? $"{baseName}-{webCount}"
: NextSourceName(scene, baseName),
Type = sourceType, Type = sourceType,
X = sourceType == SourceType.ChatBox ? 1400 : 0, X = sourceType == SourceType.ChatBox ? 1400 : 0,
Y = sourceType == SourceType.ChatBox ? 20 : 0, Y = sourceType == SourceType.ChatBox ? 20 : 0,
Width = sourceType == SourceType.ChatBox ? 500 : 0, Width = sourceType == SourceType.WebSource ? 640 : sourceType == SourceType.ChatBox ? 500 : 0,
Height = sourceType == SourceType.ChatBox ? 648 : 0, Height = sourceType == SourceType.WebSource ? 480 : sourceType == SourceType.ChatBox ? 648 : 0,
}; };
scene.Elements.Add(source); scene.Elements.Add(source);
if (sourceType == SourceType.WebSource)
{
_webView2Manager?.Register(source);
source.PropertyChanged += (_, e) =>
{
if (e.PropertyName == nameof(Source.WebUri))
_webView2Manager?.OnWebUriChanged(source);
};
}
if (sourceType == SourceType.ChatBox) if (sourceType == SourceType.ChatBox)
{ {
source.PropertyChanged += (_, e) => source.PropertyChanged += (_, e) =>
@@ -2797,6 +2843,8 @@ public class MainViewModel : ViewModelBase
SelectedElement = null; SelectedElement = null;
_ = _cameraManager.ReleaseAsync(_webcam.DeviceId); _ = _cameraManager.ReleaseAsync(_webcam.DeviceId);
} }
if (element is Source { Type: SourceType.WebSource } webSource)
_webView2Manager?.Unregister(webSource.Id);
scene.Elements.Remove(element); scene.Elements.Remove(element);
if (element is WebcamSceneConfig && _webcam != null if (element is WebcamSceneConfig && _webcam != null
@@ -3200,6 +3248,7 @@ public class MainViewModel : ViewModelBase
WebcamSceneConfig webcam => _cameraManager.GetLatestFrame(webcam.WebcamId), WebcamSceneConfig webcam => _cameraManager.GetLatestFrame(webcam.WebcamId),
Source { IsLiveCapture: true, CaptureKey: not null } live => _screenCaptureManager.GetLatestFrame(live.CaptureKey), Source { IsLiveCapture: true, CaptureKey: not null } live => _screenCaptureManager.GetLatestFrame(live.CaptureKey),
Source { Type: SourceType.ChatBox } chatBox => RenderChatBox(chatBox), Source { Type: SourceType.ChatBox } chatBox => RenderChatBox(chatBox),
Source { Type: SourceType.WebSource } web => _webView2Manager?.GetLatestFrame(web.Id),
Source { AssetId: not null } image => StaticPixelCache.Get(image.AssetId), Source { AssetId: not null } image => StaticPixelCache.Get(image.AssetId),
_ => null, _ => null,
}; };
@@ -352,4 +352,40 @@ public class LayoutStorePersistenceTests
try { File.Delete(path); } catch { /* best-effort cleanup */ } 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);
});
}
}
+1
View File
@@ -46,6 +46,7 @@
<PackageReference Include="Notification.Wpf" Version="11.0.0" /> <PackageReference Include="Notification.Wpf" Version="11.0.0" />
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="2.1.12" /> <PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="2.1.12" />
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="8.0.0" /> <PackageReference Include="System.Security.Cryptography.ProtectedData" Version="8.0.0" />
<PackageReference Include="Microsoft.Web.WebView2" Version="1.0.2903.40" />
<PackageReference Include="Velopack" Version="1.2.0" /> <PackageReference Include="Velopack" Version="1.2.0" />
</ItemGroup> </ItemGroup>