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:
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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user