Compare commits
146 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e7cded6879 | |||
| b2036a38e8 | |||
| 85fad1ab79 | |||
| e78c58fc28 | |||
| 938c5b3de4 | |||
| 9761b1d4be | |||
| e3e69d941d | |||
| de81fa3c5d | |||
| f5a9d46881 | |||
| 5aedca7305 | |||
| fae8ec5f32 | |||
| 7f14ffb11e | |||
| e2ecc248e0 | |||
| aea0670723 | |||
| 80038ff152 | |||
| 7b940b6a5a | |||
| a11b15e444 | |||
| 2c9af9426b | |||
| ecb329e578 | |||
| 0981a72eea | |||
| 94f015044a | |||
| d4aa588da0 | |||
| 3a4e17c735 | |||
| cb750660c3 | |||
| d72949e2f9 | |||
| ac6e67aff0 | |||
| 18ab553b0e | |||
| e69db4d26c | |||
| bb5dcb4ba2 | |||
| 57841d39ac | |||
| 4d9188ab2e | |||
| 197ee815a0 | |||
| a9d5f29084 | |||
| 4e0915a36e | |||
| 523c17b7da | |||
| 0c556450cb | |||
| 94e114bfdf | |||
| 5a1993b6db | |||
| f91bf8715d | |||
| 2ac00db0da | |||
| 0d0e55de31 | |||
| c11788574e | |||
| fd5198df9f | |||
| 75723acc1b | |||
| 9d00955004 | |||
| 1e4017df03 | |||
| 94a934ffa9 | |||
| 64a5a6d06f | |||
| b37b8a30f9 | |||
| 71932b9756 | |||
| 76f51e6f4e | |||
| 394d86b94c | |||
| c01206fb8a | |||
| 40c453805d | |||
| d0e4a0fb25 | |||
| b22d08eca6 | |||
| 11a7af2dc0 | |||
| 5ba4d709ae | |||
| 724af1499b | |||
| a62a283fd4 | |||
| 992bb21bdb | |||
| 58050d9ab1 | |||
| efe88b8a0b | |||
| 0f72c53aa5 | |||
| b4bba4bf68 | |||
| ea347f211c | |||
| f3d578c81b | |||
| 5e78065c2d | |||
| ec7c734bdd | |||
| c45cbc93b7 | |||
| bd396e488c | |||
| d212d5ae2c | |||
| 1a39b091a0 | |||
| e564e9884c | |||
| 081e4c1bf8 | |||
| 5175c1c8c9 | |||
| f6802c7124 | |||
| 84aa70f532 | |||
| ed9d7c1ffc | |||
| 5ca9778e23 | |||
| e002847777 | |||
| 70db34455f | |||
| 0f059ece33 | |||
| 6d11e8b3fc | |||
| 87509bcf99 | |||
| 98d30c2056 | |||
| 1295e0ed80 | |||
| 0f441d825a | |||
| d35823a4a9 | |||
| 99aeef7994 | |||
| c46f3a1168 | |||
| fbc8562cf4 | |||
| eb4c379b91 | |||
| 09a866e6a1 | |||
| 6af2026906 | |||
| 27bf74389d | |||
| 1c48849853 | |||
| 432adfdaef | |||
| 716a77f61a | |||
| 7fcb2ad3da | |||
| 4509befb98 | |||
| af0d372b65 | |||
| 89fee6ca4a | |||
| 97ffc426fe | |||
| 8dcaee0b5b | |||
| 7f2bda8ed3 | |||
| 5ead064d54 | |||
| 5a1a3c566a | |||
| 688682d5b5 | |||
| 22b780e075 | |||
| 7294a1aa2d | |||
| b4f3b52b85 | |||
| 6799c40175 | |||
| 711ba54af7 | |||
| e1d8b10388 | |||
| 7c6ec3e40a | |||
| 8fa54d2de7 | |||
| 4a321b19ff | |||
| 097dd0d9bd | |||
| 881addb5b4 | |||
| a2219be198 | |||
| 8fa78423e6 | |||
| 071ec8be0c | |||
| 480ead0fc7 | |||
| a1d9751a6f | |||
| 8f490102ee | |||
| f89b9f9ffa | |||
| e8ff4df05f | |||
| 670fe3a10a | |||
| 3bf053a3b1 | |||
| 84a038c498 | |||
| 85893ea709 | |||
| 3107f928ab | |||
| 5b9ff5b742 | |||
| dcb3637cca | |||
| d3271a0405 | |||
| fab2e09903 | |||
| 31362d1a43 | |||
| 6944db8ab4 | |||
| cb54637e9f | |||
| 1f4624c636 | |||
| 544208e002 | |||
| c9fd1bdaaa | |||
| 73397cfea7 | |||
| c5e3466df0 | |||
| 90f5ad50dd |
@@ -10,3 +10,4 @@ Helpers/OAuthCredentials.cs
|
||||
MONETIZATION.md
|
||||
MARCOM.md
|
||||
CREDENTIALS.md
|
||||
.aider*
|
||||
|
||||
@@ -38,7 +38,7 @@ conventions live here and in `ai.md`.
|
||||
|
||||
1. [`schema.md`](schema.md) — the memory-map conventions (what lives where, how to keep it true).
|
||||
2. [`ai.md`](ai.md) — the AI guide: architecture, patterns, decisions, current state.
|
||||
3. [`TASKS.md`](TASKS.md) — the task queue + authoritative YouTube API research.
|
||||
3. [`TASKS.md`](TASKS.md) — the task catalog (index + queue state); open items and API research facts are in [`TASKS/`](TASKS/).
|
||||
4. [`HANDOFF.md`](HANDOFF.md) — current operational state: what's in flight, landmines, next step.
|
||||
5. `<dir>/index.md` — the index for any directory you're about to touch.
|
||||
|
||||
|
||||
@@ -26,6 +26,10 @@ public partial class App : Application
|
||||
AppLog.Write(args.Exception, "Dispatcher unhandled exception");
|
||||
|
||||
AppLog.Write("App OnStartup begin");
|
||||
// Attribution (2026-09-04): every take must be traceable to the exact binary
|
||||
// that produced it — the wordmark shows the release counter (BuildStamp.Release);
|
||||
// the per-build GUID is forensic only and lands here.
|
||||
AppLog.Write($"Build {ytLive.Helpers.BuildStamp.Id} (compiled {ytLive.Helpers.BuildStamp.BuiltLocal})");
|
||||
|
||||
// Velopack auto-update bootstrap — checks for updates on startup and
|
||||
// applies them (the app restarts automatically if an update is found).
|
||||
|
||||
Binary file not shown.
@@ -9,22 +9,6 @@
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<!-- Gear icon: far left -->
|
||||
<Button Grid.Column="0" ToolTip="Menu" Style="{StaticResource IconButton}"
|
||||
VerticalAlignment="Center" Click="GearButton_Click">
|
||||
<Button.ContextMenu>
|
||||
<ContextMenu DataContext="{Binding PlacementTarget.DataContext, RelativeSource={RelativeSource Self}}">
|
||||
<MenuItem Header="App Settings" Command="{Binding OpenSettingsCommand}"/>
|
||||
<Separator/>
|
||||
<MenuItem Header="Report Bug" Command="{Binding OpenBugCommand}"/>
|
||||
<MenuItem Header="Feature Request" Command="{Binding OpenFeatureCommand}"/>
|
||||
<MenuItem Header="About" Command="{Binding OpenAboutCommand}"/>
|
||||
</ContextMenu>
|
||||
</Button.ContextMenu>
|
||||
<Path Data="M19.14,12.94c0.04,-0.3 0.06,-0.61 0.06,-0.94c0,-0.32 -0.02,-0.64 -0.07,-0.94l2.03,-1.58c0.18,-0.14 0.23,-0.41 0.12,-0.61l-1.92,-3.32c-0.12,-0.22 -0.37,-0.29 -0.59,-0.22l-2.39,0.96c-0.5,-0.38 -1.03,-0.7 -1.62,-0.94L14.4,2.81c-0.04,-0.24 -0.24,-0.41 -0.48,-0.41h-3.84c-0.24,0 -0.43,0.17 -0.47,0.41L9.25,5.35C8.66,5.59 8.12,5.92 7.63,6.29L5.24,5.33c-0.22,-0.08 -0.47,0 -0.59,0.22L2.74,8.87C2.62,9.08 2.66,9.34 2.86,9.48l2.03,1.58C4.84,11.36 4.8,11.69 4.8,12s0.02,0.64 0.07,0.94l-2.03,1.58c-0.18,0.14 -0.23,0.41 -0.12,0.61l1.92,3.32c0.12,0.22 0.37,0.29 0.59,0.22l2.39,-0.96c0.5,0.38 1.03,0.7 1.62,0.94l0.36,2.54c0.05,0.24 0.24,0.41 0.48,0.41h3.84c0.24,0 0.44,-0.17 0.47,-0.41l0.36,-2.54c0.59,-0.24 1.13,-0.56 1.62,-0.94l2.39,0.96c0.22,0.08 0.47,0 0.59,-0.22l1.92,-3.32c0.12,-0.22 0.07,-0.47 -0.12,-0.61L19.14,12.94zM12,15.6c-1.98,0 -3.6,-1.62 -3.6,-3.6s1.62,-3.6 3.6,-3.6s3.6,1.62 3.6,3.6S13.98,15.6 12,15.6z"
|
||||
Fill="#d0d0d0" Width="16" Height="16" Stretch="Uniform"/>
|
||||
</Button>
|
||||
|
||||
<!-- Stream stats: centered under the preview pane -->
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||
<TextBlock Text="Bitrate:" Style="{StaticResource YtLabel}" Margin="0,0,4,0"/>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
|
||||
namespace ytLive.Controls;
|
||||
@@ -9,14 +8,4 @@ public partial class BottomBar : UserControl
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
private void GearButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is Button { ContextMenu: { } menu } button)
|
||||
{
|
||||
menu.PlacementTarget = button;
|
||||
menu.Placement = System.Windows.Controls.Primitives.PlacementMode.Bottom;
|
||||
menu.IsOpen = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,607 @@
|
||||
<UserControl x:Class="ytLive.Controls.LeftPanel"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:Helpers="clr-namespace:ytLive.Helpers"
|
||||
xmlns:models="clr-namespace:ytLive.Models">
|
||||
<Border Background="#16213e" CornerRadius="6" Padding="12">
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="*"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- Edit mode (offline): layers + properties -->
|
||||
<Grid Grid.Row="0" Visibility="{Binding IsEditMode, Converter={StaticResource BoolToVis}}">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- LAYERS header + Add button -->
|
||||
<StackPanel Grid.Row="0" Orientation="Horizontal" Margin="0,8,0,0">
|
||||
<TextBlock Text="LAYERS" Style="{StaticResource SectionHeader}" VerticalAlignment="Center"/>
|
||||
<Button ToolTip="Add Layer" Style="{StaticResource IconButton}" Margin="4,-4,0,0"
|
||||
Click="AddSourceButton_Click">
|
||||
<Button.ContextMenu>
|
||||
<ContextMenu DataContext="{Binding PlacementTarget.DataContext, RelativeSource={RelativeSource Self}}">
|
||||
<MenuItem Header="Webcam" Command="{Binding AddWebcamCommand}"
|
||||
IsEnabled="{Binding CanAddWebcam}"
|
||||
ToolTip="{Binding WebcamAddToolTip}"/>
|
||||
<MenuItem Header="Image" Command="{Binding AddImageCommand}"/>
|
||||
<MenuItem Header="Text" Command="{Binding AddSourceCommand}" CommandParameter="{x:Static models:SourceType.TextOverlay}"/>
|
||||
<MenuItem Header="Countdown" Command="{Binding AddSourceCommand}" CommandParameter="{x:Static models:SourceType.Countdown}"
|
||||
Visibility="{Binding IsCountdownAvailable, Converter={StaticResource BoolToVis}}"
|
||||
ToolTip="Timer overlay — Starting and BRB screens only"/>
|
||||
<MenuItem Header="Web Resource" Command="{Binding AddSourceCommand}" CommandParameter="{x:Static models:SourceType.WebSource}"
|
||||
ToolTip="Embedded web page — set URI in Properties panel"/>
|
||||
<MenuItem Header="YouTube Chat" Command="{Binding AddSourceCommand}" CommandParameter="{x:Static models:SourceType.ChatBox}"
|
||||
IsEnabled="{Binding CanAddYouTubeChat}"
|
||||
ToolTip="One chat layer at a time — it's already in your stream"/>
|
||||
<MenuItem Header="Stream Alerts" Command="{Binding AddSourceCommand}" CommandParameter="{x:Static models:SourceType.AlertBox}"
|
||||
IsEnabled="{Binding CanAddAlerts}"
|
||||
ToolTip="Celebration zone for Super Chat & memberships — one alert layer at a time"/>
|
||||
</ContextMenu>
|
||||
</Button.ContextMenu>
|
||||
<Path Data="M12,4 L12,20 M4,12 L20,12" Stroke="#e94560" StrokeThickness="2"
|
||||
Width="14" Height="14" Stretch="Uniform"/>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
|
||||
<Helpers:FocusPreservingListBox x:Name="SourceList" Grid.Row="1" Background="Transparent" BorderThickness="0"
|
||||
ItemsSource="{Binding StagedScene.Elements}"
|
||||
SelectedItem="{Binding SelectedElement, Mode=TwoWay}"
|
||||
PreviewMouseLeftButtonDown="List_PreviewMouseLeftButtonDown"
|
||||
PreviewMouseMove="List_PreviewMouseMove"
|
||||
PreviewMouseLeftButtonUp="List_PreviewMouseLeftButtonUp"
|
||||
Foreground="#e0e0e0" FontSize="13">
|
||||
<Helpers:FocusPreservingListBox.ItemContainerStyle>
|
||||
<Style TargetType="ListBoxItem" BasedOn="{StaticResource YtListBoxItem}">
|
||||
<Setter Property="HorizontalContentAlignment" Value="Stretch"/>
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding IsVisible}" Value="False">
|
||||
<Setter Property="Opacity" Value="0.45"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</Helpers:FocusPreservingListBox.ItemContainerStyle>
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Grid Margin="0,2"
|
||||
Tag="{Binding DataContext.CanChangeBackground, RelativeSource={RelativeSource AncestorType=Window}}">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<Grid.ContextMenu>
|
||||
<ContextMenu DataContext="{Binding PlacementTarget.DataContext, RelativeSource={RelativeSource Self}}">
|
||||
<MenuItem Header="Change Capture…" Click="BackgroundMenu_ChangeCapture">
|
||||
<MenuItem.Visibility>
|
||||
<MultiBinding Converter="{StaticResource AllTrueToVis}">
|
||||
<Binding Path="IsBackground"/>
|
||||
<Binding Path="PlacementTarget.Tag" RelativeSource="{RelativeSource AncestorType={x:Type ContextMenu}}"/>
|
||||
</MultiBinding>
|
||||
</MenuItem.Visibility>
|
||||
</MenuItem>
|
||||
<MenuItem Header="Refresh Capture" Click="BackgroundMenu_RefreshCapture">
|
||||
<MenuItem.Visibility>
|
||||
<MultiBinding Converter="{StaticResource AllTrueToVis}">
|
||||
<Binding Path="IsBackground"/>
|
||||
<Binding Path="PlacementTarget.Tag" RelativeSource="{RelativeSource AncestorType={x:Type ContextMenu}}"/>
|
||||
</MultiBinding>
|
||||
</MenuItem.Visibility>
|
||||
</MenuItem>
|
||||
<MenuItem Header="Capture Window…"
|
||||
ItemsSource="{Binding DataContext.OpenWindows, RelativeSource={RelativeSource AncestorType=Window}}">
|
||||
<MenuItem.Visibility>
|
||||
<MultiBinding Converter="{StaticResource AllTrueToVis}">
|
||||
<Binding Path="IsBackground"/>
|
||||
<Binding Path="PlacementTarget.Tag" RelativeSource="{RelativeSource AncestorType={x:Type ContextMenu}}"/>
|
||||
</MultiBinding>
|
||||
</MenuItem.Visibility>
|
||||
<MenuItem.ItemContainerStyle>
|
||||
<Style TargetType="MenuItem">
|
||||
<Setter Property="Header" Value="{Binding Label}"/>
|
||||
<Setter Property="Command"
|
||||
Value="{Binding DataContext.CaptureWindowCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
|
||||
<Setter Property="CommandParameter" Value="{Binding}"/>
|
||||
</Style>
|
||||
</MenuItem.ItemContainerStyle>
|
||||
</MenuItem>
|
||||
<MenuItem Header="Refresh Window List" Click="BackgroundMenu_RefreshWindowList">
|
||||
<MenuItem.Visibility>
|
||||
<MultiBinding Converter="{StaticResource AllTrueToVis}">
|
||||
<Binding Path="IsBackground"/>
|
||||
<Binding Path="PlacementTarget.Tag" RelativeSource="{RelativeSource AncestorType={x:Type ContextMenu}}"/>
|
||||
</MultiBinding>
|
||||
</MenuItem.Visibility>
|
||||
</MenuItem>
|
||||
<Separator>
|
||||
<Separator.Visibility>
|
||||
<MultiBinding Converter="{StaticResource AllTrueToVis}">
|
||||
<Binding Path="IsBackground"/>
|
||||
<Binding Path="PlacementTarget.Tag" RelativeSource="{RelativeSource AncestorType={x:Type ContextMenu}}"/>
|
||||
</MultiBinding>
|
||||
</Separator.Visibility>
|
||||
</Separator>
|
||||
<MenuItem Header="Remove Layer" Click="SourceMenu_Remove"
|
||||
Visibility="{Binding IsBackground, Converter={StaticResource InverseBoolToVis}}"/>
|
||||
</ContextMenu>
|
||||
</Grid.ContextMenu>
|
||||
|
||||
<TextBlock Grid.Column="0" Text="{Binding Name}" VerticalAlignment="Center"
|
||||
Padding="4,2" TextTrimming="CharacterEllipsis">
|
||||
<TextBlock.Style>
|
||||
<Style TargetType="TextBlock">
|
||||
<Setter Property="Visibility" Value="Visible"/>
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding IsEditing}" Value="True">
|
||||
<Setter Property="Visibility" Value="Collapsed"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</TextBlock.Style>
|
||||
</TextBlock>
|
||||
|
||||
<TextBox Grid.Column="0" Text="{Binding Name, UpdateSourceTrigger=PropertyChanged}"
|
||||
Background="#0f3460" Foreground="#e0e0e0" BorderThickness="0"
|
||||
Padding="4,2" VerticalContentAlignment="Center"
|
||||
IsVisibleChanged="SourceNameBox_IsVisibleChanged"
|
||||
KeyDown="SourceNameBox_KeyDown" LostFocus="SourceNameBox_LostFocus">
|
||||
<TextBox.Style>
|
||||
<Style TargetType="TextBox">
|
||||
<Setter Property="Visibility" Value="Collapsed"/>
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding IsEditing}" Value="True">
|
||||
<Setter Property="Visibility" Value="Visible"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</TextBox.Style>
|
||||
</TextBox>
|
||||
|
||||
<Button Grid.Column="1" ToolTip="Edit Layer Name"
|
||||
Command="{Binding DataContext.EditElementCommand, RelativeSource={RelativeSource AncestorType=ListBox}}"
|
||||
CommandParameter="{Binding}" Style="{StaticResource IconButton}">
|
||||
<Path Data="M3,17.25V21h3.75L17.81,9.94l-3.75,-3.75L3,17.25zM20.71,7.04c0.39,-0.39 0.39,-1.02 0,-1.41l-2.34,-2.34c-0.39,-0.39 -1.02,-0.39 -1.41,0l-1.83,1.83 3.75,3.75 1.83,-1.83z"
|
||||
Fill="#d0d0d0" Width="13" Height="13" Stretch="Uniform"/>
|
||||
</Button>
|
||||
|
||||
<Button Grid.Column="2" Style="{StaticResource EyeButton}"
|
||||
Command="{Binding DataContext.ToggleElementVisibilityCommand, RelativeSource={RelativeSource AncestorType=ListBox}}"
|
||||
CommandParameter="{Binding}">
|
||||
<Path Style="{StaticResource EyeIconStyle}" Fill="#d0d0d0"
|
||||
Width="13" Height="13" Stretch="Uniform"/>
|
||||
</Button>
|
||||
|
||||
<Button Grid.Column="3" ToolTip="Remove Layer"
|
||||
Command="{Binding DataContext.RemoveSourceCommand, RelativeSource={RelativeSource AncestorType=ListBox}}"
|
||||
CommandParameter="{Binding}" Style="{StaticResource IconButton}"
|
||||
Visibility="{Binding IsBackground, Converter={StaticResource HiddenBoolToVis}}">
|
||||
<Path Data="M6,19c0,1.1 0.9,2 2,2h8c1.1,0 2,-0.9 2,-2V7H6v12zM19,4h-3.5l-1,-1h-5l-1,1H5v2h14V4z"
|
||||
Fill="#d0d0d0" Width="13" Height="13" Stretch="Uniform"/>
|
||||
</Button>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</Helpers:FocusPreservingListBox>
|
||||
|
||||
<TextBlock Grid.Row="2" Text="No layers yet" Foreground="#555" FontSize="12"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
Visibility="{Binding ShowSourcesEmptyHint, Converter={StaticResource BoolToVis}}"/>
|
||||
|
||||
<!-- Resource alert: pinned to the bottom of the Layers panel.
|
||||
Red when a required resource (the one-and-only webcam)
|
||||
could not be locked; persists and re-polls until resolved. -->
|
||||
<Border Grid.Row="3" Margin="0,8,0,0" Padding="8,6" CornerRadius="4"
|
||||
Background="#3a1216" BorderBrush="#e94560" BorderThickness="1"
|
||||
Visibility="{Binding WebcamLockAlert, Converter={StaticResource NotNullToVis}}">
|
||||
<StackPanel>
|
||||
<TextBlock Text="{Binding WebcamLockAlert}" Foreground="#ffb4b4" FontSize="11"
|
||||
TextWrapping="Wrap"/>
|
||||
<Button Content="Retry" Command="{Binding RetryWebcamLockCommand}"
|
||||
Style="{StaticResource YtButtonSecondary}" Padding="10,2"
|
||||
HorizontalAlignment="Right" Margin="0,6,0,0"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- ELEMENTS panel: per-element config beneath Sources.
|
||||
Collapses entirely when no element is selected. -->
|
||||
<Rectangle Grid.Row="4" Height="1" Fill="#2c3a5c" Margin="0,10,0,2"
|
||||
Visibility="{Binding SelectedElement, Converter={StaticResource NotNullToVis}}"/>
|
||||
<StackPanel Grid.Row="5" Orientation="Horizontal" Margin="0,8,0,0"
|
||||
Visibility="{Binding SelectedElement, Converter={StaticResource NotNullToVis}}">
|
||||
<TextBlock Text="PROPERTIES" Style="{StaticResource SectionHeader}" VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
<ScrollViewer x:Name="ElementsScrollViewer" Grid.Row="6" VerticalScrollBarVisibility="Auto" Background="Transparent"
|
||||
Visibility="{Binding SelectedElement, Converter={StaticResource NotNullToVis}}">
|
||||
<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">
|
||||
<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}"
|
||||
Style="{StaticResource YtButtonSecondary}" Padding="10,4"
|
||||
Click="MirrorButton_Click"/>
|
||||
<Button Content="{Binding SelectedElement.ShapeButtonText}"
|
||||
Style="{StaticResource YtButtonSecondary}" Padding="10,4"
|
||||
Margin="8,0,0,0" Click="ShapeButton_Click"/>
|
||||
</StackPanel>
|
||||
<TextBlock Text="Drag to move · drag the corner dot to resize"
|
||||
Foreground="#a0a0b0" FontSize="10" Margin="0,2,0,0"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Webcam border config -->
|
||||
<StackPanel Visibility="{Binding SelectedElement.IsWebcam, Converter={StaticResource BoolToVis}}" Margin="0,0,0,8">
|
||||
<TextBlock Text="Border Color" Foreground="#a0a0b0" FontSize="11" Margin="0,0,0,2"/>
|
||||
<DockPanel Margin="0,0,0,6">
|
||||
<Button DockPanel.Dock="Right" Content="..." Width="24" Height="24"
|
||||
Click="WebcamBorderColorPicker_Click" Margin="4,0,0,0"
|
||||
ToolTip="Pick color"/>
|
||||
<TextBox Text="{Binding SelectedElement.BorderColor, UpdateSourceTrigger=PropertyChanged}"
|
||||
Background="#0f3460" Foreground="#e0d0e0" BorderThickness="1"
|
||||
BorderBrush="#2c3a5c" Padding="4,2" FontSize="12"
|
||||
MaxLength="7" ToolTip="#RRGGBB"/>
|
||||
</DockPanel>
|
||||
<TextBlock Text="Border Thickness" Foreground="#a0a0b0" FontSize="11" Margin="0,0,0,2"/>
|
||||
<DockPanel Margin="0,0,0,6">
|
||||
<Button DockPanel.Dock="Right" Style="{StaticResource IconButton}"
|
||||
Width="22" Height="22" Margin="4,0,0,0"
|
||||
Click="WebcamBorderAccept_Click">
|
||||
<Path Data="M4,9 L8,14 L16,4" Stroke="#22c55e" StrokeThickness="2"
|
||||
Width="14" Height="14" Stretch="Uniform"/>
|
||||
</Button>
|
||||
<Button DockPanel.Dock="Right" Style="{StaticResource IconButton}"
|
||||
Width="22" Height="22" ToolTip="Revert"
|
||||
Click="WebcamBorderRevert_Click" Margin="4,0,0,0">
|
||||
<Path Data="M5,5 L15,15 M15,5 L5,15" Stroke="#e94560" StrokeThickness="2"
|
||||
Width="14" Height="14" Stretch="Uniform"/>
|
||||
</Button>
|
||||
<Slider DockPanel.Dock="Left" Width="120" Minimum="0" Maximum="10"
|
||||
Value="{Binding SelectedElement.BorderWidth, Mode=TwoWay}"
|
||||
VerticalAlignment="Center" TickFrequency="1" IsSnapToTickEnabled="True"/>
|
||||
<TextBlock Text="{Binding SelectedElement.BorderWidth}" Foreground="#a0a0b0" FontSize="11"
|
||||
VerticalAlignment="Center" Margin="8,0,0,0" Width="20"/>
|
||||
</DockPanel>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Countdown config -->
|
||||
<StackPanel Visibility="{Binding SelectedElement.IsCountdown, Converter={StaticResource BoolToVis}}" Margin="0,0,0,8">
|
||||
<TextBlock Text="Timer (minutes)" Foreground="#a0a0b0" FontSize="11" Margin="0,0,0,2"/>
|
||||
<DockPanel Margin="0,0,0,6">
|
||||
<Button DockPanel.Dock="Right" Style="{StaticResource IconButton}"
|
||||
Width="22" Height="22" Margin="4,0,0,0"
|
||||
Click="CountdownAccept_Click">
|
||||
<Path Data="M4,9 L8,14 L16,4" Stroke="#22c55e" StrokeThickness="2"
|
||||
Width="14" Height="14" Stretch="Uniform"/>
|
||||
</Button>
|
||||
<Button DockPanel.Dock="Right" Style="{StaticResource IconButton}"
|
||||
Width="22" Height="22" ToolTip="Revert"
|
||||
Click="CountdownRevert_Click" Margin="4,0,0,0">
|
||||
<Path Data="M5,5 L15,15 M15,5 L5,15" Stroke="#e94560" StrokeThickness="2"
|
||||
Width="14" Height="14" Stretch="Uniform"/>
|
||||
</Button>
|
||||
<Slider DockPanel.Dock="Left" Width="120" Minimum="1" Maximum="60"
|
||||
Value="{Binding SelectedElement.TimerMinutes, Mode=TwoWay}"
|
||||
VerticalAlignment="Center" TickFrequency="1" IsSnapToTickEnabled="True"/>
|
||||
<TextBlock Text="{Binding SelectedElement.TimerMinutes}" Foreground="#a0a0b0" FontSize="11"
|
||||
VerticalAlignment="Center" Margin="8,0,0,0" Width="20"/>
|
||||
</DockPanel>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Web source config -->
|
||||
<StackPanel Visibility="{Binding SelectedElement.IsWebSource, Converter={StaticResource BoolToVis}}" Margin="0,0,0,8">
|
||||
<TextBlock Text="Geometry" Foreground="#a0a0b0" FontSize="11" FontWeight="SemiBold" Margin="0,0,0,4"/>
|
||||
<DockPanel Margin="0,0,0,4">
|
||||
<TextBlock DockPanel.Dock="Left" Text="X" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="16"/>
|
||||
<TextBox Text="{Binding SelectedElement.X, UpdateSourceTrigger=PropertyChanged, StringFormat=F0}"
|
||||
Background="#0f3460" Foreground="#e0d0e0" BorderThickness="1"
|
||||
BorderBrush="#2c3a5c" Padding="4,2" FontSize="12"/>
|
||||
<TextBlock DockPanel.Dock="Left" Text=" Y" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="20" Margin="8,0,0,0"/>
|
||||
<TextBox Text="{Binding SelectedElement.Y, UpdateSourceTrigger=PropertyChanged, StringFormat=F0}"
|
||||
Background="#0f3460" Foreground="#e0d0e0" BorderThickness="1"
|
||||
BorderBrush="#2c3a5c" Padding="4,2" FontSize="12"/>
|
||||
</DockPanel>
|
||||
<DockPanel Margin="0,0,0,4">
|
||||
<TextBlock DockPanel.Dock="Left" Text="W" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="16"/>
|
||||
<TextBox Text="{Binding SelectedElement.Width, UpdateSourceTrigger=PropertyChanged, StringFormat=F0}"
|
||||
Background="#0f3460" Foreground="#e0d0e0" BorderThickness="1"
|
||||
BorderBrush="#2c3a5c" Padding="4,2" FontSize="12"/>
|
||||
<TextBlock DockPanel.Dock="Left" Text=" H" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="20" Margin="8,0,0,0"/>
|
||||
<TextBox Text="{Binding SelectedElement.Height, UpdateSourceTrigger=PropertyChanged, StringFormat=F0}"
|
||||
Background="#0f3460" Foreground="#e0d0e0" BorderThickness="1"
|
||||
BorderBrush="#2c3a5c" Padding="4,2" FontSize="12"/>
|
||||
</DockPanel>
|
||||
<TextBlock Text="URI" Foreground="#a0a0b0" FontSize="11" Margin="0,0,0,2"/>
|
||||
<DockPanel>
|
||||
<Button DockPanel.Dock="Right" Style="{StaticResource IconButton}"
|
||||
Width="22" Height="22" Margin="4,0,0,0" IsTabStop="False"
|
||||
Click="WebAccept_Click">
|
||||
<Path Data="M4,9 L8,14 L16,4" Stroke="#22c55e" StrokeThickness="2"
|
||||
Width="14" Height="14" Stretch="Uniform"/>
|
||||
</Button>
|
||||
<Button DockPanel.Dock="Right" Style="{StaticResource IconButton}"
|
||||
Width="22" Height="22" ToolTip="Revert" IsTabStop="False"
|
||||
Click="WebRevert_Click" Margin="4,0,0,0">
|
||||
<Path Data="M5,5 L15,15 M15,5 L5,15" Stroke="#e94560" StrokeThickness="2"
|
||||
Width="14" Height="14" Stretch="Uniform"/>
|
||||
</Button>
|
||||
<TextBox Text="{Binding SelectedElement.WebUri, UpdateSourceTrigger=PropertyChanged}"
|
||||
Background="#0f3460" Foreground="#e0d0e0" BorderThickness="1"
|
||||
BorderBrush="#2c3a5c" Padding="4,2" FontSize="12"
|
||||
ToolTip="https://example.com"/>
|
||||
</DockPanel>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Chat box config -->
|
||||
<StackPanel Visibility="{Binding SelectedElement.IsChatBox, Converter={StaticResource BoolToVis}}" Margin="0,0,0,8">
|
||||
<TextBlock Text="Chat Overlay" Foreground="#a0a0b0" FontSize="11" FontWeight="SemiBold" Margin="0,0,0,6"/>
|
||||
|
||||
<!-- Font Size -->
|
||||
<DockPanel Margin="0,0,0,4">
|
||||
<TextBlock DockPanel.Dock="Left" Text="Font Size" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="70"/>
|
||||
<Slider x:Name="ChatFontSizeSlider" Width="120" Minimum="8" Maximum="72" TickFrequency="2" IsSnapToTickEnabled="True"
|
||||
Value="{Binding SelectedElement.ChatFontSize, Mode=TwoWay}"
|
||||
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"/>
|
||||
<TextBox Text="{Binding SelectedElement.ChatFontColor, UpdateSourceTrigger=PropertyChanged}"
|
||||
Background="#0f3460" Foreground="#e0e0e0" BorderThickness="1"
|
||||
BorderBrush="#2c3a5c" Padding="4,2" FontSize="11" Width="80"/>
|
||||
</DockPanel>
|
||||
|
||||
<!-- Member Color -->
|
||||
<DockPanel Margin="0,0,0,4">
|
||||
<TextBlock DockPanel.Dock="Left" Text="Member" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="70"/>
|
||||
<TextBox Text="{Binding SelectedElement.ChatMemberColor, UpdateSourceTrigger=PropertyChanged}"
|
||||
Background="#0f3460" Foreground="#e0e0e0" BorderThickness="1"
|
||||
BorderBrush="#2c3a5c" Padding="4,2" FontSize="11" Width="80"/>
|
||||
</DockPanel>
|
||||
|
||||
<!-- Super Chat Color -->
|
||||
<DockPanel Margin="0,0,0,4">
|
||||
<TextBlock DockPanel.Dock="Left" Text="Super Chat" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="70"/>
|
||||
<TextBox Text="{Binding SelectedElement.ChatSuperChatColor, UpdateSourceTrigger=PropertyChanged}"
|
||||
Background="#0f3460" Foreground="#e0e0e0" BorderThickness="1"
|
||||
BorderBrush="#2c3a5c" Padding="4,2" FontSize="11" Width="80"/>
|
||||
</DockPanel>
|
||||
|
||||
<!-- Max Messages -->
|
||||
<DockPanel Margin="0,0,0,4">
|
||||
<TextBlock DockPanel.Dock="Left" Text="Max Msgs" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="70"/>
|
||||
<Slider x:Name="ChatMaxMsgsSlider" Width="120" Minimum="1" Maximum="20" TickFrequency="1" IsSnapToTickEnabled="True"
|
||||
Value="{Binding SelectedElement.ChatMaxMessages, Mode=TwoWay}"
|
||||
VerticalAlignment="Center"/>
|
||||
<TextBlock DockPanel.Dock="Right" Text="{Binding SelectedElement.ChatMaxMessages}" Foreground="#e0e0e0" FontSize="11" Margin="6,0,0,0" VerticalAlignment="Center" Width="24"/>
|
||||
</DockPanel>
|
||||
|
||||
<!-- Background Color -->
|
||||
<DockPanel Margin="0,0,0,4">
|
||||
<TextBlock DockPanel.Dock="Left" Text="Background" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="70"/>
|
||||
<TextBox Text="{Binding SelectedElement.ChatBackgroundColor, UpdateSourceTrigger=PropertyChanged}"
|
||||
Background="#0f3460" Foreground="#e0e0e0" BorderThickness="1"
|
||||
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}"/>
|
||||
<CheckBox Content="Show badges" Foreground="#a0a0b0" FontSize="11" Margin="0,0,0,2"
|
||||
IsChecked="{Binding SelectedElement.ChatShowBadges, Mode=TwoWay}"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Stream Alerts config -->
|
||||
<StackPanel Visibility="{Binding SelectedElement.IsAlertBox, Converter={StaticResource BoolToVis}}" Margin="0,0,0,8">
|
||||
<TextBlock Text="Stream Alerts" Foreground="#a0a0b0" FontSize="11" FontWeight="SemiBold" Margin="0,0,0,6"/>
|
||||
<TextBlock Text="The built-in clip plays on every alert (Super Chat, membership…) unless a custom video is picked."
|
||||
Foreground="#888" FontSize="10" TextWrapping="Wrap" Margin="0,0,0,6"/>
|
||||
|
||||
<DockPanel Margin="0,0,0,4">
|
||||
<TextBlock DockPanel.Dock="Left" Text="Built-in video" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="84"/>
|
||||
<ToggleButton Style="{StaticResource PillToggle}"
|
||||
IsChecked="{Binding SelectedElement.AlertUseDefaultVideo, Mode=TwoWay}"
|
||||
HorizontalAlignment="Left"/>
|
||||
</DockPanel>
|
||||
|
||||
<Grid Margin="0,0,0,4"
|
||||
Visibility="{Binding SelectedElement.AlertUseDefaultVideo, Converter={StaticResource InverseBoolToVis}}">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBox Grid.Column="0" Text="{Binding SelectedElement.AlertVideoPath, Mode=OneWay}"
|
||||
IsReadOnly="True" Background="#0f3460" Foreground="#a0a0b0"
|
||||
FontSize="11" VerticalAlignment="Center"/>
|
||||
<Button Grid.Column="1" Content="Browse…" Command="{Binding BrowseAlertVideoCommand}"
|
||||
CommandParameter="{Binding SelectedElement}" Style="{StaticResource YtButtonSecondary}"
|
||||
Margin="4,0,0,0" Padding="8,2"/>
|
||||
</Grid>
|
||||
|
||||
<Button Content="Reset to built-in" Command="{Binding ResetAlertVideoCommand}"
|
||||
CommandParameter="{Binding SelectedElement}" Style="{StaticResource YtButtonSecondary}"
|
||||
HorizontalAlignment="Left" Padding="8,2" Margin="0,0,4,0"/>
|
||||
<TextBlock Text="Custom clips are referenced from disk — never stored in the layout."
|
||||
Foreground="#888" FontSize="10" TextWrapping="Wrap" Margin="0,4,0,6"/>
|
||||
|
||||
<DockPanel Margin="0,0,0,4">
|
||||
<TextBlock DockPanel.Dock="Left" Text="Message ticker" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="84"/>
|
||||
<ToggleButton Style="{StaticResource PillToggle}"
|
||||
IsChecked="{Binding SelectedElement.AlertShowTicker, Mode=TwoWay}"
|
||||
HorizontalAlignment="Left"/>
|
||||
</DockPanel>
|
||||
|
||||
<!-- Announcement display: scroll (marquee), flash (pulse) or
|
||||
solid (still). Inline items, not a bound ItemsSource —
|
||||
same shape as the chat Font selector above. -->
|
||||
<DockPanel Margin="0,0,0,4">
|
||||
<TextBlock DockPanel.Dock="Left" Text="Display" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="84"/>
|
||||
<ComboBox SelectedValue="{Binding SelectedElement.AlertDisplayMethod, Mode=TwoWay}"
|
||||
SelectedValuePath="Tag"
|
||||
Background="#0f3460" Foreground="#e0e0e0" FontSize="11"
|
||||
Width="140" VerticalAlignment="Center">
|
||||
<ComboBoxItem Content="Scroll" Tag="TickerScroll"/>
|
||||
<ComboBoxItem Content="Flash" Tag="Flash"/>
|
||||
<ComboBoxItem Content="Solid" Tag="Solid"/>
|
||||
</ComboBox>
|
||||
</DockPanel>
|
||||
|
||||
<DockPanel Margin="0,0,0,4">
|
||||
<TextBlock DockPanel.Dock="Left" Text="Volume" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="84"/>
|
||||
<Slider Width="120" Minimum="0" Maximum="1"
|
||||
Value="{Binding SelectedElement.AlertVideoVolume, Mode=TwoWay}"
|
||||
VerticalAlignment="Center"/>
|
||||
<TextBlock DockPanel.Dock="Right" Text="{Binding SelectedElement.AlertVideoVolume, StringFormat={}{0:0%}}" Foreground="#e0e0e0" FontSize="11" Margin="6,0,0,0" VerticalAlignment="Center" Width="32"/>
|
||||
</DockPanel>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Background properties -->
|
||||
<StackPanel Margin="0,8,0,0"
|
||||
Visibility="{Binding SelectedElement.IsBackground, Converter={StaticResource BoolToVis}}">
|
||||
<TextBlock Text="Background" Style="{StaticResource SubHeader}"/>
|
||||
<StackPanel Margin="4,0,0,0">
|
||||
<StackPanel Orientation="Horizontal" Margin="0,0,0,8">
|
||||
<TextBlock Text="Use default background:" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center"/>
|
||||
<ToggleButton Style="{StaticResource PillToggle}"
|
||||
IsChecked="{Binding SelectedElement.UseDefaultBackground, Mode=TwoWay}"
|
||||
Margin="8,0,0,0"/>
|
||||
</StackPanel>
|
||||
<Grid Margin="0,0,0,4"
|
||||
Visibility="{Binding SelectedElement.UseDefaultBackground, Converter={StaticResource InverseBoolToVis}}">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBox Grid.Column="0" Text="{Binding SelectedElement.CustomBackgroundPath, Mode=OneWay}"
|
||||
IsReadOnly="True" Background="#0f3460" Foreground="#a0a0b0"
|
||||
FontSize="11" VerticalAlignment="Center"/>
|
||||
<Button Grid.Column="1" Content="Browse…" Command="{Binding BrowseBackgroundCommand}"
|
||||
Style="{StaticResource YtButtonSecondary}" Margin="4,0,0,0" Padding="8,2"/>
|
||||
</Grid>
|
||||
<TextBlock Text="{Binding BackgroundSizeMismatchText}"
|
||||
Foreground="#ef4444" FontSize="11" TextWrapping="Wrap"
|
||||
Margin="0,4,0,0"
|
||||
Visibility="{Binding BackgroundSizeMismatch, Converter={StaticResource BoolToVis}}"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</Grid>
|
||||
|
||||
<!-- Live mode: YouTube Live Chat (entire left panel replaced) -->
|
||||
<Grid Grid.Row="0" Visibility="{Binding IsEditMode, Converter={StaticResource InverseBoolToVis}}">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<TextBlock Grid.Row="0" Text="LIVE CHAT" Style="{StaticResource SectionHeader}"/>
|
||||
|
||||
<!-- Inactive overlay (shown when NOT live) -->
|
||||
<StackPanel Grid.Row="1" VerticalAlignment="Center" HorizontalAlignment="Center"
|
||||
Visibility="{Binding ShowChatInactiveMessage, Converter={StaticResource BoolToVis}}">
|
||||
<TextBlock Text="Chat is inactive" Foreground="#a0a0b0" FontSize="13"
|
||||
HorizontalAlignment="Center" Margin="0,0,0,4"/>
|
||||
<TextBlock Text="Chat becomes active once LlamaCasty is connected and streaming on YouTube."
|
||||
Foreground="#666" FontSize="11" TextWrapping="Wrap"
|
||||
HorizontalAlignment="Center" TextAlignment="Center" MaxWidth="240"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Message feed (shown when live) -->
|
||||
<ListBox Grid.Row="1" Background="Transparent" BorderThickness="0"
|
||||
ItemsSource="{Binding ChatMessages}"
|
||||
ItemContainerStyle="{StaticResource YtListBoxItem}"
|
||||
Visibility="{Binding ShowChatInactiveMessage, Converter={StaticResource InverseBoolToVis}}"
|
||||
x:Name="ChatListBox">
|
||||
<ListBox.ItemTemplate><DataTemplate>
|
||||
<Border x:Name="MessageBorder" Padding="6,4" CornerRadius="4">
|
||||
<Border.Style>
|
||||
<Style TargetType="Border">
|
||||
<Setter Property="Background" Value="Transparent"/>
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding IsSuperChat}" Value="True">
|
||||
<Setter Property="Background" Value="#2a1a0a"/>
|
||||
<Setter Property="BorderBrush" Value="#eab308"/>
|
||||
<Setter Property="BorderThickness" Value="1,0,1,0"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</Border.Style>
|
||||
<StackPanel>
|
||||
<StackPanel Orientation="Horizontal" Margin="0,0,0,2">
|
||||
<TextBlock Text="⭐" FontSize="10" Margin="0,0,4,0"
|
||||
VerticalAlignment="Center"
|
||||
Visibility="{Binding IsMember, Converter={StaticResource BoolToVis}}"/>
|
||||
<TextBlock Text="{Binding AuthorName}" Foreground="#e94560"
|
||||
FontWeight="Bold" FontSize="12" VerticalAlignment="Center"/>
|
||||
<TextBlock Text="{Binding SuperChatAmount, StringFormat=${0:F2}}"
|
||||
Foreground="#eab308" FontWeight="Bold" FontSize="12"
|
||||
Margin="8,0,0,0" VerticalAlignment="Center"
|
||||
Visibility="{Binding IsSuperChat, Converter={StaticResource BoolToVis}}"/>
|
||||
<TextBlock Text="{Binding SuperChatCurrency}" Foreground="#eab308"
|
||||
FontSize="11" Margin="2,0,0,0" VerticalAlignment="Center"
|
||||
Visibility="{Binding IsSuperChat, Converter={StaticResource BoolToVis}}"/>
|
||||
</StackPanel>
|
||||
<TextBlock Text="{Binding Message}" Foreground="#e0e0e0"
|
||||
TextWrapping="Wrap" Margin="0,2,0,0"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</DataTemplate></ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Border>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,322 @@
|
||||
using System.Collections;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Media;
|
||||
using ytLive.Models;
|
||||
using ytLive.ViewModels;
|
||||
|
||||
namespace ytLive.Controls;
|
||||
|
||||
public partial class LeftPanel : UserControl
|
||||
{
|
||||
private Models.SceneElement? _snapshotElement;
|
||||
private string _snapshotBorderColor = string.Empty;
|
||||
private int _snapshotBorderWidth;
|
||||
private int _snapshotTimerMinutes = 15;
|
||||
private string? _snapshotWebUri;
|
||||
|
||||
private Point _dragStartPoint;
|
||||
private int _dragIndex = -1;
|
||||
private bool _isDragging;
|
||||
private object? _lastHoveredItem;
|
||||
private bool _dragReordered;
|
||||
|
||||
public LeftPanel()
|
||||
{
|
||||
InitializeComponent();
|
||||
Loaded += (_, _) =>
|
||||
{
|
||||
if (DataContext is MainViewModel vm)
|
||||
vm.ChatMessages.CollectionChanged += OnChatMessagesChanged;
|
||||
};
|
||||
Unloaded += (_, _) =>
|
||||
{
|
||||
if (DataContext is MainViewModel vm)
|
||||
vm.ChatMessages.CollectionChanged -= OnChatMessagesChanged;
|
||||
};
|
||||
}
|
||||
|
||||
// Called by the window's UpdateSelectionOverlay (edit mode only).
|
||||
public void OnSelectionChanged(MainViewModel vm)
|
||||
{
|
||||
if (vm.SelectedElement is { } s && PreviewPane.IsDraggableElement(s))
|
||||
OpacityValueText.Text = $"{Math.Round(s.Opacity * 100)}%";
|
||||
TakeSnapshot(vm);
|
||||
}
|
||||
|
||||
// Deselection guard: true when the click is inside the source list or the
|
||||
// element property scroller (window's Window_PreviewMouseLeftButtonDown).
|
||||
public bool IsClickInside(DependencyObject original)
|
||||
=> IsDescendantOf(original, SourceList) || IsDescendantOf(original, ElementsScrollViewer);
|
||||
|
||||
private static bool IsDescendantOf(DependencyObject? child, DependencyObject ancestor)
|
||||
{
|
||||
while (child != null && !ReferenceEquals(child, ancestor))
|
||||
{
|
||||
child = child is Visual or System.Windows.Media.Media3D.Visual3D
|
||||
? VisualTreeHelper.GetParent(child)
|
||||
: LogicalTreeHelper.GetParent(child);
|
||||
}
|
||||
return child != null;
|
||||
}
|
||||
|
||||
private void SourceNameBox_IsVisibleChanged(object sender, DependencyPropertyChangedEventArgs e)
|
||||
{
|
||||
if (sender is TextBox { IsVisible: true } box)
|
||||
{
|
||||
box.Focus();
|
||||
box.SelectAll();
|
||||
}
|
||||
}
|
||||
|
||||
private void AddSourceButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is Button { ContextMenu: { } menu } button)
|
||||
{
|
||||
menu.PlacementTarget = button;
|
||||
menu.Placement = System.Windows.Controls.Primitives.PlacementMode.Bottom;
|
||||
menu.IsOpen = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnChatMessagesChanged(object? sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
|
||||
{
|
||||
if (e.Action == System.Collections.Specialized.NotifyCollectionChangedAction.Add)
|
||||
{
|
||||
ChatListBox.ScrollIntoView(ChatListBox.Items[^1]);
|
||||
}
|
||||
}
|
||||
|
||||
private void MirrorButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if ((DataContext as MainViewModel)?.SelectedElement is WebcamSceneConfig webcam)
|
||||
webcam.IsMirrored = !webcam.IsMirrored;
|
||||
}
|
||||
|
||||
private void ShapeButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if ((DataContext as MainViewModel)?.SelectedElement is WebcamSceneConfig webcam)
|
||||
webcam.ToggleClipShape();
|
||||
}
|
||||
|
||||
private void SourceMenu_Remove(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is MenuItem { DataContext: SceneElement element } && DataContext is MainViewModel vm)
|
||||
vm.RemoveSourceCommand.Execute(element);
|
||||
}
|
||||
|
||||
private void BackgroundMenu_ChangeCapture(object sender, RoutedEventArgs e)
|
||||
{
|
||||
_ = (DataContext as MainViewModel)?.ChangeBackgroundCaptureAsync();
|
||||
}
|
||||
|
||||
private void BackgroundMenu_RefreshCapture(object sender, RoutedEventArgs e)
|
||||
{
|
||||
(DataContext as MainViewModel)?.RefreshBackgroundAutoCapture();
|
||||
}
|
||||
|
||||
private void BackgroundMenu_RefreshWindowList(object sender, RoutedEventArgs e)
|
||||
{
|
||||
(DataContext as MainViewModel)?.RefreshWindowsCommand.Execute(null);
|
||||
}
|
||||
|
||||
private void OpacitySlider_ValueChanged(object sender, RoutedPropertyChangedEventArgs<double> e)
|
||||
=> OpacityValueText.Text = $"{Math.Round(e.NewValue * 100)}%";
|
||||
|
||||
private void SourceNameBox_KeyDown(object sender, KeyEventArgs e)
|
||||
{
|
||||
if (e.Key is Key.Enter or Key.Escape)
|
||||
{
|
||||
CommitSourceEdit(sender);
|
||||
e.Handled = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void SourceNameBox_LostFocus(object sender, RoutedEventArgs e)
|
||||
{
|
||||
CommitSourceEdit(sender);
|
||||
}
|
||||
|
||||
private static void CommitSourceEdit(object sender)
|
||||
{
|
||||
if (sender is FrameworkElement { DataContext: SceneElement element })
|
||||
element.IsEditing = false;
|
||||
}
|
||||
|
||||
// ─── List drag-to-reorder (scenes list + sources list) ───
|
||||
private void List_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
var listBox = (ListBox)sender;
|
||||
var item = FindItemContainerAt(listBox, e.GetPosition(listBox));
|
||||
if (item == null)
|
||||
{
|
||||
if (ReferenceEquals(listBox, SourceList))
|
||||
(DataContext as MainViewModel)!.SelectedElement = null;
|
||||
_dragIndex = -1;
|
||||
return;
|
||||
}
|
||||
if (listBox.ItemsSource is not IList items || items.Count == 0)
|
||||
{
|
||||
_dragIndex = -1;
|
||||
return;
|
||||
}
|
||||
_dragIndex = listBox.Items.IndexOf(item.DataContext);
|
||||
_dragStartPoint = e.GetPosition(listBox);
|
||||
_isDragging = false;
|
||||
_lastHoveredItem = null;
|
||||
_dragReordered = false;
|
||||
}
|
||||
|
||||
private void List_PreviewMouseMove(object sender, MouseEventArgs e)
|
||||
{
|
||||
if (_dragIndex < 0) return;
|
||||
var listBox = (ListBox)sender;
|
||||
if (e.LeftButton != MouseButtonState.Pressed || listBox.ItemsSource is not IList items)
|
||||
{
|
||||
EndListDrag(listBox);
|
||||
return;
|
||||
}
|
||||
|
||||
var position = e.GetPosition(listBox);
|
||||
if (!_isDragging)
|
||||
{
|
||||
if (Math.Abs(position.X - _dragStartPoint.X) < SystemParameters.MinimumHorizontalDragDistance &&
|
||||
Math.Abs(position.Y - _dragStartPoint.Y) < SystemParameters.MinimumVerticalDragDistance)
|
||||
return;
|
||||
_isDragging = true;
|
||||
listBox.CaptureMouse();
|
||||
}
|
||||
|
||||
var item = FindItemContainerAt(listBox, position);
|
||||
if (item == null) return;
|
||||
|
||||
var targetItem = item.DataContext;
|
||||
if (ReferenceEquals(targetItem, items[_dragIndex]) || ReferenceEquals(targetItem, _lastHoveredItem))
|
||||
return;
|
||||
|
||||
var targetIndex = listBox.Items.IndexOf(targetItem);
|
||||
if (targetIndex < 0) return;
|
||||
|
||||
var dragged = items[_dragIndex];
|
||||
items.RemoveAt(_dragIndex);
|
||||
items.Insert(targetIndex, dragged);
|
||||
_dragIndex = targetIndex;
|
||||
_lastHoveredItem = targetItem;
|
||||
_dragReordered = true;
|
||||
}
|
||||
|
||||
private void List_PreviewMouseLeftButtonUp(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
EndListDrag((ListBox)sender);
|
||||
}
|
||||
|
||||
private void EndListDrag(ListBox listBox)
|
||||
{
|
||||
if (_isDragging)
|
||||
listBox.ReleaseMouseCapture();
|
||||
var reordered = _dragReordered;
|
||||
_dragIndex = -1;
|
||||
_isDragging = false;
|
||||
_lastHoveredItem = null;
|
||||
_dragReordered = false;
|
||||
if (reordered && DataContext is MainViewModel vm)
|
||||
vm.OnSceneElementsReordered();
|
||||
}
|
||||
|
||||
private static ListBoxItem? FindItemContainerAt(ListBox listBox, Point position)
|
||||
{
|
||||
var hit = listBox.InputHitTest(position) as DependencyObject;
|
||||
while (hit != null && hit != listBox)
|
||||
{
|
||||
if (hit is ListBoxItem item) return item;
|
||||
hit = VisualTreeHelper.GetParent(hit);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void TakeSnapshot(MainViewModel vm)
|
||||
{
|
||||
if (vm.SelectedElement is Models.Source src)
|
||||
{
|
||||
_snapshotElement = src;
|
||||
_snapshotBorderColor = src.BorderColor;
|
||||
_snapshotBorderWidth = src.BorderWidth;
|
||||
_snapshotTimerMinutes = src.TimerMinutes;
|
||||
_snapshotWebUri = src.WebUri;
|
||||
}
|
||||
}
|
||||
|
||||
private void WebcamBorderColorPicker_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if ((DataContext as MainViewModel)?.SelectedElement is not SceneElement src) return;
|
||||
if (sender is FrameworkElement fe && fe.ContextMenu == null)
|
||||
{
|
||||
var menu = new System.Windows.Controls.ContextMenu();
|
||||
string[] colors = ["#FFFFFF", "#000000", "#FF0000", "#00FF00", "#0000FF",
|
||||
"#FFFF00", "#FF00FF", "#00FFFF", "#FF8800", "#8800FF",
|
||||
"#e94560", "#22c55e", "#3b82f6", "#f59e0b"];
|
||||
foreach (var c in colors)
|
||||
{
|
||||
var item = new System.Windows.Controls.MenuItem { Header = c, ToolTip = c };
|
||||
var brush = new System.Windows.Media.SolidColorBrush(
|
||||
(System.Windows.Media.Color)System.Windows.Media.ColorConverter.ConvertFromString(c));
|
||||
item.Icon = new System.Windows.Controls.Border
|
||||
{
|
||||
Width = 16, Height = 16,
|
||||
Background = brush,
|
||||
CornerRadius = new System.Windows.CornerRadius(2)
|
||||
};
|
||||
var color = c;
|
||||
item.Click += (_, _) => src.BorderColor = color;
|
||||
menu.Items.Add(item);
|
||||
}
|
||||
fe.ContextMenu = menu;
|
||||
}
|
||||
if (sender is FrameworkElement fe2)
|
||||
fe2.ContextMenu.IsOpen = true;
|
||||
}
|
||||
|
||||
private void WebcamBorderRevert_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_snapshotElement is Models.Source src)
|
||||
{
|
||||
src.BorderColor = _snapshotBorderColor;
|
||||
src.BorderWidth = _snapshotBorderWidth;
|
||||
}
|
||||
}
|
||||
|
||||
private void WebcamBorderAccept_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_snapshotElement is Models.Source src)
|
||||
{
|
||||
_snapshotBorderColor = src.BorderColor;
|
||||
_snapshotBorderWidth = src.BorderWidth;
|
||||
}
|
||||
}
|
||||
|
||||
private void CountdownRevert_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_snapshotElement is Models.Source src)
|
||||
src.TimerMinutes = _snapshotTimerMinutes;
|
||||
}
|
||||
|
||||
private void CountdownAccept_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_snapshotElement is Models.Source src)
|
||||
_snapshotTimerMinutes = src.TimerMinutes;
|
||||
}
|
||||
|
||||
private void WebRevert_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_snapshotElement is Models.Source src)
|
||||
src.WebUri = string.Empty;
|
||||
_snapshotWebUri = string.Empty;
|
||||
}
|
||||
|
||||
private void WebAccept_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_snapshotElement is Models.Source src)
|
||||
_snapshotWebUri = src.WebUri;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
<UserControl x:Class="ytLive.Controls.OverlayHost"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:Helpers="clr-namespace:ytLive.Helpers">
|
||||
<Grid Grid.RowSpan="3"
|
||||
Visibility="{Binding IsAnyOverlayOpen, Converter={StaticResource BoolToVis}}">
|
||||
<Button Background="#99000000" BorderThickness="0" Cursor="Hand"
|
||||
Command="{Binding CloseOverlayCommand}">
|
||||
<Button.Template>
|
||||
<ControlTemplate TargetType="Button">
|
||||
<Border Background="{TemplateBinding Background}"/>
|
||||
</ControlTemplate>
|
||||
</Button.Template>
|
||||
</Button>
|
||||
|
||||
<Border Background="#0A0C1A" CornerRadius="8" Width="650" MaxHeight="700"
|
||||
VerticalAlignment="Center" HorizontalAlignment="Center"
|
||||
Padding="28" BorderBrush="#333" BorderThickness="1">
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- Header -->
|
||||
<Grid Grid.Row="0">
|
||||
<TextBlock Text="{Binding OverlayTitle}" FontSize="18" FontWeight="Bold"
|
||||
Foreground="#e0e0e0" VerticalAlignment="Center"/>
|
||||
<Button HorizontalAlignment="Right" ToolTip="Close" Style="{StaticResource IconButton}"
|
||||
Command="{Binding CloseOverlayCommand}">
|
||||
<Path Data="M19,6.41L17.59,5 12,10.59 6.41,5 5,6.41 10.59,12 5,17.59 6.41,19 12,13.41 17.59,19 19,17.59 13.41,12z"
|
||||
Fill="#d0d0d0" Width="14" Height="14" Stretch="Uniform"/>
|
||||
</Button>
|
||||
</Grid>
|
||||
|
||||
<!-- App Settings: broadcast fields live in the preview's
|
||||
Text pull-out now — this panel keeps app-level prefs only.
|
||||
(Currently empty; the overlay shell stays for future use.) -->
|
||||
<StackPanel Grid.Row="1" Margin="0,16,0,0"
|
||||
Visibility="{Binding IsSettingsOpen, Converter={StaticResource BoolToVis}}">
|
||||
<TextBlock Text="Default Location for Recordings" Style="{StaticResource YtLabel}" Margin="0,0,0,4"/>
|
||||
<TextBox Style="{StaticResource YtTextBox}"
|
||||
Text="{Binding RecordFolderDisplay, Mode=OneWay}"
|
||||
IsReadOnly="True" Margin="0,0,0,12" Foreground="#a0a0b0"/>
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Button Content="Browse…" Command="{Binding ChooseRecordFolderCommand}"
|
||||
Style="{StaticResource YtButtonSecondary}" Margin="0,0,8,0"/>
|
||||
<Button Content="Use Downloads" Command="{Binding ResetRecordFolderCommand}"
|
||||
Style="{StaticResource YtButtonSecondary}"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Report Bug -->
|
||||
<StackPanel Grid.Row="1" Margin="0,16,0,0"
|
||||
Visibility="{Binding IsBugOpen, Converter={StaticResource BoolToVis}}">
|
||||
<TextBlock Text="What went wrong?" Style="{StaticResource YtLabel}" Margin="0,0,0,4"/>
|
||||
<TextBox Style="{StaticResource YtTextBox}" Height="110" AcceptsReturn="True" TextWrapping="Wrap"
|
||||
Margin="0,0,0,12"
|
||||
Text="{Binding BugReportText, UpdateSourceTrigger=PropertyChanged}"
|
||||
VerticalScrollBarVisibility="Auto"/>
|
||||
|
||||
<TextBlock Text="Email (optional)" Style="{StaticResource YtLabel}" Margin="0,0,0,4"/>
|
||||
<TextBox Style="{StaticResource YtTextBox}" Margin="0,0,0,12"
|
||||
Text="{Binding BugReportEmail, UpdateSourceTrigger=PropertyChanged}"/>
|
||||
|
||||
<TextBlock Text="Opens a pre-filled email to the LlamaCasty team." Style="{StaticResource YtLabel}"/>
|
||||
<Button Content="Send Report" Command="{Binding SubmitBugCommand}"
|
||||
Style="{StaticResource YtButton}" HorizontalAlignment="Right" Margin="0,16,0,0"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Feature Request -->
|
||||
<StackPanel Grid.Row="1" Margin="0,16,0,0"
|
||||
Visibility="{Binding IsFeatureOpen, Converter={StaticResource BoolToVis}}">
|
||||
<TextBlock Text="What would you like to see?" Style="{StaticResource YtLabel}" Margin="0,0,0,4"/>
|
||||
<TextBox Style="{StaticResource YtTextBox}" Height="110" AcceptsReturn="True" TextWrapping="Wrap"
|
||||
Margin="0,0,0,12"
|
||||
Text="{Binding FeatureRequestText, UpdateSourceTrigger=PropertyChanged}"
|
||||
VerticalScrollBarVisibility="Auto"/>
|
||||
|
||||
<TextBlock Text="Email (optional)" Style="{StaticResource YtLabel}" Margin="0,0,0,4"/>
|
||||
<TextBox Style="{StaticResource YtTextBox}" Margin="0,0,0,12"
|
||||
Text="{Binding FeatureRequestEmail, UpdateSourceTrigger=PropertyChanged}"/>
|
||||
|
||||
<TextBlock Text="Opens a pre-filled email to the LlamaCasty team." Style="{StaticResource YtLabel}"/>
|
||||
<Button Content="Send Request" Command="{Binding SubmitFeatureCommand}"
|
||||
Style="{StaticResource YtButton}" HorizontalAlignment="Right" Margin="0,16,0,0"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- About -->
|
||||
<Grid Grid.Row="1" Margin="0,16,0,0"
|
||||
Background="#171928"
|
||||
Visibility="{Binding IsAboutOpen, Converter={StaticResource BoolToVis}}">
|
||||
|
||||
<!-- Hub view -->
|
||||
<StackPanel Visibility="{Binding IsHubVisible, Converter={StaticResource BoolToVis}}">
|
||||
<Image Source="/ytLive;component/Assets/llamacasty-logo.jpg" Height="125" Stretch="Uniform"
|
||||
HorizontalAlignment="Center" Margin="0,0,0,12"/>
|
||||
<TextBlock Text="{Binding AppVersionLabel}" Foreground="#a0a0b0" FontSize="12"
|
||||
HorizontalAlignment="Center" Margin="0,4,0,0"/>
|
||||
<TextBlock Text="Live streaming for YouTube — simple enough that even the most right-brained person can intuit it."
|
||||
Foreground="#a0a0b0" FontSize="13" TextWrapping="Wrap" MaxWidth="380"
|
||||
TextAlignment="Center" Margin="0,16,0,0"/>
|
||||
<TextBlock Text="Free forever — live streams carry a brief "made with LlamaCasty!" flash. A subscription removes it and adds alerts."
|
||||
Foreground="#888" FontSize="12" TextWrapping="Wrap" MaxWidth="380"
|
||||
TextAlignment="Center" Margin="0,12,0,0"/>
|
||||
<TextBlock Text="Made by gramps · llama chile shop" Foreground="#a0a0b0" FontSize="12"
|
||||
HorizontalAlignment="Center" Margin="0,16,0,8"/>
|
||||
|
||||
<!-- Creator hub links -->
|
||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center" Margin="0,0,0,8">
|
||||
<Button Content="llama chile shop on YouTube" Command="{Binding OpenChannelCommand}"
|
||||
Style="{StaticResource YtButtonSecondary}" Margin="0,0,8,0"/>
|
||||
<Button Content="Mastodon" Command="{Binding OpenMastodonCommand}"
|
||||
Style="{StaticResource YtButtonSecondary}"/>
|
||||
</StackPanel>
|
||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center">
|
||||
<Button Content="Buy me a coffee" Command="{Binding OpenCoffeeCommand}"
|
||||
Style="{StaticResource YtButtonSecondary}" Margin="0,0,8,0"/>
|
||||
<Button Content="Unlock Premium" Command="{Binding OpenPremiumCommand}"
|
||||
Style="{StaticResource YtButtonSecondary}"
|
||||
IsEnabled="{Binding IsPremiumAvailable}"
|
||||
ToolTip="Open the LlamaCasty subscription page"/>
|
||||
<Button Content="Enter License Key" Command="{Binding OpenLicenseEntryCommand}"
|
||||
Style="{StaticResource YtButtonSecondary}"
|
||||
ToolTip="Activate a license key to remove the watermark"/>
|
||||
</StackPanel>
|
||||
|
||||
<Button Content="Licenses & legal" Command="{Binding OpenLicensesCommand}"
|
||||
Style="{StaticResource YtButtonSecondary}" HorizontalAlignment="Center"
|
||||
Margin="0,16,0,0"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Licensing/legal panel -->
|
||||
<StackPanel Visibility="{Binding IsLicensesOpen, Converter={StaticResource BoolToVis}}">
|
||||
<TextBlock Text="Third-party notices & license texts" FontSize="15" FontWeight="Bold"
|
||||
Foreground="#e0e0e0" Margin="0,0,0,8"/>
|
||||
<Border Background="#10101e" CornerRadius="4" Padding="10" MaxHeight="360"
|
||||
BorderBrush="#333" BorderThickness="1">
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
<TextBlock Text="{Binding LicensesText}" Foreground="#b8b8c8" FontSize="11"
|
||||
FontFamily="Consolas" TextWrapping="Wrap"/>
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
<Button Content="← Back to About" Style="{StaticResource YtButtonSecondary}"
|
||||
HorizontalAlignment="Left" Margin="0,12,0,0"
|
||||
Click="BackToAbout_Click"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- License key entry panel -->
|
||||
<StackPanel Visibility="{Binding IsLicenseEntryOpen, Converter={StaticResource BoolToVis}}">
|
||||
<TextBlock Text="Activate LlamaCasty Premium" FontSize="15" FontWeight="Bold"
|
||||
Foreground="#e0e0e0" Margin="0,0,0,8"/>
|
||||
<TextBlock Text="Enter your license key to remove the branding watermark."
|
||||
Foreground="#a0a0b0" FontSize="12" TextWrapping="Wrap" Margin="0,0,0,12"/>
|
||||
<TextBlock Text="License Key" Style="{StaticResource YtLabel}" Margin="0,0,0,4"/>
|
||||
<TextBox Style="{StaticResource YtTextBox}" Margin="0,0,0,12"
|
||||
Text="{Binding LicenseKey, UpdateSourceTrigger=PropertyChanged}"
|
||||
MaxLength="100"/>
|
||||
<Button Content="Activate" Command="{Binding ActivateLicenseCommand}"
|
||||
Style="{StaticResource YtButton}" HorizontalAlignment="Right"
|
||||
Margin="0,0,0,8"/>
|
||||
<TextBlock Text="{Binding LicenseMessage}" Foreground="#a0a0b0" FontSize="12"
|
||||
TextWrapping="Wrap" Margin="0,0,0,8"/>
|
||||
<Button Content="← Back to About" Style="{StaticResource YtButtonSecondary}"
|
||||
HorizontalAlignment="Left" Margin="0,8,0,0"
|
||||
Click="BackToAbout_Click"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,22 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using ytLive.ViewModels;
|
||||
|
||||
namespace ytLive.Controls;
|
||||
|
||||
public partial class OverlayHost : UserControl
|
||||
{
|
||||
public OverlayHost()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
private void BackToAbout_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is MainViewModel vm)
|
||||
{
|
||||
vm.IsLicensesOpen = false;
|
||||
vm.IsLicenseEntryOpen = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,960 @@
|
||||
<UserControl x:Class="ytLive.Controls.PreviewPane"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:Helpers="clr-namespace:ytLive.Helpers">
|
||||
<Border Background="#0a0a1a" CornerRadius="6" Margin="8,0"
|
||||
BorderBrush="{Binding PreviewGlowBrush}" BorderThickness="{Binding PreviewGlowThickness}">
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="*"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- Preview monitor -->
|
||||
<Grid x:Name="PreviewGrid" Grid.Row="0"
|
||||
PreviewMouseLeftButtonDown="Preview_MouseLeftButtonDown"
|
||||
PreviewMouseMove="Preview_MouseMove"
|
||||
PreviewMouseLeftButtonUp="Preview_MouseLeftButtonUp">
|
||||
<Viewbox Stretch="Uniform">
|
||||
<Grid x:Name="CanvasGrid" Width="1920" Height="1080" ClipToBounds="True" Background="Transparent">
|
||||
<Grid.ContextMenu>
|
||||
<ContextMenu DataContext="{Binding PlacementTarget.DataContext, RelativeSource={RelativeSource Self}}">
|
||||
<MenuItem Header="Show Desktop" IsCheckable="True"
|
||||
IsChecked="{Binding BackgroundShowDesktop}"
|
||||
Visibility="{Binding CanChangeBackground, Converter={StaticResource BoolToVis}}"
|
||||
ToolTip="Show desktop when no fullscreen game is detected"/>
|
||||
<MenuItem Header="Capture Desktop"
|
||||
Visibility="{Binding CanChangeBackground, Converter={StaticResource BoolToVis}}"
|
||||
ItemsSource="{Binding Displays}">
|
||||
<MenuItem.ItemContainerStyle>
|
||||
<Style TargetType="MenuItem">
|
||||
<Setter Property="Header" Value="{Binding Label}"/>
|
||||
<Setter Property="Command"
|
||||
Value="{Binding DataContext.SetBackgroundDisplayCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"/>
|
||||
<Setter Property="CommandParameter" Value="{Binding}"/>
|
||||
</Style>
|
||||
</MenuItem.ItemContainerStyle>
|
||||
</MenuItem>
|
||||
<MenuItem Header="Capture Window…"
|
||||
Visibility="{Binding CanChangeBackground, Converter={StaticResource BoolToVis}}"
|
||||
ItemsSource="{Binding OpenWindows}">
|
||||
<MenuItem.ItemContainerStyle>
|
||||
<Style TargetType="MenuItem">
|
||||
<Setter Property="Header" Value="{Binding Label}"/>
|
||||
<Setter Property="Command"
|
||||
Value="{Binding DataContext.CaptureWindowCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"/>
|
||||
<Setter Property="CommandParameter" Value="{Binding}"/>
|
||||
</Style>
|
||||
</MenuItem.ItemContainerStyle>
|
||||
</MenuItem>
|
||||
<MenuItem Header="Refresh Window List" Command="{Binding RefreshWindowsCommand}"
|
||||
Visibility="{Binding CanChangeBackground, Converter={StaticResource BoolToVis}}"/>
|
||||
<MenuItem Header="Refresh Desktop" Command="{Binding RefreshCaptureCommand}"
|
||||
Visibility="{Binding CanChangeBackground, Converter={StaticResource BoolToVis}}"/>
|
||||
</ContextMenu>
|
||||
</Grid.ContextMenu>
|
||||
<!-- Live desktop/game background (bottom layer; the static
|
||||
image renders above it). -->
|
||||
<Image Source="{Binding BackgroundImage}" Stretch="UniformToFill"
|
||||
Visibility="{Binding BackgroundVisible, Converter={StaticResource BoolToVis}}"
|
||||
IsHitTestVisible="False"/>
|
||||
<Image Source="{Binding ActiveBackgroundImage}" Stretch="UniformToFill"
|
||||
IsHitTestVisible="False"/>
|
||||
<Rectangle Stroke="#22c55e" StrokeThickness="6" IsHitTestVisible="False"/>
|
||||
<Canvas x:Name="OverlayCanvas" Width="1920" Height="1080">
|
||||
<ItemsControl ItemsSource="{Binding StagedScene.Elements}"
|
||||
Width="1920" Height="1080">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<Canvas/>
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemContainerStyle>
|
||||
<Style TargetType="ContentPresenter">
|
||||
<Setter Property="Canvas.Left" Value="{Binding X}"/>
|
||||
<Setter Property="Canvas.Top" Value="{Binding Y}"/>
|
||||
</Style>
|
||||
</ItemsControl.ItemContainerStyle>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Grid Width="{Binding Width}" Height="{Binding Height}"
|
||||
Opacity="{Binding Opacity}"
|
||||
Background="Transparent"
|
||||
RenderTransformOrigin="0.5,0.5">
|
||||
<Grid.Style>
|
||||
<Style TargetType="Grid">
|
||||
<Setter Property="Visibility" Value="Collapsed"/>
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding IsVisible}" Value="True">
|
||||
<Setter Property="Visibility" Value="Visible"/>
|
||||
</DataTrigger>
|
||||
<!-- The background is a fixed full-frame layer (rendered separately,
|
||||
below the static background): never hit-testable, so it can't be
|
||||
selected, dragged, or bring up the webcam context menu. -->
|
||||
<DataTrigger Binding="{Binding IsBackground}" Value="True">
|
||||
<Setter Property="IsHitTestVisible" Value="False"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</Grid.Style>
|
||||
<Grid.RenderTransform>
|
||||
<ScaleTransform ScaleX="{Binding MirrorScale}"/>
|
||||
</Grid.RenderTransform>
|
||||
<Image Source="{Binding DisplaySource}"
|
||||
RenderOptions.BitmapScalingMode="HighQuality">
|
||||
<Image.Style>
|
||||
<Style TargetType="Image">
|
||||
<Setter Property="Visibility" Value="Collapsed"/>
|
||||
<Setter Property="Stretch" Value="UniformToFill"/>
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding IsImageSource}" Value="True">
|
||||
<Setter Property="Visibility" Value="Visible"/>
|
||||
</DataTrigger>
|
||||
<DataTrigger Binding="{Binding IsChatBox}" Value="True">
|
||||
<Setter Property="Visibility" Value="Visible"/>
|
||||
</DataTrigger>
|
||||
<!-- TASK 47: the alert box's frame (the decoded clip) is written onto
|
||||
VideoImageSource exactly like the chat box's, so it needs the same
|
||||
trigger. Without it the Image stayed Collapsed and every alert was
|
||||
invisible in the preview while the decode logged healthy (2026-09-26). -->
|
||||
<DataTrigger Binding="{Binding IsAlertBox}" Value="True">
|
||||
<Setter Property="Visibility" Value="Visible"/>
|
||||
</DataTrigger>
|
||||
<DataTrigger Binding="{Binding IsWebSource}" Value="True">
|
||||
<Setter Property="Visibility" Value="Visible"/>
|
||||
<Setter Property="Stretch" Value="Fill"/>
|
||||
</DataTrigger>
|
||||
<MultiDataTrigger>
|
||||
<MultiDataTrigger.Conditions>
|
||||
<Condition Binding="{Binding IsWebcam}" Value="True"/>
|
||||
<Condition Binding="{Binding ClipShape}" Value="Traditional"/>
|
||||
</MultiDataTrigger.Conditions>
|
||||
<Setter Property="Visibility" Value="Visible"/>
|
||||
</MultiDataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</Image.Style>
|
||||
</Image>
|
||||
<Viewbox Stretch="Uniform">
|
||||
<Grid Width="1" Height="1">
|
||||
<!-- Round webcam: a plain Image (the same efficient WriteableBitmap
|
||||
path as Traditional) clipped to a unit circle and scaled by the
|
||||
Viewbox — deliberately NOT an ImageBrush, whose per-frame
|
||||
re-rasterization at full scale stalls the preview. -->
|
||||
<Image Stretch="UniformToFill"
|
||||
Source="{Binding DisplaySource}"
|
||||
RenderOptions.BitmapScalingMode="HighQuality">
|
||||
<Image.Style>
|
||||
<Style TargetType="Image">
|
||||
<Setter Property="Visibility" Value="Collapsed"/>
|
||||
<Style.Triggers>
|
||||
<MultiDataTrigger>
|
||||
<MultiDataTrigger.Conditions>
|
||||
<Condition Binding="{Binding IsWebcam}" Value="True"/>
|
||||
<Condition Binding="{Binding ClipShape}" Value="Round"/>
|
||||
</MultiDataTrigger.Conditions>
|
||||
<Setter Property="Visibility" Value="Visible"/>
|
||||
</MultiDataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</Image.Style>
|
||||
<Image.Clip>
|
||||
<EllipseGeometry Center="0.5,0.5" RadiusX="0.5" RadiusY="0.5"/>
|
||||
</Image.Clip>
|
||||
</Image>
|
||||
</Grid>
|
||||
</Viewbox>
|
||||
|
||||
<!-- 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}">
|
||||
<Rectangle.Style>
|
||||
<Style TargetType="Rectangle">
|
||||
<Setter Property="Visibility" Value="Collapsed"/>
|
||||
<Style.Triggers>
|
||||
<MultiDataTrigger>
|
||||
<MultiDataTrigger.Conditions>
|
||||
<Condition Binding="{Binding HasBorder}" Value="True"/>
|
||||
<Condition Binding="{Binding ClipShape}" Value="Traditional"/>
|
||||
</MultiDataTrigger.Conditions>
|
||||
<Setter Property="Visibility" Value="Visible"/>
|
||||
</MultiDataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</Rectangle.Style>
|
||||
</Rectangle>
|
||||
<Ellipse IsHitTestVisible="False"
|
||||
Stroke="{Binding BorderBrush}"
|
||||
StrokeThickness="{Binding BorderWidth}"
|
||||
Width="{Binding RoundBorderSize}"
|
||||
Height="{Binding RoundBorderSize}"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||
<Ellipse.Style>
|
||||
<Style TargetType="Ellipse">
|
||||
<Setter Property="Visibility" Value="Collapsed"/>
|
||||
<Style.Triggers>
|
||||
<MultiDataTrigger>
|
||||
<MultiDataTrigger.Conditions>
|
||||
<Condition Binding="{Binding HasBorder}" Value="True"/>
|
||||
<Condition Binding="{Binding ClipShape}" Value="Round"/>
|
||||
</MultiDataTrigger.Conditions>
|
||||
<Setter Property="Visibility" Value="Visible"/>
|
||||
</MultiDataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</Ellipse.Style>
|
||||
</Ellipse>
|
||||
|
||||
<Grid.ContextMenu>
|
||||
<ContextMenu DataContext="{Binding PlacementTarget.DataContext, RelativeSource={RelativeSource Self}}">
|
||||
<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}"
|
||||
Click="WebcamMenu_SetBorderAnimation"/>
|
||||
<MenuItem Header="Pulse" Tag="Pulse" IsCheckable="True"
|
||||
IsChecked="{Binding BorderAnimation, Converter={StaticResource EnumToBool}, ConverterParameter=Pulse}"
|
||||
Click="WebcamMenu_SetBorderAnimation"/>
|
||||
<MenuItem Header="Chase" Tag="Chase" IsCheckable="True"
|
||||
IsChecked="{Binding BorderAnimation, Converter={StaticResource EnumToBool}, ConverterParameter=Chase}"
|
||||
Click="WebcamMenu_SetBorderAnimation"/>
|
||||
<MenuItem Header="Rainbow" Tag="Rainbow" IsCheckable="True"
|
||||
IsChecked="{Binding BorderAnimation, Converter={StaticResource EnumToBool}, ConverterParameter=Rainbow}"
|
||||
Click="WebcamMenu_SetBorderAnimation"/>
|
||||
<MenuItem Header="Shimmer" Tag="Shimmer" IsCheckable="True"
|
||||
IsChecked="{Binding BorderAnimation, Converter={StaticResource EnumToBool}, ConverterParameter=Shimmer}"
|
||||
Click="WebcamMenu_SetBorderAnimation"/>
|
||||
<MenuItem Header="Marching Ants" Tag="MarchingAnts" IsCheckable="True"
|
||||
IsChecked="{Binding BorderAnimation, Converter={StaticResource EnumToBool}, ConverterParameter=MarchingAnts}"
|
||||
Click="WebcamMenu_SetBorderAnimation"/>
|
||||
<MenuItem Header="Glow" Tag="Glow" IsCheckable="True"
|
||||
IsChecked="{Binding BorderAnimation, Converter={StaticResource EnumToBool}, ConverterParameter=Glow}"
|
||||
Click="WebcamMenu_SetBorderAnimation"/>
|
||||
<MenuItem Header="Electricity" Tag="Electricity" IsCheckable="True"
|
||||
IsChecked="{Binding BorderAnimation, Converter={StaticResource EnumToBool}, ConverterParameter=Electricity}"
|
||||
Click="WebcamMenu_SetBorderAnimation"/>
|
||||
<MenuItem Header="Sparkles" Tag="Sparkles" IsCheckable="True"
|
||||
IsChecked="{Binding BorderAnimation, Converter={StaticResource EnumToBool}, ConverterParameter=Sparkles}"
|
||||
Click="WebcamMenu_SetBorderAnimation"/>
|
||||
</MenuItem>
|
||||
<Separator/>
|
||||
<MenuItem Header="Hide in this scene" Click="WebcamMenu_HideInScene"/>
|
||||
<MenuItem Header="Remove" Click="WebcamMenu_Remove"/>
|
||||
</ContextMenu>
|
||||
</Grid.ContextMenu>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<Grid x:Name="SelectionOverlay" IsHitTestVisible="False"
|
||||
Canvas.Left="{Binding SelectedElement.X}" Canvas.Top="{Binding SelectedElement.Y}"
|
||||
Width="{Binding SelectedElement.Width}" Height="{Binding SelectedElement.Height}">
|
||||
<Rectangle Stroke="#e94560" StrokeDashArray="4 3" StrokeThickness="3"/>
|
||||
<Ellipse Width="26" Height="26" Fill="#e94560" Stroke="White" StrokeThickness="2"
|
||||
HorizontalAlignment="Right" VerticalAlignment="Bottom" Margin="0,0,-13,-13"/>
|
||||
</Grid>
|
||||
|
||||
<ItemsControl ItemsSource="{Binding DimRects}" IsHitTestVisible="False">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<Canvas/>
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemContainerStyle>
|
||||
<Style TargetType="ContentPresenter">
|
||||
<Setter Property="Canvas.Left" Value="{Binding X}"/>
|
||||
<Setter Property="Canvas.Top" Value="{Binding Y}"/>
|
||||
</Style>
|
||||
</ItemsControl.ItemContainerStyle>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Rectangle Width="{Binding Width}" Height="{Binding Height}"
|
||||
Fill="#000000" Opacity="0.55"/>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<Rectangle IsHitTestVisible="False" Stroke="#e94560" StrokeThickness="3"
|
||||
Canvas.Left="{Binding OutputRectX}" Canvas.Top="{Binding OutputRectY}"
|
||||
Width="{Binding OutputRectWidth}" Height="{Binding OutputRectHeight}"
|
||||
Visibility="{Binding IsOutputCropped, Converter={StaticResource BoolToVis}}"/>
|
||||
|
||||
<!-- Free-tier branding credit. The source is the SAME rendered
|
||||
frame FramePump composites into the recording/stream
|
||||
(BrandFlashPresenter → MainViewModel.BrandFlashFrame), so
|
||||
the preview cannot drift from the broadcast. It replaces a
|
||||
preview-only TextBlock that no viewer ever saw, and the
|
||||
fade/spread/placement is the renderer's — there is no
|
||||
storyboard here to keep in sync. -->
|
||||
<Grid x:Name="BrandFlashElement" Width="1920" Height="1080"
|
||||
Canvas.Left="0" Canvas.Top="0"
|
||||
Background="Transparent" IsHitTestVisible="False"
|
||||
Visibility="{Binding BrandFlashVisible, Converter={StaticResource BoolToVis}}">
|
||||
<Image Source="{Binding BrandFlashImageSource}"
|
||||
Stretch="Fill" RenderOptions.BitmapScalingMode="HighQuality"/>
|
||||
</Grid>
|
||||
|
||||
<!-- Alert announcement strip (TASK 47): the same global
|
||||
1920px overlay the output compositor blits along the
|
||||
top edge. Global resource — never a Source. Without it
|
||||
the strip reached the stream but never the preview. -->
|
||||
<!-- Alert ticker: the announcement strip draws INSIDE the Stream
|
||||
Alerts box (creator ruling 2026-09-26), so this element is
|
||||
placed and sized to that box's rect rather than pinned as a
|
||||
1920px bar to the top edge of the preview. -->
|
||||
<Grid x:Name="AlertTickerElement"
|
||||
Canvas.Left="{Binding AlertTickerLeft}"
|
||||
Canvas.Top="{Binding AlertTickerTop}"
|
||||
Width="{Binding AlertTickerWidth}"
|
||||
Height="{Binding AlertTickerHeight}"
|
||||
Background="Transparent" IsHitTestVisible="False"
|
||||
Visibility="{Binding AlertTickerVisible, Converter={StaticResource BoolToVis}}">
|
||||
<Image Source="{Binding AlertTickerImageSource}"
|
||||
Stretch="Fill" RenderOptions.BitmapScalingMode="HighQuality"/>
|
||||
</Grid>
|
||||
<!-- Social bar: one centered horizontal row at the top or bottom of
|
||||
the frame, green glow while enabled. Global resource — never a
|
||||
Source, no Sources entry. Click toggles top ⇄ bottom. -->
|
||||
<Grid x:Name="SocialBarElement" Width="1920" Canvas.Top="{Binding SocialBarTop}"
|
||||
Background="Transparent"
|
||||
Visibility="{Binding SocialBarVisible, Converter={StaticResource BoolToVis}}"
|
||||
MouseLeftButtonDown="SocialBar_MouseLeftButtonDown"
|
||||
Cursor="Hand">
|
||||
<Grid.Effect>
|
||||
<DropShadowEffect Color="{Binding SocialBarGlowBrush.Color}"
|
||||
BlurRadius="18" ShadowDepth="0" Opacity="0.9"/>
|
||||
</Grid.Effect>
|
||||
<ItemsControl ItemsSource="{Binding Socials.Entries}"
|
||||
HorizontalAlignment="Center" Margin="40,8"
|
||||
IsHitTestVisible="False">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<StackPanel Orientation="Horizontal"/>
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<StackPanel Orientation="Horizontal" Margin="0,0,20,0">
|
||||
<Path Data="{Binding LogoData}" Fill="White" Width="24" Height="24"
|
||||
Stretch="Uniform" VerticalAlignment="Center"/>
|
||||
<TextBlock Text="{Binding Handle}" Foreground="White"
|
||||
FontSize="14" VerticalAlignment="Center"
|
||||
Margin="8,0,0,0"/>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</Grid>
|
||||
</Canvas>
|
||||
</Grid>
|
||||
</Viewbox>
|
||||
<Border HorizontalAlignment="Left" VerticalAlignment="Top" Margin="8,8,0,0"
|
||||
Background="#CCe94560" CornerRadius="4" Padding="8,3" IsHitTestVisible="False"
|
||||
Visibility="{Binding WebcamError, Converter={StaticResource NotNullToVis}}">
|
||||
<TextBlock Text="{Binding WebcamError}" Foreground="White" FontSize="12"
|
||||
MaxWidth="520" TextTrimming="CharacterEllipsis"/>
|
||||
</Border>
|
||||
<TextBlock Text="Preview" Foreground="#333" FontSize="24"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
Visibility="{Binding ShowPreviewPlaceholder, Converter={StaticResource BoolToVis}}"/>
|
||||
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
Visibility="{Binding ShowEmptySceneHint, Converter={StaticResource BoolToVis}}">
|
||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center">
|
||||
<TextBlock Text="🖥️" Foreground="White" FontSize="30" Margin="0,0,20,0"/>
|
||||
<TextBlock Text="📷" Foreground="White" FontSize="30" Margin="0,0,20,0"/>
|
||||
<TextBlock Text="🖼️" Foreground="White" FontSize="30" Margin="0,0,20,0"/>
|
||||
<TextBlock Text="✏️" Foreground="White" FontSize="30"/>
|
||||
</StackPanel>
|
||||
<TextBlock Text="Make it yours!" Foreground="#e0e0e0" FontSize="18"
|
||||
FontWeight="SemiBold" HorizontalAlignment="Center" Margin="0,18,0,0"/>
|
||||
<TextBlock Text="Add layers — your webcam, your screen, images, and text — to craft a brand and identity that's unmistakably you."
|
||||
Foreground="#a0a0b0" FontSize="13" Margin="0,8,0,0"
|
||||
TextWrapping="Wrap" MaxWidth="440" TextAlignment="Center"
|
||||
HorizontalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<!-- ═══ RIGHT-EDGE PULL-OUTS (always visible) ═══
|
||||
White "Stream Settings" + "YPP" tabs stacked on the preview's right
|
||||
edge; each toggles its own drawer (the same animated-width pattern).
|
||||
Only one drawer is open at a time — IsDrawerOpen exclusivity is
|
||||
VM-enforced (opening one collapses the other). Any click outside
|
||||
the rail collapses whichever is open (MainWindow code-behind). -->
|
||||
<Grid Grid.Row="0">
|
||||
<StackPanel x:Name="TextPullOutHost" Orientation="Horizontal"
|
||||
HorizontalAlignment="Right" VerticalAlignment="Stretch">
|
||||
<!-- The broadcast-metadata drawer -->
|
||||
<Border Background="#16213e" ClipToBounds="True">
|
||||
<Border.Style>
|
||||
<Style TargetType="Border">
|
||||
<Setter Property="Width" Value="0"/>
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding BroadcastForm.IsDrawerOpen}" Value="True">
|
||||
<DataTrigger.EnterActions>
|
||||
<BeginStoryboard>
|
||||
<Storyboard>
|
||||
<DoubleAnimation Storyboard.TargetProperty="Width"
|
||||
To="320" Duration="0:0:0.2">
|
||||
<DoubleAnimation.EasingFunction>
|
||||
<CubicEase EasingMode="EaseOut"/>
|
||||
</DoubleAnimation.EasingFunction>
|
||||
</DoubleAnimation>
|
||||
</Storyboard>
|
||||
</BeginStoryboard>
|
||||
</DataTrigger.EnterActions>
|
||||
<DataTrigger.ExitActions>
|
||||
<BeginStoryboard>
|
||||
<Storyboard>
|
||||
<DoubleAnimation Storyboard.TargetProperty="Width"
|
||||
To="0" Duration="0:0:0.2">
|
||||
<DoubleAnimation.EasingFunction>
|
||||
<CubicEase EasingMode="EaseOut"/>
|
||||
</DoubleAnimation.EasingFunction>
|
||||
</DoubleAnimation>
|
||||
</Storyboard>
|
||||
</BeginStoryboard>
|
||||
</DataTrigger.ExitActions>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</Border.Style>
|
||||
<!-- Fixed-width inner so text never reflows mid-animation -->
|
||||
<Grid Width="320" Margin="14,10,14,12">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
<Grid Grid.Row="0" Margin="0,0,0,8">
|
||||
<TextBlock Text="BROADCAST TEXT" Style="{StaticResource SectionHeader}"
|
||||
HorizontalAlignment="Left"/>
|
||||
<Button HorizontalAlignment="Right" VerticalAlignment="Top"
|
||||
Background="Transparent" BorderThickness="0" Cursor="Hand"
|
||||
Command="{Binding BroadcastForm.CloseDrawerCommand}"
|
||||
ToolTip="Close">
|
||||
<Button.Template>
|
||||
<ControlTemplate TargetType="Button">
|
||||
<ContentPresenter/>
|
||||
</ControlTemplate>
|
||||
</Button.Template>
|
||||
<TextBlock Text="✕" Foreground="#a0a0b0" FontSize="14" FontWeight="Bold"/>
|
||||
</Button>
|
||||
</Grid>
|
||||
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel>
|
||||
<TextBlock Text="TITLE" Style="{StaticResource YtLabel}" Margin="0,0,0,3"/>
|
||||
<TextBox Style="{StaticResource YtTextBox}" Margin="0,0,0,10"
|
||||
Text="{Binding BroadcastForm.Title, UpdateSourceTrigger=PropertyChanged}"/>
|
||||
<TextBlock Text="DESCRIPTION" Style="{StaticResource YtLabel}" Margin="0,0,0,3"/>
|
||||
<TextBox Style="{StaticResource YtTextBox}" Height="90" AcceptsReturn="True"
|
||||
TextWrapping="Wrap" VerticalScrollBarVisibility="Auto" Margin="0,0,0,10"
|
||||
Text="{Binding BroadcastForm.Description, UpdateSourceTrigger=PropertyChanged}"/>
|
||||
<TextBlock Text="TAGS (COMMA-SEPARATED)" Style="{StaticResource YtLabel}" Margin="0,0,0,3"/>
|
||||
<TextBox Style="{StaticResource YtTextBox}" Margin="0,0,0,10"
|
||||
Text="{Binding BroadcastForm.TagsCsv, UpdateSourceTrigger=PropertyChanged}"/>
|
||||
<TextBlock Text="VISIBILITY" Style="{StaticResource YtLabel}" Margin="0,0,0,3"/>
|
||||
<ComboBox Style="{StaticResource YtComboBox}" Margin="0,0,0,10"
|
||||
ItemsSource="{Binding BroadcastForm.VisibilityOptions}"
|
||||
SelectedItem="{Binding BroadcastForm.Visibility}"/>
|
||||
<CheckBox Content="Made for kids" Foreground="#e0e0e0" FontSize="12"
|
||||
Margin="0,2,0,10"
|
||||
IsChecked="{Binding BroadcastForm.MadeForKids}"/>
|
||||
<TextBlock Text="SCHEDULED START" Style="{StaticResource YtLabel}" Margin="0,0,0,3"/>
|
||||
<TextBlock Text="{Binding BroadcastForm.ScheduledStartText}"
|
||||
Foreground="#888899" FontSize="12" Margin="0,0,0,4"/>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
<StackPanel Grid.Row="2" Margin="0,10,0,0">
|
||||
<Button Style="{StaticResource YtButton}" Content="Update Broadcast"
|
||||
Command="{Binding BroadcastForm.UpdateBroadcastCommand}"/>
|
||||
<TextBlock Text="{Binding BroadcastForm.UpdateError}"
|
||||
Foreground="#ef4444" FontSize="11" TextWrapping="Wrap" Margin="0,6,0,0"
|
||||
Visibility="{Binding BroadcastForm.UpdateError, Converter={StaticResource NotNullToVis}}"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
<!-- The YPP-progress drawer (below Stream Settings on the rail) -->
|
||||
<Border Background="#16213e" ClipToBounds="True">
|
||||
<Border.Style>
|
||||
<Style TargetType="Border">
|
||||
<Setter Property="Width" Value="0"/>
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding Ypp.IsDrawerOpen}" Value="True">
|
||||
<DataTrigger.EnterActions>
|
||||
<BeginStoryboard>
|
||||
<Storyboard>
|
||||
<DoubleAnimation Storyboard.TargetProperty="Width"
|
||||
To="320" Duration="0:0:0.2">
|
||||
<DoubleAnimation.EasingFunction>
|
||||
<CubicEase EasingMode="EaseOut"/>
|
||||
</DoubleAnimation.EasingFunction>
|
||||
</DoubleAnimation>
|
||||
</Storyboard>
|
||||
</BeginStoryboard>
|
||||
</DataTrigger.EnterActions>
|
||||
<DataTrigger.ExitActions>
|
||||
<BeginStoryboard>
|
||||
<Storyboard>
|
||||
<DoubleAnimation Storyboard.TargetProperty="Width"
|
||||
To="0" Duration="0:0:0.2">
|
||||
<DoubleAnimation.EasingFunction>
|
||||
<CubicEase EasingMode="EaseOut"/>
|
||||
</DoubleAnimation.EasingFunction>
|
||||
</DoubleAnimation>
|
||||
</Storyboard>
|
||||
</BeginStoryboard>
|
||||
</DataTrigger.ExitActions>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</Border.Style>
|
||||
<Grid Width="320" Margin="14,10,14,12">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
<Grid Grid.Row="0" Margin="0,0,0,8">
|
||||
<TextBlock Text="YPP PROGRESS" Style="{StaticResource SectionHeader}"
|
||||
HorizontalAlignment="Left"/>
|
||||
<Button HorizontalAlignment="Right" VerticalAlignment="Top"
|
||||
Background="Transparent" BorderThickness="0" Cursor="Hand"
|
||||
Command="{Binding Ypp.CloseDrawerCommand}"
|
||||
ToolTip="Close">
|
||||
<Button.Template>
|
||||
<ControlTemplate TargetType="Button">
|
||||
<ContentPresenter/>
|
||||
</ControlTemplate>
|
||||
</Button.Template>
|
||||
<TextBlock Text="✕" Foreground="#a0a0b0" FontSize="14" FontWeight="Bold"/>
|
||||
</Button>
|
||||
</Grid>
|
||||
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel>
|
||||
<TextBlock Text="Sign in to YouTube to see your Partner Program progress and eligibility checklist here."
|
||||
Foreground="#d0d0e0" FontSize="13" TextWrapping="Wrap"
|
||||
Visibility="{Binding Ypp.IsSignedOut, Converter={StaticResource BoolToVis}}"/>
|
||||
<TextBlock Text="Refreshing from YouTube…" Foreground="#a0a0b0" FontSize="12"
|
||||
Margin="0,0,0,4"
|
||||
Visibility="{Binding Ypp.IsLoading, Converter={StaticResource BoolToVis}}"/>
|
||||
<TextBlock Text="{Binding Ypp.LoadError}" Foreground="#ef4444" FontSize="11"
|
||||
TextWrapping="Wrap" Margin="0,0,0,4"
|
||||
Visibility="{Binding Ypp.LoadError, Converter={StaticResource NotNullToVis}}"/>
|
||||
<StackPanel Visibility="{Binding Ypp.IsSignedOut, Converter={StaticResource InverseBoolToVis}}">
|
||||
<TextBlock Text="{Binding Ypp.DisplayName}" Foreground="#e0e0e0" FontSize="13"
|
||||
FontWeight="SemiBold" Margin="0,0,0,10"/>
|
||||
<TextBlock Text="SUBSCRIBERS — TIER 1 · FAN FUNDING" Style="{StaticResource YtLabel}" Margin="0,0,0,2"/>
|
||||
<TextBlock Text="{Binding Ypp.Tier1SubsText}" Foreground="#e0e0e0" FontSize="13"/>
|
||||
<Border Height="8" Background="#2a2a40" CornerRadius="4" Margin="0,4,0,10">
|
||||
<Border Height="8" Background="#e94560" CornerRadius="4" HorizontalAlignment="Left"
|
||||
Width="{Binding Ypp.Tier1SubsBarWidth}"/>
|
||||
</Border>
|
||||
<TextBlock Text="SUBSCRIBERS — TIER 2 · AD REVENUE" Style="{StaticResource YtLabel}" Margin="0,0,0,2"/>
|
||||
<TextBlock Text="{Binding Ypp.Tier2SubsText}" Foreground="#e0e0e0" FontSize="13"/>
|
||||
<Border Height="8" Background="#2a2a40" CornerRadius="4" Margin="0,4,0,10">
|
||||
<Border Height="8" Background="#e94560" CornerRadius="4" HorizontalAlignment="Left"
|
||||
Width="{Binding Ypp.Tier2SubsBarWidth}"/>
|
||||
</Border>
|
||||
<TextBlock Text="PUBLIC UPLOADS (90 DAYS)" Style="{StaticResource YtLabel}" Margin="0,0,0,2"/>
|
||||
<TextBlock Text="{Binding Ypp.UploadsText}" Foreground="#e0e0e0" FontSize="13"/>
|
||||
<Border Height="8" Background="#2a2a40" CornerRadius="4" Margin="0,4,0,6">
|
||||
<Border Height="8" Background="#e94560" CornerRadius="4" HorizontalAlignment="Left"
|
||||
Width="{Binding Ypp.UploadsBarWidth}"/>
|
||||
</Border>
|
||||
<TextBlock Text="LONG-FORM WATCH HOURS + SHORTS VIEWS" Style="{StaticResource YtLabel}" Margin="0,10,0,2"/>
|
||||
<TextBlock Text="These arrive with the extended analytics update — subscriber + upload + standing progress is live now."
|
||||
Foreground="#666677" FontSize="12" TextWrapping="Wrap"/>
|
||||
|
||||
<TextBlock Text="COMPLIANCE CHECKLIST" Style="{StaticResource YtLabel}" Margin="0,14,0,4"/>
|
||||
<TextBlock Text="CHANNEL STANDING" Style="{StaticResource YtLabel}" Margin="0,0,0,2"/>
|
||||
<TextBlock Text="{Binding Ypp.StandingDetailText}" Foreground="#888899" FontSize="11"
|
||||
TextWrapping="Wrap" Margin="0,0,0,8"/>
|
||||
<CheckBox Content="Two-Step Verification switched on" Foreground="#e0e0e0" FontSize="12"
|
||||
Margin="0,2,0,0"
|
||||
IsChecked="{Binding Ypp.TwoFactorEnabled}"/>
|
||||
<Button Background="Transparent" BorderThickness="0" Cursor="Hand" HorizontalAlignment="Left"
|
||||
Command="{Binding Ypp.OpenTwoStepCommand}" ToolTip="Open Google's 2-Step Verification page">
|
||||
<Button.Template>
|
||||
<ControlTemplate TargetType="Button">
|
||||
<ContentPresenter/>
|
||||
</ControlTemplate>
|
||||
</Button.Template>
|
||||
<TextBlock Text="Set up on Google →" Foreground="#5aa7ff" FontSize="12" Margin="0,0,0,6"/>
|
||||
</Button>
|
||||
<CheckBox Content="AdSense account linked" Foreground="#e0e0e0" FontSize="12"
|
||||
Margin="0,2,0,0"
|
||||
IsChecked="{Binding Ypp.AdSenseLinked}"/>
|
||||
<Button Background="Transparent" BorderThickness="0" Cursor="Hand" HorizontalAlignment="Left"
|
||||
Command="{Binding Ypp.OpenEarnCommand}" ToolTip="Open the YouTube Earn page">
|
||||
<Button.Template>
|
||||
<ControlTemplate TargetType="Button">
|
||||
<ContentPresenter/>
|
||||
</ControlTemplate>
|
||||
</Button.Template>
|
||||
<TextBlock Text="How to link on YouTube →" Foreground="#5aa7ff" FontSize="12" Margin="0,0,0,6"/>
|
||||
</Button>
|
||||
|
||||
<TextBlock Text="WHAT COUNTS" Style="{StaticResource YtLabel}" Margin="0,10,0,4"/>
|
||||
<TextBlock Text="{Binding Ypp.WhatCountsText}" Foreground="#a0a0b0" FontSize="12"
|
||||
TextWrapping="Wrap" Margin="0,0,0,6"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
<StackPanel Grid.Row="2" Margin="0,10,0,0">
|
||||
<Button Style="{StaticResource YtButton}" Content="Refresh from YouTube"
|
||||
Command="{Binding Ypp.RefreshCommand}"/>
|
||||
<TextBlock Text="{Binding Ypp.LastUpdatedText}" Foreground="#888899" FontSize="11" Margin="0,6,0,0"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
<!-- The Test-stream drawer (below YPP on the rail) -->
|
||||
<Border Background="#16213e" ClipToBounds="True">
|
||||
<Border.Style>
|
||||
<Style TargetType="Border">
|
||||
<Setter Property="Width" Value="0"/>
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding TestSession.IsDrawerOpen}" Value="True">
|
||||
<DataTrigger.EnterActions>
|
||||
<BeginStoryboard>
|
||||
<Storyboard>
|
||||
<DoubleAnimation Storyboard.TargetProperty="Width"
|
||||
To="320" Duration="0:0:0.2">
|
||||
<DoubleAnimation.EasingFunction>
|
||||
<CubicEase EasingMode="EaseOut"/>
|
||||
</DoubleAnimation.EasingFunction>
|
||||
</DoubleAnimation>
|
||||
</Storyboard>
|
||||
</BeginStoryboard>
|
||||
</DataTrigger.EnterActions>
|
||||
<DataTrigger.ExitActions>
|
||||
<BeginStoryboard>
|
||||
<Storyboard>
|
||||
<DoubleAnimation Storyboard.TargetProperty="Width"
|
||||
To="0" Duration="0:0:0.2">
|
||||
<DoubleAnimation.EasingFunction>
|
||||
<CubicEase EasingMode="EaseOut"/>
|
||||
</DoubleAnimation.EasingFunction>
|
||||
</DoubleAnimation>
|
||||
</Storyboard>
|
||||
</BeginStoryboard>
|
||||
</DataTrigger.ExitActions>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</Border.Style>
|
||||
<Grid Width="320" Margin="14,10,14,12">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
<Grid Grid.Row="0" Margin="0,0,0,8">
|
||||
<TextBlock Text="TEST STREAM" Style="{StaticResource SectionHeader}"
|
||||
HorizontalAlignment="Left"/>
|
||||
<Button HorizontalAlignment="Right" VerticalAlignment="Top"
|
||||
Background="Transparent" BorderThickness="0" Cursor="Hand"
|
||||
Command="{Binding TestSession.CloseDrawerCommand}"
|
||||
ToolTip="Close">
|
||||
<Button.Template>
|
||||
<ControlTemplate TargetType="Button">
|
||||
<ContentPresenter/>
|
||||
</ControlTemplate>
|
||||
</Button.Template>
|
||||
<TextBlock Text="✕" Foreground="#a0a0b0" FontSize="14" FontWeight="Bold"/>
|
||||
</Button>
|
||||
</Grid>
|
||||
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel>
|
||||
<TextBlock Text="{Binding TestSession.SessionCaption}"
|
||||
Foreground="#d0d0e0" FontSize="13" TextWrapping="Wrap" Margin="0,0,0,8"/>
|
||||
<TextBlock Text="{Binding TestSession.ChatIdLine}"
|
||||
Foreground="#888899" FontSize="11"
|
||||
Visibility="{Binding TestSession.IsTestSession, Converter={StaticResource BoolToVis}}"/>
|
||||
|
||||
<TextBlock Text="MOCK CHAT INPUT" Style="{StaticResource YtLabel}" Margin="0,10,0,3"/>
|
||||
<TextBlock Text="Sends a real live chat message — it round-trips through the actual chat poll and renders on your chat overlay."
|
||||
Foreground="#a0a0b0" FontSize="11" TextWrapping="Wrap" Margin="0,0,0,4"/>
|
||||
<TextBox Style="{StaticResource YtTextBox}" Margin="0,0,0,6"
|
||||
Text="{Binding TestSession.SendMessageText, UpdateSourceTrigger=PropertyChanged}"/>
|
||||
<Button Style="{StaticResource YtButton}" Content="Send Message"
|
||||
Command="{Binding TestSession.SendTestMessageCommand}"/>
|
||||
|
||||
<TextBlock Text="SIMULATED EVENTS" Style="{StaticResource YtLabel}" Margin="0,14,0,3"/>
|
||||
<TextBlock Text="Fired into your chat overlay locally — YouTube's insert API only creates text, so these never reach YouTube."
|
||||
Foreground="#a0a0b0" FontSize="11" TextWrapping="Wrap" Margin="0,0,0,4"/>
|
||||
<StackPanel Orientation="Horizontal" Margin="0,0,0,4">
|
||||
<Button Style="{StaticResource YtButtonSecondary}" Content="Subscriber" Padding="10,4" FontSize="11"
|
||||
Margin="0,0,6,0" Command="{Binding TestSession.SimulateSubscriberCommand}"/>
|
||||
<Button Style="{StaticResource YtButtonSecondary}" Content="New Member" Padding="10,4" FontSize="11"
|
||||
Margin="0,0,6,0" Command="{Binding TestSession.SimulateMemberCommand}"/>
|
||||
</StackPanel>
|
||||
<Button Style="{StaticResource YtButtonSecondary}" Content="Super Chat (5.00)" Padding="10,4" FontSize="11"
|
||||
HorizontalAlignment="Left" Command="{Binding TestSession.SimulateSuperChatCommand}"/>
|
||||
|
||||
<TextBlock Text="{Binding TestSession.LastEventText}"
|
||||
Foreground="#22c55e" FontSize="11" TextWrapping="Wrap" Margin="0,10,0,0"
|
||||
Visibility="{Binding TestSession.LastEventText, Converter={StaticResource NotNullToVis}}"/>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
<StackPanel Grid.Row="2" Margin="0,10,0,0">
|
||||
<Button Style="{StaticResource YtButton}" Content="End Test"
|
||||
Command="{Binding TestSession.EndTestCommand}"
|
||||
Visibility="{Binding TestSession.IsTestSession, Converter={StaticResource BoolToVis}}"/>
|
||||
<TextBlock Text="Test broadcasts are private — nobody is notified, and it runs through the exact same pipeline as a real live."
|
||||
Foreground="#666677" FontSize="11" TextWrapping="Wrap" Margin="0,6,0,0"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
<!-- The stacked white tabs -->
|
||||
<StackPanel Orientation="Vertical" HorizontalAlignment="Right" VerticalAlignment="Stretch">
|
||||
<Border Width="30" VerticalAlignment="Center" Background="#ffffff"
|
||||
CornerRadius="4,0,0,4" Padding="0,10" Cursor="Hand" Margin="0,4,0,0"
|
||||
ToolTip="Broadcast text fields">
|
||||
<Border.InputBindings>
|
||||
<MouseBinding Gesture="LeftClick"
|
||||
Command="{Binding BroadcastForm.ToggleDrawerCommand}"/>
|
||||
</Border.InputBindings>
|
||||
<TextBlock Text="Stream Settings" Foreground="#e94560" FontWeight="Bold" FontSize="15"
|
||||
HorizontalAlignment="Center">
|
||||
<TextBlock.LayoutTransform>
|
||||
<RotateTransform Angle="90"/>
|
||||
</TextBlock.LayoutTransform>
|
||||
</TextBlock>
|
||||
</Border>
|
||||
<Border Width="30" VerticalAlignment="Center" Background="#ffffff"
|
||||
CornerRadius="4,0,0,4" Padding="0,10" Cursor="Hand" Margin="0,4,0,0"
|
||||
ToolTip="YouTube Partner Program progress">
|
||||
<Border.InputBindings>
|
||||
<MouseBinding Gesture="LeftClick"
|
||||
Command="{Binding Ypp.ToggleDrawerCommand}"/>
|
||||
</Border.InputBindings>
|
||||
<TextBlock Text="YPP" Foreground="#e94560" FontWeight="Bold" FontSize="15"
|
||||
HorizontalAlignment="Center">
|
||||
<TextBlock.LayoutTransform>
|
||||
<RotateTransform Angle="90"/>
|
||||
</TextBlock.LayoutTransform>
|
||||
</TextBlock>
|
||||
</Border>
|
||||
<Border Width="30" VerticalAlignment="Center" Background="#ffffff"
|
||||
CornerRadius="4,0,0,4" Padding="0,10" Cursor="Hand" Margin="0,4,0,0"
|
||||
ToolTip="Test stream — mock chat input and simulated events">
|
||||
<Border.InputBindings>
|
||||
<MouseBinding Gesture="LeftClick"
|
||||
Command="{Binding TestSession.ToggleDrawerCommand}"/>
|
||||
</Border.InputBindings>
|
||||
<TextBlock Text="TEST" Foreground="#9c6f1c" FontWeight="Bold" FontSize="15"
|
||||
HorizontalAlignment="Center">
|
||||
<TextBlock.LayoutTransform>
|
||||
<RotateTransform Angle="90"/>
|
||||
</TextBlock.LayoutTransform>
|
||||
</TextBlock>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<!-- ═══ LIVE CONTROLS (below preview, never overlaps) ═══
|
||||
Line 1: Socials + TRAX meter (desktop/game audio)
|
||||
Line 2: Mic meter + volume -->
|
||||
<StackPanel Grid.Row="2" Orientation="Vertical" HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center" Margin="0,4">
|
||||
<!-- Line 1: Socials + TRAX -->
|
||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center" Margin="0,0,0,4">
|
||||
<!-- Socials button: hover glows the social bar in the preview -->
|
||||
<Button x:Name="SocialsButton"
|
||||
Command="{Binding OpenSocialDialogCommand}"
|
||||
Style="{StaticResource YtButtonSecondary}" Padding="12,4"
|
||||
FontSize="11" VerticalAlignment="Center" Margin="0,0,12,0"
|
||||
ToolTip="Add or manage your social handles"
|
||||
MouseEnter="SocialsButton_MouseEnter"
|
||||
MouseLeave="SocialsButton_MouseLeave">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Ellipse Width="8" Height="8" Fill="{Binding SocialBarDotBrush}"
|
||||
Margin="0,0,6,0" VerticalAlignment="Center"/>
|
||||
<TextBlock Text="Socials"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<!-- TRAX meter -->
|
||||
<Border Background="#D9000000" CornerRadius="6" Padding="12,8">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Button Style="{StaticResource YtButtonSecondary}" Padding="8,2"
|
||||
FontSize="11" VerticalAlignment="Center" Margin="0,0,8,0"
|
||||
ToolTipService.ToolTip="{Binding TraxToolTip}"
|
||||
ToolTipService.Placement="Top"
|
||||
Click="TraxButton_Click"
|
||||
PreviewMouseRightButtonUp="TraxButton_PreviewMouseRightButtonUp">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Ellipse Width="8" Height="8" Fill="{Binding TraxDotBrush}"
|
||||
Margin="0,0,6,0" VerticalAlignment="Center"/>
|
||||
<TextBlock Text="TRAX"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<TextBlock Text="Desktop Audio" Foreground="#a0a0b0" FontSize="11"
|
||||
FontWeight="SemiBold" VerticalAlignment="Center" Margin="0,0,8,0"
|
||||
ToolTip="Desktop/game audio via WASAPI loopback (automatic); TRAX music rides the same channel"/>
|
||||
<Border Width="288" Height="14" CornerRadius="7" Background="#3a3b52" Margin="0,0,8,0"
|
||||
ClipToBounds="True" VerticalAlignment="Center">
|
||||
<Grid>
|
||||
<Rectangle Width="57" Fill="#4a4520" HorizontalAlignment="Left" Margin="173,0,0,0"
|
||||
VerticalAlignment="Stretch"/>
|
||||
<Rectangle Width="58" Fill="#4a2222" HorizontalAlignment="Left" Margin="230,0,0,0"
|
||||
VerticalAlignment="Stretch"/>
|
||||
<Border HorizontalAlignment="Left" Width="{Binding GameMeterFillWidth}"
|
||||
Background="{Binding GameMeterBrush}" Opacity="0.75"/>
|
||||
<Rectangle Width="1" Height="10" Fill="#26000000" VerticalAlignment="Center" HorizontalAlignment="Left" Margin="36,0,0,0"/>
|
||||
<Rectangle Width="1" Height="10" Fill="#26000000" VerticalAlignment="Center" HorizontalAlignment="Left" Margin="72,0,0,0"/>
|
||||
<Rectangle Width="1" Height="10" Fill="#26000000" VerticalAlignment="Center" HorizontalAlignment="Left" Margin="108,0,0,0"/>
|
||||
<Rectangle Width="1" Height="10" Fill="#26000000" VerticalAlignment="Center" HorizontalAlignment="Left" Margin="144,0,0,0"/>
|
||||
<Rectangle Width="1" Height="10" Fill="#26000000" VerticalAlignment="Center" HorizontalAlignment="Left" Margin="180,0,0,0"/>
|
||||
<Rectangle Width="1" Height="10" Fill="#26000000" VerticalAlignment="Center" HorizontalAlignment="Left" Margin="216,0,0,0"/>
|
||||
<Rectangle Width="1" Height="10" Fill="#26000000" VerticalAlignment="Center" HorizontalAlignment="Left" Margin="252,0,0,0"/>
|
||||
<Rectangle Width="2" Fill="#00000080" HorizontalAlignment="Left" Margin="173,0,0,0"
|
||||
VerticalAlignment="Stretch"/>
|
||||
<Rectangle Width="2" Fill="#00000080" HorizontalAlignment="Left" Margin="230,0,0,0"
|
||||
VerticalAlignment="Stretch"/>
|
||||
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled"
|
||||
Visibility="{Binding TraxError, Converter={StaticResource NotNullToVis}}"
|
||||
VerticalAlignment="Center" Margin="4,0">
|
||||
<TextBlock Text="{Binding TraxError}" Foreground="#e94560" FontSize="9"
|
||||
FontWeight="SemiBold" VerticalAlignment="Center"/>
|
||||
</ScrollViewer>
|
||||
</Grid>
|
||||
</Border>
|
||||
<Grid Width="16" Height="16" Cursor="Hand" Margin="0,0,8,0"
|
||||
VerticalAlignment="Center" ToolTip="{Binding GameMuteText}"
|
||||
MouseLeftButtonUp="GameSpeaker_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 GameMuted, 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 GameMuted, Converter={StaticResource BoolToVis}}"/>
|
||||
<Path Stroke="#ef4444" StrokeThickness="2.5" StrokeEndLineCap="Round" Stretch="Uniform"
|
||||
Data="M21,3 L3,21"
|
||||
Visibility="{Binding GameMuted, Converter={StaticResource BoolToVis}}"/>
|
||||
</Grid>
|
||||
<Slider Width="130" Minimum="0" Maximum="1"
|
||||
Value="{Binding GameAudioVolume, Mode=TwoWay}" VerticalAlignment="Center"
|
||||
PreviewMouseLeftButtonDown="GameVolumeSlider_PreviewMouseLeftButtonDown"
|
||||
PreviewMouseLeftButtonUp="GameVolumeSlider_PreviewMouseLeftButtonUp"
|
||||
LostMouseCapture="GameVolumeSlider_LostMouseCapture"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
<!-- Line 2: Mic meter + volume -->
|
||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center">
|
||||
<Button Command="{Binding OpenMicPickerCommand}"
|
||||
Style="{StaticResource YtButtonSecondary}" Padding="12,4"
|
||||
FontSize="11" VerticalAlignment="Center" Margin="0,0,8,0"
|
||||
ToolTip="{Binding MicStatusToolTip}">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Ellipse Width="8" Height="8" Fill="{Binding MicStatusBrush}"
|
||||
Margin="0,0,6,0" VerticalAlignment="Center"/>
|
||||
<TextBlock Text="MIC"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<Border Width="288" Height="14" CornerRadius="7" Background="#3a3b52" Margin="0,0,8,0"
|
||||
ClipToBounds="True" VerticalAlignment="Center">
|
||||
<Grid>
|
||||
<Rectangle Width="57" Fill="#4a4520" HorizontalAlignment="Left" Margin="173,0,0,0"
|
||||
VerticalAlignment="Stretch"/>
|
||||
<Rectangle Width="58" Fill="#4a2222" HorizontalAlignment="Left" Margin="230,0,0,0"
|
||||
VerticalAlignment="Stretch"/>
|
||||
<TextBlock Text="{Binding MicSourceName}" Foreground="#b8b8c8" FontSize="10"
|
||||
VerticalAlignment="Center" Margin="6,0,0,0" MaxWidth="270"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
Visibility="{Binding MicSourceName, Converter={StaticResource NotNullToVis}}"/>
|
||||
<Border HorizontalAlignment="Left" Width="{Binding MeterFillWidth}"
|
||||
Background="{Binding MeterBrush}" Opacity="0.75"/>
|
||||
<Rectangle Width="1" Height="10" Fill="#26000000" VerticalAlignment="Center" HorizontalAlignment="Left" Margin="36,0,0,0"/>
|
||||
<Rectangle Width="1" Height="10" Fill="#26000000" VerticalAlignment="Center" HorizontalAlignment="Left" Margin="72,0,0,0"/>
|
||||
<Rectangle Width="1" Height="10" Fill="#26000000" VerticalAlignment="Center" HorizontalAlignment="Left" Margin="108,0,0,0"/>
|
||||
<Rectangle Width="1" Height="10" Fill="#26000000" VerticalAlignment="Center" HorizontalAlignment="Left" Margin="144,0,0,0"/>
|
||||
<Rectangle Width="1" Height="10" Fill="#26000000" VerticalAlignment="Center" HorizontalAlignment="Left" Margin="180,0,0,0"/>
|
||||
<Rectangle Width="1" Height="10" Fill="#26000000" VerticalAlignment="Center" HorizontalAlignment="Left" Margin="216,0,0,0"/>
|
||||
<Rectangle Width="1" Height="10" Fill="#26000000" VerticalAlignment="Center" HorizontalAlignment="Left" Margin="252,0,0,0"/>
|
||||
<Rectangle Width="2" Fill="#00000080" HorizontalAlignment="Left" Margin="173,0,0,0"
|
||||
VerticalAlignment="Stretch"/>
|
||||
<Rectangle Width="2" Fill="#00000080" HorizontalAlignment="Left" Margin="230,0,0,0"
|
||||
VerticalAlignment="Stretch"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
<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="M12,14c1.66,0 3,-1.34 3,-3V5c0,-1.66 -1.34,-3 -3,-3S9,3.34 9,5v6C9,12.66 10.34,14 12,14zM17.3,11c0,3 -2.54,5.1 -5.3,5.1S6.7,14 6.7,11H5c0,3.41 2.72,6.23 6,6.72V21h2v-3.28c3.28,-0.49 6,-3.31 6,-6.72H17.3z"
|
||||
Visibility="{Binding MicMuted, Converter={StaticResource InverseBoolToVis}}"/>
|
||||
<Path Fill="#ef4444" Stretch="Uniform"
|
||||
Data="M12,14c1.66,0 3,-1.34 3,-3V5c0,-1.66 -1.34,-3 -3,-3S9,3.34 9,5v6C9,12.66 10.34,14 12,14zM17.3,11c0,3 -2.54,5.1 -5.3,5.1S6.7,14 6.7,11H5c0,3.41 2.72,6.23 6,6.72V21h2v-3.28c3.28,-0.49 6,-3.31 6,-6.72H17.3z"
|
||||
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"
|
||||
PreviewMouseLeftButtonUp="VolumeSlider_PreviewMouseLeftButtonUp"
|
||||
LostMouseCapture="VolumeSlider_LostMouseCapture"/>
|
||||
<StackPanel Orientation="Horizontal" Margin="10,0,0,0" VerticalAlignment="Center"
|
||||
ToolTip="Audio sync (−500..+500 ms). Positive: delays the audio when it runs ahead of the video. Negative: advances it (drops the first |N| ms of the recording) when it runs behind. Calibrate with a clap: is the clap late in the video, move negative; early, move positive.">
|
||||
<TextBlock Text="AUDIO SYNC" FontSize="9" Foreground="#9898aa"
|
||||
VerticalAlignment="Center" Margin="0,0,5,0"/>
|
||||
<Slider Width="70" Minimum="-500" Maximum="500"
|
||||
Value="{Binding AudioSyncOffsetMs, Mode=TwoWay}"
|
||||
IsEnabled="{Binding IsEditMode}"
|
||||
VerticalAlignment="Center"
|
||||
ToolTip="{Binding AudioSyncOffsetMs, StringFormat=Audio sync: {0} ms}"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,464 @@
|
||||
using System;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Effects;
|
||||
using System.Windows.Media.Animation;
|
||||
using System.Windows.Shapes;
|
||||
using System.Windows.Threading;
|
||||
using ytLive.Helpers;
|
||||
using ytLive.Models;
|
||||
using ytLive.ViewModels;
|
||||
|
||||
namespace ytLive.Controls;
|
||||
|
||||
public partial class PreviewPane : UserControl
|
||||
{
|
||||
private bool _isDraggingOverlay;
|
||||
private bool _isResizing;
|
||||
private Point _grabOffset;
|
||||
private double _resizeAspect;
|
||||
|
||||
private readonly System.Collections.Generic.Dictionary<string, Storyboard> _activeBorderAnimations =
|
||||
new();
|
||||
|
||||
private DispatcherTimer? _socialGlowTimer;
|
||||
private double _socialGlowAngle;
|
||||
|
||||
public PreviewPane()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
// Static images and the webcam share the move/resize/selection behavior.
|
||||
public static bool IsDraggableElement(SceneElement element)
|
||||
=> element.IsDraggable;
|
||||
|
||||
public void UpdateSelectionOverlay(bool selected)
|
||||
{
|
||||
SelectionOverlay.Visibility = selected ? Visibility.Visible : Visibility.Collapsed;
|
||||
}
|
||||
|
||||
// ─── Clicking inside the preview / pull-out drawer (used by the window's
|
||||
// Window_PreviewMouseLeftButtonDown deselection logic) ───
|
||||
public bool IsClickInsidePreview(DependencyObject original)
|
||||
=> IsDescendantOf(original, PreviewGrid);
|
||||
|
||||
public bool IsClickInsideDrawer(DependencyObject original)
|
||||
=> IsDescendantOf(original, TextPullOutHost);
|
||||
|
||||
private static bool IsDescendantOf(DependencyObject? child, DependencyObject ancestor)
|
||||
{
|
||||
while (child != null && !ReferenceEquals(child, ancestor))
|
||||
{
|
||||
child = child is Visual or System.Windows.Media.Media3D.Visual3D
|
||||
? VisualTreeHelper.GetParent(child)
|
||||
: LogicalTreeHelper.GetParent(child);
|
||||
}
|
||||
return child != null;
|
||||
}
|
||||
|
||||
// ─── Audio meters + TRAX + socials (ROW 2 of the preview pane) ───
|
||||
private void VolumeSlider_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (sender is Slider slider)
|
||||
SetSliderValueFromClick(slider, e);
|
||||
if (sender is FrameworkElement { DataContext: MainViewModel vm })
|
||||
vm.SetVolumeAdjusting(true);
|
||||
}
|
||||
|
||||
private void VolumeSlider_PreviewMouseLeftButtonUp(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (sender is FrameworkElement { DataContext: MainViewModel vm })
|
||||
vm.SetVolumeAdjusting(false);
|
||||
}
|
||||
|
||||
private void VolumeSlider_LostMouseCapture(object sender, MouseEventArgs e)
|
||||
{
|
||||
if (sender is FrameworkElement { DataContext: MainViewModel vm })
|
||||
vm.SetVolumeAdjusting(false);
|
||||
}
|
||||
|
||||
private void GameVolumeSlider_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (sender is Slider slider)
|
||||
SetSliderValueFromClick(slider, e);
|
||||
if (sender is FrameworkElement { DataContext: MainViewModel vm })
|
||||
vm.SetGameVolumeAdjusting(true);
|
||||
}
|
||||
|
||||
private void GameVolumeSlider_PreviewMouseLeftButtonUp(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (sender is FrameworkElement { DataContext: MainViewModel vm })
|
||||
vm.SetGameVolumeAdjusting(false);
|
||||
}
|
||||
|
||||
private void GameVolumeSlider_LostMouseCapture(object sender, MouseEventArgs e)
|
||||
{
|
||||
if (sender is FrameworkElement { DataContext: MainViewModel vm })
|
||||
vm.SetGameVolumeAdjusting(false);
|
||||
}
|
||||
|
||||
private static void SetSliderValueFromClick(Slider slider, MouseButtonEventArgs e)
|
||||
{
|
||||
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;
|
||||
var pos = e.GetPosition(track);
|
||||
var fraction = Math.Clamp(pos.X / track.RenderSize.Width, 0, 1);
|
||||
slider.Value = slider.Minimum + fraction * (slider.Maximum - slider.Minimum);
|
||||
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 })
|
||||
{
|
||||
vm.ToggleMicMuteCommand.Execute(null);
|
||||
}
|
||||
}
|
||||
|
||||
private void GameSpeaker_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (sender is FrameworkElement { DataContext: MainViewModel vm })
|
||||
{
|
||||
vm.ToggleGameMuteCommand.Execute(null);
|
||||
}
|
||||
}
|
||||
|
||||
// TRAX (TASK 9): left-click toggles play/pause (picks a track when none is
|
||||
// loaded); right-click always opens the picker. The picker is the OS file
|
||||
// dialog — the "never the OS viewer" rule covers the preview, not pickers.
|
||||
private void TraxButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is ViewModels.MainViewModel vm)
|
||||
vm.TraxLeftClick();
|
||||
}
|
||||
|
||||
private void TraxButton_PreviewMouseRightButtonUp(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (DataContext is ViewModels.MainViewModel vm)
|
||||
vm.TraxRightClick();
|
||||
e.Handled = true; // the button's right-click context menu must not fire
|
||||
}
|
||||
|
||||
// ─── Webcam / background context menus (right-click on overlay elements) ───
|
||||
private void WebcamMenu_ChangeWebcam(object sender, RoutedEventArgs e)
|
||||
=> (DataContext as MainViewModel)?.ChangeWebcamCommand.Execute(null);
|
||||
|
||||
private void WebcamMenu_SetBorderAnimation(object sender, RoutedEventArgs e)
|
||||
{
|
||||
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: SceneElement element, Tag: string color })
|
||||
element.BorderColor = color;
|
||||
}
|
||||
|
||||
private void WebcamMenu_HideInScene(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is MenuItem { DataContext: SceneElement element })
|
||||
element.IsVisible = false;
|
||||
}
|
||||
|
||||
private void WebcamMenu_Remove(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is MenuItem { DataContext: SceneElement element })
|
||||
(DataContext as MainViewModel)?.RemoveSourceCommand.Execute(element);
|
||||
}
|
||||
|
||||
// ─── Social bar: click toggles top ⇄ bottom. KISS — the drag variant was
|
||||
// unusable for shaky hands (jitter around the direction deadzone), so the bar
|
||||
// rides on {Binding SocialBarTop} alone and a click flips it. ───
|
||||
private void SocialBar_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
(DataContext as MainViewModel)?.ToggleSocialBarPosition();
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void SocialsButton_MouseEnter(object sender, MouseEventArgs e)
|
||||
{
|
||||
if (SocialBarElement == null) return;
|
||||
|
||||
// Kick the social bar to full brightness so it's immediately visible
|
||||
var effect = SocialBarElement.Effect as DropShadowEffect;
|
||||
if (effect != null)
|
||||
{
|
||||
effect.BlurRadius = 40;
|
||||
effect.Opacity = 1.0;
|
||||
}
|
||||
|
||||
// Pulse the glow so it catches the eye
|
||||
_socialGlowAngle = 0;
|
||||
_socialGlowTimer?.Stop();
|
||||
_socialGlowTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(30) };
|
||||
_socialGlowTimer.Tick += SocialGlowTick;
|
||||
_socialGlowTimer.Start();
|
||||
}
|
||||
|
||||
private void SocialsButton_MouseLeave(object sender, MouseEventArgs e)
|
||||
{
|
||||
_socialGlowTimer?.Stop();
|
||||
_socialGlowTimer = null;
|
||||
|
||||
if (SocialBarElement?.Effect is DropShadowEffect effect)
|
||||
{
|
||||
effect.BlurRadius = 18;
|
||||
effect.Opacity = 0.9;
|
||||
}
|
||||
}
|
||||
|
||||
private void SocialGlowTick(object? sender, EventArgs e)
|
||||
{
|
||||
if (SocialBarElement?.Effect is not DropShadowEffect effect) return;
|
||||
|
||||
_socialGlowAngle += 0.18;
|
||||
if (_socialGlowAngle > Math.PI * 2) _socialGlowAngle -= Math.PI * 2;
|
||||
|
||||
// Smooth sine pulse: BlurRadius 24→48, Opacity 0.7→1.0
|
||||
double t = (Math.Sin(_socialGlowAngle) + 1.0) / 2.0; // 0..1
|
||||
effect.BlurRadius = 24 + t * 24;
|
||||
effect.Opacity = 0.7 + t * 0.3;
|
||||
}
|
||||
|
||||
// ─── Preview: element selection / move / resize / hit-testing ───
|
||||
private void Preview_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
var vm = DataContext as MainViewModel;
|
||||
if (vm is null || !vm.IsEditMode) return;
|
||||
var grid = (Grid)sender;
|
||||
var toCanvas = CanvasGrid.TransformToVisual(grid).Inverse;
|
||||
var p = toCanvas.Transform(e.GetPosition(grid));
|
||||
var selected = vm.SelectedElement;
|
||||
|
||||
if (selected is { } sel && IsDraggableElement(sel) && HitHandle(e.GetPosition(grid), sel))
|
||||
{
|
||||
_isResizing = true;
|
||||
_resizeAspect = sel.ClipShape == ClipShape.Round ? 1 : sel.Width / Math.Max(1, sel.Height);
|
||||
grid.CaptureMouse();
|
||||
e.Handled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
var hit = HitElement(vm, p);
|
||||
if (hit != null)
|
||||
{
|
||||
vm.SelectedElement = hit;
|
||||
_isDraggingOverlay = true;
|
||||
_grabOffset = new Point(p.X - hit.X, p.Y - hit.Y);
|
||||
grid.CaptureMouse();
|
||||
e.Handled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
vm.SelectedElement = null;
|
||||
}
|
||||
|
||||
private void Preview_MouseMove(object sender, MouseEventArgs e)
|
||||
{
|
||||
var vm = DataContext as MainViewModel;
|
||||
if (vm is null || !vm.IsEditMode) return;
|
||||
if (!_isDraggingOverlay && !_isResizing) return;
|
||||
|
||||
var grid = (Grid)sender;
|
||||
var toCanvas = CanvasGrid.TransformToVisual(grid).Inverse;
|
||||
var p = toCanvas.Transform(e.GetPosition(grid));
|
||||
var selected = vm.SelectedElement;
|
||||
if (selected == null)
|
||||
{
|
||||
EndPreviewDrag(grid);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_isDraggingOverlay)
|
||||
{
|
||||
var minX = -(selected.Width - 20);
|
||||
var maxX = 1920 - 20;
|
||||
var minY = -(selected.Height - 20);
|
||||
var maxY = 1080 - 20;
|
||||
selected.X = Math.Clamp(p.X - _grabOffset.X, Math.Min(minX, maxX), Math.Max(minX, maxX));
|
||||
selected.Y = Math.Clamp(p.Y - _grabOffset.Y, Math.Min(minY, maxY), Math.Max(minY, maxY));
|
||||
}
|
||||
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(vm.StagedScene?.Name) : 1920;
|
||||
var maxH = isWebcam ? MainViewModel.MaxWebcamHeightFor(vm.StagedScene?.Name) : 1080;
|
||||
var newW = Math.Clamp(p.X - selected.X, 32, maxW);
|
||||
var newH = proportional ? newW / _resizeAspect : Math.Clamp(p.Y - selected.Y, 32, maxH);
|
||||
if (proportional && newH > maxH)
|
||||
{
|
||||
newH = maxH;
|
||||
newW = newH * _resizeAspect;
|
||||
}
|
||||
selected.Width = newW;
|
||||
selected.Height = newH;
|
||||
}
|
||||
}
|
||||
|
||||
private void Preview_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
|
||||
=> EndPreviewDrag((Grid)sender);
|
||||
|
||||
private void EndPreviewDrag(Grid grid)
|
||||
{
|
||||
if (_isDraggingOverlay || _isResizing)
|
||||
grid.ReleaseMouseCapture();
|
||||
_isDraggingOverlay = false;
|
||||
_isResizing = false;
|
||||
}
|
||||
|
||||
private bool HitHandle(Point mouseScreen, SceneElement element)
|
||||
{
|
||||
var corner = CanvasGrid.TransformToVisual(PreviewGrid).Transform(new Point(element.X + element.Width, element.Y + element.Height));
|
||||
return Math.Abs(mouseScreen.X - corner.X) <= 20 && Math.Abs(mouseScreen.Y - corner.Y) <= 20;
|
||||
}
|
||||
|
||||
private SceneElement? HitElement(MainViewModel vm, Point p)
|
||||
{
|
||||
var scene = vm.StagedScene;
|
||||
if (scene == null) return null;
|
||||
for (var i = scene.Elements.Count - 1; i >= 0; i--)
|
||||
{
|
||||
var element = scene.Elements[i];
|
||||
if (!IsDraggableElement(element)) continue;
|
||||
if (element is Source { IsEnabled: false }) continue;
|
||||
if (element is WebcamSceneConfig { IsVisible: false }) continue;
|
||||
if (p.X >= element.X && p.X <= element.X + element.Width &&
|
||||
p.Y >= element.Y && p.Y <= element.Y + element.Height)
|
||||
return element;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<UserControl x:Class="ytLive.Controls.SceneThumbnailStrip"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
<Border Background="#0d1117" CornerRadius="4" Margin="0,0,0,4" Padding="4">
|
||||
<ItemsControl ItemsSource="{Binding Scenes}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<UniformGrid Rows="1" Columns="5"/>
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Border CornerRadius="4" Margin="2" Padding="2"
|
||||
BorderThickness="2"
|
||||
BorderBrush="Transparent"
|
||||
Cursor="Hand"
|
||||
MouseLeftButtonDown="Thumbnail_MouseLeftButtonDown">
|
||||
<Border.Style>
|
||||
<Style TargetType="Border">
|
||||
<Setter Property="BorderBrush" Value="#333344"/>
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding IsStaged}" Value="True">
|
||||
<Setter Property="BorderBrush" Value="#e94560"/>
|
||||
</DataTrigger>
|
||||
<DataTrigger Binding="{Binding IsLive}" Value="True">
|
||||
<Setter Property="BorderBrush" Value="#22c55e"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</Border.Style>
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="*"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
<Border Grid.Row="0" CornerRadius="3"
|
||||
Background="#0a0a1a" ClipToBounds="True">
|
||||
<Image Source="{Binding Snapshot}"
|
||||
Stretch="UniformToFill"
|
||||
RenderOptions.BitmapScalingMode="LowQuality"/>
|
||||
</Border>
|
||||
<TextBlock Grid.Row="1" Foreground="#a0a0b0" FontSize="10"
|
||||
HorizontalAlignment="Center"
|
||||
Margin="0,2,0,0"
|
||||
TextTrimming="CharacterEllipsis">
|
||||
<Run Text="{Binding Name, Mode=OneWay}"/><Run Text=" "/><Run Text="{Binding HotkeyLabel, Mode=OneWay}" Foreground="#6a6a7a" FontWeight="SemiBold"/>
|
||||
</TextBlock>
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</Border>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,21 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Input;
|
||||
using ytLive.Models;
|
||||
using ytLive.ViewModels;
|
||||
|
||||
namespace ytLive.Controls;
|
||||
|
||||
public partial class SceneThumbnailStrip : UserControl
|
||||
{
|
||||
public SceneThumbnailStrip()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
private void Thumbnail_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (sender is Border { DataContext: Scene scene } && DataContext is MainViewModel vm)
|
||||
vm.TransitionToScene(scene);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
<UserControl x:Class="ytLive.Controls.TopBar"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:helpers="clr-namespace:ytLive.Helpers"
|
||||
x:Name="TopBarRoot">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<!-- LOGO / BRAND — clicking opens the in-app About overlay; the gear sits
|
||||
a couple characters past the build stamp and opens App Settings. -->
|
||||
<StackPanel Grid.Column="0" Orientation="Horizontal" VerticalAlignment="Center"
|
||||
Margin="0,0,24,0">
|
||||
<Button Background="Transparent" BorderThickness="0" Cursor="Hand"
|
||||
VerticalAlignment="Center" ToolTip="About LlamaCasty"
|
||||
Command="{Binding OpenAboutCommand}">
|
||||
<Button.Template>
|
||||
<ControlTemplate TargetType="Button">
|
||||
<ContentPresenter/>
|
||||
</ControlTemplate>
|
||||
</Button.Template>
|
||||
<TextBlock FontSize="22" FontWeight="Bold" VerticalAlignment="Center">
|
||||
<Run Text="LlamaCas" Foreground="#e0e0e0"/><Run Text="ty" Foreground="#e94560"/><Run
|
||||
x:Name="BuildIdStamp" Text="{x:Static helpers:BuildStamp.Label}" FontSize="9"
|
||||
BaselineAlignment="Superscript" Foreground="#8888aa"
|
||||
ToolTipService.ToolTip="{x:Static helpers:BuildStamp.Label}"/><Run Text=" " FontSize="4"/>
|
||||
</TextBlock>
|
||||
</Button>
|
||||
<!-- Settings gear (user directive): a couple characters beyond the brand,
|
||||
nudged right a couple more places 2026-09-22 (build 77 feedback).
|
||||
Moved up from the bottom bar — one menu: App Settings / Bug / Feature / About. -->
|
||||
<Button Style="{StaticResource IconButton}" Margin="18,0,0,2" VerticalAlignment="Center"
|
||||
ToolTip="Menu" Click="GearButton_Click">
|
||||
<Button.ContextMenu>
|
||||
<ContextMenu DataContext="{Binding PlacementTarget.DataContext, RelativeSource={RelativeSource Self}}">
|
||||
<MenuItem Header="App Settings" Command="{Binding OpenSettingsCommand}"/>
|
||||
<Separator/>
|
||||
<MenuItem Header="Report Bug" Command="{Binding OpenBugCommand}"/>
|
||||
<MenuItem Header="Feature Request" Command="{Binding OpenFeatureCommand}"/>
|
||||
<MenuItem Header="About" Command="{Binding OpenAboutCommand}"/>
|
||||
</ContextMenu>
|
||||
</Button.ContextMenu>
|
||||
<Path Data="M19.43,12.98c0.04,-0.32 0.07,-0.65 0.07,-0.98c0,-0.33 -0.03,-0.66 -0.07,-0.98l2.11,-1.65c0.19,-0.15 0.24,-0.42 0.12,-0.64l-2,-3.46c-0.12,-0.22 -0.38,-0.31 -0.61,-0.22l-2.49,1c-0.52,-0.4 -1.08,-0.73 -1.69,-0.98l-0.38,-2.65C14.46,2.18 14.25,2 14,2h-4c-0.25,0 -0.46,0.18 -0.49,0.42l-0.38,2.65c-0.61,0.25 -1.17,0.59 -1.69,0.98l-2.49,-1c-0.23,-0.09 -0.49,0 -0.61,0.22l-2,3.46c-0.13,0.22 -0.07,0.49 0.12,0.64l2.11,1.65c-0.04,0.32 -0.07,0.65 -0.07,0.98c0,0.33 0.03,0.66 0.07,0.98l-2.11,1.65c-0.19,0.15 -0.24,0.42 -0.12,0.64l2,3.46c0.12,0.22 0.38,0.31 0.61,0.22l2.49,-1c0.52,0.4 1.08,0.73 1.69,0.98l0.38,2.65c0.03,0.24 0.24,0.42 0.49,0.42h4c0.25,0 0.46,-0.18 0.49,-0.42l0.38,-2.65c0.61,-0.25 1.17,-0.59 1.69,-0.98l2.49,1c0.23,0.09 0.49,0 0.61,-0.22l2,-3.46c0.12,-0.22 0.07,-0.49 -0.12,-0.64L19.43,12.98zM12,15.5c-1.93,0 -3.5,-1.57 -3.5,-3.5c0,-1.93 1.57,-3.5 3.5,-3.5c1.93,0 3.5,1.57 3.5,3.5C15.5,13.93 13.93,15.5 12,15.5z"
|
||||
Fill="#a0a0b0" Width="18" Height="18" Stretch="Uniform"/>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Session surface (top bar redesign): the bar is ONE surface rendered by
|
||||
the FIRST decision — Record or Stream. Each world is the whole bar; the
|
||||
other world never exists on screen. While running, the bar drops to a
|
||||
reality line + End so nothing mid-session can be re-armed. -->
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||
<!-- IDLE: the mode switch + the action(s) of the armed world. The three
|
||||
controls share one height (build 77 feedback) so they read as one
|
||||
family — segments host, Start/Go Live, and Test all sit at 32. -->
|
||||
<StackPanel Orientation="Horizontal"
|
||||
Visibility="{Binding ShowIdleWorld, Converter={StaticResource BoolToVis}}">
|
||||
<Border Background="#0f1622" CornerRadius="4" Padding="2" Height="32" VerticalAlignment="Center">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<ToggleButton Style="{StaticResource SegmentToggle}"
|
||||
IsChecked="{Binding RecordPillOn}">
|
||||
<TextBlock Text="REC" Style="{StaticResource SegmentLabel}"/>
|
||||
</ToggleButton>
|
||||
<ToggleButton Style="{StaticResource SegmentToggle}"
|
||||
IsChecked="{Binding OnAirPillOn}">
|
||||
<TextBlock Text="ON-AIR" Style="{StaticResource SegmentLabel}"/>
|
||||
</ToggleButton>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<!-- Go face names the world: "Start Recording" vs "Go Live".
|
||||
(Signed-out ON-AIR Start routes to the sign-in — StartSession.) -->
|
||||
<Button Content="{Binding PrimaryStartButtonLabel}" Style="{StaticResource YtButton}"
|
||||
Height="32" Margin="10,0,0,0" Command="{Binding StartStreamCommand}"
|
||||
Visibility="{Binding ShowPrimaryStartButton, Converter={StaticResource BoolToVis}}"/>
|
||||
<!-- Test is a child of Stream — a dry-run of the livestream, so it
|
||||
exists only inside the Stream world (stream armed + signed in). -->
|
||||
<Button Content="Test" Style="{StaticResource YtButtonSecondary}" Padding="12,4" FontSize="11"
|
||||
Height="32" Margin="8,0,0,0" VerticalAlignment="Center"
|
||||
Command="{Binding StartTestCommand}"
|
||||
ToolTip="Run a private test broadcast to check your chat overlay."
|
||||
Visibility="{Binding ShowTestButton, Converter={StaticResource BoolToVis}}"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- RUNNING: one reality line (dot + word + elapsed) and the stop -->
|
||||
<StackPanel Orientation="Horizontal"
|
||||
Visibility="{Binding RunningVisible, Converter={StaticResource BoolToVis}}">
|
||||
<Ellipse Width="12" Height="12" VerticalAlignment="Center"
|
||||
Fill="{Binding RunningDotBrush}"/>
|
||||
<TextBlock Text="{Binding RunningText}" FontSize="16" FontWeight="Bold"
|
||||
Foreground="White" Margin="8,0,0,0" VerticalAlignment="Center"/>
|
||||
<TextBlock Text="{Binding LiveElapsedText}" Foreground="White" FontSize="14"
|
||||
FontFamily="Consolas" VerticalAlignment="Center" Margin="8,0,0,0"/>
|
||||
<Button Content="{Binding EndButtonText}" Style="{StaticResource YtButton}"
|
||||
Background="#333" Margin="14,0,0,0" Command="{Binding EndStreamCommand}"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Account zone: lives only in the Stream world. Signed out → Sign In;
|
||||
signed in → a big avatar of the streaming account (build 77 feedback:
|
||||
"make the user icon much bigger"). Record world has no YouTube
|
||||
identity at all. -->
|
||||
<StackPanel Grid.Column="2" Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<Button Content="Sign In" Style="{StaticResource YtButtonSecondary}"
|
||||
Margin="0,0,8,0" Command="{Binding SignInCommand}"
|
||||
ToolTip="Sign in to YouTube to go live."
|
||||
Visibility="{Binding ShowSignInButton, Converter={StaticResource BoolToVis}}"/>
|
||||
<Button Style="{StaticResource IconButton}" Margin="0,0,0,0"
|
||||
ToolTip="{Binding AccountDisplayName}"
|
||||
Visibility="{Binding ShowAvatarButton, Converter={StaticResource BoolToVis}}">
|
||||
<Button.ContextMenu>
|
||||
<ContextMenu>
|
||||
<MenuItem Header="Change Account" Command="{Binding ChangeAccountCommand}"/>
|
||||
<Separator/>
|
||||
<MenuItem Header="Logout" Command="{Binding LogoutCommand}"/>
|
||||
</ContextMenu>
|
||||
</Button.ContextMenu>
|
||||
<Border Width="40" Height="40" CornerRadius="20" Background="#16213e"
|
||||
ClipToBounds="True" BorderBrush="#3a4a66" BorderThickness="1">
|
||||
<Grid>
|
||||
<Image x:Name="AvatarImage" Stretch="UniformToFill"/>
|
||||
<TextBlock x:Name="AvatarInitial" Foreground="#e0e0e0" FontSize="20"
|
||||
FontWeight="Bold" HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
Visibility="Collapsed"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,85 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Net.Http;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Media.Imaging;
|
||||
using ytLive.Helpers;
|
||||
using ytLive.ViewModels;
|
||||
|
||||
namespace ytLive.Controls;
|
||||
|
||||
public partial class TopBar : UserControl
|
||||
{
|
||||
private static readonly HttpClient AvatarHttp = new() { Timeout = TimeSpan.FromSeconds(15) };
|
||||
private string? _loadedAvatarUrl;
|
||||
|
||||
public TopBar()
|
||||
{
|
||||
InitializeComponent();
|
||||
AvatarImage.Source = null;
|
||||
AvatarInitial.Visibility = System.Windows.Visibility.Collapsed;
|
||||
}
|
||||
|
||||
public void RefreshAvatar()
|
||||
{
|
||||
_ = LoadAvatarImageAsync();
|
||||
}
|
||||
|
||||
private void GearButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
// One menu (Settings / Bug / Feature / About), opened on click — moved up
|
||||
// from the bottom bar so app-level actions live next to the brand.
|
||||
if (sender is Button { ContextMenu: { } menu } button)
|
||||
{
|
||||
menu.PlacementTarget = button;
|
||||
menu.Placement = System.Windows.Controls.Primitives.PlacementMode.Bottom;
|
||||
menu.IsOpen = true;
|
||||
}
|
||||
}
|
||||
|
||||
private async System.Threading.Tasks.Task LoadAvatarImageAsync()
|
||||
{
|
||||
var vm = DataContext as MainViewModel;
|
||||
var url = vm?.AccountAvatarUrl;
|
||||
if (vm is not { IsConnected: true })
|
||||
{
|
||||
ShowAvatarFallback(null);
|
||||
return;
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(url))
|
||||
{
|
||||
AppLog.Write("Avatar: connected but no profile image URL on the session");
|
||||
ShowAvatarFallback(null);
|
||||
return;
|
||||
}
|
||||
if (url == _loadedAvatarUrl && AvatarImage.Source != null) return;
|
||||
try
|
||||
{
|
||||
var bytes = await AvatarHttp.GetByteArrayAsync(url);
|
||||
using var ms = new MemoryStream(bytes);
|
||||
var bitmap = new BitmapImage();
|
||||
bitmap.BeginInit();
|
||||
bitmap.CacheOption = BitmapCacheOption.OnLoad;
|
||||
bitmap.StreamSource = ms;
|
||||
bitmap.EndInit();
|
||||
bitmap.Freeze();
|
||||
_loadedAvatarUrl = url;
|
||||
AvatarImage.Source = bitmap;
|
||||
AvatarInitial.Visibility = System.Windows.Visibility.Collapsed;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLog.Write($"Avatar load failed ({url}): {ex.Message}");
|
||||
ShowAvatarFallback(vm?.AccountDisplayName);
|
||||
}
|
||||
}
|
||||
|
||||
private void ShowAvatarFallback(string? name)
|
||||
{
|
||||
_loadedAvatarUrl = null;
|
||||
AvatarImage.Source = null;
|
||||
AvatarInitial.Text = string.IsNullOrWhiteSpace(name) ? "?" : name.Substring(0, 1).ToUpperInvariant();
|
||||
AvatarInitial.Visibility = System.Windows.Visibility.Visible;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
# Controls — index
|
||||
|
||||
The phase-2 carved pieces of what used to be one monolithic `MainWindow.xaml`
|
||||
(1,681 lines → a 123-line shell). Each is a `UserControl` in the `ytLive.Controls`
|
||||
namespace, extracted one roll-back commit at a time (refactor-commit-12..18).
|
||||
See [`schema.md`](../schema.md) for the memory-map conventions.
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `BottomBar.xaml(.cs)` | Footer (Row 3): stream stats (bitrate/fps/dropped/duration/health), resolution dropdown (gear menu moved up to TopBar 2026-09-22) |
|
||||
| `HealthBanner.xaml(.cs)` | Row 1 status banner + the 7 value→visibility converters live in the app theme, not here |
|
||||
| `TopBar.xaml(.cs)` | Row 0 session surface (2026-09-22 redesign): brand + gear menu (left), one segmented REC|ON-AIR mode switch + world actions (Record: Start Recording · Stream: Go Live + Test) + reality line while running (dot/word/elapsed) + End (center), account zone — Sign In or avatar w/ Change Account/Logout context menu (right). `RefreshAvatar()`, `GearButton_Click` |
|
||||
| `PreviewPane.xaml(.cs)` | Row 1 Col 1 center: the 1920×1080 `OverlayCanvas`/`SelectionOverlay`/`BrandFlashLayer`/`SocialBarElement`, text pull-out drawer, and the live-controls row (TRAX / game / mic meters + sliders + speakers + socials) |
|
||||
| `SceneThumbnailStrip.xaml(.cs)` | Row 0 scene-picker strip (`ItemsControl` over `Scenes`), staged-red / live-green border highlight |
|
||||
| `LeftPanel.xaml(.cs)` | Row 1 Col 0 left panel: edit mode → layers+properties; live → YouTube chat. Source add/edit/remove, list drag-to-reorder, snapshot revert-accept |
|
||||
| `OverlayHost.xaml(.cs)` | Root-grid overlay dialogs: Settings / Report Bug / Feature Request / About (+ licenses + license-key entry). `BackToAbout_Click` lives here |
|
||||
| `SceneThumbnailStrip.xaml.cs` | Code-behind for the strip above |
|
||||
| `LeftPanel.xaml.cs` | Code-behind for the panel above |
|
||||
|
||||
## Window ↔ control contract
|
||||
|
||||
The shell (`MainWindow.xaml(.cs)`) wires up each control and routes the few
|
||||
cross-cutting calls through it:
|
||||
|
||||
- Ctor finds each control by `x:Name` (`TopBar`, `PreviewPane`, `LeftPanel`)
|
||||
AFTER `InitializeComponent`, **before** any method touches it (null-ref guard).
|
||||
- `_previewPane.IsClickInsidePreview/Drawer` + `_leftPanel.IsClickInside` →
|
||||
deselection guard in `Window_PreviewMouseLeftButtonDown`.
|
||||
- `_previewPane.UpdateSelectionOverlay(bool)` + `_leftPanel.OnSelectionChanged(vm)`
|
||||
→ selection/edit-mode routing from the VM's `SelectedElement` change.
|
||||
- `_topBar.RefreshAvatar()` on `IsConnected`/`AccountAvatarUrl` change.
|
||||
- Handlers in moved controls cast `(DataContext as MainViewModel)` — controls
|
||||
inherit the window's DataContext; never pass the VM through a property.
|
||||
|
||||
## Gotchas learned the hard way
|
||||
|
||||
- **A UserControl cannot see `Window.Resources`** (StaticResource resolves by
|
||||
element lookup, so it stops at the control boundary). The 7 converters
|
||||
(`BoolToVis`, `InverseBoolToVis`, `NotNullToVis`, `NullToVis`, `HiddenBoolToVis`,
|
||||
`EnumToBool`, `AllTrueToVis`) were promoted from `MainWindow.Resources` into
|
||||
`Themes/Controls.xaml` (see [`Themes/index.md`](../Themes/index.md)).
|
||||
- **Moved XAML can lose its namespace**: a block cut from MainWindow carries no
|
||||
root xmlns, so any `models:`/`Helpers:` ref in it breaks the build with
|
||||
`MC2000: Value cannot be null (key)`. Declare `xmlns:models` / `xmlns:Helpers`
|
||||
on each control's root (LeftPanel hit this).
|
||||
- **Name-looked-up host stubs must stay in the window**, not a control:
|
||||
`WebViewHostPanel` (ctor `InitWebView2(WebViewHostPanel)`) and `ToastArea`
|
||||
(`NotificationService.InArea("ToastArea")`) are resolved by name from the
|
||||
window root — so `OverlayHost` deliberately leaves them as thin window-root
|
||||
children (Commit 18). Since the 2026-09-14 composition-capture redesign only
|
||||
`ToastArea` remains — `WebViewHostPanel` is deleted (web sources no longer
|
||||
host a multiline XAML control; `InitWebView2()` runs from `MainWindow_Loaded`
|
||||
with no panel argument).
|
||||
- When cutting visual wrappers, don't drop the outer `Border`'s
|
||||
background/glow/margin — Commit 15 initially lost the preview's outer border
|
||||
and had to restore it same-commit.
|
||||
|
||||
Related: [`ViewModels/index.md`](../ViewModels/index.md) (MainViewModel the
|
||||
controls bind to), [`Themes/index.md`](../Themes/index.md) (styles/converters),
|
||||
[`Helpers/index.md`](../Helpers/index.md) (ViewModelBase, converters), the
|
||||
`MainWindow.xaml` shell at repo root that hosts these.
|
||||
+69
-8
@@ -1,12 +1,42 @@
|
||||
# Distribution & IP Protection Plan
|
||||
|
||||
> 360-degree protection strategy for LlamaCasty (.NET 8 / WPF), built around Polar.sh.
|
||||
> 360-degree protection strategy for LlamaCasty (.NET 8 / WPF).
|
||||
|
||||
> ## ⛔ SUPERSEDED IN PART — 2026-09-27
|
||||
>
|
||||
> **Distribution is now the Microsoft Store: MSIX package + Store IAP.** The creator's criteria
|
||||
> were *"zero headaches, minimal maintenance (for me) while still providing accountability and
|
||||
> a reasonably easy upgrade flow"*, and that ruling deleted this document's central premise.
|
||||
>
|
||||
> **What still stands below:** §1 (code architecture / IP protection), §5 (obfuscation posture),
|
||||
> §6 (legal), §7 (hardening) — those are about protecting the *binary*, and an MSIX package is
|
||||
> still a binary. **§1.2's build-posture ceiling (HARDENED + MOCK_REWARDS, additive-only)
|
||||
> remains the agreed ceiling.**
|
||||
>
|
||||
> **What is dead:**
|
||||
> - **Polar as the distribution backbone** — the entire section below. Store IAP handles
|
||||
> checkout, entitlements, refunds, tax, and the customer portal. So does **file hosting**:
|
||||
> the Store is the delivery mechanism, so "upload `LlamaCasty.exe` to Polar as a File
|
||||
> Download benefit" (§2) no longer exists as a step.
|
||||
> - **Code signing as our problem** — MSIX is signed by **Microsoft**. There is no certificate
|
||||
> to buy, no HSM, no annual renewal. See §4.3, which was already corrected to say this, and
|
||||
> `TASKS/research-store-certification.md` §2–3.
|
||||
> - **Velopack / the update URL / the DO droplet** — Store auto-update.
|
||||
>
|
||||
> **The authoritative plan is now [`TASKS/task-48-distribution-msix.md`](TASKS/task-48-distribution-msix.md).**
|
||||
> Keep this file for the protection sections; do not re-derive a delivery strategy from it.
|
||||
|
||||
---
|
||||
|
||||
## Polar.sh as the Distribution Backbone
|
||||
## Polar.sh as the Distribution Backbone — ⛔ OBSOLETE (2026-09-27)
|
||||
|
||||
Polar handles everything between "customer wants to pay" and "customer has a working license key." We don't build any of that. What Polar gives us:
|
||||
> **Historical record only.** Retained because the fee tables and the reasoning about *why*
|
||||
> one-time beat subscription were worth working out. **None of the "How We Use It" rows are
|
||||
> current**, with one exception worth keeping: the Merchant-of-Record point — *someone else
|
||||
> collects and remits VAT/GST* — is still true under Store IAP, because **Microsoft** is now
|
||||
> that someone. That fact alone was the strongest argument for the ruling.
|
||||
|
||||
Polar handled everything between "customer wants to pay" and "customer has a working license key." We didn't build any of that. What Polar *was* going to give us:
|
||||
|
||||
| Capability | How We Use It |
|
||||
|------------|---------------|
|
||||
@@ -14,13 +44,15 @@ Polar handles everything between "customer wants to pay" and "customer has a wor
|
||||
| **File hosting** (up to 10GB) | Upload `LlamaCasty.exe` (self-contained, ~80-120MB) as a "File Download" benefit. Customers get signed, personal download URLs. SHA-256 checksums included. |
|
||||
| **Checkout** | Polar's hosted checkout page handles payment. No Stripe integration, no PCI scope, no custom checkout UI. |
|
||||
| **Customer portal** | Customers self-serve: view/copy license keys, see expiry, deactivate devices, rotate compromised keys. We don't build a portal. |
|
||||
| **Subscriptions** | If we go subscription model (deferred), Polar handles recurring billing, dunning, cancellation. |
|
||||
| **One-time orders** | **Chosen model (2026-09-21):** perpetual license via a one-time Polar order — no subscription. Polar also supports recurring billing if ever needed, but we do not use it. |
|
||||
| **Merchant of Record** | Polar collects and remits VAT/GST/sales tax globally. We never touch tax compliance. |
|
||||
| **Webhooks** | Polar notifies our backend on purchase, cancellation, key rotation. Used for optional telemetry (section 6.3). |
|
||||
|
||||
**Polar pricing (2026):** 5% + $0.50 per transaction (Starter plan). No monthly fee.
|
||||
**Polar pricing (2026):** 5% + $0.50 per transaction (Starter plan). No monthly fee. ⛔ *No
|
||||
longer paid to anyone — the equivalent cost is now inside the Store revenue share, whose rate
|
||||
is unverified (see `TASKS/research-store-certification.md` §11).*
|
||||
|
||||
**What Polar does NOT handle:** Obfuscation, code signing, anti-tamper, runtime protection, EULA, DMCA. That's all us — sections 1-3, 5-7 below.
|
||||
**What Polar did NOT handle:** Obfuscation, code signing, anti-tamper, runtime protection, EULA, DMCA. That's all us — sections 1-3, 5-7 below.
|
||||
|
||||
---
|
||||
|
||||
@@ -290,7 +322,7 @@ Single-file means everything is inside the `.exe`. No DLLs, no runtime dependenc
|
||||
|
||||
**Product setup on Polar:**
|
||||
|
||||
1. Create a product: "LlamaCasty" — one-time purchase (or subscription if deferred).
|
||||
1. Create a product: "LlamaCasty" — **one-time purchase** (perpetual license; $29 founder / $49 list).
|
||||
2. Add two benefits:
|
||||
- **License Key** — brandable prefix `LLAMA-****`, activation limit (e.g., 3 devices), optional expiry.
|
||||
- **File Download** — upload the signed `LlamaCasty.exe` (~80-120MB). Polar generates signed, personal download URLs. SHA-256 checksum included.
|
||||
@@ -315,7 +347,36 @@ Single-file means everything is inside the `.exe`. No DLLs, no runtime dependenc
|
||||
|
||||
### 4.3 Code Signing
|
||||
|
||||
**Certificate:** Buy an EV (Extended Validation) code-signing certificate from DigiCert or Sectigo ($200-400/year). EV certificates get instant SmartScreen reputation; DV/OV certs trigger SmartScreen warnings for months.
|
||||
> ⚠️ **The cert choice below is a live decision, and the old plan's EV recommendation is
|
||||
> dead.** Microsoft **removed the SmartScreen instant-bypass for EV certificates in March
|
||||
> 2024** — an EV-signed file now accrues reputation exactly like an OV-signed one, so paying
|
||||
> $400+/yr buys precisely what paying ~$150 buys. **Do not buy EV.**
|
||||
>
|
||||
> **But the more interesting finding is that you may not need a certificate at all.** Three
|
||||
> costs, current as of 2026-09-27:
|
||||
>
|
||||
> | Route | Cert/yr | SmartScreen | Note |
|
||||
> |---|---|---|---|
|
||||
> | **Microsoft Store (MSIX)** | **$0** — Microsoft re-signs | **No warning** | Requires MSIX packaging; Store review; public listing |
|
||||
> | **Microsoft Store (EXE/MSI)** | $120–300 — *your* cert | Warning ramp | Store does **not** re-sign; silent install mandatory. Strictly dominated — cert required anyway |
|
||||
> | **Direct + OV cert** | $150–300 | Warning ramp | Worldwide availability |
|
||||
> | **Direct + Azure Artifact Signing** | ~$9.99/mo ≈ $120 | Warning ramp | **Individuals: USA/Canada only.** Identity validation required |
|
||||
>
|
||||
> Signing does **not** clear the warning on day one either way: a valid cert stops the
|
||||
> *malware* warning immediately, but "unrecognized publisher" clears as SmartScreen accrues
|
||||
> reputation from real download volume. Budget cert validity at **460 days** (CA/B Forum
|
||||
> CSC-31) — this is a recurring line item, not a one-time purchase — and note that private
|
||||
> keys must sit on an **HSM or hardware token**.
|
||||
>
|
||||
> **The full comparison, the four real combinations, and the policy analysis live in
|
||||
> [`TASKS/research-store-certification.md`](TASKS/research-store-certification.md) §2–3.
|
||||
> The executable checklist is [`TASKS/task-48-distribution-msix.md`](TASKS/task-48-distribution-msix.md),
|
||||
> and the route decision is the creator's.**
|
||||
|
||||
**Certificate:** Buy an **OV (Organization Validation)** code-signing certificate from DigiCert
|
||||
or Sectigo (~$150–300/year), or an **Azure Artifact Signing** profile (~$9.99/month,
|
||||
USA/Canada individuals only) if the geography allows. ⛔ **Not EV** — no SmartScreen benefit
|
||||
since March 2024. **Or buy nothing at all** if the route is the Microsoft Store.
|
||||
|
||||
**Signing process:**
|
||||
```powershell
|
||||
|
||||
+138
-72
@@ -1,88 +1,154 @@
|
||||
# HANDOFF — Session State
|
||||
# HANDOFF — current state
|
||||
|
||||
## Branch / Commit State
|
||||
**Branch:** `main` (pre-1.0, no feature branches — creator ruling 2026-08-24).
|
||||
**Last pushed:** `b2036a3`. This unit (reconciling the pricing ruling + verifying the Store
|
||||
revenue share) is **docs-only** — no code touched, no build, no tests run.
|
||||
|
||||
**`main`**, working tree is **CLEAN**.
|
||||
## ✅ DECIDED 2026-09-27 — pricing: `$10/mo` subscription, or `$400` lifetime, **no annual**
|
||||
|
||||
Commits `46978b8` (AGENTS push policy), `fc33022` (HANDOFF), and refactor
|
||||
commits `0–5` through `e286719` are **pushed** (incl. `refactor-commit-0..5`).
|
||||
Refactor commits **6–11 are LOCAL ONLY** (policy 2026-08-30: no per-commit
|
||||
push; `git push --tags` only at a user-agreed milestone / "push it"):
|
||||
**Creator's ruling, verbatim: *"Let's just go with $10 monthly recurring subscription for option
|
||||
A and, for option B, a lifetime sub for $400. No annual."* ⛔ **No `.99` prices** (earlier
|
||||
ruling: *"Let's stop with the x.99 stuff - I find that irritating. Just say: ten bucks a
|
||||
month"*). This **supersedes** the 2026-09-21 one-time `$29 → $49` model and the old `$99/yr`
|
||||
sub. No feature gating: free gets everything, the branding flash is the only paid delta.
|
||||
|
||||
| Commit | Hash | Tag | Partial | Lines |
|
||||
|--------|------|-----|---------|-------|
|
||||
| 6 Audio | `c1126a5` | `refactor-commit-6` | `MainViewModel.Audio.cs` | 408 |
|
||||
| 7 Trax | `535a501` | `refactor-commit-7` | `MainViewModel.Trax.cs` | 132 |
|
||||
| 8 Socials | `c41ff7f` | `refactor-commit-8` | `MainViewModel.Socials.cs` | 161 |
|
||||
| 9 Streaming | `e1006a0` | `refactor-commit-9` | `MainViewModel.Streaming.cs` | 787 |
|
||||
| 10 Chat | `3b77a13` | `refactor-commit-10` | `MainViewModel.Chat.cs` | 194 |
|
||||
| 11 Overlays | `1efde5a` | `refactor-commit-11` | `MainViewModel.Overlays.cs` | 194 |
|
||||
**✅ Store revenue share VERIFIED — 15%, and subscriptions are NOT penalised.** Read from the
|
||||
ADA v8.10 PDF itself (downloaded, text-extracted — not a search excerpt). §6(b)(i) charges
|
||||
**15%** on "any Apps (and any In-App Products in such Apps) that are **not listed in Section
|
||||
6(b)(iii)**"; 12% is Games-only; 30% covers Xbox console apps/games, Xbox non-subscription
|
||||
IAP, and Windows 8/Phone 8. LlamaCasty is a Windows PC App — not a Game, not Xbox, not Win8 —
|
||||
so **6(b)(i) governs both tiers.** The agreement's own changelog (**v8.0, Oct 26 2017**) says
|
||||
it outright: *"implement the **85/15 revenue share for non-Game subscriptions**."*
|
||||
|
||||
Nightly summary: **Phase 1 complete.** The plan was 11 partials + scaffold;
|
||||
all live in `ViewModels/`. Core `MainViewModel.cs` shrank 1,890 → **758
|
||||
lines**. Every change: clean build (0 warnings), `scripts/verify.sh` (246
|
||||
passed + 2 known failures only), `scripts/scope-check.sh` with explicit file
|
||||
args, `ViewModels/index.md` updated in the SAME commit, tagged
|
||||
`refactor-commit-N`, no per-commit push.
|
||||
⇒ **`$10/mo` nets `$8.50/mo` (~$102/yr). `$400` lifetime nets `$340`.** Also confirmed: the
|
||||
15% applies *after* VAT/GST (Net Receipts), payouts are monthly above a **$50** threshold, and
|
||||
no better small-indie rate was found in the standard terms.
|
||||
|
||||
## What's In Flight
|
||||
**⛔ Two obligations §6(h) attaches to the recurring tier** (why lifetime is the hedge):
|
||||
(1) we must **fulfil the subscription for the entire period as marketed**, and on breach
|
||||
Microsoft may refund the customer *"the full amount, plus taxes... in Microsoft's sole
|
||||
discretion"*; (2) **raising the price disables auto-renew** — so `$10` is effectively locked
|
||||
for the product's life absent Microsoft's price-change process.
|
||||
|
||||
Nothing code-in-flight — working tree clean, Phase 1 done.
|
||||
**⏳ Still open:** the `$50` subscriber→lifetime upgrade-discount SKU was discussed and is
|
||||
**not approved** — do not declare it. Individual vs Company Partner Center account still
|
||||
unanswered (get it right before enrolling).
|
||||
|
||||
**Next (queued, not started): Phase 2 — split `MainWindow.xaml` (1,681 lines)
|
||||
into UserControls.** Same rules: one work unit per control, docs in the same
|
||||
commit, verify + scope-check, commit locally, `git push` only on user go.
|
||||
## ✅ DECIDED 2026-09-27 — distribution is the Microsoft Store: MSIX + Store IAP
|
||||
|
||||
**Push pending:** refactor commits 6–11 + their tags are local. The Session-1
|
||||
push checkpoint was never reached because the user directive was to run the
|
||||
series to completion; Phase 1 completion is the natural milestone to push on
|
||||
explicit user confirmation.
|
||||
**Creator's criteria, verbatim: "zero headaches, minimal maintenance (for me) while still
|
||||
providing accountability and a reasonably easy upgrade flow."** Route A is the only option
|
||||
where all four are solved by handing the work to Microsoft rather than to a certificate
|
||||
vendor: **$0/yr, no certificate, no HSM, no annual renewal, no SmartScreen ramp**, plus Store
|
||||
auto-update, Store-side payments/entitlements/refunds/support, and Microsoft review as the
|
||||
accountability layer. The rejected options and their reasons are in
|
||||
`TASKS/research-store-certification.md` §3 — **read that before reopening the question, not
|
||||
before re-deriving it.**
|
||||
|
||||
**The big consequence: the whole licensing backend is deleted.** `PolarLicenseService`,
|
||||
`PolarLicense`, `MainViewModel.License.cs` (`PremiumUrl`, customer portal, the
|
||||
`OfflineGracePeriod = 14 days` subscription-era artifact, renewal/lapse copy) and the wrong
|
||||
"Polar unlocks alerts" string all become dead code. `IsPremium` comes from the **Store
|
||||
entitlement** instead of an HTTP call — which also deletes the entire "network flaky → app
|
||||
thinks I'm expired" bug class. **Velopack, the update URL and the DO droplet go too.**
|
||||
|
||||
**What does not change: the entitlement.** Free gets everything; the branding flash stays the
|
||||
ONLY paid delta. Store IAP changes how `IsPremium` is *obtained*, never what it *gates*.
|
||||
|
||||
**⛔ The old "still open: the price" note is retired — the price is settled, see the top of
|
||||
this file.** The old "~$69 floor" and "don't break the launch-price promise" notes refer to
|
||||
the old Polar storefront and are **not** current.
|
||||
|
||||
### The first code unit: bundle ffmpeg (TASK 48 item 1)
|
||||
|
||||
Two real defects the research surfaced — **neither is fixed yet**, this was a docs unit:
|
||||
|
||||
1. **⛔ `FfmpegLocator` downloads an unsigned exe from GitHub and runs it.** Store policy
|
||||
**10.2.2** (dynamic code inclusion) verbatim, *and* the root cause of the 2026-09-01
|
||||
failure — the pin aged out of BtbN's 14-day retention and the download **404'd on the
|
||||
creator's first real recording attempt**. `FfmpegLocator.cs:30-35` records the incident but
|
||||
still reads like a design choice. **Fix: bundle it.** Also deletes the startup network
|
||||
dependency.
|
||||
2. **⛔ `Distribution.md:318` recommended a $400+/yr EV certificate for a SmartScreen bypass
|
||||
Microsoft removed in March 2024.** Fixed last commit — had it shipped, it would have cost
|
||||
$400+/yr to buy what $150 buys. Now moot anyway (MSIX is signed by Microsoft), but the
|
||||
lesson in `MyMistakes.md` stands: a policy citation is a claim about **scope**, not just
|
||||
text.
|
||||
|
||||
### ⛔ Two invariants that break silently if touched
|
||||
|
||||
- **Full trust, or recording breaks.** `DefaultRecordFolder()` writes to Downloads/MyVideos;
|
||||
that passes through unvirtualized **only** at `mediumIL`. Flip the manifest to
|
||||
`appContainer` and output vanishes with no error. A comment is owed there **when the
|
||||
manifest lands** (TASK 48 item 6) — not before.
|
||||
- **Chat is rendered, never stored.** That non-persistence half is the load-bearing part of
|
||||
the 11.12 certification answer. No chat history, moderation log, analytics, or crash payload
|
||||
may be added without re-arguing 11.12 first.
|
||||
|
||||
## Landmines
|
||||
|
||||
- **Pre-existing test failures (do not chase):**
|
||||
`AudioPipelineTests.Mix_HonorsProviderGains_AndGameMute_KillsTheLoopback` +
|
||||
`RoundClipInteractionTests.Round_Clip_Corner_Is_Grabbable_And_Shape_Is_Circle`.
|
||||
- **Tests occasionally abort early (236/237)** — known flake, accepted by `verify.sh`.
|
||||
- **App running locks `ytLive.exe`** → build fails MSB3027. Kill PID before building.
|
||||
- **Implicit usings are SDK-default but partials must still carry their own
|
||||
domain usings** (they do not inherit core's): `ICommand`/`ObservableCollection`/
|
||||
`WriteableBitmap`/`DispatcherTimer` etc. are NOT implicit. Grep-verified usings:
|
||||
- Audio: Input, Media, Threading, `NAudio.CoreAudioApi`, `ytLive`, Helpers, Models, Services, Services.Audio
|
||||
- Trax: IO, Media, `Microsoft.Win32`, Helpers, Models, Services.Audio
|
||||
- Socials: Windows, Input, Media, `ytLive`, Helpers, Models, Services, Services.Compositor
|
||||
- Streaming: ObjectModel, IO, Windows, Input, Threading, `Microsoft.Win32`, Helpers, Models, Services, Services.Encoder
|
||||
- Chat: ObjectModel, Media, Media.Imaging, Threading, Helpers, Models, Services
|
||||
- Overlays: Diagnostics, IO, Input, Helpers
|
||||
- **Pre-commit gate:** `scripts/verify.sh` (0-warning build + full tests) and
|
||||
`scripts/scope-check.sh "file1" …` — scope-check REQUIRES explicit file args.
|
||||
- Build from WSL with the Windows dotnet host only (see `AGENTS.md`).
|
||||
- **Stale fact inside `TASKS.md` (left alone, flagged here per the Scope Lock).** The "1.0 gates"
|
||||
section asserts "**TASK 36 is now shipped**", but the catalog row 36 reads **☐ Queued** and
|
||||
`task-36-gold-pass.md` still shows items 1–6 unchecked. The line most likely means *item 2*
|
||||
(branding flash goes live) shipped at `9761b1d`. **Needs a one-line fix, not a code change** —
|
||||
flagging rather than fixing because it is not this unit's job.
|
||||
- **A stale `ytLive.exe` (PID 2544) locked `bin/.../ytLive.exe`** and broke `dotnet run` with
|
||||
MSB3027. Killed. **If the build fails to copy `ytLive.exe`, check for a running instance first.**
|
||||
- **`LayerReorderPersistenceTests.RealMouseDrag_OnTheLayerList_PersistsTheReorder` is flaky by
|
||||
environment** (needs an interactive desktop session; 2 fail / 1 pass in isolation). **Run the
|
||||
suite with ytLive CLOSED.** Not a regression.
|
||||
- **`RealAppHost.RunAsync` exists for a reason** — a frame-pump test MUST `await` inside it. A
|
||||
blocking wait occupies the one shared STA thread and **hangs the whole suite with no output.**
|
||||
Always background a `vstest` run and poll the log; a foreground piped `vstest` returns nothing
|
||||
in this shell even on success.
|
||||
- **`GlobalHotkeys`: two instances registering the SAME global hotkey** — Windows refuses the
|
||||
second. Left alone deliberately.
|
||||
- **MSIX writes under a real package identity are unverified.** The docs say full trust passes
|
||||
user-profile writes through, but confirm on a real packaged build before trusting it with
|
||||
recordings (TASK 48 item 6).
|
||||
|
||||
## Todo state
|
||||
## Test state
|
||||
|
||||
- MainViewModel refactor: **Phase 1 complete (11/11 partials + scaffold).**
|
||||
`ViewModels/index.md` has a per-partial table row (code↔commit map in the
|
||||
tracker section). Core row now describes only what remains (ctor wiring,
|
||||
lifecycle, source commands, WebView2 hosting, frame-pump seam,
|
||||
`SelectedElement`, `BrandFlash*`, `BuildCompositorOptions`/`ResolveOutputFrame`).
|
||||
- TASK 3: 27/30 (preview compositor 16, text source 17, alerts 20 still open).
|
||||
- TASK 4: ✅ shipped. TASK 9: items 1–3 shipped; 4–7 open. TASK 10: steps 1–7; Velopack pending.
|
||||
- TASK 18: shipped, creator verification pending. TASK 19/23, 20, 21-A: shipped.
|
||||
- TASK 22 (audio sync offset): not started — required for v1.
|
||||
- Monitoring: cross-partial private access is the point of partial classes —
|
||||
no moved member re-declared anywhere; moved setters reference other partials
|
||||
purely by name.
|
||||
**367/367 as of `938c5b3`.** Not re-run for this unit — docs only, no code touched.
|
||||
|
||||
## Session summary (2026-08-30)
|
||||
## Uncommitted / untracked
|
||||
|
||||
- Ran the MainViewModel split to completion under the standing directive
|
||||
("continue until you can continue no longer" — no interruption prompts).
|
||||
- Commits 9 (Streaming), 10 (Chat), 11 (Overlays) landed locally, each ~8:40
|
||||
back-to-back: cut → 0-warning build → verify (246 pass + 2 known) →
|
||||
scope-check → index.md in-commit → `git tag refactor-commit-N`. No push.
|
||||
- Streaming was the hardest cut (interleaved props, master-frame consts,
|
||||
session timer, health pipeline, record rename-on-stop); Chat and Overlays
|
||||
were clean contiguous cuts. One build fix each: Streaming needed
|
||||
`ObjectModel`/`Input`, Chat needed `Media.Imaging` for `WriteableBitmap`.
|
||||
- Docs kept honest throughout: `index.md` rows for Streaming/Chat/Overlays,
|
||||
About-hub row re-pointed to the Overlays partial, core row trimmed each time.
|
||||
- `MARCOM.md`, `MONETIZATION.md` — both edited (privacy-copy guard; the Polar-obsolete banner
|
||||
and the re-opened-price note) and both **gitignored by design** (confidential business
|
||||
files, `.gitignore:10-11`). They stay local, as intended. Same for `CREDENTIALS.md` —
|
||||
**never commit those.**
|
||||
|
||||
## Next
|
||||
|
||||
1. **Bundle ffmpeg (TASK 48 item 1)** — unblocked, first code unit, fixes a real user-facing
|
||||
failure and clears the one hard certification gate.
|
||||
2. **Settle the price** — one-time vs subscription, and the number. Then declare the IAP
|
||||
products in Partner Center. **Blocks:** the Store listing, and the `OverlayHost.xaml` copy.
|
||||
3. **The packaging project + `Package.appxmanifest`** (TASK 48 items 2–3) — full trust,
|
||||
`webcam`/`microphone`, English only; Velopack deleted. Add the `DefaultRecordFolder()`
|
||||
comment in the same unit.
|
||||
4. **Polar teardown** (TASK 48 item 0) — `PolarLicenseService`, `PolarLicense`,
|
||||
`MainViewModel.License.cs`, the `OverlayHost.xaml` string, the `csproj`/`App.xaml.cs`
|
||||
Velopack sites. `IsPremium` ← Store entitlement. **Do this as one unit** — a half-torn-down
|
||||
licensing path is worse than either end state.
|
||||
5. **Verify the Store revenue-share rate** — before step 2, not after.
|
||||
6. **Vertical recording verification** — the compositor tier is proven by
|
||||
`Render_VerticalTier_Outputs_1080x1920_From_The_Center_Crop`; the *record* path has never
|
||||
been run. `FramePump.cs:645` flags off-size tiers as a known follow-up.
|
||||
7. **WACK + certification notes + the privacy policy** (TASK 48 items 4–5) — the policy must
|
||||
state camera/mic is OS-gated, nothing is retained, and nothing is fetched at runtime.
|
||||
8. **Post-session efficacy report** — roll up `CurrentHealth` (dropped frames, duration, health
|
||||
message) when a stream or recording ends. `SessionTeardownTests` is the natural home.
|
||||
9. **Measure whether the Windows camera toggle gates DShow** — gates all privacy-forward copy
|
||||
(`MARCOM.md` guard). Not a certification risk; a trust risk.
|
||||
10. **TASK 36:212 stale "shipped" line** — one-line fix, needs a hand.
|
||||
|
||||
**Dropped from the list** because the ruling made them moot: multi-instance's ffmpeg tools-dir
|
||||
race (item 1 makes it disappear), `scripts/publish.sh` + the `LlamaCasty.exe` rename (the Store
|
||||
packages and delivers — *revisit only if we ever ship outside the Store*), and the alert-gating
|
||||
copy (the wrong string dies with the Polar teardown in step 4).
|
||||
|
||||
**Everything else in the v1 queue:** stream resilience (32), bandwidth step-down (33), scheduled
|
||||
streams (34), scene-linked audio (35), the master limiter (12), text source (3.16), reward
|
||||
events (3.20), the media picker (21), webcam identity (9.5), settings units A/C/D (40), and the
|
||||
defaults split (37). Ship-checklist items live in `TASKS.md` -> "1.0 gates".
|
||||
|
||||
+3
-5
@@ -5,15 +5,13 @@ namespace ytLive.Helpers;
|
||||
/// <summary>
|
||||
/// Minimal file logger used to diagnose startup/shutdown crashes on Windows,
|
||||
/// where the WPF app has no visible console. Writes to
|
||||
/// %APPDATA%\ytLlive\startup.log, appending synchronously so entries survive
|
||||
/// a hard crash.
|
||||
/// %APPDATA%\ytLlive\startup.log (or a per-instance subfolder in DEBUG — see
|
||||
/// InstanceProfile), appending synchronously so entries survive a hard crash.
|
||||
/// </summary>
|
||||
public static class AppLog
|
||||
{
|
||||
private static readonly string LogPath = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"ytLlive",
|
||||
"startup.log");
|
||||
InstanceProfile.DataRoot, "startup.log");
|
||||
|
||||
private static readonly object Gate = new();
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
|
||||
namespace ytLive.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// Which run of LlamaCasty this process is, and where its private state lives.
|
||||
/// <para><b>Dev-only affordance (creator directive, 2026-09-26).</b> Pre-1.0 the creator
|
||||
/// needs two instances side by side: one streaming, one screen-capturing the other.
|
||||
/// Two instances on one Windows profile collide on state, and two of those collisions
|
||||
/// are hard failures rather than annoyances:
|
||||
/// <list type="bullet">
|
||||
/// <item><b>The SQLite layout DB.</b> The app's model is read-whole-scene / write-whole-scene,
|
||||
/// so two instances saving different layouts clobber each other.</item>
|
||||
/// <item><b>The WebView2 user data folder.</b> Chromium takes an exclusive lock on it; the
|
||||
/// second instance of the same exe simply fails to start.</item>
|
||||
/// <item><b>The auth token store.</b> A test instance would overwrite the real YouTube
|
||||
/// sign-in.</item>
|
||||
/// <item><b>startup.log.</b> Two appenders interleave into one file, so a crash in either
|
||||
/// instance is ambiguous.</item>
|
||||
/// </list>
|
||||
/// So in <c>DEBUG</c> a process can claim an identity and gets its own private root
|
||||
/// under <c>instances\<id></c>: <c>$env:YTLIVE_INSTANCE=2; dotnet run</c>.
|
||||
/// Recording, screen capture and the ffmpeg <c>tools\</c> cache are deliberately LEFT
|
||||
/// shared — the cache should be shared, and the creator chooses the record folder.
|
||||
/// Global hotkeys are also left alone: if both instances register the same one, Windows
|
||||
/// refuses the second, which is the correct answer.</para>
|
||||
/// <para><b>At 1.0 this disappears.</b> The whole implementation is inside
|
||||
/// <c>#if DEBUG</c>; a Release build compiles to the two pass-throughs at the bottom —
|
||||
/// no environment variable, no alternate folder, not even the variable's NAME in the
|
||||
/// binary. Call sites are unconditional, so Release cannot accidentally diverge: it
|
||||
/// always resolves the one real user profile.</para>
|
||||
/// </summary>
|
||||
public static class InstanceProfile
|
||||
{
|
||||
#if DEBUG
|
||||
/// <summary>Environment variable naming this instance. Absent or blank = the primary
|
||||
/// instance on the real user profile.</summary>
|
||||
public const string InstanceVariable = "YTLIVE_INSTANCE";
|
||||
|
||||
/// <summary>This instance's id, or null for the primary instance.</summary>
|
||||
public static string? Id => Sanitize(Environment.GetEnvironmentVariable(InstanceVariable));
|
||||
|
||||
/// <summary>Private state root: the real profile normally, a per-instance subfolder
|
||||
/// when an id is claimed.</summary>
|
||||
public static string DataRoot => Id is { } id
|
||||
? Path.Combine(DefaultRoot, "instances", id)
|
||||
: DefaultRoot;
|
||||
|
||||
/// <summary>Chromium's user data folder, or null to let WebView2 pick its default
|
||||
/// (which is what Release always does). Must be per-instance or the second
|
||||
/// instance dies on Chromium's exclusive lock.</summary>
|
||||
public static string? WebViewDataFolder => Id is null ? null : Path.Combine(DataRoot, "webview2");
|
||||
|
||||
/// <summary>Reject anything that is not a plain path segment, so the variable can
|
||||
/// never be used to walk out of the profile directory or name a UNC path.</summary>
|
||||
private static string? Sanitize(string? raw)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw)) return null;
|
||||
var trimmed = raw.Trim();
|
||||
foreach (var c in trimmed)
|
||||
if (!char.IsLetterOrDigit(c) && c != '-' && c != '_') return null;
|
||||
return trimmed;
|
||||
}
|
||||
#else
|
||||
// Release: one profile, no alternate paths, no environment variable. Multi-instance
|
||||
// is a development affordance, not a shipping feature.
|
||||
public static string DataRoot => DefaultRoot;
|
||||
public static string? WebViewDataFolder => null;
|
||||
#endif
|
||||
|
||||
/// <summary>The one real user profile: <c>%APPDATA%\ytLlive</c>. Note the historical
|
||||
/// double-L — do not "fix" it, users already have their layouts there.</summary>
|
||||
public static string DefaultRoot => Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"ytLlive");
|
||||
}
|
||||
@@ -14,9 +14,7 @@ namespace ytLive.Helpers;
|
||||
public static class TokenStore
|
||||
{
|
||||
public static string DefaultPath => Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"ytLlive",
|
||||
"ytLlive.auth");
|
||||
InstanceProfile.DataRoot, "ytLlive.auth");
|
||||
|
||||
public static void Save(YouTubeChannel channel, string? path = null)
|
||||
{
|
||||
|
||||
+9
-1504
File diff suppressed because it is too large
Load Diff
+25
-768
@@ -1,19 +1,10 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.ComponentModel;
|
||||
using System.IO;
|
||||
using System.Net.Http;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Effects;
|
||||
using System.Windows.Threading;
|
||||
using System.Windows.Media.Animation;
|
||||
using System.Windows.Media.Imaging;
|
||||
using System.Windows.Shapes;
|
||||
using ytLive.Helpers;
|
||||
using ytLive.Models;
|
||||
using ytLive.ViewModels;
|
||||
|
||||
namespace ytLive;
|
||||
@@ -21,7 +12,9 @@ namespace ytLive;
|
||||
public partial class MainWindow : Window
|
||||
{
|
||||
private readonly MainViewModel _viewModel;
|
||||
private readonly Dictionary<string, Storyboard> _activeBorderAnimations = new();
|
||||
private Controls.TopBar _topBar = null!;
|
||||
private Controls.PreviewPane _previewPane = null!;
|
||||
private Controls.LeftPanel _leftPanel = null!;
|
||||
|
||||
public MainWindow()
|
||||
{
|
||||
@@ -29,13 +22,13 @@ public partial class MainWindow : Window
|
||||
InitializeComponent();
|
||||
AppLog.Write("MainWindow ctor: after InitializeComponent");
|
||||
_viewModel = (MainViewModel)DataContext;
|
||||
_viewModel.InitWebView2(WebViewHostPanel);
|
||||
_viewModel.PropertyChanged += OnViewModelPropertyChanged;
|
||||
_viewModel.ChatMessages.CollectionChanged += OnChatMessagesChanged;
|
||||
_topBar = (Controls.TopBar)FindName("TopBar")!;
|
||||
_previewPane = (Controls.PreviewPane)FindName("PreviewPane")!;
|
||||
_leftPanel = (Controls.LeftPanel)FindName("LeftPanel")!;
|
||||
UpdateTaskbarOverlay();
|
||||
UpdateSelectionOverlay();
|
||||
AvatarImage.Source = null;
|
||||
AvatarInitial.Visibility = Visibility.Collapsed;
|
||||
_topBar.RefreshAvatar();
|
||||
RestoreWindowGeometry();
|
||||
AppLog.Write("MainWindow ctor: end");
|
||||
}
|
||||
@@ -43,6 +36,10 @@ public partial class MainWindow : Window
|
||||
private void MainWindow_Loaded(object sender, RoutedEventArgs e)
|
||||
{
|
||||
AppLog.Write("MainWindow loaded");
|
||||
// Web sources need the window HWND as the composition controller's parent,
|
||||
// which does not exist in the ctor — InitWebView2 runs here (Loaded), after
|
||||
// SourceInitialized. Nothing renders from web frames before this.
|
||||
_viewModel.InitWebView2();
|
||||
}
|
||||
|
||||
// ─── Window geometry persistence (launch size/position) ───
|
||||
@@ -116,7 +113,7 @@ public partial class MainWindow : Window
|
||||
UpdateSelectionOverlay();
|
||||
else if (e.PropertyName == nameof(MainViewModel.AccountAvatarUrl)
|
||||
|| e.PropertyName == nameof(MainViewModel.IsConnected))
|
||||
LoadAvatarImage();
|
||||
_topBar.RefreshAvatar();
|
||||
}
|
||||
|
||||
private void MainWindow_Closing(object? sender, CancelEventArgs e)
|
||||
@@ -141,772 +138,32 @@ public partial class MainWindow : Window
|
||||
: null;
|
||||
}
|
||||
|
||||
private static readonly HttpClient AvatarHttp = new() { Timeout = TimeSpan.FromSeconds(15) };
|
||||
private string? _loadedAvatarUrl;
|
||||
|
||||
private async void LoadAvatarImage()
|
||||
{
|
||||
var url = _viewModel.AccountAvatarUrl;
|
||||
if (!_viewModel.IsConnected)
|
||||
{
|
||||
ShowAvatarFallback(null);
|
||||
return;
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(url))
|
||||
{
|
||||
AppLog.Write("Avatar: connected but no profile image URL on the session");
|
||||
ShowAvatarFallback(null);
|
||||
return;
|
||||
}
|
||||
if (url == _loadedAvatarUrl && AvatarImage.Source != null) return;
|
||||
try
|
||||
{
|
||||
var bytes = await AvatarHttp.GetByteArrayAsync(url);
|
||||
using var ms = new MemoryStream(bytes);
|
||||
var bitmap = new BitmapImage();
|
||||
bitmap.BeginInit();
|
||||
bitmap.CacheOption = BitmapCacheOption.OnLoad;
|
||||
bitmap.StreamSource = ms;
|
||||
bitmap.EndInit();
|
||||
bitmap.Freeze();
|
||||
_loadedAvatarUrl = url;
|
||||
AvatarImage.Source = bitmap;
|
||||
AvatarInitial.Visibility = Visibility.Collapsed;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLog.Write($"Avatar load failed ({url}): {ex.Message}");
|
||||
ShowAvatarFallback(_viewModel.AccountDisplayName);
|
||||
}
|
||||
}
|
||||
|
||||
private void ShowAvatarFallback(string? name)
|
||||
{
|
||||
_loadedAvatarUrl = null;
|
||||
AvatarImage.Source = null;
|
||||
AvatarInitial.Text = string.IsNullOrWhiteSpace(name) ? "?" : name.Substring(0, 1).ToUpperInvariant();
|
||||
AvatarInitial.Visibility = Visibility.Visible;
|
||||
}
|
||||
|
||||
private void SourceNameBox_IsVisibleChanged(object sender, DependencyPropertyChangedEventArgs e)
|
||||
{
|
||||
if (sender is TextBox { IsVisible: true } box)
|
||||
{
|
||||
box.Focus();
|
||||
box.SelectAll();
|
||||
}
|
||||
}
|
||||
|
||||
private void VolumeSlider_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (sender is Slider slider)
|
||||
SetSliderValueFromClick(slider, e);
|
||||
if (sender is FrameworkElement { DataContext: MainViewModel vm })
|
||||
vm.SetVolumeAdjusting(true);
|
||||
}
|
||||
|
||||
private void VolumeSlider_PreviewMouseLeftButtonUp(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (sender is FrameworkElement { DataContext: MainViewModel vm })
|
||||
vm.SetVolumeAdjusting(false);
|
||||
}
|
||||
|
||||
private void VolumeSlider_LostMouseCapture(object sender, MouseEventArgs e)
|
||||
{
|
||||
if (sender is FrameworkElement { DataContext: MainViewModel vm })
|
||||
vm.SetVolumeAdjusting(false);
|
||||
}
|
||||
|
||||
private void GameVolumeSlider_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (sender is Slider slider)
|
||||
SetSliderValueFromClick(slider, e);
|
||||
if (sender is FrameworkElement { DataContext: MainViewModel vm })
|
||||
vm.SetGameVolumeAdjusting(true);
|
||||
}
|
||||
|
||||
private void GameVolumeSlider_PreviewMouseLeftButtonUp(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (sender is FrameworkElement { DataContext: MainViewModel vm })
|
||||
vm.SetGameVolumeAdjusting(false);
|
||||
}
|
||||
|
||||
private void GameVolumeSlider_LostMouseCapture(object sender, MouseEventArgs e)
|
||||
{
|
||||
if (sender is FrameworkElement { DataContext: MainViewModel vm })
|
||||
vm.SetGameVolumeAdjusting(false);
|
||||
}
|
||||
|
||||
private static void SetSliderValueFromClick(Slider slider, MouseButtonEventArgs e)
|
||||
{
|
||||
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;
|
||||
var pos = e.GetPosition(track);
|
||||
var fraction = Math.Clamp(pos.X / track.RenderSize.Width, 0, 1);
|
||||
slider.Value = slider.Minimum + fraction * (slider.Maximum - slider.Minimum);
|
||||
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 })
|
||||
{
|
||||
vm.ToggleMicMuteCommand.Execute(null);
|
||||
}
|
||||
}
|
||||
|
||||
private void GameSpeaker_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (sender is FrameworkElement { DataContext: MainViewModel vm })
|
||||
{
|
||||
vm.ToggleGameMuteCommand.Execute(null);
|
||||
}
|
||||
}
|
||||
|
||||
// TRAX (TASK 9): left-click toggles play/pause (picks a track when none is
|
||||
// loaded); right-click always opens the picker. The picker is the OS file
|
||||
// dialog — the "never the OS viewer" rule covers the preview, not pickers.
|
||||
private void TraxButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is ViewModels.MainViewModel vm)
|
||||
vm.TraxLeftClick();
|
||||
}
|
||||
|
||||
private void TraxButton_PreviewMouseRightButtonUp(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (DataContext is ViewModels.MainViewModel vm)
|
||||
vm.TraxRightClick();
|
||||
e.Handled = true; // the button's right-click context menu must not fire
|
||||
}
|
||||
|
||||
private void BackToAbout_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is ViewModels.MainViewModel vm)
|
||||
{
|
||||
vm.IsLicensesOpen = false;
|
||||
vm.IsLicenseEntryOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void AddSourceButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is Button { ContextMenu: { } menu } button)
|
||||
{
|
||||
menu.PlacementTarget = button;
|
||||
menu.Placement = System.Windows.Controls.Primitives.PlacementMode.Bottom;
|
||||
menu.IsOpen = true;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Preview image selection / move / resize ───
|
||||
private void Thumbnail_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (sender is Border { DataContext: Scene scene })
|
||||
_viewModel.TransitionToScene(scene);
|
||||
}
|
||||
|
||||
private bool _isDraggingOverlay;
|
||||
private bool _isResizing;
|
||||
private Point _grabOffset;
|
||||
private double _resizeAspect;
|
||||
|
||||
private void Window_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (e.OriginalSource is not DependencyObject original) return;
|
||||
var form = _viewModel.BroadcastForm;
|
||||
if (form.IsDrawerOpen && !IsDescendantOf(original, TextPullOutHost))
|
||||
form.CloseDrawerCommand.Execute(null);
|
||||
if (IsDescendantOf(original, PreviewGrid)) return;
|
||||
if (IsDescendantOf(original, SourceList)) return;
|
||||
if (IsDescendantOf(original, ElementsScrollViewer)) return;
|
||||
_viewModel.SelectedElement = null;
|
||||
}
|
||||
|
||||
private static bool IsDescendantOf(DependencyObject? child, DependencyObject ancestor)
|
||||
{
|
||||
while (child != null && !ReferenceEquals(child, ancestor))
|
||||
var ypp = _viewModel.Ypp;
|
||||
var test = _viewModel.TestSession;
|
||||
if (!_previewPane.IsClickInsideDrawer(original))
|
||||
{
|
||||
child = child is System.Windows.Media.Visual or System.Windows.Media.Media3D.Visual3D
|
||||
? VisualTreeHelper.GetParent(child)
|
||||
: LogicalTreeHelper.GetParent(child);
|
||||
if (form.IsDrawerOpen) form.CloseDrawerCommand.Execute(null);
|
||||
if (ypp.IsDrawerOpen) ypp.CloseDrawerCommand.Execute(null);
|
||||
if (test.IsDrawerOpen) test.CloseDrawerCommand.Execute(null);
|
||||
}
|
||||
return child != null;
|
||||
if (_previewPane.IsClickInsidePreview(original)) return;
|
||||
if (_leftPanel.IsClickInside(original)) return;
|
||||
_viewModel.SelectedElement = null;
|
||||
}
|
||||
|
||||
private void UpdateSelectionOverlay()
|
||||
{
|
||||
if (!_viewModel.IsEditMode)
|
||||
{
|
||||
SelectionOverlay.Visibility = Visibility.Collapsed;
|
||||
_previewPane.UpdateSelectionOverlay(false);
|
||||
return;
|
||||
}
|
||||
var selected = _viewModel.SelectedElement is { } s && IsDraggableElement(s);
|
||||
SelectionOverlay.Visibility = selected ? Visibility.Visible : Visibility.Collapsed;
|
||||
if (selected)
|
||||
OpacityValueText.Text = $"{Math.Round(_viewModel.SelectedElement!.Opacity * 100)}%";
|
||||
TakeSnapshot();
|
||||
}
|
||||
|
||||
private void OnChatMessagesChanged(object? sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
|
||||
{
|
||||
if (e.Action == System.Collections.Specialized.NotifyCollectionChangedAction.Add)
|
||||
{
|
||||
ChatListBox.ScrollIntoView(ChatListBox.Items[^1]);
|
||||
}
|
||||
}
|
||||
|
||||
// Static images and the webcam share the move/resize/selection behavior.
|
||||
private static bool IsDraggableElement(SceneElement element)
|
||||
=> element.IsDraggable;
|
||||
|
||||
private void MirrorButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_viewModel.SelectedElement is WebcamSceneConfig webcam)
|
||||
webcam.IsMirrored = !webcam.IsMirrored;
|
||||
}
|
||||
|
||||
private void ShapeButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_viewModel.SelectedElement is WebcamSceneConfig webcam)
|
||||
webcam.ToggleClipShape();
|
||||
}
|
||||
|
||||
private void WebcamMenu_ChangeWebcam(object sender, RoutedEventArgs e)
|
||||
=> _viewModel.ChangeWebcamCommand.Execute(null);
|
||||
|
||||
private void WebcamMenu_SetBorderAnimation(object sender, RoutedEventArgs e)
|
||||
{
|
||||
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: SceneElement element, Tag: string color })
|
||||
element.BorderColor = color;
|
||||
}
|
||||
|
||||
private void WebcamMenu_HideInScene(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is MenuItem { DataContext: SceneElement element })
|
||||
element.IsVisible = false;
|
||||
}
|
||||
|
||||
private void WebcamMenu_Remove(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is MenuItem { DataContext: SceneElement element })
|
||||
_viewModel.RemoveSourceCommand.Execute(element);
|
||||
}
|
||||
|
||||
private void BackgroundMenu_ChangeCapture(object sender, RoutedEventArgs e)
|
||||
{
|
||||
_ = _viewModel.ChangeBackgroundCaptureAsync();
|
||||
}
|
||||
|
||||
private void BackgroundMenu_RefreshCapture(object sender, RoutedEventArgs e)
|
||||
{
|
||||
_viewModel.RefreshBackgroundAutoCapture();
|
||||
}
|
||||
|
||||
private void SourceMenu_Remove(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is MenuItem { DataContext: SceneElement element })
|
||||
_viewModel.RemoveSourceCommand.Execute(element);
|
||||
}
|
||||
|
||||
private void OpacitySlider_ValueChanged(object sender, RoutedPropertyChangedEventArgs<double> e)
|
||||
=> OpacityValueText.Text = $"{Math.Round(e.NewValue * 100)}%";
|
||||
|
||||
// ─── Social bar: click toggles top ⇄ bottom. KISS — the drag variant was
|
||||
// unusable for shaky hands (jitter around the direction deadzone), so the bar
|
||||
// rides on {Binding SocialBarTop} alone and a click flips it. ───
|
||||
private void SocialBar_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
_viewModel.ToggleSocialBarPosition();
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private DispatcherTimer? _socialGlowTimer;
|
||||
private double _socialGlowAngle;
|
||||
|
||||
private void SocialsButton_MouseEnter(object sender, MouseEventArgs e)
|
||||
{
|
||||
if (SocialBarElement == null) return;
|
||||
|
||||
// Kick the social bar to full brightness so it's immediately visible
|
||||
var effect = SocialBarElement.Effect as DropShadowEffect;
|
||||
if (effect != null)
|
||||
{
|
||||
effect.BlurRadius = 40;
|
||||
effect.Opacity = 1.0;
|
||||
}
|
||||
|
||||
// Pulse the glow so it catches the eye
|
||||
_socialGlowAngle = 0;
|
||||
_socialGlowTimer?.Stop();
|
||||
_socialGlowTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(30) };
|
||||
_socialGlowTimer.Tick += SocialGlowTick;
|
||||
_socialGlowTimer.Start();
|
||||
}
|
||||
|
||||
private void SocialsButton_MouseLeave(object sender, MouseEventArgs e)
|
||||
{
|
||||
_socialGlowTimer?.Stop();
|
||||
_socialGlowTimer = null;
|
||||
|
||||
if (SocialBarElement?.Effect is DropShadowEffect effect)
|
||||
{
|
||||
effect.BlurRadius = 18;
|
||||
effect.Opacity = 0.9;
|
||||
}
|
||||
}
|
||||
|
||||
private void SocialGlowTick(object? sender, EventArgs e)
|
||||
{
|
||||
if (SocialBarElement?.Effect is not DropShadowEffect effect) return;
|
||||
|
||||
_socialGlowAngle += 0.18;
|
||||
if (_socialGlowAngle > Math.PI * 2) _socialGlowAngle -= Math.PI * 2;
|
||||
|
||||
// Smooth sine pulse: BlurRadius 24→48, Opacity 0.7→1.0
|
||||
double t = (Math.Sin(_socialGlowAngle) + 1.0) / 2.0; // 0..1
|
||||
effect.BlurRadius = 24 + t * 24;
|
||||
effect.Opacity = 0.7 + t * 0.3;
|
||||
}
|
||||
|
||||
private void Preview_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (!_viewModel.IsEditMode) return;
|
||||
var grid = (Grid)sender;
|
||||
var toCanvas = CanvasGrid.TransformToVisual(grid).Inverse;
|
||||
var p = toCanvas.Transform(e.GetPosition(grid));
|
||||
var selected = _viewModel.SelectedElement;
|
||||
|
||||
if (selected is { } sel && IsDraggableElement(sel) && HitHandle(e.GetPosition(grid), sel))
|
||||
{
|
||||
_isResizing = true;
|
||||
_resizeAspect = sel.ClipShape == ClipShape.Round ? 1 : sel.Width / Math.Max(1, sel.Height);
|
||||
grid.CaptureMouse();
|
||||
e.Handled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
var hit = HitElement(p);
|
||||
if (hit != null)
|
||||
{
|
||||
_viewModel.SelectedElement = hit;
|
||||
_isDraggingOverlay = true;
|
||||
_grabOffset = new Point(p.X - hit.X, p.Y - hit.Y);
|
||||
grid.CaptureMouse();
|
||||
e.Handled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
_viewModel.SelectedElement = null;
|
||||
}
|
||||
|
||||
private void Preview_MouseMove(object sender, MouseEventArgs e)
|
||||
{
|
||||
if (!_viewModel.IsEditMode) return;
|
||||
if (!_isDraggingOverlay && !_isResizing) return;
|
||||
|
||||
var grid = (Grid)sender;
|
||||
var toCanvas = CanvasGrid.TransformToVisual(grid).Inverse;
|
||||
var p = toCanvas.Transform(e.GetPosition(grid));
|
||||
var selected = _viewModel.SelectedElement;
|
||||
if (selected == null)
|
||||
{
|
||||
EndPreviewDrag(grid);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_isDraggingOverlay)
|
||||
{
|
||||
var minX = -(selected.Width - 20);
|
||||
var maxX = 1920 - 20;
|
||||
var minY = -(selected.Height - 20);
|
||||
var maxY = 1080 - 20;
|
||||
selected.X = Math.Clamp(p.X - _grabOffset.X, Math.Min(minX, maxX), Math.Max(minX, maxX));
|
||||
selected.Y = Math.Clamp(p.Y - _grabOffset.Y, Math.Min(minY, maxY), Math.Max(minY, maxY));
|
||||
}
|
||||
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.StagedScene?.Name) : 1920;
|
||||
var maxH = isWebcam ? MainViewModel.MaxWebcamHeightFor(_viewModel.StagedScene?.Name) : 1080;
|
||||
var newW = Math.Clamp(p.X - selected.X, 32, maxW);
|
||||
var newH = proportional ? newW / _resizeAspect : Math.Clamp(p.Y - selected.Y, 32, maxH);
|
||||
if (proportional && newH > maxH)
|
||||
{
|
||||
newH = maxH;
|
||||
newW = newH * _resizeAspect;
|
||||
}
|
||||
selected.Width = newW;
|
||||
selected.Height = newH;
|
||||
}
|
||||
}
|
||||
|
||||
private void Preview_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
|
||||
=> EndPreviewDrag((Grid)sender);
|
||||
|
||||
private void EndPreviewDrag(Grid grid)
|
||||
{
|
||||
if (_isDraggingOverlay || _isResizing)
|
||||
grid.ReleaseMouseCapture();
|
||||
_isDraggingOverlay = false;
|
||||
_isResizing = false;
|
||||
}
|
||||
|
||||
private bool HitHandle(Point mouseScreen, SceneElement element)
|
||||
{
|
||||
var corner = CanvasGrid.TransformToVisual(PreviewGrid).Transform(new Point(element.X + element.Width, element.Y + element.Height));
|
||||
return Math.Abs(mouseScreen.X - corner.X) <= 20 && Math.Abs(mouseScreen.Y - corner.Y) <= 20;
|
||||
}
|
||||
|
||||
private SceneElement? HitElement(Point p)
|
||||
{
|
||||
var scene = _viewModel.StagedScene;
|
||||
if (scene == null) return null;
|
||||
for (var i = scene.Elements.Count - 1; i >= 0; i--)
|
||||
{
|
||||
var element = scene.Elements[i];
|
||||
if (!IsDraggableElement(element)) continue;
|
||||
if (element is Source { IsEnabled: false }) continue;
|
||||
if (element is WebcamSceneConfig { IsVisible: false }) continue;
|
||||
if (p.X >= element.X && p.X <= element.X + element.Width &&
|
||||
p.Y >= element.Y && p.Y <= element.Y + element.Height)
|
||||
return element;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void SourceNameBox_KeyDown(object sender, KeyEventArgs e)
|
||||
{
|
||||
if (e.Key is Key.Enter or Key.Escape)
|
||||
{
|
||||
CommitSourceEdit(sender);
|
||||
e.Handled = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void SourceNameBox_LostFocus(object sender, RoutedEventArgs e)
|
||||
{
|
||||
CommitSourceEdit(sender);
|
||||
}
|
||||
|
||||
private static void CommitSourceEdit(object sender)
|
||||
{
|
||||
if (sender is FrameworkElement { DataContext: SceneElement element })
|
||||
element.IsEditing = false;
|
||||
}
|
||||
|
||||
// ─── List drag-to-reorder (scenes list + sources list) ───
|
||||
private Point _dragStartPoint;
|
||||
private int _dragIndex = -1;
|
||||
private bool _isDragging;
|
||||
private object? _lastHoveredItem;
|
||||
|
||||
private void List_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
var listBox = (ListBox)sender;
|
||||
var item = FindItemContainerAt(listBox, e.GetPosition(listBox));
|
||||
if (item == null)
|
||||
{
|
||||
if (ReferenceEquals(listBox, SourceList))
|
||||
_viewModel.SelectedElement = null;
|
||||
_dragIndex = -1;
|
||||
return;
|
||||
}
|
||||
if (listBox.ItemsSource is not IList items || items.Count == 0)
|
||||
{
|
||||
_dragIndex = -1;
|
||||
return;
|
||||
}
|
||||
_dragIndex = listBox.Items.IndexOf(item.DataContext);
|
||||
_dragStartPoint = e.GetPosition(listBox);
|
||||
_isDragging = false;
|
||||
_lastHoveredItem = null;
|
||||
}
|
||||
|
||||
private void List_PreviewMouseMove(object sender, MouseEventArgs e)
|
||||
{
|
||||
if (_dragIndex < 0) return;
|
||||
var listBox = (ListBox)sender;
|
||||
if (e.LeftButton != MouseButtonState.Pressed || listBox.ItemsSource is not IList items)
|
||||
{
|
||||
EndListDrag(listBox);
|
||||
return;
|
||||
}
|
||||
|
||||
var position = e.GetPosition(listBox);
|
||||
if (!_isDragging)
|
||||
{
|
||||
if (Math.Abs(position.X - _dragStartPoint.X) < SystemParameters.MinimumHorizontalDragDistance &&
|
||||
Math.Abs(position.Y - _dragStartPoint.Y) < SystemParameters.MinimumVerticalDragDistance)
|
||||
return;
|
||||
_isDragging = true;
|
||||
listBox.CaptureMouse();
|
||||
}
|
||||
|
||||
var item = FindItemContainerAt(listBox, position);
|
||||
if (item == null) return;
|
||||
|
||||
var targetItem = item.DataContext;
|
||||
if (ReferenceEquals(targetItem, items[_dragIndex]) || ReferenceEquals(targetItem, _lastHoveredItem))
|
||||
return;
|
||||
|
||||
var targetIndex = listBox.Items.IndexOf(targetItem);
|
||||
if (targetIndex < 0) return;
|
||||
|
||||
var dragged = items[_dragIndex];
|
||||
items.RemoveAt(_dragIndex);
|
||||
items.Insert(targetIndex, dragged);
|
||||
_dragIndex = targetIndex;
|
||||
_lastHoveredItem = targetItem;
|
||||
}
|
||||
|
||||
private void List_PreviewMouseLeftButtonUp(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
EndListDrag((ListBox)sender);
|
||||
}
|
||||
|
||||
private void EndListDrag(ListBox listBox)
|
||||
{
|
||||
if (_isDragging)
|
||||
listBox.ReleaseMouseCapture();
|
||||
_dragIndex = -1;
|
||||
_isDragging = false;
|
||||
_lastHoveredItem = null;
|
||||
}
|
||||
|
||||
private static ListBoxItem? FindItemContainerAt(ListBox listBox, Point position)
|
||||
{
|
||||
var hit = listBox.InputHitTest(position) as DependencyObject;
|
||||
while (hit != null && hit != listBox)
|
||||
{
|
||||
if (hit is ListBoxItem item) return item;
|
||||
hit = VisualTreeHelper.GetParent(hit);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private Models.SceneElement? _snapshotElement;
|
||||
private string _snapshotBorderColor = string.Empty;
|
||||
private int _snapshotBorderWidth;
|
||||
private int _snapshotTimerMinutes = 15;
|
||||
private string? _snapshotWebUri;
|
||||
|
||||
private void TakeSnapshot()
|
||||
{
|
||||
if (_viewModel.SelectedElement is Models.Source src)
|
||||
{
|
||||
_snapshotElement = src;
|
||||
_snapshotBorderColor = src.BorderColor;
|
||||
_snapshotBorderWidth = src.BorderWidth;
|
||||
_snapshotTimerMinutes = src.TimerMinutes;
|
||||
_snapshotWebUri = src.WebUri;
|
||||
}
|
||||
}
|
||||
|
||||
private void WebcamBorderColorPicker_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_viewModel.SelectedElement is not SceneElement src) return;
|
||||
if (sender is FrameworkElement fe && fe.ContextMenu == null)
|
||||
{
|
||||
var menu = new System.Windows.Controls.ContextMenu();
|
||||
string[] colors = ["#FFFFFF", "#000000", "#FF0000", "#00FF00", "#0000FF",
|
||||
"#FFFF00", "#FF00FF", "#00FFFF", "#FF8800", "#8800FF",
|
||||
"#e94560", "#22c55e", "#3b82f6", "#f59e0b"];
|
||||
foreach (var c in colors)
|
||||
{
|
||||
var item = new System.Windows.Controls.MenuItem { Header = c, ToolTip = c };
|
||||
var brush = new System.Windows.Media.SolidColorBrush(
|
||||
(System.Windows.Media.Color)System.Windows.Media.ColorConverter.ConvertFromString(c));
|
||||
item.Icon = new System.Windows.Controls.Border
|
||||
{
|
||||
Width = 16, Height = 16,
|
||||
Background = brush,
|
||||
CornerRadius = new System.Windows.CornerRadius(2)
|
||||
};
|
||||
var color = c;
|
||||
item.Click += (_, _) => src.BorderColor = color;
|
||||
menu.Items.Add(item);
|
||||
}
|
||||
fe.ContextMenu = menu;
|
||||
}
|
||||
if (sender is FrameworkElement fe2)
|
||||
fe2.ContextMenu.IsOpen = true;
|
||||
}
|
||||
|
||||
private void WebcamBorderRevert_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_snapshotElement is Models.Source src)
|
||||
{
|
||||
src.BorderColor = _snapshotBorderColor;
|
||||
src.BorderWidth = _snapshotBorderWidth;
|
||||
}
|
||||
}
|
||||
|
||||
private void WebcamBorderAccept_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_snapshotElement is Models.Source src)
|
||||
{
|
||||
_snapshotBorderColor = src.BorderColor;
|
||||
_snapshotBorderWidth = src.BorderWidth;
|
||||
}
|
||||
}
|
||||
|
||||
private void CountdownRevert_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_snapshotElement is Models.Source src)
|
||||
src.TimerMinutes = _snapshotTimerMinutes;
|
||||
}
|
||||
|
||||
private void CountdownAccept_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_snapshotElement is Models.Source src)
|
||||
_snapshotTimerMinutes = src.TimerMinutes;
|
||||
}
|
||||
|
||||
private void WebRevert_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_snapshotElement is Models.Source src)
|
||||
src.WebUri = string.Empty;
|
||||
_snapshotWebUri = string.Empty;
|
||||
}
|
||||
|
||||
private void WebAccept_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_snapshotElement is Models.Source src)
|
||||
_snapshotWebUri = src.WebUri;
|
||||
var selected = _viewModel.SelectedElement is { } s && Controls.PreviewPane.IsDraggableElement(s);
|
||||
_previewPane.UpdateSelectionOverlay(selected);
|
||||
_leftPanel.OnSelectionChanged(_viewModel);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,17 @@ public enum BorderAnimation
|
||||
Sparkles
|
||||
}
|
||||
|
||||
/// <summary>Whether an element's pixel content is fixed (bake once into the
|
||||
/// compositor's cached base) or changes every frame (composite each tick).
|
||||
/// See ai.md "SceneGraph component + baked-crust compositor" (TASK 31).</summary>
|
||||
public enum ElementKind
|
||||
{
|
||||
/// <summary>Pixels never change at runtime (images, background art).</summary>
|
||||
Static,
|
||||
/// <summary>Pixels change every frame (webcam, live capture, chat, web source).</summary>
|
||||
Dynamic
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A thing rendered in a scene: a multi-instance Source (image/background/text)
|
||||
/// or a singleton resource's per-scene config (webcam). Carries the shared
|
||||
@@ -44,6 +55,11 @@ public abstract class SceneElement : INotifyPropertyChanged
|
||||
private string _name = string.Empty;
|
||||
public string Name { get => _name; set => Set(ref _name, value); }
|
||||
|
||||
/// <summary>Whether this element's pixels are fixed (Static) or change every
|
||||
/// frame (Dynamic). The compositor bakes all Static layers below the first
|
||||
/// Dynamic layer once (see <c>SceneGraph.GetSplitPoint</c>).</summary>
|
||||
public virtual ElementKind Kind => ElementKind.Static;
|
||||
|
||||
/// <summary>True only for singleton resources (webcam). Gates webcam-only UI.</summary>
|
||||
public virtual bool IsWebcam => false;
|
||||
|
||||
|
||||
+72
-3
@@ -13,7 +13,10 @@ public enum SourceType
|
||||
Countdown,
|
||||
WebSource,
|
||||
ChatBox,
|
||||
MediaSource
|
||||
MediaSource,
|
||||
/// <summary>The designated alert zone: the six monetization events play their
|
||||
/// unique celebration animations here (OBS alert-box equivalent, native).</summary>
|
||||
AlertBox
|
||||
}
|
||||
|
||||
/// <summary>Distinguishes how a media source presents its content (TASK 21).</summary>
|
||||
@@ -37,6 +40,19 @@ public enum ClipShape
|
||||
Round
|
||||
}
|
||||
|
||||
/// <summary>How an alert box presents its announcement strip (TASK 47). The strip is
|
||||
/// the full-width pill naming the event ("Viewer — Super Chat · $12.34").</summary>
|
||||
public enum AlertDisplayMethod
|
||||
{
|
||||
/// <summary>Marquee: the pill scrolls across the top edge, paced so the text is
|
||||
/// read <see cref="AlertOverlayLayer.TickerReadsPerAlert"/> times per alert.</summary>
|
||||
TickerScroll,
|
||||
/// <summary>Pulse: the static pill blinks on and off for the whole alert.</summary>
|
||||
Flash,
|
||||
/// <summary>Static: the pill sits centred and still for the whole alert.</summary>
|
||||
Solid
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A multi-instance scene object: image/background/text (screen/window later).
|
||||
/// The webcam is NOT a Source — it's a singleton resource whose per-scene usage
|
||||
@@ -61,7 +77,12 @@ public class Source : SceneElement
|
||||
}
|
||||
}
|
||||
|
||||
public override bool IsDraggable => Type is SourceType.Image or SourceType.WebSource or SourceType.ChatBox;
|
||||
public override bool IsDraggable => Type is SourceType.Image or SourceType.WebSource or SourceType.ChatBox or SourceType.AlertBox;
|
||||
|
||||
/// <summary>Live captures, chat, web sources and media are dynamic; static art is fixed.</summary>
|
||||
public override ElementKind Kind => Type is SourceType.DisplayCapture or SourceType.WindowCapture
|
||||
or SourceType.ChatBox or SourceType.WebSource or SourceType.MediaSource or SourceType.AlertBox
|
||||
? ElementKind.Dynamic : ElementKind.Static;
|
||||
|
||||
public override bool IsWebSource => Type == SourceType.WebSource;
|
||||
|
||||
@@ -113,7 +134,7 @@ public class Source : SceneElement
|
||||
|
||||
public override ImageSource? DisplaySource => IsLiveCapture
|
||||
? (VideoImageSource ?? _imageSource)
|
||||
: (Type is SourceType.ChatBox or SourceType.WebSource ? VideoImageSource : _imageSource);
|
||||
: (Type is SourceType.ChatBox or SourceType.WebSource or SourceType.MediaSource or SourceType.AlertBox ? VideoImageSource : _imageSource);
|
||||
|
||||
public override bool IsImageSource => Type == SourceType.Image;
|
||||
|
||||
@@ -171,6 +192,54 @@ public class Source : SceneElement
|
||||
|
||||
public bool IsMediaSource => Type == SourceType.MediaSource;
|
||||
|
||||
// Alert video config (TASK 47): the shipped default clip lives in the Asset
|
||||
// table (seeded like image assets); the creator's replacement is a FILE PATH
|
||||
// reference only — never into the DB (creator ruling, 2026-09-26). Runtime
|
||||
// order: replacement path exists → DB default → six celebration animations.
|
||||
private string? _alertVideoAssetId;
|
||||
public string? AlertVideoAssetId
|
||||
{
|
||||
get => _alertVideoAssetId;
|
||||
set => Set(ref _alertVideoAssetId, value);
|
||||
}
|
||||
|
||||
private string? _alertVideoPath;
|
||||
public string? AlertVideoPath
|
||||
{
|
||||
get => _alertVideoPath;
|
||||
set => Set(ref _alertVideoPath, value);
|
||||
}
|
||||
|
||||
private bool _alertUseDefaultVideo = true;
|
||||
public bool AlertUseDefaultVideo
|
||||
{
|
||||
get => _alertUseDefaultVideo;
|
||||
set => Set(ref _alertUseDefaultVideo, value);
|
||||
}
|
||||
|
||||
private bool _alertShowTicker = true;
|
||||
public bool AlertShowTicker
|
||||
{
|
||||
get => _alertShowTicker;
|
||||
set => Set(ref _alertShowTicker, value);
|
||||
}
|
||||
|
||||
private double _alertVideoVolume = 1.0;
|
||||
public double AlertVideoVolume
|
||||
{
|
||||
get => _alertVideoVolume;
|
||||
set => Set(ref _alertVideoVolume, Math.Clamp(value, 0.0, 1.0));
|
||||
}
|
||||
|
||||
private AlertDisplayMethod _alertDisplayMethod = AlertDisplayMethod.TickerScroll;
|
||||
public AlertDisplayMethod AlertDisplayMethod
|
||||
{
|
||||
get => _alertDisplayMethod;
|
||||
set => Set(ref _alertDisplayMethod, value);
|
||||
}
|
||||
|
||||
public bool IsAlertBox => Type == SourceType.AlertBox;
|
||||
|
||||
// Chat box config (TASK 3.18)
|
||||
private double _chatFontSize = 16;
|
||||
public double ChatFontSize
|
||||
|
||||
@@ -13,5 +13,6 @@ public class WebcamSceneConfig : SceneElement
|
||||
public string WebcamId { get; init; } = string.Empty;
|
||||
public override bool IsWebcam => true;
|
||||
public override bool IsDraggable => true;
|
||||
public override ElementKind Kind => ElementKind.Dynamic;
|
||||
public override ImageSource? DisplaySource => VideoImageSource;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace ytLive.Models;
|
||||
|
||||
/// <summary>One user-visible open window offered by the "Capture Window…"
|
||||
/// submenu. Carries its owning process name for disambiguation and the raw
|
||||
/// HWND that becomes the "window:<hwnd>" capture key.</summary>
|
||||
public sealed class WindowInfo
|
||||
{
|
||||
public WindowInfo(string title, string processName, long hwnd, int processId)
|
||||
{
|
||||
Title = title;
|
||||
ProcessName = processName;
|
||||
Hwnd = hwnd;
|
||||
ProcessId = processId;
|
||||
}
|
||||
|
||||
public string Title { get; }
|
||||
public string ProcessName { get; }
|
||||
public long Hwnd { get; }
|
||||
public int ProcessId { get; }
|
||||
|
||||
/// <summary>Menu label: "Title — ProcessName" (e.g. "bash — WindowsTerminal").</summary>
|
||||
public string Label => $"{Title} — {ProcessName}";
|
||||
|
||||
/// <summary>The capture key fed to the screen-capture factory
|
||||
/// ("window:<hwnd>").</summary>
|
||||
public string CaptureKey => $"window:0x{Hwnd:X}";
|
||||
}
|
||||
@@ -18,6 +18,21 @@ public record ReusableStream(string Id, string IngestionAddress, string StreamNa
|
||||
public string RtmpUrl => $"{IngestionAddress}/{StreamName}";
|
||||
}
|
||||
|
||||
/// <summary>The six monetization events YouTube's live chat feed emits (the ONLY
|
||||
/// native event stream there is — free subscribers produce no event). Each kind
|
||||
/// drives its own celebration animation in the AlertBox. <c>None</c> = a normal
|
||||
/// chat row: it never enqueues an alert (subscriber mentions stay chat rows).</summary>
|
||||
public enum ChatEventKind
|
||||
{
|
||||
None,
|
||||
SuperChat,
|
||||
SuperSticker,
|
||||
NewMember,
|
||||
MemberGift,
|
||||
GiftReceived,
|
||||
MemberMilestone,
|
||||
}
|
||||
|
||||
public class ChatMessage
|
||||
{
|
||||
public string Id { get; init; } = Guid.NewGuid().ToString();
|
||||
@@ -29,6 +44,30 @@ public class ChatMessage
|
||||
public bool IsSuperChat { get; set; }
|
||||
public double SuperChatAmount { get; set; }
|
||||
public string? SuperChatCurrency { get; set; }
|
||||
|
||||
/// <summary>YouTube's own pre-rendered amount ("$12.34") — preferred over
|
||||
/// recomposing amount+currency ourselves, which mangles non-dollar locales.</summary>
|
||||
public string? SuperChatDisplayString { get; set; }
|
||||
|
||||
public bool IsMember { get; set; }
|
||||
public string? MembershipLevel { get; set; }
|
||||
|
||||
public ChatEventKind Kind { get; set; }
|
||||
|
||||
public string? SuperStickerDescription { get; set; }
|
||||
|
||||
/// <summary>membershipGiftingEvent: how many memberships were bought.</summary>
|
||||
public int GiftNewMembersCount { get; set; }
|
||||
|
||||
/// <summary>giftMembershipReceivedEvent: who gifted this one.</summary>
|
||||
public string? GifterName { get; set; }
|
||||
|
||||
/// <summary>memberMilestoneChatEvent: streak months (0 = YouTube didn't
|
||||
/// expose a count — the animation falls back to the message text).</summary>
|
||||
public int MilestoneMonths { get; set; }
|
||||
|
||||
/// <summary>TASK 41: the Test Stream dock injects synthetic member / SuperChat /
|
||||
/// subscriber events locally (YouTube's insert API only supports text), so the
|
||||
/// buffer can label them honestly — simulated events never reach YouTube.</summary>
|
||||
public bool IsSimulated { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace ytLive.Models;
|
||||
|
||||
/// <summary>One captured snapshot of a connected channel's YPP-relevant numbers
|
||||
/// (TASK 39). Appended on every refresh and archived in SQLite so a later slice
|
||||
/// can compute velocity/(de)acceleration/ETA from the history.</summary>
|
||||
public sealed class YppStatSnapshot
|
||||
{
|
||||
public DateTime CapturedAtUtc { get; set; }
|
||||
public long SubscriberCount { get; set; }
|
||||
public long VideoCount { get; set; }
|
||||
public long TotalViews { get; set; }
|
||||
public int UploadsLast90Days { get; set; }
|
||||
public bool CommunityGuidelinesGoodStanding { get; set; }
|
||||
public bool CopyrightStrikesGoodStanding { get; set; }
|
||||
public bool ContentIdClaimsGoodStanding { get; set; }
|
||||
public bool OverallGoodStanding { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
namespace ytLive.Models;
|
||||
|
||||
/// <summary>YPP eligibility thresholds as versioned, date-aware DATA — never
|
||||
/// constants (ai.md "Journey tracker"). Tier 1 (fan funding) is time-invariant;
|
||||
/// Tier 2 (ad revenue) doubles its long-form-hours and Shorts-view bars for NEW
|
||||
/// applicants after 2027-02-01. Hardcoding the pre-2027 numbers would show the
|
||||
/// wrong ETA once the doubling lands, so callers resolve the active Tier 2
|
||||
/// threshold through <see cref="Tier2For"/>.</summary>
|
||||
public static class YppThresholds
|
||||
{
|
||||
public sealed record TierThreshold(string Name, long Subscribers, long WatchHours, long ShortsViews);
|
||||
|
||||
/// <summary>The date the Tier-2 ad-revenue bars double for new applicants.</summary>
|
||||
public static readonly DateTime Tier2DoublingDate = new(2027, 2, 1);
|
||||
|
||||
/// <summary>Fan-funding tier (before the full ad/YouTube Premium revenue tier).</summary>
|
||||
public static TierThreshold Tier1 { get; } = new("Fan funding", 500, 3_000, 3_000_000);
|
||||
|
||||
/// <summary>The Tier-2 bar for a given point in time — 4,000h / 10M Shorts
|
||||
/// before the doubling date, 8,000h / 20M Shorts on or after it.</summary>
|
||||
public static TierThreshold Tier2For(DateTime when) => when >= Tier2DoublingDate
|
||||
? new TierThreshold("Ad revenue", 1_000, 8_000, 20_000_000)
|
||||
: new TierThreshold("Ad revenue", 1_000, 4_000, 10_000_000);
|
||||
|
||||
/// <summary>The currently-active Tier-2 threshold (resolved against today).</summary>
|
||||
public static TierThreshold Tier2Now => Tier2For(DateTime.UtcNow);
|
||||
|
||||
/// <summary>Valid public long-form uploads required in the trailing 90 days
|
||||
/// to keep each tier route eligible.</summary>
|
||||
public const int UploadsRequiredPer90Days = 3;
|
||||
}
|
||||
+2
-2
@@ -7,14 +7,14 @@ Plain data types. No logic beyond what a property can carry. See
|
||||
|------|---------|
|
||||
| `Scene.cs` | A scene: `Name`, `IsHidden`, `IsChatScene`, `IsEditing`, **`HasBackdrop`** (Live-only policy flag, default off — only the canonical Live scene has one, see `SceneCatalog`), `Elements` collection (images + webcam config), `WebcamConfig` accessor |
|
||||
| `SceneCatalog.cs` | The five canonical scenes (Starting/Live/BRB/Chat/Ending) — the product, by name: `All`, `IsCanonical`, `Is(name, canonical)` (case-insensitive trim), `IsChat`, `HasBackdrop(name)` (true only for Live). Drives the empty-DB seed, the "+" missing-scenes menu, and `MainViewModel.EnforceBackdropPolicy` |
|
||||
| `Source.cs` | A source: `SourceType` enum (DisplayCapture/WindowCapture/Background/Image/TextOverlay — **Webcam removed**), `X`/`Y`/`Width`/`Height`, `Opacity`, `IsEnabled`, `ImageSource` (static) / `VideoImageSource` (live frames), `DisplaySource` (`IsLiveCapture` → video, else static), `ClipShape` enum (Traditional/Round), `IsMirrored` + `MirrorScale`, `MirrorButtonText`/`ShapeButtonText`, asset identity, `IsImageSource` (XAML binds this, never `Type`), **backdrop surface**: `IsBackdrop` (permanent bottom layer, never removable), `CaptureKey` (persisted `monitor:<n>`/`window:<hwnd>`/`picker:<name>`), `IsLiveCapture` (`Type` is Display/WindowCapture) |
|
||||
| `Source.cs` | A source: `SourceType` enum (DisplayCapture/WindowCapture/Background/Image/TextOverlay/ChatBox/Countdown/WebSource/MediaSource/**AlertBox (TASK 43 — the celebration zone, one per layout)** — **Webcam removed**), `X`/`Y`/`Width`/`Height`, `Opacity`, `IsEnabled`, `ImageSource` (static) / `VideoImageSource` (live frames), `DisplaySource` (`IsLiveCapture` → video, else static), `ClipShape` enum (Traditional/Round), `IsMirrored` + `MirrorScale`, `MirrorButtonText`/`ShapeButtonText`, asset identity, `IsImageSource` (XAML binds this, never `Type`), **backdrop surface**: `IsBackdrop` (permanent bottom layer, never removable), `CaptureKey` (persisted `monitor:<n>`/`window:<hwnd>`/`picker:<name>`), `IsLiveCapture` (`Type` is Display/WindowCapture) |
|
||||
| `SceneElement.cs` | Base for anything placeable in a scene: shared layout/clip/mirror/border surface, `virtual IsWebcam`/`virtual IsImageSource`; `ToggleClipShape` + persisted `RectWidth`/`RectHeight` (pre-Round rect so round→rect restores after reload) |
|
||||
| `Webcam.cs` | Singleton webcam identity: `Id`, `DeviceId`, `Name` (one row app-wide) |
|
||||
| `WebcamSceneConfig.cs` | Per-scene webcam placement (subclass of `SceneElement`): geometry + `IsVisible` + border (`BorderColor`/`BorderOpacity`/`BorderWidth`/`BorderAnimation`) + `VideoImageSource`; `WebcamId` links to `Webcam` |
|
||||
| `QualityOption.cs` | Resolution tier record `(Display, Fps, Bitrate, Width, Height)`; `Label` formats as `1080p60 (8Mbps)` — drives the bottom-bar dropdown and the output-rect math |
|
||||
| `StreamConfig.cs` | `StreamConfig` (note: `TargetBitrate`/`Resolution` defaults are stale — not yet wired to the dropdown), `StreamHealth` (bitrate/FPS/dropped/duration), `StreamStatus` enum |
|
||||
| `MicStatus.cs` | Footer mic indicator: `NotConnected` / `Connected` / `Problem` — the state behind the MIC button's status dot (red = no device/capture off, green = capturing, yellow = a requested mic failed: in use/unplugged) |
|
||||
| `YouTube.cs` | `YouTubeChannel` and `ChatMessage` (chat feed) |
|
||||
| `YouTube.cs` | `YouTubeChannel` and `ChatMessage` (chat feed) — since TASK 43 every message carries `Kind` (`ChatEventKind` None/SuperChat/SuperSticker/NewMember/MemberGift/GiftReceived/MemberMilestone) + event payload fields (`SuperChatDisplayString`, `SuperStickerDescription`, `GiftNewMembersCount`, `GifterName`, `MilestoneMonths`) |
|
||||
| `Socials.cs` | **Social bar** (global layer, never a Source): `SocialService` enum (YouTube/Twitch/X/Instagram/TikTok/Facebook/Discord/Kick/Threads/Bluesky/GitHub/LinkedIn/Pinterest/Snapchat/Reddit/WhatsApp/Telegram/Link/Website/**Fediverse**), `SocialEntry` (Service/Handle/ProfileUrl/`FediverseSoftware`), `SocialsConfig` (Entries + `BarPosition` Top/Bottom + `BarEnabled` on/off — `BarJustify` dropped, column back-compat), `SocialServiceIcons` (canonical URL builder + `DetectService` (URL domain / fediverse `@user@domain` → **Fediverse** / bare→Website) + bundled SVG logo path data per service (`LogoDataFor`) + `LogoDataForFediverse(software)` mapping nodeinfo software names (mastodon/peertube/pixelfed/misskey/lemmy/pleroma/firefish) to logos with a generic fediverse glyph fallback (`FediverseIconData`) + `LockedIconData`/`DoNotIconData` — initials/colors gone) |
|
||||
|
||||
Related: [`ViewModels/index.md`](../ViewModels/index.md) consume these;
|
||||
|
||||
+1133
File diff suppressed because it is too large
Load Diff
@@ -28,6 +28,14 @@ ytLlive assumes you already have a YouTube creator account — connecting uses "
|
||||
to link that existing account. **ytLlive does not create accounts.** If you don't have a YouTube
|
||||
channel yet, set one up on YouTube first, then connect it here.
|
||||
|
||||
## Audio Assumption
|
||||
|
||||
ytLlive uses your **default** microphone and desktop/game output — it never hunts for a device or
|
||||
contends for one. **It assumes your audio is already configured and working** (default input and
|
||||
output set, not hijacked by another app). Audio issues are a Windows/device matter outside ytLlive's
|
||||
scope: we don't debug, detect, or try to fix them. If it doesn't sound right, fix the system's audio
|
||||
settings first — it's on the OS, not on us.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
@@ -37,25 +45,28 @@ dotnet run
|
||||
|
||||
## Version Roadmap
|
||||
|
||||
| Version | Scope |
|
||||
|---------|-------|
|
||||
| v0.1 | Scene/source management, YouTube RTMP ingest, YouTube OAuth2, live chat, stream health |
|
||||
| v0.2 | Recording to local file |
|
||||
| v0.3 | Stream scheduling |
|
||||
| v0.4 | Multi-destination restreaming |
|
||||
| 1.0 | General availability |
|
||||
**v1 is the finished product** (creator ruling, 2026-09-01 — there are no v1.x releases).
|
||||
Everything still queueing lands in v1; anything not on the feature list is on the closed
|
||||
**"Out of product — permanently"** list in `TASKS.md` — read that page before asking for a feature.
|
||||
|
||||
What v1 ships: the five-scene director's control surface, webcam + screen capture, media + web +
|
||||
text sources, chat on-stream, the social bar, mic/desktop audio with auto-duck and TRAX, local
|
||||
recording, variable quality tiers with auto step-down, one-click go-live + scheduling, stream
|
||||
resilience (auto-reconnect inside YouTube's grace, one-click back on air), built-in monetization
|
||||
awareness (reward capture, session reports, YPP journey tracker), Alerts — all free and ungated;
|
||||
the one-time license only removes the branding flash.
|
||||
|
||||
## Structure
|
||||
|
||||
| Path | Role |
|
||||
|------|------|
|
||||
| `Models/` | Scene, Source, QualityOption, StreamConfig, StreamHealth, YouTube channel/chat, Socials (social bar) |
|
||||
| `ViewModels/` | MainViewModel — scenes, stream controls, chat; GoLiveViewModel, ReuseImageViewModel, SocialsDialogViewModel |
|
||||
| `Services/` | YouTubeAuthService (OAuth2), YouTubeStreamService (broadcast/health), YouTubeChatService (chat polling), LayoutStore (SQLite), SocialValidator (social link/fediverse validation + nodeinfo icon resolution) |
|
||||
| `Helpers/` | ViewModelBase, RelayCommand, ImageCache, AppLog, FocusPreservingListBox, OAuthCredentials |
|
||||
| `Models/` | Scene, Source, WebcamSceneConfig, SceneElement, SceneCatalog, QualityOption, StreamConfig/Health, chat, Socials, BroadcastMetadata, Music, MicStatus |
|
||||
| `ViewModels/` | MainViewModel (partial classes per area) + dialogs: GoLive, CameraPicker, MicPicker, HotkeyConfig, Socials, LiveBroadcastForm |
|
||||
| `Services/` | YouTube (OAuth, streams/broadcasts, chat polling), LayoutStore (SQLite), capture (camera/screen-capture/media-file via FFmpeg), SceneCompositor + FramePump, FFmpeg encoder + RTMP, audio pipeline (WASAPI loopback + mic, mixer, filters, ducker, limiter, TRAX), SocialValidator, notifications, hotkeys, SceneGraph, ChatOverlayLayer |
|
||||
| `Helpers/` | ViewModelBase, RelayCommand, ImageCache, AppLog, TokenStore (DPAPI), OAuthCredentials, converters |
|
||||
| `Themes/` | `Controls.xaml` — the dark-theme control styles, merged once in `App.xaml` |
|
||||
| `MainWindow.xaml` | Dark-theme main UI: scene/source panel, preview, chat, status bar |
|
||||
| `SocialsDialog.xaml` | Social Media Site Promotion dialog — 6-slot social bar editor with sign-in gate and validation |
|
||||
| `MainWindow.xaml` | Dark-theme control surface: thumbnails + central preview, two-state left panel (layers/properties ↔ live chat), top bar, footer |
|
||||
| `Distribution.md` | GA-time plan: signing, installer, EULA, hardening — executed as TASK 36 |
|
||||
|
||||
## Docs (memory map)
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<Window x:Class="ytLive.RenameRecordingDialog"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Title="Rename Recording" Height="230" Width="460"
|
||||
Icon="/Assets/llamacasty-icon.png"
|
||||
Title="Rename Recording" Height="304" Width="460"
|
||||
Icon="/ytLive;component/Assets/llamacasty-icon.png"
|
||||
WindowStartupLocation="CenterOwner" ResizeMode="NoResize"
|
||||
ShowInTaskbar="False" Background="#1a1a2e">
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
Margin="0,20,0,0">
|
||||
<Button Content="Cancel" Click="Cancel_Click"
|
||||
Style="{StaticResource YtButtonSecondary}" Margin="0,0,8,0"/>
|
||||
<Button Content="Save" Click="Save_Click" Style="{StaticResource YtButton}" MinWidth="90"/>
|
||||
<Button Content="Save" Click="Save_Click" Style="{StaticResource YtButton}" MinWidth="90" x:Name="SaveRecordingButton"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Window>
|
||||
|
||||
@@ -6,12 +6,16 @@ namespace ytLive;
|
||||
|
||||
/// <summary>Modal that lets the creator rename an auto-named recording before it is
|
||||
/// finalized. Returns the chosen file stem (no extension) via <see cref="ResultFileName"/>,
|
||||
/// or <c>null</c> on Cancel / keep-auto-name.</summary>
|
||||
/// or <c>null</c> when the creator cleared the box and hit Save (keep the auto name).
|
||||
/// <para><c>DialogResult == false</c> means DISCARD — the caller deletes the footage
|
||||
/// rather than saving it (CREATOR RULING 2026-09-26). Enter on the pre-filled default
|
||||
/// is a Save.</para></summary>
|
||||
public partial class RenameRecordingDialog : Window
|
||||
{
|
||||
private readonly string _autoStem;
|
||||
|
||||
/// <summary>Stem (no extension) the creator chose, or <c>null</c> to keep auto-name.</summary>
|
||||
/// <summary>Stem (no extension) the creator chose, or <c>null</c> when the box was
|
||||
/// cleared on an explicit Save. Never set on Cancel.</summary>
|
||||
public string? ResultFileName { get; private set; }
|
||||
|
||||
public RenameRecordingDialog(string autoStem)
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using ytLive.Helpers;
|
||||
using ytLive.Services.Audio;
|
||||
using ytLive.Services.Encoder;
|
||||
|
||||
namespace ytLive.Services;
|
||||
|
||||
/// <summary>
|
||||
/// TASK 47: one alert clip's playback — video frames + on-stream audio from the
|
||||
/// same file, both paced to real time. The alert box owns a decoder per event
|
||||
/// (start at play, dispose at drain) — unlike MediaSource's app-wide refcounted
|
||||
/// session, an alert REPLAYS the same clip each event, so a fresh decode per
|
||||
/// play is required. Video is rawvideo BGRA scaled to the box rect (the
|
||||
/// <see cref="MediaVideoSource"/> shape); audio is ffmpeg <c>f32le</c> at the
|
||||
/// mixer's native 48 kHz stereo (so the mixer ring needs no resampler). The
|
||||
/// fade is applied by the alert layer, not here.
|
||||
/// </summary>
|
||||
public interface IAlertClipDecoder : IDisposable
|
||||
{
|
||||
/// <summary>Starts the decode loop (video + audio); raises the events off the
|
||||
/// calling thread. Paced to real time so playback matches wall clock.</summary>
|
||||
void Start();
|
||||
|
||||
/// <summary>Stops and kills any running decode subprocesses.</summary>
|
||||
void Stop();
|
||||
|
||||
/// <summary>One decoded video frame (box-sized, straight-alpha BGRA).</summary>
|
||||
event Action<VideoFrame>? FrameAvailable;
|
||||
|
||||
/// <summary>One chunk of on-stream audio (48 kHz stereo float).</summary>
|
||||
event Action<AudioSample>? AudioReady;
|
||||
|
||||
/// <summary>Raised once when both streams have fully played out.</summary>
|
||||
event Action? Completed;
|
||||
}
|
||||
|
||||
/// <summary>Default <see cref="IAlertClipDecoder"/>: two ffmpeg subprocesses on
|
||||
/// the same file. Video via <see cref="RawVideoFrameReader"/> on a <c>rawvideo
|
||||
/// bgra</c> pipe (-an), audio via an <c>f32le -ac 2 -ar 48000</c> pipe (-vn).
|
||||
/// The seams (locator, process factory, frame-rate probe, delay) let tests drive
|
||||
/// a fake decoder without a real codec or Windows.</summary>
|
||||
public sealed class AlertClipDecoder : IAlertClipDecoder
|
||||
{
|
||||
public event Action<VideoFrame>? FrameAvailable;
|
||||
public event Action<AudioSample>? AudioReady;
|
||||
public event Action? Completed;
|
||||
|
||||
private const int AudioSampleRate = 48000;
|
||||
private const int AudioChannels = 2;
|
||||
private const int AudioChunkFloats = 4800; // 50ms of stereo
|
||||
|
||||
private readonly string _path;
|
||||
private readonly int _width;
|
||||
private readonly int _height;
|
||||
private readonly IFfmpegLocator _locator;
|
||||
private readonly Func<IDecodeProcess> _processFactory;
|
||||
private readonly IFrameRateProbe? _probe;
|
||||
private readonly Func<TimeSpan, CancellationToken, Task> _delay;
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
private Task? _run;
|
||||
|
||||
public AlertClipDecoder(
|
||||
string path,
|
||||
int width,
|
||||
int height,
|
||||
IFfmpegLocator locator,
|
||||
Func<IDecodeProcess> processFactory,
|
||||
IFrameRateProbe? frameRateProbe = null,
|
||||
Func<TimeSpan, CancellationToken, Task>? delay = null)
|
||||
{
|
||||
_path = path;
|
||||
_width = width;
|
||||
_height = height;
|
||||
_locator = locator;
|
||||
_processFactory = processFactory;
|
||||
_probe = frameRateProbe;
|
||||
_delay = delay ?? ((t, ct) => Task.Delay(t, ct));
|
||||
}
|
||||
|
||||
public void Start()
|
||||
{
|
||||
if (_run != null) return;
|
||||
_run = Task.Run(RunAsync, CancellationToken.None);
|
||||
}
|
||||
|
||||
public void Stop() => _cts.Cancel();
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cts.Cancel();
|
||||
_cts.Dispose();
|
||||
}
|
||||
|
||||
private async Task RunAsync()
|
||||
{
|
||||
var ct = _cts.Token;
|
||||
var videoFrames = 0;
|
||||
var audioChunks = 0;
|
||||
var failed = false;
|
||||
try
|
||||
{
|
||||
double? frameRate = null;
|
||||
if (_probe != null)
|
||||
frameRate = await _probe.ProbeAsync(_path, ct).ConfigureAwait(false);
|
||||
var frameDuration = frameRate is > 0 ? TimeSpan.FromSeconds(1.0 / frameRate.Value) : TimeSpan.Zero;
|
||||
|
||||
var ffmpegPath = await _locator.LocateAsync(ct).ConfigureAwait(false);
|
||||
|
||||
var videoTask = RunVideoAsync(ffmpegPath, frameDuration, ct);
|
||||
var audioTask = RunAudioAsync(ffmpegPath, ct);
|
||||
videoFrames = await videoTask.ConfigureAwait(false);
|
||||
audioChunks = await audioTask.ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
failed = true;
|
||||
return;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// A failed decode is not fatal: the alert layer falls back to the six
|
||||
// animations (or the frame it already holds) exactly as a missing file.
|
||||
// Logged so a silent blank box traces back to a decode failure.
|
||||
failed = true;
|
||||
AppLog.Write(ex, $"Alert clip decode failed ({Path.GetFileName(_path)})");
|
||||
}
|
||||
|
||||
if (!ct.IsCancellationRequested)
|
||||
{
|
||||
AppLog.Write($"Alert clip end: {Path.GetFileName(_path)} frames={videoFrames} audioChunks={audioChunks} failed={failed}");
|
||||
Completed?.Invoke();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<int> RunVideoAsync(string ffmpegPath, TimeSpan frameDuration, CancellationToken ct)
|
||||
{
|
||||
var args =
|
||||
$"-hide_banner -loglevel error -i \"{_path}\" " +
|
||||
$"-f rawvideo -pix_fmt bgra -vf scale={_width}:{_height} -an -";
|
||||
using var process = _processFactory();
|
||||
process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = ffmpegPath,
|
||||
Arguments = args,
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = true,
|
||||
});
|
||||
|
||||
var reader = new RawVideoFrameReader(_width, _height);
|
||||
var buffer = new byte[65536];
|
||||
var stream = process.StandardOutput;
|
||||
var frames = 0;
|
||||
while (!ct.IsCancellationRequested && !process.HasExited)
|
||||
{
|
||||
var read = await stream.ReadAsync(buffer, 0, buffer.Length, ct).ConfigureAwait(false);
|
||||
if (read <= 0) break;
|
||||
var chunk = new byte[read];
|
||||
Buffer.BlockCopy(buffer, 0, chunk, 0, read);
|
||||
foreach (var frame in reader.Feed(chunk))
|
||||
{
|
||||
frames++;
|
||||
FrameAvailable?.Invoke(frame);
|
||||
if (frameDuration > TimeSpan.Zero)
|
||||
await _delay(frameDuration, ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
return frames;
|
||||
}
|
||||
|
||||
private async Task<int> RunAudioAsync(string ffmpegPath, CancellationToken ct)
|
||||
{
|
||||
var args =
|
||||
$"-hide_banner -loglevel error -i \"{_path}\" " +
|
||||
$"-f f32le -ac {AudioChannels} -ar {AudioSampleRate} -vn -";
|
||||
using var process = _processFactory();
|
||||
process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = ffmpegPath,
|
||||
Arguments = args,
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = true,
|
||||
});
|
||||
|
||||
var stream = process.StandardOutput;
|
||||
var raw = new byte[65536];
|
||||
var carry = new byte[0];
|
||||
var floatScratch = new float[AudioChunkFloats];
|
||||
var floatIndex = 0;
|
||||
var chunks = 0;
|
||||
// The pipe is byte-delimited only: a read may split a float, so leftover
|
||||
// bytes are carried into the next read instead of dropped (a dropped
|
||||
// half-float would desync the entire stream).
|
||||
while (!ct.IsCancellationRequested && !process.HasExited)
|
||||
{
|
||||
var read = await stream.ReadAsync(raw, 0, raw.Length, ct).ConfigureAwait(false);
|
||||
if (read <= 0) break;
|
||||
var total = carry.Length + read;
|
||||
var bytes = total <= raw.Length ? raw : new byte[total];
|
||||
if (carry.Length > 0)
|
||||
{
|
||||
Buffer.BlockCopy(carry, 0, bytes, 0, carry.Length);
|
||||
Buffer.BlockCopy(raw, 0, bytes, carry.Length, read);
|
||||
}
|
||||
var floatsTo = total - (total % 4);
|
||||
for (var i = 0; i < floatsTo && floatIndex < floatScratch.Length; i += 4)
|
||||
floatScratch[floatIndex++] = BitConverter.ToSingle(bytes, i);
|
||||
carry = total > floatsTo ? bytes[floatsTo..total] : new byte[0];
|
||||
|
||||
while (floatIndex == floatScratch.Length)
|
||||
{
|
||||
chunks++;
|
||||
AudioReady?.Invoke(new AudioSample(floatScratch, AudioSampleRate, AudioChannels));
|
||||
// Pace the NEXT chunk so the whole clip's audio lands at real-time
|
||||
// cadence (the fade in the alert layer tracks the same wall clock).
|
||||
await _delay(TimeSpan.FromSeconds(AudioChunkFloats / (double)(AudioSampleRate * AudioChannels)), ct).ConfigureAwait(false);
|
||||
floatScratch = new float[AudioChunkFloats];
|
||||
floatIndex = 0;
|
||||
}
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,541 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Windows;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Imaging;
|
||||
using System.Windows.Threading;
|
||||
using ytLive.Helpers;
|
||||
using ytLive.Models;
|
||||
using ytLive.Services.Audio;
|
||||
using ytLive.Services.Compositor;
|
||||
|
||||
namespace ytLive.Services;
|
||||
|
||||
/// <summary>
|
||||
/// The native alert box (OBS-style celebration zone, no third party). Owns the
|
||||
/// event queue and the animation clock: <see cref="ChatEventKind"/> events from
|
||||
/// the chat feed are enqueued one-at-a-time and played through a celebration
|
||||
/// animation (<see cref="AlertRenderer"/>) OR — when the alert-box video is
|
||||
/// configured and resolves — through a decoded clip. Frames are served to the
|
||||
/// stream compositor via <see cref="RenderFrame"/> and mirrored to the preview
|
||||
/// via <see cref="UpdatePreview"/>. Idle = transparent (null frame); playback is
|
||||
/// advanced by a 33ms dispatch ticker. The clock is exposed as
|
||||
/// <see cref="Advance"/> so tests drive playback deterministically without the
|
||||
/// timer. Ordinary chat rows (<see cref="ChatEventKind.None"/>) never enqueue —
|
||||
/// a subscriber mention is a chat row, not an alert.
|
||||
///
|
||||
/// TASK 47 (Stream Alerts video): a video-backed alert replaces the animation
|
||||
/// while a clip decodes (<see cref="IAlertClipDecoder"/>, one per play). The
|
||||
/// clip plays at its own media pace; the layer applies a ~300ms fade-in/out
|
||||
/// alpha envelope to the frames AND the audio samples (freeze-frame fade-out on
|
||||
/// the tail), clips the alert to the clip length, and forwards the scaled audio
|
||||
/// to the mixer's alert ring via <paramref name="audioSink"/> (no duck). The
|
||||
/// per-source config (path/default/ticker/volume) is resolved through
|
||||
/// <paramref name="clipPathResolver"/>; the ticker strip is served via
|
||||
/// <see cref="AlertTickerFrame"/>.
|
||||
/// </summary>
|
||||
public sealed class AlertOverlayLayer : IDisposable
|
||||
{
|
||||
/// <summary>The fade-in/out ramp length (video alpha AND audio gain). Measured
|
||||
/// against a real clip in the AlertLayerVideoTests unit; keep the ramp short
|
||||
/// enough to read as a "pop-in" on stream (see ai.md).</summary>
|
||||
internal const double FadeDurationSeconds = 0.30;
|
||||
|
||||
/// <summary>How long an alert waits for the first decoded frame or audio slab
|
||||
/// before treating the clip as unplayable and falling back to the animations
|
||||
/// (a silent decoder must never blank the alert box).</summary>
|
||||
internal const double NoFrameFallbackSeconds = 1.0;
|
||||
|
||||
private static readonly TimeSpan TickInterval = TimeSpan.FromMilliseconds(33);
|
||||
|
||||
private readonly AlertRenderer _renderer;
|
||||
private readonly Func<string, int, int, IAlertClipDecoder>? _clipDecoderFactory;
|
||||
private readonly Func<Source, string?>? _clipPathResolver;
|
||||
private readonly Action<AudioSample>? _audioSink;
|
||||
private readonly Action<VideoFrame?>? _tickerPreviewSink;
|
||||
private readonly Queue<ChatMessage> _queue = new();
|
||||
private readonly DispatcherTimer? _ticker;
|
||||
|
||||
private ChatMessage? _current;
|
||||
private Source? _currentBox;
|
||||
private IAlertClipDecoder? _clip;
|
||||
private VideoFrame? _latestClipFrame;
|
||||
private bool _clipEof;
|
||||
private double _clipEofElapsed;
|
||||
private int _clipWidth;
|
||||
private int _clipHeight;
|
||||
private bool _clipSawFrame;
|
||||
private bool _clipAudioHeard;
|
||||
private string? _tickerText;
|
||||
private AlertDisplayMethod _tickerMethod = AlertDisplayMethod.TickerScroll;
|
||||
private double _elapsed;
|
||||
private IEnumerable<Scene>? _scenes;
|
||||
|
||||
private int _contentVersion;
|
||||
private int _frameVersion = -1;
|
||||
private string? _frameKey;
|
||||
private VideoFrame? _cachedFrame;
|
||||
private DateTime _lastPreviewLog = DateTime.MinValue;
|
||||
|
||||
public AlertOverlayLayer(
|
||||
AlertRenderer renderer,
|
||||
Func<string, int, int, IAlertClipDecoder>? clipDecoderFactory = null,
|
||||
Func<Source, string?>? clipPathResolver = null,
|
||||
Action<AudioSample>? audioSink = null,
|
||||
Action<VideoFrame?>? tickerPreviewSink = null)
|
||||
{
|
||||
_renderer = renderer;
|
||||
_clipDecoderFactory = clipDecoderFactory;
|
||||
_clipPathResolver = clipPathResolver;
|
||||
_audioSink = audioSink;
|
||||
_tickerPreviewSink = tickerPreviewSink;
|
||||
if (Application.Current != null)
|
||||
{
|
||||
_ticker = new DispatcherTimer { Interval = TickInterval };
|
||||
_ticker.Tick += (_, _) =>
|
||||
{
|
||||
Advance(TickInterval.TotalSeconds);
|
||||
RefreshAlertPreviews();
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Production entry from the chat feed: enqueue one event. Messages
|
||||
/// arrive on the chat poller thread, so this marshals to the UI thread (same
|
||||
/// rule as <see cref="ChatOverlayLayer.OnMessageReceived"/>) — the preview
|
||||
/// writes create WPF <see cref="WriteableBitmap"/> instances, and creating a
|
||||
/// DependencySource off the UI thread crashes the WPF binding engine
|
||||
/// (ArgumentException "Must create DependencySource on same Thread").</summary>
|
||||
public void OnMessageReceived(ChatMessage message, IEnumerable<Scene> scenes)
|
||||
{
|
||||
var app = Application.Current;
|
||||
if (app != null && !app.Dispatcher.CheckAccess())
|
||||
{
|
||||
app.Dispatcher.Invoke(() => Ingest(message, scenes));
|
||||
return;
|
||||
}
|
||||
Ingest(message, scenes);
|
||||
}
|
||||
|
||||
private void Ingest(ChatMessage message, IEnumerable<Scene> scenes)
|
||||
{
|
||||
_scenes = scenes;
|
||||
Enqueue(message);
|
||||
_ticker?.Start();
|
||||
RefreshAlertPreviews();
|
||||
}
|
||||
|
||||
/// <summary>Queue an event (pure — also the test seam). Chat rows carry
|
||||
/// <see cref="ChatEventKind.None"/> and are ignored: no alert for a mention.</summary>
|
||||
public void Enqueue(ChatMessage message)
|
||||
{
|
||||
if (message.Kind == ChatEventKind.None) return;
|
||||
if (_queue.Count >= 10) return; // back-to-back floods drop the tail, never stall playback
|
||||
_queue.Enqueue(message);
|
||||
if (_current == null)
|
||||
{
|
||||
_current = _queue.Dequeue();
|
||||
_elapsed = 0;
|
||||
BeginClip();
|
||||
}
|
||||
_contentVersion++;
|
||||
}
|
||||
|
||||
/// <summary>Advance the playback clock (the ticker's beat — and the test seam;
|
||||
/// tests call this directly and never start the timer). Draining the queue to
|
||||
/// nothing stops the ticker and leaves the box transparent.</summary>
|
||||
public void Advance(double deltaSeconds)
|
||||
{
|
||||
// Video-backed alert: duration is clip-driven. The clip plays at media
|
||||
// pace; once it EOFs the last frame sweeps through the fade-out before
|
||||
// the alert moves on (a hard cut at EOF would pop, not fade).
|
||||
if (_clip != null)
|
||||
{
|
||||
_elapsed += deltaSeconds;
|
||||
// A decoder that starts but produces not a single frame (or slab of
|
||||
// audio) is dead weight — the box would sit transparent for the whole
|
||||
// alert. Give it a short grace, then fall back to the animations so an
|
||||
// alert ALWAYS renders (the live decode failure at 2026-09-26 11:44 was
|
||||
// this exact void, invisible in the log).
|
||||
if (!_clipSawFrame && !_clipAudioHeard && _elapsed >= NoFrameFallbackSeconds)
|
||||
{
|
||||
System.Diagnostics.Debug.WriteLine("Alert clip yielded no frames in 1s — falling back to the animation");
|
||||
StopClip();
|
||||
_elapsed = 0;
|
||||
_contentVersion++;
|
||||
return;
|
||||
}
|
||||
if (_clipEof && _elapsed - _clipEofElapsed >= FadeDurationSeconds)
|
||||
{
|
||||
_clipEof = false;
|
||||
StopClip();
|
||||
_current = null;
|
||||
_elapsed = 0;
|
||||
AdvanceToNext();
|
||||
}
|
||||
_contentVersion++;
|
||||
return;
|
||||
}
|
||||
|
||||
if (_current == null)
|
||||
{
|
||||
if (_queue.Count == 0) return;
|
||||
_current = _queue.Dequeue();
|
||||
_elapsed = 0;
|
||||
BeginClip();
|
||||
_contentVersion++;
|
||||
return;
|
||||
}
|
||||
|
||||
_elapsed += deltaSeconds;
|
||||
if (_elapsed >= DurationOf(_current))
|
||||
{
|
||||
_current = null;
|
||||
_elapsed = 0;
|
||||
AdvanceToNext();
|
||||
}
|
||||
_contentVersion++;
|
||||
}
|
||||
|
||||
/// <summary>Whether an alert is currently playing or queued.</summary>
|
||||
public bool IsPlaying => _current != null || _queue.Count > 0;
|
||||
|
||||
/// <summary>The live-output frame for one AlertBox element: the decoded clip
|
||||
/// frame (faded) while a clip is active, else the animation frame, null
|
||||
/// (transparent) when idle.</summary>
|
||||
public VideoFrame? RenderFrame(Source alertBox)
|
||||
{
|
||||
if (_current == null)
|
||||
{
|
||||
_frameKey = null;
|
||||
return null;
|
||||
}
|
||||
|
||||
if (_clip != null)
|
||||
return RenderClipFrame(alertBox);
|
||||
|
||||
var width = (int)Math.Max(1, alertBox.Width);
|
||||
var height = (int)Math.Max(1, alertBox.Height);
|
||||
var key = $"{width}x{height}|{_current.Id}|{Progress()}";
|
||||
|
||||
if (_frameKey == key && _frameVersion == _contentVersion)
|
||||
return _cachedFrame;
|
||||
|
||||
var app = Application.Current;
|
||||
if (app != null && !app.Dispatcher.CheckAccess())
|
||||
{
|
||||
var capturedKey = key;
|
||||
var capturedVersion = _contentVersion;
|
||||
return app.Dispatcher.Invoke(() =>
|
||||
{
|
||||
if (_frameKey == capturedKey && _frameVersion == capturedVersion)
|
||||
return _cachedFrame;
|
||||
return RenderCore(alertBox, width, height, capturedKey, capturedVersion);
|
||||
});
|
||||
}
|
||||
|
||||
return RenderCore(alertBox, width, height, key, _contentVersion);
|
||||
}
|
||||
|
||||
/// <summary>The ticker strip as the alert box shows it: a marquee, a pulse or a
|
||||
/// static pill naming the event while an alert plays, else null. Pure byte-math
|
||||
/// per call so the frame pump can read it on any thread (the text strip is
|
||||
/// rasterized once on the UI thread and cached).
|
||||
/// <para><b>Creator ruling 2026-09-26:</b> the strip is composited INSIDE the
|
||||
/// Stream Alerts box, not as a full-width bar at the top edge of the frame — "the
|
||||
/// ticker should appear over the stream alerts video, not over the entire preview
|
||||
/// window". So the frame is rendered at the box's own size and carries the box's
|
||||
/// origin on <see cref="VideoFrame.Placement"/>; every blit site reads
|
||||
/// <see cref="VideoFrame.OriginX"/>/<see cref="VideoFrame.OriginY"/> instead of
|
||||
/// a hardcoded 0. <b>No alert box in the scene means no ticker at all</b> — there
|
||||
/// is no longer a global position for it to live in.</para></summary>
|
||||
public VideoFrame? AlertTickerFrame
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_current == null || string.IsNullOrWhiteSpace(_tickerText)) return null;
|
||||
var box = _currentBox;
|
||||
if (box == null) return null;
|
||||
|
||||
var w = (int)Math.Max(1, Math.Round(box.Width));
|
||||
var h = (int)Math.Max(1, Math.Min(
|
||||
Math.Round(box.Height), AlertTickerRenderer.Height));
|
||||
var frame = AlertTickerRenderer.Render(
|
||||
_tickerText!, _elapsed, _tickerMethod, AlertSeconds(),
|
||||
TickerReadsPerAlert, w, h);
|
||||
return frame == null
|
||||
? null
|
||||
: new VideoFrame(frame.Width, frame.Height, frame.BgraPixels)
|
||||
{
|
||||
Placement = ((int)Math.Round(box.X), (int)Math.Round(box.Y)),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>How many times the marquee must cross the frame per alert, so the
|
||||
/// announcement is actually readable inside a 10s clip (2026-09-26: the old
|
||||
/// fixed 140px/s took ~17s per pass, so a 10s alert showed it barely once).</summary>
|
||||
internal const int TickerReadsPerAlert = 3;
|
||||
|
||||
/// <summary>Assumed alert length for the marquee rhythm. The clip's real length
|
||||
/// isn't known until it EOFs, and the shipped default clip is 10s — the number
|
||||
/// the creator's "three reads" target is stated against.</summary>
|
||||
internal const double AssumedAlertSeconds = 10.0;
|
||||
|
||||
private double AlertSeconds() => _clip != null ? AssumedAlertSeconds : DurationOf(_current!);
|
||||
|
||||
|
||||
private VideoFrame? RenderClipFrame(Source alertBox)
|
||||
{
|
||||
var frame = _latestClipFrame;
|
||||
if (frame == null) return null;
|
||||
|
||||
var w = (int)Math.Max(1, alertBox.Width);
|
||||
var h = (int)Math.Max(1, alertBox.Height);
|
||||
if (frame.Width != w || frame.Height != h)
|
||||
frame = StretchMath.BilinearScale(frame, w, h);
|
||||
|
||||
var fade = FadeAt();
|
||||
if (fade >= 1.0) return frame;
|
||||
|
||||
// Straight-alpha source-over on the compositor side: scale the source
|
||||
// alpha by the envelope so the clip blends out (a 680x200 copy per fade
|
||||
// tick is trivial; outside the ramps fade==1 returns the raw frame).
|
||||
var copy = new byte[frame.BgraPixels.Length];
|
||||
Buffer.BlockCopy(frame.BgraPixels, 0, copy, 0, copy.Length);
|
||||
var a = (byte)(fade * 255 + 0.5);
|
||||
for (var i = 3; i < copy.Length; i += 4)
|
||||
copy[i] = (byte)((copy[i] * a) >> 8);
|
||||
return new VideoFrame(frame.Width, frame.Height, copy);
|
||||
}
|
||||
|
||||
private double FadeAt()
|
||||
{
|
||||
if (_clip == null) return 1.0;
|
||||
if (_clipEof)
|
||||
return Math.Clamp(1.0 - (_elapsed - _clipEofElapsed) / FadeDurationSeconds, 0.0, 1.0);
|
||||
return Math.Clamp(_elapsed / FadeDurationSeconds, 0.0, 1.0);
|
||||
}
|
||||
|
||||
private void BeginClip()
|
||||
{
|
||||
StopClip();
|
||||
_tickerText = null;
|
||||
if (_current == null) return;
|
||||
|
||||
var box = _scenes?.SelectMany(s => s.Elements).OfType<Source>()
|
||||
.FirstOrDefault(s => s.Type == SourceType.AlertBox);
|
||||
_currentBox = box;
|
||||
if (box != null && box.AlertShowTicker)
|
||||
{
|
||||
_tickerText = ComposeTicker(_current);
|
||||
_tickerMethod = box.AlertDisplayMethod;
|
||||
}
|
||||
|
||||
if (box == null || _clipDecoderFactory == null) return;
|
||||
var path = _clipPathResolver?.Invoke(box);
|
||||
if (string.IsNullOrWhiteSpace(path)) return;
|
||||
|
||||
_clipWidth = (int)Math.Max(1, box.Width);
|
||||
_clipHeight = (int)Math.Max(1, box.Height);
|
||||
try
|
||||
{
|
||||
_clipSawFrame = false;
|
||||
_clipAudioHeard = false;
|
||||
_clip = _clipDecoderFactory(path, _clipWidth, _clipHeight);
|
||||
_clip.FrameAvailable += OnClipFrame;
|
||||
_clip.AudioReady += OnClipAudio;
|
||||
_clip.Completed += OnClipCompleted;
|
||||
_clip.Start();
|
||||
AppLog.Write($"Alert clip start: box {box.Id} path '{path}' {_clipWidth}x{_clipHeight}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLog.Write(ex, "Alert clip setup failed");
|
||||
StopClip(); // decode setup failed (bad path) — fall back to animations
|
||||
}
|
||||
}
|
||||
|
||||
private void StopClip()
|
||||
{
|
||||
var clip = _clip;
|
||||
_clip = null;
|
||||
_latestClipFrame = null;
|
||||
_clipEof = false;
|
||||
_clipSawFrame = false;
|
||||
_clipAudioHeard = false;
|
||||
if (clip == null) return;
|
||||
clip.FrameAvailable -= OnClipFrame;
|
||||
clip.AudioReady -= OnClipAudio;
|
||||
clip.Completed -= OnClipCompleted;
|
||||
try { clip.Stop(); } catch { /* already stopped */ }
|
||||
clip.Dispose();
|
||||
}
|
||||
|
||||
private void OnClipFrame(VideoFrame frame)
|
||||
{
|
||||
_clipSawFrame = true;
|
||||
_latestClipFrame = frame;
|
||||
}
|
||||
|
||||
private void OnClipCompleted()
|
||||
{
|
||||
_clipEof = true;
|
||||
_clipEofElapsed = _elapsed;
|
||||
}
|
||||
|
||||
private void OnClipAudio(AudioSample sample)
|
||||
{
|
||||
if (_audioSink == null) return;
|
||||
_clipAudioHeard = true;
|
||||
var volume = (float)Math.Clamp(_currentBox?.AlertVideoVolume ?? 1.0, 0.0, 1.0);
|
||||
if (volume <= 0f) return;
|
||||
var fade = FadeAt();
|
||||
if (fade <= 0.0) return;
|
||||
|
||||
var data = sample.Samples;
|
||||
if (fade < 1.0 || Math.Abs(volume - 1f) > 0.001f)
|
||||
{
|
||||
data = new float[data.Length];
|
||||
var gain = volume * (float)fade;
|
||||
for (var i = 0; i < sample.Samples.Length; i++)
|
||||
data[i] = sample.Samples[i] * gain;
|
||||
}
|
||||
_audioSink(new AudioSample(data, sample.SampleRate, sample.Channels));
|
||||
}
|
||||
|
||||
private void AdvanceToNext()
|
||||
{
|
||||
if (_queue.Count > 0)
|
||||
{
|
||||
_current = _queue.Dequeue();
|
||||
_elapsed = 0;
|
||||
BeginClip();
|
||||
}
|
||||
else
|
||||
{
|
||||
_ticker?.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
private VideoFrame? RenderCore(Source alertBox, int width, int height, string key, int version)
|
||||
{
|
||||
_frameKey = key;
|
||||
_frameVersion = version;
|
||||
if (_current == null) return _cachedFrame = null;
|
||||
return _cachedFrame = _renderer.Render(_current, Progress(), width, height);
|
||||
}
|
||||
|
||||
private double Progress()
|
||||
{
|
||||
if (_current == null) return 0;
|
||||
return Math.Clamp(_elapsed / DurationOf(_current), 0.0, 1.0);
|
||||
}
|
||||
|
||||
private static double DurationOf(ChatMessage m) => m.Kind switch
|
||||
{
|
||||
ChatEventKind.SuperChat => 4.6,
|
||||
ChatEventKind.SuperSticker => 4.2,
|
||||
ChatEventKind.NewMember => 4.0,
|
||||
ChatEventKind.MemberGift => 5.0,
|
||||
ChatEventKind.GiftReceived => 4.4,
|
||||
ChatEventKind.MemberMilestone => 4.8,
|
||||
_ => 4.0,
|
||||
};
|
||||
|
||||
private static string ComposeTicker(ChatMessage m)
|
||||
{
|
||||
var label = m.Kind switch
|
||||
{
|
||||
ChatEventKind.SuperChat => "Super Chat",
|
||||
ChatEventKind.SuperSticker => "Super Sticker",
|
||||
ChatEventKind.NewMember => "New Member",
|
||||
ChatEventKind.MemberGift => "Membership Gift",
|
||||
ChatEventKind.GiftReceived => "Gifted Membership",
|
||||
ChatEventKind.MemberMilestone => "Member Milestone",
|
||||
_ => "Stream Alert",
|
||||
};
|
||||
var amount = m.Kind is ChatEventKind.SuperChat or ChatEventKind.SuperSticker
|
||||
&& m.SuperChatDisplayString is { Length: > 0 } s
|
||||
? $" · {s}"
|
||||
: "";
|
||||
return $"{m.AuthorName} — {label}{amount}";
|
||||
}
|
||||
|
||||
/// <summary>Render the current state onto one AlertBox preview (mirrors chat):
|
||||
/// the playing frame, or null when idle so the source shows nothing until an
|
||||
/// event actually fires.</summary>
|
||||
public void UpdatePreview(Source alertBox)
|
||||
{
|
||||
try
|
||||
{
|
||||
var frame = RenderFrame(alertBox);
|
||||
// Preview eyes (TASK 47, 2026-09-26): a healthy decode painted nothing on
|
||||
// the canvas. UpdatePreview swallows its failures to Debug, so say once a
|
||||
// second whether the box is being handed a frame at all.
|
||||
if (IsPlaying)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
if (now - _lastPreviewLog >= TimeSpan.FromSeconds(1))
|
||||
{
|
||||
_lastPreviewLog = now;
|
||||
AppLog.Write($"Alert preview: box {alertBox.Id} frame="
|
||||
+ (frame == null ? "null" : $"{frame.Width}x{frame.Height} a{frame.BgraPixels[3]}"));
|
||||
}
|
||||
}
|
||||
if (frame == null)
|
||||
{
|
||||
alertBox.VideoImageSource = null;
|
||||
return;
|
||||
}
|
||||
var width = (int)Math.Max(1, alertBox.Width);
|
||||
var height = (int)Math.Max(1, alertBox.Height);
|
||||
var bitmap = new WriteableBitmap(width, height, 96, 96, PixelFormats.Bgra32, null);
|
||||
bitmap.WritePixels(new Int32Rect(0, 0, width, height), frame.BgraPixels, frame.Stride, 0);
|
||||
alertBox.VideoImageSource = bitmap;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
System.Diagnostics.Debug.WriteLine($"Alert preview failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Wire property-change subscriptions for all AlertBox sources across
|
||||
/// every scene (geometry changes re-render), and clear/reset their previews.</summary>
|
||||
public void UpdateConfiguredBoxes(IEnumerable<Scene> scenes)
|
||||
{
|
||||
_scenes = scenes;
|
||||
foreach (var scene in scenes)
|
||||
foreach (var alertBox in scene.Elements.OfType<Source>().Where(s => s.Type == SourceType.AlertBox))
|
||||
{
|
||||
alertBox.PropertyChanged += (_, e) =>
|
||||
{
|
||||
if (e.PropertyName is nameof(Source.Width) or nameof(Source.Height))
|
||||
UpdatePreview(alertBox);
|
||||
};
|
||||
UpdatePreview(alertBox);
|
||||
}
|
||||
}
|
||||
|
||||
private void RefreshAlertPreviews()
|
||||
{
|
||||
// The announcement strip is a GLOBAL 1920px overlay, not a per-box frame, so
|
||||
// it gets its own sink: until this existed the strip went only to the frame
|
||||
// pump and was therefore invisible in the preview pane the creator watches
|
||||
// (2026-09-26 — "I do not see the scrolling text").
|
||||
_tickerPreviewSink?.Invoke(AlertTickerFrame);
|
||||
|
||||
if (_scenes == null) return;
|
||||
foreach (var alertBox in _scenes.SelectMany(s => s.Elements).OfType<Source>()
|
||||
.Where(s => s.Type == SourceType.AlertBox))
|
||||
UpdatePreview(alertBox);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_ticker?.Stop();
|
||||
_queue.Clear();
|
||||
_current = null;
|
||||
StopClip();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
using System.Globalization;
|
||||
using System.Windows;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Imaging;
|
||||
using ytLive.Models;
|
||||
|
||||
namespace ytLive.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Renders one event's celebration animation into a BGRA8 frame for the AlertBox.
|
||||
/// Six event kinds, six UNIQUE animations — each a distinct gesture, palette and
|
||||
/// layout (the creator's ruling: these are the monetization shout-outs and free
|
||||
/// branded product placement; a one-template recoloring loses all interest in two
|
||||
/// iterations). Each card carries the bottom-right brand line so every broadcast
|
||||
/// of an event is an in-video ad placement.
|
||||
///
|
||||
/// <paramref name="t01"/> walks 0→1 over the whole animation; each kind splits it
|
||||
/// into enter / hold / exit phases with its own easing.
|
||||
/// </summary>
|
||||
public sealed class AlertRenderer
|
||||
{
|
||||
private const double EnterEnd = 0.16;
|
||||
private const double ExitStart = 0.80;
|
||||
private const string Brand = "made with LlamaCasty!";
|
||||
|
||||
private static readonly CultureInfo Invariant = CultureInfo.InvariantCulture;
|
||||
private static readonly Color White = Colors.White;
|
||||
private static readonly Color[] ConfettiPalette =
|
||||
{
|
||||
Color.FromRgb(0xf4, 0x2f, 0x5f), Color.FromRgb(0xff, 0xd1, 0x66),
|
||||
Color.FromRgb(0x38, 0xb6, 0xf8), Color.FromRgb(0xa7, 0x8b, 0xfa),
|
||||
Color.FromRgb(0x39, 0xd3, 0x53),
|
||||
};
|
||||
|
||||
public VideoFrame Render(ChatMessage e, double t01, int width, int height)
|
||||
{
|
||||
return e.Kind switch
|
||||
{
|
||||
ChatEventKind.SuperChat => SuperChat(e, t01, width, height),
|
||||
ChatEventKind.SuperSticker => SuperSticker(e, t01, width, height),
|
||||
ChatEventKind.NewMember => NewMember(e, t01, width, height),
|
||||
ChatEventKind.MemberGift => MemberGift(e, t01, width, height),
|
||||
ChatEventKind.GiftReceived => GiftReceived(e, t01, width, height),
|
||||
ChatEventKind.MemberMilestone => Milestone(e, t01, width, height),
|
||||
_ => SuperChat(e, t01, width, height),
|
||||
};
|
||||
}
|
||||
|
||||
private static VideoFrame SuperChat(ChatMessage e, double t, int w, int h)
|
||||
{
|
||||
var dpi = 96.0;
|
||||
var visual = new DrawingVisual();
|
||||
using (var ctx = visual.RenderOpen())
|
||||
{
|
||||
var margin = Math.Max(w, h) * 0.03;
|
||||
var cardX = margin;
|
||||
var cardW = w - margin * 2;
|
||||
var cardH = h - margin * 2;
|
||||
var cardCX = cardX + cardW / 2;
|
||||
|
||||
var enter = Clamp01(t / EnterEnd);
|
||||
var exit = Clamp01((t - ExitStart) / (1 - ExitStart));
|
||||
var exitEase = EaseInCubic(exit);
|
||||
var cardY = Lerp(h + cardH, margin, EaseOutCubic(enter));
|
||||
cardY = Lerp(cardY, -cardH, exitEase);
|
||||
|
||||
DrawCard(ctx, cardX, cardY, cardW, cardH, "#0d3b2b", "#14532d", 14);
|
||||
DrawBrand(ctx, cardX, cardY, cardW, cardH);
|
||||
|
||||
var shownAmount = e.SuperChatDisplayString
|
||||
?? (e.SuperChatCurrency is { Length: > 0 } cur ? $"{cur}{e.SuperChatAmount:0.00}" : $"${e.SuperChatAmount:0.00}");
|
||||
var fontSize = cardH * 0.24;
|
||||
DrawCentered(ctx, shownAmount, cardCX, cardY + cardH * 0.24, fontSize, FontWeights.Bold, Color.FromRgb(0xff, 0xd1, 0x66), cardW);
|
||||
|
||||
DrawCentered(ctx, e.AuthorName, cardCX, cardY + cardH * 0.56, cardH * 0.13, FontWeights.SemiBold, White, cardW);
|
||||
DrawCentered(ctx, e.Message, cardCX, cardY + cardH * 0.74, cardH * 0.10, FontWeights.Normal, Color.FromRgb(0xdd, 0xff, 0xf0), cardW * 0.92);
|
||||
|
||||
if (t >= EnterEnd && t <= ExitStart)
|
||||
{
|
||||
var sweep = (t - EnterEnd) / (ExitStart - EnterEnd);
|
||||
var shineX = cardX + (sweep * 2 - 0.5) * cardW;
|
||||
ctx.DrawRectangle(
|
||||
new LinearGradientBrush(
|
||||
Colors.Transparent,
|
||||
Color.FromArgb(0x30, 0xff, 0xff, 0xff),
|
||||
new Point(0, 0),
|
||||
new Point(1, 0)),
|
||||
null,
|
||||
new Rect(shineX - cardW * 0.25, cardY, cardW * 0.5, cardH));
|
||||
}
|
||||
}
|
||||
return CreateFrame(visual, w, h, dpi);
|
||||
}
|
||||
|
||||
private static VideoFrame SuperSticker(ChatMessage e, double t, int w, int h)
|
||||
{
|
||||
var dpi = 96.0;
|
||||
var visual = new DrawingVisual();
|
||||
using (var ctx = visual.RenderOpen())
|
||||
{
|
||||
var margin = Math.Max(w, h) * 0.04;
|
||||
var cardW = w - margin * 2;
|
||||
var cardH = h - margin * 2;
|
||||
var cardCX = w / 2.0;
|
||||
|
||||
var enter = Clamp01(t / EnterEnd);
|
||||
var exit = Clamp01((t - ExitStart) / (1 - ExitStart));
|
||||
var scale = EaseOutBack(enter) * (0.85 + 0.15 * (1 - exit));
|
||||
if (t > EnterEnd && t <= ExitStart)
|
||||
scale = 1.0 + 0.02 * Math.Sin(t * 26.0);
|
||||
var alpha = 1.0 - exit;
|
||||
|
||||
ctx.PushOpacity(alpha);
|
||||
|
||||
var slick = new RotateTransform(-6, w / 2.0, h / 2.0);
|
||||
ctx.PushTransform(slick);
|
||||
ctx.PushTransform(new ScaleTransform(scale, scale, w / 2.0, h / 2.0));
|
||||
|
||||
DrawCard(ctx, margin, margin, cardW, cardH, "#4a044e", "#7e22ce", 20);
|
||||
DrawBrand(ctx, margin, margin, cardW, cardH);
|
||||
|
||||
var desc = string.IsNullOrWhiteSpace(e.SuperStickerDescription) ? "SUPER STICKER" : e.SuperStickerDescription.ToUpperInvariant();
|
||||
DrawCentered(ctx, desc, cardCX, h / 2.0 - cardH * 0.12, cardH * 0.17, FontWeights.Bold, White, cardW * 0.9);
|
||||
|
||||
DrawCentered(ctx, e.AuthorName, cardCX, h / 2.0 + cardH * 0.16, cardH * 0.12, FontWeights.SemiBold, Color.FromRgb(0xff, 0xb3, 0xd9), cardW * 0.9);
|
||||
var tier = e.SuperChatDisplayString ?? (e.SuperChatCurrency is { Length: > 0 } cur ? $"{cur}{e.SuperChatAmount:0.00}" : null);
|
||||
if (!string.IsNullOrWhiteSpace(tier))
|
||||
DrawCentered(ctx, tier, cardCX, h / 2.0 + cardH * 0.32, cardH * 0.10, FontWeights.Bold, Color.FromRgb(0xff, 0xd1, 0x66), cardW * 0.9);
|
||||
|
||||
ctx.Pop();
|
||||
ctx.Pop();
|
||||
ctx.Pop();
|
||||
}
|
||||
return CreateFrame(visual, w, h, dpi);
|
||||
}
|
||||
|
||||
private static VideoFrame NewMember(ChatMessage e, double t, int w, int h)
|
||||
{
|
||||
var dpi = 96.0;
|
||||
var visual = new DrawingVisual();
|
||||
using (var ctx = visual.RenderOpen())
|
||||
{
|
||||
var margin = Math.Max(w, h) * 0.035;
|
||||
var cardW = w - margin * 2;
|
||||
var cardH = h - margin * 2;
|
||||
var cardCX = w / 2.0;
|
||||
|
||||
var enter = Clamp01(t / EnterEnd);
|
||||
var exit = Clamp01((t - ExitStart) / (1 - ExitStart));
|
||||
var cardY = Lerp(-cardH, margin, EaseOutCubic(enter));
|
||||
cardY = Lerp(cardY, h, EaseInCubic(exit));
|
||||
|
||||
DrawCard(ctx, margin, cardY, cardW, cardH, "#2e1065", "#5b21b6", 16);
|
||||
DrawBrand(ctx, margin, cardY, cardW, cardH);
|
||||
|
||||
if (t <= ExitStart)
|
||||
{
|
||||
var bandP = EaseOutCubic(Clamp01(t / 0.22));
|
||||
ctx.DrawRectangle(
|
||||
new LinearGradientBrush(Color.FromArgb(0, 0xff, 0xff, 0xff), Colors.Transparent, new Point(0, 0), new Point(0, 1)),
|
||||
null,
|
||||
new Rect(margin, cardY + cardH * 0.30, cardW * bandP, cardH * 0.18));
|
||||
}
|
||||
|
||||
DrawCentered(ctx, e.AuthorName, cardCX, cardY + cardH * 0.36, cardH * 0.22, FontWeights.Bold, White, cardW * 0.9);
|
||||
DrawCentered(ctx, "JOINED THE MEMBERSHIP", cardCX, cardY + cardH * 0.62, cardH * 0.11, FontWeights.SemiBold, Color.FromRgb(0xf5, 0xd0, 0xfe), cardW * 0.9);
|
||||
|
||||
var level = string.IsNullOrWhiteSpace(e.MembershipLevel) ? "MEMBER" : e.MembershipLevel.ToUpperInvariant();
|
||||
DrawCentered(ctx, level, cardCX, cardY + cardH * 0.80, cardH * 0.10, FontWeights.Bold, Color.FromRgb(0xff, 0xd1, 0x66), cardW * 0.9);
|
||||
}
|
||||
return CreateFrame(visual, w, h, dpi);
|
||||
}
|
||||
|
||||
private static VideoFrame MemberGift(ChatMessage e, double t, int w, int h)
|
||||
{
|
||||
var dpi = 96.0;
|
||||
var visual = new DrawingVisual();
|
||||
using (var ctx = visual.RenderOpen())
|
||||
{
|
||||
var margin = Math.Max(w, h) * 0.035;
|
||||
var cardW = w - margin * 2;
|
||||
var cardH = h - margin * 2;
|
||||
var cardCX = w / 2.0;
|
||||
|
||||
var enter = Clamp01(t / EnterEnd);
|
||||
var exit = Clamp01((t - ExitStart) / (1 - ExitStart));
|
||||
var cardX = Lerp(-cardW, margin, EaseOutCubic(enter));
|
||||
cardX = Lerp(cardX, -cardW, EaseInCubic(exit));
|
||||
|
||||
DrawCard(ctx, cardX, margin, cardW, cardH, "#422006", "#a16207", 16);
|
||||
DrawBrand(ctx, cardX, margin, cardW, cardH);
|
||||
|
||||
var count = Math.Max(1, e.GiftNewMembersCount);
|
||||
var spread = 1.0 - exit;
|
||||
var hero = count > 1 ? $"×{count}" : "MEMBERSHIP";
|
||||
DrawCentered(ctx, hero, cardCX, margin + cardH * 0.24, cardH * 0.26, FontWeights.Bold, Color.FromRgb(0xff, 0xd1, 0x66), cardW * 0.8);
|
||||
DrawCentered(ctx, $"{e.AuthorName} is gifting", cardCX, margin + cardH * 0.52, cardH * 0.11, FontWeights.SemiBold, White, cardW * 0.9);
|
||||
|
||||
var chipCount = Math.Min(count, 6);
|
||||
for (var i = 0; i < chipCount; i++)
|
||||
{
|
||||
var slot = (chipCount - 1) * 0.5;
|
||||
var gap = cardH * 0.13 * EaseOutCubic(Clamp01(t / 0.30)) * spread;
|
||||
var chipX = cardCX + (i - slot) * gap;
|
||||
var chipY = margin + cardH * 0.70 + Math.Sin((i + t * 6) * 2.4) * cardH * 0.015;
|
||||
ctx.DrawRoundedRectangle(
|
||||
new SolidColorBrush(Color.FromRgb(0xff, 0xf3, 0xc4)),
|
||||
null,
|
||||
new Rect(chipX - cardH * 0.05, chipY, cardH * 0.10, cardH * 0.10),
|
||||
cardH * 0.03, cardH * 0.03);
|
||||
}
|
||||
}
|
||||
return CreateFrame(visual, w, h, dpi);
|
||||
}
|
||||
|
||||
private static VideoFrame GiftReceived(ChatMessage e, double t, int w, int h)
|
||||
{
|
||||
var dpi = 96.0;
|
||||
var visual = new DrawingVisual();
|
||||
using (var ctx = visual.RenderOpen())
|
||||
{
|
||||
var margin = Math.Max(w, h) * 0.04;
|
||||
var cardW = w - margin * 2;
|
||||
var cardH = h - margin * 2;
|
||||
var cardCX = w / 2.0;
|
||||
|
||||
var enter = Clamp01(t / (EnterEnd * 1.4));
|
||||
var exit = Clamp01((t - ExitStart) / (1 - ExitStart));
|
||||
var scale = 0.5 + 0.5 * EaseOutBack(enter);
|
||||
var alpha = Math.Min(1.0, enter * 1.6) * (1 - exit);
|
||||
|
||||
ctx.PushOpacity(alpha);
|
||||
ctx.PushTransform(new ScaleTransform(scale, scale, w / 2.0, h / 2.0));
|
||||
|
||||
DrawCard(ctx, margin, margin, cardW, cardH, "#14532d", "#4c1d95", 18);
|
||||
DrawBrand(ctx, margin, margin, cardW, cardH);
|
||||
|
||||
// Confetti falling inside the card during the hold (deterministic seed).
|
||||
if (t >= EnterEnd && t <= ExitStart)
|
||||
{
|
||||
var hold = (t - EnterEnd) / (ExitStart - EnterEnd);
|
||||
for (var i = 0; i < 14; i++)
|
||||
{
|
||||
var seed = i * 37 + 11;
|
||||
var dotX = margin + cardW * (((seed * 53) % 97) / 97.0);
|
||||
var dotY = margin + cardH * (((seed + hold * 0.85) % 1.0));
|
||||
var size = cardH * 0.02 + (i % 3) * cardH * 0.006;
|
||||
ctx.DrawRectangle(
|
||||
new SolidColorBrush(ConfettiPalette[i % ConfettiPalette.Length]),
|
||||
null,
|
||||
new Rect(dotX, dotY, size, size));
|
||||
}
|
||||
}
|
||||
|
||||
DrawCentered(ctx, "GIFTED MEMBERSHIP", cardCX, margin + cardH * 0.22, cardH * 0.12, FontWeights.Bold, Color.FromRgb(0xff, 0xd1, 0x66), cardW * 0.9);
|
||||
var giver = string.IsNullOrWhiteSpace(e.GifterName) ? e.AuthorName : e.GifterName;
|
||||
DrawCentered(ctx, giver, cardCX, margin + cardH * 0.42, cardH * 0.20, FontWeights.Bold, White, cardW * 0.9);
|
||||
DrawCentered(ctx, "gave you a membership — celebrate!", cardCX, margin + cardH * 0.68, cardH * 0.11, FontWeights.Normal, Color.FromRgb(0xdb, 0xff, 0xf0), cardW * 0.9);
|
||||
|
||||
ctx.Pop();
|
||||
ctx.Pop();
|
||||
}
|
||||
return CreateFrame(visual, w, h, dpi);
|
||||
}
|
||||
|
||||
private static VideoFrame Milestone(ChatMessage e, double t, int w, int h)
|
||||
{
|
||||
var dpi = 96.0;
|
||||
var visual = new DrawingVisual();
|
||||
using (var ctx = visual.RenderOpen())
|
||||
{
|
||||
var margin = Math.Max(w, h) * 0.04;
|
||||
var cardW = w - margin * 2;
|
||||
var cardH = h - margin * 2;
|
||||
var cardCX = w / 2.0;
|
||||
|
||||
var enter = Clamp01(t / EnterEnd);
|
||||
var exit = Clamp01((t - ExitStart) / (1 - ExitStart));
|
||||
var cardY = Lerp(h + cardH, margin, EaseOutCubic(enter));
|
||||
cardY = Lerp(cardY, -cardH, EaseInCubic(exit));
|
||||
|
||||
DrawCard(ctx, margin, cardY, cardW, cardH, "#431407", "#c2410c", 14);
|
||||
DrawBrand(ctx, margin, cardY, cardW, cardH);
|
||||
|
||||
var hero = e.MilestoneMonths > 0 ? $"{e.MilestoneMonths}" : "MILESTONE";
|
||||
DrawCentered(ctx, hero, cardCX, cardY + cardH * 0.26, cardH * 0.30, FontWeights.Bold, Color.FromRgb(0xff, 0xdd, 0x66), cardW * 0.8);
|
||||
|
||||
var sub = e.MilestoneMonths > 0 ? "MONTHS STRONG" : "SUPPORTING THE STREAM";
|
||||
DrawCentered(ctx, sub, cardCX, cardY + cardH * 0.52, cardH * 0.11, FontWeights.SemiBold, White, cardW * 0.9);
|
||||
DrawCentered(ctx, "— " + e.AuthorName + " —", cardCX, cardY + cardH * 0.70, cardH * 0.10, FontWeights.Normal, Color.FromRgb(0xff, 0xe8, 0xd0), cardW * 0.9);
|
||||
|
||||
// Tenure bar: grows through the first half, retracts on exit.
|
||||
var barP = Clamp01((t - 0.10) / 0.55) * (1 - exit);
|
||||
var barW = cardW * 0.6 * barP;
|
||||
ctx.DrawRoundedRectangle(
|
||||
new SolidColorBrush(Color.FromRgb(0xff, 0xd1, 0x66)),
|
||||
null,
|
||||
new Rect(cardCX - barW / 2, cardY + cardH * 0.84, barW, cardH * 0.045),
|
||||
4, 4);
|
||||
}
|
||||
return CreateFrame(visual, w, h, dpi);
|
||||
}
|
||||
|
||||
// ─── Shared drawing ───
|
||||
|
||||
private static void DrawCard(DrawingContext ctx, double x, double y, double w, double h, string fromHex, string toHex, double radius)
|
||||
{
|
||||
var brush = new LinearGradientBrush(ParseColor(fromHex), ParseColor(toHex), new Point(0, 0), new Point(1, 1));
|
||||
ctx.DrawRoundedRectangle(brush, new Pen(new SolidColorBrush(Color.FromArgb(0x66, 0xff, 0xff, 0xff)), Math.Max(1, radius * 0.06)), new Rect(x, y, w, h), radius, radius);
|
||||
}
|
||||
|
||||
private static void DrawBrand(DrawingContext ctx, double x, double y, double w, double h)
|
||||
{
|
||||
var formatted = new FormattedText(
|
||||
Brand, Invariant, FlowDirection.LeftToRight,
|
||||
new Typeface(new FontFamily("Segoe UI"), FontStyles.Normal, FontWeights.Normal, FontStretches.Normal),
|
||||
Math.Max(9, h * 0.055),
|
||||
new SolidColorBrush(Color.FromArgb(0x88, 0xff, 0xff, 0xff)),
|
||||
96.0);
|
||||
ctx.DrawText(formatted, new Point(x + w - formatted.Width - h * 0.08, y + h - formatted.Height - h * 0.05));
|
||||
}
|
||||
|
||||
private static void DrawCentered(DrawingContext ctx, string text, double cx, double cy, double size, FontWeight weight, Color color, double maxWidth)
|
||||
{
|
||||
var formatted = new FormattedText(
|
||||
text, Invariant, FlowDirection.LeftToRight,
|
||||
new Typeface(new FontFamily("Segoe UI"), FontStyles.Normal, weight, FontStretches.Normal),
|
||||
size,
|
||||
new SolidColorBrush(color),
|
||||
96.0);
|
||||
formatted.MaxTextWidth = maxWidth;
|
||||
formatted.MaxLineCount = 1;
|
||||
formatted.Trimming = TextTrimming.CharacterEllipsis;
|
||||
ctx.DrawText(formatted, new Point(cx - formatted.Width / 2, cy - formatted.Height / 2));
|
||||
}
|
||||
|
||||
private static Color ParseColor(string hex)
|
||||
{
|
||||
hex = hex.Trim().TrimStart('#');
|
||||
if (hex.Length == 6)
|
||||
{
|
||||
var r = byte.Parse(hex.AsSpan(0, 2), NumberStyles.HexNumber);
|
||||
var g = byte.Parse(hex.AsSpan(2, 2), NumberStyles.HexNumber);
|
||||
var b = byte.Parse(hex.AsSpan(4, 2), NumberStyles.HexNumber);
|
||||
return Color.FromRgb(r, g, b);
|
||||
}
|
||||
if (hex.Length == 8)
|
||||
{
|
||||
var a = byte.Parse(hex.AsSpan(0, 2), NumberStyles.HexNumber);
|
||||
var r = byte.Parse(hex.AsSpan(2, 2), NumberStyles.HexNumber);
|
||||
var g = byte.Parse(hex.AsSpan(4, 2), NumberStyles.HexNumber);
|
||||
var b = byte.Parse(hex.AsSpan(6, 2), NumberStyles.HexNumber);
|
||||
return Color.FromArgb(a, r, g, b);
|
||||
}
|
||||
return Colors.White;
|
||||
}
|
||||
|
||||
private static VideoFrame CreateFrame(DrawingVisual visual, int width, int height, double dpi)
|
||||
{
|
||||
var rtb = new RenderTargetBitmap(width, height, dpi, dpi, PixelFormats.Pbgra32);
|
||||
rtb.Render(visual);
|
||||
|
||||
var stride = width * 4;
|
||||
var pixels = new byte[stride * height];
|
||||
rtb.CopyPixels(pixels, stride, 0);
|
||||
|
||||
for (var i = 0; i < pixels.Length; i += 4)
|
||||
{
|
||||
(pixels[i], pixels[i + 2]) = (pixels[i + 2], pixels[i]);
|
||||
}
|
||||
|
||||
return new VideoFrame(width, height, pixels);
|
||||
}
|
||||
|
||||
private static double Clamp01(double v) => Math.Clamp(v, 0.0, 1.0);
|
||||
|
||||
private static double Lerp(double a, double b, double p) => a + (b - a) * p;
|
||||
|
||||
private static double EaseInCubic(double p) => p * p * p;
|
||||
|
||||
private static double EaseOutCubic(double p) => 1 - Math.Pow(1 - p, 3);
|
||||
|
||||
private static double EaseOutBack(double p)
|
||||
{
|
||||
const double c1 = 1.70158;
|
||||
const double c3 = c1 + 1;
|
||||
return 1 + c3 * Math.Pow(p - 1, 3) + c1 * Math.Pow(p - 1, 2);
|
||||
}
|
||||
}
|
||||
@@ -28,8 +28,12 @@ public sealed class AudioGainProvider
|
||||
/// <summary>Mic gain for the live mix (0 = muted).</summary>
|
||||
public double MicGain() => _micMuted() ? 0 : _micVolume();
|
||||
|
||||
/// <summary>Loopback (desktop/game) gain for the live mix (0 = muted, 1 = unity).
|
||||
/// Unity because the slider now drives system volume via PushSystemVolume,
|
||||
/// so the WASAPI loopback signal is already scaled by the user's volume choice.</summary>
|
||||
public double LoopbackGain() => _gameMuted() ? 0 : 1.0;
|
||||
/// <summary>Loopback (desktop/game) gain for the live mix (0 = muted).
|
||||
/// Honesty rule (2026-09-01): WASAPI loopback capture does NOT shrink with the
|
||||
/// endpoint volume — the creator dropped system volume to 20% and the captured
|
||||
/// level stayed pegged — so the mixer must multiply by GameAudioVolume itself
|
||||
/// for the stream to follow the knob (the old unity assumption left the stream
|
||||
/// at full desktop volume while the headphones went quiet; ai.md always
|
||||
/// specified loopbackGain = GameAudioVolume — the code had drifted).</summary>
|
||||
public double LoopbackGain() => _gameMuted() ? 0 : _gameVolume();
|
||||
}
|
||||
|
||||
+154
-11
@@ -1,3 +1,4 @@
|
||||
using ytLive.Helpers;
|
||||
using ytLive.Services;
|
||||
|
||||
namespace ytLive.Services.Audio;
|
||||
@@ -13,7 +14,9 @@ namespace ytLive.Services.Audio;
|
||||
/// queues, applies the honest gains (mic = MicVolume, loopback = unity
|
||||
/// × auto-duck when the mic is hot — the slider drives system volume via mixes them to stereo float, runs the
|
||||
/// master limiter (a -1 dBFS ceiling so the sum never clips the encoder), and
|
||||
/// writes the chunk to the encoder's audio pipe.
|
||||
/// writes the chunk to the encoder's audio pipe. Since TASK 47 the alert layer's
|
||||
/// clip audio joins the same mix (pre-scaled, unity-added, never ducked) through
|
||||
/// <see cref="EnqueueAlertAudio"/>.
|
||||
/// </summary>
|
||||
public sealed class AudioMixer : IDisposable
|
||||
{
|
||||
@@ -29,12 +32,16 @@ public sealed class AudioMixer : IDisposable
|
||||
private readonly Func<double>? _micGain;
|
||||
private readonly Func<double>? _loopbackGain;
|
||||
private readonly TimeSpan _mixInterval;
|
||||
private readonly Func<int>? _syncOffsetMs;
|
||||
private readonly AudioSyncDelay _syncDelay;
|
||||
private int _advanceSamplesRemaining;
|
||||
private bool _started;
|
||||
|
||||
// Live path (TASK 9): per-source resampling → voice chain on the mic →
|
||||
// thread-safe queues → the ducker + gain/mix loop → the encoder's pipe.
|
||||
private readonly AudioRingBuffer _micBuffer;
|
||||
private readonly AudioRingBuffer _loopbackBuffer;
|
||||
private readonly AudioRingBuffer _alertBuffer;
|
||||
private readonly VoiceFilterChain _voiceChain;
|
||||
private readonly AutoDucker _ducker;
|
||||
private readonly MasterLimiter _masterLimiter = new();
|
||||
@@ -44,7 +51,14 @@ public sealed class AudioMixer : IDisposable
|
||||
private NamedPipeAudioWriter _pipe = new();
|
||||
private float[]? _micChunk;
|
||||
private float[]? _loopbackChunk;
|
||||
private float[]? _alertChunk;
|
||||
private float[]? _mixBuffer;
|
||||
private float[]? _delayedMix;
|
||||
private DateTime _lastLoopErrorLogged = DateTime.MinValue;
|
||||
private DateTime _nextStatsLog = DateTime.UtcNow + TimeSpan.FromSeconds(5);
|
||||
private long _micDrainedTotal;
|
||||
private long _loopDrainedTotal;
|
||||
private float _peakMix;
|
||||
|
||||
public AudioMixer(
|
||||
IAudioSource mic,
|
||||
@@ -52,6 +66,7 @@ public sealed class AudioMixer : IDisposable
|
||||
Action<string>? log = null,
|
||||
Func<double>? micGain = null,
|
||||
Func<double>? loopbackGain = null,
|
||||
Func<int>? syncOffsetMs = null,
|
||||
TimeSpan? mixInterval = null)
|
||||
{
|
||||
_mic = mic;
|
||||
@@ -61,17 +76,22 @@ public sealed class AudioMixer : IDisposable
|
||||
_log = log;
|
||||
_micGain = micGain;
|
||||
_loopbackGain = loopbackGain;
|
||||
_syncOffsetMs = syncOffsetMs;
|
||||
_syncDelay = new AudioSyncDelay(OutputSampleRate);
|
||||
_mixInterval = mixInterval ?? DefaultMixInterval;
|
||||
|
||||
var seconds = _mixInterval.TotalSeconds;
|
||||
var framesPerTick = Math.Max(1, (int)Math.Round(OutputSampleRate * seconds));
|
||||
_micBuffer = new AudioRingBuffer(OutputSampleRate * 2);
|
||||
_loopbackBuffer = new AudioRingBuffer(OutputSampleRate * 2 * 2);
|
||||
_alertBuffer = new AudioRingBuffer(OutputSampleRate * 2 * 8);
|
||||
_voiceChain = new VoiceFilterChain(OutputSampleRate);
|
||||
_ducker = new AutoDucker();
|
||||
_micChunk = new float[framesPerTick];
|
||||
_loopbackChunk = new float[framesPerTick * 2];
|
||||
_alertChunk = new float[framesPerTick * 2];
|
||||
_mixBuffer = new float[framesPerTick * 2];
|
||||
_delayedMix = new float[framesPerTick * 2];
|
||||
|
||||
_mic.Started += OnMicStarted;
|
||||
_mic.SampleReady += OnMicSample;
|
||||
@@ -146,19 +166,48 @@ public sealed class AudioMixer : IDisposable
|
||||
if (_liveCts != null)
|
||||
return;
|
||||
|
||||
// Capture runs from app STARTUP for the meters, so both rings carry up
|
||||
// to a full ring depth (~2s) of pre-live backlog by the time the user
|
||||
// hits record; draining it made every recorded event land ~2s late in
|
||||
// the audio track (2026-09-14, two confirmed takes). Discard it so the
|
||||
// recording starts at go-live. The writers keep running concurrently;
|
||||
// Clear under the ring's lock, so only the in-flight chunk is dropped.
|
||||
_micBuffer.Clear();
|
||||
_loopbackBuffer.Clear();
|
||||
_alertBuffer.Clear();
|
||||
|
||||
// Negative sync offset (audio runs BEHIND video): OBS's fix is to "eat
|
||||
// the head of the buffer" — discard the first |N| ms of the live stream
|
||||
// so every subsequent audio event lands |N| ms EARLIER in the recording.
|
||||
// Armed once at go-live (you can't eat the head mid-stream); positive
|
||||
// offsets stay live-reactive through the delay line in FillAndMix.
|
||||
var syncMs = _syncOffsetMs?.Invoke() ?? 0;
|
||||
_advanceSamplesRemaining = syncMs < 0
|
||||
? (int)Math.Round(OutputSampleRate / 1000.0 * -syncMs) * 2
|
||||
: 0;
|
||||
if (_advanceSamplesRemaining > 0)
|
||||
_log?.Invoke($"Audio live: advance armed ({-syncMs} ms ahead)");
|
||||
|
||||
var cts = new CancellationTokenSource();
|
||||
_liveCts = cts;
|
||||
_pipe = new NamedPipeAudioWriter();
|
||||
_pipe.Start(pipeName);
|
||||
_log?.Invoke($"Audio live: started, pipe '{pipeName}'");
|
||||
_ = Task.Run(() => LiveLoopAsync(_pipe, cts.Token));
|
||||
}
|
||||
|
||||
/// <summary>Ends the live mix loop and closes the audio pipe — ffmpeg sees
|
||||
/// EOF on the audio input. Safe when not live.</summary>
|
||||
/// EOF on the audio input. Safe when not live: the writer's Stop() is idempotent
|
||||
/// (StopStream now calls this on every rollback path).</summary>
|
||||
public void StopLive()
|
||||
{
|
||||
_liveCts?.Cancel();
|
||||
var cts = _liveCts;
|
||||
_liveCts = null;
|
||||
if (cts != null)
|
||||
{
|
||||
_log?.Invoke("Audio live: stopped");
|
||||
cts.Cancel();
|
||||
}
|
||||
_pipe.Stop();
|
||||
}
|
||||
|
||||
@@ -204,6 +253,34 @@ public sealed class AudioMixer : IDisposable
|
||||
LoopbackLevelChanged?.Invoke(level);
|
||||
}
|
||||
|
||||
/// <summary>Drops the alert clip's audio into its own ring (TASK 47 — the
|
||||
/// stream-alert video's soundtrack, pre-scaled by the alert layer to
|
||||
/// volume × fade). Mixed at unity into the live output — NO auto-duck: an
|
||||
/// alert must be heard over the mic, so the Creator's per-alert volume
|
||||
/// slider is the honest gain and nothing else is stacked on top. Monno and
|
||||
/// non-48k sources are normalized (the decoder already emits 48k stereo).</summary>
|
||||
public void EnqueueAlertAudio(AudioSample sample)
|
||||
{
|
||||
var data = sample.Samples;
|
||||
if (data.Length == 0) return;
|
||||
if (sample.SampleRate != OutputSampleRate)
|
||||
data = ResampleLoopback(data, sample.SampleRate);
|
||||
if (sample.Channels == 1)
|
||||
data = UpmixToStereo(data);
|
||||
_alertBuffer.Write(data);
|
||||
}
|
||||
|
||||
private static float[] UpmixToStereo(float[] mono)
|
||||
{
|
||||
var stereo = new float[mono.Length * 2];
|
||||
for (var i = 0; i < mono.Length; i++)
|
||||
{
|
||||
stereo[i * 2] = mono[i];
|
||||
stereo[i * 2 + 1] = mono[i];
|
||||
}
|
||||
return stereo;
|
||||
}
|
||||
|
||||
private void OnMicFailed(Exception ex)
|
||||
{
|
||||
_log?.Invoke($"Mic capture failed: {ex.Message}");
|
||||
@@ -263,8 +340,42 @@ public sealed class AudioMixer : IDisposable
|
||||
var nextTick = DateTime.UtcNow + _mixInterval;
|
||||
try
|
||||
{
|
||||
FillAndMix(micChunk, loopbackChunk, mixBuffer);
|
||||
await pipe.WriteAsync(mixBuffer, cancellationToken).ConfigureAwait(false);
|
||||
var (_, micDrained, loopDrained) = FillAndMix(micChunk, loopbackChunk, mixBuffer);
|
||||
|
||||
// Negative offset: skip the armed |N| ms of stream head. The pipe
|
||||
// writer tolerates a partial chunk (any float length is valid);
|
||||
// dropping the head re-anchors the audio stream against the video
|
||||
// stream, making each event land earlier — OBS's eat-the-head fix.
|
||||
var writeFrom = 0;
|
||||
if (_advanceSamplesRemaining > 0)
|
||||
{
|
||||
var drop = Math.Min(_advanceSamplesRemaining, mixBuffer.Length);
|
||||
_advanceSamplesRemaining -= drop;
|
||||
writeFrom = drop;
|
||||
}
|
||||
|
||||
if (writeFrom < mixBuffer.Length)
|
||||
await pipe.WriteAsync(mixBuffer.AsMemory(writeFrom), cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_micDrainedTotal += micDrained;
|
||||
_loopDrainedTotal += loopDrained;
|
||||
for (var i = writeFrom; i < mixBuffer.Length; i++)
|
||||
{
|
||||
var abs = Math.Abs(mixBuffer[i]);
|
||||
if (abs > _peakMix) _peakMix = abs;
|
||||
}
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
if (now >= _nextStatsLog && _log != null)
|
||||
{
|
||||
_nextStatsLog = now + TimeSpan.FromSeconds(5);
|
||||
_log($"Audio live: pipe connected={pipe.IsConnected} dropped writes={pipe.DroppedWrites} " +
|
||||
$"micLevel={_meter.Level:F3} loopLevel={_loopbackMeter.Level:F3} " +
|
||||
$"drained {_micDrainedTotal:N0}/{_loopDrainedTotal:N0} samples peakMix {_peakMix:F3} (per 5s)");
|
||||
_micDrainedTotal = 0;
|
||||
_loopDrainedTotal = 0;
|
||||
_peakMix = 0;
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
@@ -272,7 +383,18 @@ public sealed class AudioMixer : IDisposable
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log?.Invoke($"Audio live loop error: {ex.Message}");
|
||||
// Throttled + never on a dead session: the 2026-09-01 first-launch flood
|
||||
// was one logged NRE every 10ms tick (see _delayedMix fix). Log at most
|
||||
// once per 5s, and stop looping if the pipe writer is gone for good.
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
break;
|
||||
var now = DateTime.UtcNow;
|
||||
if (now - _lastLoopErrorLogged >= TimeSpan.FromSeconds(5))
|
||||
{
|
||||
_lastLoopErrorLogged = now;
|
||||
AppLog.Write(ex, "Audio live loop error");
|
||||
_log?.Invoke($"Audio live loop error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
var delay = nextTick - DateTime.UtcNow;
|
||||
@@ -291,11 +413,16 @@ public sealed class AudioMixer : IDisposable
|
||||
}
|
||||
|
||||
/// <summary>Drains one tick's worth of mic + loopback, silence-fills any
|
||||
/// underrun, applies the ducker and the honest gains, and mixes to stereo.</summary>
|
||||
private float FillAndMix(float[] micChunk, float[] loopbackChunk, float[] mix)
|
||||
/// underrun, applies the ducker and the honest gains, and mixes to stereo.
|
||||
/// Returns the pre-duck mic RMS and how many samples each buffer contributed
|
||||
/// (the 5s live-loop telemetry reads those to name whether capture or the
|
||||
/// pipe starved a silent recording).</summary>
|
||||
private (float MicRms, int MicDrained, int LoopDrained) FillAndMix(float[] micChunk, float[] loopbackChunk, float[] mix)
|
||||
{
|
||||
var micCount = _micBuffer.Read(micChunk, micChunk.Length);
|
||||
var loopCount = _loopbackBuffer.Read(loopbackChunk, loopbackChunk.Length);
|
||||
var alertChunk = _alertChunk!;
|
||||
var alertCount = _alertBuffer.Read(alertChunk, alertChunk.Length);
|
||||
|
||||
float micRms = 0;
|
||||
if (micCount == 0)
|
||||
@@ -321,6 +448,15 @@ public sealed class AudioMixer : IDisposable
|
||||
Array.Clear(loopbackChunk, loopCount, loopbackChunk.Length - loopCount);
|
||||
}
|
||||
|
||||
if (alertCount == 0)
|
||||
{
|
||||
Array.Clear(alertChunk, 0, alertChunk.Length);
|
||||
}
|
||||
else if (alertCount < alertChunk.Length)
|
||||
{
|
||||
Array.Clear(alertChunk, alertCount, alertChunk.Length - alertCount);
|
||||
}
|
||||
|
||||
var duck = _ducker.Update(micRms);
|
||||
var micGain = (float)(_micGain?.Invoke() ?? 1.0);
|
||||
var loopGain = (float)(_loopbackGain?.Invoke() ?? 1.0) * duck;
|
||||
@@ -329,10 +465,17 @@ public sealed class AudioMixer : IDisposable
|
||||
for (var i = 0; i < frames; i++)
|
||||
{
|
||||
var m = micChunk[i] * micGain;
|
||||
mix[i * 2] = m + loopbackChunk[i * 2] * loopGain;
|
||||
mix[i * 2 + 1] = m + loopbackChunk[i * 2 + 1] * loopGain;
|
||||
mix[i * 2] = m + loopbackChunk[i * 2] * loopGain + alertChunk[i * 2];
|
||||
mix[i * 2 + 1] = m + loopbackChunk[i * 2 + 1] * loopGain + alertChunk[i * 2 + 1];
|
||||
}
|
||||
_masterLimiter.Process(mix);
|
||||
return micRms;
|
||||
|
||||
_syncDelay.Configure(_syncOffsetMs?.Invoke() ?? 0);
|
||||
var delayed = _delayedMix;
|
||||
if (delayed is null || delayed.Length < mix.Length)
|
||||
delayed = _delayedMix = new float[mix.Length];
|
||||
_syncDelay.Process(mix, delayed);
|
||||
Array.Copy(delayed, mix, mix.Length);
|
||||
return (micRms, micCount, loopCount);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
using System;
|
||||
|
||||
namespace ytLive.Services.Audio;
|
||||
|
||||
/// <summary>
|
||||
/// TASK 22: a fixed end-to-end audio delay applied to the interleaved stereo
|
||||
/// mix in <see cref="AudioMixer"/>. This is OBS's documented fix for the
|
||||
/// "audio is ahead of video" lip-sync case: delay the audio by a few
|
||||
/// milliseconds so it lands on the video. It is a pure delay line — input in,
|
||||
/// the same audio N ms later out — so it is trivially testable and carries no
|
||||
/// state outside its own ring.
|
||||
///
|
||||
/// Positive offsets only (0..500 ms). Advancing audio (the audio runs BEHIND
|
||||
/// the video) is handled upstream in <see cref="AudioMixer"/>, not here: the
|
||||
/// mixer eats the first |N| ms of the live stream head (OBS's negative sync —
|
||||
/// see <see cref="AudioMixer.StartLive"/>), keeping this line a pure delay.
|
||||
/// Changing the delay flushes the line, so a live slider change clicks rather
|
||||
/// than smearing. Safe to call from any thread — the mixer owns it.
|
||||
/// </summary>
|
||||
public sealed class AudioSyncDelay
|
||||
{
|
||||
private readonly int _sampleRate;
|
||||
|
||||
private float[] _buffer = Array.Empty<float>();
|
||||
private int _delaySamples;
|
||||
private int _writePos;
|
||||
|
||||
public AudioSyncDelay(int sampleRate)
|
||||
{
|
||||
_sampleRate = Math.Max(1, sampleRate);
|
||||
Configure(0);
|
||||
}
|
||||
|
||||
/// <summary>Sets the delay in whole milliseconds (clamped to 0..500). A no-op
|
||||
/// when the delay hasn't actually changed — <see cref="AudioMixer.FillAndMix"/>
|
||||
/// calls this every ~10ms tick (it re-reads the live UI setting each mix), and
|
||||
/// unconditionally reallocating+zeroing the line every tick (the 2026-09-12
|
||||
/// total-silence bug: any non-zero saved offset wiped the ring before its own
|
||||
/// delayed samples could ever be read back — mic/loop meters, sampled BEFORE
|
||||
/// this stage, kept showing real levels while the pipe stayed pure silence)
|
||||
/// discarded the entire buffered history every 10ms. Changing the delay still
|
||||
/// flushes the line (a live slider change clicks rather than smearing).</summary>
|
||||
public void Configure(int delayMs)
|
||||
{
|
||||
var delaySamples = Frames(delayMs);
|
||||
if (delaySamples == _delaySamples && _buffer.Length > 0) return;
|
||||
_delaySamples = delaySamples;
|
||||
_buffer = new float[(_delaySamples + _sampleRate) * 2 + 1024];
|
||||
_writePos = 0;
|
||||
}
|
||||
|
||||
/// <summary>Delays an interleaved stereo chunk by the configured offset.
|
||||
/// The returned length equals <paramref name="input"/>'s; the head is
|
||||
/// silence until the line fills.</summary>
|
||||
public void Process(ReadOnlySpan<float> input, Span<float> output)
|
||||
{
|
||||
if (output.Length < input.Length)
|
||||
throw new ArgumentException("output must be at least as long as input", nameof(output));
|
||||
|
||||
var len = input.Length;
|
||||
if (_delaySamples == 0)
|
||||
{
|
||||
input.CopyTo(output);
|
||||
return;
|
||||
}
|
||||
|
||||
var line = _buffer;
|
||||
var lineLen = line.Length;
|
||||
for (var i = 0; i < len; i++)
|
||||
{
|
||||
line[_writePos] = input[i];
|
||||
_writePos++;
|
||||
if (_writePos == lineLen)
|
||||
_writePos = 0;
|
||||
}
|
||||
|
||||
var readStart = (_writePos - _delaySamples - len + lineLen * 2) % lineLen;
|
||||
for (var i = 0; i < len; i++)
|
||||
{
|
||||
var idx = readStart + i;
|
||||
if (idx >= lineLen)
|
||||
idx -= lineLen;
|
||||
output[i] = line[idx];
|
||||
}
|
||||
}
|
||||
|
||||
private int Frames(int delayMs)
|
||||
{
|
||||
var clamped = Math.Clamp(delayMs, 0, 500);
|
||||
// Interleaved stereo: each frame is two samples (L, R), so the delay
|
||||
// in buffer positions is twice the frame count.
|
||||
return (int)Math.Round(_sampleRate / 1000.0 * clamped) * 2;
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,12 @@ public interface IAudioPipeWriter : IDisposable
|
||||
/// <summary>True once ffmpeg has connected and the pipe is writable.</summary>
|
||||
bool IsConnected { get; }
|
||||
|
||||
/// <summary>Count of <see cref="WriteAsync"/> calls that wrote nothing because
|
||||
/// the pipe wasn't connected yet or was already closed. Part of the silent-
|
||||
/// recording diagnosis: nonzero means audio was dropped before ffmpeg ever
|
||||
/// connected to the pipe.</summary>
|
||||
long DroppedWrites { get; }
|
||||
|
||||
/// <summary>Writes interleaved float samples; dropped until the client connects.</summary>
|
||||
Task WriteAsync(ReadOnlyMemory<float> samples, CancellationToken cancellationToken = default);
|
||||
|
||||
@@ -29,6 +35,9 @@ public sealed class NamedPipeAudioWriter : IAudioPipeWriter
|
||||
private readonly object _sync = new();
|
||||
private NamedPipeServerStream? _pipe;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public long DroppedWrites { get; private set; }
|
||||
|
||||
public void Start(string pipeName)
|
||||
{
|
||||
lock (_sync)
|
||||
@@ -64,7 +73,11 @@ public sealed class NamedPipeAudioWriter : IAudioPipeWriter
|
||||
pipe = _pipe;
|
||||
}
|
||||
if (pipe == null || !pipe.IsConnected || samples.IsEmpty)
|
||||
{
|
||||
if (pipe?.IsConnected == false || pipe == null)
|
||||
DroppedWrites++;
|
||||
return;
|
||||
}
|
||||
|
||||
var bytes = MemoryMarshal.AsBytes(samples.Span).ToArray();
|
||||
await pipe.WriteAsync(bytes, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
@@ -171,6 +171,18 @@ public sealed class CameraManager : IDisposable
|
||||
toStop.PreviewBitmap = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True while a session exists for the device — started, or still starting.
|
||||
/// Used by the app's startup resource check to avoid double-acquiring a lock
|
||||
/// that the saved identity's scene configs already hold. A rolled-back (failed)
|
||||
/// session is removed, so this goes false and the startup pass can retry.
|
||||
/// </summary>
|
||||
public bool IsRunning(string deviceId)
|
||||
{
|
||||
lock (_gate)
|
||||
return _sessions.ContainsKey(deviceId);
|
||||
}
|
||||
|
||||
public VideoFrame? GetLatestFrame(string deviceId)
|
||||
{
|
||||
lock (_gate)
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
using System.Net.Http;
|
||||
using System.Text.Json;
|
||||
using ytLive.Models;
|
||||
using AppLog = ytLive.Helpers.AppLog;
|
||||
|
||||
namespace ytLive.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Pulls a connected channel's YPP-relevant numbers from the YouTube Data API
|
||||
/// (TASK 39, slice 1): subscriptions/views/videos via channels.list, and the
|
||||
/// count of public uploads in the trailing 90 days via playlistItems.list on the
|
||||
/// uploads playlist. Long-form watch hours and Shorts-feed views need the
|
||||
/// Analytics API (a later slice behind the IChannelStatsProvider seam).
|
||||
///
|
||||
/// NOTE (root cause, 2026-09-23): channels.list must NOT request the
|
||||
/// `auditDetails` part — that part alone requires the
|
||||
/// `youtubepartner-channel-audit` scope the app doesn't (and mustn't) hold, and
|
||||
/// the whole request 403s `insufficientPermissions` over it. Standing flags are
|
||||
/// not exposed to ordinary apps; the drawer deep-links to YouTube Studio instead.
|
||||
/// </summary>
|
||||
public class ChannelStatsService
|
||||
{
|
||||
private readonly YouTubeAuthService _auth;
|
||||
private readonly HttpClient _http;
|
||||
private const string ApiBase = "https://www.googleapis.com/youtube/v3";
|
||||
|
||||
public ChannelStatsService(YouTubeAuthService auth, HttpClient? http = null)
|
||||
{
|
||||
_auth = auth;
|
||||
_http = http ?? new HttpClient();
|
||||
}
|
||||
|
||||
private async Task<bool> EnsureToken()
|
||||
{
|
||||
if (_auth.CurrentChannel == null) return false;
|
||||
if (_auth.CurrentChannel.TokenExpiry <= DateTime.UtcNow.AddMinutes(5))
|
||||
return await _auth.RefreshToken();
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Captures the channel's current stats into a snapshot, or null when
|
||||
/// there's no session, a request fails, or the API returns nothing. Never throws.</summary>
|
||||
public virtual async Task<YppStatSnapshot?> CaptureCurrentAsync()
|
||||
{
|
||||
if (!await EnsureToken()) return null;
|
||||
|
||||
_http.DefaultRequestHeaders.Authorization = new("Bearer", _auth.CurrentChannel!.AccessToken);
|
||||
|
||||
var response = await _http.GetAsync(
|
||||
$"{ApiBase}/channels?mine=true&part=statistics,contentDetails");
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
// Log the response body: YouTube's error.reason names the failure
|
||||
// (insufficientPermissions / quotaExceeded / …) — the bare code was
|
||||
// undiagnosable for the 2026-09-23 auditDetails 403.
|
||||
var errorBody = await response.Content.ReadAsStringAsync();
|
||||
AppLog.Write($"YPP stats: channels.list failed ({(int)response.StatusCode}): {errorBody}");
|
||||
return null;
|
||||
}
|
||||
|
||||
var json = JsonSerializer.Deserialize<JsonElement>(await response.Content.ReadAsStringAsync());
|
||||
if (!json.TryGetProperty("items", out var items) || items.GetArrayLength() == 0)
|
||||
return null;
|
||||
|
||||
var channel = items[0];
|
||||
var statistics = channel.GetProperty("statistics");
|
||||
|
||||
var uploadsPlaylistId = channel.TryGetProperty("contentDetails", out var contentDetails)
|
||||
&& contentDetails.TryGetProperty("relatedPlaylists", out var related)
|
||||
&& related.TryGetProperty("uploads", out var uploads)
|
||||
? uploads.GetString()
|
||||
: null;
|
||||
|
||||
var uploadsLast90Days = uploadsPlaylistId != null
|
||||
? await CountUploadsLast90DaysAsync(uploadsPlaylistId)
|
||||
: 0;
|
||||
|
||||
return new YppStatSnapshot
|
||||
{
|
||||
CapturedAtUtc = DateTime.UtcNow,
|
||||
SubscriberCount = ReadInt64(statistics, "subscriberCount"),
|
||||
VideoCount = ReadInt64(statistics, "videoCount"),
|
||||
TotalViews = ReadInt64(statistics, "viewCount"),
|
||||
UploadsLast90Days = uploadsLast90Days,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>YouTube serializes statistics as JSON STRINGS ("350"); a tolerant
|
||||
/// read that also accepts a number (the real 403 hid this parse bug until the
|
||||
/// auditDetails removal surfaced the string shape in 2026-09-23's Good Dog).
|
||||
/// NOTE: JsonElement.TryGetInt64 THROWS on a non-Number token — ValueKind must
|
||||
/// be checked first (try-type-in, not try-catch).</summary>
|
||||
private static long ReadInt64(JsonElement parent, string name)
|
||||
{
|
||||
if (!parent.TryGetProperty(name, out var value)) return 0;
|
||||
if (value.ValueKind == JsonValueKind.Number && value.TryGetInt64(out var n)) return n;
|
||||
if (value.ValueKind == JsonValueKind.String && long.TryParse(value.GetString(), out n)) return n;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// <summary>Counts uploads published within the trailing 90 days, scanning the
|
||||
/// most recent 50 from the uploads playlist (generous beyond the 3/90-day YPP
|
||||
/// requirement; the YPP tab labels it as the recent-50 window).</summary>
|
||||
private async Task<int> CountUploadsLast90DaysAsync(string uploadsPlaylistId)
|
||||
{
|
||||
var response = await _http.GetAsync(
|
||||
$"{ApiBase}/playlistItems?part=snippet&playlistId={Uri.EscapeDataString(uploadsPlaylistId)}&maxResults=50");
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
AppLog.Write($"YPP stats: playlistItems.list failed ({(int)response.StatusCode})");
|
||||
return 0;
|
||||
}
|
||||
|
||||
var json = JsonSerializer.Deserialize<JsonElement>(await response.Content.ReadAsStringAsync());
|
||||
if (!json.TryGetProperty("items", out var items)) return 0;
|
||||
|
||||
var cutoff = DateTime.UtcNow.AddDays(-90);
|
||||
var count = 0;
|
||||
foreach (var item in items.EnumerateArray())
|
||||
{
|
||||
if (!item.TryGetProperty("snippet", out var snippet)
|
||||
|| !snippet.TryGetProperty("publishedAt", out var published)
|
||||
|| !DateTime.TryParse(published.GetString(), out var publishedAt))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (publishedAt.ToUniversalTime() >= cutoff)
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Linq;
|
||||
using System.Windows;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Imaging;
|
||||
using System.Windows.Threading;
|
||||
using ytLive.Helpers;
|
||||
using ytLive.Models;
|
||||
|
||||
namespace ytLive.Services;
|
||||
|
||||
/// <summary>Owns the YouTube-chat overlay behavior: the message buffer, the
|
||||
/// renderer, and the fade/mock-preview timers. The MainViewModel keeps only the
|
||||
/// thin binding surface (the shared <see cref="Messages"/> collection + two
|
||||
/// computed gate props) and hands the scene list in at each call — this class
|
||||
/// owns all chat logic so an AI can read one self-contained unit.</summary>
|
||||
public sealed class ChatOverlayLayer
|
||||
{
|
||||
private readonly ChatBoxRenderer _renderer;
|
||||
|
||||
// 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 },
|
||||
};
|
||||
|
||||
// Chat fade timer: clears chat overlay after N seconds of inactivity.
|
||||
private DispatcherTimer? _fadeTimer;
|
||||
private CancellationTokenSource? _mockCts;
|
||||
|
||||
/// <summary>The live chat message buffer. Shared with the ViewModel (which
|
||||
/// binds it to the panel) via <c>ChatMessages => _chatLayer.Messages</c>.</summary>
|
||||
public ObservableCollection<ChatMessage> Messages { get; } = new();
|
||||
|
||||
// Output-frame cache (take-5 starvation fix): the pump called RenderFrame
|
||||
// every tick and the renderer did a FULL WPF raster (FormattedText +
|
||||
// RenderTargetBitmap + CopyPixels + channel swap) each time — 15-25ms of a
|
||||
// 25.5ms render. Established answer (OBS text sources): raster on change,
|
||||
// blit the cache every tick. _contentVersion bumps on every buffer mutation
|
||||
// (CollectionChanged — covers adds, the 500-cap removal, and the fade
|
||||
// Clear from any caller); the config key covers the box geometry/appearance.
|
||||
// The cached VideoFrame is treated as immutable: the compositor only reads.
|
||||
private int _contentVersion;
|
||||
private int _frameVersion = -1;
|
||||
private string? _frameKey;
|
||||
private VideoFrame? _cachedFrame;
|
||||
|
||||
public ChatOverlayLayer(ChatBoxRenderer renderer)
|
||||
{
|
||||
_renderer = renderer;
|
||||
Messages.CollectionChanged += (_, _) => _contentVersion++;
|
||||
}
|
||||
|
||||
/// <summary>Ingest a message from the poller. UI-thread marshalled: appended
|
||||
/// to the buffer (capped at 500), mock preview stopped, swipe to real
|
||||
/// messages, and the fade timer reset. Scenes are handed in so the layer can
|
||||
/// refresh every ChatBox preview without owning the scene graph.</summary>
|
||||
public void OnMessageReceived(ChatMessage message, IEnumerable<Scene> scenes)
|
||||
{
|
||||
System.Windows.Application.Current.Dispatcher.Invoke(() =>
|
||||
{
|
||||
Messages.Add(message);
|
||||
if (Messages.Count > 500)
|
||||
Messages.RemoveAt(0);
|
||||
|
||||
// Reset chat fade timer on every message
|
||||
ResetChatFadeTimer(scenes);
|
||||
|
||||
StopMockPreview();
|
||||
|
||||
// Switch ChatBox previews from mock to real messages
|
||||
foreach (var chatBox in scenes.SelectMany(s => s.Elements).OfType<Source>()
|
||||
.Where(s => s.Type == SourceType.ChatBox))
|
||||
UpdatePreview(chatBox);
|
||||
});
|
||||
}
|
||||
|
||||
private void ResetChatFadeTimer(IEnumerable<Scene> scenes)
|
||||
{
|
||||
_fadeTimer?.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);
|
||||
_fadeTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(delay) };
|
||||
_fadeTimer.Tick += (_, _) =>
|
||||
{
|
||||
_fadeTimer?.Stop();
|
||||
_fadeTimer = null;
|
||||
Messages.Clear();
|
||||
foreach (var chatBox in scenes.SelectMany(s => s.Elements).OfType<Source>()
|
||||
.Where(s => s.Type == SourceType.ChatBox))
|
||||
UpdatePreview(chatBox);
|
||||
StartMockPreview(scenes);
|
||||
};
|
||||
_fadeTimer.Start();
|
||||
}
|
||||
|
||||
/// <summary>Renders the live-output frame for one ChatBox element (the
|
||||
/// compositor path), or null when no messages are buffered yet. Mirrors the
|
||||
/// preview render but without writing onto the source's VideoImageSource.</summary>
|
||||
public VideoFrame? RenderFrame(Source chatBox)
|
||||
{
|
||||
var width = (int)Math.Max(1, chatBox.Width);
|
||||
var height = (int)Math.Max(1, chatBox.Height);
|
||||
var key =
|
||||
$"{width}x{height}|{chatBox.ChatFontSize}|{chatBox.ChatFontFamily}|{chatBox.ChatFontColor}"
|
||||
+ $"|{chatBox.ChatBackgroundColor}|{chatBox.ChatMaxMessages}|{chatBox.ChatShowTimestamps}"
|
||||
+ $"|{chatBox.ChatShowBadges}|{chatBox.ChatMemberColor}|{chatBox.ChatSuperChatColor}";
|
||||
|
||||
if (_frameKey == key && _frameVersion == _contentVersion)
|
||||
return _cachedFrame; // same content + same config — the cached raster stands
|
||||
|
||||
// The raster is WPF (DrawingVisual + RenderTargetBitmap = UI-thread objects)
|
||||
// and the frame pump now renders from its own thread (take-8 off-UI fix) —
|
||||
// so only the cache CHECK runs off-thread; the rare miss marshals to the
|
||||
// dispatcher and re-validates there (a second message landing mid-hop must
|
||||
// not be answered by a snapshot taken before it).
|
||||
var app = System.Windows.Application.Current;
|
||||
if (app != null && !app.Dispatcher.CheckAccess())
|
||||
{
|
||||
var capturedKey = key;
|
||||
var capturedVersion = _contentVersion;
|
||||
var frame = app.Dispatcher.Invoke(() =>
|
||||
{
|
||||
if (_frameKey == capturedKey && _frameVersion == capturedVersion)
|
||||
return _cachedFrame; // someone already re-rasterized on the UI thread
|
||||
return RenderCore(chatBox, width, height, capturedKey, capturedVersion);
|
||||
});
|
||||
return frame;
|
||||
}
|
||||
|
||||
return RenderCore(chatBox, width, height, key, _contentVersion);
|
||||
}
|
||||
|
||||
private VideoFrame? RenderCore(Source chatBox, int width, int height, string key, int version)
|
||||
{
|
||||
_frameKey = key;
|
||||
_frameVersion = version;
|
||||
if (Messages.Count == 0) return _cachedFrame = null;
|
||||
|
||||
return _cachedFrame = _renderer.Render(
|
||||
Messages,
|
||||
width,
|
||||
height,
|
||||
chatBox.ChatFontSize,
|
||||
chatBox.ChatFontFamily,
|
||||
chatBox.ChatFontColor,
|
||||
chatBox.ChatBackgroundColor,
|
||||
chatBox.ChatMaxMessages,
|
||||
chatBox.ChatShowTimestamps,
|
||||
chatBox.ChatShowBadges,
|
||||
chatBox.ChatMemberColor,
|
||||
chatBox.ChatSuperChatColor);
|
||||
}
|
||||
|
||||
/// <summary>Renders one ChatBox preview: real messages when connected, mock
|
||||
/// when not. Writes the resulting bitmap onto the source's
|
||||
/// <c>VideoImageSource</c>.</summary>
|
||||
public void UpdatePreview(Source chatBox, IReadOnlyList<ChatMessage>? messages = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
messages ??= Messages.Count > 0 ? (IReadOnlyList<ChatMessage>)Messages : MockChatMessages;
|
||||
var width = (int)Math.Max(32, chatBox.Width);
|
||||
var height = (int)Math.Max(32, chatBox.Height);
|
||||
|
||||
var frame = _renderer.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, PixelFormats.Bgra32, null);
|
||||
bitmap.WritePixels(new Int32Rect(0, 0, width, height), frame.BgraPixels, frame.Stride, 0);
|
||||
chatBox.VideoImageSource = bitmap;
|
||||
}
|
||||
catch (Exception ex) { AppLog.Write($"UpdateChatBoxPreview failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
private async Task RunMockPreviewAsync(Source chatBox, CancellationToken ct)
|
||||
{
|
||||
var n = 0;
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
UpdatePreview(chatBox, MockChatMessages.Take(n + 1).ToList());
|
||||
await Task.Delay(1000, ct);
|
||||
n++;
|
||||
if (n == MockChatMessages.Count)
|
||||
{
|
||||
await Task.Delay(3000, ct);
|
||||
n = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void StartMockPreview(IEnumerable<Scene> scenes)
|
||||
{
|
||||
StopMockPreview();
|
||||
_mockCts = new CancellationTokenSource();
|
||||
var ct = _mockCts.Token;
|
||||
foreach (var chatBox in scenes.SelectMany(s => s.Elements).OfType<Source>()
|
||||
.Where(s => s.Type == SourceType.ChatBox && s.ChatPreviewEnabled))
|
||||
_ = RunMockPreviewAsync(chatBox, ct);
|
||||
}
|
||||
|
||||
public void StopMockPreview()
|
||||
{
|
||||
_mockCts?.Cancel();
|
||||
_mockCts?.Dispose();
|
||||
_mockCts = 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>
|
||||
public void UpdateConfiguredBoxes(IEnumerable<Scene> scenes)
|
||||
{
|
||||
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))
|
||||
UpdatePreview(chatBox);
|
||||
};
|
||||
UpdatePreview(chatBox);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Windows;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Imaging;
|
||||
using System.Windows.Threading;
|
||||
using ytLive.Models;
|
||||
|
||||
namespace ytLive.Services.Compositor;
|
||||
|
||||
/// <summary>
|
||||
/// Rasterizes the stream-alert ticker into a transparent BGRA strip that the
|
||||
/// output compositor overlays along the very top edge of the master frame
|
||||
/// (TASK 47). The strip is text ("Viewer — Super Chat · $12.34") inside a dark
|
||||
/// pill with the LlamaCasty accent bar, rendered ONCE per unique text on the UI
|
||||
/// thread (WPF <c>RenderTargetBitmap</c>, Pbgra32→straight-alpha like
|
||||
/// <see cref="SocialBarRenderer"/>), then cached. Each <see cref="Render"/> call
|
||||
/// is pure byte-math on that cached raster producing a scrolling marquee frame,
|
||||
/// so the frame pump can call it from any thread for every output frame at
|
||||
/// 30fps without a Dispatcher round-trip.
|
||||
/// </summary>
|
||||
public static class AlertTickerRenderer
|
||||
{
|
||||
/// <summary>Master-frame width — the ticker spans the full 1920px output.</summary>
|
||||
public const int Width = 1920;
|
||||
|
||||
/// <summary>Strip height (pill + vertical padding).</summary>
|
||||
public const int Height = 48;
|
||||
|
||||
private const int RepeatGap = 120;
|
||||
private const int CacheLimit = 48;
|
||||
|
||||
/// <summary>How long each blink half lasts in <see cref="AlertDisplayMethod.Flash"/>
|
||||
/// (0.5s on, 0.5s off — a 1Hz pulse for the whole alert).</summary>
|
||||
public const double FlashHalfSeconds = 0.5;
|
||||
|
||||
private static readonly object Gate = new();
|
||||
private static readonly Dictionary<string, VideoFrame> StripCache = new();
|
||||
|
||||
/// <summary>A frame of <paramref name="text"/> presented per
|
||||
/// <paramref name="method"/> at <paramref name="elapsedSeconds"/> into the alert,
|
||||
/// or null when there is nothing to show (no text, or a Flash in its off half).
|
||||
/// Thread-safe: reads the cached raster and does row copies only.
|
||||
/// <para><see cref="AlertDisplayMethod.TickerScroll"/> paces itself from
|
||||
/// <paramref name="readsPerAlert"/> over <paramref name="alertSeconds"/> rather
|
||||
/// than a fixed pixels-per-second: the old constant (140px/s ≈ a 17s pass) meant
|
||||
/// a 10s alert showed the text barely once, entering from the right and never
|
||||
/// crossing. The creator's target is three readable passes per alert.</para>
|
||||
/// <para><paramref name="width"/>/<paramref name="height"/> default to the
|
||||
/// full-master strip. They are parameters because the ticker now renders INTO the
|
||||
/// Stream Alerts box (creator ruling 2026-09-26) and therefore has to be the box's
|
||||
/// size: a 1920-wide strip clipped to an 800px box would hide the announcement
|
||||
/// off the right edge for most of its pass, which is the opposite of readable.</para></summary>
|
||||
public static VideoFrame? Render(string? text, double elapsedSeconds,
|
||||
AlertDisplayMethod method = AlertDisplayMethod.TickerScroll,
|
||||
double alertSeconds = 0.0, int readsPerAlert = 3,
|
||||
int width = Width, int height = Height)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text)) return null;
|
||||
if (width <= 0 || height <= 0) return null;
|
||||
var strip = GetStrip(text!);
|
||||
if (strip == null) return null;
|
||||
|
||||
if (method == AlertDisplayMethod.Flash
|
||||
&& elapsedSeconds % (FlashHalfSeconds * 2) >= FlashHalfSeconds)
|
||||
return null; // off half of the pulse
|
||||
|
||||
var x = method == AlertDisplayMethod.TickerScroll
|
||||
? ScrollX(strip.Width, width, elapsedSeconds, alertSeconds, readsPerAlert)
|
||||
: (width - strip.Width) / 2; // Solid + Flash: centred, still
|
||||
|
||||
var pixels = new byte[width * height * 4];
|
||||
CopyStrip(pixels, width, height, strip, x);
|
||||
if (method == AlertDisplayMethod.TickerScroll && x + strip.Width <= 0)
|
||||
CopyStrip(pixels, width, height, strip, x + width + RepeatGap); // wrapped tail enters
|
||||
return new VideoFrame(width, height, pixels);
|
||||
}
|
||||
|
||||
/// <summary>Left edge of the pill for a marquee that must complete
|
||||
/// <paramref name="readsPerAlert"/> passes in <paramref name="alertSeconds"/>:
|
||||
/// one pass (off the right edge, across, out of sight) takes that long.
|
||||
/// <para>The run is phase-started half a frame in, so the very first frame of an
|
||||
/// alert already shows the announcement instead of a blank strip sitting exactly
|
||||
/// off the right edge.</para></summary>
|
||||
private static int ScrollX(int stripWidth, int width, double elapsedSeconds,
|
||||
double alertSeconds, int readsPerAlert)
|
||||
{
|
||||
var cycle = width + stripWidth + RepeatGap;
|
||||
var pass = Math.Max(0.5, alertSeconds) / Math.Max(1, readsPerAlert);
|
||||
var speed = cycle / pass;
|
||||
var pos = (width / 2 + (int)(elapsedSeconds * speed)) % cycle;
|
||||
return width - pos;
|
||||
}
|
||||
|
||||
|
||||
private static void CopyStrip(byte[] dst, int dstW, int dstH, VideoFrame strip, int dstX)
|
||||
{
|
||||
if (dstX >= dstW || dstX + strip.Width <= 0) return;
|
||||
var x0 = Math.Max(0, dstX);
|
||||
var x1 = Math.Min(dstW, dstX + strip.Width);
|
||||
var sx0 = x0 - dstX;
|
||||
var copyBytes = (x1 - x0) * 4;
|
||||
var sw = strip.Width;
|
||||
// The strip is rasterised at its natural 48px; the target box can be shorter,
|
||||
// and copying strip.Height rows into a shorter dst would run off the end of the
|
||||
// array. Clip the rows, not the buffer.
|
||||
var rows = Math.Min(strip.Height, dstH);
|
||||
for (var y = 0; y < rows; y++)
|
||||
Buffer.BlockCopy(strip.BgraPixels, (y * sw + sx0) * 4, dst, (y * dstW + x0) * 4, copyBytes);
|
||||
}
|
||||
|
||||
private static VideoFrame? GetStrip(string text)
|
||||
{
|
||||
lock (Gate)
|
||||
{
|
||||
if (StripCache.TryGetValue(text, out var cached)) return cached;
|
||||
}
|
||||
var strip = RenderStrip(text);
|
||||
if (strip == null) return null;
|
||||
lock (Gate)
|
||||
{
|
||||
if (StripCache.Count >= CacheLimit) StripCache.Clear();
|
||||
if (!StripCache.TryGetValue(text, out var existing))
|
||||
StripCache[text] = strip;
|
||||
return StripCache[text];
|
||||
}
|
||||
}
|
||||
|
||||
private static VideoFrame? RenderStrip(string text)
|
||||
{
|
||||
// WPF rendering is single-threaded; the pump thread resolves to the UI
|
||||
// thread at most once per unique text (then hits the cache).
|
||||
var app = Application.Current;
|
||||
if (app != null && !app.Dispatcher.CheckAccess())
|
||||
{
|
||||
VideoFrame? result = null;
|
||||
app.Dispatcher.Invoke(() => result = RenderStrip(text));
|
||||
return result;
|
||||
}
|
||||
|
||||
const double pillHeight = 34;
|
||||
const double accentW = 6;
|
||||
const double padX = 16;
|
||||
const double gap = 8;
|
||||
|
||||
var font = new Typeface(new FontFamily("Segoe UI"), FontStyles.Normal, FontWeights.SemiBold, FontStretches.Normal);
|
||||
var ft = new FormattedText(text, CultureInfo.InvariantCulture, FlowDirection.LeftToRight,
|
||||
font, 22, Brushes.White, 1.0);
|
||||
|
||||
var pillW = (int)Math.Ceiling(padX + accentW + gap + ft.Width + padX);
|
||||
var top = (Height - pillHeight) / 2.0;
|
||||
var textTop = top + (pillHeight - ft.Height) / 2.0;
|
||||
|
||||
var visual = new DrawingVisual();
|
||||
using (var dc = visual.RenderOpen())
|
||||
{
|
||||
var pill = new Rect(0, top, pillW, pillHeight);
|
||||
dc.DrawRoundedRectangle(new SolidColorBrush(Color.FromArgb(0xE6, 0x10, 0x13, 0x24)), null, pill, pillHeight / 2, pillHeight / 2);
|
||||
dc.DrawRoundedRectangle(new SolidColorBrush(Color.FromRgb(0xe9, 0x45, 0x60)), null,
|
||||
new Rect(padX, top + (pillHeight - 12) / 2.0, accentW, 12), 2, 2);
|
||||
dc.DrawText(ft, new Point(padX + accentW + gap, textTop));
|
||||
}
|
||||
|
||||
var bitmap = new RenderTargetBitmap(pillW, Height, 96, 96, PixelFormats.Pbgra32);
|
||||
bitmap.Render(visual);
|
||||
|
||||
var pixels = new byte[pillW * Height * 4];
|
||||
bitmap.CopyPixels(pixels, pillW * 4, 0);
|
||||
|
||||
// Pbgra32 is premultiplied — unpremultiply so the compositor's
|
||||
// straight-alpha source-over doesn't halo the text edges.
|
||||
for (var i = 0; i < pixels.Length; i += 4)
|
||||
{
|
||||
var a = pixels[i + 3];
|
||||
if (a == 0 || a == 255) continue;
|
||||
pixels[i] = (byte)(pixels[i] * 255 / a);
|
||||
pixels[i + 1] = (byte)(pixels[i + 1] * 255 / a);
|
||||
pixels[i + 2] = (byte)(pixels[i + 2] * 255 / a);
|
||||
}
|
||||
return new VideoFrame(pillW, Height, pixels);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Windows;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Imaging;
|
||||
using System.Windows.Threading;
|
||||
|
||||
namespace ytLive.Services.Compositor;
|
||||
|
||||
/// <summary>
|
||||
/// The free-tier branding flash: a "made with LlamaCasty!" credit that is COMPOSITED
|
||||
/// INTO THE OUTPUT (recording or stream), not merely shown in the preview.
|
||||
/// <para>2026-09-26. This used to exist only as a WPF <c>BrandFlashLayer</c> in
|
||||
/// <c>PreviewPane.xaml</c> — a 0.25-opacity centred TextBlock the creator could see and
|
||||
/// no viewer ever could. The compositor has carried an unused <c>flashFrame</c> slot
|
||||
/// since then; this type is the producer that finally feeds it. It is wired on the
|
||||
/// PER-FRAME path only: <c>SceneGraph.GetBakedBase</c> also accepts a <c>flashFrame</c>,
|
||||
/// and that one is a trap — <c>SceneRegion.Matches</c> compares element ids only, so a
|
||||
/// flash folded into the baked static base would freeze there and never expire.</para>
|
||||
/// <para>The credit is deliberately obnoxious (creator directive 2026-09-26): full-opacity
|
||||
/// neon, a different random position every presentation, and a hard sell every 30–60s.
|
||||
/// It is gated on LICENCE state, never on a UI toggle — see <see cref="Enabled"/>.</para>
|
||||
/// </summary>
|
||||
public sealed class BrandFlashPresenter : IDisposable
|
||||
{
|
||||
/// <summary>The credit line, kept identical to the old preview TextBlock so the
|
||||
/// wording can never drift between the preview and the broadcast.</summary>
|
||||
public const string Text = "made with LlamaCasty!";
|
||||
|
||||
// Presentation envelope (creator-specified 2026-09-26): 500ms fade in, the credit
|
||||
// held at full opacity for 1000ms, then a 500ms fade out — 2000ms end to end.
|
||||
public const double FadeInSeconds = 0.5;
|
||||
public const double HoldSeconds = 1.0;
|
||||
public const double FadeOutSeconds = 0.5;
|
||||
public const double PresentationSeconds = FadeInSeconds + HoldSeconds + FadeOutSeconds;
|
||||
|
||||
/// <summary>Cadence: <c>rand(0, 30s) + 30s</c> ⇒ a flash every 30–60s, so it never
|
||||
/// lands on a beat a viewer could tune out.</summary>
|
||||
public const int IntervalFloorMs = 30_000;
|
||||
public const int IntervalJitterMs = 30_000;
|
||||
|
||||
/// <summary>The first flash lands shortly after go-live (the stream-start
|
||||
/// impression); the random cadence takes over from there.</summary>
|
||||
public const double FirstFlashDelaySeconds = 5.0;
|
||||
|
||||
/// <summary>Master-space canvas. Global overlays in this codebase are authored at
|
||||
/// master size and scaled by the compositor.</summary>
|
||||
public const int MasterWidth = 1920;
|
||||
public const int MasterHeight = 1080;
|
||||
|
||||
/// <summary>Neon recipe taken from the established overlay look (see commit
|
||||
/// message): a bright near-white CORE plus a feathered multi-radius HALO, with the
|
||||
/// red and blue channels pushed in OPPOSITE directions — Maxon's "Chromatic Glow"
|
||||
/// notes the green channel is all but invisible behind white text once R and B are
|
||||
/// split, which is exactly the look.</summary>
|
||||
private static readonly Color NeonRed = Color.FromRgb(0xe9, 0x45, 0x60); // brand red, exact
|
||||
private static readonly Color NeonBlue = Color.FromRgb(0x4d, 0x8b, 0xff); // brand blue #0f3460 lifted for luminance
|
||||
private static readonly Color CoreColor = Colors.White;
|
||||
|
||||
/// <summary>Font size chosen so the credit spans roughly half the master width:
|
||||
/// big enough to read as advertising, small enough that the random placement has
|
||||
/// real travel room (a full-width line could only ever sit at x≈0).</summary>
|
||||
private const double FontSize = 120;
|
||||
private const double GlowPadding = 34;
|
||||
private const double TickSeconds = 0.033;
|
||||
|
||||
private readonly Random _random;
|
||||
private readonly DispatcherTimer? _timer;
|
||||
private readonly object _gate = new();
|
||||
|
||||
private bool _enabled = true;
|
||||
private bool _running;
|
||||
private double _sinceStart;
|
||||
private double _nextFlashAt; // absolute time of the next flash, _sinceStart's reference
|
||||
private double _elapsed; // seconds into the presentation; >= PresentationSeconds means idle
|
||||
private VideoFrame? _raster; // tight, full-opacity glowing credit (straight alpha)
|
||||
private byte[]? _buffer; // reused master-space BGRA — hence the Epoch bump below
|
||||
private int _atX, _atY;
|
||||
private long _epoch;
|
||||
|
||||
public BrandFlashPresenter(Random? random = null)
|
||||
{
|
||||
_random = random ?? new Random();
|
||||
if (Application.Current is { } app)
|
||||
{
|
||||
_timer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(TickSeconds) };
|
||||
_timer.Tick += (_, _) => Advance(TickSeconds);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The licence gate, driven by <c>MainViewModel.IsPremium</c> (Polar
|
||||
/// validation) and NOT by a user-facing toggle: once this credit is composited into
|
||||
/// the broadcast it is the thing standing between a paying customer's stream and an
|
||||
/// advertisement, so it is re-checked on every read. A premium user mid-presentation
|
||||
/// loses the flash on the very next tick rather than at the end of the credit.</summary>
|
||||
public bool Enabled
|
||||
{
|
||||
get { lock (_gate) return _enabled; }
|
||||
set
|
||||
{
|
||||
bool restart;
|
||||
lock (_gate)
|
||||
{
|
||||
if (_enabled == value) return;
|
||||
_enabled = value;
|
||||
if (!value)
|
||||
{
|
||||
// Licence state only ever goes premium → no flash. Cut the current
|
||||
// presentation short instead of running it out ungated.
|
||||
_elapsed = PresentationSeconds;
|
||||
_timer?.Stop();
|
||||
restart = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
// …but a DOWNGRADE (key entered mid-session) must bring the cadence
|
||||
// back. The presenter now runs for the life of the app — Start() is
|
||||
// called once at startup, not per go-live — so stopping the timer on
|
||||
// the premium edge without restarting it here would leave the credit
|
||||
// dead until the process is restarted, even after a valid key.
|
||||
restart = _running;
|
||||
}
|
||||
}
|
||||
if (restart) _timer?.Start();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>True while a credit is on screen (drives the preview's visibility).</summary>
|
||||
public bool IsPresenting
|
||||
{
|
||||
get { lock (_gate) return _enabled && _elapsed < PresentationSeconds; }
|
||||
}
|
||||
|
||||
/// <summary>Test-only: is the <see cref="DispatcherTimer"/> that drives
|
||||
/// <see cref="Advance"/> actually running? The premium/downgrade edge is the only
|
||||
/// thing that ever stops and restarts it now that the presenter is started once for
|
||||
/// the life of the app, and a synchronous test body cannot observe a real timer
|
||||
/// tick — so assert the decision directly instead of sleeping through a 30–60s
|
||||
/// interval. Never call this from product code.</summary>
|
||||
internal bool IsCadenceTimerEnabled => _timer?.IsEnabled ?? false;
|
||||
|
||||
/// <summary>Begin the cadence: first flash <see cref="FirstFlashDelaySeconds"/>
|
||||
/// after the call, then every <c>rand(30s) + 30s</c>.</summary>
|
||||
public void Start()
|
||||
{
|
||||
EnsureRaster();
|
||||
lock (_gate)
|
||||
{
|
||||
_running = true;
|
||||
_sinceStart = 0;
|
||||
_nextFlashAt = FirstFlashDelaySeconds;
|
||||
}
|
||||
if (_enabled) _timer?.Start();
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_running = false;
|
||||
_elapsed = PresentationSeconds;
|
||||
}
|
||||
_timer?.Stop();
|
||||
}
|
||||
|
||||
/// <summary>Advance the presentation clock. Also the deterministic test seam: tests
|
||||
/// drive this directly and never call <see cref="Start"/>, so no timer is running
|
||||
/// and the clock only moves when the test moves it (mirrors <c>AlertOverlayLayer.Advance</c>).</summary>
|
||||
public void Advance(double deltaSeconds)
|
||||
{
|
||||
// Rasterise BEFORE taking the lock. WPF text formatting is UI-thread affine, so
|
||||
// this can hop to the dispatcher — and a blocking hop while holding _gate would
|
||||
// deadlock against a timer tick that wants the same lock.
|
||||
EnsureRaster();
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_enabled) return;
|
||||
|
||||
if (_running)
|
||||
{
|
||||
_sinceStart += deltaSeconds;
|
||||
if (_sinceStart >= _nextFlashAt)
|
||||
{
|
||||
// The interval runs FROM THIS FLASH, not from Start(). An absolute
|
||||
// deadline here made the gaps shrink every cycle (5s, then whatever
|
||||
// was left of the previous interval) and collapse toward zero.
|
||||
_nextFlashAt = _sinceStart + NextIntervalSeconds();
|
||||
BeginPresentation();
|
||||
}
|
||||
}
|
||||
|
||||
if (_elapsed < PresentationSeconds)
|
||||
{
|
||||
_elapsed += deltaSeconds;
|
||||
if (_elapsed >= PresentationSeconds) _elapsed = PresentationSeconds;
|
||||
}
|
||||
}
|
||||
|
||||
Notify();
|
||||
}
|
||||
|
||||
/// <summary>Invoked on the UI thread at the end of every tick with the frame that
|
||||
/// tick produced (null when nothing is due) — the preview's repaint hook, so the
|
||||
/// preview and the broadcast are driven by ONE clock.</summary>
|
||||
public Action<VideoFrame?>? OnFrame { get; set; }
|
||||
|
||||
private void Notify()
|
||||
{
|
||||
var cb = OnFrame;
|
||||
if (cb == null) return;
|
||||
// Read OUTSIDE the state lock: the callback is arbitrary code and must never
|
||||
// run while _gate is held.
|
||||
var frame = Frame;
|
||||
if (frame == null) { cb(null); return; }
|
||||
|
||||
if (Application.Current is { } app && !app.Dispatcher.CheckAccess())
|
||||
{
|
||||
// Off the UI thread (a test driving the clock). The frame's buffer is
|
||||
// recycled, so hand the deferred copy its own pixels.
|
||||
var copy = new byte[frame.BgraPixels.Length];
|
||||
Buffer.BlockCopy(frame.BgraPixels, 0, copy, 0, copy.Length);
|
||||
app.Dispatcher.BeginInvoke(() => cb(new VideoFrame(frame.Width, frame.Height, copy)));
|
||||
}
|
||||
else
|
||||
{
|
||||
cb(frame);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Force a presentation now — the "show me the flash" path and the test seam.</summary>
|
||||
public void Show()
|
||||
{
|
||||
EnsureRaster();
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_enabled) return;
|
||||
BeginPresentation();
|
||||
}
|
||||
}
|
||||
|
||||
private double NextIntervalSeconds()
|
||||
=> (IntervalFloorMs + _random.Next(IntervalJitterMs)) / 1000.0;
|
||||
|
||||
private void BeginPresentation()
|
||||
{
|
||||
if (_raster == null) return;
|
||||
_buffer ??= new byte[MasterWidth * MasterHeight * 4];
|
||||
// A new presentation can land anywhere, so the previous credit's pixels must go
|
||||
// before this one is written or the old spot ghosts through the fade. One 8MB
|
||||
// clear per presentation (not per tick) keeps that cheap.
|
||||
Array.Clear(_buffer);
|
||||
var spot = ComputePlacement(_raster.Width, _raster.Height, MasterWidth, MasterHeight, _random);
|
||||
_atX = (int)spot.X;
|
||||
_atY = (int)spot.Y;
|
||||
_elapsed = 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The credit for the output compositor and the preview: a master-space frame holding
|
||||
/// the glowing text at this presentation's random spot, alpha-scaled to the current
|
||||
/// point on the fade envelope. Null when idle, or unlicensed.
|
||||
/// <para><b>The frame's pixel buffer is recycled across ticks</b> (only the text rect
|
||||
/// is rewritten) and its <see cref="VideoFrame.Epoch"/> is bumped every read, so the
|
||||
/// compositor's buffer-identity paste cache can never false-hit a stale credit.
|
||||
/// Consumers blit synchronously — copy the pixels if you need to keep them.</para></summary>
|
||||
public VideoFrame? Frame
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_enabled || _elapsed >= PresentationSeconds || _raster == null || _buffer == null)
|
||||
return null;
|
||||
var opacity = OpacityAt(_elapsed);
|
||||
if (opacity <= 0) return null;
|
||||
Paint(_buffer, _raster, _atX, _atY, opacity);
|
||||
return new VideoFrame(MasterWidth, MasterHeight, _buffer) { Epoch = ++_epoch };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Full → hold → empty. Public and static so the preview and the tests
|
||||
/// agree with the renderer instead of re-deriving the curve.</summary>
|
||||
public static double OpacityAt(double elapsedSeconds)
|
||||
{
|
||||
if (elapsedSeconds < 0 || elapsedSeconds >= PresentationSeconds) return 0;
|
||||
if (elapsedSeconds < FadeInSeconds) return elapsedSeconds / FadeInSeconds;
|
||||
if (elapsedSeconds < FadeInSeconds + HoldSeconds) return 1.0;
|
||||
return 1.0 - ((elapsedSeconds - (FadeInSeconds + HoldSeconds)) / FadeOutSeconds);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A random top-left offset that keeps the whole credit ON the canvas. The text is a
|
||||
/// single pre-measured line, so it can never wrap — this is the "how far can the
|
||||
/// left margin travel" limit the creator described: <c>0 … (canvas - creditWidth)</c>
|
||||
/// horizontally, likewise vertically. Pure + static, so the rule is directly testable.
|
||||
/// </summary>
|
||||
public static Point ComputePlacement(int creditWidth, int creditHeight,
|
||||
int canvasWidth, int canvasHeight, Random random)
|
||||
{
|
||||
var maxX = Math.Max(0, canvasWidth - creditWidth);
|
||||
var maxY = Math.Max(0, canvasHeight - creditHeight);
|
||||
return new Point(random.Next(0, maxX + 1), random.Next(0, maxY + 1));
|
||||
}
|
||||
|
||||
/// <summary>Rewrite the credit rect into the master buffer with alpha scaled to
|
||||
/// <paramref name="opacity"/>. The rect is fully overwritten each tick and the spot
|
||||
/// is fixed for the whole presentation, so nothing stale accumulates and no clear
|
||||
/// pass is needed here.</summary>
|
||||
private static void Paint(byte[] buffer, VideoFrame raster, int atX, int atY, double opacity)
|
||||
{
|
||||
for (var y = 0; y < raster.Height; y++)
|
||||
{
|
||||
var dst = ((atY + y) * MasterWidth + atX) * 4;
|
||||
var src = y * raster.Stride;
|
||||
for (var x = 0; x < raster.Width; x++)
|
||||
{
|
||||
var a = raster.BgraPixels[src + x * 4 + 3];
|
||||
if (a == 0) continue;
|
||||
buffer[dst + x * 4] = raster.BgraPixels[src + x * 4];
|
||||
buffer[dst + x * 4 + 1] = raster.BgraPixels[src + x * 4 + 1];
|
||||
buffer[dst + x * 4 + 2] = raster.BgraPixels[src + x * 4 + 2];
|
||||
buffer[dst + x * 4 + 3] = (byte)Math.Round(a * opacity);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Build the credit raster once, on the UI thread, and publish it under
|
||||
/// the lock. Called from every clock entry point, so it is a no-op after the first.</summary>
|
||||
private void EnsureRaster()
|
||||
{
|
||||
lock (_gate) { if (_raster != null) return; }
|
||||
var raster = RenderNeonText(Text);
|
||||
lock (_gate) { if (raster != null) { _raster = raster; } }
|
||||
}
|
||||
|
||||
// ── rasterisation ──────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Rasterize the credit once, tight to the glyphs plus glow, on the UI thread (WPF
|
||||
/// <c>RenderTargetBitmap</c>, Pbgra32→straight alpha exactly like
|
||||
/// <see cref="AlertTickerRenderer"/>) and cached — this is per-text work, not
|
||||
/// per-frame, so the pump can afford to run every tick at 30fps.
|
||||
/// <para>The neon is a bright core plus a feathered halo: the glyphs redrawn at
|
||||
/// shrinking radii with rising alpha, the two colours offset in opposite directions,
|
||||
/// which is what makes it read as a lit tube rather than a drop shadow.</para></summary>
|
||||
private static VideoFrame? RenderNeonText(string text)
|
||||
{
|
||||
if (Application.Current is { } app && !app.Dispatcher.CheckAccess())
|
||||
{
|
||||
VideoFrame? result = null;
|
||||
app.Dispatcher.Invoke(() => result = RenderNeonText(text));
|
||||
return result;
|
||||
}
|
||||
|
||||
var font = new Typeface(new FontFamily("Segoe UI"), FontStyles.Normal,
|
||||
FontWeights.Bold, FontStretches.Normal);
|
||||
var probe = new FormattedText(text, CultureInfo.InvariantCulture,
|
||||
FlowDirection.LeftToRight, font, FontSize, Brushes.White, 1.0);
|
||||
|
||||
var w = (int)Math.Ceiling(probe.Width) + (int)(GlowPadding * 2);
|
||||
var h = (int)Math.Ceiling(probe.Height) + (int)(GlowPadding * 2);
|
||||
if (w <= 0 || h <= 0) return null;
|
||||
|
||||
var visual = new DrawingVisual();
|
||||
using (var dc = visual.RenderOpen())
|
||||
{
|
||||
// Three halo rings per colour. Wide+dim first, then progressively tighter and
|
||||
// brighter, so the falloff is feathered instead of a hard-edged blob.
|
||||
foreach (var (radius, alpha) in new[] { (30.0, 0.16), (20.0, 0.26), (11.0, 0.40) })
|
||||
{
|
||||
DrawHalo(dc, text, font, radius, alpha, NeonRed, +1);
|
||||
DrawHalo(dc, text, font, radius, alpha, NeonBlue, -1);
|
||||
}
|
||||
// A tight white bloom hugging the glyphs sells the brightness of the tube.
|
||||
DrawHalo(dc, text, font, 4.0, 0.55, CoreColor, +1);
|
||||
// The core itself, sharp and opaque, drawn last.
|
||||
dc.DrawText(new FormattedText(text, CultureInfo.InvariantCulture,
|
||||
FlowDirection.LeftToRight, font, FontSize, new SolidColorBrush(CoreColor), 1.0),
|
||||
new Point(GlowPadding, GlowPadding));
|
||||
}
|
||||
|
||||
var bitmap = new RenderTargetBitmap(w, h, 96, 96, PixelFormats.Pbgra32);
|
||||
bitmap.Render(visual);
|
||||
|
||||
var pixels = new byte[w * h * 4];
|
||||
bitmap.CopyPixels(pixels, w * 4, 0);
|
||||
|
||||
// Pbgra32 is premultiplied — unpremultiply, or straight-alpha source-over in the
|
||||
// compositor haloes the glyph edges.
|
||||
for (var i = 0; i < pixels.Length; i += 4)
|
||||
{
|
||||
var a = pixels[i + 3];
|
||||
if (a == 0 || a == 255) continue;
|
||||
pixels[i] = (byte)(pixels[i] * 255 / a);
|
||||
pixels[i + 1] = (byte)(pixels[i + 1] * 255 / a);
|
||||
pixels[i + 2] = (byte)(pixels[i + 2] * 255 / a);
|
||||
}
|
||||
return new VideoFrame(w, h, pixels);
|
||||
}
|
||||
|
||||
private static void DrawHalo(DrawingContext dc, string text, Typeface font,
|
||||
double radius, double alpha, Color color, int direction)
|
||||
{
|
||||
var brush = new SolidColorBrush(color);
|
||||
const int spokes = 12;
|
||||
for (var i = 0; i < spokes; i++)
|
||||
{
|
||||
var angle = 2 * Math.PI * i / spokes;
|
||||
brush.Opacity = alpha;
|
||||
var ft = new FormattedText(text, CultureInfo.InvariantCulture,
|
||||
FlowDirection.LeftToRight, font, FontSize, brush, 1.0);
|
||||
dc.DrawText(ft, new Point(
|
||||
GlowPadding + Math.Cos(angle) * radius * direction,
|
||||
GlowPadding + Math.Sin(angle) * radius * direction));
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose() => _timer?.Stop();
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using ytLive.Helpers;
|
||||
using ytLive.Models;
|
||||
|
||||
namespace ytLive.Services.Compositor;
|
||||
@@ -14,12 +15,32 @@ namespace ytLive.Services.Compositor;
|
||||
/// </summary>
|
||||
public sealed class SceneCompositor
|
||||
{
|
||||
// Paste cache (take-8 fix, 2026-09-04): the Live scene re-sampled ~680k bilinear
|
||||
// pixels EVERY tick even though only the camera actually changes at that rate —
|
||||
// chat/web/image layers are identical frame after frame. Same shape as OBS (the
|
||||
// source surface is cached, the compositor blits): a scaled/round-clipped/mirrored
|
||||
// layer rasterizes once and is pasted (row-copy/blend) on every later tick.
|
||||
// Keyed by the SOURCE FRAME IDENTITY (array + Epoch + CropBounds) — producers hand
|
||||
// out fresh immutable byte[], or recycled-ring arrays whose monotonic Epoch distinguishes
|
||||
// each generation (screen capture, take-11 fix). CropBounds must be in the key because
|
||||
// the same frame (same Pixels/Epoch/Width/Height) can have different CropBounds as
|
||||
// the element is resized — a stale cached raster with wrong crop pixels would corrupt
|
||||
// the output. Bounds-checked eviction clears wholesale.
|
||||
private sealed record PasteKey(byte[] Pixels, long Epoch, int SrcW, int SrcH, int DstW, int DstH, bool Round, bool Mirror, (int X, int Y, int W, int H)? CropBounds);
|
||||
private static readonly Dictionary<PasteKey, VideoFrame> _pasteCache = new();
|
||||
private static readonly Dictionary<SceneElement, VideoFrame> _elementRasters = new(ReferenceEqualityComparer.Instance);
|
||||
private static readonly object _pasteGate = new();
|
||||
private const int MaxPasteEntries = 256;
|
||||
|
||||
/// <summary>
|
||||
/// Composite <paramref name="scene"/> into the tier's output frame. Layer order (back →
|
||||
/// front): live background (the scene's <c>IsBackground</c> source) → background image →
|
||||
/// visible elements (z-order = <c>Elements</c> order, mirroring the XAML DataTemplate) →
|
||||
/// branding flash → social bar (a global overlay; spans the master width at
|
||||
/// <paramref name="socialBarTop"/>). Transparent regions read opaque black.
|
||||
/// <paramref name="socialBarTop"/>) → stream-alert ticker (drawn INSIDE the Stream
|
||||
/// Alerts box, at the origin the frame carries on <see cref="VideoFrame.Placement"/>;
|
||||
/// creator ruling 2026-09-26 — it used to be a global bar pinned to the very top).
|
||||
/// Transparent regions read opaque black.
|
||||
/// </summary>
|
||||
public VideoFrame Render(
|
||||
Scene scene,
|
||||
@@ -27,7 +48,11 @@ public sealed class SceneCompositor
|
||||
VideoFrame? flashFrame,
|
||||
CompositorOptions options,
|
||||
VideoFrame? socialBarFrame = null,
|
||||
int socialBarTop = 0)
|
||||
int socialBarTop = 0,
|
||||
VideoFrame? staticBase = null,
|
||||
int split = -1,
|
||||
byte[]? scratch = null,
|
||||
VideoFrame? tickerFrame = null)
|
||||
{
|
||||
if (scene == null) throw new ArgumentNullException(nameof(scene));
|
||||
if (frameFor == null) throw new ArgumentNullException(nameof(frameFor));
|
||||
@@ -39,46 +64,75 @@ public sealed class SceneCompositor
|
||||
|
||||
var cropW = options.SourceRectWidth;
|
||||
var cropH = options.SourceRectHeight;
|
||||
var buffer = new byte[cropW * cropH * 4];
|
||||
for (var i = 3; i < buffer.Length; i += 4)
|
||||
buffer[i] = 255; // opaque black base — video frames are never transparent
|
||||
var buffer = scratch != null && scratch.Length == cropW * cropH * 4
|
||||
? scratch
|
||||
: new byte[cropW * cropH * 4];
|
||||
|
||||
var elements = scene.Elements;
|
||||
|
||||
var liveCapture = elements.OfType<Source>().FirstOrDefault(s => s.IsBackground);
|
||||
var liveCaptureFrame = liveCapture != null && liveCapture.IsVisible ? frameFor(liveCapture) : null;
|
||||
if (liveCaptureFrame != null)
|
||||
BlitContent(buffer, cropW, cropH, 0, 0, cropW, cropH, liveCaptureFrame, 1f, false, false);
|
||||
|
||||
var background = elements.OfType<Source>().FirstOrDefault(s => s.Type == SourceType.Background);
|
||||
var backgroundFrame = background != null ? frameFor(background) : null;
|
||||
if (backgroundFrame != null)
|
||||
BlitContent(buffer, cropW, cropH, 0, 0, cropW, cropH, backgroundFrame, 1f, false, false);
|
||||
|
||||
foreach (var element in elements)
|
||||
// staticBase != null: the caller pre-baked all static layers below `split`.
|
||||
// We copy it (never mutate the cached base) and composite elements[split..] on top.
|
||||
if (staticBase != null)
|
||||
{
|
||||
if (staticBase.Width != cropW || staticBase.Height != cropH)
|
||||
throw new ArgumentException("The static base must match the source rect.", nameof(staticBase));
|
||||
Buffer.BlockCopy(staticBase.BgraPixels, 0, buffer, 0, buffer.Length);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (split < 0) split = scene.Elements.Count;
|
||||
|
||||
var elements = scene.Elements;
|
||||
var liveCapture = elements.OfType<Source>().FirstOrDefault(s => s.IsBackground);
|
||||
var liveCaptureFrame = liveCapture != null && liveCapture.IsVisible ? frameFor(liveCapture) : null;
|
||||
|
||||
// A master-sized live backdrop paints every pixel of the base — the
|
||||
// opaque-black pre-fill (2M byte writes) would be dead work. Capture
|
||||
// frames are opaque BGRA by contract (the WinRT path ignores alpha),
|
||||
// so the 1:1 row walk covers the full rect.
|
||||
var covered = liveCaptureFrame != null
|
||||
&& liveCaptureFrame.Width == cropW && liveCaptureFrame.Height == cropH;
|
||||
if (!covered)
|
||||
for (var i = 3; i < buffer.Length; i += 4)
|
||||
buffer[i] = 255; // opaque black base
|
||||
|
||||
if (liveCaptureFrame != null)
|
||||
BlitContent(buffer, cropW, cropH, 0, 0, cropW, cropH, liveCaptureFrame, 1f, false, false);
|
||||
|
||||
var background = elements.OfType<Source>().FirstOrDefault(s => s.Type == SourceType.Background);
|
||||
var backgroundFrame = background != null ? frameFor(background) : null;
|
||||
if (backgroundFrame != null)
|
||||
BlitContent(buffer, cropW, cropH, 0, 0, cropW, cropH, backgroundFrame, 1f, false, false);
|
||||
|
||||
foreach (var element in elements)
|
||||
{
|
||||
if (!element.IsVisible) continue;
|
||||
switch (element)
|
||||
{
|
||||
case Source { IsBackground: true }:
|
||||
case Source { Type: SourceType.Background }:
|
||||
case Source { Type: SourceType.TextOverlay }:
|
||||
continue;
|
||||
}
|
||||
var fullFrame = frameFor(element);
|
||||
if (fullFrame == null) continue;
|
||||
BlitBakedElement(buffer, cropW, cropH, options, element, fullFrame);
|
||||
}
|
||||
}
|
||||
|
||||
// Composite the above-split layers (dynamic + any static above them) per frame.
|
||||
for (var i = Math.Max(0, split); i < scene.Elements.Count; i++)
|
||||
{
|
||||
var element = scene.Elements[i];
|
||||
if (!element.IsVisible) continue;
|
||||
switch (element)
|
||||
{
|
||||
case Source { IsBackground: true }:
|
||||
case Source { Type: SourceType.Background }:
|
||||
case Source { Type: SourceType.TextOverlay }:
|
||||
continue; // background/background are their own layers; Text isn't shipped
|
||||
continue;
|
||||
}
|
||||
|
||||
var frame = frameFor(element);
|
||||
if (frame == null) continue;
|
||||
|
||||
var ex = (float)(element.X - options.SourceRectX);
|
||||
var ey = (float)(element.Y - options.SourceRectY);
|
||||
var ew = (float)element.Width;
|
||||
var eh = (float)element.Height;
|
||||
var isRound = element.ClipShape == ClipShape.Round;
|
||||
|
||||
BlitContent(buffer, cropW, cropH, ex, ey, ew, eh, frame,
|
||||
(float)element.Opacity, isRound, element.IsMirrored);
|
||||
if (element.HasBorder)
|
||||
DrawBorder(buffer, cropW, cropH, ex, ey, ew, eh, element, isRound);
|
||||
BlitBakedElement(buffer, cropW, cropH, options, element, frame);
|
||||
}
|
||||
|
||||
if (flashFrame != null)
|
||||
@@ -87,10 +141,242 @@ public sealed class SceneCompositor
|
||||
if (socialBarFrame != null && socialBarFrame.Width > 0 && socialBarFrame.Height > 0)
|
||||
BlitOverlay(buffer, cropW, cropH, options, socialBarFrame, 0, socialBarTop);
|
||||
|
||||
if (tickerFrame != null && tickerFrame.Width > 0 && tickerFrame.Height > 0)
|
||||
BlitOverlay(buffer, cropW, cropH, options, tickerFrame, tickerFrame.OriginX, tickerFrame.OriginY);
|
||||
|
||||
return StretchMath.BilinearScale(
|
||||
new VideoFrame(cropW, cropH, buffer), options.OutputWidth, options.OutputHeight);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Render the static base of a scene: the opaque black layer, the live-capture
|
||||
/// background, the background image, and every <paramref name="split"/> z-layers
|
||||
/// below the split point (all of which are static by construction — see
|
||||
/// <see cref="SceneElement.Kind"/>). The result is a source-rect sized frame that
|
||||
/// <see cref="Render"/> can start from and composite the dynamic/above-split layers
|
||||
/// onto each tick. Returns null when there is no static layer at all.
|
||||
/// </summary>
|
||||
public static VideoFrame? BakeStaticBase(
|
||||
Scene scene,
|
||||
int split,
|
||||
Func<SceneElement, VideoFrame?> frameFor,
|
||||
Func<CompositorOptions> options,
|
||||
VideoFrame? socialBarFrame = null,
|
||||
int socialBarTop = 0,
|
||||
VideoFrame? flashFrame = null)
|
||||
{
|
||||
if (split <= 0) return null;
|
||||
var opts = options();
|
||||
if (opts.SourceRectWidth <= 0 || opts.SourceRectHeight <= 0) return null;
|
||||
|
||||
var cropW = opts.SourceRectWidth;
|
||||
var cropH = opts.SourceRectHeight;
|
||||
var buffer = new byte[cropW * cropH * 4];
|
||||
for (var i = 3; i < buffer.Length; i += 4)
|
||||
buffer[i] = 255;
|
||||
|
||||
var elements = scene.Elements;
|
||||
|
||||
var liveCapture = elements.OfType<Source>().FirstOrDefault(s => s.IsBackground);
|
||||
if (liveCapture != null)
|
||||
{
|
||||
var liveCaptureFrame = liveCapture.IsVisible ? frameFor(liveCapture) : null;
|
||||
if (liveCaptureFrame != null && split > elements.IndexOf(liveCapture))
|
||||
BlitContent(buffer, cropW, cropH, 0, 0, cropW, cropH, liveCaptureFrame, 1f, false, false);
|
||||
}
|
||||
|
||||
var background = elements.OfType<Source>().FirstOrDefault(s => s.Type == SourceType.Background);
|
||||
if (background != null)
|
||||
{
|
||||
var backgroundFrame = frameFor(background);
|
||||
if (backgroundFrame != null && split > elements.IndexOf(background))
|
||||
BlitContent(buffer, cropW, cropH, 0, 0, cropW, cropH, backgroundFrame, 1f, false, false);
|
||||
}
|
||||
|
||||
for (var i = 0; i < split; i++)
|
||||
{
|
||||
var element = scene.Elements[i];
|
||||
if (!element.IsVisible) continue;
|
||||
switch (element)
|
||||
{
|
||||
case Source { IsBackground: true }:
|
||||
case Source { Type: SourceType.Background }:
|
||||
case Source { Type: SourceType.TextOverlay }:
|
||||
continue;
|
||||
}
|
||||
var frame = frameFor(element);
|
||||
if (frame == null) continue;
|
||||
BlitBakedElement(buffer, cropW, cropH, opts, element, frame);
|
||||
}
|
||||
|
||||
var baked = new VideoFrame(cropW, cropH, buffer);
|
||||
if (flashFrame != null)
|
||||
baked = Overlay(baked, opts, flashFrame, 0, 0);
|
||||
if (socialBarFrame != null && socialBarFrame.Width > 0 && socialBarFrame.Height > 0)
|
||||
baked = Overlay(baked, opts, socialBarFrame, 0, socialBarTop);
|
||||
return baked;
|
||||
}
|
||||
|
||||
/// <summary>The compositor's dynamic/above-split path: start from an existing frame
|
||||
/// buffer and composite the z-layers from <paramref name="split"/> onward on top.
|
||||
/// When <paramref name="baseFrame"/> is null the caller must fall back to full render.
|
||||
/// The social bar never reaches here (it IS baked into the static base) but the alert
|
||||
/// ticker must: it is dynamic by construction — only ever present while an alert plays —
|
||||
/// so it overlays LAST, pinned to the very top (TASK 47).</summary>
|
||||
public static VideoFrame CompositeLayers(
|
||||
VideoFrame baseFrame,
|
||||
Scene scene,
|
||||
int split,
|
||||
Func<SceneElement, VideoFrame?> frameFor,
|
||||
CompositorOptions options,
|
||||
VideoFrame? socialBarFrame = null,
|
||||
int socialBarTop = 0,
|
||||
VideoFrame? flashFrame = null,
|
||||
byte[]? scratch = null,
|
||||
VideoFrame? tickerFrame = null)
|
||||
{
|
||||
var cropW = options.SourceRectWidth;
|
||||
var cropH = options.SourceRectHeight;
|
||||
// We must not mutate the cached base: copy it into a fresh (or pooled) buffer.
|
||||
var buffer = scratch != null && scratch.Length == baseFrame.BgraPixels.Length
|
||||
? scratch
|
||||
: new byte[baseFrame.BgraPixels.Length];
|
||||
Buffer.BlockCopy(baseFrame.BgraPixels, 0, buffer, 0, baseFrame.BgraPixels.Length);
|
||||
|
||||
for (var i = split; i < scene.Elements.Count; i++)
|
||||
{
|
||||
var element = scene.Elements[i];
|
||||
if (!element.IsVisible) continue;
|
||||
switch (element)
|
||||
{
|
||||
case Source { IsBackground: true }:
|
||||
case Source { Type: SourceType.Background }:
|
||||
case Source { Type: SourceType.TextOverlay }:
|
||||
continue;
|
||||
}
|
||||
var frame = frameFor(element);
|
||||
if (frame == null) continue;
|
||||
BlitBakedElement(buffer, cropW, cropH, options, element, frame);
|
||||
}
|
||||
|
||||
if (tickerFrame != null && tickerFrame.Width > 0 && tickerFrame.Height > 0)
|
||||
BlitOverlay(buffer, cropW, cropH, options, tickerFrame, tickerFrame.OriginX, tickerFrame.OriginY);
|
||||
|
||||
return StretchMath.BilinearScale(new VideoFrame(cropW, cropH, buffer), options.OutputWidth, options.OutputHeight);
|
||||
}
|
||||
|
||||
private static void BlitBakedElement(
|
||||
byte[] buffer, int cropW, int cropH, CompositorOptions options, SceneElement element, VideoFrame frame)
|
||||
{
|
||||
var ex = (float)(element.X - options.SourceRectX);
|
||||
var ey = (float)(element.Y - options.SourceRectY);
|
||||
var ew = (float)element.Width;
|
||||
var eh = (float)element.Height;
|
||||
var isRound = element.ClipShape == ClipShape.Round;
|
||||
|
||||
// All rect-placed elements route through the paste cache (take-9 lesson): the
|
||||
// IsOpaque bypass kept re-sampling the webcam (~156k px) EVERY tick even when
|
||||
// the device frame had not changed; an opaque source rasterizes to an opaque
|
||||
// raster whose paste is a per-pixel copy branch, so hits beat the sampler and
|
||||
// misses cost exactly what the bypass cost.
|
||||
BlitCachedLayer(buffer, cropW, cropH, ex, ey, ew, eh, frame, element, isRound);
|
||||
if (element.HasBorder)
|
||||
DrawBorder(buffer, cropW, cropH, ex, ey, ew, eh, element, isRound);
|
||||
}
|
||||
|
||||
/// <summary>Non-opaque layer blit through the paste cache: the element-space raster
|
||||
/// (scaled to the element rect, round-clipped, mirrored) depends only on the source
|
||||
/// frame + rect + shape/mirror — NOT on position or opacity. Static content (chat,
|
||||
/// web overlays, images, the media frame between updates) is sampled ONCE and pasted
|
||||
/// at integer position with per-tixel blend; only layers whose source frame actually
|
||||
/// changed (webcam at device fps) pay resampling.</summary>
|
||||
private static void BlitCachedLayer(
|
||||
byte[] buffer, int cropW, int cropH,
|
||||
float ex, float ey, float ew, float eh,
|
||||
VideoFrame frame, SceneElement element, bool isRound)
|
||||
{
|
||||
if (ew <= 0 || eh <= 0) return;
|
||||
var dw = (int)System.Math.Ceiling(ew);
|
||||
var dh = (int)System.Math.Ceiling(eh);
|
||||
if (dw <= 0 || dh <= 0) return;
|
||||
var mirror = element.IsMirrored;
|
||||
|
||||
PasteKey? key = null;
|
||||
VideoFrame? raster = null;
|
||||
lock (_pasteGate)
|
||||
{
|
||||
key = new PasteKey(frame.BgraPixels, frame.Epoch, frame.Width, frame.Height, dw, dh, isRound, mirror, frame.CropBounds);
|
||||
_pasteCache.TryGetValue(key, out raster);
|
||||
}
|
||||
|
||||
if (raster == null)
|
||||
{
|
||||
// Rasterize in ELEMENT space on a TRANSPARENT base: the sampler writes 255
|
||||
// alpha only where it actually draws, so untouched margins (outside the
|
||||
// round clip, past the UniformToFill edges) stay alpha 0 and the paste
|
||||
// reveals the master beneath instead of black. The ARRAY is reused per
|
||||
// element (see _elementRasters note) — new source generations re-raster
|
||||
// in place, so the steady state allocates nothing.
|
||||
lock (_pasteGate)
|
||||
{
|
||||
if (_elementRasters.TryGetValue(element, out var existing)
|
||||
&& existing.Width == dw && existing.Height == dh)
|
||||
raster = existing;
|
||||
}
|
||||
if (raster == null) raster = new VideoFrame(dw, dh, new byte[dw * dh * 4]);
|
||||
Array.Clear(raster.BgraPixels);
|
||||
BlitContentRaw(raster.BgraPixels, dw, dh, 0, 0, ew, eh, frame, 1f, isRound, mirror, transparentDst: true);
|
||||
lock (_pasteGate)
|
||||
{
|
||||
// Only the SOURCE frame's identity is a real key owner; drop any older
|
||||
// entry that shared this element's reused raster so a stale key can never
|
||||
// paste a mid-update array.
|
||||
if (_pasteCache.Count >= MaxPasteEntries) _pasteCache.Clear();
|
||||
_pasteCache[key!] = raster;
|
||||
_elementRasters[element] = raster;
|
||||
}
|
||||
}
|
||||
|
||||
// Paste at integer position; opacity blends. The raster is never flagged
|
||||
// IsOpaque — round clips/margins carry alpha 0 — so the row blend always runs.
|
||||
var op = (int)(System.Math.Clamp(element.Opacity, 0f, 1f) * 256);
|
||||
if (op <= 0) return;
|
||||
var px = (int)System.Math.Floor(ex);
|
||||
var py = (int)System.Math.Floor(ey);
|
||||
var x0 = System.Math.Max(0, px);
|
||||
var y0 = System.Math.Max(0, py);
|
||||
var x1 = System.Math.Min(cropW, px + dw);
|
||||
var y1 = System.Math.Min(cropH, py + dh);
|
||||
if (x0 >= x1 || y0 >= y1) return;
|
||||
var d = buffer.AsSpan();
|
||||
var s = raster.BgraPixels;
|
||||
for (var y = y0; y < y1; y++)
|
||||
{
|
||||
var si = ((y - py) * dw + (x0 - px)) * 4;
|
||||
var len = x1 - x0;
|
||||
if (op == 256)
|
||||
BlendRowOpaque(d, (y * cropW + x0) * 4, s.AsSpan(si, len * 4), 0, len);
|
||||
else
|
||||
BlendRowWeighted(d, (y * cropW + x0) * 4, s.AsSpan(si, len * 4), 0, len, op);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Copy <paramref name="frame"/> and blit a global overlay onto the copy
|
||||
/// at (sx0, sy0) in crop space. internal (not private) because the frame pump's
|
||||
/// fully-static path needs it: that path stretches the cached bake straight to the
|
||||
/// output, so any per-frame overlay — social bar, alert ticker, brand flash — has to
|
||||
/// be composited here first. Copying is the whole point: the bake is cached, and a
|
||||
/// flash written straight into it would freeze there and never expire.</summary>
|
||||
internal static VideoFrame Overlay(VideoFrame frame, CompositorOptions options, VideoFrame overlay, int sx0, int sy0)
|
||||
{
|
||||
var cropW = options.SourceRectWidth;
|
||||
var cropH = options.SourceRectHeight;
|
||||
var buffer = new byte[frame.BgraPixels.Length];
|
||||
Buffer.BlockCopy(frame.BgraPixels, 0, buffer, 0, buffer.Length);
|
||||
BlitOverlay(buffer, cropW, cropH, options, overlay, sx0, sy0);
|
||||
return new VideoFrame(cropW, cropH, buffer);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Render a cross-fade blend between two scenes. Used by <see cref="TransitionService"/>
|
||||
/// during fade/move transitions: the caller renders each scene individually, then blends
|
||||
@@ -131,26 +417,109 @@ public sealed class SceneCompositor
|
||||
{
|
||||
if (ew <= 0 || eh <= 0) return;
|
||||
|
||||
var (scale, ox, oy) = StretchMath.UniformToFill(ew, eh, src.Width, src.Height);
|
||||
|
||||
// 1:1 aligned fast path (the full-frame backdrop, master-sized overlays):
|
||||
// bilinear sampling at scale 1 with integer offsets is the identity, so the
|
||||
// blit is a row walk, not 2M float samples + rounds. Research-first (2026-09-03,
|
||||
// take-3 fix; libyuv's row-based BLEND_NONE pattern —
|
||||
// https://chromium.googlesource.com/libyuv/libyuv/): the 258ms/frame render
|
||||
// that time-lapsed take 3 lived in the general loop below.
|
||||
if (!isRound && !isMirror && scale == 1f && ox == 0f && oy == 0f
|
||||
&& ex == MathF.Floor(ex) && ey == MathF.Floor(ey))
|
||||
{
|
||||
Blit1To1Rows(dst, dstW, dstH, (int)ex, (int)ey, src, opacity);
|
||||
return;
|
||||
}
|
||||
|
||||
BlitContentRaw(dst, dstW, dstH, ex, ey, ew, eh, src, opacity, isRound, isMirror);
|
||||
}
|
||||
|
||||
/// <summary>The general scaled/clipped/mirrored sampler (integer fixed-point
|
||||
/// bilinear + source-over). Also rasterizes paste-cache entries in ELEMENT space
|
||||
/// onto a TRANSPARENT base (alpha 0) so pasting over the master keeps the round
|
||||
/// clip and transparency of the untouched margins.</summary>
|
||||
private static void BlitContentRaw(
|
||||
byte[] dst, int dstW, int dstH,
|
||||
float ex, float ey, float ew, float eh,
|
||||
VideoFrame src, float opacity, bool isRound, bool isMirror,
|
||||
bool transparentDst = false)
|
||||
{
|
||||
if (ew <= 0 || eh <= 0) return;
|
||||
|
||||
float scale, ox, oy, invScale;
|
||||
float drawnW, drawnH;
|
||||
// No CropBounds means "the whole source canvas is the crop region" — cbW/cbH
|
||||
// MUST default to the full source dims, not 0. A zero cbW/cbH makes every
|
||||
// sxCrop/syCrop below evaluate to 0 regardless of position, collapsing the
|
||||
// entire sampled rect to source pixel (0,0) (the webcam gray-block regression,
|
||||
// 2026-09-12: introduced by the CropBounds Fill-style scaling work the same
|
||||
// day — CropBounds-bearing sources, i.e. the web widget, were never affected).
|
||||
var cbX = 0;
|
||||
var cbY = 0;
|
||||
var cbW = src.Width;
|
||||
var cbH = src.Height;
|
||||
|
||||
if (src.CropBounds is { } cb)
|
||||
{
|
||||
cbX = cb.X;
|
||||
cbY = cb.Y;
|
||||
cbW = cb.W;
|
||||
cbH = cb.H;
|
||||
scale = Math.Max(ew / cbW, eh / cbH);
|
||||
invScale = 1f / scale;
|
||||
drawnW = cbW * scale;
|
||||
drawnH = cbH * scale;
|
||||
ox = (ew - drawnW) / 2f;
|
||||
oy = (eh - drawnH) / 2f;
|
||||
}
|
||||
else
|
||||
{
|
||||
var (uScale, uOx, uOy) = StretchMath.UniformToFill(ew, eh, src.Width, src.Height);
|
||||
scale = uScale;
|
||||
invScale = 1f / uScale;
|
||||
ox = uOx;
|
||||
oy = uOy;
|
||||
drawnW = src.Width * uScale;
|
||||
drawnH = src.Height * uScale;
|
||||
}
|
||||
|
||||
var radius = Math.Min(ew, eh) / 2f;
|
||||
var cx = ew / 2f;
|
||||
var cy = eh / 2f;
|
||||
|
||||
var x0 = Math.Max(0, (int)Math.Floor(ex));
|
||||
var y0 = Math.Max(0, (int)Math.Floor(ey));
|
||||
var x1 = Math.Min(dstW - 1, (int)Math.Ceiling(ex + ew));
|
||||
var y1 = Math.Min(dstH - 1, (int)Math.Ceiling(ey + eh));
|
||||
if (x0 > x1 || y0 > y1) return;
|
||||
|
||||
var (scale, ox, oy) = StretchMath.UniformToFill(ew, eh, src.Width, src.Height);
|
||||
var drawnW = src.Width * scale;
|
||||
var drawnH = src.Height * scale;
|
||||
var radius = Math.Min(ew, eh) / 2f;
|
||||
var cx = ew / 2f;
|
||||
var cy = eh / 2f;
|
||||
var op = (int)(Math.Clamp(opacity, 0f, 1f) * 256);
|
||||
if (op <= 0) return;
|
||||
var s = src.BgraPixels;
|
||||
var srcStride = src.Width; // canvas stride in pixels (not bytes)
|
||||
|
||||
for (var y = y0; y <= y1; y++)
|
||||
{
|
||||
for (var x = x0; x <= x1; x++)
|
||||
var py = y - ey;
|
||||
if (py < oy || py > oy + drawnH) continue;
|
||||
|
||||
// syNorm: normalized [0,1] position within the drawn content (vertically)
|
||||
var syNorm = Math.Clamp((py - oy) / drawnH, 0f, 1f);
|
||||
// syCrop: pixel position within the crop region
|
||||
var syCrop = syNorm * cbH;
|
||||
// syCanvas: canvas pixel row (for buffer indexing)
|
||||
var syCanvas = Math.Clamp(cbY + syCrop, 0f, src.Height - 1f);
|
||||
var syi = (int)syCanvas;
|
||||
var sy1i = Math.Min(syi + 1, src.Height - 1);
|
||||
var fy8 = (int)((syCanvas - syi) * 256);
|
||||
var fyInv = 256 - fy8;
|
||||
|
||||
var di = (y * dstW + x0) * 4;
|
||||
for (var x = x0; x <= x1; x++, di += 4)
|
||||
{
|
||||
var px = x - ex; // element space
|
||||
var py = y - ey;
|
||||
if (px < ox || px > ox + drawnW || py < oy || py > oy + drawnH) continue;
|
||||
var px = x - ex;
|
||||
if (px < ox || px > ox + drawnW) continue;
|
||||
if (isRound)
|
||||
{
|
||||
var dx = px - cx;
|
||||
@@ -158,15 +527,175 @@ public sealed class SceneCompositor
|
||||
if (dx * dx + dy * dy > radius * radius) continue;
|
||||
}
|
||||
|
||||
var sx = (px - ox) / scale;
|
||||
var sy = (py - oy) / scale;
|
||||
if (isMirror) sx = src.Width - 1 - sx;
|
||||
var sample = StretchMath.SampleBgra(src.BgraPixels, src.Width, src.Height, sx, sy);
|
||||
BlendPixel(dst, (y * dstW + x) * 4, sample, opacity);
|
||||
// sxNorm: normalized [0,1] position within the drawn content (horizontally)
|
||||
var sxNorm = Math.Clamp((px - ox) / drawnW, 0f, 1f);
|
||||
// sxCrop: pixel position within the crop region
|
||||
var sxCrop = sxNorm * cbW;
|
||||
// sxCanvas: canvas pixel column (for buffer indexing)
|
||||
var sxCanvas = Math.Clamp(cbX + sxCrop, 0f, src.Width - 1f);
|
||||
if (isMirror) sxCanvas = src.Width - 1 - sxCanvas;
|
||||
var sxi = (int)sxCanvas;
|
||||
var sx1i = Math.Min(sxi + 1, src.Width - 1);
|
||||
var fx8 = (int)((sxCanvas - sxi) * 256);
|
||||
var fxInv = 256 - fx8;
|
||||
|
||||
// ALL pixel indexing uses srcStride (canvas stride), NOT crop width
|
||||
var p00 = (syi * srcStride + sxi) * 4;
|
||||
var p10 = (syi * srcStride + sx1i) * 4;
|
||||
var p01 = (sy1i * srcStride + sxi) * 4;
|
||||
var p11 = (sy1i * srcStride + sx1i) * 4;
|
||||
var sb = Bilinear(s[p00], s[p10], s[p01], s[p11], fx8, fxInv, fy8, fyInv);
|
||||
var sg = Bilinear(s[p00 + 1], s[p10 + 1], s[p01 + 1], s[p11 + 1], fx8, fxInv, fy8, fyInv);
|
||||
var sr = Bilinear(s[p00 + 2], s[p10 + 2], s[p01 + 2], s[p11 + 2], fx8, fxInv, fy8, fyInv);
|
||||
var saFull = Bilinear(s[p00 + 3], s[p10 + 3], s[p01 + 3], s[p11 + 3], fx8, fxInv, fy8, fyInv);
|
||||
|
||||
var sa = saFull * op >> 8;
|
||||
if (sa >= 255)
|
||||
{
|
||||
dst[di] = (byte)sb;
|
||||
dst[di + 1] = (byte)sg;
|
||||
dst[di + 2] = (byte)sr;
|
||||
dst[di + 3] = 255;
|
||||
}
|
||||
else if (sa > 0)
|
||||
{
|
||||
if (transparentDst)
|
||||
{
|
||||
// Element-raster pass on a TRANSPARENT base (the paste cache):
|
||||
// keep straight color + straight alpha so the paste rows can
|
||||
// source-over onto the opaque master. Premultiplying here (the
|
||||
// opaque-dst blend) stamped alpha 255 onto already-alpha-scaled
|
||||
// color — the paste over-wrote the scene with darkened ink, the
|
||||
// web widget's "black box" in recordings.
|
||||
dst[di] = (byte)sb;
|
||||
dst[di + 1] = (byte)sg;
|
||||
dst[di + 2] = (byte)sr;
|
||||
dst[di + 3] = (byte)sa;
|
||||
}
|
||||
else
|
||||
{
|
||||
var inv = 255 - sa;
|
||||
dst[di] = (byte)((sb * sa + dst[di] * inv + 127) / 255);
|
||||
dst[di + 1] = (byte)((sg * sa + dst[di + 1] * inv + 127) / 255);
|
||||
dst[di + 2] = (byte)((sr * sa + dst[di + 2] * inv + 127) / 255);
|
||||
dst[di + 3] = 255;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Two-stage bilinear in 8.8 fixed point; result within ±1 of the
|
||||
/// float StretchMath.SampleBgra (well inside every pixel test's ±2 tolerance).
|
||||
/// The first stage must NOT shift back to 8 bits — shifting twice collapsed
|
||||
/// every solid-255 sample to 1 (green overwrites silently degraded to no-ops;
|
||||
/// caught by the SceneCompositor/SceneGraph pixel probes 2026-09-04).</summary>
|
||||
private static int Bilinear(int tl, int tr, int bl, int br, int fx8, int fxInv, int fy8, int fyInv)
|
||||
{
|
||||
// Both stages in one 16.8 scale: top/bot ≤ 65280, ×256 + round ≤ 16.8M — int-safe.
|
||||
var top = tl * fxInv + tr * fx8;
|
||||
var bot = bl * fxInv + br * fx8;
|
||||
return (top * fyInv + bot * fy8 + 32768) >> 16;
|
||||
}
|
||||
|
||||
/// <summary>Row walk for a 1:1 aligned blit at integer offset (ex, ey): per-pixel
|
||||
/// branch on source alpha — opaque rows copy straight, transparent pixels are
|
||||
/// skipped, partial alpha blends in integer fixed-point (libyuv's pattern). The
|
||||
/// master buffer's destination alpha is always opaque (black base paints 255 and
|
||||
/// every blend writes 255), so dst alpha is never read — matching BlendPixel's
|
||||
/// source-over result for opaque dst within ±1.</summary>
|
||||
private static void Blit1To1Rows(byte[] dst, int dstW, int dstH, int ex, int ey, VideoFrame src, float opacity)
|
||||
{
|
||||
var op = (int)(Math.Clamp(opacity, 0f, 1f) * 256);
|
||||
if (op <= 0) return;
|
||||
|
||||
var x0 = Math.Max(0, ex);
|
||||
var y0 = Math.Max(0, ey);
|
||||
var x1 = Math.Min(dstW, ex + src.Width);
|
||||
var y1 = Math.Min(dstH, ey + src.Height);
|
||||
if (x0 >= x1 || y0 >= y1) return;
|
||||
|
||||
// The take-4 fix: a full-canvas opaque capture (the Live backdrop) is ONE
|
||||
// memcpy, not 2.07M managed loop iterations — the row walk below still cost
|
||||
// ~58ms/frame dominated by bounds-check arithmetic and GC of the per-pixel
|
||||
// loop's slowness, i.e. the same starvation shape as take 3, one floor lower.
|
||||
if (op == 256 && src.IsOpaque && x0 == 0 && y0 == 0 && x1 == dstW && y1 == dstH)
|
||||
{
|
||||
Buffer.BlockCopy(src.BgraPixels, 0, dst, 0, dstW * dstH * 4);
|
||||
return;
|
||||
}
|
||||
|
||||
var d = dst.AsSpan();
|
||||
var s = src.BgraPixels.AsSpan();
|
||||
var rowLen = x1 - x0;
|
||||
if (op == 256)
|
||||
{
|
||||
for (var y = y0; y < y1; y++)
|
||||
{
|
||||
var si = ((y - ey) * src.Width + (x0 - ex)) * 4;
|
||||
BlendRowOpaque(d, (y * dstW + x0) * 4, s, si, rowLen);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
for (var y = y0; y < y1; y++)
|
||||
{
|
||||
var si = ((y - ey) * src.Width + (x0 - ex)) * 4;
|
||||
BlendRowWeighted(d, (y * dstW + x0) * 4, s, si, rowLen, op);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void BlendRowOpaque(Span<byte> dst, int di, ReadOnlySpan<byte> src, int si, int count)
|
||||
{
|
||||
for (var n = 0; n < count; n++)
|
||||
{
|
||||
var a = src[si + 3];
|
||||
if (a == 255)
|
||||
{
|
||||
dst[di] = src[si];
|
||||
dst[di + 1] = src[si + 1];
|
||||
dst[di + 2] = src[si + 2];
|
||||
dst[di + 3] = 255;
|
||||
}
|
||||
else if (a != 0)
|
||||
{
|
||||
var inv = 255 - a;
|
||||
dst[di] = (byte)((src[si] * a + dst[di] * inv + 127) / 255);
|
||||
dst[di + 1] = (byte)((src[si + 1] * a + dst[di + 1] * inv + 127) / 255);
|
||||
dst[di + 2] = (byte)((src[si + 2] * a + dst[di + 2] * inv + 127) / 255);
|
||||
dst[di + 3] = 255;
|
||||
}
|
||||
si += 4;
|
||||
di += 4;
|
||||
}
|
||||
}
|
||||
|
||||
private static void BlendRowWeighted(Span<byte> dst, int di, ReadOnlySpan<byte> src, int si, int count, int op)
|
||||
{
|
||||
for (var n = 0; n < count; n++)
|
||||
{
|
||||
var a = src[si + 3] * op >> 8;
|
||||
if (a >= 255)
|
||||
{
|
||||
dst[di] = src[si];
|
||||
dst[di + 1] = src[si + 1];
|
||||
dst[di + 2] = src[si + 2];
|
||||
dst[di + 3] = 255;
|
||||
}
|
||||
else if (a > 0)
|
||||
{
|
||||
var inv = 255 - a;
|
||||
dst[di] = (byte)((src[si] * a + dst[di] * inv + 127) / 255);
|
||||
dst[di + 1] = (byte)((src[si + 1] * a + dst[di + 1] * inv + 127) / 255);
|
||||
dst[di + 2] = (byte)((src[si + 2] * a + dst[di + 2] * inv + 127) / 255);
|
||||
dst[di + 3] = 255;
|
||||
}
|
||||
si += 4;
|
||||
di += 4;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Centered OBS-style border stroke: rect ring (Traditional) or circle ring (Round).</summary>
|
||||
private static void DrawBorder(
|
||||
byte[] dst, int dstW, int dstH,
|
||||
@@ -201,21 +730,22 @@ public sealed class SceneCompositor
|
||||
}
|
||||
|
||||
/// <summary>1:1 copy of a master-sized overlay (flash, social bar) cropped to the
|
||||
/// active source rect. The overlay is positioned in master space by (sx0, sy0).</summary>
|
||||
/// active source rect. The overlay is positioned in master space by (sx0, sy0).
|
||||
/// Iterates only the intersection rect — the old loop walked every destination
|
||||
/// pixel to blit a ~64px strip (2M iterations of bounds checks per overlay).</summary>
|
||||
private static void BlitOverlay(
|
||||
byte[] dst, int dstW, int dstH, CompositorOptions options,
|
||||
VideoFrame overlay, int sx0, int sy0)
|
||||
{
|
||||
for (var y = 0; y < dstH; y++)
|
||||
var dx0 = Math.Max(0, sx0 - options.SourceRectX);
|
||||
var dy0 = Math.Max(0, sy0 - options.SourceRectY);
|
||||
var dx1 = Math.Min(dstW, sx0 - options.SourceRectX + overlay.Width);
|
||||
var dy1 = Math.Min(dstH, sy0 - options.SourceRectY + overlay.Height);
|
||||
for (var y = dy0; y < dy1; y++)
|
||||
{
|
||||
for (var x = 0; x < dstW; x++)
|
||||
{
|
||||
var sx = x + options.SourceRectX - sx0;
|
||||
var sy = y + options.SourceRectY - sy0;
|
||||
if (sx < 0 || sy < 0 || sx >= overlay.Width || sy >= overlay.Height) continue;
|
||||
var sample = StretchMath.SampleBgra(overlay.BgraPixels, overlay.Width, overlay.Height, sx, sy);
|
||||
BlendPixel(dst, (y * dstW + x) * 4, sample, 1f);
|
||||
}
|
||||
var si = ((y + options.SourceRectY - sy0) * overlay.Width
|
||||
+ dx0 + options.SourceRectX - sx0) * 4;
|
||||
BlendRowOpaque(dst, (y * dstW + dx0) * 4, overlay.BgraPixels, si, dx1 - dx0);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,14 +16,19 @@ public static class StaticPixelCache
|
||||
public static VideoFrame? Get(string assetId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(assetId)) return null;
|
||||
if (Cache.TryGetValue(assetId, out var frame)) return frame;
|
||||
// Locked: the frame pump decodes from its own thread now (take-8 off-UI fix),
|
||||
// so the dictionary genuinely races with UI-path callers (snapshots, heals).
|
||||
lock (Cache)
|
||||
{
|
||||
if (Cache.TryGetValue(assetId, out var frame)) return frame;
|
||||
|
||||
var bytes = LayoutStore.Instance?.GetAssetBytes(assetId);
|
||||
if (bytes == null || bytes.Length == 0) return null;
|
||||
var bytes = LayoutStore.Instance?.GetAssetBytes(assetId);
|
||||
if (bytes == null || bytes.Length == 0) return null;
|
||||
|
||||
var decoded = Decode(bytes);
|
||||
if (decoded != null) Cache[assetId] = decoded;
|
||||
return decoded;
|
||||
var decoded = Decode(bytes);
|
||||
if (decoded != null) Cache[assetId] = decoded;
|
||||
return decoded;
|
||||
}
|
||||
}
|
||||
|
||||
public static VideoFrame? Decode(byte[] bytes)
|
||||
|
||||
@@ -2,7 +2,9 @@ namespace ytLive.Services.Encoder;
|
||||
|
||||
/// <summary>
|
||||
/// Builds the FFmpeg command line for a live RTMP push (TASK 4 ship step 3):
|
||||
/// raw BGRA frames via stdin (paced <c>-re</c>), real audio via the named pipe
|
||||
/// raw BGRA frames via stdin (paced by the frame pump — the pump holds a strict
|
||||
/// CFR cadence, so <c>-re</c> would only add a second, fighting pacer), real
|
||||
/// audio via the named pipe
|
||||
/// (TASK 9 — the mixer streams f32le at 48 kHz stereo into <c>\\.\pipe\<name></c>;
|
||||
/// WASAPI loopback + the mic ride the pipe instead of the old anullsrc silence),
|
||||
/// H.264 + AAC encoding, FLV muxing to the ingestion URL. Pure — the encoder
|
||||
@@ -16,7 +18,6 @@ public static class FfmpegArgs
|
||||
{
|
||||
"-hide_banner", "-loglevel", "info",
|
||||
"-stats", "-stats_period", "0.5",
|
||||
"-re",
|
||||
"-f", "rawvideo", "-pix_fmt", "bgra",
|
||||
"-video_size", $"{options.Width}x{options.Height}",
|
||||
"-framerate", options.Fps.ToString(),
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using System.Buffers;
|
||||
using System.Diagnostics;
|
||||
using System.Threading.Channels;
|
||||
using ytLive.Helpers;
|
||||
using ytLive.Models;
|
||||
|
||||
@@ -30,6 +32,21 @@ public sealed class FfmpegEncoder : IFfmpegEncoder
|
||||
private Task? _stderrLoop;
|
||||
private bool _stopRequested;
|
||||
|
||||
// Pipe decoupling (slice 10, 2026-09-10): a bounded pending-frame queue drained
|
||||
// by its own task (the OBS video-thread → encoder-queue model — the encoder's
|
||||
// thread never couples back into the video thread; libobs obs-encoder.c). The
|
||||
// pump's SubmitFrameAsync now ENQUEUES (copying into a pooled buffer) instead of
|
||||
// blocking on WriteAsync when ffmpeg lags the pipe; overflow DROPS the newest
|
||||
// frame (skip-newest) and counts it. BoundedChannelFullMode.Wait + TryWrite gives
|
||||
// exactly that: when full, TryWrite returns false and the caller drops the item.
|
||||
private const int QueueCapacity = 120; // ~2s at the tier's 60fps
|
||||
private readonly Channel<PendingWrite> _frames = Channel.CreateBounded<PendingWrite>(
|
||||
new BoundedChannelOptions(QueueCapacity) { SingleReader = true });
|
||||
private Task? _drainTask;
|
||||
private int _droppedBackpressure;
|
||||
|
||||
private readonly record struct PendingWrite(byte[] Buffer, int Length);
|
||||
|
||||
public FfmpegEncoder(
|
||||
IFfmpegLocator locator,
|
||||
Func<IEncoderProcess>? processFactory = null)
|
||||
@@ -40,6 +57,13 @@ public sealed class FfmpegEncoder : IFfmpegEncoder
|
||||
|
||||
public bool IsRunning { get; private set; }
|
||||
|
||||
/// <summary>Frames dropped by the bounded queue because the drain task couldn't
|
||||
/// keep the pipe fed (the encoder lagging the real-time capture rate). The pump
|
||||
/// reports this in its stats; the burned-in frame counter in the recording
|
||||
/// shows the identical jumps. Distinct from <see cref="StreamHealth.DroppedFrames"/>
|
||||
/// (ffmpeg's own progress-derived estimate).</summary>
|
||||
public int DroppedFrames => Volatile.Read(ref _droppedBackpressure);
|
||||
|
||||
public async Task StartAsync(EncoderOptions options, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (options == null) throw new ArgumentNullException(nameof(options));
|
||||
@@ -93,26 +117,40 @@ public sealed class FfmpegEncoder : IFfmpegEncoder
|
||||
|
||||
_stopRequested = false;
|
||||
_stderrLoop = RunStderrLoopAsync(process);
|
||||
StartDrainLoop(process);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Write one raw BGRA frame to ffmpeg's stdin. Serialized internally; callers
|
||||
/// (the compositor pump) may race freely. Frames are written as-is — the caller
|
||||
/// paces to capture rate (the compositor's job, ship step 5).
|
||||
/// Enqueue one raw BGRA frame for ffmpeg's stdin (slice 10). The caller races
|
||||
/// freely (the compositor pump); this never blocks on the pipe. The pixels are
|
||||
/// copied into a pooled buffer first because — unlike the old direct write — the
|
||||
/// write happens later on the drain thread, so the caller may (and does) recycle
|
||||
/// its scratch buffer the moment this returns. When the bounded queue is full the
|
||||
/// NEWEST frame is dropped and counted (freshness over coverage; the encoder is
|
||||
/// already behind, so the stale-content failure mode never encodes).
|
||||
/// </summary>
|
||||
public async Task SubmitFrameAsync(VideoFrame frame, CancellationToken cancellationToken = default)
|
||||
public Task SubmitFrameAsync(VideoFrame frame, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (frame == null) throw new ArgumentNullException(nameof(frame));
|
||||
IEncoderProcess? process;
|
||||
lock (_gate)
|
||||
{
|
||||
if (!IsRunning) throw new InvalidOperationException("The encoder is not running.");
|
||||
process = _process;
|
||||
}
|
||||
|
||||
var bytes = frame.BgraPixels;
|
||||
await process!.StandardInput.WriteAsync(bytes, cancellationToken).ConfigureAwait(false);
|
||||
await process.StandardInput.FlushAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
return Task.FromCanceled(cancellationToken);
|
||||
|
||||
var pooled = ArrayPool<byte>.Shared.Rent(frame.BgraPixels.Length);
|
||||
Buffer.BlockCopy(frame.BgraPixels, 0, pooled, 0, frame.BgraPixels.Length);
|
||||
if (_frames.Writer.TryWrite(new PendingWrite(pooled, frame.BgraPixels.Length)))
|
||||
return Task.CompletedTask;
|
||||
|
||||
// Queue full (encoder lagging) or the channel completed during stop — drop the
|
||||
// newest and count it. The caller never blocks; the encoder catches up or the
|
||||
// session ends with a (reported) short gap instead of a frozen stall.
|
||||
ArrayPool<byte>.Shared.Return(pooled);
|
||||
Interlocked.Increment(ref _droppedBackpressure);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task StopAsync(CancellationToken cancellationToken = default)
|
||||
@@ -127,13 +165,17 @@ public sealed class FfmpegEncoder : IFfmpegEncoder
|
||||
loop = _stderrLoop;
|
||||
}
|
||||
|
||||
// Flush every queued frame, then EOF: TryComplete lets the drain task write
|
||||
// the buffered frames, close stdin → ffmpeg finalizes and exits by itself
|
||||
// (the "did not exit after EOF" kill below is only the backstop).
|
||||
try
|
||||
{
|
||||
process!.StandardInput.Dispose(); // EOF → ffmpeg finalizes + exits
|
||||
_frames.Writer.TryComplete();
|
||||
if (_drainTask != null) await _drainTask.ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLog.Write(ex, "FFmpeg encoder: closing stdin failed");
|
||||
AppLog.Write(ex, "FFmpeg encoder: drain loop faulted during stop");
|
||||
}
|
||||
|
||||
try
|
||||
@@ -178,6 +220,7 @@ public sealed class FfmpegEncoder : IFfmpegEncoder
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_frames.Writer.TryComplete(); // unblock a stuck drain loop alongside the kill
|
||||
lock (_gate)
|
||||
{
|
||||
if (!IsRunning) return;
|
||||
@@ -188,6 +231,54 @@ public sealed class FfmpegEncoder : IFfmpegEncoder
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The drain task (slice 10): owns every stdin write, so pipe backpressure — the
|
||||
/// pump's old stall — lives HERE, on a thread the pump never touches. Frames are
|
||||
/// written exactly as queued (the pooled array through its recorded length —
|
||||
/// ArrayPool may return a larger buffer), returned to the pool after the write
|
||||
/// copies into the pipe, and stdin is closed (EOF) once the queue drains.
|
||||
/// </summary>
|
||||
private void StartDrainLoop(IEncoderProcess process)
|
||||
{
|
||||
_drainTask = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
try
|
||||
{
|
||||
while (await _frames.Reader.WaitToReadAsync().ConfigureAwait(false))
|
||||
{
|
||||
while (_frames.Reader.TryRead(out var pending))
|
||||
{
|
||||
await process.StandardInput.WriteAsync(pending.Buffer, 0, pending.Length)
|
||||
.ConfigureAwait(false);
|
||||
await process.StandardInput.FlushAsync().ConfigureAwait(false);
|
||||
ArrayPool<byte>.Shared.Return(pending.Buffer);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (ChannelClosedException)
|
||||
{
|
||||
// channel completed while waiting — fall through to EOF
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLog.Write(ex, "FFmpeg encoder: drain loop write faulted");
|
||||
}
|
||||
finally
|
||||
{
|
||||
// EOF on every path — a byte that sits in the pool when the pipe
|
||||
// breaks is garbage anyway, and ffmpeg MUST see the close to finalize.
|
||||
process.StandardInput.Dispose();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLog.Write(ex, "FFmpeg encoder: drain loop faulted");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<string> ProbeEncoderAsync(string ffmpegPath, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
@@ -230,14 +321,35 @@ public sealed class FfmpegEncoder : IFfmpegEncoder
|
||||
OnStderrLine(process, line);
|
||||
}
|
||||
|
||||
var code = process.ExitCode;
|
||||
var stillRunning = false;
|
||||
lock (_gate) stillRunning = IsRunning;
|
||||
if (stillRunning && !_stopRequested && code != 0)
|
||||
// Read the exit code defensively: the stop path disposes the process
|
||||
// while this loop may still be waking (2026-09-01 "No process is
|
||||
// associated with this object" — a cosmetic race that poisoned the log).
|
||||
int exitCode;
|
||||
try
|
||||
{
|
||||
AppLog.Write($"FFmpeg encoder: subprocess exited unexpectedly ({code})");
|
||||
_health.LastError = $"FFmpeg exited with code {code}";
|
||||
ProcessFailed?.Invoke(this, $"FFmpeg exited with code {code}");
|
||||
exitCode = process.ExitCode;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
exitCode = -1;
|
||||
}
|
||||
|
||||
var stillRunning = false;
|
||||
var stopping = false;
|
||||
lock (_gate)
|
||||
{
|
||||
stillRunning = IsRunning;
|
||||
stopping = _stopRequested;
|
||||
}
|
||||
if (stillRunning && !stopping)
|
||||
{
|
||||
// ANY exit we did not ask for is a failure — including code 0.
|
||||
// The old `code != 0` gate let ffmpeg's clean early exit vanish
|
||||
// without a word while the pump kept feeding a corpse
|
||||
// (2026-09-01 first real recording: died at ~0.9s, zero logs).
|
||||
AppLog.Write($"FFmpeg encoder: subprocess exited unexpectedly (code {exitCode})");
|
||||
_health.LastError = $"FFmpeg exited with code {exitCode}";
|
||||
ProcessFailed?.Invoke(this, $"FFmpeg exited with code {exitCode}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -250,7 +362,17 @@ public sealed class FfmpegEncoder : IFfmpegEncoder
|
||||
private void OnStderrLine(IEncoderProcess process, string line)
|
||||
{
|
||||
var progress = FfmpegProgressParser.TryParse(line);
|
||||
if (progress == null) return;
|
||||
if (progress == null)
|
||||
{
|
||||
// THE EYES: everything ffmpeg says that isn't a progress line — startup
|
||||
// config, warnings, the real reason it quit. Was silently discarded
|
||||
// (2026-09-01: encoder died at ~0.9s, log silent). Truncated so a
|
||||
// \r-choked stats blob can't flood; noise now beats blindness.
|
||||
var text = line.Trim('\r', ' ', '\t');
|
||||
if (text.Length > 0)
|
||||
AppLog.Write($"ffmpeg: {(text.Length > 400 ? text[..400] + "…" : text)}");
|
||||
return;
|
||||
}
|
||||
|
||||
var dropped = Math.Max(0, (long)Math.Round(progress.Value.Fps * progress.Value.Duration.TotalSeconds) - progress.Value.Frame);
|
||||
|
||||
|
||||
@@ -20,9 +20,22 @@ public sealed class FfmpegLocator : IFfmpegLocator
|
||||
{
|
||||
public const string FileName = "ffmpeg.exe";
|
||||
|
||||
/// <summary>Pinned BtbN LGPL-shared win64 build (immutable autobuild tag; see TASKS.md).</summary>
|
||||
/// <summary>Sibling probe binary — extracted alongside ffmpeg so the media
|
||||
/// frame-rate probe (<see cref="ytLive.Services.FfmpegFrameRateProbe"/>) can
|
||||
/// derive its path from the located ffmpeg directory.</summary>
|
||||
public const string ProbeFileName = "ffprobe.exe";
|
||||
|
||||
/// <summary>
|
||||
/// Pinned BtbN LGPL-shared win64 build — <c>autobuild-2026-08-31-13-27</c>, the
|
||||
/// ffmpeg N-126342 build (immutable tag; recorded in TASKS.md per the licensing rule).
|
||||
/// Deliberately a MONTH-END tag: BtbN retains the last ~14 dailies but month-end
|
||||
/// builds for ~2 years — the previous pin (a 2026-08-09 daily) aged out of retention
|
||||
/// and 404'd on a cold cache exactly as ai.md predicted (2026-09-01, the first real
|
||||
/// recording attempt). Dated tags carry versioned asset names (ffmpeg-N-…-win64-lgpl-shared.zip);
|
||||
/// extraction is name-agnostic.
|
||||
/// </summary>
|
||||
public const string PinnedUrl =
|
||||
"https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-08-09-13-03/ffmpeg-master-latest-win64-lgpl-shared.zip";
|
||||
"https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-08-31-13-27/ffmpeg-N-126342-gf88b741dbf-win64-lgpl-shared.zip";
|
||||
|
||||
private readonly string[] _searchDirs;
|
||||
private readonly string _toolsDir;
|
||||
@@ -60,6 +73,17 @@ public sealed class FfmpegLocator : IFfmpegLocator
|
||||
ExtractBinaries(zip, _toolsDir);
|
||||
return cached;
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
// The old gap: this exception type escaped unwrapped, so a dead pin surfaced
|
||||
// as a raw "Response status code does not indicate success: 404" from the
|
||||
// frame pump. Locator failures are IOException with an actionable message.
|
||||
AppLog.Write(ex, "FFmpeg locator: download failed");
|
||||
throw new IOException(
|
||||
$"FFmpeg download failed ({ex.Message}). The pinned build may have aged out of " +
|
||||
"retention — update FfmpegLocator.PinnedUrl, or install FFmpeg (LGPL-shared build) " +
|
||||
"and make sure 'ffmpeg' is on PATH.", ex);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or InvalidDataException or NotSupportedException)
|
||||
{
|
||||
AppLog.Write(ex, "FFmpeg locator: download/extract failed");
|
||||
@@ -85,6 +109,10 @@ public sealed class FfmpegLocator : IFfmpegLocator
|
||||
e => e.FullName.EndsWith("/" + FileName, StringComparison.OrdinalIgnoreCase))
|
||||
?? throw new InvalidDataException($"The pinned FFmpeg archive does not contain {FileName}.");
|
||||
ExtractOne(exe, Path.Combine(staging, FileName));
|
||||
var probe = archive.Entries.FirstOrDefault(
|
||||
e => e.FullName.EndsWith("/" + ProbeFileName, StringComparison.OrdinalIgnoreCase));
|
||||
if (probe != null)
|
||||
ExtractOne(probe, Path.Combine(staging, ProbeFileName));
|
||||
foreach (var entry in archive.Entries)
|
||||
{
|
||||
if (entry.FullName.EndsWith(".dll", StringComparison.OrdinalIgnoreCase))
|
||||
|
||||
+588
-19
@@ -29,15 +29,92 @@ public sealed class FramePump : IDisposable
|
||||
private readonly Action<string>? _log;
|
||||
private readonly Func<TimeSpan, CancellationToken, Task> _pacingDelay;
|
||||
private readonly Func<(VideoFrame? Frame, SocialBarPosition Position)>? _socialBar;
|
||||
private readonly Func<VideoFrame?>? _alertTicker;
|
||||
private readonly Func<VideoFrame?>? _brandFlash;
|
||||
private readonly TransitionService? _transition;
|
||||
private readonly Func<Scene?>? _fromSceneProvider;
|
||||
private readonly SceneGraph? _sceneGraph;
|
||||
private readonly SceneCompositor _compositor = new();
|
||||
|
||||
// Master-buffer scratch pool (take-4 starvation fix, slice 2 of 2): a fresh
|
||||
// 8.3MB byte[] every tick is ~500MB/s of LOH churn — GC stalls masquerading
|
||||
// as render cost. The pump pools ONLY buffers it handed out (reference-equality
|
||||
// set), so bake-cache / social-bar / static-cache frames are never touched;
|
||||
// SubmitFrameAsync copies the bytes to the encoder's stdin before returning,
|
||||
// so recycling after submit is safe (MyMistakes recipe).
|
||||
private readonly HashSet<byte[]> _ownedScratch = new(ReferenceEqualityComparer.Instance);
|
||||
private readonly List<byte[]> _freeScratch = new();
|
||||
private const int MaxScratchPooled = 4;
|
||||
|
||||
private byte[] AcquireScratch(int size)
|
||||
{
|
||||
for (var i = _freeScratch.Count - 1; i >= 0; i--)
|
||||
{
|
||||
if (_freeScratch[i].Length != size) continue;
|
||||
var buffer = _freeScratch[i];
|
||||
_freeScratch.RemoveAt(i);
|
||||
return buffer;
|
||||
}
|
||||
var fresh = new byte[size];
|
||||
_ownedScratch.Add(fresh);
|
||||
return fresh;
|
||||
}
|
||||
|
||||
private void ReleaseScratch(byte[]? buffer)
|
||||
{
|
||||
if (buffer == null || !_ownedScratch.Contains(buffer)) return;
|
||||
if (_freeScratch.Count >= MaxScratchPooled || _freeScratch.Contains(buffer)) return;
|
||||
_freeScratch.Add(buffer);
|
||||
}
|
||||
|
||||
// Windows sleep quantum (take-9 finding, 2026-09-04): Task.Delay rounds every
|
||||
// request up to the system clock tick (~15.6ms default — learn.microsoft.com/en-us/
|
||||
// dotnet/api/system.threading.tasks.task.delay: "approximately 15 milliseconds on
|
||||
// Windows systems"), so a pacer requesting 3-15ms actually sleeps 15.6ms. Takes
|
||||
// 5-9 measured work ~25ms but period ~37ms: one padded wait per frame hid every
|
||||
// compositor improvement. Established media-app practice (game-loop/OBS canon —
|
||||
// stackoverflow.com/questions/5441464; and raise the resolution for the session —
|
||||
// learn.microsoft.com/en-us/windows/win32/api/timeapi/nf-timeapi-timebeginperiod):
|
||||
// timeBeginPeriod(1) while the pump runs, sleep only the BULK of the remainder,
|
||||
// and spin the last ~2ms across the deadline.
|
||||
[System.Runtime.InteropServices.DllImport("winmm.dll")]
|
||||
private static extern uint timeBeginPeriod(uint uMilliseconds);
|
||||
[System.Runtime.InteropServices.DllImport("winmm.dll")]
|
||||
private static extern uint timeEndPeriod(uint uMilliseconds);
|
||||
private static readonly long SpinTailTicks = System.Diagnostics.Stopwatch.Frequency * 2 / 1000; // 2ms
|
||||
|
||||
private readonly object _gate = new();
|
||||
private IFfmpegEncoder? _encoder;
|
||||
private CancellationTokenSource? _cts;
|
||||
private Task? _pumpTask;
|
||||
private bool _started;
|
||||
private long _outputIndex;
|
||||
|
||||
/// <summary>Captured by <see cref="ProbeRender"/> when a render exceeds ~20ms —
|
||||
/// the 2026-09-12 half-speed-render probe. Appended (once) to the next stats or
|
||||
/// stall line, then cleared.</summary>
|
||||
private string _renderDetail = "";
|
||||
|
||||
/// <summary>C4 blit-on-change cache (2026-09-15): the FULL-render path (split=0
|
||||
/// when the live-capture backdrop sits at element 0, or no SceneGraph) re-composites
|
||||
/// the ENTIRE frame every tick even when no input changed — the ty-1841 take logged
|
||||
/// a render stall on EVERY iteration (totalMs 21-44, one composite per ~30ms while
|
||||
/// the content moved ~20 updates/s). The SceneGraph split can never help here: the
|
||||
/// backdrop is element 0 and must stay dynamic (a baked capture goes stale), so the
|
||||
/// cache lives at the pump: the last full-render output plus the input identity that
|
||||
/// produced it. Unchanged identity → ONE BlockCopy (~3ms) reuses the composite;
|
||||
/// changed identity → re-composite. The cache buffer is a SEPARATE long-lived array,
|
||||
/// never the scratch pool — the caller burns the frame counter and recycles the
|
||||
/// scratch AFTER RenderScene returns, so the cache is written from the rendered
|
||||
/// scratch BEFORE returning (pre-burn, pre-recycle). The identity mirror is
|
||||
/// <see cref="BuildFullRenderSignature"/>: it resolves the same frames the compositor
|
||||
/// will (same resolver seam), so a changed frame (new capture, new webcam, new chat
|
||||
/// raster) forces a re-render and an unchanged one never serves stale bytes. Internal
|
||||
/// counters feed the Good Dog test + the 5s cache detail in the stats line.</summary>
|
||||
private byte[]? _fullCachePixels;
|
||||
private bool _fullCacheValid;
|
||||
private ulong _lastFullSignature;
|
||||
internal long CacheHits;
|
||||
internal long CacheRenders;
|
||||
|
||||
/// <summary>Forwards the encoder's parsed health — ship step 6 binds this to the bottom bar.</summary>
|
||||
public event EventHandler<StreamHealth>? HealthUpdated;
|
||||
@@ -54,8 +131,10 @@ public sealed class FramePump : IDisposable
|
||||
Action<string>? log = null,
|
||||
Func<TimeSpan, CancellationToken, Task>? pacingDelay = null,
|
||||
Func<(VideoFrame? Frame, SocialBarPosition Position)>? socialBar = null,
|
||||
Func<VideoFrame?>? alertTicker = null,
|
||||
Func<VideoFrame?>? brandFlash = null,
|
||||
TransitionService? transition = null,
|
||||
Func<Scene?>? fromSceneProvider = null)
|
||||
SceneGraph? sceneGraph = null)
|
||||
{
|
||||
_sceneProvider = sceneProvider ?? throw new ArgumentNullException(nameof(sceneProvider));
|
||||
_frameResolver = frameResolver ?? throw new ArgumentNullException(nameof(frameResolver));
|
||||
@@ -65,12 +144,19 @@ public sealed class FramePump : IDisposable
|
||||
_log = log;
|
||||
_pacingDelay = pacingDelay ?? ((delay, ct) => Task.Delay(delay, ct));
|
||||
_socialBar = socialBar;
|
||||
_alertTicker = alertTicker;
|
||||
_brandFlash = brandFlash;
|
||||
_transition = transition;
|
||||
_fromSceneProvider = fromSceneProvider;
|
||||
_sceneGraph = sceneGraph;
|
||||
}
|
||||
|
||||
public bool IsRunning { get; private set; }
|
||||
|
||||
/// <summary>The burned counter of the LAST frame submitted to the encoder. Stable
|
||||
/// across a tick's multiple resolver passes (it advances only at burn/submit, which
|
||||
/// happens AFTER render) — tests key per-tick content on it.</summary>
|
||||
internal long OutputIndex => _outputIndex;
|
||||
|
||||
/// <summary>Never throws: failures are logged and surfaced via <see cref="Failed"/>,
|
||||
/// so the VM can fire-and-forget it from a sync command handler.</summary>
|
||||
public async Task StartAsync(CancellationToken cancellationToken = default)
|
||||
@@ -107,7 +193,15 @@ public sealed class FramePump : IDisposable
|
||||
// synchronously on this thread before PumpAsync even returns.
|
||||
IsRunning = true;
|
||||
_cts = new CancellationTokenSource();
|
||||
_pumpTask = PumpAsync(options, _cts.Token);
|
||||
// The loop runs on the thread pool ON PURPOSE (take-8 finding, 2026-09-04):
|
||||
// Task.Run installs no SynchronizationContext, so every await continuation
|
||||
// stays off the UI dispatcher. Before this, the pump inherited the UI
|
||||
// thread's sync context (StartAsync is fired from a command handler), so
|
||||
// "render 22ms, wait 10ms" was the producer sitting in the dispatcher
|
||||
// queue behind the live preview it is meant to be independent of — the
|
||||
// stats quantum fix made the wait VISIBLE; this removes its cause.
|
||||
// OBS's video threads are dedicated for exactly this reason.
|
||||
_pumpTask = Task.Run(() => PumpAsync(options, _cts.Token));
|
||||
_log?.Invoke($"FramePump started ({options.Width}×{options.Height} @ {options.Fps} fps)");
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -208,6 +302,8 @@ public sealed class FramePump : IDisposable
|
||||
{
|
||||
_log?.Invoke($"FramePump: dispose failed: {ex.Message}");
|
||||
}
|
||||
_freeScratch.Clear();
|
||||
_ownedScratch.Clear();
|
||||
}
|
||||
|
||||
private async Task PumpAsync(EncoderOptions options, CancellationToken ct)
|
||||
@@ -215,6 +311,85 @@ public sealed class FramePump : IDisposable
|
||||
var interval = TimeSpan.FromSeconds(1d / Math.Max(1, options.Fps));
|
||||
var lastTick = System.Diagnostics.Stopwatch.StartNew();
|
||||
|
||||
// Deadline pacing (2026-09-03, take-3 fix): the frame interval is a DEADLINE,
|
||||
// not an afterthought sleep — the OBS libobs video-io.c pattern (researched
|
||||
// before coding; see https://github.com/obsproject/obs-studio/blob/master/
|
||||
// libobs/media-io/video-io.c). The old loop slept the FULL interval after
|
||||
// each render, so period = render + submit + interval: at take-3's 258ms
|
||||
// render that was 3.6fps stamped into a 60fps container — rawvideo stamps by
|
||||
// arrival, so 30 wall-seconds muxed as a 2.1s time-lapse, no error anywhere.
|
||||
var intervalTicks = Math.Max(1, (long)Math.Round(interval.TotalSeconds * System.Diagnostics.Stopwatch.Frequency));
|
||||
var nextTick = System.Diagnostics.Stopwatch.GetTimestamp();
|
||||
|
||||
// Stage timing (2026-09-01, take two): rawvideo carries no per-frame
|
||||
// timestamps — ffmpeg stamps frames by ARRIVAL at the declared fps. A producer
|
||||
// slower than the declared rate yields a time-lapsed, short file (observed:
|
||||
// 39 frames in 27 wall-seconds ≈ 30x at 60fps) with no error anywhere.
|
||||
// Log the render/submit split every 5s so the next take names the stage.
|
||||
var renderSw = new System.Diagnostics.Stopwatch();
|
||||
var submitSw = new System.Diagnostics.Stopwatch();
|
||||
var waitSw = new System.Diagnostics.Stopwatch();
|
||||
// Resolve-vs-composite split (2026-09-04, take-6 ambiguity): "render" was a
|
||||
// black box — the stats line now reports resolver time separately so a take
|
||||
// names the stage (get-frame vs blit) instead of feeding another guess.
|
||||
var resolveSw = new System.Diagnostics.Stopwatch();
|
||||
long renderTicks = 0, submitTicks = 0, resolveTicks = 0, waitTicks = 0, worstRender = 0, worstSubmit = 0;
|
||||
int statFrames = 0;
|
||||
int stalls = 0;
|
||||
long lastCacheHits = 0, lastCacheRenders = 0;
|
||||
var statsNext = DateTime.UtcNow + TimeSpan.FromSeconds(5);
|
||||
void ReportStats()
|
||||
{
|
||||
if (DateTime.UtcNow < statsNext) return;
|
||||
var target = 5d / interval.TotalSeconds; // frames expected per window
|
||||
IFfmpegEncoder? encoder;
|
||||
lock (_gate) encoder = _encoder;
|
||||
var dropped = encoder == null ? 0 : encoder.DroppedFrames;
|
||||
var detail = _renderDetail;
|
||||
_renderDetail = "";
|
||||
var cacheDetail = "";
|
||||
if (statFrames > 0)
|
||||
{
|
||||
// C4 window detail: rendered composites (R) vs cache reuses (H) since the
|
||||
// last report — hard numeric proof the blit-on-change cache engaged.
|
||||
var hits = CacheHits - lastCacheHits;
|
||||
var renders = CacheRenders - lastCacheRenders;
|
||||
lastCacheHits = CacheHits;
|
||||
lastCacheRenders = CacheRenders;
|
||||
if (hits > 0 || renders > 0) cacheDetail = $", cache {renders}R/{hits}H";
|
||||
}
|
||||
_log?.Invoke(statFrames == 0
|
||||
? "FramePump stats: NO frames produced in 5s (loop stalled?)" + (detail.Length > 0 ? " | " + detail : "")
|
||||
: $"FramePump stats: {statFrames}/{target:F0} frames per 5s, " +
|
||||
$"avg render {renderTicks / (double)System.Diagnostics.Stopwatch.Frequency * 1000 / statFrames:F1}ms " +
|
||||
$"(resolve {resolveTicks / (double)System.Diagnostics.Stopwatch.Frequency * 1000 / statFrames:F1}), " +
|
||||
$"avg submit {submitTicks / (double)System.Diagnostics.Stopwatch.Frequency * 1000 / statFrames:F1}ms, "
|
||||
+ $"avg wait {waitTicks / (double)System.Diagnostics.Stopwatch.Frequency * 1000 / statFrames:F1}ms, "
|
||||
+ $"worst render {worstRender / (double)System.Diagnostics.Stopwatch.Frequency * 1000:F1}ms, "
|
||||
+ $"worst submit {worstSubmit / (double)System.Diagnostics.Stopwatch.Frequency * 1000:F1}ms, "
|
||||
+ $"dropped {dropped}, stalls {stalls}" + cacheDetail + (detail.Length > 0 ? " | " + detail : ""));
|
||||
worstRender = 0;
|
||||
worstSubmit = 0;
|
||||
renderTicks = submitTicks = resolveTicks = waitTicks = 0;
|
||||
statFrames = 0;
|
||||
stalls = 0;
|
||||
statsNext = DateTime.UtcNow + TimeSpan.FromSeconds(5);
|
||||
}
|
||||
|
||||
// One wrapper shared by every render of the run — resolve time accumulates
|
||||
// inside the render measurement, and the stats line reports the split.
|
||||
timeBeginPeriod(1); // pairs with timeEndPeriod in the finally — see field note
|
||||
var previousGcMode = System.Runtime.GCSettings.LatencyMode;
|
||||
System.Runtime.GCSettings.LatencyMode = System.Runtime.GCLatencyMode.SustainedLowLatency;
|
||||
VideoFrame? TimedResolver(SceneElement element)
|
||||
{
|
||||
resolveSw.Restart();
|
||||
var frame = _frameResolver(element);
|
||||
resolveSw.Stop();
|
||||
resolveTicks += resolveSw.ElapsedTicks;
|
||||
return frame;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
while (!ct.IsCancellationRequested)
|
||||
@@ -222,6 +397,7 @@ public sealed class FramePump : IDisposable
|
||||
var scene = _sceneProvider();
|
||||
if (scene != null)
|
||||
{
|
||||
var iterStart = System.Diagnostics.Stopwatch.GetTimestamp();
|
||||
var compositorOptions = _compositorOptions();
|
||||
VideoFrame? socialBarFrame = null;
|
||||
var socialBarTop = 0;
|
||||
@@ -235,30 +411,125 @@ public sealed class FramePump : IDisposable
|
||||
: compositorOptions.SourceRectHeight - barFrame.Height;
|
||||
}
|
||||
|
||||
// The alert ticker is a per-frame overlay (it scrolls while an alert
|
||||
// plays), NEVER baked into the static base. Lawn-mower seam: read it
|
||||
// each tick and let the compositor pin it to the very top.
|
||||
var tickerFrame = _alertTicker?.Invoke();
|
||||
|
||||
// The free-tier branding credit. Read per-frame for the same reason
|
||||
// as the ticker: it fades and sits on a fresh random spot, so it can
|
||||
// never live in the baked base or the blit-on-change cache.
|
||||
var flashFrame = _brandFlash?.Invoke();
|
||||
|
||||
VideoFrame frame;
|
||||
if (_transition is { Active: true } transition && _fromSceneProvider != null)
|
||||
var scratchSize = compositorOptions.SourceRectWidth
|
||||
* compositorOptions.SourceRectHeight * 4;
|
||||
// The transition "from" frame lives in TransitionService.FromFrame
|
||||
// (captured at Start by the VM). The old per-tick fromScene render
|
||||
// here was dead weight — a full extra scene composite every
|
||||
// transition tick that BlendFrame never read; removed with the
|
||||
// pooling change because its buffer's only consumer was its own release.
|
||||
renderSw.Restart();
|
||||
var scratch = AcquireScratch(scratchSize);
|
||||
frame = RenderScene(scene, compositorOptions, socialBarFrame, socialBarTop, tickerFrame, flashFrame, scratch, TimedResolver);
|
||||
if (_transition is { Active: true } transition)
|
||||
{
|
||||
var fromScene = _fromSceneProvider();
|
||||
var fromFrame = fromScene != null
|
||||
? _compositor.Render(fromScene, _frameResolver, null, compositorOptions, socialBarFrame, socialBarTop)
|
||||
: _compositor.Render(scene, _frameResolver, null, compositorOptions, socialBarFrame, socialBarTop);
|
||||
var toFrame = _compositor.Render(scene, _frameResolver, null, compositorOptions, socialBarFrame, socialBarTop);
|
||||
frame = transition.BlendFrame(toFrame);
|
||||
frame = transition.BlendFrame(frame);
|
||||
transition.Tick(lastTick.Elapsed.TotalMilliseconds);
|
||||
lastTick.Restart();
|
||||
}
|
||||
else
|
||||
{
|
||||
frame = _compositor.Render(scene, _frameResolver, null, compositorOptions, socialBarFrame, socialBarTop);
|
||||
lastTick.Restart();
|
||||
}
|
||||
// Restarted EVERY frame (transition or not): a transition begun
|
||||
// after idle must not inherit a giant ElapsedMs and complete
|
||||
// instantly on its first Tick.
|
||||
lastTick.Restart();
|
||||
renderSw.Stop();
|
||||
renderTicks += renderSw.ElapsedTicks;
|
||||
if (renderSw.ElapsedTicks > worstRender) worstRender = renderSw.ElapsedTicks;
|
||||
|
||||
IFfmpegEncoder? encoder;
|
||||
lock (_gate) encoder = _encoder;
|
||||
if (encoder == null) break;
|
||||
await encoder.SubmitFrameAsync(frame, ct);
|
||||
|
||||
// Deadline pacing, OBS duplicate-on-lag (slice 15): emit ONE frame per
|
||||
// deadline slot — a fresh composite when the render kept up, a REPEAT
|
||||
// of this iteration's latest composite for every slot the render
|
||||
// overran. The OBS model never leaves a wall-time hole: libobs
|
||||
// media-io/video-io.c runs on its own clock and duplicates the latest
|
||||
// frame when the video thread lags, logging "lagged frames due to
|
||||
// rendering lag/stalls" — never skipped time (docs.obsproject.com/
|
||||
// backend-design: "If the video frame queue is full, it will duplicate
|
||||
// the last frame"). Slice 10 chose the opposite for freshness: an
|
||||
// overrun SKIPPED the missed slots, so a 60fps-authoring pump emitting
|
||||
// one frame per 35ms overrun authored ~1.7x playback (ty-1726: 163
|
||||
// video frames for 2.93s of audio) and ended the video 0.22s before the
|
||||
// audio tail. Counting duplicates instead of skips keeps recording
|
||||
// duration == wall duration (no acceleration, no audio tail cut) at the
|
||||
// cost of a short judder during a stall — the accepted trade. The burst
|
||||
// is microseconds (the enqueue never blocks, and the loop is clamped to
|
||||
// deadlineNow) so it can't smear the way the take-9 blocking burst did.
|
||||
var deadlineNow = System.Diagnostics.Stopwatch.GetTimestamp();
|
||||
while (!ct.IsCancellationRequested && deadlineNow >= nextTick)
|
||||
{
|
||||
// Burned-in frame counter (slice 10 judge): a clock-independent
|
||||
// pacing witness burned literally into the composite. Decode the
|
||||
// recording and read the bottom-right strip: the number must
|
||||
// advance +1 per authored frame and jump only by counted drops
|
||||
// (queue overflow). Burned here — on the buffer finally submitted,
|
||||
// which the enqueue's snapshot copies before the next iteration
|
||||
// rewrites it — so every file frame carries its own index and a
|
||||
// fast-render iteration that submitted nothing leaves no phantom
|
||||
// gap (the slice-10 unconditional bump before the gate could).
|
||||
_outputIndex++;
|
||||
BurnFrameIndex(frame.BgraPixels, frame.Width, frame.Height, _outputIndex);
|
||||
|
||||
submitSw.Restart();
|
||||
await encoder.SubmitFrameAsync(frame, ct);
|
||||
submitSw.Stop();
|
||||
statFrames++;
|
||||
submitTicks += submitSw.ElapsedTicks;
|
||||
if (submitSw.ElapsedTicks > worstSubmit) worstSubmit = submitSw.ElapsedTicks;
|
||||
nextTick += intervalTicks;
|
||||
}
|
||||
|
||||
// SubmitFrameAsync copied the bytes into the encoder's queue — the
|
||||
// tick's buffers are recyclable once the enqueue snapshot them.
|
||||
// The free-list Contains guard keeps the Cut path (BlendFrame
|
||||
// returns toFrame itself, aliasing scratch) safe.
|
||||
ReleaseScratch(frame.BgraPixels);
|
||||
ReleaseScratch(scratch);
|
||||
|
||||
// Sleep the BULK of the remainder, SPIN the 2ms tail — never hand
|
||||
// a sub-tick remainder to the sleep quantum (the timeBeginPeriod
|
||||
// note). If the renderer already ate the budget there is nothing
|
||||
// left to sleep and the loop renders the next frame straight away.
|
||||
waitSw.Restart();
|
||||
var ahead = nextTick - System.Diagnostics.Stopwatch.GetTimestamp();
|
||||
if (ahead > SpinTailTicks)
|
||||
await _pacingDelay(TimeSpan.FromSeconds(
|
||||
(ahead - SpinTailTicks) / (double)System.Diagnostics.Stopwatch.Frequency), ct);
|
||||
while (!ct.IsCancellationRequested
|
||||
&& System.Diagnostics.Stopwatch.GetTimestamp() < nextTick)
|
||||
Thread.SpinWait(400);
|
||||
waitSw.Stop();
|
||||
waitTicks += waitSw.ElapsedTicks;
|
||||
|
||||
// Stall logger (slice 10): an iteration spanning more than two full
|
||||
// intervals is the old bug's fingerprint — name the stage instead of
|
||||
// guessing. With the queue, submit should be ~1ms, so a stall here
|
||||
// means RENDER or RESOLVE (the stage terms of the last take).
|
||||
var iterWall = System.Diagnostics.Stopwatch.GetTimestamp() - iterStart;
|
||||
if (iterWall > 2 * intervalTicks)
|
||||
{
|
||||
stalls++;
|
||||
var detail = _renderDetail;
|
||||
_renderDetail = "";
|
||||
_log?.Invoke($"FramePump stall: iteration {iterWall / (double)System.Diagnostics.Stopwatch.Frequency * 1000:F0}ms " +
|
||||
$"(> 2× the {interval.TotalMilliseconds:F0}ms interval): worst render " +
|
||||
$"{worstRender / (double)System.Diagnostics.Stopwatch.Frequency * 1000:F0}ms, worst submit " +
|
||||
$"{worstSubmit / (double)System.Diagnostics.Stopwatch.Frequency * 1000:F0}ms, dropped {encoder.DroppedFrames}" +
|
||||
(detail.Length > 0 ? " | " + detail : ""));
|
||||
}
|
||||
ReportStats();
|
||||
}
|
||||
await _pacingDelay(interval, ct);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
@@ -281,12 +552,310 @@ public sealed class FramePump : IDisposable
|
||||
}
|
||||
finally
|
||||
{
|
||||
System.Runtime.GCSettings.LatencyMode = previousGcMode;
|
||||
timeEndPeriod(1);
|
||||
lock (_gate) IsRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnHealthUpdated(object? sender, StreamHealth health) => HealthUpdated?.Invoke(this, health);
|
||||
|
||||
/// <summary>Render a scene, using the baked-crust optimization when a <see cref="SceneGraph"/>
|
||||
/// is wired in: bake/cache the static layers below the split point, then composite the
|
||||
/// dynamic/above-split layers per frame. Without a SceneGraph, falls back to a full render
|
||||
/// (identical output — see SceneCompositorTests). <paramref name="scratch"/> is a pooled
|
||||
/// master buffer when supplied; the fully-static path returns the bake-cache frame itself
|
||||
/// (never pooled — the release side checks owned-by-reference).</summary>
|
||||
private VideoFrame RenderScene(
|
||||
Scene scene,
|
||||
CompositorOptions options,
|
||||
VideoFrame? socialBarFrame,
|
||||
int socialBarTop,
|
||||
VideoFrame? tickerFrame,
|
||||
VideoFrame? flashFrame,
|
||||
byte[]? scratch = null,
|
||||
Func<SceneElement, VideoFrame?>? resolver = null)
|
||||
{
|
||||
resolver ??= _frameResolver;
|
||||
if (_sceneGraph == null)
|
||||
{
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
var frame = RenderFull(scene, options, socialBarFrame, socialBarTop, tickerFrame, flashFrame, scratch, resolver);
|
||||
ProbeRender("full-no-graph", scene, -1, sw.ElapsedMilliseconds, 0);
|
||||
return frame;
|
||||
}
|
||||
|
||||
var split = _sceneGraph.GetSplitPoint(scene);
|
||||
if (split == scene.Elements.Count)
|
||||
{
|
||||
// Fully static scene: bake once, reuse.
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
var baked = _sceneGraph.GetBakedBase(scene, _frameResolver, _compositorOptions);
|
||||
if (baked != null)
|
||||
{
|
||||
// BUG (pre-existing, found while wiring the branding credit 2026-09-26):
|
||||
// this path returned the stretched bake and silently DROPPED the social
|
||||
// bar and the alert ticker. Overlay() copies before blitting, so the
|
||||
// cached bake is not mutated and the flash still cannot get sticky in it.
|
||||
if (socialBarFrame != null || tickerFrame != null || flashFrame != null)
|
||||
{
|
||||
var withOverlays = baked;
|
||||
if (flashFrame != null) withOverlays = SceneCompositor.Overlay(withOverlays, options, flashFrame, 0, 0);
|
||||
if (tickerFrame != null) withOverlays = SceneCompositor.Overlay(withOverlays, options, tickerFrame, tickerFrame.OriginX, tickerFrame.OriginY);
|
||||
if (socialBarFrame != null) withOverlays = SceneCompositor.Overlay(withOverlays, options, socialBarFrame, socialBarTop, 0);
|
||||
baked = withOverlays;
|
||||
}
|
||||
var stretched = StretchMath.BilinearScale(baked, options.OutputWidth, options.OutputHeight);
|
||||
ProbeRender("fully-static", scene, split, sw.ElapsedMilliseconds, (int)sw.ElapsedMilliseconds);
|
||||
return stretched;
|
||||
}
|
||||
}
|
||||
|
||||
var swBase = System.Diagnostics.Stopwatch.StartNew();
|
||||
var baseFrame = _sceneGraph.GetBakedBase(scene, _frameResolver, _compositorOptions);
|
||||
if (baseFrame != null)
|
||||
{
|
||||
var frame = SceneCompositor.CompositeLayers(
|
||||
baseFrame, scene, split, resolver, options, socialBarFrame, socialBarTop, flashFrame: flashFrame, tickerFrame: tickerFrame, scratch: scratch);
|
||||
ProbeRender("base+layers", scene, split, swBase.ElapsedMilliseconds, 0);
|
||||
return frame;
|
||||
}
|
||||
|
||||
// No static base (first layer is dynamic or empty scene) — full render.
|
||||
var swFull = System.Diagnostics.Stopwatch.StartNew();
|
||||
var frame2 = RenderFull(scene, options, socialBarFrame, socialBarTop, tickerFrame, flashFrame, scratch, resolver);
|
||||
ProbeRender("full-render", scene, split, swFull.ElapsedMilliseconds, 0);
|
||||
return frame2;
|
||||
}
|
||||
|
||||
/// <summary>The full-render path (no SceneGraph, or no static base) wrapped in the
|
||||
/// C4 blit-on-change cache: when every resolved input plus every element's
|
||||
/// layout/visual bits match the last composite, reuse it with one BlockCopy instead
|
||||
/// of re-compositing. Mirror the compositor's resolution <i>before</i> deciding so a
|
||||
/// hit costs ~3ms of pure copy and a miss costs exactly the old full render.</summary>
|
||||
private VideoFrame RenderFull(
|
||||
Scene scene, CompositorOptions options, VideoFrame? socialBarFrame, int socialBarTop,
|
||||
VideoFrame? tickerFrame, VideoFrame? flashFrame,
|
||||
byte[]? scratch, Func<SceneElement, VideoFrame?> resolver)
|
||||
{
|
||||
// 1:1 guard: the cache stores the OUTPUT bytes and scratch is source-sized —
|
||||
// at 1:1 they are the same buffer size. Off-size tiers (a vertical 1080×1920
|
||||
// tier, an H264-1080p tier) fall back to a per-tick full render; the deployed
|
||||
// config is master-sized and caching a per-tick fresh scale buffer is the known
|
||||
// vertical-tier follow-up (see ai.md), not silently baked here.
|
||||
var useCache = scratch != null && options.SourceRectWidth == options.OutputWidth
|
||||
&& options.SourceRectHeight == options.OutputHeight;
|
||||
var signature = useCache
|
||||
? BuildFullRenderSignature(scene, options, socialBarFrame, socialBarTop, tickerFrame, flashFrame, resolver)
|
||||
: 0UL;
|
||||
|
||||
if (useCache && _fullCacheValid && signature == _lastFullSignature
|
||||
&& _fullCachePixels!.Length == scratch!.Length)
|
||||
{
|
||||
Buffer.BlockCopy(_fullCachePixels, 0, scratch, 0, scratch.Length);
|
||||
CacheHits++;
|
||||
return new VideoFrame(options.SourceRectWidth, options.SourceRectHeight, scratch);
|
||||
}
|
||||
|
||||
var rendered = _compositor.Render(scene, resolver, flashFrame, options, socialBarFrame, socialBarTop, scratch: scratch, tickerFrame: tickerFrame);
|
||||
CacheRenders++;
|
||||
if (useCache)
|
||||
{
|
||||
var pixels = rendered.BgraPixels;
|
||||
if (_fullCachePixels == null || _fullCachePixels.Length != pixels.Length)
|
||||
_fullCachePixels = new byte[pixels.Length];
|
||||
// PRE-burn / PRE-recycle: the caller burns the counter and recycles this
|
||||
// scratch after RenderScene returns — the cache keeps a clean copy, and the
|
||||
// burn on the next hit writes to a FRESH scratch copy, never the cache.
|
||||
Buffer.BlockCopy(pixels, 0, _fullCachePixels, 0, pixels.Length);
|
||||
_lastFullSignature = signature;
|
||||
_fullCacheValid = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
_fullCacheValid = false; // size changed — never reuse a wrong-size buffer
|
||||
}
|
||||
return rendered;
|
||||
}
|
||||
|
||||
/// <summary>Hash the complete set of inputs the compositor's FULL render consumes:
|
||||
/// the crop/output dimensions, the social bar, and — in element order, mirroring
|
||||
/// <c>SceneCompositor.Render</c>'s iteration — every element's layout/visual fields
|
||||
/// plus the frame each one resolves (through the SAME resolver seam the render will
|
||||
/// use). A changed frame (new capture, new webcam Epoch, new chat raster) changes the
|
||||
/// hash → re-composite; an unchanged one reuses the cache. Buffer-array identity +
|
||||
/// Epoch + CropBounds is the compositor's own paste-key shape — producers hand out
|
||||
/// fresh immutable arrays, or recycled-ring arrays whose monotonic Epoch
|
||||
/// distinguishes generations (the capture take-11 fix), so address-identity alone is
|
||||
/// never trusted to key recycled content.</summary>
|
||||
private static ulong BuildFullRenderSignature(
|
||||
Scene scene, CompositorOptions options, VideoFrame? socialBarFrame, int socialBarTop,
|
||||
VideoFrame? tickerFrame,
|
||||
VideoFrame? flashFrame,
|
||||
Func<SceneElement, VideoFrame?> resolver)
|
||||
{
|
||||
var h = 14695981039346656037UL; // FNV-1a offset basis
|
||||
h = Mix(h, (ulong)options.SourceRectX);
|
||||
h = Mix(h, (ulong)options.SourceRectY);
|
||||
h = Mix(h, (ulong)options.SourceRectWidth);
|
||||
h = Mix(h, (ulong)options.SourceRectHeight);
|
||||
h = Mix(h, (ulong)options.OutputWidth);
|
||||
h = Mix(h, (ulong)options.OutputHeight);
|
||||
h = Mix(h, (ulong)socialBarTop);
|
||||
if (socialBarFrame != null) h = MixFrame(h, socialBarFrame);
|
||||
else h = Mix(h, 0xFFFFFFFFFFFFFFFFUL);
|
||||
if (tickerFrame != null) h = MixFrame(h, tickerFrame);
|
||||
else h = Mix(h, 0xFFFFFFFFFFFFFFFFUL);
|
||||
// BrandFlashPresenter recycles one buffer and bumps Epoch every read, so this
|
||||
// invalidates the cache on every tick of a credit — a cached frame would freeze
|
||||
// the fade and pin a stale spot.
|
||||
if (flashFrame != null) h = MixFrame(h, flashFrame);
|
||||
else h = Mix(h, 0xFFFFFFFFFFFFFFFFUL);
|
||||
|
||||
var backdropResolved = false;
|
||||
foreach (var element in scene.Elements)
|
||||
{
|
||||
h = Mix(h, (ulong)System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(element));
|
||||
h = Mix(h, element.IsVisible ? 1UL : 0UL);
|
||||
h = MixDouble(h, element.X);
|
||||
h = MixDouble(h, element.Y);
|
||||
h = MixDouble(h, element.Width);
|
||||
h = MixDouble(h, element.Height);
|
||||
h = MixDouble(h, element.Opacity);
|
||||
h = Mix(h, (ulong)element.ClipShape);
|
||||
h = Mix(h, element.IsMirrored ? 1UL : 0UL);
|
||||
h = Mix(h, (ulong)element.BorderWidth);
|
||||
h = MixDouble(h, element.BorderOpacity);
|
||||
if (element.TryGetBorderColor(out var bcR, out var bcG, out var bcB))
|
||||
h = Mix(h, (ulong)((bcR << 16) | (bcG << 8) | bcB));
|
||||
else
|
||||
h = Mix(h, 0UL);
|
||||
|
||||
// Mirror the compositor's per-element frame resolution EXACTLY, so the hash
|
||||
// reacts to the same frames a render uses and never to a frame a render
|
||||
// ignores (a false-positive change only wastes one render; a false NEGATIVE
|
||||
// would serve stale bytes — that is what this mirror prevents).
|
||||
VideoFrame? frame = null;
|
||||
if (element is Source { IsBackground: true })
|
||||
{
|
||||
h = Mix(h, 1UL); // the live backdrop — first visible one is resolved, once
|
||||
if (element.IsVisible && !backdropResolved)
|
||||
{
|
||||
frame = resolver(element);
|
||||
backdropResolved = true;
|
||||
}
|
||||
}
|
||||
else if (element is Source { Type: SourceType.TextOverlay })
|
||||
{
|
||||
h = Mix(h, 2UL); // skipped kind — never resolved by the compositor
|
||||
}
|
||||
else if (element is Source { Type: SourceType.Background })
|
||||
{
|
||||
h = Mix(h, 3UL); // background image — resolved even when invisible (Render does)
|
||||
frame = resolver(element);
|
||||
}
|
||||
else
|
||||
{
|
||||
h = Mix(h, 4UL); // regular layer — resolved only when visible
|
||||
if (element.IsVisible) frame = resolver(element);
|
||||
}
|
||||
if (frame != null) h = MixFrame(h, frame);
|
||||
else h = Mix(h, 0UL);
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
private static ulong Mix(ulong h, ulong v) => (h ^ v) * 0x9E3779B97F4A7C15UL;
|
||||
|
||||
private static ulong MixDouble(ulong h, double v)
|
||||
=> Mix(h, unchecked((ulong)BitConverter.DoubleToInt64Bits(v)));
|
||||
|
||||
private static ulong MixFrame(ulong h, VideoFrame frame)
|
||||
{
|
||||
h = Mix(h, (ulong)System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(frame.BgraPixels));
|
||||
h = Mix(h, (ulong)frame.Epoch);
|
||||
h = Mix(h, (ulong)frame.Width);
|
||||
h = Mix(h, (ulong)frame.Height);
|
||||
if (frame.CropBounds is { } cb)
|
||||
{
|
||||
h = Mix(h, (ulong)cb.X);
|
||||
h = Mix(h, (ulong)cb.Y);
|
||||
h = Mix(h, (ulong)cb.W);
|
||||
h = Mix(h, (ulong)cb.H);
|
||||
}
|
||||
else
|
||||
{
|
||||
h = Mix(h, 0xFFFFFFFFFFFFFFFFUL);
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
/// <summary>2026-09-12 half-speed-render probe: the pump only produces ~27fps
|
||||
/// (render ~35ms vs the 16.7ms deadline), and rawvideo muxes at the DECLARED fps,
|
||||
/// so every take muxes at ~half its wall length (the truncation complaint). This
|
||||
/// names WHICH compositor path ate the slow frame so the fix targets the real
|
||||
/// stage. Records only when a frame takes ≥20ms (or carries the previous detail).</summary>
|
||||
private void ProbeRender(string path, Scene scene, int split, long totalMs, int bakeMs)
|
||||
{
|
||||
if (totalMs < 20 && _renderDetail.Length == 0) return;
|
||||
var dynamics = 0;
|
||||
foreach (var e in scene.Elements)
|
||||
if (e.Kind == ElementKind.Dynamic) dynamics++;
|
||||
_renderDetail = $"render={path} split={split} elements={scene.Elements.Count} dynamic={dynamics}" +
|
||||
(bakeMs > 0 ? $" bakeMs={bakeMs}" : "") + $" totalMs={totalMs}";
|
||||
}
|
||||
|
||||
// Dot-matrix digits (5×7, one row per raster line, '1' = lit) burned into the
|
||||
// bottom-right of every composite. Basic OCR-safe shapes, sized so the strip is
|
||||
// a 36×8 white box in the corner of a 1920×1080 frame — readable with a zoomed
|
||||
// player, invisible at normal size.
|
||||
private static readonly string[][] DigitGlyphs =
|
||||
{
|
||||
new[] { "01110","10001","10001","10001","10001","10001","01110" }, // 0
|
||||
new[] { "00100","01100","00100","00100","00100","00100","01110" }, // 1
|
||||
new[] { "01110","10001","00001","00010","00100","01000","11111" }, // 2
|
||||
new[] { "11111","00001","00010","00110","00001","10001","01110" }, // 3
|
||||
new[] { "00010","00110","01010","10010","11111","00010","00010" }, // 4
|
||||
new[] { "11111","10000","11110","00001","00001","10001","01110" }, // 5
|
||||
new[] { "01110","10001","10000","11110","10001","10001","01110" }, // 6
|
||||
new[] { "11111","00001","00010","00100","01000","01000","01000" }, // 7
|
||||
new[] { "01110","10001","10001","01110","10001","10001","01110" }, // 8
|
||||
new[] { "01110","10001","10001","01111","00001","10001","01110" }, // 9
|
||||
};
|
||||
|
||||
private static void BurnFrameIndex(byte[] bgra, int width, int height, long index)
|
||||
{
|
||||
const int digitW = 5, digitH = 7, gap = 1, margin = 2, digitCount = 6;
|
||||
var stripW = digitCount * (digitW + gap) - gap;
|
||||
var left = width - margin - stripW;
|
||||
var top = height - margin - digitH;
|
||||
|
||||
// Solid white box under the digits — the underlying scene can be anything.
|
||||
for (var y = top; y < top + digitH; y++)
|
||||
for (var x = left; x < left + stripW; x++)
|
||||
{
|
||||
var i = (y * width + x) * 4;
|
||||
bgra[i] = 255;
|
||||
bgra[i + 1] = 255;
|
||||
bgra[i + 2] = 255;
|
||||
}
|
||||
|
||||
var text = index.ToString("D6");
|
||||
for (var d = 0; d < digitCount; d++)
|
||||
{
|
||||
var glyph = DigitGlyphs[text[d] - '0'];
|
||||
for (var row = 0; row < digitH; row++)
|
||||
for (var col = 0; col < digitW; col++)
|
||||
{
|
||||
if (glyph[row][col] != '1') continue;
|
||||
var i = ((top + row) * width + (left + d * (digitW + gap) + col)) * 4;
|
||||
bgra[i] = 0;
|
||||
bgra[i + 1] = 0;
|
||||
bgra[i + 2] = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void OnProcessFailed(object? sender, string message)
|
||||
{
|
||||
_log?.Invoke($"FramePump: encoder process failed: {message}");
|
||||
|
||||
@@ -22,4 +22,9 @@ public interface IFfmpegEncoder : IDisposable
|
||||
Task StartAsync(EncoderOptions options, CancellationToken cancellationToken = default);
|
||||
Task SubmitFrameAsync(VideoFrame frame, CancellationToken cancellationToken = default);
|
||||
Task StopAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Frames dropped by the encoder's bounded queue since start (the encoder
|
||||
/// lagging the real-time capture rate). Zero in a healthy session; fed to the pump's
|
||||
/// stats and mirrored by gaps in the burned-in frame counter of the recording.</summary>
|
||||
int DroppedFrames { get; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
|
||||
namespace ytLive.Services;
|
||||
|
||||
/// <summary>
|
||||
/// The real <see cref="IDecodeProcess"/>: a <see cref="Process"/> with stdout
|
||||
/// redirected as raw bytes for reading <c>rawvideo</c> frames. Constructed by
|
||||
/// <see cref="MediaVideoSource"/> for the media-file decoder subprocess.
|
||||
/// </summary>
|
||||
public sealed class FfmpegDecodeProcess : IDecodeProcess
|
||||
{
|
||||
private readonly Process _process;
|
||||
|
||||
public FfmpegDecodeProcess() => _process = new Process { EnableRaisingEvents = true };
|
||||
|
||||
public void Start(ProcessStartInfo startInfo)
|
||||
{
|
||||
_process.StartInfo = startInfo;
|
||||
_process.Start();
|
||||
}
|
||||
|
||||
public Stream StandardOutput => _process.StandardOutput.BaseStream;
|
||||
|
||||
public bool HasExited => _process.HasExited;
|
||||
public int ExitCode => _process.ExitCode;
|
||||
public void Kill()
|
||||
{
|
||||
if (!_process.HasExited) _process.Kill();
|
||||
}
|
||||
|
||||
public Task WaitForExitAsync(CancellationToken cancellationToken = default)
|
||||
=> _process.WaitForExitAsync(cancellationToken);
|
||||
|
||||
public void Dispose() => _process.Dispose();
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace ytLive.Services;
|
||||
|
||||
/// <summary>
|
||||
/// TASK 21 (slice 2): parses the native frame rate of a video from ffprobe
|
||||
/// <c>-show_entries stream=avg_frame_rate,r_frame_rate</c> output, which reports
|
||||
/// each as a rational like <c>30/1</c> or <c>60000/1001</c> (or <c>0/0</c> when
|
||||
/// unknown). Prefers <c>avg_frame_rate</c> (the true display rate over the whole
|
||||
/// file, correct for VFR) and falls back to <c>r_frame_rate</c> (the container/
|
||||
/// codec rate). Pure: no ffprobe, no I/O, unit-testable.
|
||||
/// </summary>
|
||||
public static class FfmpegFrameRateParser
|
||||
{
|
||||
public static double? ParseFrameRate(string probeOutput)
|
||||
{
|
||||
foreach (var key in new[] { "avg_frame_rate=", "r_frame_rate=" })
|
||||
{
|
||||
var value = FindValue(probeOutput, key);
|
||||
if (value is not null && TryParseRational(value, out var fps))
|
||||
return fps;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>The trimmed value of the first line beginning with <paramref name="key"/>.</summary>
|
||||
private static string? FindValue(string text, string key)
|
||||
{
|
||||
foreach (var line in text.Split('\n'))
|
||||
{
|
||||
var trimmed = line.Trim();
|
||||
if (trimmed.StartsWith(key, StringComparison.Ordinal))
|
||||
return trimmed.Substring(key.Length).Trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Parses <c>v</c> as <c>N</c> (integer) or <c>N/M</c> (rational) into
|
||||
/// a frames-per-second double. <c>0</c> and <c>0/M</c> count as unknown.</summary>
|
||||
private static bool TryParseRational(string value, out double fps)
|
||||
{
|
||||
fps = 0;
|
||||
var parts = value.Split('/');
|
||||
if (parts.Length == 0 || !int.TryParse(parts[0], NumberStyles.None, CultureInfo.InvariantCulture, out var num))
|
||||
return false;
|
||||
var den = 1;
|
||||
if (parts.Length > 1 && !int.TryParse(parts[1], NumberStyles.None, CultureInfo.InvariantCulture, out den))
|
||||
return false;
|
||||
if (num <= 0 || den <= 0) return false;
|
||||
fps = (double)num / den;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using ytLive.Services.Encoder;
|
||||
|
||||
namespace ytLive.Services;
|
||||
|
||||
/// <summary>
|
||||
/// TASK 21 (slice 2): resolves the native frame rate of a media file so the
|
||||
/// decoder can pace frames to real time. Seam so <see cref="MediaVideoSource"/>
|
||||
/// never touches ffprobe directly and tests can fake the whole probe.
|
||||
/// </summary>
|
||||
public interface IFrameRateProbe
|
||||
{
|
||||
/// <summary>Native video FPS for <paramref name="path"/>, or null when unknown
|
||||
/// (no video stream, ffprobe unavailable, unparsable).</summary>
|
||||
Task<double?> ProbeAsync(string path, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default probe: locates ffmpeg (via <see cref="IFfmpegLocator"/>), derives the
|
||||
/// sibling <c>ffprobe.exe</c> from its directory, and asks ffprobe for the
|
||||
/// <c>avg_frame_rate</c>/<c>r_frame_rate</c>. Reuses the binary-stdout
|
||||
/// <see cref="IDecodeProcess"/> seam for the subprocess (ffprobe text is read from
|
||||
/// the same raw stdout). Returns null (no pacing) if ffprobe is unavailable.
|
||||
/// </summary>
|
||||
public sealed class FfmpegFrameRateProbe : IFrameRateProbe
|
||||
{
|
||||
private readonly IFfmpegLocator _locator;
|
||||
private readonly Func<IDecodeProcess> _probeProcessFactory;
|
||||
|
||||
public FfmpegFrameRateProbe(IFfmpegLocator locator, Func<IDecodeProcess> probeProcessFactory)
|
||||
{
|
||||
_locator = locator;
|
||||
_probeProcessFactory = probeProcessFactory;
|
||||
}
|
||||
|
||||
public async Task<double?> ProbeAsync(string path, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path)) return null;
|
||||
|
||||
var ffmpegPath = await _locator.LocateAsync(cancellationToken).ConfigureAwait(false);
|
||||
var ffprobePath = Path.Combine(Path.GetDirectoryName(ffmpegPath) ?? string.Empty, "ffprobe.exe");
|
||||
if (!File.Exists(ffprobePath)) return null;
|
||||
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = ffprobePath,
|
||||
Arguments =
|
||||
$"-v error -select_streams v:0 " +
|
||||
$"-show_entries stream=avg_frame_rate,r_frame_rate " +
|
||||
$"-of default=noprint_wrappers=1 \"{path}\"",
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
|
||||
using var process = _probeProcessFactory();
|
||||
process.Start(startInfo);
|
||||
string output;
|
||||
using (var reader = new StreamReader(process.StandardOutput))
|
||||
output = reader.ReadToEnd();
|
||||
await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return FfmpegFrameRateParser.ParseFrameRate(output);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
using System;
|
||||
|
||||
namespace ytLive.Services;
|
||||
|
||||
/// <summary>
|
||||
/// A depth-bounded ring of scratch frame buffers that never writes into memory a live
|
||||
/// consumer may still be reading. A slot's byte[] is only rewritten after at least
|
||||
/// <paramref name="redLine"/> rents have cycled since it was last handed out; a slot
|
||||
/// still inside its red line is skipped, and if the whole ring is inside its red line a
|
||||
/// fresh buffer is handed out rather than lapping a loaned slot. Length-mismatched
|
||||
/// buffers are always replaced by a fresh allocation (the previous array is orphaned,
|
||||
/// never overwritten), so a hand-out keeps its bytes for as long as anyone holds it.
|
||||
///
|
||||
/// The screen capture's consumer contract makes this structural necessity (slice 16,
|
||||
/// 2026-09-14): <c>ScreenCaptureManager</c> keeps each delivered frame as
|
||||
/// <c>session.LatestFrame</c> across conversions and the dispatcher preview copy lags,
|
||||
/// so a held frame can survive several conversions. The 1742 take recorded one torn
|
||||
/// frame (new-top/old-bottom at the webcam split) — a ring slot rewritten under the
|
||||
/// consumer's read. Depth 8 × the conversion interval already exceeded the worst
|
||||
/// observed hold (~50ms, take-14 rule); the red line makes the guarantee structural
|
||||
/// instead of a sizing coincidence.
|
||||
/// </summary>
|
||||
internal sealed class FrameRingBuffer
|
||||
{
|
||||
private readonly byte[]?[] _slots;
|
||||
private readonly long[] _lastHandout;
|
||||
private readonly int _redLine;
|
||||
private readonly object _lock = new();
|
||||
private int _next;
|
||||
private long _seq;
|
||||
private long _allocations;
|
||||
|
||||
public FrameRingBuffer(int depth, int redLine)
|
||||
{
|
||||
_slots = new byte[depth][];
|
||||
_lastHandout = new long[depth];
|
||||
for (var i = 0; i < depth; i++) _lastHandout[i] = -redLine;
|
||||
_redLine = redLine;
|
||||
}
|
||||
|
||||
/// <summary>Buffers freshly allocated after the last <see cref="ConsumeAllocations"/>.</summary>
|
||||
public long Allocations
|
||||
{
|
||||
get { lock (_lock) return _allocations; }
|
||||
}
|
||||
|
||||
/// <summary>Returns the allocation count since the last call and resets it.</summary>
|
||||
public long ConsumeAllocations()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var count = _allocations;
|
||||
_allocations = 0;
|
||||
return count;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Hands out a scratch buffer of <paramref name="size"/> bytes.</summary>
|
||||
public byte[] Rent(int size)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var seq = ++_seq;
|
||||
for (var tries = 0; tries < _slots.Length; tries++)
|
||||
{
|
||||
var idx = (_next + tries) % _slots.Length;
|
||||
// Red line: rewriting this slot could hit a frame a consumer still reads.
|
||||
if (seq - _lastHandout[idx] < _redLine) continue;
|
||||
_next = (idx + 1) % _slots.Length;
|
||||
if (_slots[idx] is { Length: var len } buf && len == size)
|
||||
{
|
||||
_lastHandout[idx] = seq;
|
||||
return buf;
|
||||
}
|
||||
|
||||
// Length mismatch (or never allocated): a fresh array, never an in-place
|
||||
// overwrite — the previous loan's bytes stay valid for whoever holds it.
|
||||
_allocations++;
|
||||
var fresh = new byte[size];
|
||||
_slots[idx] = fresh;
|
||||
_lastHandout[idx] = seq;
|
||||
return fresh;
|
||||
}
|
||||
|
||||
// Defensive: the whole ring is inside its red line — do not lap a loaned slot.
|
||||
_allocations++;
|
||||
return new byte[size];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
|
||||
namespace ytLive.Services;
|
||||
|
||||
/// <summary>
|
||||
/// TASK 21: seam around the media decoder's subprocess (ffmpeg <c>rawvideo</c>).
|
||||
/// Exposes the raw binary stdout (frames) and exit control, so
|
||||
/// <see cref="MediaVideoSource"/> never touches <c>System.Diagnostics.Process</c>
|
||||
/// directly and tests can fake the whole decode. Mirrors the encoder's
|
||||
/// <c>IEncoderProcess</c> but with a binary <see cref="StandardOutput"/>.
|
||||
/// </summary>
|
||||
public interface IDecodeProcess : IDisposable
|
||||
{
|
||||
void Start(ProcessStartInfo startInfo);
|
||||
|
||||
/// <summary>Raw binary stdout — the <c>rawvideo</c> BGRA frame bytes.</summary>
|
||||
Stream StandardOutput { get; }
|
||||
|
||||
bool HasExited { get; }
|
||||
int ExitCode { get; }
|
||||
void Kill();
|
||||
Task WaitForExitAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
namespace ytLive.Services;
|
||||
|
||||
/// <summary>
|
||||
/// TASK 21: a media file decoder exposed to <see cref="MediaVideoSourceManager"/>.
|
||||
/// Mirrors <c>IScreenCaptureSource</c>/<c>ICameraFrameSource</c> so the manager
|
||||
/// (and tests) treat a local video file like any other frame source: start/stop
|
||||
/// and a per-frame <see cref="FrameAvailable"/> event consumers coalesce.
|
||||
/// </summary>
|
||||
public interface IMediaFrameSource : IDisposable
|
||||
{
|
||||
string Key { get; }
|
||||
|
||||
/// <summary>When true, the decoder restarts from the beginning on natural EOF
|
||||
/// instead of raising <see cref="Completed"/> (used for BRB/starting-soon loops).</summary>
|
||||
bool Looping { get; set; }
|
||||
|
||||
/// <summary>Raised (on a worker thread) for each decoded frame.</summary>
|
||||
event Action<VideoFrame>? FrameAvailable;
|
||||
|
||||
/// <summary>Raised when the file finishes decoding (natural EOF) — not raised
|
||||
/// while <see cref="Looping"/> is true.</summary>
|
||||
event Action? Completed;
|
||||
|
||||
Task StartAsync(CancellationToken cancellationToken = default);
|
||||
Task StopAsync();
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using System.Collections.Generic;
|
||||
using ytLive.Models;
|
||||
|
||||
namespace ytLive.Services;
|
||||
|
||||
/// <summary>Session-scoped seam that lists open top-level windows for the
|
||||
/// "Capture Window…" submenu. Mirrors <see cref="IFullScreenDetector"/> so the
|
||||
/// VM never depends on a specific Win32 implementation (and tests hand in a
|
||||
/// fake enumerator).</summary>
|
||||
public interface IWindowEnumerator
|
||||
{
|
||||
/// <summary>Visible, user-facing top-level windows belonging to other
|
||||
/// processes, ordered for display (title, process name).</summary>
|
||||
IReadOnlyList<WindowInfo> Enumerate();
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using System.Security.Cryptography;
|
||||
using Microsoft.Data.Sqlite;
|
||||
|
||||
namespace ytLive.Services;
|
||||
|
||||
public partial class LayoutStore : IDisposable
|
||||
{
|
||||
public byte[]? GetAssetBytes(string assetId)
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "SELECT Data FROM Asset WHERE Id = $id";
|
||||
cmd.Parameters.AddWithValue("$id", assetId);
|
||||
return cmd.ExecuteScalar() as byte[];
|
||||
}
|
||||
|
||||
public string UpsertAsset(byte[] data, int pixelWidth, int pixelHeight)
|
||||
{
|
||||
var hash = Convert.ToHexString(SHA256.HashData(data));
|
||||
var id = Guid.NewGuid().ToString();
|
||||
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
INSERT INTO Asset (Id, Hash, Data, PixelWidth, PixelHeight)
|
||||
VALUES ($id, $hash, $data, $w, $h)
|
||||
ON CONFLICT(Hash) DO NOTHING;
|
||||
""";
|
||||
cmd.Parameters.AddWithValue("$id", id);
|
||||
cmd.Parameters.AddWithValue("$hash", hash);
|
||||
cmd.Parameters.AddWithValue("$data", data);
|
||||
cmd.Parameters.AddWithValue("$w", pixelWidth);
|
||||
cmd.Parameters.AddWithValue("$h", pixelHeight);
|
||||
cmd.ExecuteNonQuery();
|
||||
|
||||
cmd.CommandText = "SELECT Id FROM Asset WHERE Hash = $hash;";
|
||||
return (string)cmd.ExecuteScalar()!;
|
||||
}
|
||||
|
||||
// ─── License (TASK 10 — Polar billing) ───
|
||||
|
||||
/// <summary>The saved license key, or null if never entered.</summary>
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
using ytLive.Models;
|
||||
|
||||
namespace ytLive.Services;
|
||||
|
||||
public partial class LayoutStore : IDisposable
|
||||
{
|
||||
public List<Scene> Load()
|
||||
{
|
||||
Webcam = null;
|
||||
Socials = null;
|
||||
Music = null;
|
||||
var scenes = new List<Scene>();
|
||||
var sourcesByScene = new Dictionary<string, List<(Source Source, int Sort)>>();
|
||||
var configsByScene = new Dictionary<string, List<(WebcamSceneConfig Config, int Sort)>>();
|
||||
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "SELECT Id, Name, IsHidden, IsChatScene, HasBackground, HasSocialBar FROM Scene ORDER BY SortOrder";
|
||||
using var reader = cmd.ExecuteReader();
|
||||
while (reader.Read())
|
||||
{
|
||||
scenes.Add(new Scene
|
||||
{
|
||||
Id = reader.GetString(0),
|
||||
Name = reader.GetString(1),
|
||||
IsHidden = reader.GetInt32(2) != 0,
|
||||
IsChatScene = reader.GetInt32(3) != 0,
|
||||
HasBackground = reader.GetInt32(4) != 0,
|
||||
HasSocialBar = reader.FieldCount > 5 && !reader.IsDBNull(5) && reader.GetInt32(5) != 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "SELECT Id, DeviceId, Name FROM Webcam;";
|
||||
using var reader = cmd.ExecuteReader();
|
||||
if (reader.Read())
|
||||
{
|
||||
Webcam = new Webcam
|
||||
{
|
||||
Id = reader.GetString(0),
|
||||
DeviceId = reader.GetString(1),
|
||||
Name = reader.GetString(2),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "SELECT Id, BarPosition, BarEnabled FROM Socials LIMIT 1;";
|
||||
using var reader = cmd.ExecuteReader();
|
||||
if (reader.Read())
|
||||
{
|
||||
Socials = new SocialsConfig
|
||||
{
|
||||
BarPosition = Enum.TryParse<SocialBarPosition>(reader.GetString(1), out var pos) ? pos : SocialBarPosition.Bottom,
|
||||
BarEnabled = reader.FieldCount > 2 && !reader.IsDBNull(2) && reader.GetInt32(2) != 0,
|
||||
};
|
||||
var socialsId = reader.GetString(0);
|
||||
|
||||
using var entryCmd = _connection.CreateCommand();
|
||||
entryCmd.CommandText = "SELECT Service, Handle, ProfileUrl, Software FROM SocialEntry WHERE SocialsId = $id ORDER BY SortOrder;";
|
||||
entryCmd.Parameters.AddWithValue("$id", socialsId);
|
||||
using var entryReader = entryCmd.ExecuteReader();
|
||||
while (entryReader.Read())
|
||||
{
|
||||
Socials.Entries.Add(new SocialEntry
|
||||
{
|
||||
Service = Enum.TryParse<SocialService>(entryReader.GetString(0), out var svc) ? svc : SocialService.Link,
|
||||
Handle = entryReader.GetString(1),
|
||||
ProfileUrl = entryReader.GetString(2),
|
||||
FediverseSoftware = entryReader.FieldCount > 3 && !entryReader.IsDBNull(3) ? entryReader.GetString(3) : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "SELECT TrackPath, IsEnabled FROM Music LIMIT 1;";
|
||||
using var reader = cmd.ExecuteReader();
|
||||
if (reader.Read())
|
||||
{
|
||||
Music = new Music
|
||||
{
|
||||
TrackPath = reader.GetString(0),
|
||||
IsEnabled = reader.GetInt32(1) != 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = """
|
||||
SELECT Id, SceneId, AssetId, Type, Name, IsEnabled,
|
||||
X, Y, Width, Height, Opacity, MonitorIndex, ClipShape, IsMirrored,
|
||||
IsBackground, CaptureKey, ShowDesktop,
|
||||
ChatFontSize, ChatFontColor, ChatBackgroundColor, ChatMaxMessages,
|
||||
ChatShowTimestamps, ChatShowBadges, ChatMemberColor, ChatSuperChatColor,
|
||||
WebUri, MediaPath, MediaIsLooping, MediaVolume, MediaPlaybackState,
|
||||
AlertVideoAssetId, AlertVideoPath, AlertUseDefaultVideo, AlertShowTicker, AlertVideoVolume, SortOrder,
|
||||
AlertDisplayMethod
|
||||
FROM Source ORDER BY SortOrder
|
||||
""";
|
||||
using var reader = cmd.ExecuteReader();
|
||||
while (reader.Read())
|
||||
{
|
||||
var sceneId = reader.GetString(1);
|
||||
var type = Enum.Parse<SourceType>(reader.GetString(3));
|
||||
var source = new Source
|
||||
{
|
||||
Id = reader.GetString(0),
|
||||
AssetId = reader.IsDBNull(2) ? null : reader.GetString(2),
|
||||
Type = type,
|
||||
Name = reader.GetString(4),
|
||||
IsEnabled = reader.GetInt32(5) != 0,
|
||||
X = reader.GetDouble(6),
|
||||
Y = reader.GetDouble(7),
|
||||
Width = reader.GetDouble(8),
|
||||
Height = reader.GetDouble(9),
|
||||
Opacity = reader.GetDouble(10),
|
||||
MonitorIndex = reader.IsDBNull(11) ? null : reader.GetInt32(11),
|
||||
ClipShape = Enum.TryParse<ClipShape>(reader.GetString(12), out var clip) ? clip : ClipShape.Traditional,
|
||||
IsMirrored = reader.GetInt32(13) != 0,
|
||||
// Rows saved before the Type setter derived the flag carry
|
||||
// IsBackground=0; derive from Type, keeping the persisted bit
|
||||
// for legacy DisplayCapture backdrops.
|
||||
IsBackground = type == SourceType.Background || reader.GetInt32(14) != 0,
|
||||
CaptureKey = reader.IsDBNull(15) ? null : reader.GetString(15),
|
||||
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),
|
||||
WebUri = reader.IsDBNull(25) ? null : reader.GetString(25),
|
||||
MediaPath = reader.IsDBNull(26) ? null : reader.GetString(26),
|
||||
MediaIsLooping = reader.IsDBNull(27) ? false : reader.GetInt32(27) != 0,
|
||||
MediaVolume = reader.IsDBNull(28) ? 1.0 : reader.GetDouble(28),
|
||||
MediaPlaybackState = reader.IsDBNull(29)
|
||||
? MediaPlaybackState.Stopped
|
||||
: Enum.TryParse<MediaPlaybackState>(reader.GetString(29), out var ps)
|
||||
? ps
|
||||
: MediaPlaybackState.Stopped,
|
||||
AlertVideoAssetId = reader.IsDBNull(30) ? null : reader.GetString(30),
|
||||
AlertVideoPath = reader.IsDBNull(31) ? null : reader.GetString(31),
|
||||
AlertUseDefaultVideo = reader.IsDBNull(32) ? true : reader.GetInt32(32) != 0,
|
||||
AlertShowTicker = reader.IsDBNull(33) ? true : reader.GetInt32(33) != 0,
|
||||
AlertVideoVolume = reader.IsDBNull(34) ? 1.0 : reader.GetDouble(34),
|
||||
AlertDisplayMethod = reader.IsDBNull(36) ? AlertDisplayMethod.TickerScroll : (AlertDisplayMethod)reader.GetInt32(36),
|
||||
};
|
||||
if (!sourcesByScene.TryGetValue(sceneId, out var list))
|
||||
sourcesByScene[sceneId] = list = new List<(Source, int)>();
|
||||
list.Add((source, reader.GetInt32(35)));
|
||||
}
|
||||
}
|
||||
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = """
|
||||
SELECT SceneId, WebcamId, IsVisible, X, Y, Width, Height, Opacity,
|
||||
ClipShape, IsMirrored, BorderColor, BorderOpacity, BorderWidth, BorderAnimation,
|
||||
RectWidth, RectHeight, SortOrder
|
||||
FROM WebcamSceneConfig ORDER BY SortOrder
|
||||
""";
|
||||
using var reader = cmd.ExecuteReader();
|
||||
while (reader.Read())
|
||||
{
|
||||
var sceneId = reader.GetString(0);
|
||||
var config = new WebcamSceneConfig
|
||||
{
|
||||
WebcamId = reader.GetString(1),
|
||||
Name = Webcam?.Name ?? "Webcam",
|
||||
IsVisible = reader.GetInt32(2) != 0,
|
||||
X = reader.GetDouble(3),
|
||||
Y = reader.GetDouble(4),
|
||||
Width = reader.GetDouble(5),
|
||||
Height = reader.GetDouble(6),
|
||||
Opacity = reader.GetDouble(7),
|
||||
ClipShape = Enum.TryParse<ClipShape>(reader.GetString(8), out var clip) ? clip : ClipShape.Traditional,
|
||||
IsMirrored = reader.GetInt32(9) != 0,
|
||||
BorderColor = reader.IsDBNull(10) ? string.Empty : reader.GetString(10),
|
||||
BorderOpacity = reader.IsDBNull(11) ? 1.0 : reader.GetDouble(11),
|
||||
BorderWidth = reader.IsDBNull(12) ? 0 : reader.GetInt32(12),
|
||||
BorderAnimation = Enum.TryParse<BorderAnimation>(reader.GetString(13), out var anim) ? anim : BorderAnimation.None,
|
||||
RectWidth = reader.IsDBNull(14) ? null : reader.GetDouble(14),
|
||||
RectHeight = reader.IsDBNull(15) ? null : reader.GetDouble(15),
|
||||
};
|
||||
if (!configsByScene.TryGetValue(sceneId, out var list))
|
||||
configsByScene[sceneId] = list = new List<(WebcamSceneConfig, int)>();
|
||||
list.Add((config, reader.GetInt32(16)));
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var scene in scenes)
|
||||
{
|
||||
// Source and WebcamSceneConfig rows share ONE z-order space per scene
|
||||
// (both SortOrders = the element's index within scene.Elements). Merge
|
||||
// the two tables' rows by that shared z rather than appending all
|
||||
// sources before all configs — a webcam slotted between sources must
|
||||
// reload in place. Legacy rows (written before the unified space) may
|
||||
// tie at 0: break ties sources-first, so old data reads back stacked
|
||||
// the same way it did pre-unification.
|
||||
var ordered = new List<(int Sort, SceneElement Element)>();
|
||||
if (sourcesByScene.TryGetValue(scene.Id, out var sources))
|
||||
foreach (var (source, sort) in sources)
|
||||
ordered.Add((sort, source));
|
||||
if (configsByScene.TryGetValue(scene.Id, out var configs))
|
||||
foreach (var (config, sort) in configs)
|
||||
ordered.Add((sort, config));
|
||||
|
||||
foreach (var (_, element) in ordered.OrderBy(e => e.Sort).ThenBy(e => e.Element is Source ? 0 : 1))
|
||||
scene.Elements.Add(element);
|
||||
|
||||
var bg = scene.Elements.OfType<Source>().FirstOrDefault(s => s.IsBackground);
|
||||
if (bg != null)
|
||||
{
|
||||
// Load from source-keyed settings (new) or scene-keyed (legacy migration)
|
||||
var path = GetSetting($"BackgroundPath_{bg.Id}");
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
path = GetSetting($"BackgroundPath_{scene.Id}");
|
||||
if (!string.IsNullOrWhiteSpace(path))
|
||||
bg.CustomBackgroundPath = path;
|
||||
|
||||
var useDefault = GetSetting($"BackgroundUseDefault_{bg.Id}");
|
||||
if (string.IsNullOrWhiteSpace(useDefault))
|
||||
useDefault = GetSetting($"BackgroundUseDefault_{scene.Id}");
|
||||
if (useDefault == "0")
|
||||
bg.UseDefaultBackground = false;
|
||||
}
|
||||
}
|
||||
|
||||
return scenes;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,552 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
using ytLive.Models;
|
||||
|
||||
namespace ytLive.Services;
|
||||
|
||||
public partial class LayoutStore : IDisposable
|
||||
{
|
||||
private void EnsureSchema()
|
||||
{
|
||||
string[] statements =
|
||||
{
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS Scene (
|
||||
Id TEXT PRIMARY KEY,
|
||||
Name TEXT NOT NULL,
|
||||
IsHidden INTEGER NOT NULL DEFAULT 0,
|
||||
IsChatScene INTEGER NOT NULL DEFAULT 0,
|
||||
HasBackground INTEGER NOT NULL DEFAULT 1,
|
||||
SortOrder INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS Asset (
|
||||
Id TEXT PRIMARY KEY,
|
||||
Hash TEXT NOT NULL UNIQUE,
|
||||
Data BLOB NOT NULL,
|
||||
PixelWidth INTEGER NOT NULL DEFAULT 0,
|
||||
PixelHeight INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS Source (
|
||||
Id TEXT PRIMARY KEY,
|
||||
SceneId TEXT NOT NULL REFERENCES Scene(Id) ON DELETE CASCADE,
|
||||
AssetId TEXT REFERENCES Asset(Id),
|
||||
Type TEXT NOT NULL,
|
||||
Name TEXT NOT NULL,
|
||||
IsEnabled INTEGER NOT NULL DEFAULT 1,
|
||||
X REAL NOT NULL DEFAULT 0,
|
||||
Y REAL NOT NULL DEFAULT 0,
|
||||
Width REAL NOT NULL DEFAULT 0,
|
||||
Height REAL NOT NULL DEFAULT 0,
|
||||
Opacity REAL NOT NULL DEFAULT 1,
|
||||
MonitorIndex INTEGER,
|
||||
DeviceId TEXT,
|
||||
ClipShape TEXT NOT NULL DEFAULT 'Traditional',
|
||||
IsMirrored INTEGER NOT NULL DEFAULT 0,
|
||||
IsBackground INTEGER NOT NULL DEFAULT 0,
|
||||
CaptureKey TEXT,
|
||||
SortOrder INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS Webcam (
|
||||
Id TEXT PRIMARY KEY,
|
||||
DeviceId TEXT NOT NULL UNIQUE,
|
||||
Name TEXT NOT NULL
|
||||
);
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS WebcamSceneConfig (
|
||||
SceneId TEXT NOT NULL REFERENCES Scene(Id) ON DELETE CASCADE,
|
||||
WebcamId TEXT NOT NULL REFERENCES Webcam(Id) ON DELETE CASCADE,
|
||||
IsVisible INTEGER NOT NULL DEFAULT 1,
|
||||
X REAL NOT NULL DEFAULT 0,
|
||||
Y REAL NOT NULL DEFAULT 0,
|
||||
Width REAL NOT NULL DEFAULT 0,
|
||||
Height REAL NOT NULL DEFAULT 0,
|
||||
Opacity REAL NOT NULL DEFAULT 1,
|
||||
ClipShape TEXT NOT NULL DEFAULT 'Traditional',
|
||||
IsMirrored INTEGER NOT NULL DEFAULT 0,
|
||||
RectWidth REAL,
|
||||
RectHeight REAL,
|
||||
BorderColor TEXT,
|
||||
BorderOpacity REAL NOT NULL DEFAULT 1,
|
||||
BorderWidth INTEGER NOT NULL DEFAULT 0,
|
||||
BorderAnimation TEXT NOT NULL DEFAULT 'None',
|
||||
SortOrder INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (SceneId, WebcamId)
|
||||
);
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS Socials (
|
||||
Id TEXT PRIMARY KEY,
|
||||
BarPosition TEXT NOT NULL DEFAULT 'Bottom',
|
||||
BarJustify TEXT NOT NULL DEFAULT 'Center'
|
||||
);
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS SocialEntry (
|
||||
Id TEXT PRIMARY KEY,
|
||||
SocialsId TEXT NOT NULL REFERENCES Socials(Id) ON DELETE CASCADE,
|
||||
Service TEXT NOT NULL,
|
||||
Handle TEXT NOT NULL,
|
||||
ProfileUrl TEXT NOT NULL,
|
||||
SortOrder INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS Music (
|
||||
Id TEXT PRIMARY KEY,
|
||||
TrackPath TEXT NOT NULL,
|
||||
IsEnabled INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS Settings (
|
||||
Key TEXT PRIMARY KEY,
|
||||
Value TEXT NOT NULL
|
||||
);
|
||||
""",
|
||||
// v9 → v10 (TASK 39): YPP journey-tracker snapshots, appended on every
|
||||
// stats refresh. The history is the raw data a later analytics slice
|
||||
// reads to compute velocity/(de)acceleration/ETA.
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS YppSnapshot (
|
||||
CapturedAtUtc TEXT NOT NULL PRIMARY KEY,
|
||||
SubscriberCount INTEGER NOT NULL,
|
||||
VideoCount INTEGER NOT NULL,
|
||||
TotalViews INTEGER NOT NULL,
|
||||
UploadsLast90Days INTEGER NOT NULL,
|
||||
CommunityGuidelinesGoodStanding INTEGER NOT NULL,
|
||||
CopyrightStrikesGoodStanding INTEGER NOT NULL,
|
||||
ContentIdClaimsGoodStanding INTEGER NOT NULL,
|
||||
OverallGoodStanding INTEGER NOT NULL
|
||||
);
|
||||
""",
|
||||
};
|
||||
foreach (var sql in statements)
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = sql;
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
MigrateSourceTable();
|
||||
MigrateWebcamConfigTable();
|
||||
MigrateSceneTable();
|
||||
MigrateSceneSocialBarColumn();
|
||||
MigrateSocialsTable();
|
||||
MigrateSocialEntryTable();
|
||||
if (GetUserVersion() < 3)
|
||||
MigrateToV3();
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "PRAGMA user_version = 10;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// v1 → v2: Source gains ClipShape + IsMirrored (webcam clip/mirror prefs).
|
||||
// CREATE TABLE IF NOT EXISTS doesn't touch existing tables, so pre-v2 DBs
|
||||
// get the columns here instead.
|
||||
private void MigrateSourceTable()
|
||||
{
|
||||
var columns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "PRAGMA table_info(Source);";
|
||||
using var reader = cmd.ExecuteReader();
|
||||
while (reader.Read())
|
||||
columns.Add(reader.GetString(1));
|
||||
}
|
||||
|
||||
if (!columns.Contains("ClipShape"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE Source ADD COLUMN ClipShape TEXT NOT NULL DEFAULT 'Traditional';";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
if (!columns.Contains("AlertDisplayMethod"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE Source ADD COLUMN AlertDisplayMethod INTEGER NOT NULL DEFAULT 0;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
if (!columns.Contains("IsMirrored"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE Source ADD COLUMN IsMirrored INTEGER NOT NULL DEFAULT 0;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
if (columns.Contains("IsBackdrop"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE Source RENAME COLUMN IsBackdrop TO IsBackground;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
else if (!columns.Contains("IsBackground"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE Source ADD COLUMN IsBackground INTEGER NOT NULL DEFAULT 0;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
if (!columns.Contains("CaptureKey"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE Source ADD COLUMN CaptureKey TEXT;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
// TASK 3.18: ChatBox overlay source
|
||||
if (!columns.Contains("ChatFontSize"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE Source ADD COLUMN ChatFontSize REAL NOT NULL DEFAULT 16;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
if (!columns.Contains("ChatFontColor"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE Source ADD COLUMN ChatFontColor TEXT NOT NULL DEFAULT '#FFFFFF';";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
if (!columns.Contains("ChatBackgroundColor"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE Source ADD COLUMN ChatBackgroundColor TEXT NOT NULL DEFAULT '#80000000';";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
if (!columns.Contains("ChatMaxMessages"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE Source ADD COLUMN ChatMaxMessages INTEGER NOT NULL DEFAULT 5;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
if (!columns.Contains("ChatShowTimestamps"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE Source ADD COLUMN ChatShowTimestamps INTEGER NOT NULL DEFAULT 1;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
if (!columns.Contains("ChatShowBadges"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE Source ADD COLUMN ChatShowBadges INTEGER NOT NULL DEFAULT 1;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
if (!columns.Contains("ChatMemberColor"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE Source ADD COLUMN ChatMemberColor TEXT NOT NULL DEFAULT '#e94560';";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
if (!columns.Contains("ChatSuperChatColor"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
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();
|
||||
}
|
||||
|
||||
if (!columns.Contains("WebUri"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE Source ADD COLUMN WebUri TEXT;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
// TASK 21: Media source (video/audio file playback)
|
||||
if (!columns.Contains("MediaPath"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE Source ADD COLUMN MediaPath TEXT;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
if (!columns.Contains("MediaIsLooping"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE Source ADD COLUMN MediaIsLooping INTEGER NOT NULL DEFAULT 0;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
if (!columns.Contains("MediaVolume"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE Source ADD COLUMN MediaVolume REAL NOT NULL DEFAULT 1;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
if (!columns.Contains("MediaPlaybackState"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE Source ADD COLUMN MediaPlaybackState TEXT NOT NULL DEFAULT 'Stopped';";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
// TASK 47: alert video config. The default clip asset id + the creator's
|
||||
// replacement path (a FILE reference, never a stored BLOB) + the pill,
|
||||
// ticker toggle and volume live on the row.
|
||||
if (!columns.Contains("AlertVideoAssetId"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE Source ADD COLUMN AlertVideoAssetId TEXT;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
if (!columns.Contains("AlertVideoPath"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE Source ADD COLUMN AlertVideoPath TEXT;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
if (!columns.Contains("AlertUseDefaultVideo"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE Source ADD COLUMN AlertUseDefaultVideo INTEGER NOT NULL DEFAULT 1;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
if (!columns.Contains("AlertShowTicker"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE Source ADD COLUMN AlertShowTicker INTEGER NOT NULL DEFAULT 1;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
if (!columns.Contains("AlertVideoVolume"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE Source ADD COLUMN AlertVideoVolume REAL NOT NULL DEFAULT 1;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
// Rename legacy "Backdrop" display name to "Background" in existing rows
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "UPDATE Source SET Name = 'Background' WHERE Name = 'Backdrop';";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
|
||||
// v3 → v4: WebcamSceneConfig gains RectWidth/RectHeight (the pre-Round rect,
|
||||
// so a reloaded Round webcam restores its aspect on toggle-back).
|
||||
private void MigrateWebcamConfigTable()
|
||||
{
|
||||
var columns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "PRAGMA table_info(WebcamSceneConfig);";
|
||||
using var reader = cmd.ExecuteReader();
|
||||
while (reader.Read())
|
||||
columns.Add(reader.GetString(1));
|
||||
}
|
||||
|
||||
if (!columns.Contains("RectWidth"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE WebcamSceneConfig ADD COLUMN RectWidth REAL;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
if (!columns.Contains("RectHeight"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE WebcamSceneConfig ADD COLUMN RectHeight REAL;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
|
||||
// v2 → v3: the webcam leaves the Source table. Any webcam Source rows become
|
||||
// one Webcam identity (first row — one camera app-wide) + a WebcamSceneConfig
|
||||
// per scene that had one, then the webcam rows are deleted. No backfill:
|
||||
// scenes without the webcam stay webcam-free.
|
||||
private void MigrateToV3()
|
||||
{
|
||||
var rows = new List<(string Id, string SceneId, string Name, double X, double Y, double W, double H, double Opacity, string DeviceId, string ClipShape, bool IsMirrored)>();
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = """
|
||||
SELECT Id, SceneId, Name, X, Y, Width, Height, Opacity, DeviceId, ClipShape, IsMirrored
|
||||
FROM Source WHERE Type = 'Webcam' ORDER BY SortOrder;
|
||||
""";
|
||||
using var reader = cmd.ExecuteReader();
|
||||
while (reader.Read())
|
||||
{
|
||||
rows.Add((
|
||||
reader.GetString(0),
|
||||
reader.GetString(1),
|
||||
reader.GetString(2),
|
||||
reader.GetDouble(3),
|
||||
reader.GetDouble(4),
|
||||
reader.GetDouble(5),
|
||||
reader.GetDouble(6),
|
||||
reader.GetDouble(7),
|
||||
reader.IsDBNull(8) ? string.Empty : reader.GetString(8),
|
||||
reader.GetString(9),
|
||||
reader.GetInt32(10) != 0));
|
||||
}
|
||||
}
|
||||
if (rows.Count == 0) return;
|
||||
|
||||
using var tx = _connection.BeginTransaction();
|
||||
var webcamId = Guid.NewGuid().ToString();
|
||||
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "INSERT INTO Webcam (Id, DeviceId, Name) VALUES ($id, $device, $name);";
|
||||
cmd.Transaction = tx;
|
||||
cmd.Parameters.AddWithValue("$id", webcamId);
|
||||
cmd.Parameters.AddWithValue("$device", rows[0].DeviceId);
|
||||
cmd.Parameters.AddWithValue("$name", rows[0].Name);
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
foreach (var row in rows)
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
INSERT INTO WebcamSceneConfig
|
||||
(SceneId, WebcamId, IsVisible, X, Y, Width, Height, Opacity,
|
||||
ClipShape, IsMirrored, BorderColor, BorderOpacity, BorderWidth, BorderAnimation, SortOrder)
|
||||
VALUES ($sceneId, $webcamId, 1, $x, $y, $w, $h, $opacity,
|
||||
$clip, $mirrored, NULL, 1, 0, 'None', 0);
|
||||
""";
|
||||
cmd.Transaction = tx;
|
||||
cmd.Parameters.AddWithValue("$sceneId", row.SceneId);
|
||||
cmd.Parameters.AddWithValue("$webcamId", webcamId);
|
||||
cmd.Parameters.AddWithValue("$x", row.X);
|
||||
cmd.Parameters.AddWithValue("$y", row.Y);
|
||||
cmd.Parameters.AddWithValue("$w", row.W);
|
||||
cmd.Parameters.AddWithValue("$h", row.H);
|
||||
cmd.Parameters.AddWithValue("$opacity", row.Opacity);
|
||||
cmd.Parameters.AddWithValue("$clip", row.ClipShape);
|
||||
cmd.Parameters.AddWithValue("$mirrored", row.IsMirrored ? 1 : 0);
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "DELETE FROM Source WHERE Type = 'Webcam';";
|
||||
cmd.Transaction = tx;
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
tx.Commit();
|
||||
}
|
||||
|
||||
// v5 → v6: Scene gains HasBackground. Added columns default to true; the
|
||||
// ONE-TIME backfill (runs only when the column is first added) turns every
|
||||
// non-Live canonical scene off and drops their background sources — the fix for
|
||||
// DBs saved before the Live-only policy. MainViewModel.NormalizeBackgrounds
|
||||
// re-runs the same normalization on every load for DBs that miss the backfill.
|
||||
private void MigrateSceneTable()
|
||||
{
|
||||
var columns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "PRAGMA table_info(Scene);";
|
||||
using var reader = cmd.ExecuteReader();
|
||||
while (reader.Read())
|
||||
columns.Add(reader.GetString(1));
|
||||
}
|
||||
|
||||
if (columns.Contains("HasBackdrop"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE Scene RENAME COLUMN HasBackdrop TO HasBackground;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
else if (columns.Contains("HasBackground"))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!columns.Contains("HasBackground"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE Scene ADD COLUMN HasBackground INTEGER NOT NULL DEFAULT 1;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
const string noBackground = "('starting', 'brb', 'chat', 'ending')";
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = $"UPDATE Scene SET HasBackground = 0 WHERE LOWER(TRIM(Name)) IN {noBackground};";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = $"""
|
||||
DELETE FROM Source
|
||||
WHERE IsBackground = 1 AND SceneId IN
|
||||
(SELECT Id FROM Scene WHERE LOWER(TRIM(Name)) IN {noBackground});
|
||||
""";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
|
||||
// v6 → v7: Scene gains HasSocialBar (the per-scene social-bar toggle).
|
||||
// Defaults to 0 (off) — the creator adds it explicitly via the footer [+].
|
||||
private void MigrateSceneSocialBarColumn()
|
||||
{
|
||||
var columns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "PRAGMA table_info(Scene);";
|
||||
using var reader = cmd.ExecuteReader();
|
||||
while (reader.Read())
|
||||
columns.Add(reader.GetString(1));
|
||||
}
|
||||
|
||||
if (columns.Contains("HasSocialBar")) return;
|
||||
|
||||
using var alter = _connection.CreateCommand();
|
||||
alter.CommandText = "ALTER TABLE Scene ADD COLUMN HasSocialBar INTEGER NOT NULL DEFAULT 0;";
|
||||
alter.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
// v7 → v8: Socials gains BarEnabled (the dialog's show/hide switch). The old
|
||||
// BarJustify column stays for back-compat but is no longer read or written.
|
||||
private void MigrateSocialsTable()
|
||||
{
|
||||
var columns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "PRAGMA table_info(Socials);";
|
||||
using var reader = cmd.ExecuteReader();
|
||||
while (reader.Read())
|
||||
columns.Add(reader.GetString(1));
|
||||
}
|
||||
|
||||
if (columns.Contains("BarEnabled")) return;
|
||||
|
||||
using var alter = _connection.CreateCommand();
|
||||
alter.CommandText = "ALTER TABLE Socials ADD COLUMN BarEnabled INTEGER NOT NULL DEFAULT 1;";
|
||||
alter.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
// v8 → v9: SocialEntry gains Software (the fediverse instance's nodeinfo
|
||||
// software name) so a reloaded entry still shows the right service logo.
|
||||
private void MigrateSocialEntryTable()
|
||||
{
|
||||
var columns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "PRAGMA table_info(SocialEntry);";
|
||||
using var reader = cmd.ExecuteReader();
|
||||
while (reader.Read())
|
||||
columns.Add(reader.GetString(1));
|
||||
}
|
||||
|
||||
if (columns.Contains("Software")) return;
|
||||
|
||||
using var alter = _connection.CreateCommand();
|
||||
alter.CommandText = "ALTER TABLE SocialEntry ADD COLUMN Software TEXT;";
|
||||
alter.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
using ytLive.Models;
|
||||
|
||||
namespace ytLive.Services;
|
||||
|
||||
public partial class LayoutStore : IDisposable
|
||||
{
|
||||
public void Save(IEnumerable<Scene> scenes, Webcam? webcam, SocialsConfig? socials, Music? music = null)
|
||||
{
|
||||
using var tx = _connection.BeginTransaction();
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "DELETE FROM WebcamSceneConfig;";
|
||||
cmd.Transaction = tx;
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "DELETE FROM Source;";
|
||||
cmd.Transaction = tx;
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "DELETE FROM Webcam;";
|
||||
cmd.Transaction = tx;
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "DELETE FROM SocialEntry;";
|
||||
cmd.Transaction = tx;
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "DELETE FROM Socials;";
|
||||
cmd.Transaction = tx;
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "DELETE FROM Music;";
|
||||
cmd.Transaction = tx;
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "DELETE FROM Scene;";
|
||||
cmd.Transaction = tx;
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = """
|
||||
INSERT INTO Scene (Id, Name, IsHidden, IsChatScene, HasBackground, HasSocialBar, SortOrder)
|
||||
VALUES ($id, $name, $isHidden, $isChat, $hasBackground, $hasSocialBar, $sort)
|
||||
""";
|
||||
cmd.Transaction = tx;
|
||||
var idP = cmd.Parameters.Add("$id", SqliteType.Text);
|
||||
var nameP = cmd.Parameters.Add("$name", SqliteType.Text);
|
||||
var hiddenP = cmd.Parameters.Add("$isHidden", SqliteType.Integer);
|
||||
var chatP = cmd.Parameters.Add("$isChat", SqliteType.Integer);
|
||||
var backgroundP = cmd.Parameters.Add("$hasBackground", SqliteType.Integer);
|
||||
var socialBarP = cmd.Parameters.Add("$hasSocialBar", SqliteType.Integer);
|
||||
var sortP = cmd.Parameters.Add("$sort", SqliteType.Integer);
|
||||
|
||||
var sort = 0;
|
||||
foreach (var scene in scenes)
|
||||
{
|
||||
idP.Value = scene.Id;
|
||||
nameP.Value = scene.Name;
|
||||
hiddenP.Value = scene.IsHidden ? 1 : 0;
|
||||
chatP.Value = scene.IsChatScene ? 1 : 0;
|
||||
backgroundP.Value = scene.HasBackground ? 1 : 0;
|
||||
socialBarP.Value = scene.HasSocialBar ? 1 : 0;
|
||||
sortP.Value = sort++;
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = """
|
||||
INSERT INTO Source (Id, SceneId, AssetId, Type, Name, IsEnabled,
|
||||
X, Y, Width, Height, Opacity, MonitorIndex,
|
||||
ClipShape, IsMirrored, IsBackground, CaptureKey, ShowDesktop, SortOrder,
|
||||
ChatFontSize, ChatFontColor, ChatBackgroundColor, ChatMaxMessages,
|
||||
ChatShowTimestamps, ChatShowBadges, ChatMemberColor, ChatSuperChatColor,
|
||||
WebUri, MediaPath, MediaIsLooping, MediaVolume, MediaPlaybackState,
|
||||
AlertVideoAssetId, AlertVideoPath, AlertUseDefaultVideo, AlertShowTicker, AlertVideoVolume,
|
||||
AlertDisplayMethod)
|
||||
VALUES ($id, $sceneId, $assetId, $type, $name, $isEnabled,
|
||||
$x, $y, $w, $h, $opacity, $monitor,
|
||||
$clip, $mirrored, $isBackground, $captureKey, $showDesktop, $sort,
|
||||
$chatFontSize, $chatFontColor, $chatBgColor, $chatMaxMsgs,
|
||||
$chatTimestamps, $chatBadges, $chatMemberColor, $chatSuperChatColor,
|
||||
$webUri, $mediaPath, $mediaIsLooping, $mediaVolume, $mediaPlaybackState,
|
||||
$alertVideoAssetId, $alertVideoPath, $alertUseDefaultVideo, $alertShowTicker, $alertVideoVolume,
|
||||
$alertDisplayMethod)
|
||||
""";
|
||||
cmd.Transaction = tx;
|
||||
var idP = cmd.Parameters.Add("$id", SqliteType.Text);
|
||||
var sceneIdP = cmd.Parameters.Add("$sceneId", SqliteType.Text);
|
||||
var assetIdP = cmd.Parameters.Add("$assetId", SqliteType.Text);
|
||||
var typeP = cmd.Parameters.Add("$type", SqliteType.Text);
|
||||
var nameP = cmd.Parameters.Add("$name", SqliteType.Text);
|
||||
var enabledP = cmd.Parameters.Add("$isEnabled", SqliteType.Integer);
|
||||
var xP = cmd.Parameters.Add("$x", SqliteType.Real);
|
||||
var yP = cmd.Parameters.Add("$y", SqliteType.Real);
|
||||
var wP = cmd.Parameters.Add("$w", SqliteType.Real);
|
||||
var hP = cmd.Parameters.Add("$h", SqliteType.Real);
|
||||
var opacityP = cmd.Parameters.Add("$opacity", SqliteType.Real);
|
||||
var monitorP = cmd.Parameters.Add("$monitor", SqliteType.Integer);
|
||||
var clipP = cmd.Parameters.Add("$clip", SqliteType.Text);
|
||||
var mirroredP = cmd.Parameters.Add("$mirrored", SqliteType.Integer);
|
||||
var isBackgroundP = cmd.Parameters.Add("$isBackground", 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);
|
||||
var chatBgColorP = cmd.Parameters.Add("$chatBgColor", SqliteType.Text);
|
||||
var chatMaxMsgsP = cmd.Parameters.Add("$chatMaxMsgs", SqliteType.Integer);
|
||||
var chatTimestampsP = cmd.Parameters.Add("$chatTimestamps", SqliteType.Integer);
|
||||
var chatBadgesP = cmd.Parameters.Add("$chatBadges", SqliteType.Integer);
|
||||
var chatMemberColorP = cmd.Parameters.Add("$chatMemberColor", SqliteType.Text);
|
||||
var chatSuperChatColorP = cmd.Parameters.Add("$chatSuperChatColor", SqliteType.Text);
|
||||
var webUriP = cmd.Parameters.Add("$webUri", SqliteType.Text);
|
||||
var mediaPathP = cmd.Parameters.Add("$mediaPath", SqliteType.Text);
|
||||
var mediaIsLoopingP = cmd.Parameters.Add("$mediaIsLooping", SqliteType.Integer);
|
||||
var mediaVolumeP = cmd.Parameters.Add("$mediaVolume", SqliteType.Real);
|
||||
var mediaPlaybackStateP = cmd.Parameters.Add("$mediaPlaybackState", SqliteType.Text);
|
||||
var alertVideoAssetIdP = cmd.Parameters.Add("$alertVideoAssetId", SqliteType.Text);
|
||||
var alertVideoPathP = cmd.Parameters.Add("$alertVideoPath", SqliteType.Text);
|
||||
var alertUseDefaultVideoP = cmd.Parameters.Add("$alertUseDefaultVideo", SqliteType.Integer);
|
||||
var alertShowTickerP = cmd.Parameters.Add("$alertShowTicker", SqliteType.Integer);
|
||||
var alertVideoVolumeP = cmd.Parameters.Add("$alertVideoVolume", SqliteType.Real);
|
||||
var alertDisplayMethodP = cmd.Parameters.Add("$alertDisplayMethod", SqliteType.Integer);
|
||||
|
||||
// Source and WebcamSceneConfig share ONE z-order space per scene:
|
||||
// each element gets its index within scene.Elements regardless of
|
||||
// table, so a webcam slotted between sources survives save→load.
|
||||
// (Pre-unified rows tie at 0 — load breaks ties sources-first.)
|
||||
foreach (var scene in scenes)
|
||||
{
|
||||
var z = 0;
|
||||
foreach (var element in scene.Elements)
|
||||
{
|
||||
if (element is not Source source) { z++; continue; }
|
||||
idP.Value = source.Id;
|
||||
sceneIdP.Value = scene.Id;
|
||||
assetIdP.Value = (object?)source.AssetId ?? DBNull.Value;
|
||||
typeP.Value = source.Type.ToString();
|
||||
nameP.Value = source.Name;
|
||||
enabledP.Value = source.IsEnabled ? 1 : 0;
|
||||
xP.Value = source.X;
|
||||
yP.Value = source.Y;
|
||||
wP.Value = source.Width;
|
||||
hP.Value = source.Height;
|
||||
opacityP.Value = source.Opacity;
|
||||
monitorP.Value = (object?)source.MonitorIndex ?? DBNull.Value;
|
||||
clipP.Value = source.ClipShape.ToString();
|
||||
mirroredP.Value = source.IsMirrored ? 1 : 0;
|
||||
isBackgroundP.Value = source.IsBackground ? 1 : 0;
|
||||
captureKeyP.Value = (object?)source.CaptureKey ?? DBNull.Value;
|
||||
showDesktopP.Value = source.ShowDesktop ? 1 : 0;
|
||||
sortP.Value = z++;
|
||||
chatFontSizeP.Value = source.ChatFontSize;
|
||||
chatFontColorP.Value = source.ChatFontColor;
|
||||
chatBgColorP.Value = source.ChatBackgroundColor;
|
||||
chatMaxMsgsP.Value = source.ChatMaxMessages;
|
||||
chatTimestampsP.Value = source.ChatShowTimestamps ? 1 : 0;
|
||||
chatBadgesP.Value = source.ChatShowBadges ? 1 : 0;
|
||||
chatMemberColorP.Value = source.ChatMemberColor;
|
||||
chatSuperChatColorP.Value = source.ChatSuperChatColor;
|
||||
webUriP.Value = (object?)source.WebUri ?? DBNull.Value;
|
||||
mediaPathP.Value = (object?)source.MediaPath ?? DBNull.Value;
|
||||
mediaIsLoopingP.Value = source.MediaIsLooping ? 1 : 0;
|
||||
mediaVolumeP.Value = source.MediaVolume;
|
||||
mediaPlaybackStateP.Value = source.MediaPlaybackState.ToString();
|
||||
alertVideoAssetIdP.Value = (object?)source.AlertVideoAssetId ?? DBNull.Value;
|
||||
alertVideoPathP.Value = (object?)source.AlertVideoPath ?? DBNull.Value;
|
||||
alertUseDefaultVideoP.Value = source.AlertUseDefaultVideo ? 1 : 0;
|
||||
alertShowTickerP.Value = source.AlertShowTicker ? 1 : 0;
|
||||
alertVideoVolumeP.Value = source.AlertVideoVolume;
|
||||
alertDisplayMethodP.Value = (int)source.AlertDisplayMethod;
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (webcam != null)
|
||||
{
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "INSERT INTO Webcam (Id, DeviceId, Name) VALUES ($id, $device, $name);";
|
||||
cmd.Transaction = tx;
|
||||
cmd.Parameters.AddWithValue("$id", webcam.Id);
|
||||
cmd.Parameters.AddWithValue("$device", webcam.DeviceId);
|
||||
cmd.Parameters.AddWithValue("$name", webcam.Name);
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = """
|
||||
INSERT INTO WebcamSceneConfig
|
||||
(SceneId, WebcamId, IsVisible, X, Y, Width, Height, Opacity,
|
||||
ClipShape, IsMirrored, BorderColor, BorderOpacity, BorderWidth, BorderAnimation,
|
||||
RectWidth, RectHeight, SortOrder)
|
||||
VALUES ($sceneId, $webcamId, $isVisible, $x, $y, $w, $h, $opacity,
|
||||
$clip, $mirrored, $borderColor, $borderOpacity, $borderWidth, $borderAnimation,
|
||||
$rectWidth, $rectHeight, $sort)
|
||||
""";
|
||||
cmd.Transaction = tx;
|
||||
var sceneIdP = cmd.Parameters.Add("$sceneId", SqliteType.Text);
|
||||
var webcamIdP = cmd.Parameters.Add("$webcamId", SqliteType.Text);
|
||||
var visibleP = cmd.Parameters.Add("$isVisible", SqliteType.Integer);
|
||||
var xP = cmd.Parameters.Add("$x", SqliteType.Real);
|
||||
var yP = cmd.Parameters.Add("$y", SqliteType.Real);
|
||||
var wP = cmd.Parameters.Add("$w", SqliteType.Real);
|
||||
var hP = cmd.Parameters.Add("$h", SqliteType.Real);
|
||||
var opacityP = cmd.Parameters.Add("$opacity", SqliteType.Real);
|
||||
var clipP = cmd.Parameters.Add("$clip", SqliteType.Text);
|
||||
var mirroredP = cmd.Parameters.Add("$mirrored", SqliteType.Integer);
|
||||
var colorP = cmd.Parameters.Add("$borderColor", SqliteType.Text);
|
||||
var borderOpacityP = cmd.Parameters.Add("$borderOpacity", SqliteType.Real);
|
||||
var borderWidthP = cmd.Parameters.Add("$borderWidth", SqliteType.Integer);
|
||||
var animationP = cmd.Parameters.Add("$borderAnimation", SqliteType.Text);
|
||||
var rectWidthP = cmd.Parameters.Add("$rectWidth", SqliteType.Real);
|
||||
var rectHeightP = cmd.Parameters.Add("$rectHeight", SqliteType.Real);
|
||||
var sortP = cmd.Parameters.Add("$sort", SqliteType.Integer);
|
||||
|
||||
foreach (var scene in scenes)
|
||||
{
|
||||
var z = 0;
|
||||
foreach (var element in scene.Elements)
|
||||
{
|
||||
if (element is not WebcamSceneConfig config) { z++; continue; }
|
||||
sceneIdP.Value = scene.Id;
|
||||
webcamIdP.Value = config.WebcamId;
|
||||
visibleP.Value = config.IsVisible ? 1 : 0;
|
||||
xP.Value = config.X;
|
||||
yP.Value = config.Y;
|
||||
wP.Value = config.Width;
|
||||
hP.Value = config.Height;
|
||||
opacityP.Value = config.Opacity;
|
||||
clipP.Value = config.ClipShape.ToString();
|
||||
mirroredP.Value = config.IsMirrored ? 1 : 0;
|
||||
colorP.Value = (object?)(string.IsNullOrEmpty(config.BorderColor) ? null : config.BorderColor) ?? DBNull.Value;
|
||||
borderOpacityP.Value = config.BorderOpacity;
|
||||
borderWidthP.Value = config.BorderWidth;
|
||||
animationP.Value = config.BorderAnimation.ToString();
|
||||
rectWidthP.Value = (object?)config.RectWidth ?? DBNull.Value;
|
||||
rectHeightP.Value = (object?)config.RectHeight ?? DBNull.Value;
|
||||
sortP.Value = z++;
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (socials != null && socials.Entries.Count > 0)
|
||||
{
|
||||
var socialsId = Guid.NewGuid().ToString();
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "INSERT INTO Socials (Id, BarPosition, BarEnabled) VALUES ($id, $pos, $enabled);";
|
||||
cmd.Transaction = tx;
|
||||
cmd.Parameters.AddWithValue("$id", socialsId);
|
||||
cmd.Parameters.AddWithValue("$pos", socials.BarPosition.ToString());
|
||||
cmd.Parameters.AddWithValue("$enabled", socials.BarEnabled ? 1 : 0);
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
using var entryCmd = _connection.CreateCommand();
|
||||
entryCmd.CommandText = """
|
||||
INSERT INTO SocialEntry (Id, SocialsId, Service, Handle, ProfileUrl, Software, SortOrder)
|
||||
VALUES ($id, $socialsId, $service, $handle, $url, $software, $sort)
|
||||
""";
|
||||
entryCmd.Transaction = tx;
|
||||
var eId = entryCmd.Parameters.Add("$id", SqliteType.Text);
|
||||
var eSocialsId = entryCmd.Parameters.Add("$socialsId", SqliteType.Text);
|
||||
var eService = entryCmd.Parameters.Add("$service", SqliteType.Text);
|
||||
var eHandle = entryCmd.Parameters.Add("$handle", SqliteType.Text);
|
||||
var eUrl = entryCmd.Parameters.Add("$url", SqliteType.Text);
|
||||
var eSoftware = entryCmd.Parameters.Add("$software", SqliteType.Text);
|
||||
var eSort = entryCmd.Parameters.Add("$sort", SqliteType.Integer);
|
||||
|
||||
var entrySort = 0;
|
||||
foreach (var entry in socials.Entries)
|
||||
{
|
||||
eId.Value = Guid.NewGuid().ToString();
|
||||
eSocialsId.Value = socialsId;
|
||||
eService.Value = entry.Service.ToString();
|
||||
eHandle.Value = entry.Handle;
|
||||
eUrl.Value = entry.ProfileUrl;
|
||||
eSoftware.Value = (object?)entry.FediverseSoftware ?? DBNull.Value;
|
||||
eSort.Value = entrySort++;
|
||||
entryCmd.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
|
||||
if (music != null && !string.IsNullOrWhiteSpace(music.TrackPath))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "INSERT INTO Music (Id, TrackPath, IsEnabled) VALUES ($id, $path, $enabled);";
|
||||
cmd.Transaction = tx;
|
||||
cmd.Parameters.AddWithValue("$id", Guid.NewGuid().ToString());
|
||||
cmd.Parameters.AddWithValue("$path", music.TrackPath);
|
||||
cmd.Parameters.AddWithValue("$enabled", music.IsEnabled ? 1 : 0);
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = """
|
||||
DELETE FROM Asset WHERE Id NOT IN (
|
||||
SELECT AssetId FROM Source WHERE AssetId IS NOT NULL
|
||||
UNION
|
||||
SELECT AlertVideoAssetId FROM Source WHERE AlertVideoAssetId IS NOT NULL
|
||||
UNION
|
||||
SELECT Value FROM Settings WHERE Key = 'AlertDefaultVideoAssetId'
|
||||
);
|
||||
""";
|
||||
cmd.Transaction = tx;
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
// Save custom background paths and use-default flags per background source
|
||||
foreach (var scene in scenes)
|
||||
{
|
||||
var bg = scene.Elements.OfType<Source>().FirstOrDefault(s => s.IsBackground);
|
||||
if (bg == null) continue;
|
||||
|
||||
var pathKey = $"BackgroundPath_{bg.Id}";
|
||||
if (!string.IsNullOrWhiteSpace(bg.CustomBackgroundPath))
|
||||
UpsertSetting(pathKey, bg.CustomBackgroundPath);
|
||||
else
|
||||
{
|
||||
using var del = _connection.CreateCommand();
|
||||
del.CommandText = "DELETE FROM Settings WHERE Key = $key;";
|
||||
del.Parameters.AddWithValue("$key", pathKey);
|
||||
del.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
var defaultKey = $"BackgroundUseDefault_{bg.Id}";
|
||||
UpsertSetting(defaultKey, bg.UseDefaultBackground ? "1" : "0");
|
||||
}
|
||||
|
||||
// Purge background settings left behind by deleted/duplicated rows
|
||||
// (TASK 25 heal drops extras; their keys must not accumulate).
|
||||
using (var purge = _connection.CreateCommand())
|
||||
{
|
||||
purge.Transaction = tx;
|
||||
purge.CommandText =
|
||||
@"DELETE FROM Settings
|
||||
WHERE (Key LIKE 'BackgroundUseDefault\_%' ESCAPE '\'
|
||||
OR Key LIKE 'BackgroundPath\_%' ESCAPE '\')
|
||||
AND SUBSTR(Key, INSTR(Key, '_') + 1) NOT IN (SELECT Id FROM Source);";
|
||||
purge.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
tx.Commit();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
using ytLive.Models;
|
||||
|
||||
namespace ytLive.Services;
|
||||
|
||||
public partial class LayoutStore : IDisposable
|
||||
{
|
||||
/// <summary>The last picked mic source name, or null if never chosen. Restored
|
||||
/// at startup so the sound bar reconnects to the same, already-vetted device.</summary>
|
||||
public string? LoadMicSourceName()
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "SELECT Value FROM Settings WHERE Key = 'MicSourceName';";
|
||||
var value = cmd.ExecuteScalar() as string;
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value;
|
||||
}
|
||||
|
||||
public void SaveMicSourceName(string? name)
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "DELETE FROM Settings WHERE Key = 'MicSourceName';";
|
||||
cmd.ExecuteNonQuery();
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
return;
|
||||
|
||||
using var insert = _connection.CreateCommand();
|
||||
insert.CommandText = "INSERT INTO Settings (Key, Value) VALUES ('MicSourceName', $name);";
|
||||
insert.Parameters.AddWithValue("$name", name);
|
||||
insert.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
/// <summary>The global audio-sync offset in milliseconds (TASK 22), −500..500,
|
||||
/// default 0: positive delays the mix (audio ahead of video), negative
|
||||
/// advances it (audio behind video). Restored at startup so the mixer starts
|
||||
/// on the user's offset.</summary>
|
||||
public int LoadAudioSyncOffsetMs()
|
||||
{
|
||||
var value = GetSetting("Audio.SyncOffsetMs");
|
||||
return int.TryParse(value, out var ms) ? Math.Clamp(ms, -500, 500) : 0;
|
||||
}
|
||||
|
||||
public void SaveAudioSyncOffsetMs(int ms)
|
||||
{
|
||||
UpsertSetting("Audio.SyncOffsetMs", Math.Clamp(ms, -500, 500).ToString());
|
||||
}
|
||||
|
||||
/// <summary>The user's chosen record output folder (TASK 18), or null to use the
|
||||
/// default <c>%APPDATA%\ytLlive\recordings</c>. Restored at startup.</summary>
|
||||
public string? LoadRecordFolder()
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "SELECT Value FROM Settings WHERE Key = 'RecordFolder';";
|
||||
var value = cmd.ExecuteScalar() as string;
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value;
|
||||
}
|
||||
|
||||
public void SaveRecordFolder(string? folder)
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "DELETE FROM Settings WHERE Key = 'RecordFolder';";
|
||||
cmd.ExecuteNonQuery();
|
||||
if (string.IsNullOrWhiteSpace(folder))
|
||||
return;
|
||||
|
||||
using var insert = _connection.CreateCommand();
|
||||
insert.CommandText = "INSERT INTO Settings (Key, Value) VALUES ('RecordFolder', $folder);";
|
||||
insert.Parameters.AddWithValue("$folder", folder);
|
||||
insert.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
/// <summary>The cached reusable stream (TASK 5), or null if never created.
|
||||
/// Survives restarts so the pump has its RTMP URL the moment go-live starts
|
||||
/// instead of waiting on the YouTube round-trip.</summary>
|
||||
public ReusableStream? LoadReusableStream()
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "SELECT Value FROM Settings WHERE Key = 'ReusableStream';";
|
||||
var value = cmd.ExecuteScalar() as string;
|
||||
if (string.IsNullOrWhiteSpace(value)) return null;
|
||||
|
||||
var parts = value.Split('\u001F');
|
||||
return parts.Length == 3
|
||||
? new ReusableStream(parts[0], parts[1], parts[2])
|
||||
: null;
|
||||
}
|
||||
|
||||
public void SaveReusableStream(ReusableStream? stream)
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "DELETE FROM Settings WHERE Key = 'ReusableStream';";
|
||||
cmd.ExecuteNonQuery();
|
||||
if (stream == null) return;
|
||||
|
||||
using var insert = _connection.CreateCommand();
|
||||
insert.CommandText = "INSERT INTO Settings (Key, Value) VALUES ('ReusableStream', $value);";
|
||||
insert.Parameters.AddWithValue("$value", $"{stream.Id}\u001F{stream.IngestionAddress}\u001F{stream.StreamName}");
|
||||
insert.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
public string? LoadLicenseKey()
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "SELECT Value FROM Settings WHERE Key = 'LicenseKey';";
|
||||
var value = cmd.ExecuteScalar() as string;
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value;
|
||||
}
|
||||
|
||||
public void SaveLicenseKey(string? key)
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "DELETE FROM Settings WHERE Key = 'LicenseKey';";
|
||||
cmd.ExecuteNonQuery();
|
||||
if (string.IsNullOrWhiteSpace(key)) return;
|
||||
|
||||
using var insert = _connection.CreateCommand();
|
||||
insert.CommandText = "INSERT INTO Settings (Key, Value) VALUES ('LicenseKey', $value);";
|
||||
insert.Parameters.AddWithValue("$value", key);
|
||||
insert.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
/// <summary>When the license was last validated online (ISO 8601), or null if never.</summary>
|
||||
public DateTime? LoadLicenseValidatedAt()
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "SELECT Value FROM Settings WHERE Key = 'LicenseValidatedAt';";
|
||||
var value = cmd.ExecuteScalar() as string;
|
||||
if (string.IsNullOrWhiteSpace(value)) return null;
|
||||
return DateTime.TryParse(value, out var dt) ? dt : null;
|
||||
}
|
||||
|
||||
public void SaveLicenseValidatedAt(DateTime? dt)
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "DELETE FROM Settings WHERE Key = 'LicenseValidatedAt';";
|
||||
cmd.ExecuteNonQuery();
|
||||
if (dt == null) return;
|
||||
|
||||
using var insert = _connection.CreateCommand();
|
||||
insert.CommandText = "INSERT INTO Settings (Key, Value) VALUES ('LicenseValidatedAt', $value);";
|
||||
insert.Parameters.AddWithValue("$value", dt.Value.ToString("o"));
|
||||
insert.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
/// <summary>Cached premium state (1 = premium, 0 = free). Used for offline
|
||||
/// grace period — the app checks online at startup but uses this cached
|
||||
/// value when offline.</summary>
|
||||
public bool LoadIsPremium()
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "SELECT Value FROM Settings WHERE Key = 'IsPremium';";
|
||||
var value = cmd.ExecuteScalar() as string;
|
||||
return value == "1";
|
||||
}
|
||||
|
||||
public void SaveIsPremium(bool isPremium)
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "DELETE FROM Settings WHERE Key = 'IsPremium';";
|
||||
cmd.ExecuteNonQuery();
|
||||
|
||||
using var insert = _connection.CreateCommand();
|
||||
insert.CommandText = "INSERT INTO Settings (Key, Value) VALUES ('IsPremium', $value);";
|
||||
insert.Parameters.AddWithValue("$value", isPremium ? "1" : "0");
|
||||
insert.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
public (string TransitionType, double DurationMs) LoadTransitionSettings()
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "SELECT Value FROM Settings WHERE Key = 'TransitionType';";
|
||||
var type = cmd.ExecuteScalar() as string;
|
||||
|
||||
using var cmd2 = _connection.CreateCommand();
|
||||
cmd2.CommandText = "SELECT Value FROM Settings WHERE Key = 'TransitionDurationMs';";
|
||||
var dur = cmd2.ExecuteScalar() as string;
|
||||
|
||||
return (type ?? "Fade", dur != null && double.TryParse(dur, out var d) ? d : 300);
|
||||
}
|
||||
|
||||
public void SaveTransitionSettings(string transitionType, double durationMs)
|
||||
{
|
||||
UpsertSetting("TransitionType", transitionType);
|
||||
UpsertSetting("TransitionDurationMs", durationMs.ToString("F0"));
|
||||
}
|
||||
|
||||
// ─── Broadcast metadata (broadcast pull-out) + window geometry ───
|
||||
|
||||
public Models.BroadcastMetadata LoadBroadcastMetadata()
|
||||
{
|
||||
return new Models.BroadcastMetadata
|
||||
{
|
||||
Title = GetSetting("Broadcast.Title") ?? string.Empty,
|
||||
Description = GetSetting("Broadcast.Description") ?? string.Empty,
|
||||
TagsCsv = GetSetting("Broadcast.TagsCsv") ?? string.Empty,
|
||||
Visibility = GetSetting("Broadcast.Visibility") ?? "private",
|
||||
MadeForKids = GetSetting("Broadcast.MadeForKids") == "1",
|
||||
ScheduledStartTime = DateTime.TryParse(GetSetting("Broadcast.ScheduledStartTime"), out var t) ? t : null,
|
||||
};
|
||||
}
|
||||
|
||||
public void SaveBroadcastMetadata(Models.BroadcastMetadata meta)
|
||||
{
|
||||
UpsertSetting("Broadcast.Title", meta.Title);
|
||||
UpsertSetting("Broadcast.Description", meta.Description);
|
||||
UpsertSetting("Broadcast.TagsCsv", meta.TagsCsv);
|
||||
UpsertSetting("Broadcast.Visibility", meta.Visibility);
|
||||
UpsertSetting("Broadcast.MadeForKids", meta.MadeForKids ? "1" : "0");
|
||||
UpsertSetting("Broadcast.ScheduledStartTime",
|
||||
meta.ScheduledStartTime?.ToString("o") ?? string.Empty);
|
||||
}
|
||||
|
||||
// ─── Hotkey bindings (TASK 20 step 2) ───
|
||||
|
||||
/// <summary>
|
||||
/// Load saved hotkey bindings. Returns only entries that have been explicitly
|
||||
/// saved — missing HotkeyIds are unbound (not in the returned dictionary).
|
||||
/// Caller fills in defaults for any missing entries as needed.
|
||||
/// </summary>
|
||||
public Dictionary<HotkeyId, HotkeyChord> LoadHotkeyBindings()
|
||||
{
|
||||
var result = new Dictionary<HotkeyId, HotkeyChord>();
|
||||
foreach (HotkeyId id in Enum.GetValues(typeof(HotkeyId)))
|
||||
{
|
||||
var raw = GetSetting($"Hotkey.{id}");
|
||||
if (raw == null) continue; // not saved = unbound
|
||||
if (raw == "none") continue; // explicitly unbound
|
||||
try
|
||||
{
|
||||
result[id] = HotkeyChord.ParseStorage(raw);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
// corrupt entry — skip, will fall back to default
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Save hotkey bindings. Null entries in the dictionary mean "unbound" (stored
|
||||
/// as "none"). Only the provided entries are saved; previously saved entries
|
||||
/// for HotkeyIds not in the dictionary are deleted.
|
||||
/// </summary>
|
||||
public void SaveHotkeyBindings(Dictionary<HotkeyId, HotkeyChord?> bindings)
|
||||
{
|
||||
// Delete all existing hotkey settings first
|
||||
foreach (HotkeyId id in Enum.GetValues(typeof(HotkeyId)))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "DELETE FROM Settings WHERE Key = $key;";
|
||||
cmd.Parameters.AddWithValue("$key", $"Hotkey.{id}");
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
// Save the new bindings
|
||||
foreach (var (id, chord) in bindings)
|
||||
{
|
||||
var value = chord?.ToString() ?? "none";
|
||||
UpsertSetting($"Hotkey.{id}", value);
|
||||
}
|
||||
}
|
||||
|
||||
public (double Left, double Top, double Width, double Height, string State)? LoadWindowState()
|
||||
{
|
||||
var left = GetSetting("Window.Left");
|
||||
var top = GetSetting("Window.Top");
|
||||
var width = GetSetting("Window.Width");
|
||||
var height = GetSetting("Window.Height");
|
||||
var state = GetSetting("Window.State");
|
||||
if (left == null || top == null || width == null || height == null) return null;
|
||||
if (!double.TryParse(left, out var l) || !double.TryParse(top, out var t) ||
|
||||
!double.TryParse(width, out var w) || !double.TryParse(height, out var h))
|
||||
return null;
|
||||
return (l, t, w, h, state ?? "Normal");
|
||||
}
|
||||
|
||||
public void SaveWindowState(double left, double top, double width, double height, string state)
|
||||
{
|
||||
UpsertSetting("Window.Left", left.ToString("F0"));
|
||||
UpsertSetting("Window.Top", top.ToString("F0"));
|
||||
UpsertSetting("Window.Width", width.ToString("F0"));
|
||||
UpsertSetting("Window.Height", height.ToString("F0"));
|
||||
UpsertSetting("Window.State", state);
|
||||
}
|
||||
|
||||
private string? GetSetting(string key)
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "SELECT Value FROM Settings WHERE Key = $key;";
|
||||
cmd.Parameters.AddWithValue("$key", key);
|
||||
return cmd.ExecuteScalar() as string;
|
||||
}
|
||||
|
||||
private void UpsertSetting(string key, string value)
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "DELETE FROM Settings WHERE Key = $key;";
|
||||
cmd.Parameters.AddWithValue("$key", key);
|
||||
cmd.ExecuteNonQuery();
|
||||
|
||||
using var insert = _connection.CreateCommand();
|
||||
insert.CommandText = "INSERT INTO Settings (Key, Value) VALUES ($key, $value);";
|
||||
insert.Parameters.AddWithValue("$key", key);
|
||||
insert.Parameters.AddWithValue("$value", value);
|
||||
insert.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
internal const string AlertDefaultVideoKey = "AlertDefaultVideoAssetId";
|
||||
|
||||
/// <summary>Stamps the shipped stream-alert placeholder clip (TASK 47): like any
|
||||
/// source image it lives as a BLOB in the internal Asset table, tracked by this
|
||||
/// Setting so the Save pruner keeps it alive. The creator's OWN replacement
|
||||
/// videos are never stored — they are plain path references (the 2026-09-26
|
||||
/// decision: the file the user picks stays at its real location on disk).
|
||||
/// <c>UpsertAsset</c> is hash-keyed, so re-stamping the same bytes returns the
|
||||
/// same id.</summary>
|
||||
public string EnsureAlertDefaultVideo(byte[] data)
|
||||
{
|
||||
var saved = GetSetting(AlertDefaultVideoKey);
|
||||
if (!string.IsNullOrWhiteSpace(saved) && GetAssetBytes(saved) != null)
|
||||
return saved;
|
||||
var id = UpsertAsset(data, 0, 0);
|
||||
UpsertSetting(AlertDefaultVideoKey, id);
|
||||
return id;
|
||||
}
|
||||
|
||||
/// <summary>The stored default clip's Asset id, or null when never stamped.</summary>
|
||||
public string? GetAlertDefaultVideoId() => GetSetting(AlertDefaultVideoKey);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
using ytLive.Models;
|
||||
|
||||
namespace ytLive.Services;
|
||||
|
||||
public partial class LayoutStore : IDisposable
|
||||
{
|
||||
// ─── YPP journey-tracker snapshots (TASK 39) ───
|
||||
|
||||
/// <summary>Appends one YPP stats snapshot (append-only history; velocity/ETA
|
||||
/// read it in a later analytics slice).</summary>
|
||||
public void SaveYppSnapshot(YppStatSnapshot snapshot)
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
INSERT INTO YppSnapshot (CapturedAtUtc, SubscriberCount, VideoCount, TotalViews,
|
||||
UploadsLast90Days, CommunityGuidelinesGoodStanding,
|
||||
CopyrightStrikesGoodStanding, ContentIdClaimsGoodStanding,
|
||||
OverallGoodStanding)
|
||||
VALUES ($captured, $subs, $videos, $views, $uploads, $cg, $cs, $cic, $overall);
|
||||
""";
|
||||
cmd.Parameters.AddWithValue("$captured", snapshot.CapturedAtUtc.ToString("o"));
|
||||
cmd.Parameters.AddWithValue("$subs", snapshot.SubscriberCount);
|
||||
cmd.Parameters.AddWithValue("$videos", snapshot.VideoCount);
|
||||
cmd.Parameters.AddWithValue("$views", snapshot.TotalViews);
|
||||
cmd.Parameters.AddWithValue("$uploads", snapshot.UploadsLast90Days);
|
||||
cmd.Parameters.AddWithValue("$cg", snapshot.CommunityGuidelinesGoodStanding ? 1 : 0);
|
||||
cmd.Parameters.AddWithValue("$cs", snapshot.CopyrightStrikesGoodStanding ? 1 : 0);
|
||||
cmd.Parameters.AddWithValue("$cic", snapshot.ContentIdClaimsGoodStanding ? 1 : 0);
|
||||
cmd.Parameters.AddWithValue("$overall", snapshot.OverallGoodStanding ? 1 : 0);
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
/// <summary>Most recent snapshots, newest first (defaults to the full history).</summary>
|
||||
public List<YppStatSnapshot> LoadYppSnapshots(int limit = 365)
|
||||
{
|
||||
var result = new List<YppStatSnapshot>();
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
SELECT CapturedAtUtc, SubscriberCount, VideoCount, TotalViews, UploadsLast90Days,
|
||||
CommunityGuidelinesGoodStanding, CopyrightStrikesGoodStanding,
|
||||
ContentIdClaimsGoodStanding, OverallGoodStanding
|
||||
FROM YppSnapshot ORDER BY CapturedAtUtc DESC LIMIT $limit;
|
||||
""";
|
||||
cmd.Parameters.AddWithValue("$limit", limit);
|
||||
using var reader = cmd.ExecuteReader();
|
||||
while (reader.Read())
|
||||
{
|
||||
result.Add(new YppStatSnapshot
|
||||
{
|
||||
CapturedAtUtc = DateTime.TryParse(reader.GetString(0), out var captured) ? captured : DateTime.MinValue,
|
||||
SubscriberCount = reader.GetInt64(1),
|
||||
VideoCount = reader.GetInt64(2),
|
||||
TotalViews = reader.GetInt64(3),
|
||||
UploadsLast90Days = reader.GetInt32(4),
|
||||
CommunityGuidelinesGoodStanding = reader.GetInt32(5) != 0,
|
||||
CopyrightStrikesGoodStanding = reader.GetInt32(6) != 0,
|
||||
ContentIdClaimsGoodStanding = reader.GetInt32(7) != 0,
|
||||
OverallGoodStanding = reader.GetInt32(8) != 0,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>The manually self-reported YPP checklist state (2FA / AdSense are
|
||||
/// not readable from any YouTube API — the creator ticks them in-app).</summary>
|
||||
public (bool TwoFactorEnabled, bool AdSenseLinked) LoadYppChecklist()
|
||||
{
|
||||
return (GetSetting("Ypp.TwoFactorEnabled") == "1", GetSetting("Ypp.AdSenseLinked") == "1");
|
||||
}
|
||||
|
||||
public void SaveYppChecklist(bool twoFactorEnabled, bool adsenseLinked)
|
||||
{
|
||||
UpsertSetting("Ypp.TwoFactorEnabled", twoFactorEnabled ? "1" : "0");
|
||||
UpsertSetting("Ypp.AdSenseLinked", adsenseLinked ? "1" : "0");
|
||||
}
|
||||
|
||||
/// <summary>UTC timestamp of the last successful stats refresh (used to gate
|
||||
/// the once-daily auto-refresh against the analytics quota-thin API).</summary>
|
||||
public DateTime? LoadYppLastRefreshedAtUtc()
|
||||
{
|
||||
var value = GetSetting("Ypp.LastRefreshedAtUtc");
|
||||
return DateTime.TryParse(value, out var dt) ? dt : null;
|
||||
}
|
||||
|
||||
public void SaveYppLastRefreshedAtUtc(DateTime utc)
|
||||
{
|
||||
UpsertSetting("Ypp.LastRefreshedAtUtc", utc.ToString("o"));
|
||||
}
|
||||
}
|
||||
+1
-1356
File diff suppressed because it is too large
Load Diff
@@ -86,6 +86,20 @@ public sealed class MediaCaptureFrameSource : ICameraFrameSource
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ordered reader-output-subtype candidates for a camera whose live media type
|
||||
/// is <paramref name="activeSubtype"/>. Bgra8 is the fast path (OnFrameArrived
|
||||
/// skips conversion) and works for uncompressed cameras; an MJPG-active source
|
||||
/// cannot be read as Bgra8 by a MediaFrameReader (no BGRA converter on the MJPG
|
||||
/// pipeline — StartAsync answers OutputFormatNotSupported), so MJPG leads with
|
||||
/// NV12 and falls back to the reader's default (null). Every candidate that is
|
||||
/// not Bgra8 is converted in OnFrameArrived.
|
||||
/// </summary>
|
||||
internal static string?[] ReaderSubtypeCandidates(string? activeSubtype) =>
|
||||
string.Equals(activeSubtype, "MJPG", StringComparison.OrdinalIgnoreCase)
|
||||
? new string?[] { MediaEncodingSubtypes.Nv12, null }
|
||||
: new string?[] { MediaEncodingSubtypes.Bgra8, MediaEncodingSubtypes.Nv12, null };
|
||||
|
||||
private async Task<bool> TryStartAsync(bool preferVideoRecord)
|
||||
{
|
||||
MediaCapture? capture = null;
|
||||
@@ -102,6 +116,43 @@ public sealed class MediaCaptureFrameSource : ICameraFrameSource
|
||||
};
|
||||
await capture.InitializeAsync(settings);
|
||||
|
||||
// MediaFrameReader defaults to the camera's FIRST media type — on UVC webcams
|
||||
// (Logitech C920 et al.) that is often YUY2 at a crippled rate (720p5!), and a
|
||||
// real-time 60fps container then shows a 5-20fps face cam as "every few frames
|
||||
// cut out". OBS's mechanism, available via VideoDeviceController (VideoProfile
|
||||
// is NOT projected in the 19041 SDK): negotiate the stream property. Pick the
|
||||
// best MJPEG >=640x360 @>=30fps (largest, fastest), set it BEFORE the frame
|
||||
// reader is created, fall back to device default on refusal, and log the live
|
||||
// media type so the next diagnosis starts from facts.
|
||||
try
|
||||
{
|
||||
var controller = capture.VideoDeviceController;
|
||||
var candidates = controller == null
|
||||
? new System.Collections.Generic.List<Windows.Media.MediaProperties.VideoEncodingProperties>()
|
||||
: controller
|
||||
.GetAvailableMediaStreamProperties(MediaStreamType.VideoPreview)
|
||||
.OfType<Windows.Media.MediaProperties.VideoEncodingProperties>()
|
||||
.Where(v => string.Equals(v.Subtype, "MJPG", StringComparison.OrdinalIgnoreCase)
|
||||
&& v.Width >= 640 && v.Height >= 360
|
||||
&& v.FrameRate.Denominator > 0
|
||||
&& v.FrameRate.Numerator / v.FrameRate.Denominator >= 30.0)
|
||||
.OrderByDescending(v => v.FrameRate.Numerator / (double)Math.Max(1, v.FrameRate.Denominator))
|
||||
.ThenByDescending(v => (long)v.Width * v.Height)
|
||||
.ToList();
|
||||
if (candidates.Count > 0)
|
||||
{
|
||||
var pick = candidates
|
||||
.OrderBy(v => (long)v.Width * v.Height)
|
||||
.LastOrDefault(v => v.Width <= 1280) ?? candidates[0];
|
||||
await controller!.SetMediaStreamPropertiesAsync(MediaStreamType.VideoPreview, pick);
|
||||
}
|
||||
}
|
||||
catch (Exception fmtEx)
|
||||
{
|
||||
AppLog.Write($"MediaCaptureFrameSource: MJPG negotiation refused ({fmtEx.Message}); " +
|
||||
"keeping device-default media type");
|
||||
}
|
||||
|
||||
// Post-init resource validation: the capture must actually be bound to
|
||||
// the device we asked for and must expose a live video controller.
|
||||
if (!string.Equals(capture.MediaCaptureSettings?.VideoDeviceId, _deviceId,
|
||||
@@ -143,21 +194,81 @@ public sealed class MediaCaptureFrameSource : ICameraFrameSource
|
||||
capture.Failed += OnCaptureFailed;
|
||||
capture.CameraStreamStateChanged += OnCameraStreamStateChanged;
|
||||
|
||||
reader = await capture.CreateFrameReaderAsync(colorSource, MediaEncodingSubtypes.Bgra8);
|
||||
if (reader == null)
|
||||
throw new InvalidOperationException($"Camera '{_deviceId}' created no frame reader.");
|
||||
// The reader must output a subtype the source can actually produce, else
|
||||
// StartAsync refuses (OutputFormatNotSupported). An uncompressed camera
|
||||
// converts to Bgra8 fine (the fast path); a camera sitting in MJPG — which
|
||||
// is what we find when ANOTHER app left it there and ours can't re-negotiate
|
||||
// under SharedReadOnly contention — cannot be read as Bgra8 by a
|
||||
// MediaFrameReader (the MJPG pipeline exposes NV12 output, not BGRA). Walk
|
||||
// the candidate ladder; anything that isn't Bgra8 is converted in
|
||||
// OnFrameArrived anyway.
|
||||
string? activeSubtype = null;
|
||||
try
|
||||
{
|
||||
var active = capture.VideoDeviceController?
|
||||
.GetMediaStreamProperties(colorSource.Info.MediaStreamType);
|
||||
if (active is Windows.Media.MediaProperties.VideoEncodingProperties vep)
|
||||
{
|
||||
activeSubtype = vep.Subtype;
|
||||
AppLog.Write($"Camera '{_deviceId}' media type: {vep.Subtype} " +
|
||||
$"{vep.Width}x{vep.Height} @ {vep.FrameRate.Numerator}/{vep.FrameRate.Denominator}fps");
|
||||
}
|
||||
}
|
||||
catch { /* logging must never kill acquisition */ }
|
||||
|
||||
reader.FrameArrived += OnFrameArrived;
|
||||
var status = await reader.StartAsync();
|
||||
if (status != MediaFrameReaderStartStatus.Success)
|
||||
throw new InvalidOperationException(
|
||||
$"Camera '{_deviceId}' frame reader refused to start: {status}.");
|
||||
// Each candidate is allocated AND started under its own catch: a WinRT
|
||||
// call answering an unsupported subtype can THROW (E_INVALIDARG / "value
|
||||
// does not fall within the expected range") instead of returning a start
|
||||
// status, so every throw must degrade to the next candidate — never let
|
||||
// one rejected format abort the whole acquisition. Individual rejections
|
||||
// are logged and collected; only the last-resort net below (the outer
|
||||
// try/catch of this method) keeps cowboying device-level errors.
|
||||
var refused = new List<string>();
|
||||
foreach (var outputSubtype in ReaderSubtypeCandidates(activeSubtype))
|
||||
{
|
||||
var attempt = outputSubtype ?? "<default>";
|
||||
try
|
||||
{
|
||||
reader = outputSubtype is null
|
||||
? await capture.CreateFrameReaderAsync(colorSource)
|
||||
: await capture.CreateFrameReaderAsync(colorSource, outputSubtype);
|
||||
if (reader == null)
|
||||
{
|
||||
refused.Add($"{attempt}: no frame reader");
|
||||
continue;
|
||||
}
|
||||
|
||||
_capture = capture;
|
||||
_frameReader = reader;
|
||||
_lastError = null;
|
||||
_isFailed = false;
|
||||
return true;
|
||||
reader.FrameArrived += OnFrameArrived;
|
||||
var status = await reader.StartAsync();
|
||||
if (status == MediaFrameReaderStartStatus.Success)
|
||||
{
|
||||
_capture = capture;
|
||||
_frameReader = reader;
|
||||
_lastError = null;
|
||||
_isFailed = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
reader.FrameArrived -= OnFrameArrived;
|
||||
reader.Dispose();
|
||||
reader = null;
|
||||
refused.Add($"{attempt}: {status}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLog.Write($"MediaCaptureFrameSource: reader as '{attempt}' refused: {ex.Message}");
|
||||
if (reader != null)
|
||||
{
|
||||
reader.FrameArrived -= OnFrameArrived;
|
||||
reader.Dispose();
|
||||
reader = null;
|
||||
}
|
||||
refused.Add($"{attempt}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(
|
||||
$"Camera '{_deviceId}' frame reader refused to start: {string.Join(", ", refused)}.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -209,6 +320,35 @@ public sealed class MediaCaptureFrameSource : ICameraFrameSource
|
||||
}
|
||||
}
|
||||
|
||||
// Buffer recycling (same lesson as the screen capture): a fresh ~3.7MB byte[]
|
||||
// per device frame at 30-60fps is 110-220MB/s of LOH churn — gen2 pauses land
|
||||
// on the frame pump as 35-40ms render spikes. An 8-deep ring (take-14 rule:
|
||||
// depth × source period must exceed the worst consumer hold), rotated on the
|
||||
// single frame-reader thread, with per-frame Epoch so the identity-keyed paste
|
||||
// cache can never serve a recycled array as unchanged.
|
||||
private readonly byte[]?[] _frameRing = new byte[8][];
|
||||
private int _ringNext;
|
||||
private long _epoch;
|
||||
|
||||
private byte[] RentRingBuffer(int size)
|
||||
{
|
||||
for (var tries = 0; tries < _frameRing.Length; tries++)
|
||||
{
|
||||
var idx = (_ringNext + tries) % _frameRing.Length;
|
||||
var buf = _frameRing[idx];
|
||||
if (buf != null && buf.Length == size)
|
||||
{
|
||||
_ringNext = (idx + 1) % _frameRing.Length;
|
||||
return buf;
|
||||
}
|
||||
}
|
||||
var slot = _ringNext;
|
||||
_ringNext = (slot + 1) % _frameRing.Length;
|
||||
var fresh = new byte[size];
|
||||
_frameRing[slot] = fresh;
|
||||
return fresh;
|
||||
}
|
||||
|
||||
private void OnFrameArrived(MediaFrameReader sender, MediaFrameArrivedEventArgs args)
|
||||
{
|
||||
using var frame = sender.TryAcquireLatestFrame();
|
||||
@@ -225,9 +365,14 @@ public sealed class MediaCaptureFrameSource : ICameraFrameSource
|
||||
using var reference = buffer.CreateReference();
|
||||
if (WindowsRuntimeMarshal.TryGetDataUnsafe(reference, out var pixelsPtr, out var capacity))
|
||||
{
|
||||
var pixels = new byte[capacity];
|
||||
var pixels = RentRingBuffer((int)capacity);
|
||||
Marshal.Copy(pixelsPtr, pixels, 0, (int)capacity);
|
||||
FrameAvailable?.Invoke(new VideoFrame(bitmap.PixelWidth, bitmap.PixelHeight, pixels));
|
||||
|
||||
// MediaCapture video carries no alpha plane — the OS fills 255.
|
||||
var outFrame = new VideoFrame(bitmap.PixelWidth, bitmap.PixelHeight, pixels)
|
||||
{ IsOpaque = true, Epoch = ++_epoch };
|
||||
|
||||
FrameAvailable?.Invoke(outFrame);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using ytLive.Services.Encoder;
|
||||
|
||||
namespace ytLive.Services;
|
||||
|
||||
/// <summary>
|
||||
/// TASK 21: decodes a local media file (any format ffmpeg handles) into
|
||||
/// <see cref="VideoFrame"/>s for a MediaSource. Implements
|
||||
/// <see cref="IMediaFrameSource"/>: raises <see cref="FrameAvailable"/> per
|
||||
/// frame and <see cref="IMediaFrameSource.Completed"/> on natural EOF (unless
|
||||
/// <see cref="IMediaFrameSource.Looping"/>). Spawns ffmpeg with
|
||||
/// <c>-f rawvideo -pix_fmt bgra</c> and drains the raw BGRA pipe via
|
||||
/// <see cref="RawVideoFrameReader"/>. The decoder process and emitters are seams
|
||||
/// so tests run without a real codec or audio device.
|
||||
/// </summary>
|
||||
public sealed class MediaVideoSource : IMediaFrameSource
|
||||
{
|
||||
public event Action<VideoFrame>? FrameAvailable;
|
||||
public event Action? Completed;
|
||||
|
||||
public string Key => _path;
|
||||
|
||||
public bool Looping { get; set; }
|
||||
|
||||
private readonly string _path;
|
||||
private readonly int _width;
|
||||
private readonly int _height;
|
||||
private readonly IFfmpegLocator _locator;
|
||||
private readonly Func<IDecodeProcess> _processFactory;
|
||||
private readonly IFrameRateProbe? _probe;
|
||||
private readonly Func<TimeSpan, CancellationToken, Task> _delay;
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
private IDisposable? _run;
|
||||
private IDecodeProcess? _active;
|
||||
|
||||
public MediaVideoSource(
|
||||
string path,
|
||||
int width,
|
||||
int height,
|
||||
IFfmpegLocator locator,
|
||||
Func<IDecodeProcess> processFactory,
|
||||
IFrameRateProbe? frameRateProbe = null,
|
||||
Func<TimeSpan, CancellationToken, Task>? delay = null)
|
||||
{
|
||||
_path = path;
|
||||
_width = width;
|
||||
_height = height;
|
||||
_locator = locator;
|
||||
_processFactory = processFactory;
|
||||
_probe = frameRateProbe;
|
||||
_delay = delay ?? ((t, ct) => Task.Delay(t, ct));
|
||||
}
|
||||
|
||||
public Task StartAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_run != null) return Task.CompletedTask;
|
||||
_run = _cts.Token.Register(StopInternal);
|
||||
_ = Task.Run(RunAsync, CancellationToken.None);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task StopAsync()
|
||||
{
|
||||
_cts.Cancel();
|
||||
_run = null;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cts.Cancel();
|
||||
_cts.Dispose();
|
||||
_active?.Dispose();
|
||||
}
|
||||
|
||||
private void StopInternal() => KillProcess();
|
||||
|
||||
private async Task RunAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
double? frameRate = null;
|
||||
if (_probe != null)
|
||||
frameRate = await _probe.ProbeAsync(_path, _cts.Token).ConfigureAwait(false);
|
||||
var frameDuration = frameRate is > 0 ? TimeSpan.FromSeconds(1.0 / frameRate.Value) : TimeSpan.Zero;
|
||||
|
||||
var ffmpegPath = await _locator.LocateAsync(_cts.Token).ConfigureAwait(false);
|
||||
var args =
|
||||
$"-hide_banner -loglevel error -i \"{_path}\" " +
|
||||
$"-f rawvideo -pix_fmt bgra -vf scale={_width}:{_height} -an -";
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = ffmpegPath,
|
||||
Arguments = args,
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
|
||||
do
|
||||
{
|
||||
using (var process = _processFactory())
|
||||
{
|
||||
_active = process;
|
||||
process.Start(startInfo);
|
||||
|
||||
var reader = new RawVideoFrameReader(_width, _height);
|
||||
var buffer = new byte[65536];
|
||||
var stream = process.StandardOutput;
|
||||
while (!_cts.IsCancellationRequested && !process.HasExited)
|
||||
{
|
||||
var read = await stream.ReadAsync(buffer, 0, buffer.Length, _cts.Token).ConfigureAwait(false);
|
||||
if (read <= 0) break;
|
||||
foreach (var frame in reader.Feed(buffer.AsSpan(0, read).ToArray()))
|
||||
{
|
||||
FrameAvailable?.Invoke(frame);
|
||||
if (frameDuration > TimeSpan.Zero)
|
||||
await _delay(frameDuration, _cts.Token).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
_active = null;
|
||||
}
|
||||
while (Looping && !_cts.IsCancellationRequested);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Fail($"Media decode failed: {ex.Message}");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_cts.IsCancellationRequested)
|
||||
Completed?.Invoke();
|
||||
}
|
||||
|
||||
private void Fail(string message) => Console.Error.WriteLine(message);
|
||||
|
||||
private void KillProcess()
|
||||
{
|
||||
try { _active?.Kill(); } catch { /* already exited */ }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Imaging;
|
||||
using System.Windows.Threading;
|
||||
using ytLive.Helpers;
|
||||
|
||||
namespace ytLive.Services;
|
||||
|
||||
/// <summary>
|
||||
/// TASK 21: owns media-file decode sessions app-wide, refcounted by file path.
|
||||
/// One file = at most one decoder, one shared WriteableBitmap; the last release
|
||||
/// stops and disposes the source. Frames arrive on a worker thread and are
|
||||
/// coalesced onto the UI dispatcher (at most one pending copy per session, using
|
||||
/// the latest frame). Mirrors ScreenCaptureManager/CameraManager.
|
||||
/// </summary>
|
||||
public sealed class MediaVideoSourceManager : IDisposable
|
||||
{
|
||||
private sealed class MediaSession
|
||||
{
|
||||
public string Key { get; }
|
||||
public IMediaFrameSource Source { get; }
|
||||
public Action<VideoFrame>? FrameHandler;
|
||||
public int RefCount;
|
||||
public bool Started;
|
||||
public WriteableBitmap? PreviewBitmap;
|
||||
public VideoFrame? LatestFrame;
|
||||
public bool FramePending;
|
||||
|
||||
public MediaSession(string key, IMediaFrameSource source)
|
||||
{
|
||||
Key = key;
|
||||
Source = source;
|
||||
RefCount = 1;
|
||||
}
|
||||
}
|
||||
|
||||
private readonly Func<string, IMediaFrameSource?> _sourceFactory;
|
||||
private readonly Dispatcher? _uiDispatcher;
|
||||
private readonly Dictionary<string, MediaSession> _sessions = new();
|
||||
private readonly object _gate = new();
|
||||
|
||||
/// <summary>Raised on the UI thread when a media file's shared preview bitmap is first created.</summary>
|
||||
public event Action<string, WriteableBitmap>? PreviewBitmapChanged;
|
||||
|
||||
/// <summary>Raised when a media decoder cannot be created (bad path).</summary>
|
||||
public event Action<string, string>? MediaFailed;
|
||||
|
||||
public MediaVideoSourceManager(Func<string, IMediaFrameSource?> sourceFactory, Dispatcher? uiDispatcher = null)
|
||||
{
|
||||
_sourceFactory = sourceFactory;
|
||||
_uiDispatcher = uiDispatcher;
|
||||
}
|
||||
|
||||
/// <summary>Increments the refcount for a file, starting the decoder the first time.</summary>
|
||||
public async Task<bool> AcquireAsync(string key)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key)) return false;
|
||||
|
||||
MediaSession session;
|
||||
bool shouldStart;
|
||||
lock (_gate)
|
||||
{
|
||||
if (_sessions.TryGetValue(key, out var existing))
|
||||
{
|
||||
existing.RefCount++;
|
||||
shouldStart = false;
|
||||
session = existing;
|
||||
}
|
||||
else
|
||||
{
|
||||
IMediaFrameSource? source;
|
||||
try
|
||||
{
|
||||
source = _sourceFactory(key);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLog.Write($"MediaSource: creating decoder '{key}' failed: {ex.Message}");
|
||||
MediaFailed?.Invoke(key, ex.Message);
|
||||
return false;
|
||||
}
|
||||
if (source == null)
|
||||
{
|
||||
MediaFailed?.Invoke(key, "No decoder for this file");
|
||||
return false;
|
||||
}
|
||||
session = new MediaSession(key, source);
|
||||
session.FrameHandler = frame => OnFrameAvailable(session, frame);
|
||||
session.Source.FrameAvailable += session.FrameHandler;
|
||||
_sessions[key] = session;
|
||||
shouldStart = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!shouldStart) return session.Started;
|
||||
|
||||
try
|
||||
{
|
||||
await session.Source.StartAsync();
|
||||
session.Started = true;
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
lock (_gate)
|
||||
_sessions.Remove(key);
|
||||
session.Source.FrameAvailable -= session.FrameHandler;
|
||||
AppLog.Write($"MediaSource: failed to start decoder '{key}': {ex.Message}");
|
||||
MediaFailed?.Invoke(key, ex.Message);
|
||||
await SafeStopAsync(session.Source);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task ReleaseAsync(string key)
|
||||
{
|
||||
MediaSession? toStop = null;
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_sessions.TryGetValue(key, out var session)) return;
|
||||
if (--session.RefCount > 0) return;
|
||||
_sessions.Remove(key);
|
||||
toStop = session;
|
||||
}
|
||||
|
||||
if (toStop == null) return;
|
||||
toStop.Source.FrameAvailable -= toStop.FrameHandler;
|
||||
await SafeStopAsync(toStop.Source);
|
||||
toStop.PreviewBitmap = null;
|
||||
}
|
||||
|
||||
/// <summary>Releases a file unconditionally (every ref) — used on layout reloads.</summary>
|
||||
public async Task ReleaseAllAsync(string key)
|
||||
{
|
||||
MediaSession? toStop = null;
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_sessions.TryGetValue(key, out var session)) return;
|
||||
session.RefCount = 0;
|
||||
_sessions.Remove(key);
|
||||
toStop = session;
|
||||
}
|
||||
|
||||
if (toStop == null) return;
|
||||
toStop.Source.FrameAvailable -= toStop.FrameHandler;
|
||||
await SafeStopAsync(toStop.Source);
|
||||
toStop.PreviewBitmap = null;
|
||||
}
|
||||
|
||||
/// <summary>The most recent decoded frame for a file, or null before the first
|
||||
/// frame (or if the file has no session). The live compositor reads media from here.</summary>
|
||||
public VideoFrame? GetLatestFrame(string key)
|
||||
{
|
||||
lock (_gate)
|
||||
return _sessions.TryGetValue(key, out var session) ? session.LatestFrame : null;
|
||||
}
|
||||
|
||||
/// <summary>The shared preview bitmap for a file (so a config created after the
|
||||
/// first frame can adopt the running bitmap), or null.</summary>
|
||||
public WriteableBitmap? GetPreviewBitmap(string key)
|
||||
{
|
||||
lock (_gate)
|
||||
return _sessions.TryGetValue(key, out var session) ? session.PreviewBitmap : null;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
List<MediaSession> sessions;
|
||||
lock (_gate)
|
||||
{
|
||||
sessions = new List<MediaSession>(_sessions.Values);
|
||||
_sessions.Clear();
|
||||
}
|
||||
|
||||
foreach (var session in sessions)
|
||||
{
|
||||
session.Source.FrameAvailable -= session.FrameHandler;
|
||||
_ = SafeStopAsync(session.Source);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task SafeStopAsync(IMediaFrameSource source)
|
||||
{
|
||||
try
|
||||
{
|
||||
await source.StopAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLog.Write($"MediaSource: stopping decoder failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryGetActiveSession(MediaSession session)
|
||||
{
|
||||
lock (_gate)
|
||||
return _sessions.TryGetValue(session.Key, out var current) && ReferenceEquals(current, session);
|
||||
}
|
||||
|
||||
private void OnFrameAvailable(MediaSession session, VideoFrame frame)
|
||||
{
|
||||
if (!TryGetActiveSession(session)) return;
|
||||
session.LatestFrame = frame;
|
||||
|
||||
if (session.PreviewBitmap == null)
|
||||
{
|
||||
if (_uiDispatcher == null) return;
|
||||
if (_uiDispatcher.CheckAccess())
|
||||
EnsurePreviewBitmap(session);
|
||||
else
|
||||
_uiDispatcher.BeginInvoke(() =>
|
||||
{
|
||||
if (TryGetActiveSession(session))
|
||||
EnsurePreviewBitmap(session);
|
||||
}, DispatcherPriority.Render);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_uiDispatcher == null || _uiDispatcher.CheckAccess())
|
||||
{
|
||||
CopyFrame(session);
|
||||
return;
|
||||
}
|
||||
|
||||
if (session.FramePending) return;
|
||||
session.FramePending = true;
|
||||
_uiDispatcher.BeginInvoke(() =>
|
||||
{
|
||||
session.FramePending = false;
|
||||
if (TryGetActiveSession(session) && session.PreviewBitmap != null)
|
||||
CopyFrame(session);
|
||||
}, DispatcherPriority.Render);
|
||||
}
|
||||
|
||||
private void EnsurePreviewBitmap(MediaSession session)
|
||||
{
|
||||
if (session.PreviewBitmap != null || session.LatestFrame == null) return;
|
||||
var frame = session.LatestFrame;
|
||||
var bitmap = new WriteableBitmap(frame.Width, frame.Height, 96, 96, PixelFormats.Bgra32, null);
|
||||
bitmap.WritePixels(new Int32Rect(0, 0, frame.Width, frame.Height), frame.BgraPixels, frame.Stride, 0);
|
||||
session.PreviewBitmap = bitmap;
|
||||
PreviewBitmapChanged?.Invoke(session.Key, bitmap);
|
||||
}
|
||||
|
||||
private void CopyFrame(MediaSession session)
|
||||
{
|
||||
var bitmap = session.PreviewBitmap;
|
||||
var frame = session.LatestFrame;
|
||||
if (bitmap == null || frame == null) return;
|
||||
if (frame.Width != bitmap.PixelWidth || frame.Height != bitmap.PixelHeight) return;
|
||||
bitmap.WritePixels(new Int32Rect(0, 0, frame.Width, frame.Height), frame.BgraPixels, frame.Stride, 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
|
||||
namespace ytLive.Services;
|
||||
|
||||
/// <summary>
|
||||
/// An atomic "publish only if strictly newer" gate for the capture's latest-wins
|
||||
/// hand-out. Screen capture conversions may now overlap (slice 17), so two
|
||||
/// conversions can finish briefly out of order; without a monotonic gate the slower
|
||||
/// (older) completion would overwrite the faster (newer) one's LatestFrame and the
|
||||
/// compositor would render STALE content — a time hole in the other direction.
|
||||
/// Sequence must be a strictly increasing per-source counter
|
||||
/// (<c>Interlocked.Increment</c> in <c>ScreenCaptureFrameSource</c>).
|
||||
/// </summary>
|
||||
internal sealed class MonotonicGate
|
||||
{
|
||||
private long _last;
|
||||
|
||||
/// <summary>Returns true iff <paramref name="seq"/> is strictly newer than every
|
||||
/// seq accepted so far (and records it).</summary>
|
||||
public bool TryPublish(long seq)
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
var current = Interlocked.Read(ref _last);
|
||||
if (seq <= current) return false;
|
||||
if (Interlocked.CompareExchange(ref _last, seq, current) == current) return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
namespace ytLive.Services;
|
||||
|
||||
/// <summary>
|
||||
/// TASK 21: parses a stream of tightly-packed raw BGRA frames into
|
||||
/// <see cref="VideoFrame"/>s. The consumer of an ffmpeg <c>-f rawvideo -pix_fmt
|
||||
/// bgra</c> stdout pipe: every frame is exactly <c>width*height*4</c> bytes, so a
|
||||
/// frame is emitted whenever that many bytes have accumulated. Pure and fully
|
||||
/// deterministic, so it is tested with a synthetic byte stream — no ffmpeg, no
|
||||
/// process, no audio device needed.
|
||||
/// </summary>
|
||||
public sealed class RawVideoFrameReader
|
||||
{
|
||||
private readonly int _frameBytes;
|
||||
private readonly int _width;
|
||||
private readonly int _height;
|
||||
private byte[]? _pending;
|
||||
|
||||
public RawVideoFrameReader(int width, int height)
|
||||
{
|
||||
_width = width;
|
||||
_height = height;
|
||||
_frameBytes = width * height * 4;
|
||||
}
|
||||
|
||||
/// <summary>Feeds <paramref name="bytes"/> read from the pipe and returns any
|
||||
/// whole frames reconstructed so far. Partial/pending bytes are kept for the
|
||||
/// next call — frames never split or merge across reads.</summary>
|
||||
public List<VideoFrame> Feed(byte[] bytes)
|
||||
{
|
||||
var pending = _pending;
|
||||
byte[] combined;
|
||||
if (pending != null && pending.Length > 0)
|
||||
{
|
||||
combined = new byte[pending.Length + bytes.Length];
|
||||
Buffer.BlockCopy(pending, 0, combined, 0, pending.Length);
|
||||
Buffer.BlockCopy(bytes, 0, combined, pending.Length, bytes.Length);
|
||||
_pending = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
combined = bytes;
|
||||
}
|
||||
|
||||
var frames = new List<VideoFrame>();
|
||||
var offset = 0;
|
||||
while (combined.Length - offset >= _frameBytes)
|
||||
{
|
||||
var frame = new byte[_frameBytes];
|
||||
Buffer.BlockCopy(combined, offset, frame, 0, _frameBytes);
|
||||
frames.Add(new VideoFrame(_width, _height, frame));
|
||||
offset += _frameBytes;
|
||||
}
|
||||
|
||||
if (offset < combined.Length)
|
||||
_pending = combined[offset..];
|
||||
|
||||
return frames;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Linq;
|
||||
using ytLive.Models;
|
||||
using ytLive.Services.Compositor;
|
||||
|
||||
namespace ytLive.Services;
|
||||
|
||||
/// <summary>
|
||||
/// The scene graph (TASK 31): single owner of the scene collection and the
|
||||
/// element inventory / mutation surface, plus the static/dynamic classification
|
||||
/// queries the compositor optimization depends on.
|
||||
///
|
||||
/// Scenes are referenced (not owned): the same <see cref="Scene"/> instances live
|
||||
/// on the ViewModel's bindable surface; SceneGraph adds/removes/reorders their
|
||||
/// <see cref="Scene.Elements"/> and answers queries (background / webcam / chat /
|
||||
/// split point) that used to be scattered LINQ across MainViewModel.
|
||||
///
|
||||
/// Classification: <see cref="ElementKind.Static"/> (images, background art) is
|
||||
/// baked once; <see cref="ElementKind.Dynamic"/> (webcam, live capture, chat, web)
|
||||
/// is composited per frame. The <b>split point</b> is the index of the first
|
||||
/// dynamic element — everything below it forms the baked base.
|
||||
/// </summary>
|
||||
public sealed class SceneGraph
|
||||
{
|
||||
private readonly Dictionary<string, SceneRegion> _regionCache = new(StringComparer.Ordinal);
|
||||
private readonly object _gate = new();
|
||||
|
||||
/// <summary>The shared scene collection (same instances the ViewModel binds).</summary>
|
||||
public ObservableCollection<Scene> Scenes { get; } = new();
|
||||
|
||||
/// <summary>The one Background row for a scene — null when the scene has none.</summary>
|
||||
public Source? GetBackground(Scene scene)
|
||||
=> scene.Elements.OfType<Source>().FirstOrDefault(s => s.IsBackground);
|
||||
|
||||
/// <summary>This scene's webcam usage, if any.</summary>
|
||||
public WebcamSceneConfig? GetWebcam(Scene scene)
|
||||
=> scene.Elements.OfType<WebcamSceneConfig>().FirstOrDefault();
|
||||
|
||||
/// <summary>All chat-box elements across every scene (for ChatOverlayLayer).</summary>
|
||||
public IEnumerable<Source> GetChatBoxes()
|
||||
=> Scenes.SelectMany(s => s.Elements.OfType<Source>().Where(x => x.Type == SourceType.ChatBox));
|
||||
|
||||
/// <summary>Index of the first dynamic element (the split point). If no dynamic
|
||||
/// element exists this equals <see cref="Scene.Elements"/> count — fully baked.</summary>
|
||||
public int GetSplitPoint(Scene scene)
|
||||
{
|
||||
var elements = scene.Elements;
|
||||
for (var i = 0; i < elements.Count; i++)
|
||||
{
|
||||
if (elements[i].Kind == ElementKind.Dynamic)
|
||||
return i;
|
||||
}
|
||||
return elements.Count;
|
||||
}
|
||||
|
||||
/// <summary>True when the whole scene is static (no dynamic element) — the
|
||||
/// compositor can bake it once and skip per-frame compositing.</summary>
|
||||
public bool IsStatic(Scene scene)
|
||||
=> scene.Elements.All(e => e.Kind == ElementKind.Static);
|
||||
|
||||
/// <summary>Invalidate the cached bake region for a scene after any mutation
|
||||
/// (element add/remove/reorder, static resize/opacity/visibility, asset change).</summary>
|
||||
public void InvalidateBake(Scene scene)
|
||||
{
|
||||
lock (_gate) _regionCache.Remove(scene.Id);
|
||||
}
|
||||
|
||||
// ─── Mutation surface (single owner of element operations) ───
|
||||
|
||||
/// <summary>Append an element to a scene and (re)bake accordingly.</summary>
|
||||
public void AddElement(Scene scene, SceneElement element)
|
||||
{
|
||||
scene.Elements.Add(element);
|
||||
InvalidateBake(scene);
|
||||
}
|
||||
|
||||
/// <summary>Insert an element at a specific z-index and (re)bake accordingly.</summary>
|
||||
public void InsertElement(Scene scene, int index, SceneElement element)
|
||||
{
|
||||
scene.Elements.Insert(index, element);
|
||||
InvalidateBake(scene);
|
||||
}
|
||||
|
||||
/// <summary>Remove an element and (re)bake accordingly.</summary>
|
||||
public bool RemoveElement(Scene scene, SceneElement element)
|
||||
{
|
||||
var removed = scene.Elements.Remove(element);
|
||||
if (removed) InvalidateBake(scene);
|
||||
return removed;
|
||||
}
|
||||
|
||||
/// <summary>Reorder an element in z-space and (re)bake accordingly.</summary>
|
||||
public void MoveElement(Scene scene, SceneElement element, int newIndex)
|
||||
{
|
||||
var current = scene.Elements.IndexOf(element);
|
||||
if (current < 0) return;
|
||||
scene.Elements.Move(current, newIndex);
|
||||
InvalidateBake(scene);
|
||||
}
|
||||
|
||||
/// <summary>Add a scene to the collection (wiring is handled by the ViewModel).</summary>
|
||||
public void AddScene(Scene scene) => Scenes.Add(scene);
|
||||
|
||||
/// <summary>Get (or bake) the cached static base for a scene. Null means "no static
|
||||
/// base" (first layer is dynamic or the scene is empty) — caller falls back to full render.</summary>
|
||||
public VideoFrame? GetBakedBase(
|
||||
Scene scene,
|
||||
Func<SceneElement, VideoFrame?> frameFor,
|
||||
Func<CompositorOptions> options,
|
||||
VideoFrame? socialBarFrame = null,
|
||||
int socialBarTop = 0,
|
||||
VideoFrame? flashFrame = null)
|
||||
{
|
||||
var split = GetSplitPoint(scene);
|
||||
if (split == 0) return null;
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
if (_regionCache.TryGetValue(scene.Id, out var cached)
|
||||
&& cached.Matches(scene, split))
|
||||
return cached.Frame;
|
||||
}
|
||||
|
||||
var baked = SceneCompositor.BakeStaticBase(
|
||||
scene, split, frameFor, options, socialBarFrame, socialBarTop, flashFrame);
|
||||
if (baked == null) return null;
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
_regionCache[scene.Id] = new SceneRegion(baked, scene.Elements.Take(split).ToList());
|
||||
}
|
||||
return baked;
|
||||
}
|
||||
|
||||
/// <summary>The baked frame + the element identity it was baked from (for invalidation).</summary>
|
||||
private sealed class SceneRegion
|
||||
{
|
||||
public VideoFrame Frame { get; }
|
||||
private readonly string[] _staticElementIds;
|
||||
|
||||
public SceneRegion(VideoFrame frame, List<SceneElement> staticBelow)
|
||||
{
|
||||
Frame = frame;
|
||||
_staticElementIds = staticBelow.Select(e => e.Id).ToArray();
|
||||
}
|
||||
|
||||
/// <summary>Only static layers below the split are part of the base. Two bakes
|
||||
/// match iff the same static element identities, in the same order, are below
|
||||
/// the split — dynamic-only pixel/layout changes never invalidate.</summary>
|
||||
public bool Matches(Scene scene, int split)
|
||||
{
|
||||
var below = scene.Elements.Take(split).Select(e => e.Id).ToArray();
|
||||
return below.Length == _staticElementIds.Length
|
||||
&& below.SequenceEqual(_staticElementIds);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Runtime.InteropServices.WindowsRuntime;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Windows.Graphics;
|
||||
using Windows.Graphics.Capture;
|
||||
@@ -27,9 +29,23 @@ public sealed class ScreenCaptureFrameSource : IScreenCaptureSource
|
||||
private GraphicsCaptureSession? _session;
|
||||
private SizeInt32 _poolSize;
|
||||
private bool _started;
|
||||
private bool _framePending;
|
||||
private int _convertInFlight;
|
||||
private DateTime _lastErrorLog = DateTime.MinValue;
|
||||
|
||||
// Buffer recycling (take-11 spikes, 2026-09-04; slice 16, 2026-09-14): a fresh
|
||||
// ~8.3MB byte[] per DWM frame ≈ 500MB/s of LOH churn — the gen2 pauses it forced
|
||||
// surfaced as the "worst render 35-65ms" spikes that capped fps at ~42. The pool
|
||||
// is a reuse-distance ring: a buffer is only rewritten after ≥ redLine rents have
|
||||
// cycled since its last hand-out (FrameRingBuffer), so a frame a consumer still
|
||||
// holds — session.LatestFrame survives across conversions and the dispatcher
|
||||
// preview copy lags — can never be overwritten in place (the 1742 tear, a ring
|
||||
// slot rewritten under the consumer's read, handed the compositor one
|
||||
// new-top/old-bottom frame). Each hand-out still carries an Epoch so identity-keyed
|
||||
// consumers (the compositor's paste cache) cannot false-hit a recycled array.
|
||||
private readonly FrameRingBuffer _ring = new(8, redLine: 4);
|
||||
private long _epoch;
|
||||
private readonly MonotonicGate _publishGate = new();
|
||||
|
||||
// The composition master frame (see ai.md "Resolution tiers"): the background
|
||||
// is an input layer, so we never hold a CPU frame bigger than the master.
|
||||
private const int MaxBackgroundWidth = 1920;
|
||||
@@ -38,6 +54,28 @@ public sealed class ScreenCaptureFrameSource : IScreenCaptureSource
|
||||
// A failing conversion must not re-flood the log at frame rate.
|
||||
private static readonly TimeSpan ErrorLogThrottle = TimeSpan.FromSeconds(5);
|
||||
|
||||
// Conversion cadence (slice 16, 2026-09-14; slice 17, 2026-09-14): the monitor
|
||||
// delivers at the 240Hz DWM cadence (~4.2ms) while slots are 16.6ms. A 10ms floor
|
||||
// between conversion starts keeps the open edge above the ~60 conversions/s the pump
|
||||
// can use. What actually bounded the desktop feed on takes 1742/1824 was the SERIAL
|
||||
// GPU→CPU readback (`CreateCopyFromSurfaceAsync` ≈ 40-50ms of the ~47ms conversion
|
||||
// on a 240Hz-HDR box shared with the encoder), capping captures at ~17-20/s.
|
||||
// Slice 17 overlaps up to MaxConcurrentConversions readbacks (pool sized to
|
||||
// accommodate in-flight frames) and publishes only monotonically newer frames
|
||||
// (MonotonicGate — a slow older completion must never overwrite a newer LatestFrame).
|
||||
private static readonly TimeSpan MinConvertInterval = TimeSpan.FromMilliseconds(10);
|
||||
private static readonly TimeSpan TelemetryInterval = TimeSpan.FromSeconds(2);
|
||||
private const int MaxConcurrentConversions = 3;
|
||||
private const int PoolBufferCount = 5;
|
||||
private DateTime _lastConvertAt = DateTime.MinValue;
|
||||
private DateTime _telemetryFrom = DateTime.UtcNow;
|
||||
private DateTime _lastTelemetry = DateTime.UtcNow;
|
||||
private long _skippedCadence;
|
||||
private long _skippedBusy;
|
||||
private long _conversions;
|
||||
private long _convertMsTotal;
|
||||
private long _convertMsMax;
|
||||
|
||||
public string Key { get; }
|
||||
public event Action<VideoFrame>? FrameAvailable;
|
||||
|
||||
@@ -74,7 +112,7 @@ public sealed class ScreenCaptureFrameSource : IScreenCaptureSource
|
||||
var item = _item;
|
||||
var device = Direct3D11Helper.CreateDevice();
|
||||
var framePool = Direct3D11CaptureFramePool.CreateFreeThreaded(
|
||||
device, DirectXPixelFormat.B8G8R8A8UIntNormalized, 2, item.Size);
|
||||
device, DirectXPixelFormat.B8G8R8A8UIntNormalized, PoolBufferCount, item.Size);
|
||||
_poolSize = item.Size;
|
||||
var session = framePool.CreateCaptureSession(item);
|
||||
framePool.FrameArrived += OnFrameArrived;
|
||||
@@ -91,7 +129,7 @@ public sealed class ScreenCaptureFrameSource : IScreenCaptureSource
|
||||
lock (_gate)
|
||||
{
|
||||
_started = false;
|
||||
_framePending = false;
|
||||
_convertInFlight = 0;
|
||||
if (_framePool != null)
|
||||
_framePool.FrameArrived -= OnFrameArrived;
|
||||
_session?.Dispose();
|
||||
@@ -106,6 +144,7 @@ public sealed class ScreenCaptureFrameSource : IScreenCaptureSource
|
||||
{
|
||||
var frame = sender.TryGetNextFrame();
|
||||
if (frame == null) return;
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_started)
|
||||
@@ -113,33 +152,54 @@ public sealed class ScreenCaptureFrameSource : IScreenCaptureSource
|
||||
frame.Dispose();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (frame.ContentSize.Width != _poolSize.Width || frame.ContentSize.Height != _poolSize.Height)
|
||||
{
|
||||
sender.Recreate(Direct3D11Helper.CreateDevice(),
|
||||
DirectXPixelFormat.B8G8R8A8UIntNormalized, 2, frame.ContentSize);
|
||||
_poolSize = frame.ContentSize;
|
||||
}
|
||||
if (frame.ContentSize.Width != _poolSize.Width || frame.ContentSize.Height != _poolSize.Height)
|
||||
{
|
||||
sender.Recreate(Direct3D11Helper.CreateDevice(),
|
||||
DirectXPixelFormat.B8G8R8A8UIntNormalized, PoolBufferCount, frame.ContentSize);
|
||||
_poolSize = frame.ContentSize;
|
||||
}
|
||||
|
||||
if (_framePending)
|
||||
{
|
||||
frame.Dispose();
|
||||
return;
|
||||
// Up to MaxConcurrentConversions readbacks in flight (slice 17), spaced by
|
||||
// MinConvertInterval (slice 16): the 240Hz delivery otherwise queued one
|
||||
// conversion every ~4.2ms, and the serial ~47ms readback pinned the desktop
|
||||
// layer to ~17 updates/s on the 1824 take.
|
||||
if (_convertInFlight >= MaxConcurrentConversions)
|
||||
{
|
||||
_skippedBusy++;
|
||||
frame.Dispose();
|
||||
return;
|
||||
}
|
||||
var now = DateTime.UtcNow;
|
||||
if (now - _lastConvertAt < MinConvertInterval)
|
||||
{
|
||||
_skippedCadence++;
|
||||
frame.Dispose();
|
||||
return;
|
||||
}
|
||||
_lastConvertAt = now;
|
||||
_convertInFlight++;
|
||||
}
|
||||
_framePending = true;
|
||||
_ = ProcessFrameAsync(frame);
|
||||
}
|
||||
|
||||
private async Task ProcessFrameAsync(Direct3D11CaptureFrame frame)
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
using (frame)
|
||||
using (var softwareBitmap = await SoftwareBitmap.CreateCopyFromSurfaceAsync(
|
||||
frame.Surface, BitmapAlphaMode.Ignore))
|
||||
{
|
||||
FrameAvailable?.Invoke(CopyToVideoFrame(softwareBitmap));
|
||||
// Monotonic sequencing across the overlapping conversions (slice 17): a
|
||||
// completed readback is published only if strictly newer than the last
|
||||
// one published — a slow older completion must never overwrite a newer
|
||||
// LatestFrame (that would be a time hole in the other direction).
|
||||
var epoch = Interlocked.Increment(ref _epoch);
|
||||
var videoFrame = CopyToVideoFrame(softwareBitmap, epoch);
|
||||
if (_publishGate.TryPublish(epoch))
|
||||
FrameAvailable?.Invoke(videoFrame);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -153,11 +213,41 @@ public sealed class ScreenCaptureFrameSource : IScreenCaptureSource
|
||||
}
|
||||
finally
|
||||
{
|
||||
_framePending = false;
|
||||
lock (_gate)
|
||||
{
|
||||
EmitTelemetry(sw.ElapsedMilliseconds);
|
||||
_convertInFlight--;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static VideoFrame CopyToVideoFrame(SoftwareBitmap bitmap)
|
||||
private void EmitTelemetry(long conversionMs)
|
||||
{
|
||||
_conversions++;
|
||||
_convertMsTotal += conversionMs;
|
||||
if (conversionMs > _convertMsMax) _convertMsMax = conversionMs;
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
if (now - _lastTelemetry < TelemetryInterval) return;
|
||||
|
||||
var span = now - _telemetryFrom;
|
||||
var perSecond = span.TotalSeconds > 0 ? _conversions / span.TotalSeconds : 0;
|
||||
AppLog.Write(
|
||||
$"ScreenCapture telemetry [{Key}]: {_conversions} frames in {span.TotalSeconds:F1}s " +
|
||||
$"({perSecond:F0}/s), conv avg {( _conversions == 0 ? 0 : _convertMsTotal / _conversions )}ms " +
|
||||
$"max {_convertMsMax}ms, skip busy {_skippedBusy} cadence {_skippedCadence}, " +
|
||||
$"ring allocs {_ring.ConsumeAllocations()}");
|
||||
|
||||
_lastTelemetry = now;
|
||||
_telemetryFrom = now;
|
||||
_conversions = 0;
|
||||
_convertMsTotal = 0;
|
||||
_convertMsMax = 0;
|
||||
_skippedBusy = 0;
|
||||
_skippedCadence = 0;
|
||||
}
|
||||
|
||||
private VideoFrame CopyToVideoFrame(SoftwareBitmap bitmap, long epoch)
|
||||
{
|
||||
var sw = bitmap.PixelWidth;
|
||||
var sh = bitmap.PixelHeight;
|
||||
@@ -177,45 +267,61 @@ public sealed class ScreenCaptureFrameSource : IScreenCaptureSource
|
||||
var scale = Math.Min(MaxBackgroundWidth / (double)sw, MaxBackgroundHeight / (double)sh);
|
||||
var dw = Math.Max(1, (int)(sw * scale));
|
||||
var dh = Math.Max(1, (int)(sh * scale));
|
||||
return new VideoFrame(dw, dh, DownscaleBgra(data, sw, sh, srcStride, dw, dh));
|
||||
// DWM delivers an opaque surface (alpha 255); bilinear keeps it 255.
|
||||
var scaled = _ring.Rent(dw * dh * 4);
|
||||
return new VideoFrame(dw, dh, DownscaleBgra(data, sw, sh, srcStride, dw, dh, scaled))
|
||||
{ IsOpaque = true, Epoch = epoch };
|
||||
}
|
||||
|
||||
var pixels = new byte[count];
|
||||
var pixels = _ring.Rent(count);
|
||||
Marshal.Copy(data, pixels, 0, pixels.Length);
|
||||
return new VideoFrame(sw, sh, pixels);
|
||||
return new VideoFrame(sw, sh, pixels) { IsOpaque = true, Epoch = epoch };
|
||||
}
|
||||
|
||||
// Bilinear downscale to the master frame. Reads each source row pair through
|
||||
// Marshal.Copy (no unsafe), writing tightly packed BGRA output.
|
||||
private static byte[] DownscaleBgra(IntPtr src, int sw, int sh, int srcStride, int dw, int dh)
|
||||
// Integer 8.8 fixed-point bilinear downscale to the master frame (slice 16,
|
||||
// 2026-09-14: the two-stage math from SceneCompositor.Bilinear — the same "shift
|
||||
// only at the end" rule from MyMistakes item on fixed-point blending). The previous
|
||||
// double-per-pixel version ran ~30-45ms per 2.5K→1080p frame on a quiet desktop
|
||||
// and the 1742 take's stall measured ~150ms/frame under load (90%-frozen desktop,
|
||||
// ~6 fresh frames/s); integer math keeps the same bilinear result within ±1 while
|
||||
// dropping the cost to the row-pair Marshal.Copy. Rows are read once per source
|
||||
// row pair through Marshal.Copy (no unsafe), writing tightly packed BGRA output.
|
||||
private byte[] DownscaleBgra(IntPtr src, int sw, int sh, int srcStride, int dw, int dh, byte[] dst)
|
||||
{
|
||||
// Row scratch is per-conversion (overlapping conversions since slice 17 each
|
||||
// bring their own — two 10KB arrays, no shared state). Size: a 2560-wide row
|
||||
// pair, the largest the monitor path delivers before the downscale.
|
||||
var row0 = new byte[srcStride];
|
||||
var row1 = new byte[srcStride];
|
||||
var dst = new byte[dw * dh * 4];
|
||||
var xs = sw / (double)dw;
|
||||
var ys = sh / (double)dh;
|
||||
|
||||
for (var y = 0; y < dh; y++)
|
||||
{
|
||||
var sy = Math.Min(sh - 1, (int)(y * ys));
|
||||
var sy8 = (int)((long)y * sh * 256 / dh);
|
||||
var sy = sy8 >> 8;
|
||||
var sy1 = Math.Min(sh - 1, sy + 1);
|
||||
var fy = (y * ys) - sy;
|
||||
var fy8 = sy8 & 255;
|
||||
var fyInv = 256 - fy8;
|
||||
Marshal.Copy(IntPtr.Add(src, sy * srcStride), row0, 0, srcStride);
|
||||
Marshal.Copy(IntPtr.Add(src, sy1 * srcStride), row1, 0, srcStride);
|
||||
|
||||
var dRow = y * dw * 4;
|
||||
for (var x = 0; x < dw; x++)
|
||||
{
|
||||
var sx = Math.Min(sw - 1, (int)(x * xs));
|
||||
var sx8 = (int)((long)x * sw * 256 / dw);
|
||||
var sx = Math.Min(sw - 1, sx8 >> 8);
|
||||
var sx1 = Math.Min(sw - 1, sx + 1);
|
||||
var fx = (x * xs) - sx;
|
||||
for (var c = 0; c < 4; c++)
|
||||
var fx8 = sx8 & 255;
|
||||
var fxInv = 256 - fx8;
|
||||
|
||||
var i0 = sx * 4;
|
||||
var i1 = sx1 * 4;
|
||||
var j = dRow + x * 4;
|
||||
for (var c = 0; c < 4; c++, i0++, i1++, j++)
|
||||
{
|
||||
var i0 = sx * 4 + c;
|
||||
var i1 = sx1 * 4 + c;
|
||||
var top = row0[i0] + (row0[i1] - row0[i0]) * fx;
|
||||
var bottom = row1[i0] + (row1[i1] - row1[i0]) * fx;
|
||||
dst[dRow + x * 4 + c] = (byte)(top + (bottom - top) * fy);
|
||||
// Two-stage in one 16.8 scale: top/bot ≤ 65280, ×256 + round ≤ 33.5M — int-safe.
|
||||
var top = row0[i0] * fxInv + row0[i1] * fx8;
|
||||
var bot = row1[i0] * fxInv + row1[i1] * fx8;
|
||||
dst[j] = (byte)((top * fyInv + bot * fy8 + 32768) >> 16);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +38,11 @@ public sealed class ScreenCaptureSourceFactory
|
||||
|
||||
if (key.StartsWith("window:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var hexText = key.AsSpan("window:".Length);
|
||||
// Keys are written as "window:0x<Hwnd:X>" (CaptureInterop/WindowInfo),
|
||||
// but NumberStyles.HexNumber does not accept the "0x" prefix — strip it.
|
||||
var hexText = key.AsSpan("window:".Length).TrimStart();
|
||||
if (hexText.Length >= 2 && hexText[0] == '0' && (hexText[1] == 'x' || hexText[1] == 'X'))
|
||||
hexText = hexText[2..];
|
||||
if (long.TryParse(hexText, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var hwnd))
|
||||
return ScreenCaptureFrameSource.CreateForWindow(new IntPtr(hwnd));
|
||||
return null;
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows.Input;
|
||||
using ytLive.Helpers;
|
||||
using ytLive.Models;
|
||||
|
||||
namespace ytLive.Services;
|
||||
|
||||
/// <summary>
|
||||
/// TASK 41 — the Test Stream pull-out (the "TEST" tab at the bottom of the Live
|
||||
/// screen's right rail). Drives a private test broadcast's chat tooling:
|
||||
/// Mock Chat Input sends a REAL liveChat/messages.insert (round-tripping through
|
||||
/// the normal ~2s poll and the live overlay path), and the simulated-event buttons
|
||||
/// inject synthetic member / SuperChat / subscriber rows through the exact
|
||||
/// MessageReceived seam the real poll uses — local-only, never sent to YouTube
|
||||
/// (the insert API only creates text). The broadcast itself runs through the real
|
||||
/// private go-live pipeline; this VM only owns the dock.
|
||||
/// </summary>
|
||||
public sealed class TestSessionViewModel : ViewModelBase
|
||||
{
|
||||
private readonly YouTubeStreamService _stream;
|
||||
private readonly YouTubeChatService _chat;
|
||||
private readonly Action? _onEndStream;
|
||||
|
||||
private bool _isDrawerOpen;
|
||||
private bool _isTestSession;
|
||||
private string? _broadcastId;
|
||||
private string? _liveChatId;
|
||||
private string _sendMessageText = string.Empty;
|
||||
private bool _isSending;
|
||||
private string? _lastEventText;
|
||||
private int _eventCounter;
|
||||
|
||||
/// <summary>Fake viewer names for simulated events — several backs make the
|
||||
/// rows look like a real muted room.</summary>
|
||||
private static readonly string[] SimulatedViewers = { "PixelPasta", "NightskyNick", "LunaGlow", "CactusFox" };
|
||||
|
||||
public TestSessionViewModel(
|
||||
YouTubeStreamService stream,
|
||||
YouTubeChatService chat,
|
||||
Action? onEndStream = null)
|
||||
{
|
||||
_stream = stream;
|
||||
_chat = chat;
|
||||
_onEndStream = onEndStream;
|
||||
|
||||
SendTestMessageCommand = new RelayCommand(_ => _ = SendTestMessageAsync(), _ => CanSendTestMessage);
|
||||
SimulateSubscriberCommand = new RelayCommand(_ => Simulate(SimulatedSubscriber()));
|
||||
SimulateMemberCommand = new RelayCommand(_ => Simulate(SimulatedMember()));
|
||||
SimulateSuperChatCommand = new RelayCommand(_ => Simulate(SimulatedSuperChat()));
|
||||
EndTestCommand = new RelayCommand(_ => _onEndStream?.Invoke());
|
||||
ToggleDrawerCommand = new RelayCommand(_ => IsDrawerOpen = !IsDrawerOpen);
|
||||
CloseDrawerCommand = new RelayCommand(_ => IsDrawerOpen = false);
|
||||
}
|
||||
|
||||
public ICommand SendTestMessageCommand { get; }
|
||||
public RelayCommand SimulateSubscriberCommand { get; }
|
||||
public RelayCommand SimulateMemberCommand { get; }
|
||||
public RelayCommand SimulateSuperChatCommand { get; }
|
||||
public RelayCommand EndTestCommand { get; }
|
||||
public RelayCommand ToggleDrawerCommand { get; }
|
||||
public RelayCommand CloseDrawerCommand { get; }
|
||||
|
||||
public bool IsDrawerOpen
|
||||
{
|
||||
get => _isDrawerOpen;
|
||||
set => SetProperty(ref _isDrawerOpen, value);
|
||||
}
|
||||
|
||||
/// <summary>Whether a test broadcast is currently live (ended by BeginTest /
|
||||
/// EndTestSilently; EndTestCommand asks the app to stop, which routes back here).</summary>
|
||||
public bool IsTestSession
|
||||
{
|
||||
get => _isTestSession;
|
||||
private set
|
||||
{
|
||||
if (SetProperty(ref _isTestSession, value))
|
||||
{
|
||||
OnPropertyChanged(nameof(SessionCaption));
|
||||
OnPropertyChanged(nameof(ChatIdLine));
|
||||
OnPropertyChanged(nameof(CanSendTestMessage));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public string? BroadcastId => _broadcastId;
|
||||
|
||||
public string ChatIdLine => _liveChatId == null ? "liveChatId: —" : $"liveChatId: {_liveChatId}";
|
||||
|
||||
public string SessionCaption => IsTestSession
|
||||
? "Test stream is live — private to you, zero notifications, real chat."
|
||||
: "No test in progress — hit the Test button in the top bar to start a private broadcast and check your chat overlay.";
|
||||
|
||||
public string SendMessageText
|
||||
{
|
||||
get => _sendMessageText;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _sendMessageText, value))
|
||||
OnPropertyChanged(nameof(CanSendTestMessage));
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsSending
|
||||
{
|
||||
get => _isSending;
|
||||
private set
|
||||
{
|
||||
if (SetProperty(ref _isSending, value))
|
||||
OnPropertyChanged(nameof(CanSendTestMessage));
|
||||
}
|
||||
}
|
||||
|
||||
public bool CanSendTestMessage => IsTestSession && !IsSending && _liveChatId != null
|
||||
&& !string.IsNullOrWhiteSpace(SendMessageText);
|
||||
|
||||
/// <summary>Feedback line for the last send / simulated event (null = nothing yet).</summary>
|
||||
public string? LastEventText
|
||||
{
|
||||
get => _lastEventText;
|
||||
private set => SetProperty(ref _lastEventText, value);
|
||||
}
|
||||
|
||||
/// <summary>Called once by MainViewModel when the test broadcast is live and its
|
||||
/// liveChatId resolved — opens the dock so the chat tools are immediately usable.</summary>
|
||||
public void BeginTest(string broadcastId, string? liveChatId)
|
||||
{
|
||||
_broadcastId = broadcastId;
|
||||
_liveChatId = string.IsNullOrWhiteSpace(liveChatId) ? null : liveChatId;
|
||||
SendMessageText = string.Empty;
|
||||
LastEventText = _liveChatId == null
|
||||
? "Chat polling couldn't start — Mock Chat Input is disabled."
|
||||
: null;
|
||||
IsTestSession = true;
|
||||
IsDrawerOpen = true;
|
||||
}
|
||||
|
||||
/// <summary>Called by MainViewModel's stop path when the broadcast ends — the
|
||||
/// dock forgets the ended session (idempotent).</summary>
|
||||
public void EndTestSilently()
|
||||
{
|
||||
_broadcastId = null;
|
||||
_liveChatId = null;
|
||||
IsTestSession = false;
|
||||
IsSending = false;
|
||||
}
|
||||
|
||||
/// <summary>Mock Chat Input: real liveChat/messages.insert. The inserted text
|
||||
/// arrives through the normal poll and renders via the live overlay path — the
|
||||
/// whole point is that the round-trip is the real one.</summary>
|
||||
public async Task SendTestMessageAsync()
|
||||
{
|
||||
if (!CanSendTestMessage || _liveChatId == null) return;
|
||||
var text = SendMessageText.Trim();
|
||||
IsSending = true;
|
||||
SendMessageText = string.Empty;
|
||||
LastEventText = null;
|
||||
try
|
||||
{
|
||||
var error = await _stream.InsertChatMessageAsync(_liveChatId, text);
|
||||
LastEventText = error == null
|
||||
? $"Sent — “{text}” should appear on the chat overlay within a couple of seconds."
|
||||
: $"Couldn't send: {error}";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLog.Write($"Test chat message send failed: {ex.Message}");
|
||||
LastEventText = "Couldn't send that message — try again.";
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsSending = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void Simulate(ChatMessage message)
|
||||
{
|
||||
if (!IsTestSession) return;
|
||||
_chat.InjectSimulatedMessage(message);
|
||||
_eventCounter++;
|
||||
LastEventText = $"Fired #{_eventCounter}: {Describe(message)} — renders on any visible chat overlay.";
|
||||
AppLog.Write($"Test chat simulation {_eventCounter} injected");
|
||||
}
|
||||
|
||||
/// <summary>Subscriptions aren't chat events on YouTube, so a "subscriber" row
|
||||
/// is a plain text-shaped message carrying the count — honest: the chat overlay
|
||||
/// shows it as a regular row, which is exactly what a sub looks like in chat.
|
||||
/// It carries <see cref="ChatEventKind.None"/> on purpose: no alert animation
|
||||
/// fires for free subs — the chat mention is the announcement (creator ruling).</summary>
|
||||
private ChatMessage SimulatedSubscriber()
|
||||
{
|
||||
var author = NextViewer();
|
||||
return NewSimulated(author, "New subscriber — welcome to the herd! 🦙 (simulated subscribe)");
|
||||
}
|
||||
|
||||
private ChatMessage SimulatedMember()
|
||||
{
|
||||
var author = NextViewer();
|
||||
var message = NewSimulated(author, $"🎉 {author} became a member!");
|
||||
message.IsMember = true;
|
||||
message.MembershipLevel = "New member";
|
||||
message.Kind = ChatEventKind.NewMember;
|
||||
return message;
|
||||
}
|
||||
|
||||
private ChatMessage SimulatedSuperChat()
|
||||
{
|
||||
var author = NextViewer();
|
||||
var message = NewSimulated(author, "Keep the streams flowing, llama power 🦙");
|
||||
message.IsSuperChat = true;
|
||||
message.SuperChatAmount = 5.00;
|
||||
message.SuperChatCurrency = "$";
|
||||
message.SuperChatDisplayString = "$5.00";
|
||||
message.Kind = ChatEventKind.SuperChat;
|
||||
return message;
|
||||
}
|
||||
|
||||
private ChatMessage NewSimulated(string author, string text)
|
||||
{
|
||||
return new ChatMessage
|
||||
{
|
||||
AuthorName = author,
|
||||
Message = text,
|
||||
Timestamp = DateTime.Now,
|
||||
IsSimulated = true,
|
||||
};
|
||||
}
|
||||
|
||||
private string NextViewer() => SimulatedViewers[_eventCounter % SimulatedViewers.Length];
|
||||
|
||||
private static string Describe(ChatMessage m)
|
||||
{
|
||||
if (m.IsSuperChat) return $"{m.AuthorName} pledged {m.SuperChatCurrency}{m.SuperChatAmount:0.00} in a Super Chat";
|
||||
if (m.IsMember) return $"{m.AuthorName} just became a member ({m.MembershipLevel})";
|
||||
return $"{m.AuthorName} subscribed";
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,43 @@ public sealed class VideoFrame
|
||||
public byte[] BgraPixels { get; }
|
||||
public int Stride => Width * 4;
|
||||
|
||||
/// <summary>Producer frame epoch — a monotonically increasing number stamped by
|
||||
/// sources that RECYCLE their pixel buffers (the screen-capture ring, take-11 spike
|
||||
/// fix). Consumers that key caches on buffer identity (the compositor's paste cache)
|
||||
/// MUST include it, or a recycled array false-hits with stale content. Producers
|
||||
/// that hand out fresh arrays per frame leave it 0 — identity alone is then enough.</summary>
|
||||
public long Epoch { get; init; }
|
||||
|
||||
/// <summary>Producer contract: every alpha byte in <see cref="BgraPixels"/> is 255
|
||||
/// (DWM capture surfaces and MediaCapture video carry no alpha — the OS fills 255).
|
||||
/// Lets the compositor take a straight-copy fast path for a full-canvas opaque
|
||||
/// layer instead of 2M per-pixel blends. NEVER set it for paths whose pixels can
|
||||
/// be transparent (static art, web overlays, chat, the social-bar strip).</summary>
|
||||
public bool IsOpaque { get; init; }
|
||||
|
||||
/// <summary>WebSource crop metadata: the alpha bounding box of the widget content
|
||||
/// within the full canvas (X/Y/W/H in pixels). When set, the compositor uses these
|
||||
/// bounds for Fill-style scaling (stretch to cover, no aspect preservation) instead of
|
||||
/// UniformToFill. The transparent margins of the full canvas are preserved so they
|
||||
/// reveal layers beneath — the crop defines the actual content region.</summary>
|
||||
public (int X, int Y, int W, int H)? CropBounds { get; init; }
|
||||
|
||||
/// <summary>Where a SMALLER-than-canvas overlay composites, in master pixels
|
||||
/// (creator ruling 2026-09-26: the alert ticker draws INSIDE the Stream Alerts video
|
||||
/// box, not as a full-width strip pinned to the top edge). <c>null</c> — the default
|
||||
/// — means the canvas origin, which is what every full-canvas overlay wants.
|
||||
/// <para>This is deliberately NOT a full-canvas frame: at 30–60fps a 1920×1080
|
||||
/// overlay is 8.3MB of large-object-heap garbage per tick, and a 10s alert would
|
||||
/// churn ~2.5GB. A box-sized strip is ~370KB, so the position travels on the frame
|
||||
/// instead of in a 1920-wide transparent margin.</para></summary>
|
||||
public (int X, int Y)? Placement { get; init; }
|
||||
|
||||
/// <summary>Composite origin X: <see cref="Placement"/> if set, else 0.</summary>
|
||||
public int OriginX => Placement?.X ?? 0;
|
||||
|
||||
/// <summary>Composite origin Y: <see cref="Placement"/> if set, else 0.</summary>
|
||||
public int OriginY => Placement?.Y ?? 0;
|
||||
|
||||
public VideoFrame(int width, int height, byte[] bgraPixels)
|
||||
{
|
||||
Width = width;
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Runtime.InteropServices.WindowsRuntime;
|
||||
using System.Threading.Tasks;
|
||||
using Windows.Graphics;
|
||||
using Windows.Graphics.Capture;
|
||||
using Windows.Graphics.DirectX;
|
||||
using Windows.Graphics.DirectX.Direct3D11;
|
||||
using Windows.Graphics.Imaging;
|
||||
using ytLive.Helpers;
|
||||
|
||||
namespace ytLive.Services;
|
||||
|
||||
/// <summary>
|
||||
/// The frame-driven web capture for one widget: a GraphicsCaptureItem backed by
|
||||
/// the widget's Windows.UI.Composition visual (see WebView2Manager). Owns the
|
||||
/// item, a free-threaded Direct3D11CaptureFramePool and the capture session;
|
||||
/// frames arrive in step with Chromium's own presentation (~60Hz on a real
|
||||
/// animation), replacing the old CapturePreviewAsync PNG poll whose ~35-165ms
|
||||
/// encode cost capped the recording at ~10Hz and the preview at ~5fps.
|
||||
///
|
||||
/// The capture surface holds a REAL alpha channel (the controller renders onto
|
||||
/// a transparent composition visual), so the GPU→CPU copy uses
|
||||
/// <see cref="BitmapAlphaMode.Straight"/> and the frame is
|
||||
/// <see cref="VideoFrame.IsOpaque"/> = false — the compositor's straight-alpha
|
||||
/// blend math ("web layer transparency", 2026-09-13) requires the readback to
|
||||
/// keep premultiplication OFF. Unlike the screen source there is no master-size
|
||||
/// downscale: the controller renders at the RAW 1920×1080 master canvas, so the
|
||||
/// CPU never holds a larger frame.
|
||||
///
|
||||
/// Per-frame the alpha bounding box of the widget (<see cref="FindContentBounds"/>)
|
||||
/// is measured on this capture worker thread and stamped onto the frame as
|
||||
/// <see cref="VideoFrame.CropBounds"/> — same alpha-bbox contract as the old
|
||||
/// PNG path, but off the UI thread (a 2M-pixel scan per frame there is exactly
|
||||
/// the cost this redesign exists to remove).
|
||||
/// </summary>
|
||||
public sealed class WebCaptureFrameSource : IScreenCaptureSource
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
private GraphicsCaptureItem _item;
|
||||
private Direct3D11CaptureFramePool? _framePool;
|
||||
private GraphicsCaptureSession? _session;
|
||||
private SizeInt32 _poolSize;
|
||||
private bool _started;
|
||||
private bool _framePending;
|
||||
private DateTime _lastErrorLog = DateTime.MinValue;
|
||||
|
||||
// Buffer recycling: identical to the screen source's ring+Epoch contract
|
||||
// (see ScreenCaptureFrameSource) — the compositor's paste cache is keyed on
|
||||
// array identity, so recycled buffers must carry a monotonic Epoch or a
|
||||
// reused array false-hits with stale content.
|
||||
private readonly byte[]?[] _frameRing = new byte[8][];
|
||||
private int _ringNext;
|
||||
private long _epoch;
|
||||
|
||||
private static readonly TimeSpan ErrorLogThrottle = TimeSpan.FromSeconds(5);
|
||||
|
||||
public string Key { get; }
|
||||
public event Action<VideoFrame>? FrameAvailable;
|
||||
|
||||
internal WebCaptureFrameSource(string key, GraphicsCaptureItem item)
|
||||
{
|
||||
Key = key;
|
||||
_item = item;
|
||||
}
|
||||
|
||||
/// <summary>Wraps an item created from a composition visual by the web manager.</summary>
|
||||
public static WebCaptureFrameSource CreateForVisual(string key, GraphicsCaptureItem item)
|
||||
=> new(key, item);
|
||||
|
||||
public Task StartAsync()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_started) return Task.CompletedTask;
|
||||
var item = _item;
|
||||
var device = Direct3D11Helper.CreateDevice();
|
||||
var framePool = Direct3D11CaptureFramePool.CreateFreeThreaded(
|
||||
device, DirectXPixelFormat.B8G8R8A8UIntNormalized, 2, item.Size);
|
||||
_poolSize = item.Size;
|
||||
var session = framePool.CreateCaptureSession(item);
|
||||
framePool.FrameArrived += OnFrameArrived;
|
||||
session.StartCapture();
|
||||
_framePool = framePool;
|
||||
_session = session;
|
||||
_started = true;
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task StopAsync()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_started = false;
|
||||
_framePending = false;
|
||||
if (_framePool != null)
|
||||
_framePool.FrameArrived -= OnFrameArrived;
|
||||
_session?.Dispose();
|
||||
_session = null;
|
||||
_framePool?.Dispose();
|
||||
_framePool = null;
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private void OnFrameArrived(Direct3D11CaptureFramePool sender, object args)
|
||||
{
|
||||
var frame = sender.TryGetNextFrame();
|
||||
if (frame == null) return;
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_started)
|
||||
{
|
||||
frame.Dispose();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (frame.ContentSize.Width != _poolSize.Width || frame.ContentSize.Height != _poolSize.Height)
|
||||
{
|
||||
sender.Recreate(Direct3D11Helper.CreateDevice(),
|
||||
DirectXPixelFormat.B8G8R8A8UIntNormalized, 2, frame.ContentSize);
|
||||
_poolSize = frame.ContentSize;
|
||||
}
|
||||
|
||||
if (_framePending)
|
||||
{
|
||||
frame.Dispose();
|
||||
return;
|
||||
}
|
||||
_framePending = true;
|
||||
_ = ProcessFrameAsync(frame);
|
||||
}
|
||||
|
||||
private async Task ProcessFrameAsync(Direct3D11CaptureFrame frame)
|
||||
{
|
||||
try
|
||||
{
|
||||
using (frame)
|
||||
using (var softwareBitmap = await SoftwareBitmap.CreateCopyFromSurfaceAsync(
|
||||
frame.Surface, BitmapAlphaMode.Straight))
|
||||
{
|
||||
FrameAvailable?.Invoke(CopyToVideoFrame(softwareBitmap));
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
if (now - _lastErrorLog >= ErrorLogThrottle)
|
||||
{
|
||||
_lastErrorLog = now;
|
||||
AppLog.Write($"WebCaptureFrameSource: frame conversion failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_framePending = false;
|
||||
}
|
||||
}
|
||||
|
||||
private byte[] RentRingBuffer(int size)
|
||||
{
|
||||
for (var tries = 0; tries < _frameRing.Length; tries++)
|
||||
{
|
||||
var idx = (_ringNext + tries) % _frameRing.Length;
|
||||
var buf = _frameRing[idx];
|
||||
if (buf is { Length: var len } && len == size)
|
||||
{
|
||||
_ringNext = (idx + 1) % _frameRing.Length;
|
||||
return buf;
|
||||
}
|
||||
}
|
||||
var slot = _ringNext;
|
||||
_ringNext = (slot + 1) % _frameRing.Length;
|
||||
var fresh = new byte[size];
|
||||
_frameRing[slot] = fresh;
|
||||
return fresh;
|
||||
}
|
||||
|
||||
private VideoFrame CopyToVideoFrame(SoftwareBitmap bitmap)
|
||||
{
|
||||
var sw = bitmap.PixelWidth;
|
||||
var sh = bitmap.PixelHeight;
|
||||
using var buffer = bitmap.LockBuffer(BitmapBufferAccessMode.Read);
|
||||
using var reference = buffer.CreateReference();
|
||||
if (!WindowsRuntimeMarshal.TryGetDataUnsafe(reference, out var data, out var capacity))
|
||||
throw new InvalidOperationException("Could not access the frame buffer.");
|
||||
|
||||
var srcStride = sw * 4;
|
||||
var count = (int)Math.Min(capacity, (uint)(sh * srcStride));
|
||||
var pixels = RentRingBuffer(count);
|
||||
Marshal.Copy(data, pixels, 0, pixels.Length);
|
||||
|
||||
var (cropX, cropY, cropW, cropH) = FindContentBounds(pixels, sw, sh, srcStride);
|
||||
return new VideoFrame(sw, sh, pixels)
|
||||
{
|
||||
IsOpaque = false,
|
||||
Epoch = ++_epoch,
|
||||
CropBounds = (cropX, cropY, cropW, cropH),
|
||||
};
|
||||
}
|
||||
|
||||
// The widget's extent is measured from the captured frame itself: the
|
||||
// bounding box of non-transparent pixels (the background is injected
|
||||
// transparent, so the alpha channel IS the widget). Immune to DOM timing
|
||||
// (fonts/images/animations), needs no JS round-trip, and CANNOT break the
|
||||
// render: a widget that fills the canvas gets the full frame; a widget that
|
||||
// floats in transparent margin gets exactly its content box — used for the
|
||||
// preview bitmap / selection. The frame handed to the compositor is always
|
||||
// the FULL canvas, so the page's transparent margins keep revealing the
|
||||
// layers beneath (the "black square over webcam" was the crop fed to the
|
||||
// compositor: UniformToFill zoomed the opaque content box over the element).
|
||||
internal static (int X, int Y, int W, int H) FindContentBounds(byte[] pixels, int pixW, int pixH, int stride)
|
||||
{
|
||||
int minX = pixW, minY = pixH, maxX = -1, maxY = -1;
|
||||
for (var y = 0; y < pixH; y++)
|
||||
{
|
||||
var row = y * stride;
|
||||
for (var x = 0; x < pixW; x++)
|
||||
{
|
||||
if (pixels[row + x * 4 + 3] == 0) continue;
|
||||
if (x < minX) minX = x;
|
||||
if (x > maxX) maxX = x;
|
||||
if (y < minY) minY = y;
|
||||
if (y > maxY) maxY = y;
|
||||
}
|
||||
}
|
||||
if (maxX < minX || maxY < minY) return (0, 0, pixW, pixH);
|
||||
return (minX, minY, maxX - minX + 1, maxY - minY + 1);
|
||||
}
|
||||
}
|
||||
+365
-154
@@ -1,86 +1,93 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Drawing;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Imaging;
|
||||
using System.Windows.Threading;
|
||||
using Microsoft.Web.WebView2.Core;
|
||||
using Microsoft.Web.WebView2.Wpf;
|
||||
using Windows.Graphics.Capture;
|
||||
using Windows.System;
|
||||
using Windows.UI.Composition;
|
||||
using ytLive.Helpers;
|
||||
using ytLive.Models;
|
||||
|
||||
namespace ytLive.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Owns the composition-based web sources (one per widget). A session renders
|
||||
/// its widget through a <see cref="CoreWebView2CompositionController"/> whose
|
||||
/// visual tree lives off-screen on the WPF UI thread (OBS web-source model:
|
||||
/// the page renders at the MASTER CANVAS size 1920×1080, stable, never tracked);
|
||||
/// the controller's root visual is captured by a <see cref="WebCaptureFrameSource"/>
|
||||
/// at Chromium's own presentation cadence. The preview is crop-copied to a shared
|
||||
/// WriteableBitmap on the UI thread (dispatcher-coalesced latest-wins, mirror of
|
||||
/// ScreenCaptureManager); the FramePump reads <see cref="GetLatestFrame"/>.
|
||||
///
|
||||
/// The composition stack needs a DispatcherQueue on this thread (CsWinRT does not
|
||||
/// project <c>CreateOnCurrentThread</c> on the 19041 SDK — only the dedicated
|
||||
/// variant — so it is installed via a CoreMessaging P/Invoke, the documented
|
||||
/// fallback the WebView2CompositionControl / webview_windows embedders use).
|
||||
/// Everything composition-side therefore runs on the WPF UI thread; only the
|
||||
/// frame conversion and alpha-bbox scan run on the capture worker.
|
||||
///
|
||||
/// Seam: an internal <c>Func<string, IScreenCaptureSource></c> source
|
||||
/// factory substitutes the whole WebView2+item creation in tests — the manager
|
||||
/// then owns the session/coalesce/crop/preview plumbing exactly as in production.
|
||||
/// </summary>
|
||||
public sealed class WebView2Manager : IDisposable
|
||||
{
|
||||
private readonly Panel _hostPanel;
|
||||
private readonly Dispatcher _dispatcher;
|
||||
private readonly Func<string, IScreenCaptureSource>? _sessionSourceFactory;
|
||||
private readonly Dictionary<string, WebSourceSession> _sessions = new();
|
||||
private Task<CoreWebView2Environment>? _environmentTask;
|
||||
private DispatcherQueueController? _dispatcherQueueController;
|
||||
private Windows.UI.Composition.Compositor? _compositor;
|
||||
|
||||
public event Action<string, WriteableBitmap>? PreviewBitmapChanged;
|
||||
|
||||
private sealed class WebSourceSession
|
||||
{
|
||||
public required WebView2 Control { get; init; }
|
||||
public required DispatcherTimer CaptureTimer { get; init; }
|
||||
public required string Key { get; init; }
|
||||
public CoreWebView2? Web;
|
||||
public CoreWebView2CompositionController? Controller;
|
||||
public IScreenCaptureSource? Source;
|
||||
public Action<VideoFrame>? FrameHandler;
|
||||
public VideoFrame? LatestFrame;
|
||||
public WriteableBitmap? PreviewBitmap;
|
||||
|
||||
// Buffer reuse (2026-09-04 lesson): the preview crop copy reuses one
|
||||
// scratch array while the widget's extent holds — no per-frame LOH mint.
|
||||
public byte[]? CropScratch;
|
||||
public bool Disposed;
|
||||
public bool Initialized;
|
||||
public bool FramePending;
|
||||
public bool FirstCaptureLogged;
|
||||
public int WidgetLogsRemaining;
|
||||
}
|
||||
|
||||
public WebView2Manager(Panel hostPanel, Dispatcher dispatcher)
|
||||
public WebView2Manager(Dispatcher dispatcher)
|
||||
: this(dispatcher, null)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>Test seam: the source factory replaces WebView2+composition
|
||||
/// creation, so the session/coalesce/crop/preview plumbing is testable
|
||||
/// without the runtime.</summary>
|
||||
internal WebView2Manager(Dispatcher dispatcher, Func<string, IScreenCaptureSource>? sessionSourceFactory)
|
||||
{
|
||||
_hostPanel = hostPanel;
|
||||
_dispatcher = dispatcher;
|
||||
_sessionSourceFactory = sessionSourceFactory;
|
||||
}
|
||||
|
||||
public void Register(Source source)
|
||||
{
|
||||
if (_sessions.ContainsKey(source.Id)) return;
|
||||
|
||||
// OBS model: the page renders at the MASTER CANVAS size (1920×1080),
|
||||
// stable, never tracked. Full-bleed widgets (designed for the canvas)
|
||||
// render fully instead of being clipped to a small box-sized viewport;
|
||||
// dragging the box never reflows the page. The capture is tightly
|
||||
// cropped to the widget's ALPHA bounding box (FindContentBounds) and
|
||||
// the display (Stretch=Fill) maps it flush under the box, anchored at
|
||||
// (0,0) — no DOM query, so no timing fragility; worst case is the
|
||||
// full frame, which is the confirmed-good rendering.
|
||||
var webView = new WebView2
|
||||
{
|
||||
Width = 1920,
|
||||
Height = 1080,
|
||||
DefaultBackgroundColor = System.Drawing.Color.Transparent,
|
||||
Visibility = System.Windows.Visibility.Visible,
|
||||
};
|
||||
Canvas.SetLeft(webView, -5000);
|
||||
Canvas.SetTop(webView, -5000);
|
||||
|
||||
_hostPanel.Children.Add(webView);
|
||||
|
||||
var timer = new DispatcherTimer
|
||||
{
|
||||
Interval = TimeSpan.FromMilliseconds(100),
|
||||
};
|
||||
|
||||
var session = new WebSourceSession
|
||||
{
|
||||
Control = webView,
|
||||
CaptureTimer = timer,
|
||||
};
|
||||
|
||||
_sessions[source.Id] = session;
|
||||
|
||||
timer.Tick += (_, _) =>
|
||||
{
|
||||
if (!session.Disposed && session.Initialized)
|
||||
_ = CaptureFrame(source.Id);
|
||||
};
|
||||
|
||||
_ = InitializeAsync(source, session);
|
||||
_sessions[source.Id] = new WebSourceSession { Key = source.Id };
|
||||
_ = InitializeAsync(source, _sessions[source.Id]);
|
||||
}
|
||||
|
||||
public void Unregister(string sourceId) => RemoveSession(sourceId);
|
||||
@@ -93,7 +100,7 @@ public sealed class WebView2Manager : IDisposable
|
||||
if (string.IsNullOrEmpty(source.WebUri))
|
||||
{
|
||||
session.LatestFrame = null;
|
||||
session.Control.CoreWebView2?.NavigateToString(
|
||||
session.Web?.NavigateToString(
|
||||
"<html><body style='background:transparent;margin:0'></body></html>");
|
||||
return;
|
||||
}
|
||||
@@ -106,42 +113,116 @@ public sealed class WebView2Manager : IDisposable
|
||||
return _sessions.TryGetValue(sourceId, out var session) ? session.LatestFrame : null;
|
||||
}
|
||||
|
||||
// Injected BEFORE the document is parsed (AddScriptToExecuteOnDocumentCreatedAsync) so
|
||||
// the widget page's own CSS/scripts cannot repaint html/body opaque — the reason the
|
||||
// late NavigationStarting/NavigationCompleted ExecuteScriptAsync injection lost the
|
||||
// fight (page styles ran first). A transparent capture REQUIRES the transparency in
|
||||
// place before the page paints, on the composition surface too.
|
||||
//
|
||||
// The OLD inline `element.style.background='transparent'` form lost to any page CSS:
|
||||
// a widget that paints a background-COLOR on a container (or html/body with stronger
|
||||
// specificity) after connect re-opaques the capture → the "black opaque box" in the
|
||||
// recording while the early frames were transparent. This is a solved problem in the
|
||||
// overlay ecosystem — OBS browser sources use a Custom CSS override and the decade-
|
||||
// validated formula for arbitrary pages is a `!important` background-color wipe
|
||||
// (OBS Forums 2016 "body { background-color: rgba(0,0,0,0) !important }",
|
||||
// https://obsproject.com/forum/threads/translucent-transparent-browser-source.59549/,
|
||||
// and the div-level variant for stubborn widgets, woahtech.com OBS custom-CSS guide).
|
||||
// A `<style>` node injected before parse, with `!important`, outranks every page rule
|
||||
// (only an author !important beats a later document-order !important; ours runs first).
|
||||
// Only background-COLOR was wiped at first — the 15:34 take showed the widget's own
|
||||
// full-canvas backdrop surviving as the box's black void: that paint is a CSS
|
||||
// background-IMAGE (gradient/backdrop), and the OBS fix for the same symptom is
|
||||
// `background: none !important` / background-image none, not color alone
|
||||
// (obsproject/obs-studio#6659 — "set the CSS for html and body to background: none
|
||||
// !important"). Wipe background-image too; HTML overlay art (the widget's content) is
|
||||
// <img>/DOM/CSS shapes and survives — only container decorations are removed, which is
|
||||
// OBS-browser-source semantics for a transparent overlay.
|
||||
internal const string TransparentBackgroundScript =
|
||||
"(function(){" +
|
||||
"if(document.getElementById('ytl-transparent-bg'))return;" +
|
||||
"var s=document.createElement('style');" +
|
||||
"s.id='ytl-transparent-bg';" +
|
||||
"s.appendChild(document.createTextNode(" +
|
||||
"'html,body{background-color:transparent!important;background-image:none!important;margin:0!important;overflow:hidden!important;}'" +
|
||||
"'html,body,html *{background-color:transparent!important;background-image:none!important;}'" +
|
||||
"));" +
|
||||
"(document.head||document.documentElement).appendChild(s);" +
|
||||
"})();";
|
||||
|
||||
private async Task InitializeAsync(Source source, WebSourceSession session)
|
||||
{
|
||||
try
|
||||
{
|
||||
await session.Control.EnsureCoreWebView2Async();
|
||||
|
||||
var cws = session.Control.CoreWebView2!;
|
||||
|
||||
cws.NavigationStarting += (_, _) =>
|
||||
IScreenCaptureSource sourceImpl;
|
||||
if (_sessionSourceFactory != null)
|
||||
{
|
||||
_ = cws.ExecuteScriptAsync(
|
||||
"document.documentElement.style.background='transparent';" +
|
||||
"document.documentElement.style.overflow='hidden';" +
|
||||
"document.body.style.background='transparent';" +
|
||||
"document.body.style.overflow='hidden';");
|
||||
};
|
||||
|
||||
cws.NavigationCompleted += (_, _) =>
|
||||
// Test path: the factory hands over a fully-configured source; the
|
||||
// branch has no awaits, so Register() completes the session inline.
|
||||
sourceImpl = _sessionSourceFactory(source.Id);
|
||||
}
|
||||
else
|
||||
{
|
||||
_ = cws.ExecuteScriptAsync(
|
||||
"document.documentElement.style.background='transparent';" +
|
||||
"document.documentElement.style.overflow='hidden';" +
|
||||
"document.body.style.background='transparent';" +
|
||||
"document.body.style.overflow='hidden';");
|
||||
_ = CaptureFrame(source.Id);
|
||||
};
|
||||
var controller = await CreateControllerAsync();
|
||||
if (controller == null)
|
||||
{
|
||||
RemoveSession(source.Id);
|
||||
return;
|
||||
}
|
||||
|
||||
session.Controller = controller;
|
||||
session.Web = controller.CoreWebView2;
|
||||
if (session.Web == null)
|
||||
{
|
||||
RemoveSession(source.Id);
|
||||
return;
|
||||
}
|
||||
|
||||
// Pre-page hook: runs after the global object exists but BEFORE the document
|
||||
// parses / any page script runs — must complete before navigating away from
|
||||
// about:blank for it to apply to the widget document.
|
||||
await session.Web.AddScriptToExecuteOnDocumentCreatedAsync(TransparentBackgroundScript);
|
||||
|
||||
session.Web.NavigationStarting += (_, _) =>
|
||||
{
|
||||
_ = session.Web?.ExecuteScriptAsync(TransparentBackgroundScript);
|
||||
};
|
||||
|
||||
session.Web.NavigationCompleted += (_, e) =>
|
||||
{
|
||||
_ = session.Web?.ExecuteScriptAsync(TransparentBackgroundScript);
|
||||
if (e.IsSuccess && session.Web?.Source is { } src && src != "about:blank")
|
||||
{
|
||||
// Log-only diagnostic on the REAL widget document (a few post-paint
|
||||
// frames so styles/backgrounds settle). The first-capture dump only
|
||||
// ever saw about:blank; the old PNG widget dumps are gone with the
|
||||
// PNG path — alpha stats now ride the frame-driven channel.
|
||||
session.WidgetLogsRemaining = 3;
|
||||
var rearm = session;
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(30));
|
||||
if (!rearm.Disposed) rearm.WidgetLogsRemaining = 3;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
session.Web.NavigateToString(
|
||||
"<html><body style='background:transparent;margin:0'></body></html>");
|
||||
|
||||
if (!string.IsNullOrEmpty(source.WebUri))
|
||||
Navigate(source.WebUri, session);
|
||||
|
||||
session.Initialized = true;
|
||||
|
||||
sourceImpl = new WebCaptureFrameSource(source.Id, CreateCaptureItem(controller));
|
||||
}
|
||||
|
||||
session.Source = sourceImpl;
|
||||
session.FrameHandler = frame => OnFrameAvailable(session, frame);
|
||||
sourceImpl.FrameAvailable += session.FrameHandler;
|
||||
await sourceImpl.StartAsync();
|
||||
session.Initialized = true;
|
||||
|
||||
session.Control.CoreWebView2.NavigateToString(
|
||||
"<html><body style='background:transparent;margin:0'></body></html>");
|
||||
|
||||
if (!string.IsNullOrEmpty(source.WebUri))
|
||||
Navigate(source.WebUri, session);
|
||||
|
||||
session.CaptureTimer.Start();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -150,116 +231,246 @@ public sealed class WebView2Manager : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
// The environment MUST exist before the controller is created — it's created once
|
||||
// and shared by every session (one browser process). These flags (the
|
||||
// Electron-class embedder answer to Chromium throttling hidden pages — the moment
|
||||
// the app window is unfocused or covered, rAF parks and timers clamp to ~1s) keep
|
||||
// the renderer at full rate: --disable-backgrounding-occluded-windows,
|
||||
// --disable-renderer-backgrounding, and disable the native-window occlusion
|
||||
// detector that marks the page hidden.
|
||||
private Task<CoreWebView2Environment> GetEnvironmentAsync()
|
||||
{
|
||||
if (_environmentTask != null) return _environmentTask;
|
||||
var options = new CoreWebView2EnvironmentOptions
|
||||
{
|
||||
AdditionalBrowserArguments =
|
||||
"--disable-backgrounding-occluded-windows --disable-renderer-backgrounding --disable-features=CalculateNativeWinOcclusion",
|
||||
};
|
||||
// Per-instance user data folder in DEBUG: Chromium locks it exclusively, so
|
||||
// without this the second dev instance of the same exe fails to start at all.
|
||||
// Release passes null and WebView2 uses its default.
|
||||
var userData = InstanceProfile.WebViewDataFolder;
|
||||
if (userData != null) Directory.CreateDirectory(userData);
|
||||
return _environmentTask = CoreWebView2Environment.CreateAsync(null, userData, options);
|
||||
}
|
||||
|
||||
/// <summary>Creates the composition controller off-screen under the main
|
||||
/// window (the parent HWND is required, the visual never mounts a window
|
||||
/// target). Set up is copied from the WebView2CompositionControl /
|
||||
/// webview_windows embedders: raw-pixel bounds at the master canvas,
|
||||
/// fixed rasterization scale, transparent default background.</summary>
|
||||
private async Task<CoreWebView2CompositionController?> CreateControllerAsync()
|
||||
{
|
||||
var mainWindow = Application.Current?.MainWindow;
|
||||
var hwnd = mainWindow == null
|
||||
? IntPtr.Zero
|
||||
: new System.Windows.Interop.WindowInteropHelper(mainWindow).Handle;
|
||||
if (hwnd == IntPtr.Zero)
|
||||
{
|
||||
AppLog.Write("WebView2Manager: main window handle unavailable — web source not created");
|
||||
return null;
|
||||
}
|
||||
|
||||
var env = await GetEnvironmentAsync();
|
||||
if (_compositor == null) _compositor = CreateCompositorForCurrentThread();
|
||||
|
||||
var controller = await env.CreateCoreWebView2CompositionControllerAsync(hwnd);
|
||||
controller.Bounds = new Rectangle(0, 0, 1920, 1080);
|
||||
controller.BoundsMode = CoreWebView2BoundsMode.UseRawPixels;
|
||||
controller.ShouldDetectMonitorScaleChanges = false;
|
||||
controller.RasterizationScale = 1.0;
|
||||
controller.IsVisible = true;
|
||||
controller.DefaultBackgroundColor = System.Drawing.Color.Transparent;
|
||||
return controller;
|
||||
}
|
||||
|
||||
private void Navigate(string url, WebSourceSession session)
|
||||
{
|
||||
try { session.Control.CoreWebView2?.Navigate(url); }
|
||||
try { session.Web?.Navigate(url); }
|
||||
catch (Exception ex) { AppLog.Write($"WebView2Manager: navigate failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
// The widget's extent is measured from the captured frame itself: the
|
||||
// bounding box of non-transparent pixels (the background is injected
|
||||
// transparent, so the alpha channel IS the widget). This is immune to
|
||||
// DOM timing (fonts/images/animations), needs no JS round-trip, and
|
||||
// CANNOT break rendering: if the widget fills the canvas the box equals
|
||||
// the full frame (identical to the confirmed-good uncropped display); if
|
||||
// it floats in transparent margin, the crop maps it flush under the box.
|
||||
internal static (int X, int Y, int W, int H) FindContentBounds(byte[] pixels, int pixW, int pixH, int stride)
|
||||
// The visual tree webview_windows wires (offscreen mode): a root container
|
||||
// visual sized to the master canvas (the captured subject — the Flutter
|
||||
// plugin calls GraphicsCaptureItem.CreateFromVisual on this root), with the
|
||||
// controller's root visual target set to a child that fills the root via
|
||||
// RelativeSizeAdjustment. BoundsMode raw pixels + RasterizationScale 1.0 keep
|
||||
// the capture at exactly 1920×1080 regardless of monitor scale.
|
||||
private GraphicsCaptureItem CreateCaptureItem(CoreWebView2CompositionController controller)
|
||||
{
|
||||
int minX = pixW, minY = pixH, maxX = -1, maxY = -1;
|
||||
for (var y = 0; y < pixH; y++)
|
||||
{
|
||||
var row = y * stride;
|
||||
for (var x = 0; x < pixW; x++)
|
||||
{
|
||||
if (pixels[row + x * 4 + 3] == 0) continue;
|
||||
if (x < minX) minX = x;
|
||||
if (x > maxX) maxX = x;
|
||||
if (y < minY) minY = y;
|
||||
if (y > maxY) maxY = y;
|
||||
}
|
||||
}
|
||||
if (maxX < minX || maxY < minY) return (0, 0, pixW, pixH);
|
||||
return (minX, minY, maxX - minX + 1, maxY - minY + 1);
|
||||
var compositor = _compositor!;
|
||||
var root = compositor.CreateContainerVisual();
|
||||
root.Size = new System.Numerics.Vector2(1920f, 1080f);
|
||||
root.IsVisible = true;
|
||||
|
||||
var webViewVisual = compositor.CreateContainerVisual();
|
||||
webViewVisual.RelativeSizeAdjustment = new System.Numerics.Vector2(1f, 1f);
|
||||
root.Children.InsertAtTop(webViewVisual);
|
||||
|
||||
controller.RootVisualTarget = webViewVisual;
|
||||
return GraphicsCaptureItem.CreateFromVisual(root);
|
||||
}
|
||||
|
||||
private async Task CaptureFrame(string sourceId)
|
||||
// CsWinRT does not project DispatcherQueueController.CreateOnCurrentThread
|
||||
// on the 19041 SDK (only CreateOnDedicatedThread [CS0117, verified by probe]);
|
||||
// the documented fallback — "how to create a DispatcherQueue on the current
|
||||
// thread" (learn.microsoft.com CoreMessaging docs) — is CreateDispatcherQueueController
|
||||
// from CoreMessaging.dll, wrapped via FromAbi (the CaptureInterop pattern). One
|
||||
// per UI thread, then new Compositor() (which requires the DQ) works here.
|
||||
private Windows.UI.Composition.Compositor CreateCompositorForCurrentThread()
|
||||
{
|
||||
if (!_sessions.TryGetValue(sourceId, out var session) || session.Disposed) return;
|
||||
|
||||
try
|
||||
var options = new DispatcherQueueOptions
|
||||
{
|
||||
var webView = session.Control;
|
||||
if (webView.CoreWebView2 == null) return;
|
||||
DwSize = Marshal.SizeOf<DispatcherQueueOptions>(),
|
||||
ThreadType = 2, // DQTYPE_THREAD_CURRENT
|
||||
ApartmentType = 2, // DQTAT_COM_STA — the WPF UI thread is STA
|
||||
};
|
||||
var hr = CreateDispatcherQueueController(options, out var controllerPtr);
|
||||
if (hr != 0)
|
||||
throw Marshal.GetExceptionForHR(hr)!;
|
||||
_dispatcherQueueController = DispatcherQueueController.FromAbi(controllerPtr);
|
||||
return new Windows.UI.Composition.Compositor();
|
||||
}
|
||||
|
||||
using var ms = new MemoryStream();
|
||||
await webView.CoreWebView2.CapturePreviewAsync(
|
||||
CoreWebView2CapturePreviewImageFormat.Png, ms);
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct DispatcherQueueOptions
|
||||
{
|
||||
public int DwSize;
|
||||
public int ThreadType;
|
||||
public int ApartmentType;
|
||||
}
|
||||
|
||||
ms.Position = 0;
|
||||
[DllImport("CoreMessaging.dll")]
|
||||
private static extern int CreateDispatcherQueueController(
|
||||
DispatcherQueueOptions options, out IntPtr dispatcherQueueController);
|
||||
|
||||
var bitmap = new BitmapImage();
|
||||
bitmap.BeginInit();
|
||||
bitmap.StreamSource = ms;
|
||||
bitmap.CacheOption = BitmapCacheOption.OnLoad;
|
||||
bitmap.EndInit();
|
||||
bitmap.Freeze();
|
||||
private void OnFrameAvailable(WebSourceSession session, VideoFrame frame)
|
||||
{
|
||||
if (session.Disposed) return;
|
||||
session.LatestFrame = frame;
|
||||
|
||||
var formatted = new FormatConvertedBitmap(bitmap,
|
||||
PixelFormats.Bgra32, null, 0);
|
||||
if (_dispatcher.CheckAccess())
|
||||
{
|
||||
UpdatePreview(session);
|
||||
return;
|
||||
}
|
||||
if (session.FramePending) return;
|
||||
session.FramePending = true;
|
||||
_dispatcher.BeginInvoke(() =>
|
||||
{
|
||||
session.FramePending = false;
|
||||
if (!session.Disposed)
|
||||
UpdatePreview(session);
|
||||
}, DispatcherPriority.Render);
|
||||
}
|
||||
|
||||
var pixW = formatted.PixelWidth;
|
||||
var pixH = formatted.PixelHeight;
|
||||
private void UpdatePreview(WebSourceSession session)
|
||||
{
|
||||
var frame = session.LatestFrame;
|
||||
if (frame == null) return;
|
||||
|
||||
if (pixW < 1 || pixH < 1) return;
|
||||
var (cropX, cropY, cropW, cropH) = frame.CropBounds ?? (0, 0, frame.Width, frame.Height);
|
||||
if (cropW < 1 || cropH < 1)
|
||||
{
|
||||
cropX = 0; cropY = 0;
|
||||
cropW = frame.Width; cropH = frame.Height;
|
||||
}
|
||||
|
||||
var pixels = new byte[pixW * pixH * 4];
|
||||
formatted.CopyPixels(pixels, pixW * 4, 0);
|
||||
// The preview is sized to the widget's alpha bounding box (the compositor
|
||||
// gets the FULL canvas + CropBounds; the crop is only for the preview).
|
||||
// A fresh box (widget resizes / first content paints) mints a new bitmap
|
||||
// once — WPF re-renders a reused mutable bitmap after WritePixels, so
|
||||
// later frames only re-copy into the same one.
|
||||
if (session.PreviewBitmap == null
|
||||
|| session.PreviewBitmap.PixelWidth != cropW
|
||||
|| session.PreviewBitmap.PixelHeight != cropH)
|
||||
{
|
||||
session.PreviewBitmap = new WriteableBitmap(cropW, cropH, 96, 96, PixelFormats.Bgra32, null);
|
||||
PreviewBitmapChanged?.Invoke(session.Key, session.PreviewBitmap);
|
||||
}
|
||||
|
||||
var stride = pixW * 4;
|
||||
var need = cropW * cropH * 4;
|
||||
var scratch = session.CropScratch is { } s && s.Length >= need
|
||||
? s
|
||||
: (session.CropScratch = new byte[need]);
|
||||
var stride = frame.Stride;
|
||||
if (cropW == frame.Width && cropH == frame.Height && cropX == 0 && cropY == 0)
|
||||
Buffer.BlockCopy(frame.BgraPixels, 0, scratch, 0, need);
|
||||
else
|
||||
for (var r = 0; r < cropH; r++)
|
||||
Buffer.BlockCopy(frame.BgraPixels, (cropY + r) * stride + cropX * 4, scratch, r * cropW * 4, cropW * 4);
|
||||
|
||||
var (cropX, cropY, cropW, cropH) = FindContentBounds(pixels, pixW, pixH, stride);
|
||||
session.PreviewBitmap.WritePixels(new Int32Rect(0, 0, cropW, cropH), scratch, cropW * 4, 0);
|
||||
|
||||
var outPixels = pixels;
|
||||
if (cropW != pixW || cropH != pixH || cropX != 0 || cropY != 0)
|
||||
// alpha[min/max/mean/zero%] — the one number that decides whether a widget's
|
||||
// capture really has transparent margins (0% = opaque, ~100% if the page
|
||||
// painted transparent-only). Sampled every 16th pixel like the original log.
|
||||
if (!session.FirstCaptureLogged)
|
||||
{
|
||||
session.FirstCaptureLogged = true;
|
||||
AppLog.Write(
|
||||
$"WebView2Manager: first capture for '{session.Key}' {frame.Width}x{frame.Height} " +
|
||||
$"{AlphaStats(frame.BgraPixels, frame.Width, frame.Height, stride)} " +
|
||||
$"contentBounds=({cropX},{cropY},{cropW},{cropH})");
|
||||
}
|
||||
|
||||
if (session.WidgetLogsRemaining > 0)
|
||||
{
|
||||
var dumpNo = 3 - session.WidgetLogsRemaining + 1;
|
||||
session.WidgetLogsRemaining--;
|
||||
AppLog.Write(
|
||||
$"WebView2Manager: widget capture [{dumpNo}/3] for '{session.Key}' {frame.Width}x{frame.Height} " +
|
||||
$"{AlphaStats(frame.BgraPixels, frame.Width, frame.Height, stride)} " +
|
||||
$"contentBounds=({cropX},{cropY},{cropW},{cropH})");
|
||||
}
|
||||
}
|
||||
|
||||
private static string AlphaStats(byte[] pixels, int pixW, int pixH, int stride)
|
||||
{
|
||||
int minA = 255, maxA = 0;
|
||||
long sumA = 0, nA = 0, zeroA = 0;
|
||||
for (var y = 0; y < pixH; y += 16)
|
||||
{
|
||||
var row = y * stride;
|
||||
for (var x = 0; x < pixW; x += 16)
|
||||
{
|
||||
var dstStride = cropW * 4;
|
||||
outPixels = new byte[dstStride * cropH];
|
||||
for (var r = 0; r < cropH; r++)
|
||||
Buffer.BlockCopy(pixels, (cropY + r) * stride + cropX * 4, outPixels, r * dstStride, dstStride);
|
||||
var a = pixels[row + x * 4 + 3];
|
||||
if (a < minA) minA = a;
|
||||
if (a > maxA) maxA = a;
|
||||
sumA += a;
|
||||
if (a == 0) zeroA++;
|
||||
nA++;
|
||||
}
|
||||
|
||||
var wb = new WriteableBitmap(cropW, cropH, 96, 96, PixelFormats.Bgra32, null);
|
||||
wb.WritePixels(new Int32Rect(0, 0, cropW, cropH), outPixels, cropW * 4, 0);
|
||||
wb.Freeze();
|
||||
|
||||
session.LatestFrame = new VideoFrame(cropW, cropH, outPixels);
|
||||
|
||||
_ = _dispatcher.BeginInvoke(() =>
|
||||
{
|
||||
PreviewBitmapChanged?.Invoke(sourceId, wb);
|
||||
});
|
||||
}
|
||||
catch (ObjectDisposedException) { }
|
||||
catch (InvalidOperationException) { }
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLog.Write($"WebView2Manager: capture failed for '{sourceId}': {ex.Message}");
|
||||
}
|
||||
return $"alpha[min={minA},max={maxA},mean={sumA / (double)nA:F1},zero={100.0 * zeroA / nA:F1}%]";
|
||||
}
|
||||
|
||||
private void RemoveSession(string sourceId)
|
||||
{
|
||||
if (!_sessions.TryGetValue(sourceId, out var session)) return;
|
||||
session.Disposed = true;
|
||||
session.CaptureTimer.Stop();
|
||||
_hostPanel.Children.Remove(session.Control);
|
||||
try { session.Control.Dispose(); } catch { }
|
||||
|
||||
var src = session.Source;
|
||||
if (src != null)
|
||||
{
|
||||
if (session.FrameHandler != null)
|
||||
src.FrameAvailable -= session.FrameHandler;
|
||||
_ = SafeStopAsync(src);
|
||||
}
|
||||
try { session.Controller?.Close(); } catch { }
|
||||
session.Web = null;
|
||||
_sessions.Remove(sourceId);
|
||||
}
|
||||
|
||||
private static async Task SafeStopAsync(IScreenCaptureSource source)
|
||||
{
|
||||
try { await source.StopAsync(); }
|
||||
catch (Exception ex) { AppLog.Write($"WebView2Manager: stopping web capture failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var id in _sessions.Keys.ToList())
|
||||
RemoveSession(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using ytLive.Models;
|
||||
|
||||
namespace ytLive.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Win32 implementation of <see cref="IWindowEnumerator"/>. Enumerates visible
|
||||
/// top-level windows for the "Capture Window…" submenu, mirroring the style of
|
||||
/// <see cref="Win32FullScreenDetector"/>: visible, non-cloaked, zero-rect
|
||||
/// excluded, and windows belonging to this process excluded (the app must never
|
||||
/// offer itself for pinning). Ordered by process name, then title.
|
||||
/// </summary>
|
||||
public sealed class Win32WindowEnumerator : IWindowEnumerator
|
||||
{
|
||||
private static readonly IntPtr OwnProcessId = new(Environment.ProcessId);
|
||||
|
||||
public IReadOnlyList<WindowInfo> Enumerate()
|
||||
{
|
||||
var results = new List<WindowInfo>();
|
||||
EnumWindows((hwnd, _) =>
|
||||
{
|
||||
if (TryDescribe(hwnd, out var info))
|
||||
results.Add(info);
|
||||
return true; // keep enumerating
|
||||
}, IntPtr.Zero);
|
||||
|
||||
results.Sort(static (a, b) =>
|
||||
{
|
||||
var byProcess = string.Compare(a.ProcessName, b.ProcessName, StringComparison.OrdinalIgnoreCase);
|
||||
return byProcess != 0 ? byProcess
|
||||
: string.Compare(a.Title, b.Title, StringComparison.OrdinalIgnoreCase);
|
||||
});
|
||||
return results;
|
||||
}
|
||||
|
||||
private static bool TryDescribe(IntPtr hwnd, out WindowInfo info)
|
||||
{
|
||||
info = null!;
|
||||
if (GetWindowThreadProcessId(hwnd, out var pid) == 0) return false;
|
||||
if (pid == 0 || new IntPtr((long)pid) == OwnProcessId) return false;
|
||||
if (!IsWindowVisible(hwnd)) return false;
|
||||
if (IsCloaked(hwnd)) return false;
|
||||
if (!TryGetRect(hwnd, out var rect) || rect.Right <= rect.Left || rect.Bottom <= rect.Top) return false;
|
||||
|
||||
var title = GetTitle(hwnd);
|
||||
if (string.IsNullOrWhiteSpace(title)) return false;
|
||||
|
||||
var processName = ProcessName((int)pid);
|
||||
if (string.IsNullOrWhiteSpace(processName)) return false;
|
||||
|
||||
info = new WindowInfo(title, processName, hwnd.ToInt64(), (int)pid);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string ProcessName(int pid)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var process = Process.GetProcessById(pid);
|
||||
return process.ProcessName;
|
||||
}
|
||||
catch { return string.Empty; }
|
||||
}
|
||||
|
||||
private static string GetTitle(IntPtr hwnd)
|
||||
{
|
||||
var length = GetWindowTextLength(hwnd);
|
||||
if (length <= 0) return string.Empty;
|
||||
var sb = new StringBuilder(length + 1);
|
||||
GetWindowText(hwnd, sb, sb.Capacity);
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static bool IsCloaked(IntPtr hwnd)
|
||||
{
|
||||
const int DWMWA_CLOAKED = 14;
|
||||
var cloaked = 0;
|
||||
return DwmGetWindowAttribute(hwnd, DWMWA_CLOAKED, out cloaked, sizeof(int)) == 0 && cloaked != 0;
|
||||
}
|
||||
|
||||
private static bool TryGetRect(IntPtr hwnd, out Rect rect)
|
||||
{
|
||||
rect = default;
|
||||
return GetWindowRect(hwnd, out rect);
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct Rect
|
||||
{
|
||||
public int Left;
|
||||
public int Top;
|
||||
public int Right;
|
||||
public int Bottom;
|
||||
}
|
||||
|
||||
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", CharSet = CharSet.Unicode)]
|
||||
private static extern int GetWindowText(IntPtr hWnd, StringBuilder lpString, int nMaxCount);
|
||||
|
||||
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
|
||||
private static extern int GetWindowTextLength(IntPtr hWnd);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern bool GetWindowRect(IntPtr hWnd, out Rect lpRect);
|
||||
|
||||
[DllImport("dwmapi.dll")]
|
||||
private static extern int DwmGetWindowAttribute(IntPtr hwnd, int dwAttribute, out int pvAttribute, int cbAttribute);
|
||||
}
|
||||
+196
-69
@@ -6,16 +6,22 @@ namespace ytLive.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Polls YouTube Live Chat API for messages.
|
||||
/// YouTube doesn't have WebSocket for chat — polling is the only option.
|
||||
/// YouTube doesn't have WebSocket for chat — polling is the only option. The
|
||||
/// cadence follows the server's <c>pollingIntervalMillis</c> (the streamList
|
||||
/// connection semantics: reconnect with nextPageToken on that cadence, never
|
||||
/// faster than the refresh rate — YouTube rejects requests sent too quickly).
|
||||
/// The chat feed IS YouTube's event feed: Super Chats, stickers, memberships,
|
||||
/// gifts and milestones arrive here as typed messages (see <see cref="ChatEventKind"/>).
|
||||
/// </summary>
|
||||
public class YouTubeChatService : IDisposable
|
||||
{
|
||||
private readonly YouTubeAuthService _auth;
|
||||
private readonly HttpClient _http = new();
|
||||
private readonly HttpClient _http;
|
||||
private Timer? _pollTimer;
|
||||
private string? _nextPageToken;
|
||||
private string? _liveChatId;
|
||||
private bool _isRunning;
|
||||
private int _intervalMs = 2000;
|
||||
|
||||
public event Action<ChatMessage>? MessageReceived;
|
||||
|
||||
@@ -24,9 +30,10 @@ public class YouTubeChatService : IDisposable
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
public YouTubeChatService(YouTubeAuthService auth)
|
||||
public YouTubeChatService(YouTubeAuthService auth, HttpClient? http = null)
|
||||
{
|
||||
_auth = auth;
|
||||
_http = http ?? new HttpClient();
|
||||
}
|
||||
|
||||
public void Start(string liveChatId, int pollIntervalMs = 2000)
|
||||
@@ -34,7 +41,9 @@ public class YouTubeChatService : IDisposable
|
||||
_liveChatId = liveChatId;
|
||||
_isRunning = true;
|
||||
_nextPageToken = null;
|
||||
_pollTimer = new(async _ => await Poll(), null, 0, pollIntervalMs);
|
||||
_intervalMs = Math.Clamp(pollIntervalMs, 1000, 6000);
|
||||
_pollTimer = new Timer(async _ => await Poll(), null, Timeout.Infinite, Timeout.Infinite);
|
||||
_pollTimer.Change(0, Timeout.Infinite);
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
@@ -44,16 +53,24 @@ public class YouTubeChatService : IDisposable
|
||||
_pollTimer = null;
|
||||
}
|
||||
|
||||
/// <summary>TASK 41: injects a synthetic message through the exact seam the real
|
||||
/// poll uses (MessageReceived), so a simulated member / SuperChat / subscriber
|
||||
/// event renders through the whole overlay pipeline. YouTube's insert API only
|
||||
/// creates text messages — these events are local-only by design and never
|
||||
/// reach YouTube (marked <see cref="ChatMessage.IsSimulated"/>).</summary>
|
||||
public void InjectSimulatedMessage(ChatMessage message)
|
||||
=> MessageReceived?.Invoke(message);
|
||||
|
||||
private async Task Poll()
|
||||
{
|
||||
if (!_isRunning || _liveChatId == null || _auth.CurrentChannel == null) return;
|
||||
|
||||
try
|
||||
{
|
||||
if (!_isRunning || _liveChatId == null || _auth.CurrentChannel == null) return;
|
||||
|
||||
if (_auth.CurrentChannel.TokenExpiry <= DateTime.UtcNow.AddMinutes(5))
|
||||
await _auth.RefreshToken();
|
||||
|
||||
var url = $"https://www.googleapis.com/youtube/v3/liveChat/messages?liveChatId={_liveChatId}&part=snippet,authorDetails&maxResults=50";
|
||||
var url = $"https://www.googleapis.com/youtube/v3/liveChat/messages?liveChatId={_liveChatId}&part=snippet,authorDetails&maxResults=2000";
|
||||
if (_nextPageToken != null)
|
||||
url += $"&pageToken={_nextPageToken}";
|
||||
|
||||
@@ -62,76 +79,182 @@ public class YouTubeChatService : IDisposable
|
||||
if (!response.IsSuccessStatusCode) return;
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
var data = JsonSerializer.Deserialize<JsonElement>(json);
|
||||
var page = ParsePage(json);
|
||||
|
||||
if (data.TryGetProperty("nextPageToken", out var token))
|
||||
_nextPageToken = token.GetString();
|
||||
|
||||
if (!data.TryGetProperty("items", out var items)) return;
|
||||
|
||||
foreach (var item in items.EnumerateArray())
|
||||
{
|
||||
var snippet = item.GetProperty("snippet");
|
||||
var author = item.GetProperty("authorDetails");
|
||||
var type = snippet.GetProperty("type").GetString() ?? "textMessageEvent";
|
||||
|
||||
var message = new ChatMessage
|
||||
{
|
||||
Id = item.GetProperty("id").GetString()!,
|
||||
AuthorName = author.GetProperty("displayName").GetString()!,
|
||||
AuthorChannelId = author.GetProperty("channelId").GetString()!,
|
||||
AuthorImageUrl = author.GetProperty("profileImageUrl").GetString()!,
|
||||
Timestamp = DateTime.Parse(snippet.GetProperty("publishedAt").GetString()!),
|
||||
IsMember = author.GetProperty("isChatSponsor").GetBoolean(),
|
||||
MembershipLevel = author.TryGetProperty("badgeText", out var badgeText)
|
||||
? badgeText.GetString()
|
||||
: null
|
||||
};
|
||||
|
||||
switch (type)
|
||||
{
|
||||
case "superChatEvent":
|
||||
message.IsSuperChat = true;
|
||||
if (snippet.TryGetProperty("superChatDetails", out var scDetails))
|
||||
{
|
||||
message.SuperChatAmount = scDetails.TryGetProperty("amountMicros", out var amt)
|
||||
? amt.GetDouble() / 1_000_000.0
|
||||
: 0;
|
||||
message.SuperChatCurrency = scDetails.TryGetProperty("currency", out var cur)
|
||||
? cur.GetString()
|
||||
: null;
|
||||
message.Message = scDetails.TryGetProperty("userComment", out var comment)
|
||||
? comment.GetString() ?? string.Empty
|
||||
: string.Empty;
|
||||
}
|
||||
break;
|
||||
|
||||
case "newSponsorEvent":
|
||||
message.Message = $"🎉 {message.AuthorName} became a member!";
|
||||
if (snippet.TryGetProperty("membershipDetails", out var memDetails))
|
||||
{
|
||||
message.MembershipLevel = memDetails.TryGetProperty("memberLevelName", out var level)
|
||||
? level.GetString()
|
||||
: message.MembershipLevel;
|
||||
}
|
||||
break;
|
||||
|
||||
default: // textMessageEvent
|
||||
message.Message = snippet.TryGetProperty("textMessageDetails", out var textDetails)
|
||||
? textDetails.TryGetProperty("messageText", out var text)
|
||||
? text.GetString() ?? string.Empty
|
||||
: string.Empty
|
||||
: string.Empty;
|
||||
break;
|
||||
}
|
||||
if (page.NextPageToken != null)
|
||||
_nextPageToken = page.NextPageToken;
|
||||
if (page.PollIntervalMs > 0 && page.PollIntervalMs != _intervalMs)
|
||||
_intervalMs = page.PollIntervalMs;
|
||||
|
||||
foreach (var message in page.Messages)
|
||||
MessageReceived?.Invoke(message);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
System.Diagnostics.Debug.WriteLine($"Chat poll error: {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Re-arm the one-shot poll on the server's cadence. Guarded by
|
||||
// _isRunning so a concurrent Stop() (dispose + null) can't race a
|
||||
// Change() back onto the disposed timer.
|
||||
if (_isRunning)
|
||||
_pollTimer?.Change(_intervalMs, Timeout.Infinite);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>One liveChat/messages page → typed chat messages + pagination +
|
||||
/// the server's poll cadence. Internal + separated from the HTTP so the six
|
||||
/// event parses are deterministically testable without any network.</summary>
|
||||
internal static ChatPage ParsePage(string json)
|
||||
{
|
||||
var data = JsonSerializer.Deserialize<JsonElement>(json);
|
||||
var messages = new List<ChatMessage>();
|
||||
|
||||
var nextPageToken = data.TryGetProperty("nextPageToken", out var token)
|
||||
? token.GetString()
|
||||
: null;
|
||||
|
||||
var intervalMs = data.TryGetProperty("pollingIntervalMillis", out var interval)
|
||||
&& interval.ValueKind == JsonValueKind.Number
|
||||
? interval.GetInt32()
|
||||
: 0;
|
||||
|
||||
if (!data.TryGetProperty("items", out var items)) return new ChatPage(messages, nextPageToken, intervalMs);
|
||||
|
||||
foreach (var item in items.EnumerateArray())
|
||||
{
|
||||
var snippet = item.GetProperty("snippet");
|
||||
var type = snippet.TryGetProperty("type", out var t) && t.ValueKind == JsonValueKind.String
|
||||
? t.GetString()
|
||||
: "textMessageEvent";
|
||||
|
||||
var message = new ChatMessage
|
||||
{
|
||||
Id = item.TryGetProperty("id", out var idProp) ? idProp.GetString() ?? Guid.NewGuid().ToString() : Guid.NewGuid().ToString(),
|
||||
Timestamp = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
if (item.TryGetProperty("authorDetails", out var author))
|
||||
{
|
||||
message.AuthorName = author.TryGetProperty("displayName", out var dn) ? dn.GetString() ?? string.Empty : string.Empty;
|
||||
message.AuthorChannelId = author.TryGetProperty("channelId", out var cid) ? cid.GetString() ?? string.Empty : string.Empty;
|
||||
message.AuthorImageUrl = author.TryGetProperty("profileImageUrl", out var im) ? im.GetString() ?? string.Empty : string.Empty;
|
||||
message.IsMember = author.TryGetProperty("isChatSponsor", out var sp) ? sp.GetBoolean() : false;
|
||||
message.MembershipLevel = author.TryGetProperty("badgeText", out var badge)
|
||||
? badge.GetString()
|
||||
: null;
|
||||
}
|
||||
|
||||
if (snippet.TryGetProperty("publishedAt", out var published)
|
||||
&& DateTime.TryParse(published.GetString(), out var publishedAt))
|
||||
message.Timestamp = publishedAt;
|
||||
|
||||
switch (type)
|
||||
{
|
||||
case "superChatEvent":
|
||||
message.IsSuperChat = true;
|
||||
message.Kind = ChatEventKind.SuperChat;
|
||||
if (snippet.TryGetProperty("superChatDetails", out var scDetails))
|
||||
{
|
||||
if (scDetails.TryGetProperty("displayString", out var display)
|
||||
&& display.ValueKind == JsonValueKind.String)
|
||||
message.SuperChatDisplayString = display.GetString();
|
||||
if (scDetails.TryGetProperty("amountMicros", out var amt)
|
||||
&& amt.ValueKind == JsonValueKind.Number)
|
||||
message.SuperChatAmount = amt.GetDouble() / 1_000_000.0;
|
||||
message.SuperChatCurrency = scDetails.TryGetProperty("currency", out var cur)
|
||||
? cur.GetString()
|
||||
: null;
|
||||
message.Message = scDetails.TryGetProperty("userComment", out var comment)
|
||||
? comment.GetString() ?? string.Empty
|
||||
: string.Empty;
|
||||
}
|
||||
break;
|
||||
|
||||
case "superStickerEvent":
|
||||
message.Kind = ChatEventKind.SuperSticker;
|
||||
if (snippet.TryGetProperty("superStickerDetails", out var stickerDetails))
|
||||
{
|
||||
message.SuperStickerDescription = stickerDetails.TryGetProperty("stickerDescription", out var desc)
|
||||
? desc.GetString()
|
||||
: stickerDetails.TryGetProperty("description", out var descAlt)
|
||||
? descAlt.GetString()
|
||||
: null;
|
||||
if (stickerDetails.TryGetProperty("displayString", out var display)
|
||||
&& display.ValueKind == JsonValueKind.String)
|
||||
message.SuperChatDisplayString = display.GetString();
|
||||
message.SuperChatCurrency = stickerDetails.TryGetProperty("currency", out var cur)
|
||||
? cur.GetString()
|
||||
: null;
|
||||
message.Message = stickerDetails.TryGetProperty("userComment", out var comment)
|
||||
? comment.GetString() ?? string.Empty
|
||||
: string.Empty;
|
||||
}
|
||||
break;
|
||||
|
||||
case "newSponsorEvent":
|
||||
message.Kind = ChatEventKind.NewMember;
|
||||
message.IsMember = true;
|
||||
message.Message = $"🎉 {message.AuthorName} became a member!";
|
||||
if (snippet.TryGetProperty("membershipDetails", out var memDetails))
|
||||
{
|
||||
message.MembershipLevel = memDetails.TryGetProperty("memberLevelName", out var level)
|
||||
? level.GetString()
|
||||
: message.MembershipLevel;
|
||||
}
|
||||
break;
|
||||
|
||||
case "membershipGiftingEvent":
|
||||
message.Kind = ChatEventKind.MemberGift;
|
||||
if (snippet.TryGetProperty("membershipGiftingDetails", out var giftingDetails)
|
||||
&& giftingDetails.TryGetProperty("giftNewMembersCount", out var count)
|
||||
&& count.ValueKind == JsonValueKind.Number)
|
||||
message.GiftNewMembersCount = count.GetInt32();
|
||||
message.Message = message.GiftNewMembersCount > 0
|
||||
? $"🎁 {message.AuthorName} gifted {message.GiftNewMembersCount} memberships!"
|
||||
: $"🎁 {message.AuthorName} gifted memberships!";
|
||||
break;
|
||||
|
||||
case "giftMembershipReceivedEvent":
|
||||
message.Kind = ChatEventKind.GiftReceived;
|
||||
if (snippet.TryGetProperty("giftMembershipReceivedDetails", out var giftReceived)
|
||||
&& giftReceived.TryGetProperty("gifterName", out var gifter))
|
||||
message.GifterName = gifter.GetString();
|
||||
message.Message = message.GifterName != null
|
||||
? $"{message.GifterName} gifted you a membership! 🎉"
|
||||
: $"{message.AuthorName} received a membership gift! 🎉";
|
||||
break;
|
||||
|
||||
case "memberMilestoneChatEvent":
|
||||
message.Kind = ChatEventKind.MemberMilestone;
|
||||
message.IsMember = true;
|
||||
message.Message = snippet.TryGetProperty("textMessageDetails", out var msd)
|
||||
? msd.TryGetProperty("messageText", out var mst)
|
||||
? mst.GetString() ?? string.Empty
|
||||
: string.Empty
|
||||
: string.Empty;
|
||||
if (message.Message.Length > 0)
|
||||
{
|
||||
var match = System.Text.RegularExpressions.Regex.Match(message.Message, @"(\d+)\s*month", System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||
if (match.Success && int.TryParse(match.Groups[1].Value, out var months))
|
||||
message.MilestoneMonths = months;
|
||||
}
|
||||
break;
|
||||
|
||||
default: // textMessageEvent and anything else — anonymous chat rows only.
|
||||
message.Message = snippet.TryGetProperty("textMessageDetails", out var textDetails)
|
||||
? textDetails.TryGetProperty("messageText", out var text)
|
||||
? text.GetString() ?? string.Empty
|
||||
: string.Empty
|
||||
: string.Empty;
|
||||
break;
|
||||
}
|
||||
|
||||
messages.Add(message);
|
||||
}
|
||||
|
||||
return new ChatPage(messages, nextPageToken, intervalMs);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
@@ -140,3 +263,7 @@ public class YouTubeChatService : IDisposable
|
||||
_http.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Decoded liveChat/messages response: the messages + where to resume +
|
||||
/// how long to wait before reconnecting.</summary>
|
||||
internal sealed record ChatPage(List<ChatMessage> Messages, string? NextPageToken, int PollIntervalMs);
|
||||
|
||||
@@ -124,6 +124,60 @@ public class YouTubeStreamService
|
||||
return $"YouTube rejected the update ({(int)response.StatusCode})";
|
||||
}
|
||||
|
||||
/// <summary>Proper close-out (TASK 9 design decision 6 — implemented 2026-09-01;
|
||||
/// until then we relied entirely on enableAutoStop, leaving viewers on a frozen
|
||||
/// "stream offline" screen for ~a minute): POST liveBroadcasts.transition
|
||||
/// broadcastStatus=complete. MUST be called AFTER the encoder closed the RTMP
|
||||
/// push so no frames post-date the end. **Pre-flight (2026-09-22):** the POST is
|
||||
/// now gated on the broadcast's OWN lifeCycleStatus — every 2026-09-22 end
|
||||
/// logged 403 invalidTransition because enableAutoStop/YouTube had already marked
|
||||
/// the broadcast complete; a blind complete only earned the 403 + log noise. We
|
||||
/// skip ONLY on a confirmed already-ended status (complete/revoked) — an
|
||||
/// inconclusive check still posts (old behavior) rather than silently stranding a
|
||||
/// live broadcast. The POST is never-throwing: a rare still-failing transition
|
||||
/// surfaces as an error string, never a throw — the stop path must not fail over a
|
||||
/// cosmetic close-out.</summary>
|
||||
public async Task<string?> EndBroadcastAsync(string broadcastId)
|
||||
{
|
||||
if (!await EnsureToken()) return "not signed in";
|
||||
|
||||
_http.DefaultRequestHeaders.Authorization = new("Bearer", _auth.CurrentChannel!.AccessToken);
|
||||
|
||||
var lifeCycle = await GetLifeCycleStatusAsync(broadcastId);
|
||||
if (lifeCycle is "complete" or "revoked")
|
||||
{
|
||||
// Already ended (autoStop/YouTube raced us) — a blind complete only
|
||||
// earns a 403 + noise. enableAutoStop finished it.
|
||||
AppLog.Write($"End close-out: {broadcastId} already at lifeCycleStatus '{lifeCycle}' — skipping complete");
|
||||
return null;
|
||||
}
|
||||
|
||||
var response = await _http.PostAsync(
|
||||
$"{ApiBase}/liveBroadcasts/transition?broadcastStatus=complete&id={Uri.EscapeDataString(broadcastId)}&part=status",
|
||||
content: null);
|
||||
|
||||
if (response.IsSuccessStatusCode) return null;
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
AppLog.Write($"Broadcast transition(complete) failed ({(int)response.StatusCode}): {body}");
|
||||
return $"YouTube rejected the end transition ({(int)response.StatusCode})";
|
||||
}
|
||||
|
||||
/// <summary>One liveBroadcasts.list (part=status) read of the broadcast's own
|
||||
/// lifeCycleStatus — the only reliable "can we complete?" signal. Null when the
|
||||
/// list fails or the status is missing (caller degrades gracefully).</summary>
|
||||
private async Task<string?> GetLifeCycleStatusAsync(string broadcastId)
|
||||
{
|
||||
var response = await _http.GetAsync(
|
||||
$"{ApiBase}/liveBroadcasts?part=status&id={Uri.EscapeDataString(broadcastId)}");
|
||||
if (!response.IsSuccessStatusCode) return null;
|
||||
var json = JsonSerializer.Deserialize<JsonElement>(await response.Content.ReadAsStringAsync());
|
||||
if (!json.TryGetProperty("items", out var items) || items.GetArrayLength() == 0) return null;
|
||||
if (!items[0].TryGetProperty("status", out var status)) return null;
|
||||
return status.TryGetProperty("lifeCycleStatus", out var lifeCycle)
|
||||
? lifeCycle.GetString()
|
||||
: null;
|
||||
}
|
||||
|
||||
/// <summary>Returns the channel's reusable stream (TASK 5 design decision 2):
|
||||
/// lists existing streams first and reuses the one with cdn.isReusable=true,
|
||||
/// creating it with variable resolution/frame rate on first use. Binding to a
|
||||
@@ -209,43 +263,114 @@ public class YouTubeStreamService
|
||||
if (items.GetArrayLength() == 0) return null;
|
||||
|
||||
var status = items[0].GetProperty("status");
|
||||
var health = new StreamHealth
|
||||
// The real API nests health as status.healthStatus = { status, lastUpdateTimeSeconds,
|
||||
// configurationIssues[] }. The flat "healthStatus":"bad" shape is our old wrong
|
||||
// assumption — accept both so neither crashes the report-by-exception poll.
|
||||
var health = new StreamHealth();
|
||||
var issueElements = new List<JsonElement>();
|
||||
if (status.TryGetProperty("healthStatus", out var healthStatus))
|
||||
{
|
||||
HealthStatus = status.TryGetProperty("healthStatus", out var healthStatus)
|
||||
? healthStatus.GetString()
|
||||
: null,
|
||||
};
|
||||
if (status.TryGetProperty("configurationIssues", out var issues))
|
||||
{
|
||||
foreach (var issue in issues.EnumerateArray())
|
||||
if (healthStatus.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var severity = issue.TryGetProperty("severity", out var sev) ? sev.GetString() : null;
|
||||
var type = issue.TryGetProperty("type", out var t) ? t.GetString() : null;
|
||||
health.ConfigurationIssues.Add(new StreamConfigurationIssue
|
||||
{
|
||||
Severity = severity switch
|
||||
{
|
||||
"error" => StreamIssueSeverity.Error,
|
||||
"warning" => StreamIssueSeverity.Warning,
|
||||
_ => StreamIssueSeverity.Info,
|
||||
},
|
||||
Type = type,
|
||||
});
|
||||
health.HealthStatus = healthStatus.GetString();
|
||||
}
|
||||
else if (healthStatus.TryGetProperty("status", out var inner) &&
|
||||
inner.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
health.HealthStatus = inner.GetString();
|
||||
}
|
||||
if (healthStatus.TryGetProperty("configurationIssues", out var nested))
|
||||
issueElements.AddRange(nested.EnumerateArray());
|
||||
}
|
||||
if (status.TryGetProperty("configurationIssues", out var flat))
|
||||
issueElements.AddRange(flat.EnumerateArray());
|
||||
foreach (var issue in issueElements)
|
||||
{
|
||||
var severity = issue.TryGetProperty("severity", out var sev) && sev.ValueKind == JsonValueKind.String
|
||||
? sev.GetString()
|
||||
: null;
|
||||
var type = issue.TryGetProperty("type", out var t) && t.ValueKind == JsonValueKind.String
|
||||
? t.GetString()
|
||||
: null;
|
||||
health.ConfigurationIssues.Add(new StreamConfigurationIssue
|
||||
{
|
||||
Severity = severity switch
|
||||
{
|
||||
"error" => StreamIssueSeverity.Error,
|
||||
"warning" => StreamIssueSeverity.Warning,
|
||||
_ => StreamIssueSeverity.Info,
|
||||
},
|
||||
Type = type,
|
||||
});
|
||||
}
|
||||
return health;
|
||||
}
|
||||
|
||||
/// <summary>Returns the liveChatId for a broadcast by fetching its contentDetails.
|
||||
/// The liveChatId is needed to poll chat messages. Called after broadcast creation
|
||||
/// in PrepareAndStartLiveAsync.</summary>
|
||||
public async Task<string?> GetBroadcastLiveChatIdAsync(string broadcastId)
|
||||
/// <summary>TASK 41 — mock chat input: posts a real text message into the live
|
||||
/// chat via <c>liveChat/messages.insert</c>. Requires the same OAuth scopes the
|
||||
/// app already holds (youtube.force-ssl). Returns itself or a human-readable
|
||||
/// error; never throws. The inserted message round-trips back through the normal
|
||||
/// chat poll (~2s) and renders through the live overlay path.</summary>
|
||||
public async Task<string?> InsertChatMessageAsync(string liveChatId, string messageText)
|
||||
{
|
||||
if (!await EnsureToken()) return "not signed in";
|
||||
|
||||
var body = new
|
||||
{
|
||||
snippet = new
|
||||
{
|
||||
liveChatId,
|
||||
type = "textMessageEvent",
|
||||
textMessageDetails = new { messageText }
|
||||
}
|
||||
};
|
||||
|
||||
_http.DefaultRequestHeaders.Authorization = new("Bearer", _auth.CurrentChannel!.AccessToken);
|
||||
var response = await _http.PostAsJsonAsync(
|
||||
$"{ApiBase}/liveChat/messages?part=snippet", body);
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
AppLog.Write($"Test chat message inserted into {liveChatId}");
|
||||
return null;
|
||||
}
|
||||
|
||||
var errorBody = await response.Content.ReadAsStringAsync();
|
||||
AppLog.Write($"Chat message insert failed ({(int)response.StatusCode}): {errorBody}");
|
||||
return $"YouTube rejected the message ({(int)response.StatusCode})";
|
||||
}
|
||||
|
||||
/// <summary>Resolves the broadcast's liveChatId (needed to poll chat). Two facts
|
||||
/// pin this down (2026-09-25, from the official liveBroadcasts reference + the
|
||||
/// GetLiveChatId.java sample):
|
||||
/// 1. It lives under <c>snippet.liveChatId</c> — <c>contentDetails</c> has no such
|
||||
/// property, so part=contentDetails could NEVER resolve it (the "Chat polling
|
||||
/// couldn't start" report).
|
||||
/// 2. YouTube only populates it once the broadcast is <c>live</c> (the sample lists
|
||||
/// broadcastStatus=active) — fetching right after insert (lifecycleStatus
|
||||
/// <c>ready</c>) returns no id. So this polls with a bounded retry, which the
|
||||
/// caller must run AFTER the encoder starts pushing RTMP (enableAutoStart flips
|
||||
/// the broadcast to live). Returns the id or null after the retries exhaust;
|
||||
/// never throws.</summary>
|
||||
public async Task<string?> GetBroadcastLiveChatIdAsync(
|
||||
string broadcastId, int maxAttempts = 10, int delayMs = 2000)
|
||||
{
|
||||
for (var attempt = 0; attempt < maxAttempts; attempt++)
|
||||
{
|
||||
if (attempt > 0) await Task.Delay(delayMs);
|
||||
var liveChatId = await FetchLiveChatIdAsync(broadcastId);
|
||||
if (liveChatId != null) return liveChatId;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private async Task<string?> FetchLiveChatIdAsync(string broadcastId)
|
||||
{
|
||||
if (!await EnsureToken()) return null;
|
||||
|
||||
_http.DefaultRequestHeaders.Authorization = new("Bearer", _auth.CurrentChannel!.AccessToken);
|
||||
var response = await _http.GetAsync(
|
||||
$"{ApiBase}/liveBroadcasts?part=contentDetails&id={broadcastId}");
|
||||
$"{ApiBase}/liveBroadcasts?part=snippet&id={broadcastId}");
|
||||
if (!response.IsSuccessStatusCode) return null;
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
@@ -253,8 +378,8 @@ public class YouTubeStreamService
|
||||
var items = data.GetProperty("items");
|
||||
if (items.GetArrayLength() == 0) return null;
|
||||
|
||||
var contentDetails = items[0].GetProperty("contentDetails");
|
||||
if (contentDetails.TryGetProperty("liveChatId", out var liveChatId))
|
||||
var snippet = items[0].GetProperty("snippet");
|
||||
if (snippet.TryGetProperty("liveChatId", out var liveChatId))
|
||||
return liveChatId.GetString();
|
||||
|
||||
return null;
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
using System.Diagnostics;
|
||||
using System.Windows.Input;
|
||||
using ytLive.Helpers;
|
||||
using ytLive.Models;
|
||||
|
||||
namespace ytLive.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Backing VM for the YPP pull-out (the "YPP" tab below Stream Settings on the
|
||||
/// Live screen's right edge). Shows the connected channel's current position
|
||||
/// toward the YPP tier thresholds (subscribers + 90-day uploads via the Data
|
||||
/// API), a compliance checklist (2FA/AdSense self-reported — channel standing is
|
||||
/// NOT exposed to ordinary apps, so the drawer deep-links to the Earn page for it)
|
||||
/// and the "what counts" education the creator would otherwise have to leave the
|
||||
/// app to find. Snapshots are appended to SQLite on every refresh so a later
|
||||
/// analytics slice can compute velocity/ETA from history.
|
||||
/// Drawer open/close lives here (mirrors LiveBroadcastFormViewModel).
|
||||
/// </summary>
|
||||
public sealed class YppTrackerViewModel : ViewModelBase
|
||||
{
|
||||
private const double BarMaxWidth = 220;
|
||||
|
||||
private readonly Func<LayoutStore> _store;
|
||||
private readonly Func<ChannelStatsService> _stats;
|
||||
private readonly Func<bool> _isConnected;
|
||||
private readonly Func<string?> _displayName;
|
||||
|
||||
private bool _isDrawerOpen;
|
||||
private bool _isLoading;
|
||||
private bool _isRefreshing;
|
||||
private string? _loadError;
|
||||
private string _displayNameText = string.Empty;
|
||||
private long _subscriberCount;
|
||||
private long _videoCount;
|
||||
private long _totalViews;
|
||||
private int _uploadsLast90Days;
|
||||
private bool _hasSnapshot;
|
||||
private bool _twoFactorEnabled;
|
||||
private bool _adsenseLinked;
|
||||
private DateTime? _lastRefreshedAtUtc;
|
||||
|
||||
public YppTrackerViewModel(
|
||||
Func<LayoutStore> store,
|
||||
Func<ChannelStatsService> stats,
|
||||
Func<bool> isConnected,
|
||||
Func<string?> displayName)
|
||||
{
|
||||
_store = store;
|
||||
_stats = stats;
|
||||
_isConnected = isConnected;
|
||||
_displayName = displayName;
|
||||
|
||||
RefreshCommand = new RelayCommand(_ => _ = RefreshAsync());
|
||||
ToggleDrawerCommand = new RelayCommand(_ => IsDrawerOpen = !IsDrawerOpen);
|
||||
CloseDrawerCommand = new RelayCommand(_ => IsDrawerOpen = false);
|
||||
OpenEarnCommand = new RelayCommand(_ => OpenUrl(EarnUrl));
|
||||
OpenTwoStepCommand = new RelayCommand(_ => OpenUrl(TwoStepVerificationUrl));
|
||||
OpenAdsenseCommand = new RelayCommand(_ => OpenUrl(AdsenseGuideUrl));
|
||||
|
||||
(_twoFactorEnabled, _adsenseLinked) = _store().LoadYppChecklist();
|
||||
_lastRefreshedAtUtc = _store().LoadYppLastRefreshedAtUtc();
|
||||
}
|
||||
|
||||
// Deep links — the parts of the checklist YouTube does NOT expose to any API
|
||||
// resolve in the browser; everything API-derived stays in-app.
|
||||
private const string EarnUrl = "https://www.youtube.com/earn";
|
||||
private const string TwoStepVerificationUrl = "https://myaccount.google.com/signinoptions/two-step-verification";
|
||||
private const string AdsenseGuideUrl = "https://support.google.com/youtube/answer/72851";
|
||||
|
||||
public RelayCommand RefreshCommand { get; }
|
||||
public RelayCommand ToggleDrawerCommand { get; }
|
||||
public RelayCommand CloseDrawerCommand { get; }
|
||||
public RelayCommand OpenEarnCommand { get; }
|
||||
public RelayCommand OpenTwoStepCommand { get; }
|
||||
public RelayCommand OpenAdsenseCommand { get; }
|
||||
|
||||
public bool IsDrawerOpen
|
||||
{
|
||||
get => _isDrawerOpen;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _isDrawerOpen, value) && value)
|
||||
_ = EnsureLoadedAsync();
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsLoading
|
||||
{
|
||||
get => _isLoading;
|
||||
private set => SetProperty(ref _isLoading, value);
|
||||
}
|
||||
|
||||
public bool IsSignedOut => !_isConnected();
|
||||
|
||||
public string? LoadError
|
||||
{
|
||||
get => _loadError;
|
||||
private set => SetProperty(ref _loadError, value);
|
||||
}
|
||||
|
||||
public string DisplayName => _displayNameText;
|
||||
|
||||
// ─── Live numbers (from the Data API snapshot) ───
|
||||
|
||||
public long SubscriberCount => _subscriberCount;
|
||||
public long VideoCount => _videoCount;
|
||||
public long TotalViews => _totalViews;
|
||||
public int UploadsLast90Days => _uploadsLast90Days;
|
||||
|
||||
/// <summary>True once a snapshot has successfully captured at least once this
|
||||
/// session (drives the standing detail text's "sign in to load" state).</summary>
|
||||
public bool HasSnapshot => _hasSnapshot;
|
||||
|
||||
// ─── Manual checklist (2FA / AdSense — no API exposes them) ───
|
||||
|
||||
public bool TwoFactorEnabled
|
||||
{
|
||||
get => _twoFactorEnabled;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _twoFactorEnabled, value))
|
||||
PersistChecklist();
|
||||
}
|
||||
}
|
||||
|
||||
public bool AdSenseLinked
|
||||
{
|
||||
get => _adsenseLinked;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _adsenseLinked, value))
|
||||
PersistChecklist();
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Progress rows (subscribers is fixed; uploads is a 3/90d bar) ───
|
||||
|
||||
public double Tier1SubsProgress => Progress(SubscriberCount, YppThresholds.Tier1.Subscribers);
|
||||
public double Tier2SubsProgress => Progress(SubscriberCount, YppThresholds.Tier2Now.Subscribers);
|
||||
public double UploadsProgress => Progress(UploadsLast90Days, YppThresholds.UploadsRequiredPer90Days);
|
||||
|
||||
public double Tier1SubsBarWidth => Progress(SubscriberCount, YppThresholds.Tier1.Subscribers) * BarMaxWidth;
|
||||
public double Tier2SubsBarWidth => Progress(SubscriberCount, YppThresholds.Tier2Now.Subscribers) * BarMaxWidth;
|
||||
public double UploadsBarWidth => Progress(UploadsLast90Days, YppThresholds.UploadsRequiredPer90Days) * BarMaxWidth;
|
||||
|
||||
public string Tier1SubsText => $"{SubscriberCount:N0} / {YppThresholds.Tier1.Subscribers:N0}";
|
||||
public string Tier2SubsText => $"{SubscriberCount:N0} / {YppThresholds.Tier2Now.Subscribers:N0}";
|
||||
public string UploadsText => $"{UploadsLast90Days} / {YppThresholds.UploadsRequiredPer90Days} in the last 90 days";
|
||||
|
||||
public string StandingDetailText
|
||||
{
|
||||
get
|
||||
{
|
||||
if (!HasSnapshot) return "Sign in to load your channel's progress from YouTube.";
|
||||
return "Channel standing isn't exposed to YouTube apps — check the Earn page for your live status.";
|
||||
}
|
||||
}
|
||||
|
||||
public string LastUpdatedText
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_lastRefreshedAtUtc == null) return "Not refreshed yet";
|
||||
var local = _lastRefreshedAtUtc.Value.ToLocalTime();
|
||||
return local.Date == DateTime.Today
|
||||
? $"Updated today at {local:HH:mm}"
|
||||
: $"Updated {local:g}";
|
||||
}
|
||||
}
|
||||
|
||||
public string WhatCountsText { get; } =
|
||||
"Long-form watch hours: valid public VODs and livestreams watched in the trailing 12 " +
|
||||
"months — Shorts-feed views don't count, and private, unlisted, deleted or ad-campaign " +
|
||||
"time is excluded. Shorts views: valid public Shorts viewed in the Shorts feed over 90 " +
|
||||
"days. Uploads: valid public long-form videos posted in the last 90 days. Zero active " +
|
||||
"Community Guidelines strikes, 2-Step Verification switched on, and a linked AdSense " +
|
||||
"account complete the checklist. Figures here are API-derived and close to — but not " +
|
||||
"identical to — YouTube Studio's Earn tab.";
|
||||
|
||||
/// <summary>Account sign-in/out changed (fires from MainViewModel): reset the
|
||||
/// signed-out state and refresh when a session now exists.</summary>
|
||||
public void OnAccountChanged()
|
||||
{
|
||||
OnPropertyChanged(nameof(IsSignedOut));
|
||||
OnPropertyChanged(nameof(DisplayName));
|
||||
if (_hasSnapshot && !_isConnected())
|
||||
{
|
||||
_hasSnapshot = false;
|
||||
OnPropertyChanged(nameof(HasSnapshot));
|
||||
}
|
||||
if (_isConnected())
|
||||
_ = EnsureLoadedAsync();
|
||||
}
|
||||
|
||||
/// <summary>Loads once per session/day (quota-honest: the analytics slice that
|
||||
/// gives watch-hours/shorts has a thin targeted-query budget, so this gate is
|
||||
/// baked in now). A manual "Refresh" always goes to the wire.</summary>
|
||||
public async Task EnsureLoadedAsync()
|
||||
{
|
||||
if (!_isConnected() || _hasSnapshot) return;
|
||||
var last = _store().LoadYppLastRefreshedAtUtc();
|
||||
if (last != null && DateTime.UtcNow - last.Value < TimeSpan.FromHours(24)) return;
|
||||
await RefreshAsync();
|
||||
}
|
||||
|
||||
public async Task RefreshAsync()
|
||||
{
|
||||
if (_isRefreshing) return;
|
||||
_isRefreshing = true;
|
||||
IsLoading = true;
|
||||
LoadError = null;
|
||||
try
|
||||
{
|
||||
if (!_isConnected())
|
||||
{
|
||||
_lastRefreshedAtUtc = null;
|
||||
OnPropertyChanged(nameof(LastUpdatedText));
|
||||
return;
|
||||
}
|
||||
|
||||
var snapshot = await _stats().CaptureCurrentAsync();
|
||||
if (snapshot == null)
|
||||
{
|
||||
LoadError = "Couldn't reach YouTube — try again.";
|
||||
return;
|
||||
}
|
||||
|
||||
ApplySnapshot(snapshot);
|
||||
_store().SaveYppSnapshot(snapshot);
|
||||
_lastRefreshedAtUtc = snapshot.CapturedAtUtc;
|
||||
_store().SaveYppLastRefreshedAtUtc(snapshot.CapturedAtUtc);
|
||||
OnPropertyChanged(nameof(LastUpdatedText));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLog.Write($"YPP refresh failed: {ex}");
|
||||
LoadError = "Refresh failed — try again.";
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isRefreshing = false;
|
||||
IsLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplySnapshot(YppStatSnapshot snapshot)
|
||||
{
|
||||
_displayNameText = _displayName() ?? string.Empty;
|
||||
_subscriberCount = snapshot.SubscriberCount;
|
||||
_videoCount = snapshot.VideoCount;
|
||||
_totalViews = snapshot.TotalViews;
|
||||
_uploadsLast90Days = snapshot.UploadsLast90Days;
|
||||
_hasSnapshot = true;
|
||||
|
||||
OnPropertyChanged(nameof(DisplayName));
|
||||
OnPropertyChanged(nameof(SubscriberCount));
|
||||
OnPropertyChanged(nameof(VideoCount));
|
||||
OnPropertyChanged(nameof(TotalViews));
|
||||
OnPropertyChanged(nameof(UploadsLast90Days));
|
||||
OnPropertyChanged(nameof(HasSnapshot));
|
||||
OnPropertyChanged(nameof(Tier1SubsProgress));
|
||||
OnPropertyChanged(nameof(Tier2SubsProgress));
|
||||
OnPropertyChanged(nameof(UploadsProgress));
|
||||
OnPropertyChanged(nameof(Tier1SubsBarWidth));
|
||||
OnPropertyChanged(nameof(Tier2SubsBarWidth));
|
||||
OnPropertyChanged(nameof(UploadsBarWidth));
|
||||
OnPropertyChanged(nameof(Tier1SubsText));
|
||||
OnPropertyChanged(nameof(Tier2SubsText));
|
||||
OnPropertyChanged(nameof(UploadsText));
|
||||
OnPropertyChanged(nameof(StandingDetailText));
|
||||
}
|
||||
|
||||
private void PersistChecklist() => _store().SaveYppChecklist(TwoFactorEnabled, AdSenseLinked);
|
||||
|
||||
private static double Progress(long current, long target)
|
||||
=> target <= 0 ? 0 : Math.Clamp((double)current / target, 0, 1);
|
||||
|
||||
private static void OpenUrl(string url)
|
||||
{
|
||||
Process.Start(new ProcessStartInfo(url) { UseShellExecute = true });
|
||||
}
|
||||
}
|
||||
+14
-6
@@ -6,10 +6,15 @@ External-facing logic: YouTube API, persistence. See
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `YouTubeAuthService.cs` | OAuth2 via Google: loopback callback (`http://localhost:8765/oauth2/callback`), token exchange, refresh, channel fetch. Constructor takes optional `HttpClient` + `sessionChanged` callback (test seam + save hook); session persists via `Helpers/TokenStore` (DPAPI); `ClearSession()` signs out (called by `MainViewModel.StopStream` on End Livestream) |
|
||||
| `YouTubeStreamService.cs` | Broadcast/stream management via the v3 API. **`CreateBroadcast` always sends `privacyStatus = "private"`** (ship step 7 — private-only until v1) and the one-click v3 flags (`enableAutoStart/Stop`, `enableMonitorStream=false`, `latencyPreference=low`); injectable `HttpClient? http = null` ctor seam so tests can fake the API. **`GetOrCreateReusableStreamAsync` (TASK 9, shipped 2026-08-16)** returns the channel's `ReusableStream` — lists `liveStreams?mine=true` and reuses the existing `cdn.isReusable` stream, inserting once only on first use (`resolution=variable`, `frameRate=variable`, `isReusable=true`); a stream id given to `CreateBroadcast` binds at insert via `contentDetails.boundStreamId`. RTMP URL = `ingestionAddress + "/" + streamName`. **`GetStreamHealthAsync(streamId)` (TASK 9 item 3, shipped 2026-08-16)** polls `liveStreams?part=status` → `StreamHealth` with parsed `configurationIssues[]` |
|
||||
| `YouTubeStreamService.cs` | Broadcast/stream management via the v3 API. **`CreateBroadcast` always sends `privacyStatus = "private"`** (ship step 7 — private-only until v1) and the one-click v3 flags (`enableAutoStart/Stop`, `enableMonitorStream=false`, `latencyPreference=low`); injectable `HttpClient? http = null` ctor seam so tests can fake the API. **`GetOrCreateReusableStreamAsync` (TASK 9, shipped 2026-08-16)** returns the channel's `ReusableStream` — lists `liveStreams?mine=true` and reuses the existing `cdn.isReusable` stream, inserting once only on first use (`resolution=variable`, `frameRate=variable`, `isReusable=true`); a stream id given to `CreateBroadcast` binds at insert via `contentDetails.boundStreamId`. RTMP URL = `ingestionAddress + "/" + streamName`. **`GetStreamHealthAsync(streamId)` (TASK 9 item 3, shipped 2026-08-16)** polls `liveStreams?part=status` → `StreamHealth` with parsed `configurationIssues[]` (NOTE 2026-09-22: the real API nests `status.healthStatus = {status, lastUpdateTimeSeconds, configurationIssues[]}` as an OBJECT — the parse reads the nested `.status` and tolerates the old flat-string shape; the object shape used to crash every poll) | **`EndBroadcastAsync` (2026-09-01; hardened 2026-09-22)** posts `liveBroadcasts/transition?broadcastStatus=complete&part=status` AFTER RTMP EOF with a pre-flight `liveBroadcasts.list` read of the broadcast's OWN `lifeCycleStatus`: only transitions from `live`/`testing`, skips already-complete broadcasts silently (2026-09-22: every blind end logged `invalidTransition` — autoStop races us to complete); `enableAutoStop` finishes every skip |
|
||||
| `StreamHealthReporter.cs` | Pure report-by-exception decision (TASK 9 item 3): `BannerFor(issues)` → `HealthIssueReport(Text?, IsError)` — null text on good/ok/noData/info-only, first warning/error issue produces its type text, error beats warning for color |
|
||||
| `YouTubeChatService.cs` | Polls `liveChat/messages`, raises `MessageReceived`; `IDisposable` |
|
||||
| `LayoutStore.cs` | SQLite persistence (`Microsoft.Data.Sqlite`) at `%APPDATA%\ytLlive\ytLlive.db`; assets stored as BLOBs keyed by SHA-256 content hash; save/open layout files; schema `user_version` 6 (`Source.ClipShape`/`IsMirrored` via `ALTER TABLE` for pre-v2 DBs; v3 = singleton `Webcam` + per-scene `WebcamSceneConfig`, migrated idempotently **without backfill** — the stale `Source.DeviceId` column remains but is no longer read/written; v4 = `WebcamSceneConfig.RectWidth`/`RectHeight`, the pre-Round rect for the round-to-rect restore; v5 = `Source.IsBackdrop` + `Source.CaptureKey`, the live-capture backdrop; v6 = `Scene.HasBackdrop` — Live-only policy, one-time backfill turns Starting/BRB/Chat/Ending off + drops their backdrop sources; `MainViewModel.NormalizeBackgrounds` re-normalizes on every load — TASK 25: one locked "Background" per canonical screen (static art everywhere, DisplayCapture on Live), duplicates dropped, index 0, orphaned `BackgroundUseDefault_*`/`BackgroundPath_*` Settings keys purged at save). Settings key/value table holds app state: `MicSourceName`, `ReusableStream` (TASK 9 — the cached reusable stream's id/address/name so the pump has its RTMP URL at startup) |
|
||||
| `YouTubeChatService.cs` | Polls `liveChat/messages`, raises `MessageReceived`; `IDisposable`. **TASK 43 rewrite (2026-09-24):** re-arms its one-shot poll on the server's `pollingIntervalMillis` (streamList semantics — reconnect on that cadence with `nextPageToken`, clamp 1000–6000ms, `maxResults=2000`); `ParsePage` (internal static seam) decodes **all six** event types into `ChatMessage.Kind`/fields (superChat / superSticker / newSponsor / membershipGifting / giftMembershipReceived / memberMilestoneChat + milestone months best-effort regex); optional `HttpClient` ctor seam endures; `InjectSimulatedMessage` (TASK 41) preserved |
|
||||
| `ChatBoxRenderer.cs` | Chat overlay rasterizer (WPF FormattedText → BGRA `VideoFrame`), live rows + Super Chat/member styling |
|
||||
| `ChatOverlayLayer.cs` | **The chat component (Commit G):** owns the message buffer, `ChatBoxRenderer`, fade/mock timers, per-source preview renders, live `RenderFrame`; raster-on-change cache pattern (OBS text-source contract) |
|
||||
| `AlertRenderer.cs` | **The alert rasterizer (TASK 43, 2026-09-24):** renders one event's celebration animation into a BGRA `VideoFrame` — SIX DISTINCT animations (SuperChat slide-up/shine/count-up, SuperSticker scale-pop, NewMember drop-in/flash-band, MemberGift slide-left/chip-fan, GiftReceived confetti scale-in, MemberMilestone rise/growth-bar), enter/hold/exit phases, brand line on every card. **Still the fallback** when no alert clip is playable (TASK 47) |
|
||||
| `AlertOverlayLayer.cs` | **The native alert-box component (TASK 43; video branch TASK 47):** event queue (cap 10) + 33ms dispatch ticker + `RenderFrame` cache-first live path + `UpdatePreview`; **`Advance(double)` is the deterministic test clock** (tests never start the timer); `ChatEventKind.None` rows (sub mentions) never enqueue; idle = transparent (null frame). Since TASK 47 a clip branch owns `IAlertClipDecoder` per play — ctor seams `(clipDecoderFactory, clipPathResolver, audioSink)`, fade in/out over `FadeDurationSeconds = 0.30` (straight-alpha copies + volume×fade audio), EOF freeze-frame → drain, and `AlertTickerFrame` composes the scrolling message strip |
|
||||
| `AlertClipDecoder.cs` | **Per-play alert video decoder (TASK 47):** `IAlertClipDecoder` (Start/Stop/Dispose + `FrameAvailable`/`AudioReady`/`Completed`) + ffmpeg child emitting `rawvideo bgra` (`-vf scale=W:H`, read via `RawVideoFrameReader`) and `f32le -ar 48000 -ac 2` on a **carry-buffer** audio loop (float boundary straddles never drop samples), both real-time paced; bgra is opaque (layer owns alpha). One-shot lifetime — created per alert, disposed at drain; tests inject a fake |
|
||||
| `LayoutStore.cs` | SQLite persistence (`Microsoft.Data.Sqlite`) at `%APPDATA%\ytLlive\ytLlive.db`; assets stored as BLOBs keyed by SHA-256 content hash; save/open layout files; schema `user_version` 10 (`Source.ClipShape`/`IsMirrored` via `ALTER TABLE` for pre-v2 DBs; v3 = singleton `Webcam` + per-scene `WebcamSceneConfig`, migrated idempotently **without backfill** — the stale `Source.DeviceId` column remains but is no longer read/written; v4 = `WebcamSceneConfig.RectWidth`/`RectHeight`, the pre-Round rect for the round-to-rect restore; v5 = `Source.IsBackdrop` + `Source.CaptureKey`, the live-capture backdrop; v6 = `Scene.HasBackdrop` — Live-only policy, one-time backfill turns Starting/BRB/Chat/Ending off + drops their backdrop sources; v9→v10 (TASK 39) = `YppSnapshot` history table; all later columns stay additive guarded ALTERs — TASK 47 adds the five alert `Source` columns (`AlertVideoAssetId`/`AlertVideoPath`/`AlertUseDefaultVideo`/`AlertShowTicker`/`AlertVideoVolume`) without a version bump; `MainViewModel.NormalizeBackgrounds` re-normalizes on every load — TASK 25: one locked "Background" per canonical screen (static art everywhere, DisplayCapture on Live), duplicates dropped, index 0, orphaned `BackgroundUseDefault_*`/`BackgroundPath_*` Settings keys purged at save). Settings key/value table holds app state: `MicSourceName`, `ReusableStream` (TASK 9 — the cached reusable stream's id/address/name so the pump has its RTMP URL at startup), `AlertDefaultVideoAssetId` (TASK 47 — the stamped built-in alert clip's asset row; save's prune exempts it via UNION) |
|
||||
| `VideoFrame.cs` | Normalized CPU frame seam (`Width`/`Height`/tightly-packed BGRA `byte[]`) — the only pixel type the rest of the app knows about; future capture sources (screen, background-removed webcam) feed the same seam |
|
||||
| `CameraDeviceInfo.cs` | `(Id, DisplayName)` for a physical capture device |
|
||||
| `ICameraEnumerator.cs` | `GetCamerasAsync()` — seam so the picker/`CameraManager` never touch WinRT (tests inject fakes) |
|
||||
@@ -28,13 +33,16 @@ External-facing logic: YouTube API, persistence. See
|
||||
| `ScreenCaptureFrameSource.cs` | One `Direct3D11CaptureFramePool` (free-threaded, 2 buffers) + session per target; frames → `SoftwareBitmap.CreateCopyFromSurfaceAsync` (BGRA, alpha ignored) → `VideoFrame`, bytes read via `WindowsRuntimeMarshal.TryGetDataUnsafe` (CsWinRT-safe — the `IMemoryBufferByteAccess` ComImport cast fails on every frame and is gone). Surfaces > 1920×1080 downscaled bilinearly to the master; conversion failures logged ≤ once/5 s. DRM content = black frames (OS limit). `CreateForMonitor`/`CreateForWindow`/`CreateForPicker` |
|
||||
| `ScreenCaptureManager.cs` | Screen-capture ownership mirroring `CameraManager`: refcounted by target key, one shared `WriteableBitmap`, dispatcher-coalesced latest-frame copies; `PreviewBitmapChanged`/`CaptureFailed` events; `ReleaseAllAsync` used on re-designation |
|
||||
| `ScreenCaptureSourceFactory.cs` | `Resolve(key)` parses `monitor:<n>` / `window:<hwnd>` / `picker:<name>` into a source; `PickAsync()` shows the OS `GraphicsCapturePicker` and returns the `picker:` key (transient — a reload falls back to auto-detection) |
|
||||
| `Compositor/SceneCompositor.cs` | **The output compositor (TASK 4 ship step 1)**: renders a scene into the encoder's master `VideoFrame` (tightly-packed BGRA8), mirroring the XAML preview minus editing chrome — backdrop → background → elements (`UniformToFill` cover-crop, round clip, mirror, opacity, border) → branding flash → social bar (optional `socialBarFrame` + `socialBarTop` in master space, blitted last so the bar sits above the flash; the old `BlitFlash` generalized to `BlitOverlay` with source offsets). Pure and WPF-free: frames injected via a `Func<SceneElement, VideoFrame?>` resolver (webcam → DeviceId, image → AssetId, backdrop → CaptureKey); output sized by `CompositorOptions` (16:9 = full master 1:1; vertical 9:16 = 607×1080 crop → 1080×1920 bilinear). Preview stays XAML (editing view); this is the output view — see `ai.md` "Scene compositor" |
|
||||
| `WebView2Manager.cs` | **Web-source ownership (TASK 17, composition-capture since 2026-09-14):** per registered `Source { Type: WebSource }` it owns a `CoreWebView2CompositionController` + its visual tree (`RootVisualTarget` child under a 1920×1080 root `ContainerVisual` on one `Windows.UI.Composition.Compositor` created on the UI thread via the CoreMessaging `CreateDispatcherQueueController` P/Invoke), a wrapped `WebCaptureFrameSource`, and the shared crop-sized `WriteableBitmap` preview (dispatcher-coalesced copy at Render priority, `PreviewBitmapChanged` raised only on recreation). Nav + `TransparentBackgroundScript` injection unchanged. Public ctor `(Dispatcher)`; internal seam ctor `(Dispatcher, Func<string, IScreenCaptureSource>?)` for hermetic tests (no WinRT). Caller: `MainViewModel.InitWebView2()` (now invoked from `MainWindow_Loaded` — the parent HWND must exist). See `../ai.md` "Slice 14" |
|
||||
| `WebCaptureFrameSource.cs` | The `IScreenCaptureSource` for a web session: owns the `GraphicsCaptureItem` (`CreateFromVisual`) + a free-threaded `Direct3D11CaptureFramePool` (2 buffers) + session; readback via `SoftwareBitmap.CreateCopyFromSurfaceAsync` (BGRA, **`BitmapAlphaMode.Straight`** — the compositor blends straight alpha); per-frame `FindContentBounds` alpha-bbox (internal static, unit-tested) stamps `VideoFrame.CropBounds`; ring up to 2 fresh frames + Epoch reuse; `IsOpaque=false`. Mirror of `ScreenCaptureFrameSource` |
|
||||
| `Compositor/SceneCompositor.cs` | **The output compositor (TASK 4 ship step 1)**: renders a scene into the encoder's master `VideoFrame` (tightly-packed BGRA8), mirroring the XAML preview minus editing chrome — backdrop → background → elements (`UniformToFill` cover-crop, round clip, mirror, opacity, border) → branding flash → social bar (optional `socialBarFrame` + `socialBarTop` in master space, blitted last so the bar sits above the flash; the old `BlitFlash` generalized to `BlitOverlay` with source offsets). **TASK 47:** `Render` + `CompositeLayers` take a trailing `tickerFrame` blitted at (0,0) after the bar — the alert ticker is a message-scrolling dynamic overlay, never baked. Pure and WPF-free: frames injected via a `Func<SceneElement, VideoFrame?>` resolver (webcam → DeviceId, image → AssetId, backdrop → CaptureKey); output sized by `CompositorOptions` (16:9 = full master 1:1; vertical 9:16 = 607×1080 crop → 1080×1920 bilinear). Preview stays XAML (editing view); this is the output view — see `ai.md` "Scene compositor" |
|
||||
| `Compositor/CompositorOptions.cs` | The active tier's output rect (source space over the 1920×1080 master, integer-aligned — `MainViewModel.OutputRectX` can be 656.5) + target W×H |
|
||||
| `Compositor/StretchMath.cs` | Pure pixel math: the WPF `UniformToFill` cover-crop, clamped bilinear sample/scale (unit-tested half of the compositor) |
|
||||
| `Compositor/StaticPixelCache.cs` | Asset bytes → cached BGRA8 `VideoFrame`, decoded once per content hash — the output path's raw-pixel source (the preview uses ImageCache's `BitmapImage`) |
|
||||
| `Encoder/IFfmpegLocator.cs` | **TASK 4 ship step 2 seam**: resolves an absolute path to `ffmpeg.exe` (PATH first → cached `%APPDATA%\ytLlive\tools\ffmpeg.exe` → pinned BtbN LGPL-shared zip download). The encoder step and tests fake it |
|
||||
| `SocialValidator.cs` | `ISocialValidator` seam + `HttpSocialValidator` default: validates a social handle/URL by constructing the canonical profile URL and issuing an HTTP GET — the "act of validation" before adding to the bar. 200/redirect = exists; 404/connection failure = rejected. Fediverse `@user@domain` additionally does a best-effort nodeinfo lookup (`/.well-known/nodeinfo` → `software.name`) so the entry can show the instance's real logo; nodeinfo failure still validates (generic fediverse glyph). If the identity domain's nodeinfo is blocked (SSO) but the bare root 302s to the real instance (YunoHost default-app subdomains), the lookup follows the redirect and asks that host. Since the bar bug-fix branch the resolution **probes well-known subdomains** (`mastodon.`, `social.`, ... order in `FediverseSubdomainCandidates`) when both the identity domain and its redirect come up empty, bounded by a ~15s linked-CTS budget; `ResolveFediverseSoftwareAsync(domain, ct)` on the seam powers the load-time heal. Constructor takes optional `HttpClient` + `Action<string>` log for tests |
|
||||
| `Compositor/SocialBarRenderer.cs` | **Social bar output strip** (bar bug-fix branch): rasterizes the global social bar into a transparent straight-alpha BGRA8 `VideoFrame` strip (1920 wide, 40px content + 24px glow pad) via `RenderTargetBitmap` — one Path (logo) + one TextBlock (handle) per entry, white on transparent with the preview's green `#2ecc71` glow baked in (Pbgra32 → straight-alpha unpremultiply). UI thread only; the frame is immutable afterwards so the `FramePump` reads it from its own thread |
|
||||
| `Compositor/AlertTickerRenderer.cs` | **Alert message ticker strip (TASK 47):** marquee overlay rendered per call by pure byte-math onto a 1920×48 transparent BGRA frame — the text pill ("Author — Kind · amount") is rasterized once per text on the UI thread (cached, unpremultiplied straight alpha) and scrolled at 140px/s with a repeat-gap wrap, so the pump can call `Render(elapsed, text)` from its own thread without locking. **Dynamic overlay — never baked** (unlike the social bar) |
|
||||
| `Encoder/EncoderOptions.cs` | One go-live's encoder config: full `RtmpUrl` (ingest + stream key), W×H/FPS/bitrate from the quality tier, `VideoEncoder` (null = probe), audio sample rate/channels, `GopSize` = FPS×4 (the ≤4s keyframe bound) |
|
||||
| `Encoder/IFfmpegEncoder.cs` | **TASK 4 ship step 3 seam**: start/feed/stop the live encoder; `HealthUpdated` (parsed `StreamHealth`), `ProcessFailed` on unexpected non-zero exit. Constructor-injected locator + process factory (tests fake both) |
|
||||
| `Encoder/FfmpegEncoder.cs` | The default encoder: spawn `ffmpeg.exe` (probe `-encoders` first → hardware NVENC/QSV/AMF, OpenH264 fallback — never libx264), feed raw BGRA frames into stdin, parse `-stats` lines into `StreamHealth`, graceful stop via stdin EOF + 10s kill watchdog. Constructed by `MainViewModel` since ship step 5 (`new FfmpegEncoder(new FfmpegLocator())`), driven by the `FramePump` |
|
||||
@@ -43,7 +51,7 @@ External-facing logic: YouTube API, persistence. See
|
||||
| `Encoder/FfmpegArgs.cs` | Pure FFmpeg command-line builder: rawvideo `pipe:0` input, `anullsrc` silent audio (WASAPI replaces it), H.264+AAC, closed GOP (`-g fps×4 -keyint_min -sc_threshold 0 -bf 0`, `yuv420p`), FLV → RTMP |
|
||||
| `Encoder/FfmpegProgressParser.cs` | Pure parser for `frame=/fps=/size=/time=/bitrate=` stats lines → `FfmpegProgress` |
|
||||
| `Encoder/FfmpegEncoderPicker.cs` | Pure H.264 encoder picker from `-encoders` output: NVENC → QSV → AMF → OpenH264; **never returns libx264** (GPL) |
|
||||
| `Encoder/FramePump.cs` | **TASK 4 ship step 5**: the live frame producer — while live it snapshots the active scene each tick, resolves every element to its latest frame (`Func<SceneElement, VideoFrame?>` resolver), composites it into the tier's output frame, and paces frames into the encoder at the tier's FPS. All collaborators constructor-injected seams; free of WPF and the capture managers. `StartAsync` never throws (failures log + `Failed`); no RTMP URL = encoder skipped; `StopAsync` stops the encoder before awaiting the loop (backpressure deadlock); `ProcessFailed` self-stops. Since the bar bug-fix branch it takes an optional `socialBar: Func<(VideoFrame? Frame, SocialBarPosition Position)>?` seam — a pre-rendered bar strip composited last (above the flash) at the top edge or `SourceRectHeight − bar height`. See `ai.md` "Live frame pipeline" |
|
||||
| `Encoder/FramePump.cs` | **TASK 4 ship step 5**: the live frame producer — while live it snapshots the active scene each tick, resolves every element to its latest frame (`Func<SceneElement, VideoFrame?>` resolver), composites it into the tier's output frame, and paces frames into the encoder at the tier's FPS. All collaborators constructor-injected seams; free of WPF and the capture managers. `StartAsync` never throws (failures log + `Failed`); no RTMP URL = encoder skipped; `StopAsync` stops the encoder before awaiting the loop (backpressure deadlock); `ProcessFailed` self-stops. Since the bar bug-fix branch it takes an optional `socialBar: Func<(VideoFrame? Frame, SocialBarPosition Position)>?` seam — a pre-rendered bar strip composited last (above the flash) at the top edge or `SourceRectHeight − bar height`. **TASK 47:** adds an `alertTicker: Func<VideoFrame?>?` ctor seam (parallel to `socialBar`), read per tick and threaded through `RenderScene`/`RenderFull` + **mixed into `BuildFullRenderSignature`** so the ticker scroll changes the cache key. See `ai.md` "Live frame pipeline" |
|
||||
| `Encoder/FfmpegLocator.cs` | The default locator: PATH probe wins, then the cache, then pull+extract (`ffmpeg.exe` **+ the `libav*.dll` family** via a staging dir, failures via `AppLog`). Pinned to BtbN `autobuild-2026-08-09-13-03` **lgpl-shared** (dynamic linking = LGPL compliance without static-relink §6 material; no GPL libx264; NVENC/QSV/AMF + libopenh264 + native AAC — see `ai.md` → Licensing). Search dirs / tools dir / downloader constructor-injected for hermetic tests. Wired into the encoder since ship step 5 |
|
||||
| `INotificationService.cs` | **Toast seam (TASK 24)**: `Show(title, message, AppNotificationSeverity)` + `AppNotificationSeverity { Info, Success, Warning, Error }` + `Info/Success/Warning/Error` extension methods — non-blocking user-facing messages; VMs/services raise through this so failures surface without MessageBox blocking the UI thread |
|
||||
| `NotificationService.cs` | The default seam impl (Notification.Wpf 11.0.0, MIT): wraps a library `NotificationManager`; routes to the in-window `NotificationArea` named `ToastArea` (MainWindow bottom-right above the footer); lifetimes via the pure `BuildRequest` mapping + `Cards` tint table — Info/Success ~4s, Warning ~8s auto-dismiss, Error sticky (`NeverExpires()` = `TimeSpan.MaxValue`, never null); dark card tints matching the health-banner language; all Show calls marshal to the captured Dispatcher. See [`../ai.md`](../ai.md) "Toast notifications" |
|
||||
@@ -51,7 +59,7 @@ External-facing logic: YouTube API, persistence. See
|
||||
| `Audio/AudioSample.cs` | One captured chunk: interleaved PCM float (-1..1) + sample rate + channels |
|
||||
| `Audio/WasapiLoopbackAudioSource.cs` | Desktop/game capture: NAudio `WasapiLoopbackCapture` on the default render device — automatic at unity; the game bar's meter consumes it |
|
||||
| `Audio/WasapiMicAudioSource.cs` | Mic capture: NAudio `WasapiCapture` with the device resolved by `FriendlyName` matching `MicSourceName` (DisplayName only), default capture endpoint fallback; name provider `Func<string?>` re-read at each `Start` so a device picked mid-session takes effect immediately (the mixer restarts the mic on pick) |
|
||||
| `Audio/AudioMixer.cs` | Owns both sources; capture starts once at startup (`MainViewModel.StartMicCaptureAsync`) and stops on `Shutdown` — NOT go-live (preview monitoring). Mic samples → `AudioLevelMeter` → `MicLevelChanged`; loopback samples → the game bar's meter via `LoopbackLevelChanged`. Surfaces mic connection state: `MicConnected`/`MicFailed` (drives the status dot) + `RestartMic()` (device swap mid-session, keeps loopback). Failures log via `AppLog`; mic failure zeroes the meter, loopback failure doesn't kill the mic. Note: the meter `Push` is unconditional (the `?.` on the event would otherwise skip the argument when nothing is subscribed) |
|
||||
| `Audio/AudioMixer.cs` | Owns both sources; capture starts once at startup (`MainViewModel.StartMicCaptureAsync`) and stops on `Shutdown` — NOT go-live (preview monitoring). Mic samples → `AudioLevelMeter` → `MicLevelChanged`; loopback samples → the game bar's meter via `LoopbackLevelChanged`. Surfaces mic connection state: `MicConnected`/`MicFailed` (drives the status dot) + `RestartMic()` (device swap mid-session, keeps loopback). Failures log via `AppLog`; mic failure zeroes the meter, loopback failure doesn't kill the mic. Note: the meter `Push` is unconditional (the `?.` on the event would otherwise skip the argument when nothing is subscribed). **TASK 47 — alert audio:** `EnqueueAlertAudio` writes a dedicated 8s stereo-48k ring (`AlertBufferSeconds`), drained in `FillAndMix` pre-limiter and added at **unity (never ducked)**; `StartLive` clears the ring |
|
||||
| `Audio/AudioLevelMeter.cs` | Pure smoothed RMS level (0..1): `Push(AudioSample)` + `Reset` — the unit-tested math behind `AudioLevel` and `GameAudioLevel`. `ToDisplay(float)` maps the raw linear RMS onto the meter's display scale (−60..0 dBFS spread across 0..1, with **+10 dB input amplification** so real speech peaks hit the red zone at maxed volume) — real speech/game RMS (~0.01..0.1) would otherwise leave a flat scale looking dead; ≤0.001 linear reads as zero (never idles on background noise) |
|
||||
| `Audio/WaveToFloat.cs` | Pure WASAPI buffer → float conversion: IEEE float 32-bit direct, PCM 16-bit normalized, `WaveFormatExtensible` IEEE-float subformat GUID, trailing partial samples ignored |
|
||||
| `Audio/MasterLimiter.cs` | **Master output limiter**: a **−1 dBFS ceiling** (`Ceiling = 0.891`) applied after the live mix so the honest gains can never sum past 0 dBFS and clip the AAC encode. Instant attack per frame (a hot frame is scaled exactly to the ceiling — no overshoot), smoothed release toward unity so loud passages don't pump. Pure — unit-tested |
|
||||
|
||||
@@ -0,0 +1,461 @@
|
||||
# ytLlive — Windows Store & code-signing certification research (authoritative)
|
||||
|
||||
> Catalog: [`TASKS.md`](../TASKS.md) · memory map: [`schema.md`](../schema.md) · distribution plan: [`Distribution.md`](../Distribution.md)
|
||||
>
|
||||
> **Researched 2026-09-27** against **Microsoft Store Policies version 7.20** (published
|
||||
> 2026-09-15, effective 2026-10-22) and the MSIX packaging docs. Policy version moves —
|
||||
> re-check the version before acting on any line here.
|
||||
>
|
||||
> **This file is the "why".** The executable checklist lives in
|
||||
> [`task-48-distribution-msix.md`](task-48-distribution-msix.md). No code changed as a
|
||||
> result of this research; it only decides what task-48 will eventually do.
|
||||
|
||||
---
|
||||
|
||||
## 1. Bottom line
|
||||
|
||||
**✅ ROUTE DECIDED 2026-09-27 — the creator chose route A: Microsoft Store, MSIX package,
|
||||
Store IAP.** The research below is what the decision was made from, and it stays as the
|
||||
audit trail so the choice is never re-litigated. The executable checklist is
|
||||
`TASKS/task-48-distribution-msix.md`; the ruling and the criteria behind it are in that
|
||||
file's item 0.
|
||||
|
||||
**MSIX packaging is viable.** One real blocker, and it is a code change we should want
|
||||
anyway (§4). Three technical blockers that normally kill a Store submission — drivers,
|
||||
per-user services, elevation — we **do not trip** (§7).
|
||||
|
||||
**Nothing else is open on distribution.** The only number still unverified is the Store
|
||||
revenue-share rate (§11 item 4), which is a *pricing* input, not a distribution one.
|
||||
|
||||
---
|
||||
|
||||
## 2. ⚠️ The one line in `Distribution.md` that is now wrong
|
||||
|
||||
`Distribution.md:318` currently says:
|
||||
|
||||
> Buy an EV (Extended Validation) code-signing certificate from DigiCert or Sectigo
|
||||
> ($200-400/year). EV certificates get instant SmartScreen reputation; DV/OV certs
|
||||
> trigger SmartScreen warnings for months.
|
||||
|
||||
**The EV half of that is false.** Microsoft removed the SmartScreen instant-bypass for EV
|
||||
certificates in **March 2024**. An EV-signed file now accumulates reputation exactly the
|
||||
way an OV-signed file does. Paying $400+/yr buys precisely what paying $150 buys.
|
||||
|
||||
`schema.md` rule 4: stale facts get FIXED, not appended. Corrected in `Distribution.md`
|
||||
(2026-09-27).
|
||||
|
||||
Also worth knowing before budgeting:
|
||||
|
||||
- **Cert validity is capped at 460 days** (CA/B Forum CSC-31, effective 2026-03-01). This
|
||||
is a recurring annual line item, not a one-time purchase.
|
||||
- **Private keys must live on an HSM or hardware token** (CA/B rule since 2023-05). Budget
|
||||
for the token or a cloud HSM.
|
||||
- **Signing does not clear the warning on day one.** A valid cert stops the *malware*
|
||||
warning immediately; the "unrecognized publisher" warning clears as SmartScreen accrues
|
||||
reputation from real download volume. Expect a ramp on every route.
|
||||
- **Third-party AV vendors layer their own reputation blocking on top of SmartScreen**, so
|
||||
signed-but-new can still warn on consumer machines. The Store route is the only one that
|
||||
is warning-free, because Microsoft holds the reputation.
|
||||
|
||||
---
|
||||
|
||||
## 3. Signing economics
|
||||
|
||||
| Route | Cert cost | SmartScreen | Install UX | Notes |
|
||||
|---|---|---|---|---|
|
||||
| **Microsoft Store, MSIX package** | **$0** — Microsoft re-signs | No warning | Normal | Store review, public listing, updates handled by Store |
|
||||
| **Microsoft Store, EXE/MSI (10.2.9)** | **$150–300/yr — your own cert** | Warning ramp | **Silent install only** | Store does NOT re-sign; you maintain a versioned URL |
|
||||
| **Direct + OV cert** | $150–300/yr | Warning ramp | Yours | Worldwide availability |
|
||||
| **Direct + Azure Artifact Signing** | ~$9.99/mo ≈ $120/yr | Warning ramp | Yours | **Individuals: USA/Canada only.** Identity validation required |
|
||||
| **Direct + EV cert** | $400+/yr | Same as OV | Yours | **Do not buy.** No benefit since March 2024 |
|
||||
| **Unsigned direct** | $0 | Strong block | Yours | Unusable for public download |
|
||||
|
||||
### Four real combinations
|
||||
|
||||
| # | Distribute | Verify payment | Cert | Revenue cut |
|
||||
|---|---|---|---|---|
|
||||
| **A** | Store | Store IAP | $0 | yes |
|
||||
| **B** | Store | **Polar** | $0 | no |
|
||||
| **C** | Polar file hosting | Polar | $150–300/yr | no |
|
||||
| **D** | Store EXE (10.2.9) | Polar | $150–300/yr | no |
|
||||
|
||||
**✅ A was chosen (2026-09-27)** — the creator's criteria were "zero headaches, minimal
|
||||
maintenance (for me) while still providing accountability and a reasonably easy upgrade
|
||||
flow," and A is the only option where *every* one of those is solved by handing the work to
|
||||
Microsoft rather than to a certificate vendor. The rest of this section stays as the record
|
||||
of what was rejected and why, so the decision is not reopened by a later session that
|
||||
remembers only that "certs cost $150/yr."
|
||||
|
||||
**A** is the only one where Polar becomes unnecessary — the Store handles payment,
|
||||
entitlement, and refunds, and `PolarLicenseService` and the customer-portal plumbing are
|
||||
deleted. That is a *distribution* choice that happens to subsume licensing.
|
||||
|
||||
**B** is the compromise that was **not** taken: the Store would have solved the SmartScreen
|
||||
problem and the annual cert bill while Polar stayed the licensing system. Rejected because
|
||||
it keeps a licensing backend, a customer portal, activation limits, and an offline-grace
|
||||
machine alive — the exact maintenance the ruling was made to eliminate.
|
||||
|
||||
**C** is the status quo previously sketched in `Distribution.md:14` / `:296` — Polar hosts
|
||||
the signed exe (10GB, signed personal download URLs, SHA-256 included). **The Store is not
|
||||
needed for delivery at all**; its only unique value is *free Microsoft signing with no
|
||||
SmartScreen warning*, and its cost is MSIX packaging work, Store review, and a public
|
||||
listing. Note the irony: C is the only option with a recurring cost *and* the only one where
|
||||
the creator maintains payment plumbing themselves.
|
||||
|
||||
**D is dominated** — strictly worse than both A and C: cert required anyway, silent install
|
||||
required, and we maintain the versioned download URL. Documented in one line so it is
|
||||
never re-litigated.
|
||||
|
||||
---
|
||||
|
||||
## 4. ⛔ The one real blocker: 10.2.2 (dynamic code inclusion)
|
||||
|
||||
**Policy 10.2.2:**
|
||||
|
||||
> Your product must not attempt to fundamentally change or extend its described
|
||||
> functionality or introduce features or functionality that is in violation of Store
|
||||
> Policies **through any form of dynamic inclusion of code**. Your product should not, for
|
||||
> example, **download a remote script and subsequently execute that script** in a manner
|
||||
> that is not consistent with the described functionality.
|
||||
|
||||
**What we do today:** `Services/Encoder/FfmpegLocator.cs:37-38` pins a GitHub release URL,
|
||||
and `LocateAsync` downloads the zip (line 69), extracts it (line 73), and the encoder then
|
||||
executes that binary. On a cold cache the app **downloads an unsigned executable from the
|
||||
internet and runs it.**
|
||||
|
||||
That is the pattern 10.2.2 names. The word is "script," so a narrow reviewer might quibble
|
||||
about a PE binary — but 10.2.3 reinforces it, and 10.2.9 separately requires all PE files
|
||||
to chain to the Microsoft Trusted Root Program, which a downloaded BtbN build does not.
|
||||
|
||||
**The fix:** bundle `ffmpeg.exe` + `ffprobe.exe` + the `libav*.dll` family **inside the
|
||||
package** instead of downloading. No dynamic code inclusion, so 10.2.2 is satisfied.
|
||||
|
||||
**This is a fix we want regardless of route.** `FfmpegLocator.cs:30-35` records that the
|
||||
previous daily pin aged out of GitHub retention and **404'd on the first real recording
|
||||
attempt (2026-09-01)**. Bundling kills that entire class of cold-start failure permanently,
|
||||
and it also removes the startup network dependency.
|
||||
|
||||
The LGPL-shared licensing reasoning in `FfmpegLocator` is unaffected — dynamic linking was
|
||||
the deliberate choice for LGPL §6 compliance, not for download avoidance. Confirm
|
||||
`THIRD-PARTY-NOTICES.txt` (already shipped) covers the bundled build.
|
||||
|
||||
**What I got wrong initially:** I believed MSIX might forbid *launching* a bundled
|
||||
executable. It does not — see §5.
|
||||
|
||||
---
|
||||
|
||||
## 5. MSIX technical facts (full trust vs AppContainer)
|
||||
|
||||
MSIX packages run at one of two trust levels, chosen by the manifest:
|
||||
|
||||
| Trust level | Behavior |
|
||||
|---|---|
|
||||
| **`mediumIL` / full trust** | **Same permissions as a standard desktop app.** Package identity + some file/registry virtualization. **Can spawn child processes.** ← this is what a WPF broadcast tool wants |
|
||||
| `appContainer` | Strictly isolated. Process and children see only explicitly granted resources. File/registry virtualized. |
|
||||
|
||||
**A packaged WPF app declaring `runFullTrust` runs at medium integrity — it is not an
|
||||
AppContainer.** Bundling and launching ffmpeg from inside the package is therefore fine.
|
||||
This is the "Desktop Bridge" / `packagedClassicApp` model.
|
||||
|
||||
**Critical file-write consequence:** "Writes to user profile locations (such as AppData) are
|
||||
redirected to per-package locations for AppContainer apps, or **pass through for full-trust
|
||||
apps**." The package install folder itself is read-only.
|
||||
|
||||
**Manifest this app needs:**
|
||||
|
||||
- `rescap:Capability Name="runFullTrust"` — medium integrity
|
||||
- `webcam` and `microphone` capabilities
|
||||
- `uap10:TrustLevel="mediumIL"`
|
||||
- `uap10:RuntimeBehavior="packagedClassicApp"`
|
||||
|
||||
### The full-trust recording invariant (durable architectural fact)
|
||||
|
||||
`ViewModels/MainViewModel.Recording.cs` writes recordings to
|
||||
`%USERPROFILE%\Downloads` or `SpecialFolder.MyVideos` (`DefaultRecordFolder()`, line
|
||||
173–179), with `Microsoft.Win32.OpenFolderDialog` for overrides (`ChooseRecordFolder()`,
|
||||
line 151). The whole temp-write-then-move lifecycle stays inside one directory
|
||||
(line 131–132).
|
||||
|
||||
**This works only because we are full trust.** At `mediumIL` those writes pass through
|
||||
unvirtualized and `OpenFolderDialog` is an ordinary Win32 folder browser with no capability
|
||||
token involved. **No `broadFileSystemAccess` is needed.**
|
||||
|
||||
⚠️ **If anyone ever flips the manifest to `appContainer`, recording silently breaks** —
|
||||
`Directory.CreateDirectory` and `File.Move` start resolving to a per-package virtualized
|
||||
location. A code comment belongs at `DefaultRecordFolder()` **when the manifest lands**, not
|
||||
before (a comment describing a manifest that does not exist is worse than no comment).
|
||||
|
||||
---
|
||||
|
||||
## 6. Store policy verdicts — what binds, what doesn't
|
||||
|
||||
Policy 7.20. "Binds" = must be satisfied to submit.
|
||||
|
||||
### ⛔ Binds — blockers or real work
|
||||
|
||||
| Policy | Requirement | Our status |
|
||||
|---|---|---|
|
||||
| **10.2.2** | No dynamic code inclusion (download-and-execute) | ❌ **VIOLATED** — see §4. Fix: bundle ffmpeg |
|
||||
| **10.3.1** | Login required → supply a **working demo account** in certification notes | ☐ TODO — we require YouTube sign-in |
|
||||
| **10.5.1** | **Privacy policy mandatory**; URL in Partner Center. Desktop Bridge + Win32 **always** need one. Must state what PI is accessed/collected/transmitted, how used/stored/secured, types of parties disclosed to, controls the user has, compliance with law, and be kept current | ☐ TODO — `llamachile.shop` can host it |
|
||||
| **10.5.4** | Collect/store/transmit PI securely with modern cryptography | ✅ DPAPI for tokens, HTTPS to YouTube |
|
||||
| **10.5.5** | No highly sensitive PI (health/**financial**) unless tied to functionality + express consent | ⚠️ **SuperChat/reward path touches financial data** — keep display-text-only processing, never log payloads |
|
||||
| **10.6** | Declared capabilities must relate to the product's functions; **must not circumvent OS checks** | ✅ camera/mic for a broadcast tool is self-evident; must use standard MF/DShow/WASAPI paths |
|
||||
| **10.7** | Localize for every declared language; declare **English only** | ☐ Keep the manifest to one language |
|
||||
| **10.1.1** | Title must be unique, **no marketing or descriptive text, no extraneous keywords** | Store name is exactly `LlamaCasty` |
|
||||
| **10.1.3** | Search terms: max 7, no pricing terms, **no other product titles** unless you published them | ☐ Cannot list `OBS` or `StreamYard` as search terms |
|
||||
| **10.1.4** | Distinct informative metadata + **active Store presence** | ☐ Listing must not be a stub |
|
||||
| **10.2.7** | Must enable clean uninstall | ☐ MSIX gives this free |
|
||||
| **10.4.2** | Start promptly, stay responsive, shut down gracefully, survive native API exceptions | ✅ `AppLog` + dispatcher handlers exist |
|
||||
| **10.11.1** | **IARC age-rating questionnaire** required | ☐ General-audience streaming tool, 12+ territory |
|
||||
| **10.11.2** | Keep the rating accurate as features change | ☐ Re-visit if audience-facing features shift |
|
||||
| **11.12** | **User Generated Content** — see §9 | ⚠️ Judgment call, documented not built |
|
||||
| **10.8.1** | Non-game PC products **may** use a secure third-party purchase API **or** Store IAP | ✅ **Superseded 2026-09-27: we chose Store IAP, not Polar.** The third-party-purchase path is no longer the plan |
|
||||
| **10.8.2** | Third-party purchase: identify provider, authenticate user, obtain confirmation, PCI DSS, note in Partner Center | ⛔ **Void — Polar is being torn down (TASK 10).** Store IAP is the Microsoft commerce engine, so the third-party-provider/PCI-DSS obligations do not apply |
|
||||
| **10.8.3** | **Individual** accounts cannot require financial info for primary functionality; requiring it forces a **Company** account | ⚠️ **Open question** — see §11 |
|
||||
| **10.14** | Individual accounts usually fine for a solo dev; Company required if the product needs financial info for primary functionality, or a consumer would read the name as a business | ⚠️ Same question as 10.8.3 |
|
||||
| **10.8.7** | Pricing must follow the FTC Guides Against Deceptive Pricing and must not be irrationally high vs. features | ☐ A pricing decision, not a code task |
|
||||
| **10.5.8** | Must **follow** applicable safety/privacy laws re children | ☐ Follow-the-law obligation — see §8 |
|
||||
| **10.1.5** | May enable acquisition of your other Store products or add-ons | N/A |
|
||||
|
||||
### ✅ Does NOT bind — recorded so it is never re-raised
|
||||
|
||||
| Policy | Why it doesn't apply |
|
||||
|---|---|
|
||||
| **10.10** (Advertising) | **Ads are injected server-side by YouTube after the RTMP handoff.** LlamaCasty displays no ads, renders no ad SDK, and controls no ad decision. Every 10.10 clause is conditioned on ads *your product displays*. Not applicable. |
|
||||
| **10.2.5** (Store-only install) | 7.20 applies "installed and updated only through the Store" to **game products and Xbox console products**. A non-game PC app is not locked to MSIX. *(The 2018 v7.7 wording said "all of your apps" — that language has been narrowed.)* |
|
||||
| **10.2.3** (secondary software) | ffmpeg **enhances** the product's functionality, and once bundled it is not "offered to install" as separate software |
|
||||
| **10.2.8** (Windows settings) | `RegisterHotKey` and standard DShow/MF paths are supported APIs. No accessibility-API or undocumented-API tricks |
|
||||
| **10.10.1 / 10.10.2** | Follows from 10.10 — no ads displayed |
|
||||
| **11.13** (Storefront) | Not a storefront |
|
||||
| **11.14** (Gambling) | Not applicable |
|
||||
| **11.15** (Child Safety) | Prohibits products enabling exploitation/grooming/sexualization/sextortion/trafficking, and adult-themed products targeting children. A broadcast tool is not in this class. Nothing LlamaCasty does creates, uploads, or distributes child-exploiting content |
|
||||
| **10.5.2** | We do not publish a customer's PI to an outside service beyond the stream they themselves broadcast |
|
||||
| **10.5.7** (location) | No device location access |
|
||||
| **10.2.9** | Only if we chose the Store EXE route. It would bind — see §3, route D is dominated |
|
||||
|
||||
### Certification mechanics
|
||||
|
||||
- Technical compliance is tested by the **Windows App Certification Kit** before submission.
|
||||
- Certification takes **up to 3 business days**; the listing appears ~15 min after publish.
|
||||
- MSIX package requirements: SHA2-256 block map, `StoreManifest`, 25 GB max.
|
||||
- Partner Center account: individual vs company — see §11.
|
||||
|
||||
---
|
||||
|
||||
## 7. MSIX disqualifiers we do NOT trip
|
||||
|
||||
Verified by grep across the codebase 2026-09-27. The MSIX prep docs list four hard
|
||||
disqualifiers; **all four are clear**:
|
||||
|
||||
| Disqualifier | Our status |
|
||||
|---|---|
|
||||
| "Your application requires a Windows driver. MSIX doesn't support Windows drivers." | ✅ We **consume** DShow filters, we don't install a driver. (A creator's third-party Virtual Capture Device is their hardware, not something we ship.) |
|
||||
| "Your application requires a user Windows service. MSIX doesn't support per-user Windows services." | ✅ No service installation anywhere |
|
||||
| Apps requiring **elevation** for any functionality are not accepted | ✅ No `requireAdministrator` / `highestAvailable` / UAC manifest |
|
||||
| "Your app's modules are loaded in-process to processes that are not in your Windows app package" (shell extensions, etc.) | ✅ No shell extensions, no in-process module loading, no jump lists |
|
||||
|
||||
Also verified: there is **no `.manifest` file in the repo at all** today, so packaging is
|
||||
purely additive — a new `Package.appxmanifest` plus a packaging project. The existing
|
||||
unpackaged exe keeps working.
|
||||
|
||||
### Other store rules worth knowing while listing
|
||||
|
||||
- **10.1.4** requires an active Store presence — the listing cannot be a placeholder.
|
||||
- **10.7** — declare English only, or localize the description into every declared language.
|
||||
- **10.9** — the app must stay functional when notifications are disabled. Our toast stack
|
||||
must not be load-bearing.
|
||||
|
||||
---
|
||||
|
||||
## 8. Minors, COPPA, and the age question
|
||||
|
||||
### The operator is 13+ by construction
|
||||
|
||||
YouTube Terms of Service:
|
||||
|
||||
- **"You must be at least 13 years old to use the Service"**
|
||||
- 13–17 permitted **with parent/guardian permission**
|
||||
- **Under 13 cannot create an account** — exceptions are YouTube Kids and supervised
|
||||
accounts via Family Link or Google Workspace for Education
|
||||
- Google Accounts: 13 is the minimum to self-manage
|
||||
- **The floor varies by region** — at least one YouTube ToS variant states 14, tracking EU
|
||||
digital-consent ages. Treat it as 13–14, not a fixed 13.
|
||||
|
||||
**Why this does real work:** LlamaCasty *requires* a YouTube account to operate, because
|
||||
streaming requires one. So the person running the broadcast is 13+ by construction. A
|
||||
COPPA theory premised on "a service collecting personal information from children under 13"
|
||||
has no purchase against the operator — and that is true **by construction, not by promise**.
|
||||
That is the cleanest sentence available for a certification note.
|
||||
|
||||
### The residual exposure
|
||||
|
||||
The **viewers** may still be under 13 on supervised/family-linked accounts, so the chat
|
||||
ingestion point does not fully disappear. It weakens substantially, because:
|
||||
|
||||
- Live chat is restricted/disabled on supervised YouTube kid accounts
|
||||
- YouTube moderates the messages before we ever see them (§9)
|
||||
- Our mitigation: **retain nothing** — parse, display, discard (see `task-49`)
|
||||
|
||||
### COPPA is very unlikely to bind
|
||||
|
||||
COPPA applies to child-directed services and to operators collecting PI from children under
|
||||
13. The three-factor test (subject matter, audience, evidence of child focus) is nowhere
|
||||
near met by a broadcast tool aimed at a gaming audience. **10.5.8 is a follow-the-law
|
||||
obligation, not a be-child-safe obligation** — it says "follow all applicable laws," it does
|
||||
not impose COPPA duties on us directly.
|
||||
|
||||
**But note the enforcement trend** (FTC, September 2025 Apitor settlement): operators are
|
||||
held responsible for **what third-party components collect on their behalf**, and the FTC
|
||||
expects a real inquiry into third-party collection practices. That case was about an SDK;
|
||||
the principle is what matters for a chat pipe. This is a documentation obligation, and the
|
||||
answer is "we integrate no third-party analytics or ad SDKs, and we persist no chat or
|
||||
reward payloads."
|
||||
|
||||
---
|
||||
|
||||
## 9. 11.12 User Generated Content — the judgment call
|
||||
|
||||
11.12 triggers on "content that users contribute to a product and which can be viewed or
|
||||
accessed by other users **in an online state**." **Chat is literally that.** If a reviewer
|
||||
classifies the chat surface as UGC, the requirements are:
|
||||
|
||||
1. Published **terms of service, code of conduct, and/or content guidelines** for UGC,
|
||||
accessible **in-product and on your website**
|
||||
2. A **means for users to report** inappropriate/illegal/harmful UGC to the developer for
|
||||
review, and/or proactive detection
|
||||
3. Respect **user safety, parental, and content privilege settings**, and handle restricted
|
||||
access gracefully
|
||||
4. **Remove or disable UGC when requested by Microsoft**
|
||||
5. Disclose that content is **not created by the developer/publisher** where appropriate
|
||||
|
||||
### The argument: YouTube discharges the moderation obligation upstream
|
||||
|
||||
| 11.12 asks for | How YouTube already covers it |
|
||||
|---|---|
|
||||
| Published ToS / CoC / content guidelines | Community Guidelines, linkable from our listing |
|
||||
| A reporting mechanism | Creator-side chat reporting; YouTube enforces |
|
||||
| Respect parental / privilege settings | YouTube's restricted + supervised accounts |
|
||||
| Remove/disable on request | YouTube removes; we never hosted the original |
|
||||
| Disclose third-party UGC origin | Visibly YouTube chat; YouTube is platform of record |
|
||||
|
||||
**And the enforcement is demonstrably real.** YouTube's transparency report for
|
||||
**Jan–Mar 2026**: approximately **1.6 billion comments removed in a single quarter**, with
|
||||
**child safety the #2 removal reason at 124,624,293** (hateful/abusive 15,977,127;
|
||||
harassment/cyberbullying 71,326,861; spam/scams 1.31 B).
|
||||
|
||||
**The certification-note sentence:**
|
||||
|
||||
> UGC is mirrored from YouTube, which moderates it at industrial scale upstream. We render
|
||||
> transiently, persist nothing, and provide no user-to-user communication surface.
|
||||
|
||||
**The "persist nothing" half is ours to keep** — and it is the same property
|
||||
`task-49` (profanity filter) needs. Convenient, not coincidental.
|
||||
|
||||
**Our read:** 11.12 targets products hosting a UGC *community*. A chat *mirror* rendered
|
||||
from YouTube — where YouTube is the platform of record and already supplies reporting,
|
||||
age-gating, and parental controls — is arguably not "our" UGC. **This is a judgment call,
|
||||
and we resolve it by documenting the position rather than building features on a guess.**
|
||||
State the reasoning in the certification notes; Microsoft reads them.
|
||||
|
||||
### The documentation we owe regardless
|
||||
|
||||
| Artifact | Requirement |
|
||||
|---|---|
|
||||
| **Privacy policy** on `llamachile.shop` | 10.5.1. Must state: camera/mic access is user-directed and OS-gated; **no retention** of chat or reward payloads; no viewer data collected; **ffmpeg is bundled, nothing is fetched at runtime** |
|
||||
| **Code of conduct + content guidelines** | 11.12 / 11.15. General audience; explicitly not directed at under-13s |
|
||||
| **IARC age-rating questionnaire** | 10.11.1 |
|
||||
| **Certification notes** | the 11.12 reading, the YouTube age-gate argument, the Polar third-party purchase box, the demo account |
|
||||
|
||||
---
|
||||
|
||||
## 10. Camera, microphone, and "only when the user says so"
|
||||
|
||||
**We do not write that certification. Windows does it — and the Store forbids us from
|
||||
bypassing it.** Three layers:
|
||||
|
||||
1. **Policy 10.6** — "You must not circumvent operating system checks for capability
|
||||
usage." Route through the standard OS consent path; do not roll our own.
|
||||
2. **Windows privacy settings** — Settings → Privacy & security → Camera / Microphone.
|
||||
Since **Windows 10 1903**, *"Let desktop apps access your camera/microphone"* is the
|
||||
toggle that governs a Win32 broadcast app. Turn it off and the app loses access. **That
|
||||
is the "explicit user direction" mechanism, enforced by the OS, not by our EULA.**
|
||||
3. **Hardware/OS indicators** — camera light (or a notification when there is no light) and
|
||||
a microphone icon in the taskbar tray. Automatic. The OS is the disclosure mechanism, so
|
||||
we get neither credit nor blame from it.
|
||||
|
||||
### ⚠️ The open question — measure, don't assume
|
||||
|
||||
Microsoft's own support page carries this caveat:
|
||||
|
||||
> Desktop apps may not always appear in the list of apps available to the Camera and
|
||||
> Microphone settings pages or might still be able to access your camera or microphone even
|
||||
> when these settings are turned off.
|
||||
|
||||
**Untested: does the Win11 desktop-app camera toggle reliably gate a DShow webcam and a
|
||||
capture card?**
|
||||
|
||||
- If it **does** gate them, we can make privacy-forward claims after verifying.
|
||||
- If it **does not**, that is **not** a Store certification failure — we declared `webcam`
|
||||
honestly and use supported APIs — but it is a real user-trust issue, and we must not
|
||||
write privacy-forward marketing copy on the assumption that it works.
|
||||
|
||||
**Tracked as an open item in `TASKS.md`. A guard note in `MARCOM.md` blocks privacy-forward
|
||||
copy until it passes.**
|
||||
|
||||
---
|
||||
|
||||
## 11. Open questions
|
||||
|
||||
1. **Individual or Company Partner Center account?** (10.8.3 / 10.14.) Individual accounts
|
||||
are "usually appropriate for a single developer working on their own," and a Company
|
||||
account is required if the product requires financial account information **for primary
|
||||
functionality**. Polar collects payment externally and we never ask the user for card
|
||||
details, so we look like a clean individual case — but it is a question for Partner
|
||||
Center, and getting it wrong means re-enrolling.
|
||||
2. **Does the desktop-app camera toggle gate DShow?** (§10) — gates the `MARCOM.md`
|
||||
privacy claim.
|
||||
3. **MSIX write behavior under a real package identity** — full trust passes writes through
|
||||
per the docs, but worth confirming on a real packaged build before trusting it with
|
||||
recordings.
|
||||
4. **Store revenue-share terms** — do not plan on any specific percentage. The terms have
|
||||
moved more than once and small indie apps sometimes qualify for better rates. Verify
|
||||
current terms and conditions before pricing against them.
|
||||
5. **`liveBroadcasts.insert` requires `status.selfDeclaredMadeForKids`** (COPPA) — already
|
||||
known, see [`research-youtube-api.md`](research-youtube-api.md) item 1. Confirm the
|
||||
dialog wording is right for a tool that is never child-directed.
|
||||
|
||||
---
|
||||
|
||||
## 12. Profanity filter — design constraints (feeds `task-49`)
|
||||
|
||||
YouTube's Community Guidelines carry a specific **Vulgar language policy** under
|
||||
"Sensitive content," alongside nudity/sexual content, child safety, and self-harm. So
|
||||
profanity is a named policy surface, not an edge case.
|
||||
|
||||
Three external reasons it earns a slot, plus the real one:
|
||||
|
||||
1. YouTube treats it as policy-relevant, so 11.12's "proactive detection" language has
|
||||
something to attach to
|
||||
2. Competitive parity — overlay tools in this lane generally offer it
|
||||
3. It is a reasonable reading of 11.12's proactive-detection language
|
||||
4. **The real reason: we control what gets projected on screen.** That is the creator's
|
||||
call, not YouTube's.
|
||||
|
||||
**Four constraints, because they protect the privacy story rather than complicate it:**
|
||||
|
||||
1. **Local and on-device only.** No server, no telemetry of message content, nothing leaves
|
||||
the machine.
|
||||
2. **Non-persistent.** Filter at ingest, discard the raw text, keep only the display
|
||||
string. Same property that makes the 11.12 answer in §9 strong.
|
||||
3. **User-supplied word list**, opt-in, stored locally. **Not a hardcoded slur list baked
|
||||
into the binary** — that is an unpleasant artifact, generates false positives across
|
||||
dialects and languages, and becomes our problem the moment someone extracts it. A
|
||||
user-curated list is also simply more useful: streamers know their community's
|
||||
euphemisms and we do not.
|
||||
4. **Match display text only.** Never the SuperChat amount or any reward-event field —
|
||||
those are financial data under 10.5.5, and matching on them would be a real mistake.
|
||||
|
||||
The existing alert box already has a ticker and three display methods
|
||||
(`Controls/OverlayHost.xaml`, `ViewModels/MainViewModel.Chat.cs`), so there is probably a
|
||||
**single insertion point** rather than scattered call sites. Verify before building.
|
||||
@@ -0,0 +1,33 @@
|
||||
# ytLlive — YouTube Live API research facts (authoritative)
|
||||
|
||||
> Catalog: [`TASKS.md`](../TASKS.md) · memory map: [`schema.md`](../schema.md).
|
||||
|
||||
## YouTube Live API — research facts (authoritative, v3 build)
|
||||
|
||||
Lifecycle: `created → ready → [testing] → live → complete` (transitional `liveStarting` / `testStarting`).
|
||||
|
||||
1. **liveBroadcasts.insert** requires: `snippet.title`, `snippet.scheduledStartTime`, `status.privacyStatus`, `status.selfDeclaredMadeForKids` (COPPA).
|
||||
2. **liveStreams.insert** requires: `snippet.title`, `cdn.frameRate`, `cdn.ingestionType`, `cdn.resolution`. **None of the four (except title) can ever change after creation** — changing them means delete + recreate the stream. This is the hard constraint behind the quality grey-out.
|
||||
3. **Title / description / privacy**: editable at any time, including while live (`liveBroadcasts.update`, part=`snippet,status`).
|
||||
4. **contentDetails** (DVR, recordFromStart, monitorStream, embed, latency): editable only in `created` / `ready`.
|
||||
5. **Transition to live** only allowed when the bound stream's `status.streamStatus == active`.
|
||||
|
||||
### Two features that reshape the design
|
||||
|
||||
1. **enableAutoStart / enableAutoStop** — instant one-click go-live, no transition call. With `enableAutoStart=true` we never call `transition(live)`: the broadcast auto-goes-live the moment the encoder starts. Combined with `enableMonitorStream=false` (our preview pane replaces YouTube's monitor stream — the thing that forces a testing stage), the flow is **create → bind → Start Stream → encoder starts → YouTube brings it live**. No testing, no transition polling, no liveStarting stuck-state handling.
|
||||
2. **`cdn.resolution=variable` / `cdn.frameRate=variable`** — free auto step-down. YouTube auto-detects what we send; since we ARE the encoder we can drop bitrate/resolution on the fly with zero API calls. Declaring an explicit resolution instead (e.g. 1080p) requires a new stream, which can't happen mid-broadcast. Variable is the enabler for the whole auto step-down feature. **(Claim-check 2026-09-01: the enabler is real, the governor is not built — the drop-side policy ships as TASK 33, v1 per the complete-v1 ruling.)**
|
||||
|
||||
### Compliance gotchas (maps perfectly to report-by-exception)
|
||||
|
||||
1. `liveStreams.status.healthStatus`: `good | ok | bad | noData` plus `configurationIssues[]` with `type` + `severity` (`info|warning|error`). Literally built for report-by-exception — poll it, render nothing on good/ok, surface a banner only on warning/error. No need to invent our own health logic.
|
||||
2. Encoder must comply or YouTube flags it: keyframes ≤ 4s (`gopSizeLong`), closed GOP, H.264, audio AAC/MP3 @ 44.1/48kHz, mono/stereo only.
|
||||
3. Error codes to handle: `errorStreamInactive`, `invalidTransition`, `redundantTransition`, `liveStreamDeletionNotAllowed`, `liveStreamModificationNotAllowed`, `liveBroadcastBindingNotAllowed`.
|
||||
|
||||
### Tips we should take advantage of
|
||||
|
||||
1. **Reusable streams** (`isReusable=true`): one stream per channel, cache its ingestion URL + stream name, reuse for every broadcast. No rebinding dance each go-live. This is exactly the manual-stream-key baseline.
|
||||
2. **Backup ingestion address**: YouTube provides a simultaneous-push backup — future hardening, not v1.
|
||||
3. **`recordFromStart` + `enableDvr` default true** → every live is auto-recorded and immediately replayable. Free VOD archive, matches the v0.2 recording goal.
|
||||
4. **`latencyPreference`: `normal | low | ultraLow`** — for homelab streamers talking to chat, `low` (or `ultraLow`, capped at 1080p) is a real feature.
|
||||
5. **Broadcast ID == Video ID** — one ID to track everything.
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
# TASK 1 — Initial Scaffold
|
||||
|
||||
> Catalog: [`TASKS.md`](../TASKS.md) — status and requirements live here.
|
||||
|
||||
|
||||
**Goal:** Working C# / WPF project with MVVM architecture, dark-theme main window, and YouTube service stubs.
|
||||
|
||||
### Status: ✅ Done
|
||||
|
||||
1. ✅ Models: Scene, Source, StreamConfig, StreamHealth, YouTubeChannel, ChatMessage
|
||||
2. ✅ Services: YouTubeAuthService (OAuth2), YouTubeStreamService (broadcast/health), YouTubeChatService (chat polling)
|
||||
3. ✅ MainViewModel: scene management, stream controls, chat
|
||||
4. ✅ MainWindow: scene/source panel, preview area, chat panel, status bar
|
||||
5. ✅ Clean build, 0 warnings (WSL + Windows)
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
# TASK 2 — YouTube OAuth2 Authentication
|
||||
|
||||
> Catalog: [`TASKS.md`](../TASKS.md) — status and requirements live here.
|
||||
|
||||
|
||||
**Goal:** Fully working Google OAuth2 flow — user clicks "YouTube", browser opens, authorization callback lands, channel info is stored.
|
||||
|
||||
### Status: ✅ Done
|
||||
|
||||
1. ✅ Two-state Start/End Stream button, go-live dialog (account + title/description/visibility), red top bar, pulsing LIVE badge + elapsed timer, preview glow, taskbar red dot
|
||||
2. ✅ Real OAuth2 wiring — baked-in Google credentials (desktop client; loopback callback) + `YouTubeAuthService` complete: browser launch, `HttpListener` callback, token exchange, refresh, channel fetch
|
||||
3. ✅ Token persistence via Windows DPAPI (`Helpers/TokenStore.cs` → `%APPDATA%\ytLlive\ytLlive.auth`), best-effort reload + proactive refresh at startup, saved after every exchange/refresh
|
||||
4. ✅ Account sign-in/change surfaced in the GoLive dialog (saved account shown with "Change Account"; "Sign in to YouTube" when none; Start disabled until signed in)
|
||||
5. ✅ ~~**End Livestream signs out**~~ **SUPERSEDED by TASK 18 (2026-08-29): explicit sign-out only** — `StopStream()` no longer clears the session (sign out via Logout / Change Account, `SignOutYouTubeAsync`); stopping a stream or recording leaves the creator signed in. Originally shipped as: a graceful end completes the session: `StopStream()` calls `YouTubeAuthService.ClearSession()` + `TokenStore.Clear()` + `IsConnected = false`, so the next Start Stream dialog requires a fresh sign-in. A crash never runs End, so the DPAPI token survives and the creator stays signed in. Resume/reconnect after a midstream crash is deliberately deferred to TASK 3: the socket can't be resumed (it dies with the process), so "resume" = fast reconnect with a saved broadcast ID/stream key within YouTube's disconnect-grace window; too slow and `enableAutoStop` ends the broadcast. **Orphan closed 2026-09-01:** this mechanism is owned by **TASK 32 (Stream resilience)** — in-session blip retry inside the grace window; cross-process "resume" of a dead broadcast is officially out-of-product (the API makes it impossible — creator ruling).
|
||||
6. ✅ Tests in `ytLive.Tests` (xUnit, net8.0-windows): TokenStore DPAPI roundtrip/corrupt/missing/clear + mocked exchange channel-parse + refresh expiry bump + `ClearSession` — 7 passing
|
||||
|
||||
**Design constraint:** Sign-in must NEVER block core exploration. Users can build scenes, add sources, and audition the software without authenticating. But **going live requires authentication** — the "Start Stream" dialog is where the account sign-in lives, alongside all stream metadata.
|
||||
|
||||
**Two-state flow:** There is no separate "Connect" button. The top bar shows a single button — **Start Stream** when idle, **End Stream** when live. Clicking Start Stream opens one dialog that supplies everything: account (previously-saved account shown as default, with a Change Account action) + title/description/visibility.
|
||||
|
||||
**Live indicators (unmissable):** Top bar + window title bar flip red, a pulsing **● LIVE** badge with elapsed timer appears in the top bar, the preview area gets a red glow, and the taskbar icon shows a red overlay dot. Window title bar shows the stream title once validated.
|
||||
|
||||
### Requirements:
|
||||
|
||||
1. **Google Cloud OAuth credentials** — client ID + secret, **baked into `Helpers/OAuthCredentials.cs`** (desktop "Desktop app" OAuth client; loopback callback — no console redirect URI registration needed; creators never configure)
|
||||
2. **Local HTTP listener** — `HttpListener` on `http://localhost:PORT/oauth2/callback` to catch the redirect
|
||||
3. **Browser launch** — open the authorization URL in the default browser
|
||||
4. **Token persistence** — store access/refresh tokens securely (Windows DPAPI), reload on startup
|
||||
5. **UI state** — account shown in the Start Stream dialog; "Change Account" action triggers re-auth
|
||||
6. **Go Live gated on auth** — Start Stream dialog requires sign-in to enable the Start button; scene building works without it
|
||||
|
||||
### Tests:
|
||||
|
||||
1. Mock token exchange response, verify channel info parsed
|
||||
2. Verify token refresh triggers when near expiry
|
||||
3. Verify credential load/save roundtrip
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
# TASK 3 — Capture Pipeline (Scenes/Sources)
|
||||
|
||||
> Catalog: [`TASKS.md`](../TASKS.md) — status and requirements live here.
|
||||
|
||||
|
||||
**Goal:** Real video preview in the center panel — the minimal source set below, composited per scene.
|
||||
|
||||
### Status: 🔶 In progress — **Alerts (item 20) SHIPPED 2026-09-24 via TASK 43**; still open: item 17 (Text source), item 20's `RewardEvents` SQLite persistence half (16 compositing + 19 background removal excluded)
|
||||
|
||||
1. ✅ **Milestone 1 — webcam** — MediaCapture (WinRT SDK projection) with device enumeration, CPU-first frame source, refcounted `CameraManager`, picker dialog, clip shapes (Traditional + Round) + mirror, 480×270 default placement — schema v2
|
||||
2. ✅ **Schema v3 (Ship Branch A)** — multi-scene webcam (singleton `Webcam` + per-scene `WebcamSceneConfig`), right-click border/context menu, static OBS-style borders, 50%-per-dimension webcam size cap, device-swap (`ReleaseAllAsync`) — 25 tests passing
|
||||
3. ✅ **Schema v4** — round→rect restore persisted (`WebcamSceneConfig.RectWidth`/`RectHeight`) + one-time legacy-square 16:9 heal on load
|
||||
4. ✅ **Screen backdrop (ship task #1, schema v5)** — live desktop/game capture as a permanent, non-deletable bottom layer (`Source.IsBackdrop`), auto-detecting the full-screen game at launch/focus (else the **primary display** — never assumed monitor 0) via `Win32FullScreenDetector` (now with `GetDisplays()`/`PrimaryMonitorIndex()` for the in-app display picker), content re-designated via the OS `GraphicsCapturePicker` ("Change Capture…") or the in-app "Capture Display" submenu, refcounted/shared capture sessions in `ScreenCaptureManager` mirroring `CameraManager` — 45 tests passing
|
||||
5. ✅ **Schema v6 — backdrop Live-only by policy** — `Scene.HasBackdrop`, enforced by scene name on every load (`EnforceBackdropPolicy`: Starting/BRB/Chat/Ending never carry one; the one-time v5→v6 backfill covers all four), the scene context-menu "Backdrop" checkbox is gone (policy owns the flag), preview watermark hides when the backdrop renders, capture changed to `WindowsRuntimeMarshal.TryGetDataUnsafe` (the CsWinRT-safe frame-read) + downscale to the 1920×1080 master + 5s-throttled error logging (was flooding `startup.log` with 5 MB of cast errors and burning CPU), round webcam no longer re-rasterizes an `ImageBrush` every frame (Image + EllipseGeometry clip) — the live-mode stutter fix
|
||||
6. ✅ **The five-scene catalog (`SceneCatalog`)** — Starting/Live/BRB/Chat/Ending is the product — work with less, never more; the (+) button only shows when a canonical scene is missing and re-adds it (its menu lists only the missing ones) — 62 tests passing
|
||||
7. ✅ **Webcam-after-session-start fix** — a webcam added to a scene after the camera was already running (e.g. Chat) previously rendered a transparent container — `CameraManager.GetPreviewBitmap` + propagation in `AddWebcamToActiveSceneAsync`/`ReacquireWebcam` now hands the running shared frames to any newly added `WebcamSceneConfig` — 65 tests passing
|
||||
8. ✅ **Chat scene webcam size cap** — raised from 50%-per-dimension (960×540) to half the screen AREA (~1358×764 @16:9, `MaxWebcamWidthFor`/`MaxWebcamHeightFor` keyed by canonical name) so the viewer sees the creator better
|
||||
9. ✅ **"Add Webcam" always opens the camera picker** — deleting one scene's webcam then re-adding used to resurrect the old camera when another scene still used it — `SwapWebcamIdentityAsync` now swaps the app-wide identity if a different camera is chosen, same path as "Change Webcam…"
|
||||
10. ✅ **Webcam resource validation + first-frame proof** — `MediaCaptureFrameSource` validates post-init (VideoDeviceId match, stream properties ≥1, `reader.StartAsync()` status read + throws on non-Success); subscribes `capture.Failed` + `CameraStreamStateChanged` → `SourceFailed` event on the seam; fallback ladder (VideoPreview → VideoRecord). `CameraManager.AcquireAsync` requires first-frame proof (4s timeout): returns true only after a real frame arrives — silent empty box impossible. `MainViewModel` subscribes `CameraFailed` → red `WebcamError` chip in preview + MessageBox names suspect apps (`CameraConflictProbe`). 19041 SDK projection gaps: `Exclusive`/`DeviceLost` not projected; `CameraStreamState.Failed` compared by `(int)2`. 81 tests passing
|
||||
11. ✅ **Scenes/sources UI** — add/reorder/rename, image + background overlays with move/resize/opacity/reuse
|
||||
12. ✅ **Audio UX shipped (UI)** — the bottom-bar footer is now two lines (dropped/duration moved under bitrate/fps), with the mic's sound meter + mute button + volume slider grouped CENTERED on the footer's top line, beneath the preview panel (meter: 288px, muted slate track with ruler graduations + muted yellow/red zone tints, green→yellow→red fill; mute = speaker icon → red do-not-symbol when muted, and the slider and speaker stay in sync (volume 0 ⇔ muted — sliding off flips the speaker to muted, sliding up from 0 clears it); mic volume defaults to 80%, muting zeroes the meter and restores the prior volume on unmute (which flashes the meter to the restored position ~300ms before it returns to the live level); the meter is a READ-ONLY realtime level display (fill = live level × volume — volume is a gain on ambient noise; while the slider is dragged the bar previews the slider position and bounces back to the live level on release, which is 0 with no input — clicking the meter does nothing), clicking the MIC label opens a microphone picker whose chosen source shows left-justified inside the meter bar (fill at 75% opacity so the name + ruler markings show through); slim dimensional slider — gradient track/fill, gloss-sphere thumb; the old flat pink 18px-filled one is gone), everything else on line 2 (bitrate/fps/dropped/duration/health left, quality + gear right) — the creator's only audio control, desktop/game audio is automatic (KISS rule). **Post-test follow-up (2026-08-13, game audio bar branch):** the **game audio bar** (desktop/game, a mirror of the mic bar: meter + mute + volume) is **overlaid at the bottom of the preview window** (bottom-center dark chip — monitoring UI only, never on the live output) and appears only while a **full-screen game is producing sound** in the preview (`IGameAudioDetector` seam + default `GameAudioDetector` polling `IFullScreenDetector` + the live loopback level, floor 0.5%, into a pure `GameAudioHysteresis`: SHOW after ~500ms of fullscreen+sound, HIDE ~1s after leaving fullscreen, **silence never hides an active bar**; the VM polls it on a 250ms `DispatcherTimer`); **capture now runs for the app's lifetime** (mixer started once at startup via `StartMicCaptureAsync`, disposed in `Shutdown` — not go-live) so the meters preview live; the **MIC label became a button with a status dot** (`Models/MicStatus`: green = connected via the source `Started` event, yellow = mic problem, red = no device); picking a mic takes effect immediately (`AudioMixer.RestartMic`, loopback keeps running); fixed a latent `?.Invoke(meter.Push(...))` short-circuit that skipped the meter update when nothing was subscribed. **Round 2 (2026-08-13):** the meters were dead on a flat scale (real speech/game RMS is ~0.01..0.1 linear) — the raw level is now mapped via `AudioLevelMeter.ToDisplay` (−60..0 dBFS spread across 0..1) so typical input reads ~1/3..2/3 of the bar at default volume. 169 tests passing, 0 warnings. **Round 3 (2026-08-13):** the mic bar gained a **mic mute icon** (a microphone glyph in the speaker's 16px style, red + slash when muted) between the meter and the speaker — both mutes adjacent with spacing between them, same `ToggleMicMuteCommand`; and the top-center LIVE badge became an **always-visible REC sign** — dark gray dot + dim "REC" offline, bright red (#e94560) + "REC" + elapsed while live, **darker red (#8f1f1f)** when live with a **private** stream (driven by the dialog's chosen visibility) — `RecDotBrush`/`RecTextBrush`/`RecDotOpacity`/`IsLivePrivate`, pulsing while live. **Queued:** task 22 (voice filters). Voice-filter note: the meter's `ToDisplay` input is the pre-filter mic level; when filters land they must apply BEFORE the meter/encoder mix
|
||||
13. ✅ The connected YouTube account's avatar/name shows in the top bar next to Start Stream (`SyncConnectedAccount`); the scenes list is content-height now (no dead space before SOURCES)
|
||||
14. ✅ **Social bar v2 (six-slot dialog, sign-in gate, real logos)** — global bar layer (never a Source, no sources-list row), content-sized, centered, GREEN glow when ON, top/bottom snap-drag (default BOTTOM, persisted `SocialBarPosition`; drag clamps to 0/1040, tie→bottom). Footer Social button gets a state dot (green=ON). Dialog "Social Media Site Promotion" (`SocialsDialog` + `ViewModels/SocialsDialogViewModel`, WPF-free + injected `ISocialValidator`/sign-in/sign-out fakes): ON/OFF bar switch (`SocialsConfig.BarEnabled`, schema v8), 6 fixed slots — row 1 always YouTube (signed-in → channel handle; signed-out → sign-in gate → OAuth; delete → confirm sign-out, mirrors `StopStream`), row 2 free, rows 3-6 lock icons on freemium (Premium seam: all six). Validation: `DetectService` (URL domain / fediverse `@user@domain` / bare→Website) → async `ISocialValidator` on confirm/Save; valid snaps to text + real service logo (bundled SVG path data via `LogoDataFor`, Simple Icons CC0 — initials badges gone); invalid → red do-not, stays editable, Save blocked. LCR justify dropped (`BarJustify` unread), per-scene toggle dropped (`Scene.HasSocialBar` back-compat). **Post-test fixes (2026-08-12):** footer label "Socials" (not "Social"); fediverse `@user@domain` validates — the full handle is the identity end-to-end (`DetectService`/`CanonicalUrlFor`/`HttpSocialValidator` build `https://domain/@user`, no domain loss); **Cancel is a hard stop** — `ISocialValidator.LookupAsync` takes a `CancellationToken`, dialog VM owns a CTS, Cancel/X/Save abort in-flight lookups (HTTP request killed, canceled continuations never touch slot state), and `ConfirmEdit` skips re-submitting identical text (LostFocus on dismiss never re-fires a lookup). `HttpSocialValidator` now has real tests (fake `HttpMessageHandler`). 105 tests passing. **Post-test fixes (2026-08-12, round 2):** fediverse `@user@domain` no longer shows a generic chain — it resolves to the instance's actual software via nodeinfo (`/.well-known/nodeinfo` → `software.name`; `SocialService.Fediverse` enum member + `SocialEntry.FediverseSoftware` persisted in a new `SocialEntry.Software` column, schema migration by column-presence) and renders that software's bundled logo (`LogoDataForFediverse`: mastodon/peertube/pixelfed/misskey/lemmy/pleroma/firefish, generic fediverse honeycomb fallback). Dialog row-2 edit/trash icons were too dark — `IconButton` style gains `Foreground=#d0d0d0`; trash overrides `#e94560` (app red). 112 tests passing. **Post-test fixes (2026-08-12, round 3):** a fediverse handle whose identity domain is itself a redirect (e.g. YunoHost default-app subdomains — `@user@llamachile.tube` where the mastodon instance lives at `mastodon.llamachile.tube`) now still resolves its software: nodeinfo on the identity domain is SSO-blocked, so `HttpSocialValidator` follows the bare root `https://domain/` 302 to the real instance host and re-runs the nodeinfo lookup there.
|
||||
15. ✅ **Window capture** — absorbed into the Screen picker (no separate source type); dedicated window-as-source work is pending
|
||||
16. ❌ **Scene compositing (D3DImage/MediaElement)** — SUPERSEDED, permanently out (2026-09-01): the software output compositor (TASK 4 step 1) + the XAML preview ARE the design; a D3D11 swap stays a possibility behind the `VideoFrame` seam, not a feature
|
||||
17. ☐ **Text source** — live text ("Starting soon", "Back in 5", handle, callout)
|
||||
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 pop-ins (SHIPPED 2026-09-24 via TASK 43, NATIVE)** —
|
||||
free and ungated (see Monetization in `ai.md`). **v1 IN** per the complete-v1 ruling. The
|
||||
deliverable is now **native events + native animations, no third-party widget**: the chat feed
|
||||
(`liveChat/messages`, six types decoded — TASK 43 half 1) drives a `SourceType.AlertBox`
|
||||
("Stream Alerts", one celebration zone per layout) where each event plays its **own unique
|
||||
branded animation** (`AlertRenderer`/`AlertOverlayLayer`, 33ms ticker, `Advance` test clock).
|
||||
**Creator rulings:** a free-subscriber mention is a plain chat row — NO sub alert (YouTube
|
||||
emits no public event for free subs), NO viewer count (vetoed), and every card draws the
|
||||
"made with LlamaCasty!" brand line (each broadcast = free product placement). **Precursor
|
||||
(still open): the reward-event persistence half — the canonical `RewardEvents` SQLite table
|
||||
(all six types persisted per broadcast) + `superChatEvents.list` backfill + the session-report
|
||||
rollup (TASK 10 → "Related work — monetization awareness"). The shipped alert path parses
|
||||
events into `ChatMessage` directly in memory; the report reads that table once it exists.**
|
||||
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
|
||||
27. ✅ **Webcam row gates on the app-wide identity (2026-08-24, TASK 26)** — the (+) menu's Webcam item greys out whenever a webcam exists **anywhere** (`CanAddWebcam` = `StagedScene != null && _webcam == null`; renamed from `CanAddWebcamToStagedScene` whose per-scene rule let a second picker run from a scene lacking the config), raised at both `_webcam` mutation sites (create / last-config removal) + scene staging + elements change. Creator's visual pass found it: minis/no-capture-rows/capture-controls all good. ONE integration test `WebcamMenuGateTests.CanAddWebcam_Gates_On_The_AppWide_Webcam_Identity` (real window + temp DB seeded with a webcam in Starting; asserts greyed while Live staged, re-enabled after `RemoveSourceCommand` clears the last config + the `Webcam` DB row). Stale map fixed in the same commit: ai.md's "empty-canvas right-click Show Webcam" claim dropped — that XAML never shipped (`ShowWebcamCommand`/`CanShowWebcamInStagedScene` are wired but unbound dead code, audit item). 224 tests (223 pass; the pre-existing AudioPipelineTests failure is unrelated)
|
||||
28. ✅ **YouTube Chat layer: one-per-layout gate + legacy label heal (2026-08-24, TASK 27)** — same rule as TASK 26 applied to the chat layer: the (+) menu's YouTube Chat item greys out while any scene carries a ChatBox source (`CanAddYouTubeChat`, raised on staging + elements change; `AddSource` refuses a second), tooltip "One chat layer at a time — it's already in your stream". Plus the creator's label fix: layers added by commit `65641d8` were named "Chat"; LoadLayout now heals exactly that un-renamed default to "YouTube Chat" so the Layers row matches the (+) picklist (creator renames untouched, idempotent). ONE integration test `ChatLayerGateTests.LegacyChatName_Heals_And_CanAddYouTubeChat_Gates_On_The_Existing_Layer` (real window + temp DB with a legacy "Chat" row: healed on load + persisted on save, greyed cross-scene and in-scene, re-enabled after remove). 225 tests (224 pass; pre-existing AudioPipelineTests failure unrelated)
|
||||
29. ✅ **Broadcast metadata pull-out + launch geometry (2026-08-24)** — creator ask: "a tab-pullout on the right side of the preview pane — white tab, YouTube-red label reading 'Text' — opening a form with all settable liveBroadcast fields; fields that can't be filled before launch greyed out; saving/updating remote content; field data saved and pre-loaded on app run". Shipped as specified with one correction: the API is the inverse of "more fields once live" for contentDetails (those lock in created/ready) — the pull-out carries the always-editable snippet/status set: Title, Description, Tags (csv), Visibility (private/unlisted/public), Made-for-Kids, plus read-only Scheduled Start. **Always visible** (creator revised same day from Live-only gating). White 30px tab, rotated red "Text", right edge vertically centered; click slides a dark 320px drawer left over the preview (200ms CubicEase). Every edit persists to Settings keys (`Broadcast.*`) immediately; **Update Broadcast** button calls the new `YouTubeStreamService.UpdateBroadcast` (PUT `liveBroadcasts?part=snippet,status`, echoes scheduledStartTime because update replaces the whole snippet part). Go Live prefills from the form and captures what was inserted (`CaptureGoLive`). The old un-persisted Default Stream Title/Description fields came OFF the App Settings overlay (replaced by the form); `DefaultStreamTitle/Description` VM properties deleted. **Launch geometry** (same unit): default 1920×1040, MinWidth 1366, MinHeight 768; `WindowStartupLocation=Manual` + window size/position persisted on close via `RestoreBounds` (maximized-safe), restored in ctor clamped to minimums and the primary work area (disconnected-secondary fallback). ONE integration test `BroadcastPullOutTests.Metadata_Persists_WindowRestores_Clamped_And_UpdatePatchesRemote`. 228 tests (227 pass; pre-existing AudioPipelineTests failure unrelated)
|
||||
30. ✅ **Gear menu cleanup + Default Location for Recordings + single Start button (2026-08-29)** — **KISS: removed** Gear-menu `Save Layout`/`Save Layout As…`/`Open Layout…` (redundant with auto-save; multi-layout = OBS) + dead `SaveLayoutCommand`/`SaveLayoutAsCommand`/`OpenLayoutCommand` + `SaveLayoutAs()`/`OpenLayoutFile()` methods. **App Settings panel:** first real setting — `Default Location for Recordings` (configured folder via `ChooseRecordFolderCommand` + "Use Downloads" reset via `ResetRecordFolderCommand`, resolved path shown). Fallback chain: configured → `%USERPROFILE%\Downloads` → `MyVideos`. `StartRecordFile()` + `ChooseRecordFolder()` `InitialDirectory` updated to use the new fallback. **Single Start button:** `PrimaryButtonText`/`IsStreamingStart` + their `OnPropertyChanged` calls removed; button `Content="Start"` in XAML — pills are the intent surface, button is always just Start. `Choose Record Folder` fast-path kept in the button context menu. Monetization (About links/premium) deferred to a later task. ONE fallback test `DefaultRecordFolder_Falls_Back_To_Downloads`. build 0 warnings, 247 tests (2 known failures, pre-existing).
|
||||
|
||||
### The Minimal Source Set (design decision — do not expand casually)
|
||||
|
||||
ytLlive is YouTube-only and 90% of users are casual. OBS's long source list is off-putting; we ship
|
||||
the hot few and nothing esoteric. If a user needs more, they've graduated to OBS.
|
||||
|
||||
1. **Webcam** — the face cam. Non-negotiable.
|
||||
2. **Screen** — the main event (game, slides, browser). One source; a picker chooses a monitor *or* a
|
||||
window. (Window capture is absorbed here — no separate source type.)
|
||||
3. **Background** — a full-canvas backdrop image. Fills the whole scene automatically, zero fiddling.
|
||||
Kept separate from Image on purpose: same pixels, but this one needs no positioning.
|
||||
4. **Image** — a floating graphic/logo overlay (watermark, badge, corner branding). Free-positioned.
|
||||
5. **Text** — live text ("Starting soon", "Back in 5", handle, callout). Casual streamers live on this.
|
||||
6. **Chat box** — YouTube live chat rendered *on* the stream so viewers read along in-video. YT-native.
|
||||
7. **Alerts** — Super Chat / membership pop-ins. The dopamine source. **The one big lift — DONE
|
||||
(2026-09-24, TASK 43): native events + native animations.** One `SourceType.AlertBox`
|
||||
celebration zone; six event types = six unique branded animations; free-sub mention = chat row
|
||||
only; no viewer count. **Free and ungated** — paid = flash removal only (corrected 2026-09-01).
|
||||
Pending: the `RewardEvents` SQLite persistence half (item 20; the report + journey read it,
|
||||
TASK 10 → related work).
|
||||
|
||||
Deliberately NOT supported: game capture, browser source, media playlist, VLC, color-key voodoo, MIDI.
|
||||
|
||||
### Source memory model (design decision)
|
||||
|
||||
1. A scene has resources. Resources can be shared across scenes.
|
||||
2. A resource exists exactly once in memory, no matter how many scenes use it (a logo in five
|
||||
scenes = one loaded bitmap).
|
||||
3. Every resource carries a **catalog of scenes**: one usage entry per scene it appears in, each
|
||||
entry dictating that scene's use — placement (X/Y/Width/Height), opacity, z-order, enabled,
|
||||
scale mode, crop.
|
||||
4. Usages are named `{resourceName}.{sceneName}` — whatever the user named the resource, dot, the
|
||||
scene name: `logo.starting`, `logo.live`, `myPic.brb`. Not a hardcoded "logo".
|
||||
5. A webcam in two scenes = one capture session, two catalog entries.
|
||||
6. Refcount by catalog size: the last usage removed → the resource is disposed and evicted.
|
||||
7. The resource (not a per-scene node) owns everything `IDisposable`.
|
||||
|
||||
### Scene transitions (design decision)
|
||||
|
||||
Scene switching while live must never stutter. Supported types, most → least economical:
|
||||
|
||||
1. **Cut** — instant switch. The default. Zero cost.
|
||||
2. **Fade** — short crossfade (~300ms).
|
||||
3. **Move** — a simple, economical move transition, done to perfection and memory-efficient. The
|
||||
smart streamer's bread and butter.
|
||||
4. **Custom (media) transitions** — require media elements (video/stinger playback during the
|
||||
transition). Heavier, but creators pay for these, so we support them. Their media follows the
|
||||
same resource memory model: loaded once, catalogued by scene.
|
||||
|
||||
Preview shows the transition too (WYSIWYG). No wipes/slides/LUTs beyond the four above.
|
||||
|
||||
### Requirements:
|
||||
|
||||
1. **Screen** — Windows.Graphics.Capture (WinRT), enumerate displays/windows, picker
|
||||
2. **Webcam** — MediaCapture (WinRT SDK projection) with device enumeration — ✅ **milestone 1 done**:
|
||||
1. TFM bumped to `net8.0-windows10.0.19041.0` (app **and** tests) so the WinRT projection resolves from the SDK reference packs — no NuGet package, no capability manifest (unpackaged desktop app)
|
||||
2. `MediaCaptureFrameSource` (CPU-first: `MemoryPreference = Cpu`, BGRA8 via `CreateFrameReaderAsync`), `MediaCaptureCameraEnumerator` (`DeviceInformation.FindAllAsync(DeviceClass.VideoCapture)`)
|
||||
3. `CameraManager`: refcounted by `DeviceId`, one shared `WriteableBitmap` app-wide, dispatcher-coalesced UI updates (~render rate, latest-frame drop), placeholder/`AppLog` + warning on failure
|
||||
4. `CameraPickerDialog` (mirror of `ReuseImageDialog`) — "Searching for cameras…" / list / "No cameras found" states
|
||||
5. One webcam app-wide: Add → Webcam greyed out once one exists ("it's already in your stream" tooltip); persisted `DeviceId` re-acquires after layout load
|
||||
6. Default placement 16:9 **480×270**, bottom-right, 32px margin; drag/resize/selection shared with Image sources
|
||||
7. **Clip shapes: Traditional + Round** (phone view dropped — the 9:16 phone output is the vertical output-crop tier); **mirror**; both persisted in the layout DB (schema v2) and toggled from the source chip
|
||||
8. **Background removal = milestone 2** (ONNX Runtime + DirectML, MediaPipe Selfie Segmentation) — not in this build
|
||||
3. **Background / Image / Text** — static sources positioned/scaled/opacity
|
||||
4. **Chat box** — rendered from the live chat poll (right panel is the same feed, raw)
|
||||
5. **Scene compositing** — per-scene source layering (z-order = sources list order, top-to-bottom
|
||||
back-to-front), preview rendered via D3DImage or MediaElement
|
||||
6. **Branding flash** — the topmost full-frame "made with ytLlive!" layer at ~25% opacity, ~1s on /
|
||||
300s off (see Monetization in `ai.md`), gated on `BrandFlashEnabled` + live/recording. Lives in the
|
||||
preview compositor now (`BrandFlashLayer` in `MainWindow.xaml` CanvasGrid, driven by
|
||||
`BrandFlashActive`/`BrandFlashTimer` in `MainViewModel`); the encoder output renders the same layer,
|
||||
and v0.2 local recordings carry it too
|
||||
7. **Drag/drop placement & reorder** — intuitive, visual (per design principle):
|
||||
1. **Preview:** click-drag a source in the center panel to reposition it; resize via handles
|
||||
2. **Scenes list:** drag rows to reorder scenes
|
||||
3. **Sources list:** drag rows to reorder sources (this *is* the z-order) — implemented
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
# TASK 4 — RTMP Ingest to YouTube
|
||||
|
||||
> Catalog: [`TASKS.md`](../TASKS.md) — status and requirements live here.
|
||||
|
||||
|
||||
**Goal:** Push encoded video to YouTube's RTMP ingest.
|
||||
|
||||
### Status: ✅ Done — all 7 items shipped
|
||||
|
||||
1. ✅ **Ship step 1 — the output compositor SHIPPED** (2026-08-10)
|
||||
2. ✅ **Ship step 2 — the FFmpeg locator SHIPPED** (2026-08-10)
|
||||
3. ✅ **Encoder + RTMP push SHIPPED** (2026-08-12) — the FFmpeg subprocess: raw BGRA frames via stdin, stderr health parsing, FLV mux + push to the ingestion URL (see the ship step 3 plan below)
|
||||
4. ✅ **WASAPI audio capture SHIPPED** (2026-08-12) — NAudio loopback (desktop/game) + the picked mic feeding `AudioLevel`, so the realtime meter comes alive (see the ship step 4 plan below)
|
||||
5. ✅ **Frame-pipeline wiring SHIPPED** (2026-08-12) — `CameraManager`/`ScreenCaptureManager` → compositor resolver → encoder, driven by a paced `FramePump` (see the ship step 5 plan below)
|
||||
6. ✅ **Health stats SHIPPED** (2026-08-13) — `FramePump.HealthUpdated` (encoder's parsed bitrate/FPS/dropped/duration, already forwarded from `FfmpegEncoder.OnStderrLine`) now lands in the bottom bar: `MainViewModel.OnFramePumpHealthUpdated` marshals to the UI thread (the stderr loop raises on a background thread) and copies into `CurrentHealth` (the bottom bar's existing binding); `ResetHealth` zeroes dropped/duration on go-live and on End so stats never linger from a previous session (bitrate/FPS stay on the tier's targets). The bar lights up with real values once TASK 9 supplies the RTMP URL (until then the pump skips the encoder and the bar shows the tier's targets)
|
||||
7. ✅ **One-click go live + private-only enforcement SHIPPED** (2026-08-14) — the Go Live dialog is **locked to Private** (no dropdown, `GoLiveViewModel.Visibility` is a get-only "Private"); `YouTubeStreamService.CreateBroadcast` **always sends `privacyStatus = "private"`** (dialog + service enforcement, requirement 8 — nothing can go out non-private) and gained an injectable `HttpClient? http = null` seam for tests; `BeginGoLive` now calls `CreateBroadcastAsync` and remembers `_currentBroadcastId` for TASK 9's bind/transition (failure → `StreamStatus.Error`, never a crash; `StopStream` clears the ID); the REC sign shows a **PRIVATE badge** (dark-red border, next to REC, `IsLivePrivate`) when the live stream is private; settings' dead "Default Visibility" dropdown + `MainViewModel.Visibilities`/`DefaultStreamVisibility` removed. The broadcast-insert integration test asserts the request body carries `"privacyStatus":"private"` (2 new tests → 173 passing, 0 warnings)
|
||||
|
||||
The pipeline chain the encoder needs doesn't exist yet: **scene compositing** (the master 1920×1080 frame
|
||||
without the preview's editing chrome) → **audio capture** (WASAPI, feeds the meter) → **H.264+AAC encode**
|
||||
→ **vertical-tier crop/scale** → **RTMP push** → **health stats** into the bottom bar. Nothing can encode
|
||||
until a frame source exists, so the compositor is ship step 1. The pipeline is
|
||||
`CameraManager + ScreenCaptureManager → compositor resolver → compositor → encoder → RTMP`.
|
||||
|
||||
### Requirements:
|
||||
|
||||
1. **Encoding** — H.264 (hardware via NVENC/AMD, fallback x264) + AAC audio; **must comply**: keyframes ≤ 4s (gopSizeLong), closed GOP, AAC/MP3 @ 44.1/48kHz, mono/stereo only. **License posture (decided): GPL-free build** — NVENC (NVIDIA) / QSV (Intel) / AMF (AMD) + OpenH264 software fallback + built-in AAC; no libx264 (GPL contaminates a paid product). Output containers are identical either way (H.264+AAC in `.flv` for RTMP, `.mp4`/`.ts` for VOD) — the format is NOT the differentiator, the license and per-GPU quality are. **License guardrails (never violate — see `ai.md` → "Licensing — do not violate"):** only BtbN `lgpl`/`lgpl-shared` builds; never GPL (gyan.dev) or `nonfree` (fdk-aac); never static for distribution (LGPL §6 relink material); never link FFmpeg into the app; never drop `THIRD-PARTY-NOTICES.txt` from the app/About screen.
|
||||
2. **RTMP push** — **FFmpeg subprocess (decided)**: app feeds raw frames via stdin, parses stderr for health; one battle-tested binary does encode + FLV mux + push. **No self-heal (claim-check 2026-09-01): ffmpeg's reconnect flags are input-side; an RTMP *output* push does not reconnect itself — retry is app-side, owned by TASK 32.** **Binary distribution (decided): check-then-pull** — probe `where ffmpeg`/PATH at first go-live; if absent, download a **pinned** build (**BtbN LGPL-shared win64** zip, ~75 MB — gyan.dev's builds are GPLv3 and ship libx264, which violates the license posture; BtbN's LGPL variant drops x264/x265 while keeping NVENC/QSV/AMF + libopenh264 + native AAC) to `%APPDATA%\ytLlive\tools\ffmpeg.exe` (extract `ffmpeg.exe` **plus the `libav*.dll` family**) and cache it, offline-friendly. Behind an `IFfmpegLocator` seam so tests fake it (ship step 2, below). Push goes to the cached reusable stream's ingestion URL
|
||||
3. **Quality ladder** — the offered tiers, with **1080p60 @ 8 Mbps as the standard/default**:
|
||||
1. 720p30 @ 6 Mbps
|
||||
2. 720p60 @ 6 Mbps
|
||||
3. 1080p30 @ 8 Mbps
|
||||
4. **1080p60 @ 8 Mbps** (default — mainstream ceiling, GPU hardware-encoded so the gaming
|
||||
machine never notices; upload headroom stays comfortable)
|
||||
5. Vertical 1080×1920 @ 60fps @ 8 Mbps (9:16 phone tier)
|
||||
The composition master is always 1920×1080; a tier is an output rect + target resolution
|
||||
(see `ai.md` "Resolution tiers"). Vertical output = the centered 607×1080 crop of the master
|
||||
scaled to 1080×1920 (semi-crop preview is already implemented; the encoder applies the same rect).
|
||||
1080p60 is the ceiling by design — "if you want 1440 or 4K or 8K → OBS is your solution"; the app
|
||||
targets the most mainstream creator, not power users.
|
||||
Ladder is sculpted by a **cached probe** (IP-only TCP vs public ingest host; no auth required).
|
||||
Quality is greyed out while live because the declared resolution can't change mid-stream — but
|
||||
with `variable`, we can **auto step-down** bitrate/resolution on the fly with zero API calls
|
||||
(no stream recreation); 60fps presumes a hardware encoder — no hardware encoder → auto
|
||||
fallback to 720p60/1080p30
|
||||
4. **Stream key management** — reuse the cached reusable stream (one per channel) instead of creating a new one per go-live; prefill default YouTube ingest URL `rtmp://a.rtmp.youtube.com/live2`
|
||||
5. **Health stats** — bitrate, FPS, dropped frames reported live in the bottom bar (encoder-side)
|
||||
6. **One-click go live** — defaults that work out of the box
|
||||
7. **Audio capture (feeds the meter — this task ships the wiring)** — WASAPI loopback (desktop/game at unity, zero UI — "it just is") + the picked mic (`MicSourceName` from the `MicPickerDialog`). The mic capture feeds `AudioLevel` so the realtime meter comes alive (today it reads 0 — the mixer feed is pending, see `ai.md` audio notes). AAC mono/stereo @ 48 kHz per the compliance rules.
|
||||
8. **Private-only go live until v1 (reputation guard, decided 2026-08-10)** — until the v1 release, go-live is **locked to private streams only** so a software error can never publish something public/unlisted that damages the creator's reputation. RTMP push itself has no privacy — privacy lives on the YouTube **live broadcast object**, which this app already controls via its OAuth API calls. So the lock is purely API-side: the Go Live flow always creates/updates the broadcast with `privacyStatus = "private"` and a guard **refuses** to set anything else (same spirit as the Live-only backdrop policy). The UI shows a clear "PRIVATE" badge next to the stream state so the creator always knows who can see them. Enforcement must be verifiable in the auth-service tests (fake the broadcast-insert/update call, assert `privacyStatus` is forced to private).
|
||||
9. **v1 release gate: bundle the full license texts (decided 2026-08-10)** — `THIRD-PARTY-NOTICES.txt` currently links the canonical license texts rather than embedding them. At the **v1 (GA) release**, the full texts of every license it names (LGPL v2.1+, BSD-2-Clause, MIT, Apache-2.0) MUST be bundled alongside it (shipped in the app output, e.g. a `licenses/` folder next to the notices file, still reachable from the About screen). This is a **release blocker for v1, not a task to queue early** — do it in the release pass. The repo should treat this like the private-only go-live gate: a checkbox that cannot silently lapse.
|
||||
|
||||
#### Ship step 1 — Scene compositor (the frame source)
|
||||
|
||||
**Goal:** a pure-CPU software compositor producing the encoder's master frame (BGRA8, the `VideoFrame`
|
||||
seam) from the scene model. The preview stays XAML (the editing view); the compositor is the **output
|
||||
view** — WPF's `RenderTargetBitmap` can't be used (software-rendered + captures chrome). Two renderers
|
||||
must agree, so the XAML (`MainWindow.xaml` CanvasGrid + element DataTemplate) is the contract.
|
||||
|
||||
**Decisions (locked 2026-08-10):** **Path A CPU blitter** — GPU effort belongs to NVENC (the encoder),
|
||||
not composition; with an FFmpeg subprocess the master crosses a CPU readback to the pipe every frame
|
||||
anyway, so GPU compositing buys ~nothing at this layer count (2-3 live layers; static layers
|
||||
pre-composite once). A D3D11 compositor can replace this one later **behind the same seam** (the CPU
|
||||
master buffer stays the contract). **Render the output rect directly**: compositor is constructed with
|
||||
`CompositorOptions {SourceRectX/Y/W/H, OutputWidth, OutputHeight}`; 16:9 tiers = full 1920×1080 1:1;
|
||||
vertical (9:16) = composite the centered 607×1080 crop then bilinear-upscale to 1080×1920. Reuses
|
||||
`MainViewModel.OutputRectX/Y/W/H` (note `(1920−607)/2 = 656.5` → align to integer pixels for output).
|
||||
|
||||
**Render spec (back → front, mirror the XAML exactly):**
|
||||
1. Backdrop — the Live scene's `IsBackdrop` Source (`CaptureKey` → live frame), `UniformToFill`
|
||||
full-frame (XAML's separate `BackdropImage` layer; the backdrop *element* renders nothing — its
|
||||
DataTemplate Image is Collapsed for DisplayCapture).
|
||||
2. Background — the scene's `Background` Source, `UniformToFill` full-frame (the `ActiveBackgroundImage`
|
||||
layer, not per-element).
|
||||
3. Elements in `Scene.Elements` order (back→front), skip `IsVisible=false`. What actually renders:
|
||||
1. `Source` Type `Image` → static asset, `UniformToFill` cover-crop into (X, Y, W, H)
|
||||
2. `WebcamSceneConfig` → latest frame by `DeviceId`: Traditional = `UniformToFill` rect; Round = circle
|
||||
diameter `min(W,H)` (alpha 0 outside — true circle, not oval); mirror = horizontal flip around
|
||||
element center (`MirrorScale`); opacity = per-pixel multiply (content + border); border = stroked
|
||||
rect / centered circle at `RoundBorderSize`, width `BorderWidth`, alpha `BorderOpacity`
|
||||
3. `Background` / `IsBackdrop` / `TextOverlay` are NOT per-element (layers above; Text not shipped)
|
||||
4. Branding flash — pre-rendered full-frame "made with ytLlive!" at 25% alpha when live +
|
||||
`BrandFlashEnabled` + timer active. Passed in as a `VideoFrame?` (compositor core stays pure byte-math,
|
||||
no WPF; likely a bundled asset rather than runtime text rendering).
|
||||
5. NOT in output (preview chrome only): SelectionOverlay, DimRects, output-rect outline, badge, placeholder.
|
||||
|
||||
**New files (all in `Services/Compositor/`):**
|
||||
1. `SceneCompositor.cs` — `Render(Scene, frameFor: Func<SceneElement, VideoFrame?>, flashFrame:
|
||||
VideoFrame?, CompositorOptions) → VideoFrame` (output-sized). The caller's `frameFor` resolver maps
|
||||
each element to its frame (webcam → DeviceId, image → AssetId via `StaticPixelCache`, backdrop →
|
||||
CaptureKey) — the compositor stays pure/hermetic/no WPF.
|
||||
2. `CompositorOptions.cs` — source-rect + output W×H.
|
||||
3. `StretchMath.cs` — `UniformToFill` cover-crop, ellipse mask, bilinear scale (pure, unit-tested).
|
||||
4. `StaticPixelCache.cs` — asset `byte[]` → cached BGRA `VideoFrame` (WPF `BitmapDecoder` + `CopyPixels`,
|
||||
decode once per content hash).
|
||||
|
||||
**Test plan (Good Dog Rule — ONE integration test):** `SceneCompositorTests` — a scene with backdrop
|
||||
(solid red fake frame) + round webcam (solid green) + image (solid blue) → render 16:9 master → assert
|
||||
per-layer probe pixels (corner = backdrop color, element center = webcam color, outside the round clip =
|
||||
backdrop color, mirrored element swaps left/right); a vertical-tier variant asserts 1080×1920 output +
|
||||
crop fidelity. Focused unit tests on `StretchMath`. Tests push frames directly — no capture managers
|
||||
involved (they wire in a later step).
|
||||
|
||||
**Same-PR housekeeping:** fix the stale comment `MainViewModel.cs:324` ("shown under the meter on line 2"
|
||||
→ "shown left-justified INSIDE the meter bar" — `ai.md` is the authority); this task's requirements now
|
||||
include the explicit audio-capture/meter wiring (#7 above).
|
||||
|
||||
**Out of scope (later ship steps):** FFmpeg locator + license posture (covered in requirements 1-2),
|
||||
encoder + RTMP push, WASAPI audio capture (loopback + mic) feeding `AudioLevel`, wiring
|
||||
`CameraManager`/`ScreenCaptureManager` into the frame pipeline, brand-flash timer wiring, health stats
|
||||
(bitrate/FPS/dropped).
|
||||
|
||||
**Built (2026-08-10):** all four files shipped in `Services/Compositor/`, `SceneElement.TryGetBorderColor`
|
||||
made public (shared hex parse with the compositor — no duplicated color parsing), the stale
|
||||
`MainViewModel.cs:324` comment corrected, and the pre-existing CS1998 in `YouTubeAuthServiceTests`
|
||||
cleaned up — build **0 warnings**. Tests: the `SceneCompositorTests` integration test (full-scene master
|
||||
pixels, vertical tier, flash) + 4 `StretchMath` units — **72 passing**.
|
||||
|
||||
#### Ship step 2 — FFmpeg locator (the encoder's binary)
|
||||
|
||||
**Goal:** resolve a usable `ffmpeg.exe` on demand (the encoder's one external dependency), never shipping
|
||||
a binary in the repo. Returns an absolute path; downloads only when neither PATH nor the local cache
|
||||
provides one.
|
||||
|
||||
**Decisions (locked 2026-08-10):**
|
||||
1. **BtbN LGPL-shared win64 build** — not gyan.dev (gyan's "essentials" is GPLv3 and ships libx264, which
|
||||
violates requirement 1's license posture) and **not the static lgpl build**: LGPLv2.1 §6 wants
|
||||
relinkable object files for static linking, but the **shared** (dynamic-DLL) variant sidesteps that —
|
||||
compliance is "license text + source offer + unmodified binaries" (see `THIRD-PARTY-NOTICES.txt` and
|
||||
`ai.md` → Licensing). Drops libx264/libx265 while keeping NVENC/QSV/AMF, libopenh264 (the LGPL-legal
|
||||
H.264 software fallback) and native AAC — exactly the requirement-1 encoder profile.
|
||||
2. **Pinned URL** — **RE-PINNED 2026-09-01**: `https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-08-31-13-27/ffmpeg-N-126342-gf88b741dbf-win64-lgpl-shared.zip`
|
||||
(BtbN **lgpl-shared** variant, ffmpeg N-126342; ~75 MB zip — earlier "~30 MB" estimate corrected). A dated autobuild tag is immutable; BtbN retention
|
||||
keeps the last 14 daily builds + each month-end build for 2 years, so a cold cache after retention
|
||||
expiry 404s — a logged, recoverable failure (the seam throws; the encoder step surfaces it). The
|
||||
first pin (`autobuild-2026-08-09-13-03`, a daily) aged out on 2026-09-01 — the first real recording
|
||||
attempt — proving the rule; the new pin is deliberately the **month-end** build (2-year retention).
|
||||
Dated tags carry versioned asset names (`ffmpeg-N-…-win64-lgpl-shared.zip`), extraction is
|
||||
name-agnostic; download failures now wrap in `IOException` with an actionable message
|
||||
("refresh `FfmpegLocator.PinnedUrl` or install ffmpeg on PATH") instead of leaking a raw 404. Once
|
||||
cached, the URL is never touched again. The pin is a single `const`, bumpable in one place — and must
|
||||
always stay on the **shared** variant (never `gpl`, `nonfree`, or static; see ai.md Licensing).
|
||||
3. **Check-then-pull order** — (1) PATH probe (the user's own install wins), (2) cached
|
||||
`%APPDATA%\ytLlive\tools\ffmpeg.exe`, (3) download + extract. Extract `ffmpeg.exe` **plus the
|
||||
`libav*.dll` family** (the shared build's bin/ folder; Windows resolves the DLLs from the exe's own
|
||||
directory) into a staging dir then move into place — a crash never leaves a corrupt or partial cache.
|
||||
4. **Seam** — `IFfmpegLocator.LocateAsync(CancellationToken)`: search dirs, tools dir, and the downloader
|
||||
(`Func<string, CancellationToken, Task<byte[]>>`) are constructor-injected with production defaults, so
|
||||
tests fake the network (feeding a real in-memory zip) and never touch disk outside a temp dir.
|
||||
|
||||
**New files (all in `Services/Encoder/`):**
|
||||
1. `IFfmpegLocator.cs` — the seam.
|
||||
2. `FfmpegLocator.cs` — the impl (PATH probe → cache → pull+extract exe + DLLs), failures logged via `AppLog`.
|
||||
3. `THIRD-PARTY-NOTICES.txt` (repo root) — the LGPL/BSD/MIT notices + source offer, copied to the build
|
||||
output. *(Surfacing changed on 2026-08-13: the top-bar About button that opened the file in the OS
|
||||
viewer is GONE — the notices are reachable in-app via the logo → About overlay instead.)*
|
||||
|
||||
**Test plan:** the hermetic integration test drives the full decision ladder against a temp tools dir and
|
||||
a fake downloader returning a real in-memory zip (`.../bin/ffmpeg.exe` entry): PATH hit wins without
|
||||
downloading, cache hit skips the network, cold cache downloads → extracts → `ffmpeg.exe` lands in the
|
||||
tools dir, and a second call serves the cache (downloader invoked exactly once). Focused unit tests:
|
||||
**shared-build DLLs extract alongside the exe**, empty zip throws, missing entry throws, empty download
|
||||
throws, downloader failure propagates, zero-byte cache is refreshed.
|
||||
|
||||
**Same-PR housekeeping:** requirement 2's stale binary facts corrected in this plan (~30 MB → ~75 MB zip;
|
||||
"gyan.dev/BtB N" → BtbN LGPL-shared only, with the why); the "never do" licensing guardrails recorded in
|
||||
`ai.md` so the reasoning survives.
|
||||
|
||||
**Out of scope (later ship steps):** the FFmpeg subprocess encoder (frames in via stdin, stderr health
|
||||
parsing), RTMP push, WASAPI audio capture, the frame-pipeline wiring, health stats.
|
||||
|
||||
**Built (2026-08-10):** `IFfmpegLocator` + `FfmpegLocator` shipped in `Services/Encoder/`, pinned to the
|
||||
**lgpl-shared** build `autobuild-2026-08-09-13-03` (extracts `ffmpeg.exe` + the `libav*.dll` family via a
|
||||
staging dir). `THIRD-PARTY-NOTICES.txt` (repo root) ships to the build output; the "never do" licensing
|
||||
guardrails are recorded in `ai.md` — build **0 warnings**.
|
||||
Tests: the hermetic `FfmpegLocatorTests` integration test (PATH → cache → download decision ladder with a
|
||||
fake downloader serving a real in-memory zip) + edge/unit cases (shared-build DLL extraction, zero-byte
|
||||
cache refresh, empty payload, missing zip entry, downloader failure) — **78 passing**.
|
||||
|
||||
#### Ship step 3 — Encoder + RTMP push (the FFmpeg subprocess)
|
||||
|
||||
**Goal:** encode raw BGRA master frames into H.264+AAC FLV and push them to the reusable stream's RTMP
|
||||
ingestion URL — one battle-tested subprocess doing encode + mux + push (retry is app-side, TASK 32 — ffmpeg does not self-reconnect an RTMP output), the app feeding
|
||||
frames via stdin and parsing stderr for health (req 2).
|
||||
|
||||
**Decisions (locked):** the encoder is a thin orchestrator over `ffmpeg.exe` — no H.264/AAC code in the
|
||||
app. Arguments (pure `FfmpegArgs.Build`): `-re -f rawvideo -pix_fmt bgra -video_size WxH -framerate FPS
|
||||
-i pipe:0` (frames in), a **silent placeholder audio track** via `-f lavfi -i anullsrc` (the WASAPI
|
||||
capture step replaces this input), `-c:v <encoder> -b:v K -maxrate K -bufsize 2K` + **`-g fps×4`
|
||||
`-keyint_min fps×4` `-sc_threshold 0` `-bf 0` `-pix_fmt yuv420p`** (the keyframe ≤4s / closed-GOP /
|
||||
H.264 compliance), `-c:a aac -ar 48000 -ac 2`, `-f flv <rtmpUrl>`. Encoder choice is **probed from the
|
||||
binary's `-encoders` listing** (`FfmpegEncoderPicker`, pure): hardware NVENC → QSV → AMF, then OpenH264
|
||||
software fallback — **never libx264** (GPL; see `ai.md` → Licensing). The seam (`IFfmpegEncoder` +
|
||||
`IEncoderProcess`, constructor-injected locator + process factory) keeps it hermetic — tests fake the
|
||||
whole subprocess (probe + encoder), no real binary.
|
||||
|
||||
**Behavior:** `StartAsync` (locate → probe → spawn → stderr loop), `SubmitFrameAsync` (serialized BGRA
|
||||
stdin writes, ~2 Hz health via `HealthUpdated`/`StreamHealth` — bitrate/FPS/duration/dropped-from-frame-
|
||||
count), `StopAsync` (stdin EOF → ffmpeg finalizes + exits by itself; 10s watchdog kill), `ProcessFailed`
|
||||
on a non-zero unexpected exit.
|
||||
|
||||
**Built (2026-08-12):** `EncoderOptions` + `IFfmpegEncoder`/`FfmpegEncoder` + `IEncoderProcess`/
|
||||
`FfmpegEncoderProcess` + pure `FfmpegArgs`/`FfmpegProgressParser`/`FfmpegEncoderPicker` in
|
||||
`Services/Encoder/`. Not yet constructed by the app (the frame-pipeline wiring, ship step 5, owns it).
|
||||
Tests: `FfmpegEncoderTests` integration (probe → spawn with NVENC preferred → frames into stdin →
|
||||
progress parsed → graceful stop, no kill) + units (args compliance/GOP, progress parser, picker
|
||||
preference + GPL guard, no-URL/not-running/noop stops, process-death `ProcessFailed`) — **122 passing**.
|
||||
|
||||
#### Ship step 4 — WASAPI audio capture (the meter comes alive)
|
||||
|
||||
**Goal:** capture desktop/game audio (loopback) and the picked mic, feed the mic level into `AudioLevel`
|
||||
so the realtime meter reads something other than 0, run capture **only while live** (req 7).
|
||||
|
||||
**Decisions (locked):**
|
||||
1. **NAudio `NAudio.Wasapi` 2.2.1** — the wasapi feature package (not the `NAudio` meta-package): it
|
||||
carries the capture types (`WasapiCapture`/`WasapiLoopbackCapture` + the MMDevice enumeration) with
|
||||
`NAudio.Core`/`NAudio.Asio` pulled in transitively. MIT — recorded in `THIRD-PARTY-NOTICES.txt` (item 9).
|
||||
2. **`IAudioSource` seam** (`Start`/`Stop`/`SampleReady`/`Failed`, IDisposable) — the app consumes the
|
||||
seam; the two WASAPI implementations wrap NAudio; tests inject hermetic fakes (no real audio devices,
|
||||
no timers). Loopback = `WasapiLoopbackCapture` on the default render device; mic = `WasapiCapture`
|
||||
with the NAudio device resolved by `FriendlyName` matching `MicSourceName` (the app only persists the
|
||||
DisplayName), falling back to the default capture endpoint. Mic device resolution is re-read at each
|
||||
`Start` via a name provider so a mic picked mid-session takes effect next go-live.
|
||||
3. **`AudioMixer` owns both sources** — starts/stops both with go-live (`BeginGoLive` success → `Start`,
|
||||
`StopStream` → `Stop`). Mic samples feed a pure `AudioLevelMeter` (RMS, exponential smoothing) and
|
||||
raise `MicLevelChanged`, marshalled to the UI thread into `AudioLevel`; desktop samples are currently
|
||||
dropped (consumed by the encoder's AAC mix in a later step). Capture failures are logged via `AppLog`
|
||||
(mic failure also zeroes the meter); loopback failure doesn't kill the mic.
|
||||
4. **Byte→float** — pure `WaveToFloat.Convert` handles the WASAPI mix formats: IEEE float 32-bit (direct)
|
||||
and PCM 16-bit (normalized to -1..1), including `WaveFormatExtensible` with the IEEE-float subformat
|
||||
GUID. Trailing partial samples are ignored.
|
||||
|
||||
**Built (2026-08-12):** `Services/Audio/` ships `IAudioSource` + `AudioSample`, `WasapiLoopbackAudioSource`,
|
||||
`WasapiMicAudioSource`, `AudioMixer`, `AudioLevelMeter`, `WaveToFloat`; `MainViewModel` constructs the
|
||||
mixer (mic source fed `() => MicSourceName`), starts it on go-live and stops it on end-stream, and maps
|
||||
`MicLevelChanged` → `AudioLevel`. A pre-existing CS8602 in `FfmpegEncoder.cs:139` surfaced during this
|
||||
step's rebuild and was fixed (`process!`) — build **0 warnings**. Tests: `AudioMixerTests` (mixer
|
||||
lifecycle/forwarding/failure against fakes, meter RMS/smoothing/reset, `WaveToFloat` float/PCM16/
|
||||
extensible/truncation) — **139 passing**.
|
||||
|
||||
**Deferred (later ship steps):** wiring the desktop-capture samples into the encoder's AAC mix (replaces
|
||||
the `-f lavfi -i anullsrc` placeholder; the encoder construction itself shipped in ship step 5).
|
||||
*(The "capture while not live" + "audio UI beyond the mic controls" deferrals were SHIPPED on the
|
||||
2026-08-13 game audio bar branch — capture is now always-on for preview and the game bar is the second
|
||||
audio UI. The mixer's short-circuit meter fix + `Started`/`RestartMic` seams live in the same branch.)*
|
||||
|
||||
#### Ship step 5 — Frame-pipeline wiring (the encoder gets a frame source)
|
||||
|
||||
**Goal:** the chain `CameraManager`/`ScreenCaptureManager` → compositor resolver → encoder, driven while
|
||||
live by a paced frame pump: snapshot the active scene → resolve each element to its latest frame →
|
||||
composite into the tier's output frame → pace into the encoder's stdin at the tier's FPS.
|
||||
|
||||
**Decisions (locked via user Q&A, 2026-08-12):**
|
||||
1. **Video pipeline first** — the `-f lavfi -i anullsrc` silent track stays; mixing the loopback/mic
|
||||
WASAPI samples into the encoder's AAC track is its own later step.
|
||||
2. **RTMP URL via a provider seam** — `MainViewModel._rtmpUrlProvider` is a `Func<string?>` returning
|
||||
null today (the reusable stream's ingest URL lands with TASK 9); when it yields null the pump logs
|
||||
and skips the encoder entirely, so go-live runs the existing visual flow without pushing.
|
||||
|
||||
**Design:**
|
||||
1. `Services/Encoder/FramePump.cs` — the frame producer. All collaborators constructor-injected seams
|
||||
(`Func<Scene?>`, `Func<SceneElement, VideoFrame?>` resolver, `Func<CompositorOptions>`,
|
||||
`Func<EncoderOptions?>`, `Func<IFfmpegEncoder>`, `Action<string>` log, injectable pacing delay) so it
|
||||
stays free of WPF and of the capture managers and is hermetic in tests. `StartAsync` never throws
|
||||
(failures log + surface via `Failed` — the VM fires-and-forgets from the sync command handler);
|
||||
loop = snapshot → render → `SubmitFrameAsync`, paced at `1/options.Fps` (default `Task.Delay`; tests
|
||||
inject `Task.Yield`). `StopAsync` stops the encoder (closes stdin) BEFORE awaiting the loop — closing
|
||||
stdin unblocks a write stuck on pipe backpressure, so stop can't deadlock on the pump. `ProcessFailed`
|
||||
self-stops the pump. `HealthUpdated` forwards the encoder's stats (ship step 6 binds the bottom bar).
|
||||
2. `ScreenCaptureManager.GetLatestFrame(key)` — mirrors `CameraManager.GetLatestFrame(deviceId)`; the
|
||||
backdrop's live frame for the compositor.
|
||||
3. `MainViewModel` — owns the resolver (`WebcamSceneConfig` → `GetLatestFrame(WebcamId)`;
|
||||
`Source.IsLiveCapture` → `GetLatestFrame(CaptureKey)`; image/background → `StaticPixelCache.Get(AssetId)`),
|
||||
builds `CompositorOptions` from the tier + `OutputRect*` (doubles rounded to ints — the vertical
|
||||
607.5 half-pixel crop rounds to a perfectly-centered 608), builds `EncoderOptions` from the tier when
|
||||
the URL provider returns one, constructs the real `FfmpegEncoder(new FfmpegLocator())`, starts the
|
||||
pump on go-live, stops it on end-stream, disposes in `Shutdown`, and flips `StreamStatus.Error` when
|
||||
the pump fails while live (minimal — detailed health surfacing is ship step 6).
|
||||
|
||||
**Test plan (Good Dog Rule — ONE integration test):** `FramePumpTests.Start_CompositesScene_FeedsEncoder_StopsCleanly`
|
||||
drives the full lifecycle against fakes — real `SceneCompositor` + real `FramePump`, fake `IFfmpegEncoder`
|
||||
— asserting the composited red backdrop frame actually reaches the encoder at the tier size and that stop
|
||||
tears everything down. Units: no-URL start skips the encoder, re-entrant start/stop no-ops, encoder
|
||||
start-failure raises `Failed` + disposes, `ProcessFailed` self-stops the pump, `HealthUpdated` forwards.
|
||||
`ScreenCaptureManagerTests.GetLatestFrame_ReturnsLatestPump_UntilReleased` pins the new accessor.
|
||||
|
||||
**Out of scope (later ship steps):** the loopback/mic → AAC mix (replaces `anullsrc`), health stats in the
|
||||
bottom bar (ship step 6), scene-switching transitions, and any flash-frame wiring.
|
||||
|
||||
**Built (2026-08-12):** `FramePump` shipped in `Services/Encoder/`, `ScreenCaptureManager.GetLatestFrame`
|
||||
added, `MainViewModel` wired end-to-end (resolver + both option builders + pump lifecycle), `FramePumpTests`
|
||||
(7) + `GetLatestFrame` test (1) added — build **0 warnings**, **147 tests passing**. Known consideration:
|
||||
the pump reads the active scene on a background thread while the UI can still edit it; a concurrent-mutation
|
||||
exception is contained (logged + `Failed` + pump stops) rather than crashing.
|
||||
|
||||
#### Ship step 5.5 — Social bar bug fixes + the bar on the live output (2026-08-13)
|
||||
|
||||
**Bug 1 — bar wouldn't reliably change position (root cause found, then simplified):** the original
|
||||
drag set `Canvas.SetTop(bar, …)` with a local value, which permanently overrides the
|
||||
`Canvas.Top="{Binding SocialBarTop}"` binding — the release-time `SetSocialBarPosition` →
|
||||
`PropertyChanged(SocialBarTop)` could never beat it. First fix added direction-snapping during the drag
|
||||
(`SocialBarSnap.Decide`, ±6px deadzone) + `bar.ClearValue(Canvas.TopProperty)` on release — but that
|
||||
still misbehaved for shaky hands (jitter around the deadzone: it snapped up reliably, then refused to
|
||||
come back down and snapped back to top). **Superseded by a click-toggle (KISS, user decision):** clicking
|
||||
the bar in the preview flips it top ⇄ bottom (`MainViewModel.ToggleSocialBarPosition` → the existing
|
||||
`SetSocialBarPosition`), the bar rides `{Binding SocialBarTop}` alone (no local values, no deadzone, no
|
||||
jitter sensitivity), and `SocialBarSnap` was removed. The `ClearValue` lesson stands: never set a local
|
||||
value on a property the binding owns.
|
||||
|
||||
**Bug 2 — Mastodon showed the generic 7-star honeycomb (root cause found):** the DB row
|
||||
`@gramps@llamachile.tube` had `Software = NULL` — nodeinfo was only ever resolved against the identity domain
|
||||
(`llamachile.tube`, a landing page), never probed for the real instance at `mastodon.llamachile.tube`.
|
||||
Fixed on three fronts: `HttpSocialValidator` now **probes well-known subdomains** (mastodon. → social. →
|
||||
… `FediverseSubdomainCandidates`) when the identity domain and its redirect both come up empty, under a
|
||||
~15s linked-CTS budget, with an optional `Action<string>` log; `MainViewModel` **heals** any fediverse entry
|
||||
missing a software name on layout load (`HealFediverseSoftwareAsync` — static, testable; instance wrapper
|
||||
runs it off the UI thread, applies via the dispatcher, saves); `SocialEntry.FediverseSoftware` is now
|
||||
**settable** and raises `PropertyChanged` for `LogoData`, so the heal updates the icon in place. If the user
|
||||
later re-saves the entry with the icon fixed, the name persists with it.
|
||||
|
||||
**Compositor rendering (user-approved scope):** the social bar now appears **on the live output**, not just
|
||||
the preview — `Compositor/SocialBarRenderer.cs` rasterizes the entries into a transparent straight-alpha
|
||||
BGRA strip (1920-wide, 40px content + 24px glow pad, green `#2ecc71` glow baked in) via `RenderTargetBitmap`
|
||||
(WPF glue like `StaticPixelCache`; the compositor core stays pure). `SceneCompositor.Render` takes optional
|
||||
`socialBarFrame` + `socialBarTop` (master space) and blits it **last — above the branding flash** (the old
|
||||
`BlitFlash` generalized to offset `BlitOverlay`). `FramePump` gains a `socialBar:` seam
|
||||
(`Func<(VideoFrame?, SocialBarPosition)>`, re-read every frame) and places the bar at `0` or
|
||||
`SourceRectHeight − bar height`. `MainViewModel` owns the frame (`RenderSocialBarFrame`, re-rendered on
|
||||
load/save/notify) and feeds the seam.
|
||||
|
||||
**Tests (+6 → 153 passing, 0 warnings):** settable `FediverseSoftware` updates `LogoData`,
|
||||
subdomain-probe unit + null-when-silent unit, the branch's **one integration test**
|
||||
`Socials_HealMissingFediverseSoftware_RoundTripsThroughDb` (temp-DB roundtrip: NULL software → healed via
|
||||
the validator → persisted), compositor bar overlay (top/bottom + above-flash), `FramePump` bar pass-through
|
||||
(Top then flipped to Bottom mid-run — the seam is re-read each frame), and both `ISocialValidator` fakes
|
||||
(`FakeValidator`/`BlockingValidator`) gained `ResolveFediverseSoftwareAsync`. The drag-snap units
|
||||
(`SocialBarSnap`) were removed with the click-toggle supersession.
|
||||
|
||||
**Not included (say the word):** rewriting the healed entry's `ProfileUrl` to `https://mastodon.llamachile.tube/@gramps`.
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
# TASK 5 — Layout Persistence (SQLite)
|
||||
|
||||
> Catalog: [`TASKS.md`](../TASKS.md) — status and requirements live here.
|
||||
|
||||
|
||||
**Goal:** Scenes, sources, and asset bytes survive restarts; assets are always available.
|
||||
|
||||
### Status: ✅ Done
|
||||
|
||||
1. ✅ SQLite database (`Microsoft.Data.Sqlite`) at `%APPDATA%\ytLlive\ytLlive.db`; schema versioned via `PRAGMA user_version` (currently **v8**)
|
||||
2. ✅ Assets live in the DB (BLOB keyed by SHA-256 content hash), never file paths — deleting the original file never breaks a scene
|
||||
3. ✅ File-model save/open — the active layout file is tracked (default is the AppData DB); **Save Layout As… / Open Layout…** switch the active file; auto-save writes to whatever is active
|
||||
4. ✅ Auto-save (invisible) — ~1.5s debounce on scene/source add/remove/reorder/rename/hide + any source transform change; flush on window close
|
||||
5. ✅ Startup — load the active file; seed the five canonical scenes only when the DB is empty; (+) re-adds a missing canonical scene and is hidden once all five are present; adding beyond the five is rejected
|
||||
6. ✅ Schema v1 → v9 — webcam columns (v2), singleton `Webcam` + per-scene `WebcamSceneConfig` (v3), `RectWidth`/`RectHeight` round-to-rect restore (v4), `Source.IsBackdrop` + `Source.CaptureKey` (v5), `Scene.HasBackdrop` — backdrop **Live-only by policy** (v6, one-time backfill + `EnforceBackdropPolicy` on every load), `Scene.HasSocialBar` (v7, dropped per-scene toggle — column back-compat, unread), `Socials.BarEnabled` (v8); the `SocialEntry.Software` fediverse-software column is a **column-presence migration** (commented v8→v9, no version bump — `user_version` stays 8); v9 = single-row `Music`. **Load-time rule:** sources load with `IsBackground` derived from `Type` (OR'd with the persisted column so legacy DisplayCapture backdrops keep their flag) — rows saved before the Type setter derived the flag heal on load
|
||||
7. ✅ WindowHandle stays in-memory (per-session); save = transactional rewrite; orphaned assets pruned. **v9 lands in the TASK 8 audio milestone** (single-row `Music` — `TrackPath`/`IsEnabled`)
|
||||
|
||||
### Design decisions
|
||||
|
||||
1. **SQLite database** (`Microsoft.Data.Sqlite`) at `%APPDATA%\ytLlive\ytLlive.db`; schema versioned
|
||||
via `PRAGMA user_version`.
|
||||
2. **Assets live in the DB, not on disk** — `Asset` table stores image bytes (BLOB) keyed by a
|
||||
SHA-256 content hash (unique). Identical image content collapses to one row regardless of file
|
||||
name — the 1:M resource memory model, enforced by the database. No file paths; deleting the
|
||||
original file never breaks a scene.
|
||||
3. **File-model save/open** — the active layout file is tracked (default is the AppData DB).
|
||||
**Save Layout As… / Open Layout…** switch the active file; auto-save writes to whatever is active.
|
||||
4. **Auto-save (invisible)** — ~1.5s debounce on scene add/remove/reorder/rename/hide, source
|
||||
add/remove/reorder, and any source transform change; flush on window close.
|
||||
5. **Schema** — `Scene` (Id, Name, IsHidden, IsChatScene, HasBackdrop, HasSocialBar, SortOrder), `Asset` (Id, Hash, Data,
|
||||
PixelWidth, PixelHeight), `Source` (Id, SceneId FK cascade, AssetId FK, Type, Name, IsEnabled,
|
||||
X/Y/Width/Height/Opacity, MonitorIndex, DeviceId, ClipShape, IsMirrored, SortOrder), `Socials` (Id,
|
||||
BarPosition, BarJustify — back-compat, unread, BarEnabled added via `ALTER`), `SocialEntry` (Id,
|
||||
SocialsId FK cascade, Service, Handle, ProfileUrl, SortOrder) — `user_version` **8** (v1 → v2 =
|
||||
`ALTER TABLE` adds the two webcam columns; v3 = singleton `Webcam` + per-scene `WebcamSceneConfig`;
|
||||
v4 = `WebcamSceneConfig.RectWidth`/`RectHeight` for the round-to-rect restore; v5 = `Source.IsBackdrop`
|
||||
+ `Source.CaptureKey` for the live-capture backdrop; v6 = `Scene.HasBackdrop` — the backdrop is
|
||||
**Live-only by policy** (one-time backfill turns Starting/BRB/Chat/Ending off and drops their backdrop
|
||||
sources; `EnforceBackdropPolicy` re-normalizes every load); v7 = `Scene.HasSocialBar` (per-scene toggle
|
||||
dropped — column back-compat, unread); v8 = `Socials.BarEnabled`). The `SocialEntry.Software`
|
||||
fediverse-software column is a column-presence migration (commented v8→v9, no version bump).
|
||||
`WindowHandle` stays in-memory (per-session). Save = transactional rewrite; orphaned assets pruned.
|
||||
6. **Startup** — load the active file; seed the five canonical scenes
|
||||
(Starting/Live/BRB/Chat/Ending, `SceneCatalog`) only when the DB is empty. The (+)
|
||||
button re-adds a missing canonical scene and is hidden once all five are present;
|
||||
adding beyond the five is rejected — work with less, never more.
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# TASK 6 — UI polish batch (scenes/sources rows, dedup naming, social bar)
|
||||
|
||||
> Catalog: [`TASKS.md`](../TASKS.md) — status and requirements live here.
|
||||
|
||||
|
||||
**Goal:** clean up the two side lists and the social bar per gramps's review.
|
||||
|
||||
### Status: ✅ Done
|
||||
|
||||
1. ✅ Scene rows are pure selection rows — the per-row edit/trash/visibility icons and the inline rename TextBox are gone (`EditSceneCommand`/`RemoveSceneCommand`/`ToggleSceneVisibilityCommand` + handlers + `Scene.IsEditing` removed; `IsHidden` stays persisted + dims hidden rows)
|
||||
2. ✅ Source rows gained the trio — edit (inline rename via new `EditElementCommand` + `SceneElement.IsEditing`), visibility eye (new `ToggleElementVisibilityCommand` flips `SceneElement.IsVisible`; the eye style now binds `IsVisible`, open/slashed + row dims to 0.45 when hidden), and the existing trash
|
||||
3. ✅ Duplicate resource names get a no-space incrementing suffix via shared `NextSourceName` (Image, Image2, Image3…) — next free number derived from the names actually in the scene, so deleting a middle resource never collides (`AddSource` + `AddReusedImage` both use it)
|
||||
4. ✅ Social bar renders the full validated handle — `MaxWidth=200` + `TextTrimming` removed from BOTH `SocialBarRenderer` and the preview template (mastodon `@gramps@…` no longer cuts off)
|
||||
5. ✅ Side panels stay fixed-width (left 220 / right 300) — deliberate: they never re-layout on resize, the preview absorbs it
|
||||
6. ✅ Focus-loss capture lag documented as a known OS limit in `ai.md` — deferred by user decision (no code change)
|
||||
|
||||
### Design decisions
|
||||
|
||||
1. **Next free number from names, not type counts** — the old scheme counted elements by `SourceType` (`count == 0 ? baseName : base+count+1`), which collided after deletions; the new helper scans actual names.
|
||||
2. **One WPF App per AppDomain** — the real-App tests (round-clip + naming) share `RealAppHost` (a dedicated STA thread owning the single `App`) via the `RealApp` serial collection, instead of each calling `new App()`.
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# TASK 7 — Meter scaling amplification (voice meter uses the full bar)
|
||||
|
||||
> Catalog: [`TASKS.md`](../TASKS.md) — status and requirements live here.
|
||||
|
||||
|
||||
**Goal:** the mic (and game) meters feel light — speech peaks should peg into the red at maxed volume.
|
||||
|
||||
### Status: ✅ Done
|
||||
|
||||
1. ✅ `AudioLevelMeter.ToDisplay` now adds **+10 dB of input amplification** before the −60..0 dBFS → 0..1 log mapping (was raw dB): speech peaks (~0.2 RMS, −14 dBFS) read ~0.93 → red zone; normal speech (~0.05, −26 dBFS) ~0.73 → yellow; background noise ≤0.001 linear (−60 dBFS) still reads 0 (the meter never idles on it)
|
||||
2. ✅ Unit tests updated to the new mapping + new `ToDisplay_Pushes_Speech_Peaks_Into_Red_At_Maxed_Volume` (0.2 → 0.92..0.95, 0.05 → 0.72..0.75)
|
||||
|
||||
### Design decisions
|
||||
|
||||
1. **Amplify at the mapping, not in the VM** — one knob (`ToDisplay`), shared by the mic bar and the game bar; the `× MicVolume` gain-on-noise behavior is untouched (raising the slider still moves ambient noise up the bar).
|
||||
2. **+10 dB, not more** — louder boosts push quiet speech into the lower half and make the noise floor visible; +10 puts speech peaks solidly in red while idle stays at zero.
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# TASK 8 — Audio milestone: real stream audio + voice filters + TRAX music (2026-08-14)
|
||||
|
||||
> Catalog: [`TASKS.md`](../TASKS.md) — status and requirements live here.
|
||||
|
||||
|
||||
**Goal:** every audio issue done and tested in one branch — real mic/game audio reaches the encoder (replacing the `anullsrc` silence), `MicVolume`/`GameAudioVolume`/mute become real pre-AAC gains, TASK 10's voice filters land, auto-duck keeps the creator's voice over game + music, and a free background-music source ("TRAX") plays into the sound bar and the stream.
|
||||
|
||||
### Status: ✅ SHIPPED 2026-08-14 — 196 tests passing, 0 warnings (ONE integration test: `AudioPipelineTests.Mix_WithFiltersDuckAndGain_Lands_On_AudioPipe`)
|
||||
|
||||
1. ✅ **Real audio into the encoder** — the mixer's loopback samples were **dropped today** (`AudioMixer.OnLoopbackSample` metered only) and `MicVolume` was meter-only; `FfmpegArgs.cs:28` ran `-f lavfi -i anullsrc` (silence). Transport: **ffmpeg reads a Windows named pipe** — `-f f32le -ar 48000 -ac 2 -i \\.\pipe\ytllive_audio` replaces the anullsrc block, plus explicit `-map 0:v -map 1:a`; pipe name via `EncoderOptions.AudioPipeName` (`DefaultAudioPipeName = "ytllive_audio"`). Mixer stays **two inputs (mic + loopback)** — no N-source abstraction, no `StereoMixer` class. The VM owns the pipe lifecycle (`BeginGoLive` → `_audioMixer.StartLive(pipeName)`, `StopStream` → `_audioMixer.StopLive()` before the pump stops); `FfmpegEncoder.cs` is untouched.
|
||||
2. ✅ **Honest gains reach the stream** — `MicVolume` scales the mic channel; `GameAudioVolume` + mute scale the loopback channel (the "mute in preview but stream still plays" footgun dies with it). One knob per input = KISS. Implemented as `Func<double>` gain seams on `AudioMixer` (`micGain`/`loopbackGain`), wired by `MainViewModel`.
|
||||
3. ✅ **Voice filters (TASK 10, before the meter AND the mix)** — `VoiceFilterChain`: bass (`LowShelfFilter` 120 Hz +4 dB) → treble (`HighShelfFilter` 8 kHz +3 dB) → **noise gate** (pure C#, creator's choice) → compressor (threshold 0.5, 4:1). Pure stateful DSP (TDF2), sine-in unit tests. Meter stays post-filter.
|
||||
4. ✅ **Auto-duck** — pure envelope (`AutoDucker`): mic RMS > 0.02 → game+music dip ×0.25 (~12 dB), attack 0.05 / release 0.005, recover on release. Always-on, no knobs. Strong form of the creator's "voice always over the game volume" idea.
|
||||
5. ✅ **TRAX — background music, FREE** (keeps the one paid line = Alerts + flash removal). `MusicPlayer`: NAudio `MediaFoundationReader` (mp3/wav/m4a) → `VolumeWaveProvider16` at the hardcoded **0.20** (fixed, not changeable) → `WaveOutEvent` on the default device (added the sibling `NAudio.WinMM` 2.2.1 package — `WaveOutEvent` isn't in `NAudio.Wasapi`), loop on end. **No 3rd mixer input and no `MusicVolume`** — music plays on the desktop, the existing loopback picks it up: heard in headphones, the sound-bar meter bounces, and the stream carries it through the loopback channel (ducked with game when the voice is hot). Known wrinkle (out of scope): YouTube mutes VODs carrying copyrighted music — future "music on live, off VOD".
|
||||
6. ✅ **TRAX footer control (final spec, agreed 2026-08-14)** — `YtButtonSecondary`, **status dot + "TRAX"** text, in the center footer stack beside MIC:
|
||||
- **Status dot** (same 8px Ellipse pattern as MIC/Socials): **red** no track loaded · **yellow** loaded not playing · **green** playing.
|
||||
- **Left-click** → toggle play/pause; no track loaded → opens the picker instead.
|
||||
- **Right-click** → always opens the in-app track picker (`TraxButton_PreviewMouseRightButtonUp`, code-behind pattern like `GameSpeaker_MouseLeftButtonUp`, `e.Handled = true` so no context menu).
|
||||
- **Tooltip** → playing/loaded track name; "No track — right-click to choose background music" when empty.
|
||||
- **No slider** — volume is the 0.20 constant. Picker = plain OS `OpenFileDialog` filtered to mp3/wav/m4a/aac/flac/ogg.
|
||||
7. ✅ **Sound bar shows music** — `IsGameAudioBarVisible = gameDetectorProducingSound || isMusicPlaying`; relabelled "Game Audio Capture" → **"Desktop Audio"** (TRAX rides the same channel). **Follow-up (2026-08-15): the gating is gone — the game audio bar is ALWAYS visible** (the `IGameAudioDetector`/`GameAudioDetector`/`GameAudioHysteresis` stack + the VM's 250ms poll timer and their tests were deleted; the bar kept hiding the desktop meter when no full-screen game with sound was up, and the creator wants it pinned).
|
||||
8. ✅ **Capture hardening (design changed at build time)** — the plan's "force IEEE-float 48 kHz on both WASAPI sources" was **dropped**: NAudio 2.2.1's `WasapiCapture` exposes no overridable `GetDefaultMixFormat`, so both sources capture the device's own mix format and the mixer's pure `TinyResampler` normalizes any rate/channel count to 48 kHz stereo (the resampler IS the design, not a fallback). Stop path disposes the audio pipe (EOF) in `MainViewModel.StopStream` before the pump stops, so both ffmpeg inputs end in order.
|
||||
9. ✅ **Schema v9** — single-row `Music` (`TrackPath`, `IsEnabled`; volume is the 0.20 constant) + migration in `LayoutStore.cs` (`user_version` 9; `Save` gained an optional `Music? music = null` param so existing 3-arg callers still compile; load query + null reset).
|
||||
10. ✅ **Docs in the same commit** — `ai.md` audio section rewritten ("one knob per input", 2-in mix, auto-duck, TRAX free, VOD-mute wrinkle), TASK 10's status, this task's status, HANDOFF.
|
||||
|
||||
**Files (new):** `Services/Audio/VoiceFilterChain.cs` (+ `LowShelfFilter`/`HighShelfFilter`/`NoiseGate`/`Compressor`), `Services/Audio/AudioRingBuffer.cs` (async-arrival source buffers), `Services/Audio/AutoDucker.cs`, `Services/Audio/TinyResampler.cs`, `Services/Audio/MusicPlayer.cs`, `Services/Audio/NamedPipeAudioWriter.cs` (+ `IAudioPipeWriter` seam), `Models/Music.cs`.
|
||||
|
||||
**Files (changed):** `AudioMixer.cs` (filter chain before meter+mix, loopback into the mix, gains via `Func<double>` seams, ducker, silence-filler so the pipe never stalls, `StartLive`/`StopLive`), `WasapiMicAudioSource.cs` (device mix format — resampler normalizes; no forced format), `FfmpegArgs.cs` (named-pipe input + `-map`, name via `EncoderOptions.AudioPipeName`), `EncoderOptions.cs` (`AudioPipeName`), `ytLive.csproj` (`NAudio.WinMM` 2.2.1 for `WaveOutEvent`), `LayoutStore.cs` (schema v9), `MainViewModel.cs`/`MainWindow.xaml`/`MainWindow.xaml.cs` (footer TRAX group, sound-bar visibility OR music, go-live starts the pipe, end closes it first).
|
||||
|
||||
**Testing (Good Dog Rule — ONE integration test):** `AudioPipelineTests.Mix_WithFiltersDuckAndGain_Lands_On_AudioPipe` — real filter chain + mixer + ducker + `NamedPipeAudioWriter`, fake `IAudioSource`s, test-side `NamedPipeClientStream` reads the bytes and asserts post-filter/post-gain/post-duck mixed stereo (ring buffers pre-filled so the first pipe tick already carries real audio — avoids a start-of-stream silence race). Units: each DSP stage (known sine-in → expected gain), ring buffer wrap/underflow/overwrite, ducker envelope, resampler (down/up/stateful/identity), `FfmpegArgs` pipe+map, schema v9 roundtrip + no-music-row. Existing tests stay green.
|
||||
|
||||
**Out of scope (this branch):** IP webcam (video-only when it lands — never an audio input), chat box source (TASK 3 item 18), alt-key crop, credits, background removal, music-off-VOD, `PremiumUrl` (TASK 10 seam).
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
# TASK 9 — YouTube Live Stream Management
|
||||
|
||||
> Catalog: [`TASKS.md`](../TASKS.md) — status and requirements live here.
|
||||
|
||||
|
||||
**Goal:** Create/bind broadcasts, monitor YouTube-side stream health — the v3 way.
|
||||
|
||||
### Status: ⏳ In progress — items 1–3 SHIPPED (reusable stream 2026-08-16; report-by-exception health 2026-08-16); item 4 SHIPPED (status reconciled 2026-09-01); item 5 open; item 6 = deliberate lock (TASK 36); item 7 scoped to core fields — advanced tab is out-of-product
|
||||
|
||||
1. ☑ **Broadcast creation** — title/description/privacy/scheduledStartTime via API, with the v3 flags above (SHIPPED: `CreateBroadcast` sends `enableAutoStart/Stop`, `enableMonitorStream=false`, `latencyPreference=low`, `selfDeclaredMadeForKids=false`)
|
||||
2. ☑ **Reusable stream** — create once, cache + reuse; bind to broadcast (SHIPPED: `GetOrCreateReusableStreamAsync` lists-then-inserts the `variable`/`isReusable` stream, cached via `LayoutStore` Settings, bound at broadcast insert via `boundStreamId`; `_rtmpUrlProvider` yields the ingest URL so go-live actually encodes + pushes)
|
||||
3. ☑ **Health monitoring** — poll `liveStreams.list` `healthStatus` + `configurationIssues[]`, surface banner only on warning/error (SHIPPED: `GetStreamHealthAsync(streamId)` 30s while live; pure `StreamHealthReporter.BannerFor` = report-by-exception; banner strip under the top bar, amber warning / dark-red error, via `HealthIssueBanner`/`HealthIssueBackground`; poll failures log-only; ONE integration test `GetStreamHealthAsync_Report_By_Exception_Banner_Only_On_Warning_Or_Error`)
|
||||
4. ☑ Live chat — poll `liveChat/messages`, render in right panel, support Super Chat + membership badges — **SHIPPED** (status reconciled 2026-09-01: `YouTubeChatService` polls + parses `superChatEvent`/`newSponsorEvent` with badge/level on `ChatMessage`; the panel MOVED to the left-panel live state by Control Surface UX, "right panel" superseded; the remaining reward types land with the monetization chain, TASK 10 → related work)
|
||||
5. ☐ Error handling — the YouTube error codes: `errorStreamInactive`, `invalidTransition`, `redundantTransition`, `liveStreamDeletionNotAllowed`, `liveStreamModificationNotAllowed`, `liveBroadcastBindingNotAllowed`
|
||||
6. ❌ **Visibility picker** — DELIBERATE TEST-PHASE LOCK (creator decision 2026-09-01), not open work: "always Private" stays during multi-month real-world testing so breakage VODs never clutter the channel. `recordFromStart`/DVR stay on — Private VODs are invisible review tapes; bulk-delete pre-GA. Unlock = TASK 36 gold-pass item (remove the override in `YouTubeStreamService.CreateBroadcast`, wire the dialog selection). See `ai.md` → YouTube Live API constraints.
|
||||
7. 🔶 **Full broadcast form** — RESCOPED 2026-09-01, not the old two-tab plan. **Core fields (SHIPPED as the Text drawer, 2026-08-24):** title, description, tags, visibility, made-for-kids — live-editable. **Scheduling:** ships as **TASK 34** (Scheduled checkbox + datetime in the same drawer). **Advanced tab is PERMANENTLY OUT** (the 10% margin): latency (locked `low`), DVR/record-from-start (locked on), embed, projection, CC, region restrictions stay fixed at sane defaults, invisible — every exposed field is a support ticket. `categoryId` removal: done (not a `liveBroadcast` field). Monetization enablement (if ever needed) rides the reward-events chain via `liveBroadcasts.update`, not a form field
|
||||
|
||||
### Design decisions (v3)
|
||||
|
||||
1. **One-click go-live** — `liveBroadcasts.insert` with `enableAutoStart=true`, `enableAutoStop=true`, `enableMonitorStream=false`, `selfDeclaredMadeForKids=false`, `latencyPreference=low`. No `transition(live)` call, no testing stage, no liveStarting polling. Encoder starts → YouTube brings it live by itself.
|
||||
2. **Variable reusable stream** — `cdn.resolution=variable`, `cdn.frameRate=variable`, `isReusable=true`. Create once per channel, cache ingestion URL + stream name, reuse for every broadcast. Any quality tier works without recreation; auto step-down needs no API calls.
|
||||
3. **Report-by-exception** — poll `liveStreams.list`; banner only on `healthStatus` warning/error issues (`configurationIssues[]`). Bottom strip = YouTube logo + green/red connection dot (clickable → opens the dialog).
|
||||
4. **One dialog, three states** — `not connected` (sign-in) / `connected-offline` (all editable) / `live` (title + description + visibility editable; quality + account greyed out). Both entry points (Start Stream button + bottom strip) open it; prefilled from saved session profile.
|
||||
5. **Live edits** — `liveBroadcasts.update` with part=`snippet,status` for title/description/privacy.
|
||||
6. **End stream** — stop encoder → `transition(complete)`, with `enableAutoStop` as the safety net. **SHIPPED 2026-09-01** (`EndBroadcastAsync`, wired into `StopStream` after RTMP EOF; the call had been specced since TASK 9 but never existed — found during recording verification). **Hardened 2026-09-22:** a blind complete 403s `invalidTransition` once autoStop/YouTube marks the broadcast complete (seen on every 2026-09-22 end) — the call now pre-flights `liveBroadcasts.list→lifeCycleStatus` and only transitions from `live`/`testing`, skipping already-complete broadcasts silently (`enableAutoStop` finishes every skip).
|
||||
7. **Broadcast ID == Video ID** — one ID to track status, health, and the auto-created VOD (`recordFromStart` + `enableDvr`).
|
||||
|
||||
### Requirements:
|
||||
|
||||
1. **Broadcast creation** — title/description/privacy/scheduledStartTime via API, with the v3 flags above
|
||||
2. **Reusable stream** — create once, cache + reuse; bind to broadcast
|
||||
3. **Health monitoring** — poll `liveStreams.list` `healthStatus` + `configurationIssues[]`, surface banner only on warning/error
|
||||
4. **Live chat** — poll `liveChat/messages`, render in right panel, support Super Chat + membership badges
|
||||
5. **Error handling** — the YouTube error codes: `errorStreamInactive`, `invalidTransition`, `redundantTransition`, `liveStreamDeletionNotAllowed`, `liveStreamModificationNotAllowed`, `liveBroadcastBindingNotAllowed`
|
||||
6. **Visibility picker** — Private/Unlisted/Public from the go-live dialog
|
||||
7. **Full broadcast form** — Core + Advanced tabs with all API-supported fields
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
# TASK 10 — Monetization: watermark-only one-time license + Polar billing
|
||||
|
||||
> Catalog: [`TASKS.md`](../TASKS.md) — status and requirements live here.
|
||||
|
||||
|
||||
**Goal:** a **one-time perpetual license** via Polar.sh that removes the branding watermark. All features
|
||||
are free — the only difference between free and paid is the watermark. (Model changed 2026-09-21 from
|
||||
annual subscription to one-time "own it": $29 founder / $49 list lifetime. See `MONETIZATION.md` →
|
||||
Pricing for the reasoning.)
|
||||
|
||||
**Related work — monetization awareness (2026-09-01, ungated, free):** this task's billing is the
|
||||
*payer* side; the *product* also carries built-in, ungated monetization awareness for every creator —
|
||||
reward-event capture (all 7 `liveChatMessage` reward event types → canonical `RewardEvent` → SQLite
|
||||
persistence), per-session/broadcast reports (members, Super Chat $, stickers, gifts, milestones — the
|
||||
in-app replacement for the emailed "stream activity report"), and a journey tracker (position % +
|
||||
ETA vs. versioned, date-aware YPP thresholds; the Tier-2 bar doubles for new applicants 2027-02-01).
|
||||
Details and design notes: `ai.md` → Monetization. This is tracked as related work under this task's
|
||||
narrative, not a separate task number — it ships in its own code slices with one integration test each.
|
||||
**Scope: v1** (2026-09-01 complete-v1 ruling — there is no v1.x): slice order = reward-event capture →
|
||||
session report → journey tracker → Alerts (TASK 3 item 20).
|
||||
|
||||
**Business details (pricing, Polar product/checkout/discounts) in `MONETIZATION.md` (gitignored).**
|
||||
|
||||
**Polar product:** `d105dfa1-497e-423b-8cd4-e0ee2e3abbc0` (LlamaCasty, ⚠️ currently configured as
|
||||
$99/yr **subscription** — must be re-created as a **one-time** product: $29 founder / $49 list lifetime)
|
||||
**Checkout:** `llamacasty.com` → Polar hosted page (~80% configured)
|
||||
**Discounts:** `LLAMAFOUNDER` (100% off, 50 uses, `once`), `LLAMA50` (50% off, `once`)
|
||||
**Org ID:** `c05fb364-b967-4f6c-adf2-8a144e46d085` (org-scoped OAT — `organization_id` can be omitted from API calls)
|
||||
**Key prefix:** `LCYT-`
|
||||
**Validate endpoint:** `POST https://api.polar.sh/v1/customer-portal/license-keys/validate` (no auth required for client-side validation)
|
||||
|
||||
### Status: 🔶 In progress — steps 1–7 shipped, Velopack update URL pending
|
||||
|
||||
1. ✅ **Billing provider** — Polar (polar.sh) chosen and configured. Product, price, license key benefit, discounts, checkout link all created.
|
||||
2. ✅ **License key entry + verification UI** — About overlay swap panel (same pattern as Licenses panel). Text input + "Activate" button + status message. "Enter License Key" button in the hub.
|
||||
3. ✅ **Offline entitlement** — `LayoutStore` Settings table: `LicenseKey`, `LicenseValidatedAt`, `IsPremium`. 14-day offline grace period. Startup re-validates online, falls back to cache.
|
||||
4. ✅ **BrandFlash → watermark toggle** — `IsPremium` property setter flips `BrandFlashEnabled = !value`. Flash stops immediately on premium activation. Flash asset VideoFrame rendering deferred to TASK 4 compositor.
|
||||
5. ✅ **Remove social slot locks** — all 6 social bar slots open to everyone (no `IsPremium` gating on slots 3-6) — shipped as TASK 10c
|
||||
6. ✅ **"Unlock Premium" button** — `PremiumUrl` set to Polar checkout; `IsPremiumAvailable` is true; button lights up in the About hub.
|
||||
7. ✅ **Licensing model** — startup re-validates against Polar API. **Perpetual key (2026-09-21): the
|
||||
license never expires, `IsPremium` never lapses, and the flash does not return.** The former
|
||||
renewal/lapse path is dead. Offline grace period (14 days) still applies to the validation call only.
|
||||
8. 🔶 **Velopack auto-updates** — NuGet packages + bootstrap in `App.xaml.cs`. Update URL self-hosted on DO droplet (pending configuration).
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
# TASK 11 — Post-pause polish batch: the creator's 8 review issues (2026-08-15, shipped)
|
||||
|
||||
> Catalog: [`TASKS.md`](../TASKS.md) — status and requirements live here.
|
||||
|
||||
|
||||
**Status: ✅ SHIPPED 2026-08-15 — 197 tests passing, 0 warnings (ONE integration test:
|
||||
`AudioPipelineTests.Mix_HonorsProviderGains_AndGameMute_KillsTheLoopback`).**
|
||||
|
||||
The creator reviewed the TASK 8 build and filed 8 issues. All fixed in one branch on `main`:
|
||||
|
||||
1. ✅ **Desktop/game audio volume slider had no effect** (headset OR stream) — the `AudioMixer`'s
|
||||
`micGain`/`loopbackGain` seams defaulted to unity because the VM never passed them; the sliders
|
||||
were decorative. Fixed: new `AudioGainProvider` (`Services/Audio/`) hands `MicMuted`/`MicVolume`/
|
||||
`GameMuted`/`GameAudioVolume` to the mixer at construction, read live each mix tick.
|
||||
2. ✅ **Desktop/game mute button had no effect** — same root cause; `GameMuted` now zeroes the
|
||||
loopback on the stream. Locally, `MusicPlayer.LocalGain` (new) is scaled by the game bar on every
|
||||
volume/mute change + on TRAX load, so the creator **hears** the control work on the music (the
|
||||
track rides the loopback channel, so it ducks locally exactly as it does on stream).
|
||||
3. ✅ **TRAX played once then stopped** — `MusicPlayer.OnPlaybackStopped` only looped when
|
||||
`Position >= Length`, unreliable for `MediaFoundationReader`; now any clean stop
|
||||
(`e.Exception == null`) rewinds + replays; only errors/explicit stops surface via `PlaybackEnded`.
|
||||
4. ✅ **TRAX/MIC buttons swapped** — TRAX no longer sits between MIC and the (mic-specific) meter;
|
||||
the footer's mic cluster is MIC + meter + mute + volume.
|
||||
5. ✅ **Mic source persists across restarts** — `LayoutStore` gained a `Settings` key/value table
|
||||
(`SaveMicSourceName`/`LoadMicSourceName`); the VM saves on pick and restores before the mixer's
|
||||
first `Start`, so a restart reconnects the same already-vetted device (green dot) or reports it
|
||||
missing (yellow) instead of falling back to the default endpoint.
|
||||
6. ✅ **TRAX moved into the game audio bar** — left of the "Desktop Audio" label in the preview
|
||||
overlay (the creator's follow-up pick), no longer in the footer; its tooltip teaches the clicks
|
||||
("left-click pauses/plays, right-click chooses a track").
|
||||
7. ✅ **Backdrop's icons shifted right** — the trash button's `Collapsed` released its column; a new
|
||||
`HiddenBoolToVisibilityConverter` keeps the slot reserved (`Hidden`), so edit/eye stay in their
|
||||
fixed columns for every source row.
|
||||
8. ✅ **No separation between scenes and sources** — a 1px hairline with top/bottom padding now sits
|
||||
between the two listboxes in the left panel.
|
||||
|
||||
### Design decisions
|
||||
|
||||
1. **The gain seams are the one source of truth** — `AudioGainProvider` is a thin seam (four Funcs)
|
||||
so the mixer contract and the mute⇔zero-volume rule are testable without constructing the VM.
|
||||
2. **Stream-honest AND locally audible** — the game bar's controls now do two things: scale the
|
||||
loopback on the stream and scale the music in the headphones. Native game audio is untouched
|
||||
(system output, OBS-style non-monitored); an OBS-style monitor loop is out of scope.
|
||||
3. **Persist the DisplayName, not the device ID** — the FriendlyName match was already the mic's
|
||||
identity end-to-end; `Settings` just makes it survive restarts.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user