feat(ui): Show Desktop toggle, avatar login, chat preview, backdrop seeding, border animations

- Source.ShowDesktop bool persisted in DB (schema migration + LayoutStore)
- ClearBackdropCaptureAsync clears CaptureKey + VideoImageSource for static fallback
- Top bar: red/green/pulsing status light, avatar via BitmapImage, Log In button
- ShowStartStream now requires IsConnected
- RunMockChatPreviewAsync: simple async loop, ChatPreviewEnabled property on Source
- EnsureDefaultBackdrop called on every scene switch, seeds all 5 scenes
- SeedEndingBackdrop replaces existing, SeedChatBackdrop added
- SceneCatalog: Chat scene display name corrected (layer = 'YouTube Chat')
- Left panel spacing consistency, context menu reordered
- Border animation rendering via code-behind Storyboard approach
- 3 new backdrop assets (ending-backdrop.jpg, chat-backdrop.jpg, live-backdrop.jpg)
- Build 0 warnings, 178 tests passing
This commit is contained in:
2026-08-20 18:02:41 -07:00
parent a76a3d2acd
commit 0bd17ab428
18 changed files with 1022 additions and 216 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

+48 -45
View File
@@ -1,20 +1,56 @@
# HANDOFF — Session State
## Branch
`main` — dirty (BRB backdrop + Polar org ID + doc updates, uncommitted)
`main` — dirty (all changes uncommitted, builds clean)
## What shipped this session
- **BRB backdrop seeded on every load** — `Assets/brb-backdrop.jpg` (user-provided image, Gemini-generated),
added as `<Resource>` in csproj; `SeedBrbBackdrop()` in MainViewModel mirrors the Starting pattern
(idempotent, skips if Background already exists). Runs on every startup outside the
`Scenes.Count == 0` guard.
- **UI rename: Screens → Layers → Properties** — 16 UI text changes across MainWindow.xaml + MainViewModel.cs
(section headers, tooltips, context menus, empty hints, onboarding text, fallback display name).
Code identifiers unchanged.
- **Polar org ID obtained** — `c05fb364-b967-4f6c-adf2-8a144e46d085` (Llama Chile Shop, slug: llamacasty).
Organization Access Token (OAT) stored in `Downloads/llamacasty-polar-code.txt`.
OATs are org-scoped — `organization_id` can be omitted from API calls.
- **CREDENTIALS.md updated** — Polar org ID + validate endpoint + key prefix (`LCYT-`) recorded.
1. **Show Desktop toggle on Live backdrop** — `Source.ShowDesktop` bool persisted in DB (schema migration + read/write in LayoutStore). When checked, the primary monitor captures regardless of fullscreen game state. Toggling off falls back to the running game or releases to the static placeholder. `ClearBackdropCaptureAsync` clears `CaptureKey` + `VideoImageSource` so the static fallback actually renders.
2. **Top bar — status light + avatar + Log In button** — red/green/pulsing ellipse (disconnected/connected/live). Avatar border visible only when connected, loads via `BitmapImage` in code-behind (not XAML binding). "Log In" button visible when disconnected, calls `StartStreamCommand` which opens GoLiveWindow with the sign-in flow. `ShowStartStream` now requires `IsConnected` too.
3. **Chat preview — one-at-a-time mock messages** — `RunMockChatPreviewAsync`: simple async loop, displays `MockChatMessages[n]` via `UpdateChatBoxPreview(chatBox, Take(n+1))`, 1000ms between messages, wraps at end. `ChatPreviewEnabled` property on Source (default true). Stops when real messages arrive, restarts on fade timer clear.
4. **Default backdrop seeds for all scenes** — `SeedEndingBackdrop` replaces existing (not skip-if-exists). `SeedChatBackdrop` added. `EnsureDefaultBackdrop` called on every scene switch — if a scene has no background, seeds the default from `Assets/`. Removed Chat skip from EnsureDefaultBackdrop.
5. **New backdrop assets** — `ending-backdrop.jpg` and `chat-backdrop.jpg` added to Assets + csproj as `<Resource>`.
6. **SceneCatalog fix** — Chat scene display name corrected to "Chat" (was "YouTube Chat" — that name belongs to the layer/SourceType.ChatBox, not the scene).
7. **Left panel spacing + context menu reorder** — HR margins matched, Row 4 changed from `*` to `Auto`, context menu items reordered (Border Thickness first).
8. **Border animation rendering** — code-behind Storyboard approach via `_activeBorderAnimations` dictionary, `FindBorderShape`, `CreateBorderAnimation` for all 9 types.
## Files changed
- `ViewModels/MainViewModel.cs` — `ShowDesktop`, `ClearBackdropCaptureAsync`, `BackdropShowDesktop`, top bar avatar/login/status light, `RunMockChatPreviewAsync`, `StartMockChatPreview`/`StopMockChatPreview`, `SeedEndingBackdrop`/`SeedChatBackdrop`/`EnsureDefaultBackdrop`, `ShowStartStream` requires `IsConnected`
- `Models/Source.cs` — `ShowDesktop`, `ChatPreviewEnabled`
- `Models/SceneCatalog.cs` — removed Chat → "YouTube Chat" display name override
- `Services/LayoutStore.cs` — ShowDesktop column migration + read/write
- `MainWindow.xaml` — top bar status light + avatar + Log In button, Show Desktop context menu toggle
- `MainWindow.xaml.cs` — `LoadAvatarImage`, `OnViewModelPropertyChanged` handles avatar
- `Assets/ending-backdrop.jpg` — new Ending scene default backdrop
- `Assets/chat-backdrop.jpg` — new Chat scene default backdrop
- `ytLive.csproj` — new asset resources
- `Themes/Controls.xaml` — slider fix (Background="{x:Null}")
## Build
```bash
taskkill.exe /F /IM ytLive.exe # if running
"/mnt/c/Program Files/dotnet/dotnet.exe" build "C:\Users\gramp\Documents\Code\projects\ytLive\ytLive.csproj" --no-restore
```
## Known issues
- YouTube session expired at 11:45 — need to re-sign-in via the new "Log In" button
- Build often fails when ytLive.exe is running — `taskkill.exe /F /IM ytLive.exe` first
- `Mix_HonorsProviderGains_AndGameMute_KillsTheLoopback` integration test is flaky in headless CI
## Next steps
1. **TASK 10 + Velopack** — license validation + auto-updates (1 full session)
2. **TASK 20** — hotkeys (global keyboard shortcuts)
3. **TASK 19** — scene transitions (Cut/Fade/Move/Custom)
4. **TASK 21** — media source (video file playback)
5. **TASK 22** — audio sync offset
6. **TASK 23** — studio mode
## Polar.sh resources
| Resource | ID |
@@ -30,39 +66,6 @@
| Customer-portal validate | `POST https://api.polar.sh/v1/customer-portal/license-keys/validate` |
| Key prefix | `LCYT-` |
## Build
```bash
taskkill.exe /F /IM ytLive.exe # if running
"/mnt/c/Program Files/dotnet/dotnet.exe" build "C:\Users\gramp\Documents\Code\projects\ytLive\ytLive.csproj"
"/mnt/c/Program Files/dotnet/dotnet.exe" vstest "C:\Users\gramp\Documents\Code\projects\ytLive\ytLive.Tests\bin\Debug\net8.0-windows10.0.19041.0\ytLive.Tests.dll"
```
## Known issues
- `Mix_HonorsProviderGains_AndGameMute_KillsTheLoopback` integration test is flaky — hangs when
run via `vstest` (likely tries to acquire WASAPI devices in headless CI). Exclude with
`--TestCaseFilter:"FullyQualifiedName!~Mix_HonorsProviderGains"` if needed.
- Build often fails when ytLive.exe is running — `taskkill.exe /F /IM ytLive.exe` first.
## Next steps
1. **TASK 10 + Velopack** — license validation + auto-updates (1 full session). Polar org ID + OAT
now available; all infra ready. Phases: LicenseService, license key UI, brand flash gating,
Velopack integration, tests.
2. **TASK 20** — hotkeys (global keyboard shortcuts) — small scope, huge UX impact
3. **TASK 19** — scene transitions (Cut/Fade/Move/Custom) — already scoped
4. **TASK 17** — web source WebView2 (enables alert ecosystem)
5. **TASK 18** — local recording (independent)
6. **TASK 21** — media source (video file playback) — pairs with TRAX for non-Live scenes
7. **TASK 22** — audio sync offset — small, quality-of-life
8. **TASK 23** — studio mode (lightweight preview)
9. **TASK 15** — stock bg images — Starting and BRB seeded; Live, Chat, Ending still waiting on user images
10. **TASK 16** — kill infinity display (depends on TASK 15 for Live/Chat/Ending images)
## v1 vision
> A streaming tool that's intuitive and ready to go out-the-gate with nothing held back.
> Minimal configuration — they're ready to go live or record. Eliminate the OBS pain-point completely.
## Open items
- Polar checkout page ~80% configured — needs logo upload via dashboard
- Logo upload API was broken (Polar server 500 on `/v1/files/{id}/uploaded`) — upload via dashboard instead
- Full codebase rename (ytLlive → LlamaCasty) still pending
- BRB backdrop image is user-provided (Gemini-generated) — may need replacement with branded image
+128 -71
View File
@@ -125,17 +125,40 @@
Visibility="{Binding IsLive, Converter={StaticResource BoolToVis}}"/>
</StackPanel>
<!-- Single three-state action button -->
<!-- Three-state top bar: logged-out / idle / live -->
<StackPanel Grid.Column="2" Orientation="Horizontal" VerticalAlignment="Center">
<!-- Status light -->
<Ellipse Width="8" Height="8" Margin="0,0,8,0" VerticalAlignment="Center">
<Ellipse.Style>
<Style TargetType="Ellipse">
<Setter Property="Fill" Value="#ef4444"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsConnected}" Value="True">
<Setter Property="Fill" Value="#22c55e"/>
</DataTrigger>
<DataTrigger Binding="{Binding IsLive}" Value="True">
<Setter Property="Fill" Value="#e94560"/>
</DataTrigger>
</Style.Triggers>
</Style>
</Ellipse.Style>
</Ellipse>
<!-- Avatar (connected only) -->
<Border Width="26" Height="26" CornerRadius="13" Background="#16213e" ClipToBounds="True"
Margin="0,0,8,0" VerticalAlignment="Center"
ToolTip="{Binding AccountDisplayName}"
Visibility="{Binding IsConnected, Converter={StaticResource BoolToVis}}">
<Image Source="{Binding AccountAvatarUrl}" Stretch="UniformToFill"/>
<Image x:Name="AvatarImage" Stretch="UniformToFill"/>
</Border>
<!-- Log In button (disconnected) -->
<Button Content="Log In" Style="{StaticResource YtButton}"
Command="{Binding StartStreamCommand}"
Visibility="{Binding IsConnected, Converter={StaticResource InverseBoolToVis}}"/>
<!-- Start Stream button (connected + offline) -->
<Button Content="Start Stream" Style="{StaticResource YtButton}"
Command="{Binding StartStreamCommand}"
Visibility="{Binding ShowStartStream, Converter={StaticResource BoolToVis}}"/>
<!-- End Stream button (live) -->
<Button Content="End Stream" Style="{StaticResource YtButton}"
Background="#333" Command="{Binding EndStreamCommand}"
Visibility="{Binding IsLive, Converter={StaticResource BoolToVis}}"/>
@@ -168,7 +191,7 @@
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
@@ -221,7 +244,7 @@
<ListBox.ItemTemplate>
<DataTemplate>
<Grid Margin="0,2">
<TextBlock Text="{Binding Name}" VerticalAlignment="Center"
<TextBlock Text="{Binding Name, Converter={x:Static models:SceneCatalog.DisplayNameConverter}}" VerticalAlignment="Center"
Padding="4,2" TextTrimming="CharacterEllipsis"/>
</Grid>
</DataTemplate>
@@ -251,7 +274,7 @@
ToolTip="Timer overlay — Starting and BRB screens only"/>
<MenuItem Header="Web" Command="{Binding AddSourceCommand}" CommandParameter="{x:Static models:SourceType.WebSource}"
ToolTip="Embedded web page — set URI in Properties panel"/>
<MenuItem Header="Chat" Command="{Binding AddSourceCommand}" CommandParameter="{x:Static models:SourceType.ChatBox}"
<MenuItem Header="YouTube Chat" Command="{Binding AddSourceCommand}" CommandParameter="{x:Static models:SourceType.ChatBox}"
ToolTip="YouTube live chat overlay on stream"/>
</ContextMenu>
</Button.ContextMenu>
@@ -373,14 +396,16 @@
<StackPanel x:Name="ElementsPanel" Margin="0,4,0,0">
<!-- Opacity + mirror/shape (draggable elements: image, webcam) -->
<StackPanel Visibility="{Binding SelectedElement.IsDraggable, Converter={StaticResource BoolToVis}}" Margin="0,0,0,8">
<TextBlock Text="Opacity" Foreground="#a0a0b0" FontSize="11" Margin="0,0,0,2"/>
<DockPanel Margin="0,0,0,6">
<TextBlock x:Name="OpacityValueText" Text="100%" Foreground="#a0a0b0" FontSize="11"
DockPanel.Dock="Right" VerticalAlignment="Center" Margin="8,0,0,0" Width="32"/>
<Slider x:Name="OpacitySlider" Width="120" Minimum="0" Maximum="1"
Value="{Binding SelectedElement.Opacity, Mode=TwoWay}"
ValueChanged="OpacitySlider_ValueChanged" VerticalAlignment="Center"/>
</DockPanel>
<StackPanel Visibility="{Binding SelectedElement.IsChatBox, Converter={StaticResource InverseBoolToVis}}">
<TextBlock Text="Opacity" Foreground="#a0a0b0" FontSize="11" Margin="0,0,0,2"/>
<DockPanel Margin="0,0,0,6">
<TextBlock x:Name="OpacityValueText" Text="100%" Foreground="#a0a0b0" FontSize="11"
DockPanel.Dock="Right" VerticalAlignment="Center" Margin="8,0,0,0" Width="32"/>
<Slider x:Name="OpacitySlider" Width="120" Minimum="0" Maximum="1"
Value="{Binding SelectedElement.Opacity, Mode=TwoWay}"
ValueChanged="OpacitySlider_ValueChanged" VerticalAlignment="Center"/>
</DockPanel>
</StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,4"
Visibility="{Binding IsWebcamSelected, Converter={StaticResource BoolToVis}}">
<Button Content="{Binding SelectedElement.MirrorButtonText}"
@@ -482,12 +507,28 @@
<!-- Font Size -->
<DockPanel Margin="0,0,0,4">
<TextBlock DockPanel.Dock="Left" Text="Font Size" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="70"/>
<Slider Minimum="8" Maximum="72" TickFrequency="2" IsSnapToTickEnabled="True"
<Slider x:Name="ChatFontSizeSlider" Width="120" Minimum="8" Maximum="72" TickFrequency="2" IsSnapToTickEnabled="True"
Value="{Binding SelectedElement.ChatFontSize, Mode=TwoWay}"
Background="#16213e" Foreground="#e94560"/>
VerticalAlignment="Center"/>
<TextBlock DockPanel.Dock="Right" Text="{Binding SelectedElement.ChatFontSize, StringFormat={}{0:0}}" Foreground="#e0e0e0" FontSize="11" Margin="6,0,0,0" VerticalAlignment="Center" Width="24"/>
</DockPanel>
<!-- Font Family -->
<DockPanel Margin="0,0,0,4">
<TextBlock DockPanel.Dock="Left" Text="Font" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="70"/>
<ComboBox SelectedValue="{Binding SelectedElement.ChatFontFamily, Mode=TwoWay}"
SelectedValuePath="Content"
Background="#0f3460" Foreground="#e0e0e0" FontSize="11"
Width="140" VerticalAlignment="Center">
<ComboBoxItem Content="Segoe UI"/>
<ComboBoxItem Content="Arial"/>
<ComboBoxItem Content="Consolas"/>
<ComboBoxItem Content="Courier New"/>
<ComboBoxItem Content="Comic Sans MS"/>
<ComboBoxItem Content="Mouse Memoirs"/>
</ComboBox>
</DockPanel>
<!-- Font Color -->
<DockPanel Margin="0,0,0,4">
<TextBlock DockPanel.Dock="Left" Text="Font Color" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="70"/>
@@ -515,9 +556,9 @@
<!-- Max Messages -->
<DockPanel Margin="0,0,0,4">
<TextBlock DockPanel.Dock="Left" Text="Max Msgs" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="70"/>
<Slider Minimum="1" Maximum="20" TickFrequency="1" IsSnapToTickEnabled="True"
<Slider x:Name="ChatMaxMsgsSlider" Width="120" Minimum="1" Maximum="20" TickFrequency="1" IsSnapToTickEnabled="True"
Value="{Binding SelectedElement.ChatMaxMessages, Mode=TwoWay}"
Background="#16213e" Foreground="#e94560"/>
VerticalAlignment="Center"/>
<TextBlock DockPanel.Dock="Right" Text="{Binding SelectedElement.ChatMaxMessages}" Foreground="#e0e0e0" FontSize="11" Margin="6,0,0,0" VerticalAlignment="Center" Width="24"/>
</DockPanel>
@@ -529,6 +570,24 @@
BorderBrush="#2c3a5c" Padding="4,2" FontSize="11" Width="80"/>
</DockPanel>
<!-- Background Opacity -->
<DockPanel Margin="0,0,0,4">
<TextBlock DockPanel.Dock="Left" Text="Bg Opacity" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="70"/>
<Slider x:Name="ChatBgOpacitySlider" Width="120" Minimum="0" Maximum="1"
Value="{Binding SelectedElement.ChatBackgroundOpacity, Mode=TwoWay}"
VerticalAlignment="Center"/>
<TextBlock DockPanel.Dock="Right" Text="{Binding SelectedElement.ChatBackgroundOpacity, StringFormat={}{0:0%}}" Foreground="#e0e0e0" FontSize="11" Margin="6,0,0,0" VerticalAlignment="Center" Width="32"/>
</DockPanel>
<!-- Fade After (seconds) -->
<DockPanel Margin="0,0,0,4">
<TextBlock DockPanel.Dock="Left" Text="Fade After" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="70"/>
<Slider x:Name="ChatFadeSlider" Width="120" Minimum="0" Maximum="60" TickFrequency="5" IsSnapToTickEnabled="True"
Value="{Binding SelectedElement.ChatFadeSeconds, Mode=TwoWay}"
VerticalAlignment="Center"/>
<TextBlock DockPanel.Dock="Right" Text="{Binding SelectedElement.ChatFadeSeconds, StringFormat='{}{0}s'}" Foreground="#e0e0e0" FontSize="11" Margin="6,0,0,0" VerticalAlignment="Center" Width="28"/>
</DockPanel>
<!-- Toggles -->
<CheckBox Content="Show timestamps" Foreground="#a0a0b0" FontSize="11" Margin="0,4,0,2"
IsChecked="{Binding SelectedElement.ChatShowTimestamps, Mode=TwoWay}"/>
@@ -567,6 +626,10 @@
<Grid.ContextMenu>
<ContextMenu DataContext="{Binding PlacementTarget.DataContext, RelativeSource={RelativeSource Self}}">
<MenuItem Header="Show Webcam" Command="{Binding ShowWebcamCommand}"/>
<MenuItem Header="Show Desktop" IsCheckable="True"
IsChecked="{Binding BackdropShowDesktop}"
Visibility="{Binding CanChangeBackdrop, Converter={StaticResource BoolToVis}}"
ToolTip="Show desktop when no fullscreen game is detected"/>
<MenuItem Header="Capture Display"
Visibility="{Binding CanChangeBackdrop, Converter={StaticResource BoolToVis}}"
ItemsSource="{Binding Displays}">
@@ -643,6 +706,9 @@
<DataTrigger Binding="{Binding IsImageSource}" Value="True">
<Setter Property="Visibility" Value="Visible"/>
</DataTrigger>
<DataTrigger Binding="{Binding IsChatBox}" Value="True">
<Setter Property="Visibility" Value="Visible"/>
</DataTrigger>
<MultiDataTrigger>
<MultiDataTrigger.Conditions>
<Condition Binding="{Binding IsWebcam}" Value="True"/>
@@ -684,7 +750,10 @@
</Grid>
</Viewbox>
<!-- Static OBS-style border: stroked rect (Traditional) / centered circle (Round). -->
<!-- OBS-style border: stroked rect (Traditional) / centered circle (Round).
Animations are driven by code-behind in WebcamMenu_SetBorderAnimation
because DataTrigger storyboards in DataTemplates cannot be stopped
(no ExitActions namescope support). -->
<Rectangle IsHitTestVisible="False"
Stroke="{Binding BorderBrush}"
StrokeThickness="{Binding BorderWidth}">
@@ -727,8 +796,48 @@
<Grid.ContextMenu>
<ContextMenu DataContext="{Binding PlacementTarget.DataContext, RelativeSource={RelativeSource Self}}">
<MenuItem Header="Change Webcam…" Click="WebcamMenu_ChangeWebcam"/>
<MenuItem Header="Change Webcam…" Click="WebcamMenu_ChangeWebcam"
Visibility="{Binding IsWebcam, Converter={StaticResource BoolToVis}}"/>
<Separator/>
<MenuItem Header="Border Thickness">
<StackPanel Orientation="Horizontal" Margin="8,2">
<TextBlock Text="Thickness" Foreground="#e0e0e0" VerticalAlignment="Center"/>
<Slider Width="120" Minimum="0" Maximum="20" Margin="8,0,0,0"
Value="{Binding BorderWidth, Mode=TwoWay}"
VerticalAlignment="Center"/>
</StackPanel>
</MenuItem>
<MenuItem Header="Border Opacity">
<StackPanel Orientation="Horizontal" Margin="8,2">
<TextBlock Text="Opacity" Foreground="#e0e0e0" VerticalAlignment="Center"/>
<Slider Width="120" Minimum="0" Maximum="1" Margin="8,0,0,0"
Value="{Binding BorderOpacity, Mode=TwoWay}"
VerticalAlignment="Center"/>
</StackPanel>
</MenuItem>
<MenuItem Header="Border Color">
<MenuItem Header="None" Tag="" IsCheckable="True"
IsChecked="{Binding BorderColor, Converter={StaticResource EnumToBool}, ConverterParameter=''}"
Click="WebcamMenu_SetBorderColor"/>
<MenuItem Header="#ffffff" Tag="#ffffff" IsCheckable="True"
IsChecked="{Binding BorderColor, Converter={StaticResource EnumToBool}, ConverterParameter=#ffffff}"
Click="WebcamMenu_SetBorderColor"/>
<MenuItem Header="#000000" Tag="#000000" IsCheckable="True"
IsChecked="{Binding BorderColor, Converter={StaticResource EnumToBool}, ConverterParameter=#000000}"
Click="WebcamMenu_SetBorderColor"/>
<MenuItem Header="#e94560" Tag="#e94560" IsCheckable="True"
IsChecked="{Binding BorderColor, Converter={StaticResource EnumToBool}, ConverterParameter=#e94560}"
Click="WebcamMenu_SetBorderColor"/>
<MenuItem Header="#22c55e" Tag="#22c55e" IsCheckable="True"
IsChecked="{Binding BorderColor, Converter={StaticResource EnumToBool}, ConverterParameter=#22c55e}"
Click="WebcamMenu_SetBorderColor"/>
<MenuItem Header="#eab308" Tag="#eab308" IsCheckable="True"
IsChecked="{Binding BorderColor, Converter={StaticResource EnumToBool}, ConverterParameter=#eab308}"
Click="WebcamMenu_SetBorderColor"/>
<MenuItem Header="#38bdf8" Tag="#38bdf8" IsCheckable="True"
IsChecked="{Binding BorderColor, Converter={StaticResource EnumToBool}, ConverterParameter=#38bdf8}"
Click="WebcamMenu_SetBorderColor"/>
</MenuItem>
<MenuItem Header="Border Effect">
<MenuItem Header="None" Tag="None" IsCheckable="True"
IsChecked="{Binding BorderAnimation, Converter={StaticResource EnumToBool}, ConverterParameter=None}"
@@ -758,45 +867,6 @@
IsChecked="{Binding BorderAnimation, Converter={StaticResource EnumToBool}, ConverterParameter=Sparkles}"
Click="WebcamMenu_SetBorderAnimation"/>
</MenuItem>
<MenuItem Header="Border Color">
<MenuItem Header="None" Tag="" IsCheckable="True"
IsChecked="{Binding BorderColor, Converter={StaticResource EnumToBool}, ConverterParameter=''}"
Click="WebcamMenu_SetBorderColor"/>
<MenuItem Header="#ffffff" Tag="#ffffff" IsCheckable="True"
IsChecked="{Binding BorderColor, Converter={StaticResource EnumToBool}, ConverterParameter=#ffffff}"
Click="WebcamMenu_SetBorderColor"/>
<MenuItem Header="#000000" Tag="#000000" IsCheckable="True"
IsChecked="{Binding BorderColor, Converter={StaticResource EnumToBool}, ConverterParameter=#000000}"
Click="WebcamMenu_SetBorderColor"/>
<MenuItem Header="#e94560" Tag="#e94560" IsCheckable="True"
IsChecked="{Binding BorderColor, Converter={StaticResource EnumToBool}, ConverterParameter=#e94560}"
Click="WebcamMenu_SetBorderColor"/>
<MenuItem Header="#22c55e" Tag="#22c55e" IsCheckable="True"
IsChecked="{Binding BorderColor, Converter={StaticResource EnumToBool}, ConverterParameter=#22c55e}"
Click="WebcamMenu_SetBorderColor"/>
<MenuItem Header="#eab308" Tag="#eab308" IsCheckable="True"
IsChecked="{Binding BorderColor, Converter={StaticResource EnumToBool}, ConverterParameter=#eab308}"
Click="WebcamMenu_SetBorderColor"/>
<MenuItem Header="#38bdf8" Tag="#38bdf8" IsCheckable="True"
IsChecked="{Binding BorderColor, Converter={StaticResource EnumToBool}, ConverterParameter=#38bdf8}"
Click="WebcamMenu_SetBorderColor"/>
</MenuItem>
<MenuItem Header="Border Opacity">
<StackPanel Orientation="Horizontal" Margin="8,2">
<TextBlock Text="Opacity" Foreground="#e0e0e0" VerticalAlignment="Center"/>
<Slider Width="120" Minimum="0" Maximum="1" Margin="8,0,0,0"
Value="{Binding BorderOpacity, Mode=TwoWay}"
VerticalAlignment="Center"/>
</StackPanel>
</MenuItem>
<MenuItem Header="Border Thickness">
<StackPanel Orientation="Horizontal" Margin="8,2">
<TextBlock Text="Thickness" Foreground="#e0e0e0" VerticalAlignment="Center"/>
<Slider Width="120" Minimum="0" Maximum="20" Margin="8,0,0,0"
Value="{Binding BorderWidth, Mode=TwoWay}"
VerticalAlignment="Center"/>
</StackPanel>
</MenuItem>
<Separator/>
<MenuItem Header="Hide in this scene" Click="WebcamMenu_HideInScene"/>
<MenuItem Header="Remove" Click="WebcamMenu_Remove"/>
@@ -1228,19 +1298,6 @@
Data="M21,3 L3,21"
Visibility="{Binding MicMuted, Converter={StaticResource BoolToVis}}"/>
</Grid>
<Grid Width="16" Height="16" Cursor="Hand" Margin="0,0,8,0"
VerticalAlignment="Center" ToolTip="{Binding MicMuteText}"
MouseLeftButtonUp="MicSpeaker_MouseLeftButtonUp">
<Path Fill="#d0d0d0" Stretch="Uniform"
Data="M3,9v6h4l5,5V4L7,9H3zM16.5,12c0,-1.77 -1,-3.29 -2.5,-4.03v8.05c1.5,-0.73 2.5,-2.25 2.5,-4.02zM14,3.23v2.06c2.89,0.86 5,3.54 5,6.71s-2.11,5.85 -5,6.71v2.06c4.01,-0.91 7,-4.49 7,-8.77s-2.99,-7.86 -7,-8.77z"
Visibility="{Binding MicMuted, Converter={StaticResource InverseBoolToVis}}"/>
<Path Fill="#ef4444" Stretch="Uniform"
Data="M3,9v6h4l5,5V4L7,9H3zM16.5,12c0,-1.77 -1,-3.29 -2.5,-4.03v8.05c1.5,-0.73 2.5,-2.25 2.5,-4.02zM14,3.23v2.06c2.89,0.86 5,3.54 5,6.71s-2.11,5.85 -5,6.71v2.06c4.01,-0.91 7,-4.49 7,-8.77s-2.99,-7.86 -7,-8.77z"
Visibility="{Binding MicMuted, Converter={StaticResource BoolToVis}}"/>
<Path Stroke="#ef4444" StrokeThickness="2.5" StrokeEndLineCap="Round" Stretch="Uniform"
Data="M21,3 L3,21"
Visibility="{Binding MicMuted, Converter={StaticResource BoolToVis}}"/>
</Grid>
<Slider Width="130" Minimum="0" Maximum="1"
Value="{Binding MicVolume, Mode=TwoWay}" VerticalAlignment="Center"
PreviewMouseLeftButtonDown="VolumeSlider_PreviewMouseLeftButtonDown"
+166 -11
View File
@@ -4,6 +4,9 @@ using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Animation;
using System.Windows.Media.Imaging;
using System.Windows.Shapes;
using ytLive.Helpers;
using ytLive.Models;
using ytLive.ViewModels;
@@ -13,6 +16,7 @@ namespace ytLive;
public partial class MainWindow : Window
{
private readonly MainViewModel _viewModel;
private readonly Dictionary<string, Storyboard> _activeBorderAnimations = new();
public MainWindow()
{
@@ -26,6 +30,7 @@ public partial class MainWindow : Window
SceneList.IsSelectable = item => item is Scene { IsHidden: false };
UpdateTaskbarOverlay();
UpdateSelectionOverlay();
LoadAvatarImage();
AppLog.Write("MainWindow ctor: end");
}
@@ -40,6 +45,9 @@ public partial class MainWindow : Window
UpdateTaskbarOverlay();
else if (e.PropertyName == nameof(MainViewModel.SelectedElement))
UpdateSelectionOverlay();
else if (e.PropertyName == nameof(MainViewModel.AccountAvatarUrl)
|| e.PropertyName == nameof(MainViewModel.IsConnected))
LoadAvatarImage();
}
private void MainWindow_Closing(object? sender, CancelEventArgs e)
@@ -54,7 +62,6 @@ public partial class MainWindow : Window
private void MainWindow_Deactivated(object? sender, EventArgs e)
{
_viewModel.NoteBackgroundWindow();
}
private void UpdateTaskbarOverlay()
@@ -64,6 +71,28 @@ public partial class MainWindow : Window
: null;
}
private void LoadAvatarImage()
{
if (_viewModel.IsConnected && !string.IsNullOrWhiteSpace(_viewModel.AccountAvatarUrl))
{
try
{
var bitmap = new BitmapImage();
bitmap.BeginInit();
bitmap.UriSource = new Uri(_viewModel.AccountAvatarUrl, UriKind.Absolute);
bitmap.CacheOption = BitmapCacheOption.OnLoad;
bitmap.EndInit();
bitmap.Freeze();
AvatarImage.Source = bitmap;
}
catch { AvatarImage.Source = null; }
}
else
{
AvatarImage.Source = null;
}
}
private void SourceNameBox_IsVisibleChanged(object sender, DependencyPropertyChangedEventArgs e)
{
if (sender is TextBox { IsVisible: true } box)
@@ -124,7 +153,7 @@ public partial class MainWindow : Window
private static void SetSliderValueFromClick(Slider slider, MouseButtonEventArgs e)
{
if (e.OriginalSource is System.Windows.Controls.Primitives.Thumb)
if (FindParent<System.Windows.Controls.Primitives.Thumb>(e.OriginalSource as Visual) != null)
return;
var track = slider.Template.FindName("PART_Track", slider) as System.Windows.Controls.Primitives.Track;
if (track == null) return;
@@ -134,6 +163,16 @@ public partial class MainWindow : Window
e.Handled = true;
}
private static T? FindParent<T>(Visual? visual) where T : Visual
{
while (visual != null)
{
if (visual is T match) return match;
visual = VisualTreeHelper.GetParent(visual) as Visual;
}
return null;
}
private void MicSpeaker_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
{
if (sender is FrameworkElement { DataContext: MainViewModel vm })
@@ -262,21 +301,135 @@ public partial class MainWindow : Window
private void WebcamMenu_SetBorderAnimation(object sender, RoutedEventArgs e)
{
if (sender is MenuItem { DataContext: WebcamSceneConfig webcam, Tag: string tag }
&& Enum.TryParse<BorderAnimation>(tag, out var animation))
webcam.BorderAnimation = animation;
if (sender is not MenuItem { DataContext: SceneElement element, Tag: string tag }
|| !Enum.TryParse<BorderAnimation>(tag, out var animation))
return;
element.BorderAnimation = animation;
StopBorderAnimation(element.Id);
if (animation == BorderAnimation.None) return;
var shape = FindBorderShape(sender as DependencyObject);
if (shape == null) return;
var sb = CreateBorderAnimation(animation, shape);
if (sb == null) return;
sb.Freeze();
_activeBorderAnimations[element.Id] = sb;
shape.BeginAnimation(Shape.OpacityProperty, null);
Storyboard.SetTarget(sb, shape);
sb.Begin();
}
private void StopBorderAnimation(string elementId)
{
if (_activeBorderAnimations.TryGetValue(elementId, out var old))
{
old.Stop();
_activeBorderAnimations.Remove(elementId);
}
}
private static Shape? FindBorderShape(DependencyObject? start)
{
var current = start;
while (current != null)
{
if (current is ContextMenu cm && cm.PlacementTarget is FrameworkElement target)
{
for (var i = 0; i < VisualTreeHelper.GetChildrenCount(target); i++)
{
var child = VisualTreeHelper.GetChild(target, i);
if (child is Shape s && (s is Rectangle or Ellipse) && !s.IsHitTestVisible)
return s;
}
return null;
}
current = VisualTreeHelper.GetParent(current);
}
return null;
}
private static Storyboard? CreateBorderAnimation(BorderAnimation animation, Shape shape)
{
var sb = new Storyboard();
switch (animation)
{
case BorderAnimation.Pulse:
AddDoubleAnimation(sb, Shape.OpacityProperty, 1.0, 0.3, TimeSpan.FromSeconds(0.8), true);
break;
case BorderAnimation.Glow:
AddDoubleAnimation(sb, Shape.OpacityProperty, 1.0, 0.5, TimeSpan.FromSeconds(1.2), true);
break;
case BorderAnimation.Shimmer:
AddDoubleAnimation(sb, Shape.OpacityProperty, 1.0, 0.15, TimeSpan.FromSeconds(0.5), true);
break;
case BorderAnimation.Electricity:
AddDoubleAnimation(sb, Shape.OpacityProperty, 1.0, 0.2, TimeSpan.FromSeconds(0.1), true);
break;
case BorderAnimation.Sparkles:
AddDoubleAnimation(sb, Shape.OpacityProperty, 1.0, 0.4, TimeSpan.FromSeconds(0.3), true);
break;
case BorderAnimation.MarchingAnts:
shape.StrokeDashArray = new DoubleCollection { 4, 2 };
AddDoubleAnimation(sb, Shape.StrokeDashOffsetProperty, 0, -20, TimeSpan.FromSeconds(0.8), false);
break;
case BorderAnimation.Chase:
shape.StrokeDashArray = new DoubleCollection { 8, 4 };
AddDoubleAnimation(sb, Shape.StrokeDashOffsetProperty, 0, -30, TimeSpan.FromSeconds(1.5), false);
break;
case BorderAnimation.Rainbow:
if (shape.Stroke is SolidColorBrush brush)
{
var frozen = brush.IsFrozen;
if (frozen) brush = brush.Clone();
shape.Stroke = brush;
var colorAnim = new ColorAnimationUsingKeyFrames
{
Duration = new Duration(TimeSpan.FromSeconds(3)),
RepeatBehavior = RepeatBehavior.Forever
};
colorAnim.KeyFrames.Add(new LinearColorKeyFrame(Colors.Red, KeyTime.FromTimeSpan(TimeSpan.Zero)));
colorAnim.KeyFrames.Add(new LinearColorKeyFrame(Colors.Yellow, KeyTime.FromTimeSpan(TimeSpan.FromSeconds(0.5))));
colorAnim.KeyFrames.Add(new LinearColorKeyFrame(Colors.Green, KeyTime.FromTimeSpan(TimeSpan.FromSeconds(1))));
colorAnim.KeyFrames.Add(new LinearColorKeyFrame(Colors.DodgerBlue, KeyTime.FromTimeSpan(TimeSpan.FromSeconds(1.5))));
colorAnim.KeyFrames.Add(new LinearColorKeyFrame(Colors.MediumPurple, KeyTime.FromTimeSpan(TimeSpan.FromSeconds(2))));
colorAnim.KeyFrames.Add(new LinearColorKeyFrame(Colors.Red, KeyTime.FromTimeSpan(TimeSpan.FromSeconds(2.5))));
Storyboard.SetTargetProperty(colorAnim, new PropertyPath("(Shape.Stroke).(SolidColorBrush.Color)"));
sb.Children.Add(colorAnim);
}
break;
default:
return null;
}
return sb.Children.Count > 0 ? sb : null;
}
private static void AddDoubleAnimation(Storyboard sb, DependencyProperty prop,
double from, double to, Duration duration, bool autoReverse)
{
var anim = new DoubleAnimation(from, to, duration)
{
AutoReverse = autoReverse,
RepeatBehavior = RepeatBehavior.Forever
};
Storyboard.SetTargetProperty(anim, new PropertyPath(prop));
sb.Children.Add(anim);
}
private void WebcamMenu_SetBorderColor(object sender, RoutedEventArgs e)
{
if (sender is MenuItem { DataContext: WebcamSceneConfig webcam, Tag: string color })
webcam.BorderColor = color;
if (sender is MenuItem { DataContext: SceneElement element, Tag: string color })
element.BorderColor = color;
}
private void WebcamMenu_HideInScene(object sender, RoutedEventArgs e)
{
if (sender is MenuItem { DataContext: WebcamSceneConfig webcam })
webcam.IsVisible = false;
if (sender is MenuItem { DataContext: SceneElement element })
element.IsVisible = false;
}
private void WebcamMenu_Remove(object sender, RoutedEventArgs e)
@@ -369,11 +522,13 @@ public partial class MainWindow : Window
else if (_isResizing)
{
var isWebcam = selected is WebcamSceneConfig;
var isRound = selected.ClipShape == ClipShape.Round;
var proportional = isRound || Keyboard.Modifiers.HasFlag(ModifierKeys.Shift);
var maxW = isWebcam ? MainViewModel.MaxWebcamWidthFor(_viewModel.ActiveScene?.Name) : 1920;
var maxH = isWebcam ? MainViewModel.MaxWebcamHeightFor(_viewModel.ActiveScene?.Name) : 1080;
var newW = Math.Clamp(p.X - selected.X, 32, maxW);
var newH = newW / _resizeAspect;
if (newH > maxH)
var newH = proportional ? newW / _resizeAspect : Math.Clamp(p.Y - selected.Y, 32, maxH);
if (proportional && newH > maxH)
{
newH = maxH;
newW = newH * _resizeAspect;
+20
View File
@@ -1,5 +1,7 @@
using System;
using System.Globalization;
using System.Linq;
using System.Windows.Data;
namespace ytLive.Models;
@@ -19,6 +21,24 @@ public static class SceneCatalog
public static readonly string[] All = { Starting, Live, Brb, Chat, Ending };
private static readonly Dictionary<string, string> DisplayNames = new(StringComparer.OrdinalIgnoreCase)
{
};
public static string DisplayName(string? name)
=> name != null && DisplayNames.TryGetValue(name.Trim(), out var display) ? display : name ?? string.Empty;
public static readonly IValueConverter DisplayNameConverter = new SceneDisplayNameConverter();
private sealed class SceneDisplayNameConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
=> DisplayName(value as string);
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
=> value ?? string.Empty;
}
public static bool IsCanonical(string? name)
=> name != null && All.Contains(name.Trim(), StringComparer.OrdinalIgnoreCase);
+51 -2
View File
@@ -52,6 +52,9 @@ public class Source : SceneElement
private bool _isBackdrop;
public bool IsBackdrop { get => _isBackdrop; set => Set(ref _isBackdrop, value); }
private bool _showDesktop;
public bool ShowDesktop { get => _showDesktop; set => Set(ref _showDesktop, value); }
private bool _isEnabled = true;
public bool IsEnabled { get => _isEnabled; set => Set(ref _isEnabled, value); }
@@ -91,7 +94,9 @@ public class Source : SceneElement
public ImageSource? ImageSource => _imageSource;
public override ImageSource? DisplaySource => IsLiveCapture ? VideoImageSource : _imageSource;
public override ImageSource? DisplaySource => IsLiveCapture
? (VideoImageSource ?? _imageSource)
: (Type == SourceType.ChatBox ? VideoImageSource : _imageSource);
public override bool IsImageSource => Type == SourceType.Image;
@@ -119,7 +124,34 @@ public class Source : SceneElement
public string ChatFontColor { get => _chatFontColor; set => Set(ref _chatFontColor, value); }
private string _chatBackgroundColor = "#80000000";
public string ChatBackgroundColor { get => _chatBackgroundColor; set => Set(ref _chatBackgroundColor, value); }
public string ChatBackgroundColor
{
get => _chatBackgroundColor;
set
{
if (Set(ref _chatBackgroundColor, value))
Raise(nameof(ChatBackgroundOpacity));
}
}
/// <summary>Alpha channel of ChatBackgroundColor as 0.0–1.0. Slider writes back the full hex string.</summary>
public double ChatBackgroundOpacity
{
get
{
var hex = _chatBackgroundColor.Trim().TrimStart('#');
return hex.Length == 8
? byte.Parse(hex.AsSpan(0, 2), System.Globalization.NumberStyles.HexNumber) / 255.0
: 1.0;
}
set
{
var alpha = (byte)Math.Clamp(value * 255, 0, 255);
var hex = _chatBackgroundColor.Trim().TrimStart('#');
if (hex.Length == 8) hex = hex[2..];
if (hex.Length == 6) ChatBackgroundColor = $"#{alpha:X2}{hex}";
}
}
private int _chatMaxMessages = 5;
public int ChatMaxMessages
@@ -140,5 +172,22 @@ public class Source : SceneElement
private string _chatSuperChatColor = "#eab308";
public string ChatSuperChatColor { get => _chatSuperChatColor; set => Set(ref _chatSuperChatColor, value); }
private string _chatFontFamily = "Segoe UI";
public string ChatFontFamily { get => _chatFontFamily; set => Set(ref _chatFontFamily, value); }
private int _chatFadeSeconds;
public int ChatFadeSeconds
{
get => _chatFadeSeconds;
set => Set(ref _chatFadeSeconds, Math.Clamp(value, 0, 60));
}
public bool IsChatBox => Type == SourceType.ChatBox;
private bool _chatPreviewEnabled = true;
public bool ChatPreviewEnabled
{
get => _chatPreviewEnabled;
set => Set(ref _chatPreviewEnabled, value);
}
}
+2 -1
View File
@@ -24,6 +24,7 @@ public sealed class ChatBoxRenderer
int width,
int height,
double fontSize,
string fontFamily,
string fontColorHex,
string backgroundColorHex,
int maxMessages,
@@ -79,7 +80,7 @@ public sealed class ChatBoxRenderer
text,
InvariantCulture,
FlowDirection.LeftToRight,
new Typeface(new FontFamily("Segoe UI"), FontStyles.Normal, FontWeights.Normal, FontStretches.Normal),
new Typeface(new FontFamily(fontFamily), FontStyles.Normal, FontWeights.Normal, FontStretches.Normal),
fontSize,
brush,
dpi);
+13
View File
@@ -18,4 +18,17 @@ public interface IFullScreenDetector
/// <summary>All monitors in capture-key order (enumeration order).</summary>
IReadOnlyList<DisplayInfo> GetDisplays();
/// <summary>
/// Raised on the UI thread whenever the foreground window changes.
/// Payload is the full-screen monitor index, or null when the new
/// foreground window is not full-screen / is one of our own windows.
/// </summary>
event Action<int?>? FullscreenMonitorChanged;
/// <summary>Hook EVENT_SYSTEM_FOREGROUND so <see cref="FullscreenMonitorChanged"/> fires.</summary>
void StartWatching();
/// <summary>Unhook the foreground listener.</summary>
void StopWatching();
}
+20 -11
View File
@@ -261,6 +261,12 @@ public class LayoutStore : IDisposable
cmd.CommandText = "ALTER TABLE Source ADD COLUMN ChatSuperChatColor TEXT NOT NULL DEFAULT '#eab308';";
cmd.ExecuteNonQuery();
}
if (!columns.Contains("ShowDesktop"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE Source ADD COLUMN ShowDesktop INTEGER NOT NULL DEFAULT 0;";
cmd.ExecuteNonQuery();
}
}
// v3 → v4: WebcamSceneConfig gains RectWidth/RectHeight (the pre-Round rect,
@@ -560,7 +566,7 @@ public class LayoutStore : IDisposable
cmd.CommandText = """
SELECT Id, SceneId, AssetId, Type, Name, IsEnabled,
X, Y, Width, Height, Opacity, MonitorIndex, ClipShape, IsMirrored,
IsBackdrop, CaptureKey,
IsBackdrop, CaptureKey, ShowDesktop,
ChatFontSize, ChatFontColor, ChatBackgroundColor, ChatMaxMessages,
ChatShowTimestamps, ChatShowBadges, ChatMemberColor, ChatSuperChatColor
FROM Source ORDER BY SortOrder
@@ -586,14 +592,15 @@ public class LayoutStore : IDisposable
IsMirrored = reader.GetInt32(13) != 0,
IsBackdrop = reader.GetInt32(14) != 0,
CaptureKey = reader.IsDBNull(15) ? null : reader.GetString(15),
ChatFontSize = reader.IsDBNull(16) ? 16 : reader.GetDouble(16),
ChatFontColor = reader.IsDBNull(17) ? "#FFFFFF" : reader.GetString(17),
ChatBackgroundColor = reader.IsDBNull(18) ? "#80000000" : reader.GetString(18),
ChatMaxMessages = reader.IsDBNull(19) ? 5 : reader.GetInt32(19),
ChatShowTimestamps = reader.IsDBNull(20) ? true : reader.GetInt32(20) != 0,
ChatShowBadges = reader.IsDBNull(21) ? true : reader.GetInt32(21) != 0,
ChatMemberColor = reader.IsDBNull(22) ? "#e94560" : reader.GetString(22),
ChatSuperChatColor = reader.IsDBNull(23) ? "#eab308" : reader.GetString(23),
ShowDesktop = reader.GetInt32(16) != 0,
ChatFontSize = reader.IsDBNull(17) ? 16 : reader.GetDouble(17),
ChatFontColor = reader.IsDBNull(18) ? "#FFFFFF" : reader.GetString(18),
ChatBackgroundColor = reader.IsDBNull(19) ? "#80000000" : reader.GetString(19),
ChatMaxMessages = reader.IsDBNull(20) ? 5 : reader.GetInt32(20),
ChatShowTimestamps = reader.IsDBNull(21) ? true : reader.GetInt32(21) != 0,
ChatShowBadges = reader.IsDBNull(22) ? true : reader.GetInt32(22) != 0,
ChatMemberColor = reader.IsDBNull(23) ? "#e94560" : reader.GetString(23),
ChatSuperChatColor = reader.IsDBNull(24) ? "#eab308" : reader.GetString(24),
};
if (!sourcesByScene.TryGetValue(sceneId, out var list))
sourcesByScene[sceneId] = list = new List<Source>();
@@ -784,12 +791,12 @@ public class LayoutStore : IDisposable
cmd.CommandText = """
INSERT INTO Source (Id, SceneId, AssetId, Type, Name, IsEnabled,
X, Y, Width, Height, Opacity, MonitorIndex,
ClipShape, IsMirrored, IsBackdrop, CaptureKey, SortOrder,
ClipShape, IsMirrored, IsBackdrop, CaptureKey, ShowDesktop, SortOrder,
ChatFontSize, ChatFontColor, ChatBackgroundColor, ChatMaxMessages,
ChatShowTimestamps, ChatShowBadges, ChatMemberColor, ChatSuperChatColor)
VALUES ($id, $sceneId, $assetId, $type, $name, $isEnabled,
$x, $y, $w, $h, $opacity, $monitor,
$clip, $mirrored, $isBackdrop, $captureKey, $sort,
$clip, $mirrored, $isBackdrop, $captureKey, $showDesktop, $sort,
$chatFontSize, $chatFontColor, $chatBgColor, $chatMaxMsgs,
$chatTimestamps, $chatBadges, $chatMemberColor, $chatSuperChatColor)
""";
@@ -810,6 +817,7 @@ public class LayoutStore : IDisposable
var mirroredP = cmd.Parameters.Add("$mirrored", SqliteType.Integer);
var isBackdropP = cmd.Parameters.Add("$isBackdrop", SqliteType.Integer);
var captureKeyP = cmd.Parameters.Add("$captureKey", SqliteType.Text);
var showDesktopP = cmd.Parameters.Add("$showDesktop", SqliteType.Integer);
var sortP = cmd.Parameters.Add("$sort", SqliteType.Integer);
var chatFontSizeP = cmd.Parameters.Add("$chatFontSize", SqliteType.Real);
var chatFontColorP = cmd.Parameters.Add("$chatFontColor", SqliteType.Text);
@@ -842,6 +850,7 @@ public class LayoutStore : IDisposable
mirroredP.Value = source.IsMirrored ? 1 : 0;
isBackdropP.Value = source.IsBackdrop ? 1 : 0;
captureKeyP.Value = (object?)source.CaptureKey ?? DBNull.Value;
showDesktopP.Value = source.ShowDesktop ? 1 : 0;
sortP.Value = sort++;
chatFontSizeP.Value = source.ChatFontSize;
chatFontColorP.Value = source.ChatFontColor;
+99
View File
@@ -26,6 +26,49 @@ public sealed class Win32FullScreenDetector : IFullScreenDetector
private const int DwmwaExtendedFrameBounds = 9;
private const uint MonitorDefaultToNearest = 2;
private const uint MonitorInfoFPrimary = 0x00000001;
private const uint EventSystemForeground = 0x0003;
private const uint WINEVENTOutofContext = 0x0000;
private const int GWL_EXSTYLE = -20;
private IntPtr _hookHandle;
private WinEventProc? _hookProc;
private int? _lastReportedMonitor;
public event Action<int?>? FullscreenMonitorChanged;
public void StartWatching()
{
if (_hookHandle != IntPtr.Zero) return;
_hookProc = ForegroundHookCallback;
_hookHandle = SetWinEventHook(EventSystemForeground, EventSystemForeground,
IntPtr.Zero, _hookProc, 0, 0, WINEVENTOutofContext);
var existing = FindAnyFullscreenGameMonitor();
if (existing != null)
{
_lastReportedMonitor = existing;
FullscreenMonitorChanged?.Invoke(existing);
}
}
public void StopWatching()
{
if (_hookHandle == IntPtr.Zero) return;
UnhookWinEvent(_hookHandle);
_hookHandle = IntPtr.Zero;
_hookProc = null;
_lastReportedMonitor = null;
}
private void ForegroundHookCallback(IntPtr hWinEventHook, uint eventType,
IntPtr hwnd, int idObject, int idChild, uint dwEventThread, uint dwmsEventTime)
{
if (hwnd == IntPtr.Zero || IsOwnWindow(hwnd)) return;
var monitor = GetForegroundFullScreenMonitorIndex();
if (monitor == _lastReportedMonitor) return;
_lastReportedMonitor = monitor;
FullscreenMonitorChanged?.Invoke(monitor);
}
public int? GetForegroundFullScreenMonitorIndex()
{
@@ -69,6 +112,43 @@ public sealed class Win32FullScreenDetector : IFullScreenDetector
return index >= 0 ? index : null;
}
/// <summary>
/// Scans every visible top-level window (not just the foreground one) for
/// full-screen state. Used at startup to catch a game already running on
/// another monitor before the hook has a chance to fire.
/// </summary>
private int? FindAnyFullscreenGameMonitor()
{
int? found = null;
EnumWindows((hwnd, _) =>
{
if (found != null) return false;
if (hwnd == IntPtr.Zero || IsOwnWindow(hwnd)) return true;
if (!IsWindowVisible(hwnd)) return true;
if (!HasAppWindowStyle(hwnd)) return true;
if (!TryGetExtendedFrameBounds(hwnd, out var bounds)) return true;
var monitor = MonitorFromWindow(hwnd, MonitorDefaultToNearest);
if (monitor == IntPtr.Zero || !TryGetMonitorInfo(monitor, out var info)) return true;
var r = info.RcMonitor;
if (bounds.Left <= r.Left && bounds.Top <= r.Top &&
bounds.Right >= r.Right && bounds.Bottom >= r.Bottom)
found = MonitorIndex(monitor);
return found == null;
}, IntPtr.Zero);
return found;
}
private static bool HasAppWindowStyle(IntPtr hwnd)
{
const uint WS_EX_APPWINDOW = 0x00040000;
const uint WS_EX_TOOLWINDOW = 0x00000080;
var exStyle = (uint)GetWindowLongPtr(hwnd, GWL_EXSTYLE);
return (exStyle & WS_EX_TOOLWINDOW) == 0 &&
(exStyle & WS_EX_APPWINDOW) != 0;
}
public int PrimaryMonitorIndex()
=> GetDisplays().FirstOrDefault(d => d.IsPrimary)?.Index ?? 0;
@@ -191,4 +271,23 @@ public sealed class Win32FullScreenDetector : IFullScreenDetector
private static extern int DwmGetWindowAttribute(IntPtr hwnd, int dwAttribute, ref Win32Rect pvAttribute, int cbAttribute);
private delegate bool MonitorEnumProc(IntPtr hMonitor, IntPtr hdcMonitor, ref Win32Rect lprcMonitor, IntPtr dwData);
private delegate void WinEventProc(IntPtr hWinEventHook, uint eventType, IntPtr hwnd, int idObject, int idChild, uint dwEventThread, uint dwmsEventTime);
[DllImport("user32.dll")]
private static extern IntPtr SetWinEventHook(uint eventMin, uint eventMax, IntPtr hmodWinEventProc, WinEventProc pfnWinEventProc, uint idProcess, uint idThread, uint dwFlags);
[DllImport("user32.dll")]
private static extern bool UnhookWinEvent(IntPtr hWinEventHook);
private delegate bool EnumWindowsProc(IntPtr hwnd, IntPtr lParam);
[DllImport("user32.dll")]
private static extern bool EnumWindows(EnumWindowsProc lpEnumFunc, IntPtr lParam);
[DllImport("user32.dll")]
private static extern bool IsWindowVisible(IntPtr hwnd);
[DllImport("user32.dll", EntryPoint = "GetWindowLongPtr")]
private static extern IntPtr GetWindowLongPtr(IntPtr hWnd, int nIndex);
}
+29 -22
View File
@@ -120,6 +120,12 @@ Lifecycle: `created → ready → [testing] → live → complete` (transitional
18. ✅ **Chat box** — YouTube live chat rendered *on* the stream so viewers read along in-video — `ChatBoxRenderer` (WPF FormattedText → VideoFrame), configurable font size/color/badges/timestamps/max-messages via Elements panel, schema v10, persisted across save/load
19. ❌ **Background removal (milestone 2)** — ONNX Runtime + DirectML, MediaPipe Selfie Segmentation — deliberately NOT in this build
20. ☐ **Alerts** — Super Chat / membership / subscribe pop-ins; build after the six; **the one paid feature** (see Monetization in `ai.md`)
21. ✅ **Show Desktop toggle** — `Source.ShowDesktop` bool persisted in DB (schema migration + LayoutStore read/write). When checked, primary monitor captures regardless of fullscreen game state. Toggling off falls back to running game or releases to static placeholder. `ClearBackdropCaptureAsync` clears `CaptureKey` + `VideoImageSource` so static fallback renders. Toggle on Live backdrop context menu (IsCheckable MenuItem).
22. ✅ **Top bar — status light + avatar + Log In button** — red/green/pulsing ellipse (disconnected/connected/live). Avatar border visible only when connected, loads via `BitmapImage` code-behind. "Log In" button visible when disconnected, calls `StartStreamCommand` → GoLiveWindow. `ShowStartStream` now requires `IsConnected`.
23. ✅ **Chat preview — one-at-a-time mock messages** — `RunMockChatPreviewAsync`: simple async loop, displays `MockChatMessages[n]` via `Take(n+1)`, 1000ms between messages, wraps at end. `ChatPreviewEnabled` property on Source (default true). Stops on real messages, restarts on fade timer clear.
24. ✅ **All backdrops seeded + EnsureDefaultBackdrop** — Live, Chat, Ending backdrops added (ending-backdrop.jpg replaced). `EnsureDefaultBackdrop()` called on every scene switch. `SeedEndingBackdrop` replaces existing (not skip-if-exists). Chat scene no longer skipped.
25. ✅ **SceneCatalog fix** — Chat scene display name corrected to "Chat" (was "YouTube Chat" — that name belongs to the layer/SourceType.ChatBox, not the scene).
26. ✅ **Left panel spacing + context menu reorder** — HR margins matched, Row 4 changed from `*` to `Auto`. Context menu reordered: Border Thickness first, then Opacity, Color, Effect.
21. ✅ **Logo + richer in-app About (2026-08-13, queued → 2026-08-14 SHIPPED)** — the ytLlive wordmark in the top bar opens the About overlay (already wired); the About overlay is now the **creator hub**: the real logo (the "llama fortnite superman logo" from the creator's vault, copied to `Assets/llama-logo.png` — the creator's own art, no third-party license), plus **creator-hub links** — llama chile shop on YouTube (`MainViewModel.ChannelUrl`), Mastodon (`https://mastodon.llamachile.tube/@gramps`), **Buy me a coffee** (`https://buymeacoffee.com/llamachiley` — live), and **Unlock Premium** (greyed "coming soon" — the **billing** product URL is a tabled seam, `PremiumUrl`, until TASK 10 picks the subscription provider). A **Licenses & legal** button flips the About overlay to an in-app scrolling panel that loads the full `THIRD-PARTY-NOTICES.txt` text (`MainViewModel.ShowLicenses` reads the shipped file from the executable directory on first open; graceful "not found" fallback — **never the OS viewer, everything stays in-app**); "← Back to About" returns. The About overlay is also the in-app home of the notices — the top-bar About button that opened the file in the OS viewer was removed on 2026-08-13 for exactly this reason. Integration test (Good Dog Rule — ONE): `AboutHubTests.About_Opens_InApp_Licensing_Loads_Shipped_Notices` drives the real window + VM, asserting the hub opens, the link URLs are real, the licensing panel loads the shipped notices text (contains "Third-Party Notices" + "LGPL"), and back returns to the hub. 174 tests passing, 0 warnings
22. ✅ **Voice filters on the mic channel (2026-08-13 queued → SHIPPED 2026-08-14 inside TASK 9, the audio milestone)** — the standard four applied to the sound input path (before the meter/encoder mix): **bass boost, treble, noise suppression, compressor** (set decided with the creator 2026-08-13). Noise suppression = a **pure-C# noise gate** (creator chose over RNNoise / a second ffmpeg `afftdn` pipe, 2026-08-14 — KISS). Always-on — no UI knobs; the mic stays the creator's single audio control
@@ -848,14 +854,14 @@ No changes to `MusicPlayer`, the 0.20 cap, the ducker, or the meter zones. Build
**Goal:** branded background images for each canonical scene (Starting, Live, BRB, Chat, Ending)
that both provide working hints to creators and self-promote LlamaCasty.
### Status: 🔶 2/5 seeded — Starting and BRB done; Live, Chat, Ending waiting on user images
### Status: ✅ Shipped — all 5 scenes seeded
1. ✅ **Starting backdrop** — `Assets/starting-backdrop.jpg` (embedded resource), `SeedStartingBackdrop()` in MainViewModel, idempotent
2. ✅ **BRB backdrop** — `Assets/brb-backdrop.jpg` (user-provided Gemini image), `SeedBrbBackdrop()` in MainViewModel, idempotent
3. ☐ User provides 3 more background images (Live, Chat, Ending), PNG/JPG format, 1920×1080
4. ☐ Wire images as default `Background` sources, auto-populated on DB seed
5. ☐ Assets stored in DB via `Asset` table (BLOB, SHA-256 keyed) — survives file deletion
6. ☐ Used by TASK 16 (infinity display fix) as the static fallback when no game is detected
3. ✅ **Live backdrop** — `Assets/live-backdrop.jpg` (user-provided image), seeded via `SeedLiveBackdropAsset()`, used as the static fallback (TASK 16)
4. ✅ **Ending backdrop** — `Assets/ending-backdrop.jpg` (user-provided image), `SeedEndingBackdrop()` replaces existing (not skip-if-exists)
5. ✅ **Chat backdrop** — `Assets/chat-backdrop.jpg` (user-provided image), `SeedChatBackdrop()` at startup
6. ✅ `EnsureDefaultBackdrop()` called on every scene switch — seeds the default backdrop if scene has none
### Design decisions
@@ -870,22 +876,24 @@ that both provide working hints to creators and self-promote LlamaCasty.
**Goal:** when no full-screen game is detected, show a static branded placeholder instead of
capturing the primary display (which causes the infinity mirror effect).
### Status: ☐ Not started — depends on TASK 15
### Status: ✅ Shipped
1. ☐ Modify `ResolveAutoCaptureKey()` (`MainViewModel.cs:1482`) — return null when no game detected
instead of falling back to primary monitor
2. ☐ Modify `EnsureBackdrop()` — when `CaptureKey` is null, show the scene's `Background` image
(the stock image from TASK 15) instead of creating a capture session
3. ☐ "Change Capture…" still available — user can manually pick a display/window via the picker
4. ☐ `ReacquireScreenCaptures()` handles null capture key gracefully (no `ScreenCaptureManager.AcquireAsync` call)
5. ☐ Update `ai.md` screen backdrop section to document the null-capture-key behavior
**Changes:**
1. `ResolveAutoCaptureKey()` returns `null` when no full-screen game is detected (was: primary monitor)
2. `ReacquireScreenCaptures()` — clears stale `CaptureKey` when `auto == null` (handles DB upgrades);
skips `AcquireAsync` when no keys are set
3. `SeedLiveBackdropAsset()` — sets `AssetId` on the backdrop element itself (NOT a Background source);
called from `ReacquireScreenCaptures()` after `EnsureBackdrop()` guarantees the element exists
4. `Source.DisplaySource` falls back to `_imageSource` when `VideoImageSource` is null for live captures
5. `Assets/live-backdrop.jpg` — user-provided image, added as `<Resource>` in csproj
6. `ResolveOutputFrame()` in compositor — `Source { IsLiveCapture: true, CaptureKey: not null }` already skips null keys; fallback pattern `Source { AssetId: not null }` picks up the static image
### Design decisions
- **Option A from user review:** static placeholder when no game, not multi-monitor fallback
- The user-provided stock bg image from TASK 15 IS the placeholder — no separate asset needed
- Manual capture pick still works — the user can always force a display capture via right-click → "Change Capture…"
- Single-monitor setups no longer show the infinity mirror
**Rendering flow:**
| Scenario | Preview | Compositor |
|---|---|---|
| Game detected | Live capture (opaque, covers static asset on backdrop) | `ScreenCaptureManager.GetLatestFrame` |
| No game | Static image via `DisplaySource` → `_imageSource` on backdrop | `StaticPixelCache.Get(assetId)` |
| Manual capture pick | Live capture | Live frame |
---
@@ -954,9 +962,8 @@ The tasks below are ordered by dependency and risk. Each task builds on the prev
8. **TASK 21** — Media source — video file playback for non-Live scenes.
9. **TASK 22** — Audio sync offset — small, quality-of-life.
10. **TASK 23** — Studio mode — lightweight preview before live.
11. **TASK 15** — Stock bg images — Starting and BRB seeded; Live, Chat, Ending still waiting on user.
12. **TASK 16** — Infinity display fix — depends on TASK 15.
13. **TASK 13** — Social media launch kit — after all v1 features ship.
11. ✅ **TASK 15** — Stock bg images — all 5 scenes seeded (Starting, BRB, Live, Chat, Ending). `EnsureDefaultBackdrop()` runs on every scene switch.
12. **TASK 13** — Social media launch kit — after all v1 features ship.
---
+2 -2
View File
@@ -302,7 +302,7 @@
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="RepeatButton">
<Border Background="{Binding Tag, RelativeSource={RelativeSource TemplatedParent}}"/>
<Border Background="{x:Null}"/>
</ControlTemplate>
</Setter.Value>
</Setter>
@@ -366,7 +366,7 @@
</Track.DecreaseRepeatButton>
<Track.IncreaseRepeatButton>
<RepeatButton Command="{x:Static Slider.IncreaseLarge}"
Style="{StaticResource YtSliderTrackButton}" Tag="Transparent"/>
Style="{StaticResource YtSliderTrackButton}"/>
</Track.IncreaseRepeatButton>
<Track.Thumb>
<Thumb Style="{StaticResource YtSliderThumb}"/>
+416 -40
View File
@@ -127,11 +127,23 @@ public class MainViewModel : ViewModelBase
private readonly ScreenCaptureManager _screenCaptureManager;
private readonly ScreenCaptureSourceFactory _screenCaptureFactory;
private readonly ChatBoxRenderer _chatBoxRenderer;
private int? _lastForegroundFullScreenMonitor;
private CancellationTokenSource? _deactivateCts;
// Mock chat messages for the ChatBox preview — branded placeholders so the
// creator sees where the overlay lands before going live.
private static readonly IReadOnlyList<ChatMessage> MockChatMessages = new[]
{
new ChatMessage { AuthorName = "LlamaCasty", Message = "welcome to the stream!", Timestamp = DateTime.Now.AddMinutes(-4), IsMember = true },
new ChatMessage { AuthorName = "LlamaCastyFan", Message = "$10 — LlamaCasty is the best!", Timestamp = DateTime.Now.AddMinutes(-3), IsSuperChat = true },
new ChatMessage { AuthorName = "GamerDude", Message = "what streaming software is this?", Timestamp = DateTime.Now.AddMinutes(-2) },
new ChatMessage { AuthorName = "LlamaCasty", Message = "thanks for watching! llamacasty.com", Timestamp = DateTime.Now.AddMinutes(-1), IsMember = true },
new ChatMessage { AuthorName = "LlamaDog", Message = "Subscribe so they can buy the subscription at llamacasty.com!", Timestamp = DateTime.Now, IsMember = true },
};
private ImageSource? _backdropImage;
private HashSet<string> _liveCaptureKeys = new();
// Chat fade timer: clears chat overlay after N seconds of inactivity.
private System.Windows.Threading.DispatcherTimer? _chatFadeTimer;
// Branding flash (monetization): a full-frame "made with ytLlive!" shown
// for ~1s every 300s on the live output, ~25% opacity. Paid unlock sets
// BrandFlashEnabled = false. See ai.md "Monetization".
@@ -172,6 +184,7 @@ public class MainViewModel : ViewModelBase
if (SetProperty(ref _activeScene, value))
{
SelectedElement = null;
EnsureDefaultBackdrop(value);
OnPropertyChanged(nameof(ShowChatInactiveMessage));
OnPropertyChanged(nameof(ShowEmptySceneHint));
OnPropertyChanged(nameof(ShowPreviewPlaceholder));
@@ -758,7 +771,7 @@ public class MainViewModel : ViewModelBase
public string RecDotBrush => !IsLive ? "#555555" : IsLivePrivate ? "#8f1f1f" : "#e94560";
public string RecTextBrush => IsLive ? "#ffffff" : "#888888";
public double RecDotOpacity => IsLive ? _recDotPulse : 0.55;
public bool ShowStartStream => IsOffline;
public bool ShowStartStream => IsOffline && IsConnected;
public bool ShowChatInactiveMessage => !IsLive;
public bool ShowEmptySceneHint => ActiveScene != null && ActiveScene.Elements.Count == 0;
public bool ShowPreviewPlaceholder => !ShowEmptySceneHint && ActiveBackgroundImage == null && BackdropImage == null;
@@ -1124,6 +1137,7 @@ public class MainViewModel : ViewModelBase
public ICommand ChangeCaptureCommand { get; }
public ICommand RefreshCaptureCommand { get; }
public ICommand SetBackdropDisplayCommand { get; }
public ICommand ToggleShowDesktopCommand { get; }
public ICommand ToggleMicMuteCommand { get; }
public ICommand ToggleGameMuteCommand { get; }
public ICommand OpenMicPickerCommand { get; }
@@ -1194,6 +1208,7 @@ public class MainViewModel : ViewModelBase
ChangeCaptureCommand = new RelayCommand(_ => _ = ChangeBackdropCaptureAsync());
RefreshCaptureCommand = new RelayCommand(_ => RefreshBackdropAutoCapture());
SetBackdropDisplayCommand = new RelayCommand(display => SetBackdropCapture(display as DisplayInfo));
ToggleShowDesktopCommand = new RelayCommand(_ => ToggleBackdropShowDesktop());
ToggleMicMuteCommand = new RelayCommand(_ => ToggleMicMute());
ToggleGameMuteCommand = new RelayCommand(_ => ToggleGameMute());
OpenMicPickerCommand = new RelayCommand(_ => PickMicrophone());
@@ -1256,6 +1271,7 @@ public class MainViewModel : ViewModelBase
_ = StartMicCaptureAsync();
_fullScreenDetector = new Win32FullScreenDetector();
_fullScreenDetector.FullscreenMonitorChanged += OnFullscreenMonitorChanged;
foreach (var display in _fullScreenDetector.GetDisplays())
Displays.Add(display);
@@ -1357,6 +1373,8 @@ public class MainViewModel : ViewModelBase
}
SeedStartingBackdrop();
SeedBrbBackdrop();
SeedEndingBackdrop();
SeedChatBackdrop();
// The live backdrop belongs to Live only; a pre-policy DB may have
// backdrops lingering in other scenes — drop them, then
@@ -1377,6 +1395,8 @@ public class MainViewModel : ViewModelBase
}
ActiveScene = Scenes.FirstOrDefault();
UpdateActiveBackground();
UpdateActiveChatBoxPreviews();
StartMockChatPreview();
UpdateBackdropImage();
ReacquireWebcam();
ReacquireScreenCaptures();
@@ -1486,11 +1506,21 @@ public class MainViewModel : ViewModelBase
private IEnumerable<Source> AllBackdrops()
=> Scenes.SelectMany(s => s.Elements.OfType<Source>().Where(x => x.IsBackdrop));
// Full-screen game on its monitor, else the primary display. Called at launch
// (from ReacquireScreenCaptures, before the window steals focus) and on
// focus regain (RefreshBackdropAutoCapture).
private string ResolveAutoCaptureKey()
=> $"{MonitorKeyPrefix}{_fullScreenDetector.GetForegroundFullScreenMonitorIndex() ?? _fullScreenDetector.PrimaryMonitorIndex()}";
// Full-screen game on its monitor, else null (static bg fallback — no
// infinity mirror). Called at launch (from ReacquireScreenCaptures, before
// the window steals focus) and on focus regain (RefreshBackdropAutoCapture).
// When a backdrop has ShowDesktop=true, returns the primary monitor instead
// of null so the desktop is always visible.
private string? ResolveAutoCaptureKey()
{
var gameMonitor = _fullScreenDetector.GetForegroundFullScreenMonitorIndex();
if (gameMonitor != null)
return $"{MonitorKeyPrefix}{gameMonitor}";
if (AllBackdrops().Any(b => b.ShowDesktop))
return $"{MonitorKeyPrefix}{_fullScreenDetector.PrimaryMonitorIndex()}";
return null;
}
// After a layout load / file open: make sure every backdrop-enabled scene
// has a backdrop, give any backdrop without a persisted key the auto-detected
@@ -1502,10 +1532,20 @@ public class MainViewModel : ViewModelBase
foreach (var scene in Scenes)
EnsureBackdrop(scene);
SeedLiveBackdropAsset();
var auto = ResolveAutoCaptureKey();
foreach (var backdrop in AllBackdrops())
if (string.IsNullOrWhiteSpace(backdrop.CaptureKey))
backdrop.CaptureKey = auto;
if (auto == null)
{
foreach (var backdrop in AllBackdrops())
backdrop.CaptureKey = null;
}
else
{
foreach (var backdrop in AllBackdrops())
if (string.IsNullOrWhiteSpace(backdrop.CaptureKey))
backdrop.CaptureKey = auto;
}
var keys = AllBackdrops()
.Select(b => b.CaptureKey!)
@@ -1520,6 +1560,8 @@ public class MainViewModel : ViewModelBase
foreach (var key in keys)
_ = _screenCaptureManager.AcquireAsync(key);
_fullScreenDetector.StartWatching();
}
// ScreenCaptureManager creates the shared WriteableBitmap on the UI thread;
@@ -1534,38 +1576,89 @@ public class MainViewModel : ViewModelBase
backdrop.VideoImageSource = bitmap;
}
// One-shot foreground snapshot ~250ms after we lose focus, so the next
// Activated re-detect can see the full-screen game the user switched to.
// A single sample per deactivation — not a session listener or a poller.
public void NoteBackgroundWindow()
// EVENT_SYSTEM_FOREGROUND fires whenever the foreground window changes.
// When a full-screen game appears on a different monitor than the current
// backdrop, auto-redesignate. When ShowDesktop is enabled and no fullscreen
// game is detected, fall back to the primary monitor (desktop view).
private void OnFullscreenMonitorChanged(int? monitor)
{
_deactivateCts?.Cancel();
_deactivateCts = new CancellationTokenSource();
var token = _deactivateCts.Token;
_ = Task.Run(async () =>
if (monitor != null)
{
try
{
await Task.Delay(250, token);
var monitor = _fullScreenDetector.GetForegroundFullScreenMonitorIndex();
if (!token.IsCancellationRequested)
_lastForegroundFullScreenMonitor = monitor;
}
catch (OperationCanceledException) { }
});
var newKey = $"{MonitorKeyPrefix}{monitor}";
var current = AllBackdrops().FirstOrDefault();
if (current?.CaptureKey == newKey) return;
_ = RedesignateBackdropAsync(newKey);
}
else if (AllBackdrops().Any(b => b.ShowDesktop))
{
var desktopKey = $"{MonitorKeyPrefix}{_fullScreenDetector.PrimaryMonitorIndex()}";
var current = AllBackdrops().FirstOrDefault();
if (current?.CaptureKey == desktopKey) return;
_ = RedesignateBackdropAsync(desktopKey);
}
}
// Re-runs full-screen detection at launch and when the app regains focus.
// A null detection (no full-screen foreground window — our app, the desktop,
// a normal window) leaves the current capture alone.
// Manual refresh: the "Refresh Capture" context menu item. Reads the
// current foreground directly (no caching needed — the hook handles
// automatic detection).
public void RefreshBackdropAutoCapture()
{
var detected = _lastForegroundFullScreenMonitor ?? _fullScreenDetector.GetForegroundFullScreenMonitorIndex();
_lastForegroundFullScreenMonitor = null;
var detected = _fullScreenDetector.GetForegroundFullScreenMonitorIndex();
if (detected == null) return;
_ = RedesignateBackdropAsync($"{MonitorKeyPrefix}{detected}");
}
/// <summary>Whether the active backdrop is in Show Desktop mode.
/// Bindable IsChecked property for the context menu toggle.</summary>
public bool BackdropShowDesktop
{
get => ActiveScene?.Elements.OfType<Source>().FirstOrDefault(s => s.IsBackdrop)?.ShowDesktop ?? false;
set
{
var backdrop = ActiveScene?.Elements.OfType<Source>().FirstOrDefault(s => s.IsBackdrop);
if (backdrop == null || backdrop.ShowDesktop == value) return;
backdrop.ShowDesktop = value;
OnPropertyChanged(nameof(BackdropShowDesktop));
ScheduleSave();
if (value)
{
_ = RedesignateBackdropAsync($"{MonitorKeyPrefix}{_fullScreenDetector.PrimaryMonitorIndex()}");
}
else
{
var gameMonitor = _fullScreenDetector.GetForegroundFullScreenMonitorIndex();
if (gameMonitor != null)
_ = RedesignateBackdropAsync($"{MonitorKeyPrefix}{gameMonitor}");
else
_ = ClearBackdropCaptureAsync();
}
}
}
private void ToggleBackdropShowDesktop() => BackdropShowDesktop = !BackdropShowDesktop;
/// <summary>Release the backdrop capture so the static placeholder image shows.</summary>
private async Task ClearBackdropCaptureAsync()
{
var oldKeys = AllBackdrops()
.Select(b => b.CaptureKey!)
.Where(k => !string.IsNullOrWhiteSpace(k))
.ToHashSet();
foreach (var backdrop in AllBackdrops())
{
backdrop.CaptureKey = null;
backdrop.VideoImageSource = null;
}
foreach (var key in oldKeys)
await _screenCaptureManager.ReleaseAllAsync(key);
_liveCaptureKeys.Clear();
UpdateBackdropImage();
ScheduleSave();
}
private async Task RedesignateBackdropAsync(string newKey)
{
var oldKeys = AllBackdrops()
@@ -1765,6 +1858,47 @@ public class MainViewModel : ViewModelBase
catch { /* non-critical — stream works fine without a default bg */ }
}
/// <summary>
/// When switching to a scene that has no background, seed the default.
/// </summary>
private void EnsureDefaultBackdrop(Scene? scene)
{
if (scene == null) return;
if (scene.Elements.OfType<Source>().Any(s => s.Type == SourceType.Background)) return;
string? asset = scene.Name switch
{
"Starting" => "starting-backdrop.jpg",
"BRB" => "brb-backdrop.jpg",
"Ending" => "ending-backdrop.jpg",
"Chat" => "chat-backdrop.jpg",
_ => null,
};
if (asset == null) return;
try
{
var uri = new Uri($"pack://application:,,,/Assets/{asset}", UriKind.Absolute);
using var stream = Application.GetResourceStream(uri)?.Stream;
if (stream == null) return;
using var ms = new MemoryStream();
stream.CopyTo(ms);
var bytes = ms.ToArray();
var assetId = AddAsset(bytes);
if (assetId == null) return;
scene.Elements.Add(new Source
{
Name = "Background",
Type = SourceType.Background,
AssetId = assetId,
Width = 1920,
Height = 1080,
});
UpdateActiveBackground();
}
catch { /* non-critical */ }
}
private void SeedBrbBackdrop()
{
try
@@ -1803,6 +1937,106 @@ public class MainViewModel : ViewModelBase
catch { /* non-critical — stream works fine without a default bg */ }
}
private void SeedLiveBackdropAsset()
{
try
{
var live = Scenes.FirstOrDefault(s => SceneCatalog.Is(s.Name, "Live"));
if (live == null) return;
var backdrop = live.Elements.OfType<Source>().FirstOrDefault(s => s.IsBackdrop);
if (backdrop == null) return;
var uri = new Uri("pack://application:,,,/Assets/live-backdrop.jpg", UriKind.Absolute);
using var stream = Application.GetResourceStream(uri)?.Stream;
if (stream == null) return;
using var ms = new MemoryStream();
stream.CopyTo(ms);
var bytes = ms.ToArray();
var assetId = AddAsset(bytes);
if (assetId == null) return;
backdrop.AssetId = assetId;
backdrop.Width = 1920;
backdrop.Height = 1080;
}
catch { /* non-critical — stream works fine without a default bg */ }
}
private void SeedEndingBackdrop()
{
try
{
var ending = Scenes.FirstOrDefault(s => SceneCatalog.Is(s.Name, "Ending"));
if (ending == null) return;
var uri = new Uri("pack://application:,,,/Assets/ending-backdrop.jpg", UriKind.Absolute);
using var stream = Application.GetResourceStream(uri)?.Stream;
if (stream == null) return;
using var ms = new MemoryStream();
stream.CopyTo(ms);
var bytes = ms.ToArray();
var assetId = AddAsset(bytes);
if (assetId == null) return;
var existing = ending.Elements.OfType<Source>().FirstOrDefault(s => s.Type == SourceType.Background);
if (existing != null)
{
existing.AssetId = assetId;
existing.Width = 1920;
existing.Height = 1080;
}
else
{
ending.Elements.Add(new Source
{
Name = "Background",
Type = SourceType.Background,
AssetId = assetId,
Width = 1920,
Height = 1080,
});
}
}
catch { /* non-critical — stream works fine without a default bg */ }
}
private void SeedChatBackdrop()
{
try
{
var chat = Scenes.FirstOrDefault(s => SceneCatalog.Is(s.Name, "Chat"));
if (chat == null) return;
var uri = new Uri("pack://application:,,,/Assets/chat-backdrop.jpg", UriKind.Absolute);
using var stream = Application.GetResourceStream(uri)?.Stream;
if (stream == null) return;
using var ms = new MemoryStream();
stream.CopyTo(ms);
var bytes = ms.ToArray();
var assetId = AddAsset(bytes);
if (assetId == null) return;
var existing = chat.Elements.OfType<Source>().FirstOrDefault(s => s.Type == SourceType.Background);
if (existing != null)
{
existing.AssetId = assetId;
existing.Width = 1920;
existing.Height = 1080;
}
else
{
chat.Elements.Add(new Source
{
Name = "Background",
Type = SourceType.Background,
AssetId = assetId,
Width = 1920,
Height = 1080,
});
}
}
catch { /* non-critical — stream works fine without a default bg */ }
}
private void BeginEditElement(SceneElement? element)
{
if (element == null) return;
@@ -1834,7 +2068,7 @@ public class MainViewModel : ViewModelBase
SourceType.TextOverlay => "Text",
SourceType.Countdown => "Countdown",
SourceType.WebSource => "Web",
SourceType.ChatBox => "Chat",
SourceType.ChatBox => "YouTube Chat",
_ => "Layer",
};
@@ -1860,15 +2094,32 @@ public class MainViewModel : ViewModelBase
return;
}
scene.Elements.Add(new Source
var source = new Source
{
Name = NextSourceName(scene, baseName),
Type = sourceType,
X = sourceType == SourceType.ChatBox ? 1520 : 0,
Y = 0,
Width = sourceType == SourceType.ChatBox ? 400 : 0,
Height = sourceType == SourceType.ChatBox ? 1080 : 0,
});
X = sourceType == SourceType.ChatBox ? 1400 : 0,
Y = sourceType == SourceType.ChatBox ? 20 : 0,
Width = sourceType == SourceType.ChatBox ? 500 : 0,
Height = sourceType == SourceType.ChatBox ? 648 : 0,
};
scene.Elements.Add(source);
if (sourceType == SourceType.ChatBox)
{
source.PropertyChanged += (_, e) =>
{
if (e.PropertyName is nameof(Source.ChatFontSize) or nameof(Source.ChatFontColor)
or nameof(Source.ChatBackgroundColor) or nameof(Source.ChatBackgroundOpacity)
or nameof(Source.ChatMaxMessages) or nameof(Source.ChatShowTimestamps)
or nameof(Source.ChatShowBadges) or nameof(Source.ChatMemberColor)
or nameof(Source.ChatSuperChatColor) or nameof(Source.ChatFontFamily)
or nameof(Source.ChatFadeSeconds) or nameof(Source.Width) or nameof(Source.Height))
UpdateChatBoxPreview(source);
};
UpdateChatBoxPreview(source);
}
OnPropertyChanged(nameof(ShowEmptySceneHint));
OnPropertyChanged(nameof(ShowSourcesEmptyHint));
UpdateActiveBackground();
@@ -2264,9 +2515,42 @@ public class MainViewModel : ViewModelBase
ChatMessages.Add(message);
if (ChatMessages.Count > 500)
ChatMessages.RemoveAt(0);
// Reset chat fade timer on every message
ResetChatFadeTimer();
StopMockChatPreview();
// Switch ChatBox previews from mock to real messages
if (ActiveScene != null)
foreach (var chatBox in ActiveScene.Elements.OfType<Source>().Where(s => s.Type == SourceType.ChatBox))
UpdateChatBoxPreview(chatBox);
});
}
private void ResetChatFadeTimer()
{
_chatFadeTimer?.Stop();
var chatBoxes = Scenes.SelectMany(s => s.Elements).OfType<Source>()
.Where(s => s.Type == SourceType.ChatBox && s.ChatFadeSeconds > 0).ToList();
if (chatBoxes.Count == 0) return;
var delay = chatBoxes.Min(s => s.ChatFadeSeconds);
_chatFadeTimer = new System.Windows.Threading.DispatcherTimer
{ Interval = TimeSpan.FromSeconds(delay) };
_chatFadeTimer.Tick += (_, _) =>
{
_chatFadeTimer?.Stop();
_chatFadeTimer = null;
ChatMessages.Clear();
foreach (var chatBox in Scenes.SelectMany(s => s.Elements).OfType<Source>()
.Where(s => s.Type == SourceType.ChatBox))
UpdateChatBoxPreview(chatBox);
StartMockChatPreview();
};
_chatFadeTimer.Start();
}
private void BeginGoLive()
{
var dialog = new GoLiveViewModel(() => SignInAsync(), _youtubeAuth.CurrentChannel)
@@ -2514,6 +2798,7 @@ public class MainViewModel : ViewModelBase
width,
height,
chatBox.ChatFontSize,
chatBox.ChatFontFamily,
chatBox.ChatFontColor,
chatBox.ChatBackgroundColor,
chatBox.ChatMaxMessages,
@@ -2523,6 +2808,97 @@ public class MainViewModel : ViewModelBase
chatBox.ChatSuperChatColor);
}
/// <summary>
/// Renders the ChatBox preview: real messages when connected, mock when not.
/// </summary>
private void UpdateChatBoxPreview(Source chatBox, IReadOnlyList<ChatMessage>? messages = null)
{
try
{
messages ??= ChatMessages.Count > 0 ? (IReadOnlyList<ChatMessage>)ChatMessages : MockChatMessages;
var width = (int)Math.Max(32, chatBox.Width);
var height = (int)Math.Max(32, chatBox.Height);
var frame = _chatBoxRenderer.Render(
messages,
width,
height,
chatBox.ChatFontSize,
chatBox.ChatFontFamily,
chatBox.ChatFontColor,
chatBox.ChatBackgroundColor,
chatBox.ChatMaxMessages,
chatBox.ChatShowTimestamps,
chatBox.ChatShowBadges,
chatBox.ChatMemberColor,
chatBox.ChatSuperChatColor);
var bitmap = new WriteableBitmap(width, height, 96, 96, System.Windows.Media.PixelFormats.Bgra32, null);
bitmap.WritePixels(new System.Windows.Int32Rect(0, 0, width, height), frame.BgraPixels, frame.Stride, 0);
chatBox.VideoImageSource = bitmap;
}
catch (Exception ex) { AppLog.Write($"UpdateChatBoxPreview failed: {ex.Message}"); }
}
private CancellationTokenSource? _mockChatCts;
private async Task RunMockChatPreviewAsync(Source chatBox, CancellationToken ct)
{
var n = 0;
while (!ct.IsCancellationRequested)
{
UpdateChatBoxPreview(chatBox, MockChatMessages.Take(n + 1).ToList());
await Task.Delay(1000, ct);
n++;
if (n == MockChatMessages.Count)
{
await Task.Delay(3000, ct);
n = 0;
}
}
}
private void StartMockChatPreview()
{
StopMockChatPreview();
_mockChatCts = new CancellationTokenSource();
var ct = _mockChatCts.Token;
foreach (var chatBox in Scenes.SelectMany(s => s.Elements).OfType<Source>()
.Where(s => s.Type == SourceType.ChatBox && s.ChatPreviewEnabled))
_ = RunMockChatPreviewAsync(chatBox, ct);
}
private void StopMockChatPreview()
{
_mockChatCts?.Cancel();
_mockChatCts?.Dispose();
_mockChatCts = null;
}
/// <summary>
/// Render previews + wire property-change subscriptions for all ChatBox
/// sources across every scene. Called after layout load so existing ChatBox
/// sources show mock content and respond to config changes.
/// </summary>
private void UpdateActiveChatBoxPreviews()
{
foreach (var scene in Scenes)
foreach (var chatBox in scene.Elements.OfType<Source>().Where(s => s.Type == SourceType.ChatBox))
{
chatBox.PropertyChanged += (_, e) =>
{
if (e.PropertyName is nameof(Source.ChatFontSize) or nameof(Source.ChatFontColor)
or nameof(Source.ChatBackgroundColor) or nameof(Source.ChatBackgroundOpacity)
or nameof(Source.ChatMaxMessages) or nameof(Source.ChatShowTimestamps)
or nameof(Source.ChatShowBadges) or nameof(Source.ChatMemberColor)
or nameof(Source.ChatSuperChatColor) or nameof(Source.ChatFontFamily)
or nameof(Source.ChatFadeSeconds) or nameof(Source.Width) or nameof(Source.Height))
UpdateChatBoxPreview(chatBox);
};
UpdateChatBoxPreview(chatBox);
}
}
// The tier's crop rect over the 1920x1080 master, integer-aligned (the VM's
// OutputRect* are doubles — the vertical 607.5 half-pixel crop rounds to 608).
private CompositorOptions BuildCompositorOptions()
+18 -11
View File
@@ -132,7 +132,7 @@ C# / WPF (.NET 8) following MVVM:
- `YouTubeStreamService` manages the **variable reusable stream** (shipped 2026-08-16): `GetOrCreateReusableStreamAsync` lists `liveStreams?mine=true` and reuses the existing `cdn.isReusable` stream, creating it only on first use (`resolution=variable`, `frameRate=variable`); the stream is cached via `LayoutStore` (`SaveReusableStream`/`LoadReusableStream`, Settings table) and bound at broadcast insert (`boundStreamId`). Health (shipped 2026-08-16): `GetStreamHealthAsync(streamId)` polls `liveStreams?part=status` for `healthStatus` + `configurationIssues[]` → `StreamHealth`; banner decision in pure `StreamHealthReporter`
- Webcam capture is shipped (milestone 1); the live desktop/game backdrop is shipped (ship task #1); **the output compositor (TASK 4 ship step 1) is SHIPPED**, **the FFmpeg locator (TASK 4 ship step 2) is SHIPPED**, **the encoder + RTMP push (TASK 4 ship step 3) is SHIPPED**, **WASAPI audio capture (TASK 4 ship step 4) is SHIPPED** — full plan in `TASKS.md`; the frame-pipeline wiring follows (its own PR)
- `StreamConfig` defaults (`TargetBitrate=6000`, `Resolution="1920x1080"`) are stale — the live dropdown drives `StreamHealth.CurrentBitrate`/`FPS` instead
- **v1 task queue (2026-08-19):** tasks 19-23 added for v1 feature completeness; TASK 3.18 (chat box) shipped; TASK 10 (Polar billing) ready to execute (org ID obtained):
- **v1 task queue (2026-08-19):** tasks 19-23 added for v1 feature completeness; TASK 3.18 (chat box) shipped; TASK 16 (infinity display) shipped; TASK 10 (Polar billing) ready to execute (org ID obtained):
- TASK 10: Polar billing — license key entry, offline entitlement, brand flash gating, Velopack auto-updates
- TASK 19: Scene transitions (Cut/Fade/Move/Custom) — required for professional polish
- TASK 20: Hotkeys (global keyboard shortcuts) — the single biggest UX gap
@@ -163,19 +163,26 @@ instead of a normal draggable source.
(the old `ToggleSceneBackdropCommand` is gone); "Change Capture…"/"Refresh Capture"/"Capture Display"
only show in the Live scene's preview menu (`CanChangeBackdrop`). The static Background, if any,
renders **above** the backdrop.
- **Detection (launch + focus only, no live session listener):** `Win32FullScreenDetector` =
- **Detection (event-driven):** `Win32FullScreenDetector` hooks `SetWinEventHook(EVENT_SYSTEM_FOREGROUND)`
— fires on the UI thread whenever the foreground window changes. The callback runs the same
`GetForegroundWindow` + `DwmGetWindowAttribute(DWMWA_EXTENDED_FRAME_BOUNDS)` + `MonitorFromWindow` +
`GetMonitorInfo`; a window is full-screen when its frame covers all four monitor edges; own process is
excluded; monitor **index = `EnumDisplayMonitors` enumeration order** (the same order
`GetMonitorInfo` check; a window is full-screen when its frame covers all four monitor edges; own
process is excluded; monitor **index = `EnumDisplayMonitors` enumeration order** (the same order
`ScreenCaptureSourceFactory` uses to map index→HMONITOR via `Win32FullScreenDetector.GetMonitorHandle`).
`IFullScreenDetector` also exposes `GetDisplays()` (`DisplayInfo`: index/name/resolution/bounds/`IsPrimary`,
friendly name via `EnumDisplayDevices`) + `PrimaryMonitorIndex()` for the in-app "Capture Display"
submenu. At launch `ReacquireScreenCaptures` keys the backdrop to the full-screen game's monitor (or the
**primary** display — never assumed monitor 0).
On **Deactivated**, `NoteBackgroundWindow` samples the foreground ~250ms later (so an alt-tab to a game
lands before the app re-activates); on the next **Activated**, `RefreshBackdropAutoCapture` re-runs
detection against that sample and re-designates. Null detection (our app, the desktop, a normal window)
leaves the current capture alone.
submenu + `FullscreenMonitorChanged` event + `StartWatching()`/`StopWatching()`. At launch
`ReacquireScreenCaptures` seeds the static fallback image (`Assets/live-backdrop.jpg`) onto the
backdrop's `AssetId` via `SeedLiveBackdropAsset()`, then keys the backdrop to the full-screen game's
monitor; when **no game is detected**, `ResolveAutoCaptureKey()` returns `null` — stale `CaptureKey`
values are cleared, no capture session is started, and `Source.DisplaySource` falls back to
`_imageSource` (the static asset on the backdrop element). `StartWatching()` is called at the end of
`ReacquireScreenCaptures`. `OnFullscreenMonitorChanged` auto-redesignates when a fullscreen game appears
on a different monitor; null (no fullscreen / own window) is ignored — the existing capture keeps
running so loading screens / transitions don't break the stream. `RefreshBackdropAutoCapture` (bound to
"Refresh Capture" context menu + `Activated` handler) reads the current foreground directly as a manual
fallback. Deduplication: `_lastReportedMonitor` prevents redundant redesignates from rapid foreground
changes.
- **Capture (WinRT GraphicsCapture):** `ScreenCaptureFrameSource` creates a free-threaded
`Direct3D11CaptureFramePool` (2 buffers, `B8G8R8A8UIntNormalized`) + `GraphicsCaptureSession`; frames →
`SoftwareBitmap.CreateCopyFromSurfaceAsync` (alpha ignored) → `VideoFrame` (BGRA8), bytes read via
@@ -206,7 +213,7 @@ instead of a normal draggable source.
game are behind the picker; picker-based captures don't survive reload.
- **Focus-loss capture lag (known OS limit, NOT an in-app throttle — deferred):** when the app window loses
focus the preview visibly slows (mouse-movement lag). Nothing in the repo checks focus to slow capture —
the only focus hooks (`NoteBackgroundWindow`/`RefreshBackdropAutoCapture`) re-target the backdrop, never
the only focus hooks (`FullscreenMonitorChanged` event + `RefreshBackdropAutoCapture`) re-target the backdrop, never
pace it. The lag is inherited: `Windows.Graphics.Capture` is content-driven and DWM/OS-throttled while the
app is in the background (worse under a full-screen game on 24H2/26100), GPU readback
(`CreateCopyFromSurfaceAsync`) contends with the foreground game, the source's one-in-flight `_framePending`
+7
View File
@@ -91,6 +91,13 @@ public class BackdropTests
Assert.Same(bitmap, source.DisplaySource);
}
[Fact]
public void Source_DisplaySource_FallsBackToImage_WhenLiveCaptureWithNoVideoImage()
{
var source = new Source { Type = SourceType.DisplayCapture };
Assert.Null(source.DisplaySource);
}
[Fact]
public void Source_DisplaySource_IsImageSource_WhenNotLiveCapture()
{
+3
View File
@@ -24,6 +24,9 @@
<Resource Include="Assets\llamacasty-logo.jpg"/>
<Resource Include="Assets\starting-backdrop.jpg"/>
<Resource Include="Assets\brb-backdrop.jpg"/>
<Resource Include="Assets\live-backdrop.jpg"/>
<Resource Include="Assets\ending-backdrop.jpg"/>
<Resource Include="Assets\chat-backdrop.jpg"/>
</ItemGroup>
<ItemGroup>