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:
+104
-45
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user