Compare commits
39
Commits
82960d0ca3
...
d195c29f79
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d195c29f79 | ||
|
|
5f7bcc2425 | ||
|
|
5c4acf141b | ||
|
|
58eb640c4e | ||
|
|
60c4a0032f | ||
|
|
106896c408 | ||
|
|
7216c95449 | ||
|
|
1993e7cc8b | ||
|
|
ede6ecac8e | ||
|
|
bde2a4cf4b | ||
|
|
4b7e798757 | ||
|
|
f921c0174b | ||
|
|
6310f50c27 | ||
|
|
76e8c22815 | ||
|
|
a6aea5bf26 | ||
|
|
01a3fcaaf7 | ||
|
|
264a90dae8 | ||
|
|
c856d11fb5 | ||
|
|
c9bd7e6093 | ||
|
|
e6a7147dc3 | ||
|
|
0e1f948189 | ||
|
|
caf91e6564 | ||
|
|
82609a5046 | ||
|
|
91e308e8e2 | ||
|
|
221b81628a | ||
|
|
3985a413a7 | ||
|
|
9fa06c67c2 | ||
|
|
20f059422b | ||
|
|
6208cb9409 | ||
|
|
ff5e8d94ae | ||
|
|
54902cb838 | ||
|
|
790cff45c2 | ||
|
|
b3d7cf30b2 | ||
|
|
31b655cecb | ||
|
|
0fdd9d5faf | ||
|
|
41b3933b08 | ||
|
|
d6088f8e35 | ||
|
|
c297b3fb0f | ||
|
|
8221bd6478 |
@@ -141,8 +141,9 @@ line points at the file that explains it.
|
||||
→ `docs/ai/ui-surface.md`
|
||||
- **Licensing: no non-CC0 icon art, no dropped `THIRD-PARTY-NOTICES.txt`, no
|
||||
licence-incompatible dependency, no unvetted ML model.** → `docs/ai/product.md`
|
||||
- **The private-only broadcast lock is deliberate** — do not unlock it, do not raise it as a
|
||||
question; it ends at v1 by the creator's ruling. → `docs/ai/product.md`
|
||||
- **Force Private only for development/test runs.** The released creator flow offers Private,
|
||||
Unlisted, or Public, defaults to Private, and allows live visibility changes through
|
||||
`UpdateBroadcast`. → `docs/ai/youtube-api.md`
|
||||
- **Read render numbers in Release only** — Debug pixel paths are several× slower.
|
||||
→ `docs/ai/environment.md`
|
||||
|
||||
|
||||
+3
-2
@@ -19,10 +19,11 @@ public partial class App : Application
|
||||
AppDomain.CurrentDomain.UnhandledException += (_, args) =>
|
||||
AppLog.Write(args.ExceptionObject is Exception ex
|
||||
? "AppDomain unhandled exception: " + ex
|
||||
: "AppDomain unhandled exception object: " + args.ExceptionObject);
|
||||
: "AppDomain unhandled exception object: " + args.ExceptionObject,
|
||||
AppLogMessageType.Error, AppLogErrorClass.Class1);
|
||||
|
||||
DispatcherUnhandledException += (_, args) =>
|
||||
AppLog.Write(args.Exception, "Dispatcher unhandled exception");
|
||||
AppLog.Write(args.Exception, "Dispatcher unhandled exception", AppLogErrorClass.Class1);
|
||||
|
||||
AppLog.Write("App OnStartup begin");
|
||||
// Attribution (2026-09-04): every take must be traceable to the exact binary
|
||||
|
||||
@@ -7,15 +7,15 @@
|
||||
to a window or reference it from code that compiles in Release; the only host is
|
||||
OverlayHost.xaml.cs inside #if DEBUG. -->
|
||||
<StackPanel>
|
||||
<TextBlock Text="Developer" Foreground="{StaticResource Brush.Attention}" FontSize="11"
|
||||
FontWeight="SemiBold" Margin="0,10,0,2"/>
|
||||
<TextBlock Text="Developer" Style="{StaticResource PropertyHeader}"
|
||||
Foreground="{StaticResource Brush.Attention}"/>
|
||||
|
||||
<CheckBox Margin="10,2,0,0" IsChecked="{Binding DeveloperMode, Mode=TwoWay}"
|
||||
Foreground="{StaticResource Brush.OnSurface}"
|
||||
<CheckBox Style="{StaticResource PropertyPillCheck}" Margin="10,2,0,0"
|
||||
IsChecked="{Binding DeveloperMode, Mode=TwoWay}"
|
||||
Content="Developer mode — all resolutions, output budget ignored"/>
|
||||
|
||||
<TextBlock Text="Debug builds only. Overrides the free output profile (921,600 px / 30 fps). Never present in a release build."
|
||||
Foreground="{StaticResource Brush.Attention}" FontSize="10" TextWrapping="Wrap"
|
||||
Style="{StaticResource PropertyHint}" Foreground="{StaticResource Brush.Attention}"
|
||||
Margin="10,4,0,8"/>
|
||||
</StackPanel>
|
||||
</UserControl>
|
||||
|
||||
+359
-186
@@ -82,6 +82,9 @@
|
||||
<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"/>
|
||||
<MenuItem Header="Ticker" Command="{Binding AddSourceCommand}" CommandParameter="{x:Static models:SourceType.Ticker}"
|
||||
IsEnabled="{Binding CanAddTicker}"
|
||||
ToolTip="Recent alert feed — one independently configured ticker per scene"/>
|
||||
<!-- Privacy blur: Live scene only (creator ruling 2026-11-01). Sits
|
||||
above the desktop and below the brand logo; several allowed. -->
|
||||
<MenuItem Header="Blur" Command="{Binding AddSourceCommand}" CommandParameter="{x:Static models:SourceType.Blur}"
|
||||
@@ -219,6 +222,9 @@
|
||||
<Path Data="M6,16 V11 A6,6 0 0 1 18,11 V16 L20,18 H4 Z M10,20 H14" Stroke="{StaticResource Brush.Tertiary}" StrokeThickness="1.6" StrokeLineJoin="Round" Stretch="Uniform">
|
||||
<Path.Style><Style TargetType="Path"><Setter Property="Visibility" Value="Collapsed"/><Style.Triggers><DataTrigger Binding="{Binding IsAlertBox}" Value="True"><Setter Property="Visibility" Value="Visible"/></DataTrigger></Style.Triggers></Style></Path.Style>
|
||||
</Path>
|
||||
<Path Data="M3,6 H21 M3,12 H21 M3,18 H21" Stroke="{StaticResource Brush.Tertiary}" StrokeThickness="1.6" Stretch="Uniform">
|
||||
<Path.Style><Style TargetType="Path"><Setter Property="Visibility" Value="Collapsed"/><Style.Triggers><DataTrigger Binding="{Binding IsTicker}" Value="True"><Setter Property="Visibility" Value="Visible"/></DataTrigger></Style.Triggers></Style></Path.Style>
|
||||
</Path>
|
||||
</Grid>
|
||||
|
||||
<TextBlock Grid.Column="1" Text="{Binding Name}" VerticalAlignment="Center"
|
||||
@@ -277,7 +283,7 @@
|
||||
</ListBox.ItemTemplate>
|
||||
</Helpers:FocusPreservingListBox>
|
||||
|
||||
<TextBlock Grid.Row="2" Text="No layers yet" Foreground="#555" FontSize="12"
|
||||
<TextBlock Grid.Row="2" Text="No layers yet" Foreground="{StaticResource Brush.OnSurfaceDisabled}" FontSize="12"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
Visibility="{Binding ShowSourcesEmptyHint, Converter={StaticResource BoolToVis}}"/>
|
||||
|
||||
@@ -291,7 +297,7 @@
|
||||
<TextBlock Text="{Binding WebcamLockAlert}" Foreground="{StaticResource Brush.OnError}" FontSize="11"
|
||||
TextWrapping="Wrap"/>
|
||||
<Button Content="Retry" Command="{Binding RetryWebcamLockCommand}"
|
||||
Style="{StaticResource YtButtonSecondary}" Padding="10,2"
|
||||
Style="{StaticResource PropertyButton}" Padding="10,2"
|
||||
HorizontalAlignment="Right" Margin="0,6,0,0"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
@@ -311,14 +317,102 @@
|
||||
<ScrollViewer x:Name="ElementsScrollViewer" Grid.Row="6" VerticalScrollBarVisibility="Auto" Background="Transparent"
|
||||
Visibility="{Binding SelectedElement, Converter={StaticResource NotNullToVis}}">
|
||||
<StackPanel x:Name="ElementsPanel" Margin="8,4,0,0">
|
||||
<StackPanel Visibility="{Binding SelectedElement.IsTextOverlay, Converter={StaticResource BoolToVis}}" Margin="0,0,0,8">
|
||||
<TextBlock Text="Text" Style="{StaticResource PropertyHeader}"/>
|
||||
<TextBlock Text="Static text — it will not animate automatically. Move and resize it in the preview."
|
||||
Style="{StaticResource PropertyHint}" Margin="10,0,0,6" TextWrapping="Wrap"/>
|
||||
<TextBox x:Name="TextLayerEditor" Text="{Binding SelectedElement.TextContent, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"
|
||||
Style="{StaticResource YtTextBoxCompact}" AcceptsReturn="True" TextWrapping="Wrap"
|
||||
VerticalScrollBarVisibility="Auto" MinHeight="72" MaxHeight="120" Margin="10,0,0,8"
|
||||
AutomationProperties.Name="Text layer content"/>
|
||||
<TextBlock Text="Typography" Style="{StaticResource PropertyHeader}"/>
|
||||
<StackPanel x:Name="TextTypographyOptions" Margin="10,0,0,6">
|
||||
<TextBlock Text="Font Type" Style="{StaticResource PropertyFieldLabel}" Margin="0,0,0,3"/>
|
||||
<ComboBox x:Name="TextLayerFontTypePicker"
|
||||
ItemsSource="{Binding TextFontTypeOptions}"
|
||||
SelectedItem="{Binding SelectedTextFontType, Mode=TwoWay}"
|
||||
Style="{StaticResource YtComboBox}" Height="30" Margin="0,0,0,6"/>
|
||||
<TextBlock Text="Font" Style="{StaticResource PropertyFieldLabel}" Margin="0,0,0,3"/>
|
||||
<ComboBox x:Name="TextLayerFontPicker"
|
||||
ItemsSource="{Binding TextFontFamilies}"
|
||||
SelectedItem="{Binding SelectedTextFontFamily, Mode=TwoWay}"
|
||||
Style="{StaticResource YtComboBox}" Height="30" Margin="0,0,0,6"
|
||||
AutomationProperties.Name="Text layer font"/>
|
||||
<DockPanel Margin="0,0,0,0" LastChildFill="False">
|
||||
<TextBlock DockPanel.Dock="Left" Text="Size" Style="{StaticResource PropertyFieldLabel}"
|
||||
Width="30" VerticalAlignment="Center"/>
|
||||
<ComboBox x:Name="TextLayerSizePicker" DockPanel.Dock="Left"
|
||||
ItemsSource="{Binding TextFontSizeOptions}"
|
||||
SelectedItem="{Binding SelectedElement.TextFontSize, Mode=TwoWay}"
|
||||
Style="{StaticResource YtComboBox}" Height="30" Width="62"
|
||||
AutomationProperties.Name="Text layer size"/>
|
||||
<TextBlock DockPanel.Dock="Left" Text="Color" Style="{StaticResource PropertyFieldLabel}"
|
||||
Width="34" Margin="6,0,0,0" VerticalAlignment="Center"/>
|
||||
<TextBox x:Name="TextLayerColorEditor" DockPanel.Dock="Left"
|
||||
Text="{Binding SelectedElement.TextColor, Mode=TwoWay, UpdateSourceTrigger=LostFocus}"
|
||||
Style="{StaticResource YtTextBoxCompact}" MaxLength="9" Width="68"
|
||||
ToolTip="#RRGGBB or #AARRGGBB" AutomationProperties.Name="Text layer color"/>
|
||||
<ComboBox x:Name="TextLayerColorPicker" DockPanel.Dock="Left"
|
||||
ItemsSource="{Binding SelectedElement.TextColorOptions}"
|
||||
SelectedItem="{Binding SelectedElement.TextColorEntry, Mode=TwoWay}"
|
||||
ItemTemplateSelector="{StaticResource BorderColorTemplates}"
|
||||
Style="{StaticResource YtComboBox}" Width="100" Margin="4,0,0,0"
|
||||
ToolTip="Choose a text color" AutomationProperties.Name="Text layer color palette"/>
|
||||
</DockPanel>
|
||||
</StackPanel>
|
||||
<TextBlock Text="Emphasis" Style="{StaticResource PropertyHeader}"/>
|
||||
<StackPanel x:Name="TextEmphasisOptions" Margin="10,0,0,4">
|
||||
<DockPanel Margin="0,0,0,4" LastChildFill="False">
|
||||
<TextBlock DockPanel.Dock="Left" Text="Bold" Style="{StaticResource PropertyFieldLabel}"
|
||||
Width="72" VerticalAlignment="Center"/>
|
||||
<ToggleButton x:Name="TextBoldToggle" Style="{StaticResource PillToggle}"
|
||||
IsChecked="{Binding SelectedElement.TextBold, Mode=TwoWay}"
|
||||
ToolTip="Bold text" HorizontalAlignment="Left"/>
|
||||
</DockPanel>
|
||||
<DockPanel Margin="0,0,0,4" LastChildFill="False">
|
||||
<TextBlock DockPanel.Dock="Left" Text="Italic" Style="{StaticResource PropertyFieldLabel}"
|
||||
Width="72" VerticalAlignment="Center"/>
|
||||
<ToggleButton x:Name="TextItalicToggle" Style="{StaticResource PillToggle}"
|
||||
IsChecked="{Binding SelectedElement.TextItalic, Mode=TwoWay}"
|
||||
ToolTip="Italic text" HorizontalAlignment="Left"/>
|
||||
</DockPanel>
|
||||
<DockPanel LastChildFill="False">
|
||||
<TextBlock DockPanel.Dock="Left" Text="Underline" Style="{StaticResource PropertyFieldLabel}"
|
||||
Width="72" VerticalAlignment="Center"/>
|
||||
<ToggleButton x:Name="TextUnderlineToggle" Style="{StaticResource PillToggle}"
|
||||
IsChecked="{Binding SelectedElement.TextUnderline, Mode=TwoWay}"
|
||||
ToolTip="Underline text" HorizontalAlignment="Left"/>
|
||||
</DockPanel>
|
||||
</StackPanel>
|
||||
<TextBlock Text="Alignment" Style="{StaticResource PropertyHeader}"/>
|
||||
<DockPanel Margin="10,0,0,4">
|
||||
<TextBlock DockPanel.Dock="Left" Text="Horizontal" Style="{StaticResource PropertyFieldLabel}"
|
||||
Width="72" VerticalAlignment="Center"/>
|
||||
<ComboBox x:Name="TextLayerHorizontalAlignmentPicker"
|
||||
ItemsSource="{Binding TextHorizontalAlignmentOptions}"
|
||||
SelectedItem="{Binding SelectedElement.TextAlignment, Mode=TwoWay}"
|
||||
Style="{StaticResource YtComboBox}" Height="30"
|
||||
AutomationProperties.Name="Horizontal text alignment"/>
|
||||
</DockPanel>
|
||||
<DockPanel Margin="10,0,0,0">
|
||||
<TextBlock DockPanel.Dock="Left" Text="Vertical" Style="{StaticResource PropertyFieldLabel}"
|
||||
Width="72" VerticalAlignment="Center"/>
|
||||
<ComboBox x:Name="TextLayerVerticalAlignmentPicker"
|
||||
ItemsSource="{Binding TextVerticalAlignmentOptions}"
|
||||
SelectedItem="{Binding SelectedElement.TextVerticalAlignment, Mode=TwoWay}"
|
||||
Style="{StaticResource YtComboBox}" Height="30"
|
||||
AutomationProperties.Name="Vertical text alignment"/>
|
||||
</DockPanel>
|
||||
</StackPanel>
|
||||
|
||||
<!-- 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}}">
|
||||
<StackPanel Visibility="{Binding SelectedElement.IsBlur, Converter={StaticResource InverseBoolToVis}}">
|
||||
<TextBlock Text="Opacity" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" Margin="0,0,0,2"/>
|
||||
<DockPanel Margin="0,0,0,6">
|
||||
<TextBlock x:Name="OpacityValueText" Text="100%" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11"
|
||||
DockPanel.Dock="Right" VerticalAlignment="Center" Margin="8,0,0,0" Width="32"/>
|
||||
<TextBlock Text="Opacity" Style="{StaticResource PropertyHeader}"/>
|
||||
<DockPanel Margin="10,0,0,6">
|
||||
<TextBlock x:Name="OpacityValueText" Text="100%" Style="{StaticResource PropertyFieldLabel}"
|
||||
DockPanel.Dock="Right" 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"/>
|
||||
@@ -330,10 +424,10 @@
|
||||
is not offered in the vertical Chat layout, so that option
|
||||
is not even shown there (CanToggleClipShape). -->
|
||||
<StackPanel Visibility="{Binding IsWebcamSelected, Converter={StaticResource BoolToVis}}">
|
||||
<TextBlock Text="Shape" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" FontWeight="SemiBold" Margin="0,0,0,4"/>
|
||||
<TextBlock Text="Shape" Style="{StaticResource PropertyHeader}"/>
|
||||
<StackPanel Orientation="Horizontal" Margin="10,0,0,4">
|
||||
<Button Content="{Binding SelectedElement.MirrorButtonText}"
|
||||
Style="{StaticResource YtButtonSecondary}" Padding="10,4"
|
||||
Style="{StaticResource PropertyButton}" Padding="10,4"
|
||||
Click="MirrorButton_Click"/>
|
||||
<ToggleButton Style="{StaticResource SegmentToggle}" Padding="10,4" Margin="8,0,0,0"
|
||||
IsChecked="{Binding SelectedElement.IsRectShape, Mode=TwoWay}">
|
||||
@@ -350,7 +444,7 @@
|
||||
strength slider, on the SAME 0–1 scale and default as the
|
||||
webcam's background blur — one radius curve, shared. -->
|
||||
<StackPanel Visibility="{Binding IsBlurSelected, Converter={StaticResource BoolToVis}}" Margin="0,0,0,8">
|
||||
<TextBlock Text="Blur" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" FontWeight="SemiBold" Margin="0,0,0,4"/>
|
||||
<TextBlock Text="Blur" Style="{StaticResource PropertyHeader}"/>
|
||||
<StackPanel Orientation="Horizontal" Margin="10,0,0,6">
|
||||
<ToggleButton Style="{StaticResource SegmentToggle}" Padding="10,4"
|
||||
IsChecked="{Binding SelectedElement.IsRectShape, Mode=TwoWay}">
|
||||
@@ -362,21 +456,20 @@
|
||||
</ToggleButton>
|
||||
</StackPanel>
|
||||
<DockPanel Margin="10,0,0,4">
|
||||
<TextBlock DockPanel.Dock="Right" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" VerticalAlignment="Center" Margin="8,0,0,0" Width="34">
|
||||
<TextBlock DockPanel.Dock="Right" Style="{StaticResource PropertyFieldLabel}" Margin="8,0,0,0" Width="34">
|
||||
<Run Text="{Binding SelectedElement.BlurAmount, Mode=OneWay, StringFormat={}{0:P0}}"/>
|
||||
</TextBlock>
|
||||
<Slider Minimum="0" Maximum="1" SmallChange="0.05" LargeChange="0.1"
|
||||
Value="{Binding SelectedElement.BlurAmount, Mode=TwoWay}"
|
||||
VerticalAlignment="Center"/>
|
||||
</DockPanel>
|
||||
<TextBlock Text="Strongest blur wins where they overlap — layers cannot add up."
|
||||
Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="10" TextWrapping="Wrap" Margin="10,2,0,0"/>
|
||||
<TextBlock Text="Strongest blur wins where they overlap — layers cannot add up." Style="{StaticResource PropertyHint}"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- The drag hint sits under the shape row — indented, so the
|
||||
parentage shows. -->
|
||||
<TextBlock Text="Drag to move · drag the corner dot to resize"
|
||||
Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="10" Margin="10,2,0,0"/>
|
||||
Style="{StaticResource PropertyHint}" Margin="10,2,0,0"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Webcam geometry (TASK 57): under the vertical tier the camera
|
||||
@@ -386,23 +479,23 @@
|
||||
above) so the webcam's border/background section stays the
|
||||
one panel bound to SelectedElement.IsWebcam. -->
|
||||
<StackPanel Visibility="{Binding SelectedElement.IsDraggable, Converter={StaticResource BoolToVis}}" Margin="0,0,0,8">
|
||||
<TextBlock Text="Geometry" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" FontWeight="SemiBold" Margin="0,8,0,4"/>
|
||||
<TextBlock Text="Geometry" Style="{StaticResource PropertyHeader}"/>
|
||||
<!-- TASK 61: the four fields share ONE fixed width — wide enough
|
||||
for the largest geometry value (1920, plus a typed sign) and
|
||||
no wider, so Y/H no longer stretch to fill the row. -->
|
||||
<DockPanel Margin="10,0,0,4" LastChildFill="False">
|
||||
<TextBlock DockPanel.Dock="Left" Text="X" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" VerticalAlignment="Center" Width="16"/>
|
||||
<TextBlock DockPanel.Dock="Left" Text="X" Style="{StaticResource PropertyFieldLabel}" Width="16"/>
|
||||
<TextBox Text="{Binding SelectedElement.X, UpdateSourceTrigger=PropertyChanged, StringFormat=F0}"
|
||||
Style="{StaticResource YtTextBoxCompact}" Width="44"/>
|
||||
<TextBlock DockPanel.Dock="Left" Text=" Y" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" VerticalAlignment="Center" Width="20" Margin="8,0,0,0"/>
|
||||
<TextBlock DockPanel.Dock="Left" Text=" Y" Style="{StaticResource PropertyFieldLabel}" Width="20" Margin="8,0,0,0"/>
|
||||
<TextBox Text="{Binding SelectedElement.Y, UpdateSourceTrigger=PropertyChanged, StringFormat=F0}"
|
||||
Style="{StaticResource YtTextBoxCompact}" Width="44"/>
|
||||
</DockPanel>
|
||||
<DockPanel Margin="10,0,0,4" LastChildFill="False">
|
||||
<TextBlock DockPanel.Dock="Left" Text="W" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" VerticalAlignment="Center" Width="16"/>
|
||||
<TextBlock DockPanel.Dock="Left" Text="W" Style="{StaticResource PropertyFieldLabel}" Width="16"/>
|
||||
<TextBox Text="{Binding SelectedElement.Width, UpdateSourceTrigger=PropertyChanged, StringFormat=F0}"
|
||||
Style="{StaticResource YtTextBoxCompact}" Width="44"/>
|
||||
<TextBlock DockPanel.Dock="Left" Text=" H" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" VerticalAlignment="Center" Width="20" Margin="8,0,0,0"/>
|
||||
<TextBlock DockPanel.Dock="Left" Text=" H" Style="{StaticResource PropertyFieldLabel}" Width="20" Margin="8,0,0,0"/>
|
||||
<TextBox Text="{Binding SelectedElement.Height, UpdateSourceTrigger=PropertyChanged, StringFormat=F0}"
|
||||
Style="{StaticResource YtTextBoxCompact}" Width="44"/>
|
||||
</DockPanel>
|
||||
@@ -410,7 +503,7 @@
|
||||
|
||||
<!-- Webcam border config -->
|
||||
<StackPanel Visibility="{Binding SelectedElement.IsWebcam, Converter={StaticResource BoolToVis}}" Margin="0,0,0,8">
|
||||
<TextBlock Text="Border Color" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" FontWeight="SemiBold" Margin="0,8,0,2"/>
|
||||
<TextBlock Text="Border Color" Style="{StaticResource PropertyHeader}"/>
|
||||
<!-- TASK 63: the border colour is picked with the app's OWN
|
||||
drop-down (YtComboBox) — the same widget the Display
|
||||
and Font pickers use — not a hand-rolled menu. The hex
|
||||
@@ -429,7 +522,7 @@
|
||||
ToolTip="Border colour" MinWidth="100"
|
||||
Margin="8,0,0,0" VerticalAlignment="Center"/>
|
||||
</DockPanel>
|
||||
<TextBlock Text="Border Thickness" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" FontWeight="SemiBold" Margin="0,8,0,2"/>
|
||||
<TextBlock Text="Border Thickness" Style="{StaticResource PropertyHeader}"/>
|
||||
<DockPanel Margin="10,0,0,6">
|
||||
<Button DockPanel.Dock="Right" Style="{StaticResource IconButton}"
|
||||
Width="22" Height="22" Margin="4,0,0,0"
|
||||
@@ -446,17 +539,16 @@
|
||||
<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="{StaticResource Brush.OnSurfaceVariant}" FontSize="11"
|
||||
VerticalAlignment="Center" Margin="8,0,0,0" Width="20"/>
|
||||
<TextBlock Text="{Binding SelectedElement.BorderWidth}" Style="{StaticResource PropertyFieldLabel}" Margin="8,0,0,0" Width="20"/>
|
||||
</DockPanel>
|
||||
|
||||
<!-- TASK 51: background effects — two pills acting as radio buttons
|
||||
(one on turns the other off; both off = no effect). TASK 59:
|
||||
the pills are indented under the Background title, and the
|
||||
strength row one level deeper, under Blur. -->
|
||||
<TextBlock Text="Background" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" FontWeight="SemiBold" Margin="0,8,0,6"/>
|
||||
<TextBlock Text="Background" Style="{StaticResource PropertyHeader}"/>
|
||||
<DockPanel Margin="10,0,0,4">
|
||||
<TextBlock DockPanel.Dock="Left" Text="Remove" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" VerticalAlignment="Center" Width="84"/>
|
||||
<TextBlock DockPanel.Dock="Left" Text="Remove" Style="{StaticResource PropertyFieldLabel}" Width="84"/>
|
||||
<ToggleButton x:Name="RemoveBackgroundPill" Style="{StaticResource PillToggle}"
|
||||
IsChecked="{Binding SelectedElement.RemoveBackground, Mode=TwoWay}"
|
||||
AutomationProperties.Name="Remove background"
|
||||
@@ -464,7 +556,7 @@
|
||||
HorizontalAlignment="Left"/>
|
||||
</DockPanel>
|
||||
<DockPanel Margin="10,0,0,4">
|
||||
<TextBlock DockPanel.Dock="Left" Text="Blur" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" VerticalAlignment="Center" Width="84"/>
|
||||
<TextBlock DockPanel.Dock="Left" Text="Blur" Style="{StaticResource PropertyFieldLabel}" Width="84"/>
|
||||
<ToggleButton x:Name="BlurBackgroundPill" Style="{StaticResource PillToggle}"
|
||||
IsChecked="{Binding SelectedElement.BlurBackground, Mode=TwoWay}"
|
||||
AutomationProperties.Name="Blur background"
|
||||
@@ -473,9 +565,9 @@
|
||||
</DockPanel>
|
||||
<DockPanel Margin="20,0,0,6"
|
||||
Visibility="{Binding SelectedElement.HasBackgroundEffect, Converter={StaticResource BoolToVis}}">
|
||||
<TextBlock DockPanel.Dock="Left" Text="Strength" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" VerticalAlignment="Center" Width="84"/>
|
||||
<TextBlock DockPanel.Dock="Left" Text="Strength" Style="{StaticResource PropertyFieldLabel}" Width="84"/>
|
||||
<TextBlock DockPanel.Dock="Right" Text="{Binding SelectedElement.BackgroundStrength, StringFormat={}{0:P0}}"
|
||||
Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" VerticalAlignment="Center" Margin="8,0,0,0" Width="34"/>
|
||||
Style="{StaticResource PropertyFieldLabel}" Margin="8,0,0,0" Width="34"/>
|
||||
<Slider x:Name="BackgroundStrengthSlider" Minimum="0" Maximum="1"
|
||||
Value="{Binding SelectedElement.BackgroundStrength, Mode=TwoWay}"
|
||||
TickFrequency="0.05" IsSnapToTickEnabled="True" VerticalAlignment="Center"
|
||||
@@ -492,82 +584,79 @@
|
||||
<StackPanel Visibility="{Binding SelectedElement.IsScoreboard, Converter={StaticResource BoolToVis}}"
|
||||
IsEnabled="{Binding CanUseScoreboard}" Margin="0,0,0,8">
|
||||
<TextBlock Text="Sign in to YouTube to use the scoreboard."
|
||||
Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" Margin="0,0,0,4"
|
||||
Style="{StaticResource PropertyHint}" Margin="0,0,0,4"
|
||||
Visibility="{Binding CanUseScoreboard, Converter={StaticResource InverseBoolToVis}}"/>
|
||||
<!-- DEBUG builds only: prove the strip in the preview before a real
|
||||
stream. Absent from Release, not merely hidden by a flag. -->
|
||||
<CheckBox Style="{StaticResource YtCheckBox}" Margin="0,0,0,4"
|
||||
<CheckBox Style="{StaticResource PropertyPillCheck}" Margin="0,0,0,4"
|
||||
IsChecked="{Binding ScoreboardSimulate, Mode=TwoWay}"
|
||||
ToolTip="Push sample events so the strip appears in the preview."
|
||||
Visibility="{Binding IsDevelopmentBuild, Converter={StaticResource BoolToVis}}">
|
||||
<!-- Dev-only affordances wear orange so a development control can never
|
||||
be mistaken for product. -->
|
||||
<TextBlock Text="Simulate (dev)" Foreground="{StaticResource Brush.Attention}" FontSize="12"/>
|
||||
<TextBlock Text="Simulate (dev)" Style="{StaticResource PropertyFieldLabel}"
|
||||
Foreground="{StaticResource Brush.Attention}"/>
|
||||
</CheckBox>
|
||||
<CheckBox Style="{StaticResource YtCheckBox}" Margin="0,0,0,4"
|
||||
<CheckBox Style="{StaticResource PropertyPillCheck}" Margin="0,0,0,4"
|
||||
Content="Show the scoreboard" IsChecked="{Binding ScoreboardEnabled, Mode=TwoWay}"
|
||||
ToolTip="Off hides the strip in every scene AND stops the channel polling."/>
|
||||
<TextBlock Text="Edge (socials take the other)" Foreground="{StaticResource Brush.OnSurfaceVariant}"
|
||||
FontSize="11" Margin="10,0,0,2"/>
|
||||
<TextBlock Text="Edge (socials take the other)" Style="{StaticResource PropertyHeader}"/>
|
||||
<ComboBox Style="{StaticResource YtComboBox}" Height="24" FontSize="11" Margin="10,0,0,6"
|
||||
ItemsSource="{Binding ScoreboardEdgeOptions}"
|
||||
SelectedItem="{Binding ScoreboardEdge, Mode=TwoWay}"/>
|
||||
|
||||
<DockPanel Margin="10,0,0,6">
|
||||
<TextBlock DockPanel.Dock="Left" Text="Scroll speed" Foreground="{StaticResource Brush.OnSurfaceVariant}"
|
||||
FontSize="11" Width="76" VerticalAlignment="Center"/>
|
||||
<TextBlock DockPanel.Dock="Right" Width="56" TextAlignment="Right" FontSize="11"
|
||||
Foreground="{StaticResource Brush.OnSurfaceVariant}" VerticalAlignment="Center"
|
||||
<TextBlock DockPanel.Dock="Left" Text="Scroll speed" Style="{StaticResource PropertyFieldLabel}" Width="76"/>
|
||||
<TextBlock DockPanel.Dock="Right" Width="56" TextAlignment="Right"
|
||||
Style="{StaticResource PropertyFieldLabel}"
|
||||
Text="{Binding ScoreboardScrollSpeed, StringFormat={}{0:0} px/s}"/>
|
||||
<Slider Minimum="20" Maximum="200" SmallChange="10" LargeChange="40"
|
||||
Value="{Binding ScoreboardScrollSpeed, Mode=TwoWay}" VerticalAlignment="Center"/>
|
||||
</DockPanel>
|
||||
|
||||
<TextBlock Text="Facts" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" FontWeight="SemiBold" Margin="0,0,0,4"/>
|
||||
<TextBlock Text="Facts" Style="{StaticResource PropertyHeader}"/>
|
||||
<WrapPanel Margin="10,0,0,0">
|
||||
<CheckBox Style="{StaticResource YtCheckBox}" Content="Subscribers" Margin="0,0,10,4"
|
||||
<CheckBox Style="{StaticResource PropertyPillCheck}" Content="Subscribers" Margin="0,0,10,4"
|
||||
IsChecked="{Binding ScoreboardShowSubscribers, Mode=TwoWay}"/>
|
||||
<CheckBox Style="{StaticResource YtCheckBox}" Content="Watch hours" Margin="0,0,10,4"
|
||||
<CheckBox Style="{StaticResource PropertyPillCheck}" Content="Watch hours" Margin="0,0,10,4"
|
||||
IsChecked="{Binding ScoreboardShowWatchHours, Mode=TwoWay}"/>
|
||||
<CheckBox Style="{StaticResource YtCheckBox}" Content="Super Chats" Margin="0,0,10,4"
|
||||
<CheckBox Style="{StaticResource PropertyPillCheck}" Content="Super Chats" Margin="0,0,10,4"
|
||||
IsChecked="{Binding ScoreboardShowSuperChats, Mode=TwoWay}"/>
|
||||
<CheckBox Style="{StaticResource YtCheckBox}" Content="New members" Margin="0,0,10,4"
|
||||
<CheckBox Style="{StaticResource PropertyPillCheck}" Content="New members" Margin="0,0,10,4"
|
||||
IsChecked="{Binding ScoreboardShowNewMembers, Mode=TwoWay}"/>
|
||||
<CheckBox Style="{StaticResource YtCheckBox}" Content="Milestones" Margin="0,0,10,4"
|
||||
<CheckBox Style="{StaticResource PropertyPillCheck}" Content="Milestones" Margin="0,0,10,4"
|
||||
IsChecked="{Binding ScoreboardShowMilestones, Mode=TwoWay}"/>
|
||||
<CheckBox Style="{StaticResource YtCheckBox}" Content="Last member" Margin="0,0,10,4"
|
||||
<CheckBox Style="{StaticResource PropertyPillCheck}" Content="Last member" Margin="0,0,10,4"
|
||||
IsChecked="{Binding ScoreboardShowLastMember, Mode=TwoWay}"/>
|
||||
<CheckBox Style="{StaticResource YtCheckBox}" Content="Last Super Chat" Margin="0,0,10,4"
|
||||
<CheckBox Style="{StaticResource PropertyPillCheck}" Content="Last Super Chat" Margin="0,0,10,4"
|
||||
IsChecked="{Binding ScoreboardShowLastSuperChat, Mode=TwoWay}"/>
|
||||
<CheckBox Style="{StaticResource YtCheckBox}" Content="Last gift" Margin="0,0,10,4"
|
||||
<CheckBox Style="{StaticResource PropertyPillCheck}" Content="Last gift" Margin="0,0,10,4"
|
||||
IsChecked="{Binding ScoreboardShowLastGift, Mode=TwoWay}"/>
|
||||
</WrapPanel>
|
||||
|
||||
<TextBlock Text="Text colour" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" Margin="10,6,0,2"/>
|
||||
<TextBlock Text="Text colour" Style="{StaticResource PropertyHeader}"/>
|
||||
<ComboBox Style="{StaticResource YtComboBox}" Height="24" FontSize="11" Margin="10,0,0,6"
|
||||
ItemsSource="{Binding ScoreboardColorOptions}" DisplayMemberPath="Name"
|
||||
SelectedValuePath="Hex" SelectedValue="{Binding ScoreboardTextColor, Mode=TwoWay}"/>
|
||||
|
||||
<CheckBox Style="{StaticResource YtCheckBox}" Content="Background" Margin="10,0,0,4"
|
||||
<CheckBox Style="{StaticResource PropertyPillCheck}" Content="Background" Margin="10,0,0,4"
|
||||
IsChecked="{Binding ScoreboardBackgroundEnabled, Mode=TwoWay}"/>
|
||||
<ComboBox Style="{StaticResource YtComboBox}" Height="24" FontSize="11" Margin="10,0,0,6"
|
||||
ItemsSource="{Binding ScoreboardColorOptions}" DisplayMemberPath="Name"
|
||||
SelectedValuePath="Hex" SelectedValue="{Binding ScoreboardBackgroundColor, Mode=TwoWay}"/>
|
||||
<DockPanel Margin="10,0,0,6">
|
||||
<TextBlock DockPanel.Dock="Left" Text="Opacity" Foreground="{StaticResource Brush.OnSurfaceVariant}"
|
||||
FontSize="11" Width="60" VerticalAlignment="Center"/>
|
||||
<TextBlock DockPanel.Dock="Right" Width="42" TextAlignment="Right" FontSize="11"
|
||||
Foreground="{StaticResource Brush.OnSurfaceVariant}" VerticalAlignment="Center"
|
||||
<TextBlock DockPanel.Dock="Left" Text="Opacity" Style="{StaticResource PropertyFieldLabel}" Width="60"/>
|
||||
<TextBlock DockPanel.Dock="Right" Width="42" TextAlignment="Right"
|
||||
Style="{StaticResource PropertyFieldLabel}"
|
||||
Text="{Binding ScoreboardBackgroundOpacity, StringFormat={}{0:P0}}"/>
|
||||
<Slider Minimum="0" Maximum="1" SmallChange="0.05" LargeChange="0.1"
|
||||
Value="{Binding ScoreboardBackgroundOpacity, Mode=TwoWay}" VerticalAlignment="Center"/>
|
||||
</DockPanel>
|
||||
|
||||
<DockPanel Margin="10,0,0,0">
|
||||
<TextBlock DockPanel.Dock="Left" Text="Text size" Foreground="{StaticResource Brush.OnSurfaceVariant}"
|
||||
FontSize="11" Width="60" VerticalAlignment="Center"/>
|
||||
<TextBlock DockPanel.Dock="Right" Width="56" TextAlignment="Right" FontSize="11"
|
||||
Foreground="{StaticResource Brush.OnSurfaceVariant}" VerticalAlignment="Center"
|
||||
<TextBlock DockPanel.Dock="Left" Text="Text size" Style="{StaticResource PropertyFieldLabel}" Width="60"/>
|
||||
<TextBlock DockPanel.Dock="Right" Width="56" TextAlignment="Right"
|
||||
Style="{StaticResource PropertyFieldLabel}"
|
||||
Text="{Binding ScoreboardFontSizeLabel}"/>
|
||||
<Slider Minimum="14" Maximum="24" SmallChange="1" LargeChange="2" TickFrequency="1" IsSnapToTickEnabled="True"
|
||||
Value="{Binding ScoreboardFontSize, Mode=TwoWay}" VerticalAlignment="Center"/>
|
||||
@@ -576,8 +665,8 @@
|
||||
|
||||
<!-- Countdown config -->
|
||||
<StackPanel Visibility="{Binding SelectedElement.IsCountdown, Converter={StaticResource BoolToVis}}" Margin="0,0,0,8">
|
||||
<TextBlock Text="Timer (minutes)" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" Margin="0,0,0,2"/>
|
||||
<DockPanel Margin="0,0,0,6">
|
||||
<TextBlock Text="Timer (minutes)" Style="{StaticResource PropertyHeader}"/>
|
||||
<DockPanel Margin="10,0,0,6">
|
||||
<Button DockPanel.Dock="Right" Style="{StaticResource IconButton}"
|
||||
Width="22" Height="22" Margin="4,0,0,0"
|
||||
Click="CountdownAccept_Click">
|
||||
@@ -593,32 +682,31 @@
|
||||
<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="{StaticResource Brush.OnSurfaceVariant}" FontSize="11"
|
||||
VerticalAlignment="Center" Margin="8,0,0,0" Width="20"/>
|
||||
<TextBlock Text="{Binding SelectedElement.TimerMinutes}" Style="{StaticResource PropertyFieldLabel}" 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="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" FontWeight="SemiBold" Margin="0,0,0,4"/>
|
||||
<TextBlock Text="Geometry" Style="{StaticResource PropertyHeader}"/>
|
||||
<DockPanel Margin="0,0,0,4">
|
||||
<TextBlock DockPanel.Dock="Left" Text="X" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" VerticalAlignment="Center" Width="16"/>
|
||||
<TextBlock DockPanel.Dock="Left" Text="X" Style="{StaticResource PropertyFieldLabel}" Width="16"/>
|
||||
<TextBox Text="{Binding SelectedElement.X, UpdateSourceTrigger=PropertyChanged, StringFormat=F0}"
|
||||
Style="{StaticResource YtTextBoxCompact}"/>
|
||||
<TextBlock DockPanel.Dock="Left" Text=" Y" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" VerticalAlignment="Center" Width="20" Margin="8,0,0,0"/>
|
||||
<TextBlock DockPanel.Dock="Left" Text=" Y" Style="{StaticResource PropertyFieldLabel}" Width="20" Margin="8,0,0,0"/>
|
||||
<TextBox Text="{Binding SelectedElement.Y, UpdateSourceTrigger=PropertyChanged, StringFormat=F0}"
|
||||
Style="{StaticResource YtTextBoxCompact}"/>
|
||||
</DockPanel>
|
||||
<DockPanel Margin="0,0,0,4">
|
||||
<TextBlock DockPanel.Dock="Left" Text="W" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" VerticalAlignment="Center" Width="16"/>
|
||||
<TextBlock DockPanel.Dock="Left" Text="W" Style="{StaticResource PropertyFieldLabel}" Width="16"/>
|
||||
<TextBox Text="{Binding SelectedElement.Width, UpdateSourceTrigger=PropertyChanged, StringFormat=F0}"
|
||||
Style="{StaticResource YtTextBoxCompact}"/>
|
||||
<TextBlock DockPanel.Dock="Left" Text=" H" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" VerticalAlignment="Center" Width="20" Margin="8,0,0,0"/>
|
||||
<TextBlock DockPanel.Dock="Left" Text=" H" Style="{StaticResource PropertyFieldLabel}" Width="20" Margin="8,0,0,0"/>
|
||||
<TextBox Text="{Binding SelectedElement.Height, UpdateSourceTrigger=PropertyChanged, StringFormat=F0}"
|
||||
Style="{StaticResource YtTextBoxCompact}"/>
|
||||
</DockPanel>
|
||||
<TextBlock Text="URI" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" Margin="0,0,0,2"/>
|
||||
<DockPanel>
|
||||
<TextBlock Text="URI" Style="{StaticResource PropertyHeader}"/>
|
||||
<DockPanel Margin="10,0,0,0">
|
||||
<Button DockPanel.Dock="Right" Style="{StaticResource IconButton}"
|
||||
Width="22" Height="22" Margin="4,0,0,0" IsTabStop="False"
|
||||
Click="WebAccept_Click">
|
||||
@@ -639,10 +727,10 @@
|
||||
|
||||
<!-- Chat box config -->
|
||||
<StackPanel Visibility="{Binding SelectedElement.IsChatBox, Converter={StaticResource BoolToVis}}" Margin="0,0,0,8">
|
||||
<TextBlock Text="Chat Overlay" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" FontWeight="SemiBold" Margin="0,0,0,6"/>
|
||||
<TextBlock Text="Chat Overlay" Style="{StaticResource PropertyHeader}"/>
|
||||
|
||||
<!-- Border (same controls as the webcam's border) -->
|
||||
<TextBlock Text="Border Color" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" FontWeight="SemiBold" Margin="0,0,0,2"/>
|
||||
<TextBlock Text="Border Color" Style="{StaticResource PropertyHeader}"/>
|
||||
<DockPanel Margin="10,0,0,6">
|
||||
<TextBox DockPanel.Dock="Left"
|
||||
Text="{Binding SelectedElement.BorderColor, UpdateSourceTrigger=PropertyChanged}"
|
||||
@@ -656,26 +744,24 @@
|
||||
ToolTip="Border colour" MinWidth="100"
|
||||
Margin="8,0,0,0" VerticalAlignment="Center"/>
|
||||
</DockPanel>
|
||||
<TextBlock Text="Border Thickness" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" FontWeight="SemiBold" Margin="0,0,0,2"/>
|
||||
<TextBlock Text="Border Thickness" Style="{StaticResource PropertyHeader}"/>
|
||||
<DockPanel Margin="10,0,0,6">
|
||||
<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="{StaticResource Brush.OnSurfaceVariant}" FontSize="11"
|
||||
VerticalAlignment="Center" Margin="8,0,0,0" Width="20"/>
|
||||
<TextBlock Text="{Binding SelectedElement.BorderWidth}" Style="{StaticResource PropertyFieldLabel}" Margin="8,0,0,0" Width="20"/>
|
||||
</DockPanel>
|
||||
<TextBlock Text="Border Opacity" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" FontWeight="SemiBold" Margin="0,0,0,2"/>
|
||||
<TextBlock Text="Border Opacity" Style="{StaticResource PropertyHeader}"/>
|
||||
<DockPanel Margin="10,0,0,8">
|
||||
<Slider DockPanel.Dock="Left" Width="120" Minimum="0" Maximum="1"
|
||||
Value="{Binding SelectedElement.BorderOpacity, Mode=TwoWay}"
|
||||
VerticalAlignment="Center"/>
|
||||
<TextBlock Text="{Binding SelectedElement.BorderOpacity, StringFormat={}{0:P0}}" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11"
|
||||
VerticalAlignment="Center" Margin="8,0,0,0" Width="34"/>
|
||||
<TextBlock Text="{Binding SelectedElement.BorderOpacity, StringFormat={}{0:P0}}" Style="{StaticResource PropertyFieldLabel}" Margin="8,0,0,0" Width="34"/>
|
||||
</DockPanel>
|
||||
|
||||
<!-- Font Size -->
|
||||
<DockPanel Margin="0,0,0,4">
|
||||
<TextBlock DockPanel.Dock="Left" Text="Font Size" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" VerticalAlignment="Center" Width="70"/>
|
||||
<TextBlock DockPanel.Dock="Left" Text="Font Size" Style="{StaticResource PropertyFieldLabel}" Width="70"/>
|
||||
<Slider x:Name="ChatFontSizeSlider" Width="120" Minimum="8" Maximum="72" TickFrequency="2" IsSnapToTickEnabled="True"
|
||||
Value="{Binding SelectedElement.ChatFontSize, Mode=TwoWay}"
|
||||
VerticalAlignment="Center"/>
|
||||
@@ -684,7 +770,7 @@
|
||||
|
||||
<!-- Font Family -->
|
||||
<DockPanel Margin="0,0,0,4">
|
||||
<TextBlock DockPanel.Dock="Left" Text="Font" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" VerticalAlignment="Center" Width="70"/>
|
||||
<TextBlock DockPanel.Dock="Left" Text="Font" Style="{StaticResource PropertyFieldLabel}" Width="70"/>
|
||||
<ComboBox SelectedValue="{Binding SelectedElement.ChatFontFamily, Mode=TwoWay}"
|
||||
SelectedValuePath="Content"
|
||||
Style="{StaticResource YtComboBox}"
|
||||
@@ -700,28 +786,28 @@
|
||||
|
||||
<!-- Font Color -->
|
||||
<DockPanel Margin="0,0,0,4">
|
||||
<TextBlock DockPanel.Dock="Left" Text="Font Color" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" VerticalAlignment="Center" Width="70"/>
|
||||
<TextBlock DockPanel.Dock="Left" Text="Font Color" Style="{StaticResource PropertyFieldLabel}" Width="70"/>
|
||||
<TextBox Text="{Binding SelectedElement.ChatFontColor, UpdateSourceTrigger=PropertyChanged}"
|
||||
Style="{StaticResource YtTextBoxCompact}" Width="80"/>
|
||||
</DockPanel>
|
||||
|
||||
<!-- Member Color -->
|
||||
<DockPanel Margin="0,0,0,4">
|
||||
<TextBlock DockPanel.Dock="Left" Text="Member" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" VerticalAlignment="Center" Width="70"/>
|
||||
<TextBlock DockPanel.Dock="Left" Text="Member" Style="{StaticResource PropertyFieldLabel}" Width="70"/>
|
||||
<TextBox Text="{Binding SelectedElement.ChatMemberColor, UpdateSourceTrigger=PropertyChanged}"
|
||||
Style="{StaticResource YtTextBoxCompact}" Width="80"/>
|
||||
</DockPanel>
|
||||
|
||||
<!-- Super Chat Color -->
|
||||
<DockPanel Margin="0,0,0,4">
|
||||
<TextBlock DockPanel.Dock="Left" Text="Super Chat" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" VerticalAlignment="Center" Width="70"/>
|
||||
<TextBlock DockPanel.Dock="Left" Text="Super Chat" Style="{StaticResource PropertyFieldLabel}" Width="70"/>
|
||||
<TextBox Text="{Binding SelectedElement.ChatSuperChatColor, UpdateSourceTrigger=PropertyChanged}"
|
||||
Style="{StaticResource YtTextBoxCompact}" Width="80"/>
|
||||
</DockPanel>
|
||||
|
||||
<!-- Max Messages -->
|
||||
<DockPanel Margin="0,0,0,4">
|
||||
<TextBlock DockPanel.Dock="Left" Text="Max Msgs" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" VerticalAlignment="Center" Width="70"/>
|
||||
<TextBlock DockPanel.Dock="Left" Text="Max Msgs" Style="{StaticResource PropertyFieldLabel}" Width="70"/>
|
||||
<Slider x:Name="ChatMaxMsgsSlider" Width="120" Minimum="1" Maximum="20" TickFrequency="1" IsSnapToTickEnabled="True"
|
||||
Value="{Binding SelectedElement.ChatMaxMessages, Mode=TwoWay}"
|
||||
VerticalAlignment="Center"/>
|
||||
@@ -730,14 +816,14 @@
|
||||
|
||||
<!-- Background Color -->
|
||||
<DockPanel Margin="0,0,0,4">
|
||||
<TextBlock DockPanel.Dock="Left" Text="Background" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" VerticalAlignment="Center" Width="70"/>
|
||||
<TextBlock DockPanel.Dock="Left" Text="Background" Style="{StaticResource PropertyFieldLabel}" Width="70"/>
|
||||
<TextBox Text="{Binding SelectedElement.ChatBackgroundColor, UpdateSourceTrigger=PropertyChanged}"
|
||||
Style="{StaticResource YtTextBoxCompact}" Width="80"/>
|
||||
</DockPanel>
|
||||
|
||||
<!-- Background Opacity -->
|
||||
<DockPanel Margin="0,0,0,4">
|
||||
<TextBlock DockPanel.Dock="Left" Text="Bg Opacity" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" VerticalAlignment="Center" Width="70"/>
|
||||
<TextBlock DockPanel.Dock="Left" Text="Bg Opacity" Style="{StaticResource PropertyFieldLabel}" Width="70"/>
|
||||
<Slider x:Name="ChatBgOpacitySlider" Width="120" Minimum="0" Maximum="1"
|
||||
Value="{Binding SelectedElement.ChatBackgroundOpacity, Mode=TwoWay}"
|
||||
VerticalAlignment="Center"/>
|
||||
@@ -746,7 +832,7 @@
|
||||
|
||||
<!-- Fade After (seconds) -->
|
||||
<DockPanel Margin="0,0,0,4">
|
||||
<TextBlock DockPanel.Dock="Left" Text="Fade After" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" VerticalAlignment="Center" Width="70"/>
|
||||
<TextBlock DockPanel.Dock="Left" Text="Fade After" Style="{StaticResource PropertyFieldLabel}" Width="70"/>
|
||||
<Slider x:Name="ChatFadeSlider" Width="120" Minimum="0" Maximum="60" TickFrequency="5" IsSnapToTickEnabled="True"
|
||||
Value="{Binding SelectedElement.ChatFadeSeconds, Mode=TwoWay}"
|
||||
VerticalAlignment="Center"/>
|
||||
@@ -754,54 +840,33 @@
|
||||
</DockPanel>
|
||||
|
||||
<!-- Toggles -->
|
||||
<CheckBox Content="Show timestamps" Style="{StaticResource YtCheckBox}" Margin="0,4,0,2"
|
||||
<CheckBox Content="Show timestamps" Style="{StaticResource PropertyPillCheck}" Margin="0,4,0,2"
|
||||
IsChecked="{Binding SelectedElement.ChatShowTimestamps, Mode=TwoWay}"/>
|
||||
<CheckBox Content="Show badges" Style="{StaticResource YtCheckBox}" Margin="0,0,0,2"
|
||||
<CheckBox Content="Show badges" Style="{StaticResource PropertyPillCheck}" 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="{StaticResource Brush.OnSurfaceVariant}" 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"/>
|
||||
<TextBlock Text="Stream Alerts" Style="{StaticResource PropertyHeader}"/>
|
||||
<TextBlock Text="The built-in clip plays on every alert (Super Chat, membership…) unless a custom video is picked." Style="{StaticResource PropertyHint}"/>
|
||||
|
||||
<DockPanel Margin="0,0,0,4">
|
||||
<TextBlock Text="Video" Style="{StaticResource PropertyHeader}"/>
|
||||
<DockPanel Margin="10,0,0,4">
|
||||
<!-- Test alert (creator ruling 2026-11-01): plays the clip this box
|
||||
would play — custom if one is set, built-in otherwise. -->
|
||||
<Button DockPanel.Dock="Right" Content="▶ Test alert"
|
||||
Command="{Binding TestAlertCommand}"
|
||||
Style="{StaticResource YtButtonSecondary}"
|
||||
Style="{StaticResource PropertyButton}"
|
||||
Padding="8,2" FontSize="11" VerticalAlignment="Center"
|
||||
ToolTip="Play the alert here, exactly as a viewer would trigger it"/>
|
||||
<TextBlock DockPanel.Dock="Left" Text="Built-in video" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" VerticalAlignment="Center" Width="84"/>
|
||||
<TextBlock DockPanel.Dock="Left" Text="Built-in" Style="{StaticResource PropertyFieldLabel}" Width="84"/>
|
||||
<ToggleButton Style="{StaticResource PillToggle}"
|
||||
IsChecked="{Binding SelectedElement.AlertUseDefaultVideo, Mode=TwoWay}"
|
||||
HorizontalAlignment="Left"/>
|
||||
</DockPanel>
|
||||
|
||||
<!-- Sound byte (creator ruling 2026-11-01): plays WITH the alert clip, on
|
||||
the default device, so it is audible and shows in the desktop meter.
|
||||
The video itself always plays once to completion. -->
|
||||
<TextBlock Text="Sound byte" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11"
|
||||
FontWeight="SemiBold" Margin="0,6,0,2"/>
|
||||
<DockPanel Margin="0,0,0,2">
|
||||
<Button DockPanel.Dock="Right" Content="Browse…" Command="{Binding BrowseAlertAudioCommand}"
|
||||
CommandParameter="{Binding SelectedElement}" Style="{StaticResource YtButtonSecondary}"
|
||||
Padding="8,2" Margin="6,0,0,0" FontSize="11"/>
|
||||
<Button DockPanel.Dock="Right" Content="✕" Command="{Binding ClearAlertAudioCommand}"
|
||||
CommandParameter="{Binding SelectedElement}" Style="{StaticResource YtButtonSecondary}"
|
||||
Padding="7,2" FontSize="11" ToolTip="Remove the sound byte"
|
||||
Visibility="{Binding SelectedElement.HasAlertAudio, Converter={StaticResource BoolToVis}}"/>
|
||||
<TextBox Text="{Binding SelectedElement.AlertAudioFileDisplay, Mode=OneWay}"
|
||||
Style="{StaticResource YtTextBoxCompact}" IsReadOnly="True"
|
||||
Foreground="{StaticResource Brush.OnSurfaceVariant}" VerticalAlignment="Center"/>
|
||||
</DockPanel>
|
||||
<CheckBox Content="Loop the sound until the video ends" Style="{StaticResource YtCheckBox}" Margin="0,2,0,6"
|
||||
IsChecked="{Binding SelectedElement.AlertAudioLoop, Mode=TwoWay}"
|
||||
IsEnabled="{Binding SelectedElement.HasAlertAudio}"/>
|
||||
|
||||
<Grid Margin="0,0,0,4"
|
||||
<Grid Margin="10,0,0,4"
|
||||
Visibility="{Binding SelectedElement.AlertUseDefaultVideo, Converter={StaticResource InverseBoolToVis}}">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
@@ -811,29 +876,53 @@
|
||||
Style="{StaticResource YtTextBoxCompact}"
|
||||
IsReadOnly="True" Foreground="{StaticResource Brush.OnSurfaceVariant}" VerticalAlignment="Center"/>
|
||||
<Button Grid.Column="1" Content="Browse…" Command="{Binding BrowseAlertVideoCommand}"
|
||||
CommandParameter="{Binding SelectedElement}" Style="{StaticResource YtButtonSecondary}"
|
||||
CommandParameter="{Binding SelectedElement}" Style="{StaticResource PropertyButton}"
|
||||
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"/>
|
||||
CommandParameter="{Binding SelectedElement}" Style="{StaticResource PropertyButton}"
|
||||
HorizontalAlignment="Left" Padding="8,2" Margin="10,0,0,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"/>
|
||||
Style="{StaticResource PropertyHint}"/>
|
||||
|
||||
<DockPanel Margin="0,0,0,4">
|
||||
<TextBlock DockPanel.Dock="Left" Text="Message ticker" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" VerticalAlignment="Center" Width="84"/>
|
||||
<!-- Sound byte: plays WITH the clip, on the default device, so it is both
|
||||
audible and visible in the desktop meter. -->
|
||||
<Border Style="{StaticResource PropertyDivider}"/>
|
||||
<TextBlock Text="Sound byte" Style="{StaticResource PropertyHeader}"/>
|
||||
<DockPanel Margin="10,0,0,2">
|
||||
<Button DockPanel.Dock="Right" Content="Browse…" Command="{Binding BrowseAlertAudioCommand}"
|
||||
CommandParameter="{Binding SelectedElement}" Style="{StaticResource PropertyButton}"
|
||||
Padding="8,2" Margin="6,0,0,0" FontSize="11"/>
|
||||
<Button DockPanel.Dock="Right" Content="✕" Command="{Binding ClearAlertAudioCommand}"
|
||||
CommandParameter="{Binding SelectedElement}" Style="{StaticResource PropertyButton}"
|
||||
Padding="7,2" FontSize="11" ToolTip="Remove the sound byte"
|
||||
Visibility="{Binding SelectedElement.HasAlertAudio, Converter={StaticResource BoolToVis}}"/>
|
||||
<TextBox Text="{Binding SelectedElement.AlertAudioFileDisplay, Mode=OneWay}"
|
||||
Style="{StaticResource YtTextBoxCompact}" IsReadOnly="True"
|
||||
Foreground="{StaticResource Brush.OnSurfaceVariant}" VerticalAlignment="Center"/>
|
||||
</DockPanel>
|
||||
<CheckBox Content="Loop until the video ends" Style="{StaticResource PropertyPillCheck}" Margin="10,4,0,2"
|
||||
IsChecked="{Binding SelectedElement.AlertAudioLoop, Mode=TwoWay}"
|
||||
IsEnabled="{Binding SelectedElement.HasAlertAudio}"/>
|
||||
<TextBlock Text="The video always plays once, to completion. The sound may loop under it."
|
||||
Style="{StaticResource PropertyHint}"/>
|
||||
|
||||
<Border Style="{StaticResource PropertyDivider}"/>
|
||||
<TextBlock Text="Ticker" Style="{StaticResource PropertyHeader}"/>
|
||||
<DockPanel Margin="10,0,0,4">
|
||||
<TextBlock DockPanel.Dock="Left" Text="Current event" Style="{StaticResource PropertyFieldLabel}" Width="84"/>
|
||||
<ToggleButton Style="{StaticResource PillToggle}"
|
||||
IsChecked="{Binding SelectedElement.AlertShowTicker, Mode=TwoWay}"
|
||||
IsChecked="{Binding SelectedElement.Ticker.IsEnabled, 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="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" VerticalAlignment="Center" Width="84"/>
|
||||
<ComboBox SelectedValue="{Binding SelectedElement.AlertDisplayMethod, Mode=TwoWay}"
|
||||
<DockPanel Margin="10,0,0,4">
|
||||
<TextBlock DockPanel.Dock="Left" Text="Display" Style="{StaticResource PropertyFieldLabel}" Width="84"/>
|
||||
<ComboBox SelectedValue="{Binding SelectedElement.Ticker.DisplayMethod, Mode=TwoWay}"
|
||||
SelectedValuePath="Tag"
|
||||
Style="{StaticResource YtComboBox}"
|
||||
Width="140" VerticalAlignment="Center">
|
||||
@@ -843,8 +932,8 @@
|
||||
</ComboBox>
|
||||
</DockPanel>
|
||||
|
||||
<DockPanel Margin="0,0,0,4">
|
||||
<TextBlock DockPanel.Dock="Left" Text="Volume" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" VerticalAlignment="Center" Width="84"/>
|
||||
<DockPanel Margin="10,0,0,4">
|
||||
<TextBlock DockPanel.Dock="Left" Text="Volume" Style="{StaticResource PropertyFieldLabel}" Width="84"/>
|
||||
<Slider Width="120" Minimum="0" Maximum="1"
|
||||
Value="{Binding SelectedElement.AlertVideoVolume, Mode=TwoWay}"
|
||||
VerticalAlignment="Center"/>
|
||||
@@ -852,13 +941,33 @@
|
||||
</DockPanel>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Per-scene ticker layer: same Ticker settings/renderer as the
|
||||
ticker embedded in Stream Alerts, but it reads the recent event feed. -->
|
||||
<StackPanel Visibility="{Binding SelectedElement.IsTicker, Converter={StaticResource BoolToVis}}" Margin="0,0,0,8">
|
||||
<TextBlock Text="Recent Alert Ticker" Style="{StaticResource PropertyHeader}"/>
|
||||
<TextBlock Text="Shows current and queued alert events. This ticker's settings are independent in each scene."
|
||||
Style="{StaticResource PropertyHint}"/>
|
||||
<CheckBox Content="Show recent alerts" Style="{StaticResource PropertyPillCheck}" Margin="0,2,0,4"
|
||||
IsChecked="{Binding SelectedElement.Ticker.IsEnabled, Mode=TwoWay}"/>
|
||||
<DockPanel Margin="10,0,0,4">
|
||||
<TextBlock DockPanel.Dock="Left" Text="Display" Style="{StaticResource PropertyFieldLabel}" Width="84"/>
|
||||
<ComboBox SelectedValue="{Binding SelectedElement.Ticker.DisplayMethod, Mode=TwoWay}"
|
||||
SelectedValuePath="Tag" Style="{StaticResource YtComboBox}"
|
||||
Width="140" VerticalAlignment="Center">
|
||||
<ComboBoxItem Content="Scroll" Tag="TickerScroll"/>
|
||||
<ComboBoxItem Content="Flash" Tag="Flash"/>
|
||||
<ComboBoxItem Content="Solid" Tag="Solid"/>
|
||||
</ComboBox>
|
||||
</DockPanel>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Background properties -->
|
||||
<StackPanel Margin="0,8,0,0"
|
||||
Visibility="{Binding SelectedElement.IsBackground, Converter={StaticResource BoolToVis}}">
|
||||
<TextBlock Text="Background" Style="{StaticResource SubHeader}"/>
|
||||
<TextBlock Text="Background" Style="{StaticResource PropertyHeader}"/>
|
||||
<StackPanel Margin="4,0,0,0">
|
||||
<StackPanel Orientation="Horizontal" Margin="0,0,0,8">
|
||||
<TextBlock Text="Use default background:" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" VerticalAlignment="Center"/>
|
||||
<TextBlock Text="Use default background:" Style="{StaticResource PropertyFieldLabel}"/>
|
||||
<ToggleButton Style="{StaticResource PillToggle}"
|
||||
IsChecked="{Binding SelectedElement.UseDefaultBackground, Mode=TwoWay}"
|
||||
Margin="8,0,0,0"/>
|
||||
@@ -873,7 +982,7 @@
|
||||
Style="{StaticResource YtTextBoxCompact}"
|
||||
IsReadOnly="True" Foreground="{StaticResource Brush.OnSurfaceVariant}" VerticalAlignment="Center"/>
|
||||
<Button Grid.Column="1" Content="Browse…" Command="{Binding BrowseBackgroundCommand}"
|
||||
Style="{StaticResource YtButtonSecondary}" Margin="4,0,0,0" Padding="8,2"/>
|
||||
Style="{StaticResource PropertyButton}" Margin="4,0,0,0" Padding="8,2"/>
|
||||
</Grid>
|
||||
<TextBlock Text="{Binding BackgroundSizeMismatchText}"
|
||||
Foreground="{StaticResource Brush.Error}" FontSize="11" TextWrapping="Wrap"
|
||||
@@ -892,69 +1001,133 @@
|
||||
<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="{StaticResource Brush.OnSurfaceVariant}" 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 Grid.Row="0" Orientation="Horizontal" Margin="0,0,0,8">
|
||||
<ToggleButton x:Name="LiveChatTabButton" Style="{StaticResource SegmentToggle}"
|
||||
IsChecked="{Binding IsLiveChatTabSelected, Mode=TwoWay}" Padding="10,5">
|
||||
<TextBlock Text="Live Chat" Style="{StaticResource SegmentLabel}"/>
|
||||
</ToggleButton>
|
||||
<ToggleButton x:Name="LogsTabButton" Style="{StaticResource SegmentToggle}"
|
||||
IsChecked="{Binding IsLogsTabSelected, Mode=TwoWay}" Padding="10,5" Margin="4,0,0,0">
|
||||
<TextBlock Text="Logs" Style="{StaticResource SegmentLabel}"/>
|
||||
</ToggleButton>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Pinned message -->
|
||||
<Border Grid.Row="1" VerticalAlignment="Top" Margin="4,4,4,0" Padding="6,4" CornerRadius="4"
|
||||
Background="{StaticResource Brush.SurfaceContainerHigh}" BorderBrush="{StaticResource Brush.Tertiary}" BorderThickness="1"
|
||||
Visibility="{Binding PinnedChatMessage, Converter={StaticResource NotNullToVis}}">
|
||||
<StackPanel>
|
||||
<TextBlock Text="{Binding PinnedChatMessage.AuthorName}" Foreground="{StaticResource Brush.Tertiary}" FontWeight="SemiBold" FontSize="11"/>
|
||||
<TextBlock Text="{Binding PinnedChatMessage.Message}" Foreground="{StaticResource Brush.OnSurface}" FontSize="11" TextWrapping="Wrap"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<Grid Grid.Row="1">
|
||||
<Grid Visibility="{Binding IsLiveChatTabSelected, Converter={StaticResource BoolToVis}}">
|
||||
<!-- Inactive overlay (shown when NOT live) -->
|
||||
<StackPanel VerticalAlignment="Center" HorizontalAlignment="Center"
|
||||
Visibility="{Binding ShowChatInactiveMessage, Converter={StaticResource BoolToVis}}">
|
||||
<TextBlock Text="Chat is inactive" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="13"
|
||||
HorizontalAlignment="Center" Margin="0,0,0,4"/>
|
||||
<TextBlock Text="Chat becomes active once LlamaCasty is connected and streaming on YouTube."
|
||||
Foreground="{StaticResource Brush.OnSurfaceDisabled}" FontSize="11" TextWrapping="Wrap"
|
||||
HorizontalAlignment="Center" TextAlignment="Center" MaxWidth="240"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Message feed (shown when live) -->
|
||||
<ListBox Grid.Row="1" Background="Transparent" BorderThickness="0" Margin="0,40,0,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="{StaticResource Brush.SurfaceContainerHigh}"/>
|
||||
<Setter Property="BorderBrush" Value="{StaticResource Brush.Secondary}"/>
|
||||
<Setter Property="BorderThickness" Value="1,0,1,0"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</Border.Style>
|
||||
<!-- Pinned message -->
|
||||
<Border VerticalAlignment="Top" Margin="4,4,4,0" Padding="6,4" CornerRadius="4"
|
||||
Background="{StaticResource Brush.SurfaceContainerHigh}" BorderBrush="{StaticResource Brush.Tertiary}" BorderThickness="1"
|
||||
Visibility="{Binding PinnedChatMessage, Converter={StaticResource NotNullToVis}}">
|
||||
<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="{StaticResource Brush.Tertiary}"
|
||||
FontWeight="Bold" FontSize="12" VerticalAlignment="Center"/>
|
||||
<TextBlock Text="{Binding SuperChatAmount, StringFormat=${0:F2}}"
|
||||
Foreground="{StaticResource Brush.Secondary}" FontWeight="Bold" FontSize="12"
|
||||
Margin="8,0,0,0" VerticalAlignment="Center"
|
||||
Visibility="{Binding IsSuperChat, Converter={StaticResource BoolToVis}}"/>
|
||||
<TextBlock Text="{Binding SuperChatCurrency}" Foreground="{StaticResource Brush.Secondary}"
|
||||
FontSize="11" Margin="2,0,0,0" VerticalAlignment="Center"
|
||||
Visibility="{Binding IsSuperChat, Converter={StaticResource BoolToVis}}"/>
|
||||
</StackPanel>
|
||||
<TextBlock Text="{Binding Message}" Foreground="{StaticResource Brush.OnSurface}"
|
||||
TextWrapping="Wrap" Margin="0,2,0,0"/>
|
||||
<TextBlock Text="{Binding PinnedChatMessage.AuthorName}" Foreground="{StaticResource Brush.Tertiary}" FontWeight="SemiBold" FontSize="11"/>
|
||||
<TextBlock Text="{Binding PinnedChatMessage.Message}" Foreground="{StaticResource Brush.OnSurface}" FontSize="11" TextWrapping="Wrap"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</DataTemplate></ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
|
||||
<!-- Message feed (shown when live) -->
|
||||
<ListBox Background="Transparent" BorderThickness="0" Margin="0,40,0,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="{StaticResource Brush.SurfaceContainerHigh}"/>
|
||||
<Setter Property="BorderBrush" Value="{StaticResource Brush.Secondary}"/>
|
||||
<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="{StaticResource Brush.Tertiary}"
|
||||
FontWeight="Bold" FontSize="12" VerticalAlignment="Center"/>
|
||||
<TextBlock Text="{Binding SuperChatAmount, StringFormat=${0:F2}}"
|
||||
Foreground="{StaticResource Brush.Secondary}" FontWeight="Bold" FontSize="12"
|
||||
Margin="8,0,0,0" VerticalAlignment="Center"
|
||||
Visibility="{Binding IsSuperChat, Converter={StaticResource BoolToVis}}"/>
|
||||
<TextBlock Text="{Binding SuperChatCurrency}" Foreground="{StaticResource Brush.Secondary}"
|
||||
FontSize="11" Margin="2,0,0,0" VerticalAlignment="Center"
|
||||
Visibility="{Binding IsSuperChat, Converter={StaticResource BoolToVis}}"/>
|
||||
</StackPanel>
|
||||
<TextBlock Text="{Binding Message}" Foreground="{StaticResource Brush.OnSurface}"
|
||||
TextWrapping="Wrap" Margin="0,2,0,0"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</DataTemplate></ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
</Grid>
|
||||
|
||||
<Grid Visibility="{Binding IsLogsTabSelected, Converter={StaticResource BoolToVis}}">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
</Grid.RowDefinitions>
|
||||
<ScrollViewer Grid.Row="0" MaxHeight="160" VerticalScrollBarVisibility="Auto"
|
||||
Visibility="{Binding HasPinnedLogEntries, Converter={StaticResource BoolToVis}}">
|
||||
<StackPanel>
|
||||
<TextBlock Text="Needs attention" Style="{StaticResource PropertyHeader}" Margin="0,0,0,4"/>
|
||||
<ItemsControl x:Name="PinnedLogPanel" ItemsSource="{Binding PinnedLogEntries}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Border Padding="6,4" Margin="0,0,0,4" CornerRadius="4"
|
||||
Background="{StaticResource Brush.SurfaceContainerHigh}">
|
||||
<DockPanel LastChildFill="True">
|
||||
<Button DockPanel.Dock="Right" Content="Acknowledge"
|
||||
Style="{StaticResource PropertyButton}" Margin="8,0,0,0"
|
||||
VerticalAlignment="Top"
|
||||
Command="{Binding DataContext.AcknowledgeLogEntryCommand, RelativeSource={RelativeSource AncestorType=UserControl}}"
|
||||
CommandParameter="{Binding}"/>
|
||||
<StackPanel>
|
||||
<TextBlock Text="{Binding ErrorClassLabel}"
|
||||
Style="{StaticResource AppLogEntryText}" FontWeight="Bold"/>
|
||||
<TextBlock Text="{Binding Text}" Style="{StaticResource AppLogEntryText}"/>
|
||||
</StackPanel>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
<ListBox x:Name="LogListBox" Grid.Row="1" Background="Transparent" BorderThickness="0"
|
||||
ItemsSource="{Binding CurrentLaunchLogEntries}"
|
||||
ItemContainerStyle="{StaticResource YtListBoxItem}"
|
||||
ScrollViewer.CanContentScroll="True"
|
||||
VirtualizingPanel.IsVirtualizing="True"
|
||||
VirtualizingPanel.VirtualizationMode="Recycling">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<DockPanel Margin="6,4">
|
||||
<TextBlock DockPanel.Dock="Left" Text="{Binding TypeLabel}"
|
||||
Style="{StaticResource AppLogEntryText}" FontWeight="Bold"
|
||||
Margin="0,0,8,0"/>
|
||||
<TextBlock Text="{Binding Text}" Style="{StaticResource AppLogEntryText}"/>
|
||||
</DockPanel>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
+10
-31
@@ -36,7 +36,7 @@
|
||||
</Grid>
|
||||
|
||||
<!-- Report Bug -->
|
||||
<StackPanel Grid.Row="1" Margin="0,16,0,0"
|
||||
<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"
|
||||
@@ -85,9 +85,14 @@
|
||||
<TextBlock Text="Live streaming for YouTube — simple enough that even the most right-brained person can intuit it."
|
||||
Foreground="{StaticResource Brush.OnSurfaceVariant}" 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 x:Name="UpgradeCopy"
|
||||
Text="Free at 720p30. The paid version unlocks higher stream and recording quality. One-time purchase; no subscription. Launch price $79, then $99 on the announced date."
|
||||
Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="12"
|
||||
TextWrapping="Wrap" MaxWidth="380" TextAlignment="Center" Margin="0,12,0,0"/>
|
||||
<Button x:Name="UpgradeLinkButton" Content="Learn more at llamacasty.com"
|
||||
Command="{Binding OpenPremiumCommand}" Style="{StaticResource YtButtonSecondary}"
|
||||
HorizontalAlignment="Center" Margin="0,12,0,0"
|
||||
ToolTip="Opens llamacasty.com in your browser when clicked"/>
|
||||
<TextBlock Text="Made by gramps · llama chile shop" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="12"
|
||||
HorizontalAlignment="Center" Margin="0,16,0,8"/>
|
||||
|
||||
@@ -100,14 +105,7 @@
|
||||
</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"/>
|
||||
Style="{StaticResource YtButtonSecondary}"/>
|
||||
</StackPanel>
|
||||
|
||||
<Button Content="Licenses & legal" Command="{Binding OpenLicensesCommand}"
|
||||
@@ -131,25 +129,6 @@
|
||||
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="{StaticResource Brush.OnSurface}" Margin="0,0,0,8"/>
|
||||
<TextBlock Text="Enter your license key to remove the branding watermark."
|
||||
Foreground="{StaticResource Brush.OnSurfaceVariant}" 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="{StaticResource Brush.OnSurfaceVariant}" 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>
|
||||
|
||||
@@ -90,6 +90,15 @@
|
||||
<Style TargetType="ContentPresenter">
|
||||
<Setter Property="Canvas.Left" Value="{Binding X}"/>
|
||||
<Setter Property="Canvas.Top" Value="{Binding Y}"/>
|
||||
<!-- ⛔ The brand logo renders ONCE, in BrandLogoOverlay above
|
||||
the blur host, so it can never be blurred. Drawing it here
|
||||
as well stacked two copies on top of each other
|
||||
(creator report, 2026-11-01). -->
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding IsBrandLogo}" Value="True">
|
||||
<Setter Property="Visibility" Value="Collapsed"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</ItemsControl.ItemContainerStyle>
|
||||
<ItemsControl.ItemTemplate>
|
||||
@@ -143,11 +152,17 @@
|
||||
<DataTrigger Binding="{Binding IsAlertBox}" Value="True">
|
||||
<Setter Property="Visibility" Value="Visible"/>
|
||||
</DataTrigger>
|
||||
<DataTrigger Binding="{Binding IsTicker}" Value="True">
|
||||
<Setter Property="Visibility" Value="Visible"/>
|
||||
</DataTrigger>
|
||||
<!-- The scoreboard strip is a frame-source too: without this trigger its
|
||||
Image stays collapsed and every property change looks inert. -->
|
||||
<DataTrigger Binding="{Binding IsScoreboard}" Value="True">
|
||||
<Setter Property="Visibility" Value="Visible"/>
|
||||
</DataTrigger>
|
||||
<DataTrigger Binding="{Binding IsTextOverlay}" Value="True">
|
||||
<Setter Property="Visibility" Value="Visible"/>
|
||||
</DataTrigger>
|
||||
<DataTrigger Binding="{Binding IsWebSource}" Value="True">
|
||||
<Setter Property="Visibility" Value="Visible"/>
|
||||
<Setter Property="Stretch" Value="Fill"/>
|
||||
@@ -476,6 +491,7 @@
|
||||
<Image Canvas.Left="{Binding StagedBrandLogo.X}" Canvas.Top="{Binding StagedBrandLogo.Y}"
|
||||
Width="{Binding StagedBrandLogo.Width}" Height="{Binding StagedBrandLogo.Height}"
|
||||
Source="{Binding StagedBrandLogo.DisplaySource}" Stretch="Fill"
|
||||
Opacity="{Binding StagedBrandLogo.Opacity}"
|
||||
IsHitTestVisible="False"
|
||||
Visibility="{Binding StagedBrandLogo, Converter={StaticResource NotNullToVis}}"/>
|
||||
</Canvas>
|
||||
|
||||
@@ -677,6 +677,13 @@ public partial class PreviewPane : UserControl
|
||||
if (vm.SelectedElement is { } selected && IsHitTestable(selected) && Contains(selected, p))
|
||||
return selected;
|
||||
|
||||
// The brand logo is drawn ABOVE everything (the compositor blits it after the blur group,
|
||||
// and the preview renders it in BrandLogoOverlay), so a click inside it must select it —
|
||||
// otherwise you see the logo on top and grab the webcam underneath it.
|
||||
var brand = vm.StagedBrandLogo;
|
||||
if (brand is { IsVisible: true } && Contains(brand, p))
|
||||
return brand;
|
||||
|
||||
for (var i = scene.Elements.Count - 1; i >= 0; i--)
|
||||
{
|
||||
var element = scene.Elements[i];
|
||||
|
||||
+10
-3
@@ -10,13 +10,20 @@ See [`schema.md`](../schema.md) for the memory-map conventions.
|
||||
| `BottomBar.xaml(.cs)` | Footer (Row 3): intentionally empty, height-preserving spacer; stream readouts and resolution picker moved to the top-left cell in the 2026-10-26 surface redesign |
|
||||
| `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: brand + gear menu (left), explicit `EDIT` toggle beside the REC|ON-AIR mode switch and world actions (Record: Start Recording · Stream: Go Live + Test), running-state reality line + End (center), account zone (right). Left-click gear opens the menu; right-click opens App Settings directly. `RefreshAvatar()`, `GearButton_Click` |
|
||||
| `PreviewPane.xaml(.cs)` | Row 2 Col 1 center: 1920×1080 `OverlayCanvas`/`SelectionOverlay`/`BrandFlashLayer`/`SocialBarElement`, text drawer, outgoing-picture transition overlay, right-edge Stream Info/YPP/Socials/TEST tabs and drawers, and live controls (TRAX / game / mic meters + sliders + speakers). The list-selected draggable layer is temporarily raised in preview z-order and custom hit-testing; deselection restores the scene's unchanged order (`SelectedLayerPreviewHitTestingTests`). **TASK 57:** dragging/resizing a webcam in the vertical tier routes through `MainViewModel.MoveWebcamInVerticalTier` / `ResizeWebcamInVerticalTier` for the six-spot snap or Chat camera's two sizes. |
|
||||
| `PreviewPane.xaml(.cs)` | Row 2 Col 1 center: 1920×1080 `OverlayCanvas`/`SelectionOverlay`/`BrandFlashLayer`/`SocialBarElement`, static TextOverlay preview image from the shared cached text raster, text drawer, outgoing-picture transition overlay, right-edge Stream Info/YPP/Socials/TEST tabs and drawers, and live controls (TRAX / game / mic meters + sliders + speakers). The list-selected draggable layer is temporarily raised in preview z-order and custom hit-testing; deselection restores the scene's unchanged order (`SelectedLayerPreviewHitTestingTests`). **TASK 57:** dragging/resizing a webcam in the vertical tier routes through `MainViewModel.MoveWebcamInVerticalTier` / `ResizeWebcamInVerticalTier` for the six-spot snap or Chat camera's two sizes. |
|
||||
| `SceneThumbnailStrip.xaml(.cs)` | Top row over the preview column: five fixed-size scene cards with scene name, hotkey, staged `PROGRAM` badge, orange staged/live border |
|
||||
| `LeftPanel.xaml(.cs)` | Row 2 Col 0 left panel: explicit EditingActive toggle on → Scene Layers+properties; off → YouTube chat, independent of stream status. Source add/edit/remove, list drag-to-reorder, snapshot revert-accept. **TASK 51:** the webcam properties carry the Background section — `RemoveBackgroundPill` / `BlurBackgroundPill` (`PillToggle`, radio-exclusive via `WebcamSceneConfig.RemoveBackground`/`BlurBackground`) + `BackgroundStrengthSlider` (0..1, 5% snaps, shown only while an effect is on). **TASK 57:** the webcam properties carry a Geometry section (X/Y/W/H) — manual placement of the camera, bound to the **live** placement (the one the current tier shows), gated on `IsWebcamSelected` (not `SelectedElement.IsWebcam` — the TASK 51 test finds that panel by binding text and would match twice); the Shape button's `Visibility` is bound to `CanToggleClipShape`, so the circle option is NOT OFFERED in the vertical Chat layout. **TASK 58:** every drop-down, check box and field in the properties panel carries the theme's style (`YtComboBox`/`YtCheckBox`/`YtTextBoxCompact`) and paints no colour of its own — the guard is `ytLive.Tests/PropertiesStyleTests.cs`. **TASK 59:** the webcam's properties are a HIERARCHY — a `Shape` title owns the mirror switch and the shape choice (`Rect` | `Circle` as two `SegmentToggle` options, Rect selected by default, the circle option hidden where `CanToggleClipShape` is false), and every titled block (Geometry, Border Color, Border Thickness, Background) owns its content one indent deeper (Strength sits under Blur). **TASK 60:** the border-colour drop-down builds its menu from `Models/BorderColorPalette.cs` — each row is the hex plus the colour's NAME (`#e94560 Crimson`, tooltip "Crimson (#e94560)"); the swatch icon is unchanged. **TASK 61:** the four geometry fields (X/Y/W/H) share one fixed width (44px — the largest value, 1920, plus a sign) and their rows set `LastChildFill="False"`, so Y/H no longer stretch to fill the row. **TASK 62:** the border-colour field shows the palette's name beside the hex (`SelectedElement.BorderColorName`, empty for a custom hex) and keeps a fixed width so the name stays next to the hex. **TASK 63:** the border colour is picked with the **app's own drop-down** (`YtComboBox`) — the `...` button and its code-built `ContextMenu` are gone; the closed box shows the swatch + the colour's NAME, the rows show swatch + hex + name (the panel's own `DataTemplate`s + `Helpers/ClosedBoxTemplateSelector.cs`), and the hex field stays for a custom colour; `IsDescendantOf` falls back to `LogicalTreeHelper.GetParent` / `TemplatedParent` when `VisualTreeHelper.GetParent` returns `null` (at `PopupRoot`) so clicking a popup row does not trigger `MainWindow`'s deselection guard |
|
||||
| `OverlayHost.xaml(.cs)` | Root-grid overlays: App Settings / Report Bug / Feature Request / About; App Settings now groups recording, brand-logo, preview, and transition controls. `BackToAbout_Click` lives here |
|
||||
| `LeftPanel.xaml(.cs)` | Row 2 Col 0 left panel: explicit EditingActive toggle on → Scene Layers+properties; off → YouTube chat, independent of stream status. Source add/edit/remove, list drag-to-reorder, snapshot revert-accept; TextOverlay properties edit multiline content, font type/family, size, color via the shared palette, emphasis, and horizontal/vertical alignment with a static-text notice; sub-options are indented under their headings. **TASK 51:** the webcam properties carry the Background section — `RemoveBackgroundPill` / `BlurBackgroundPill` (`PillToggle`, radio-exclusive via `WebcamSceneConfig.RemoveBackground`/`BlurBackground`) + `BackgroundStrengthSlider` (0..1, 5% snaps, shown only while an effect is on). **TASK 57:** the webcam properties carry a Geometry section (X/Y/W/H) — manual placement of the camera, bound to the **live** placement (the one the current tier shows), gated on `IsWebcamSelected` (not `SelectedElement.IsWebcam` — the TASK 51 test finds that panel by binding text and would match twice); the Shape button's `Visibility` is bound to `CanToggleClipShape`, so the circle option is NOT OFFERED in the vertical Chat layout. **TASK 58:** every drop-down, check box and field in the properties panel carries the theme's style (`YtComboBox`/`YtCheckBox`/`YtTextBoxCompact`) and paints no colour of its own — the guard is `ytLive.Tests/PropertiesStyleTests.cs`. **TASK 59:** the webcam's properties are a HIERARCHY — a `Shape` title owns the mirror switch and the shape choice (`Rect` | `Circle` as two `SegmentToggle` options, Rect selected by default, the circle option hidden where `CanToggleClipShape` is false), and every titled block (Geometry, Border Color, Border Thickness, Background) owns its content one indent deeper (Strength sits under Blur). **TASK 60:** the border-colour drop-down builds its menu from `Models/BorderColorPalette.cs` — each row is the hex plus the colour's NAME (`#e94560 Crimson`, tooltip "Crimson (#e94560)"); the swatch icon is unchanged. **TASK 61:** the four geometry fields (X/Y/W/H) share one fixed width (44px — the largest value, 1920, plus a sign) and their rows set `LastChildFill="False"`, so Y/H no longer stretch to fill the row. **TASK 62:** the border-colour field shows the palette's name beside the hex (`SelectedElement.BorderColorName`, empty for a custom hex) and keeps a fixed width so the name stays next to the hex. **TASK 63:** the border colour is picked with the **app's own drop-down** (`YtComboBox`) — the `...` button and its code-built `ContextMenu` are gone; the closed box shows the swatch + the colour's NAME, the rows show swatch + hex + name (the panel's own `DataTemplate`s + `Helpers/ClosedBoxTemplateSelector.cs`), and the hex field stays for a custom colour; `IsDescendantOf` falls back to `LogicalTreeHelper.GetParent` / `TemplatedParent` when `VisualTreeHelper.GetParent` returns `null` (at `PopupRoot`) so clicking a popup row does not trigger `MainWindow`'s deselection guard |
|
||||
| `OverlayHost.xaml(.cs)` | Root-grid overlays: App Settings / Report Bug / Feature Request / About; About includes the output-quality conversion copy and an explicit llamacasty.com link, while the licensing/legal panel remains in-app. App Settings groups recording, brand-logo, preview, and transition controls. `BackToAbout_Click` lives here |
|
||||
| `SceneThumbnailStrip.xaml.cs` | Code-behind for the strip above |
|
||||
| `LeftPanel.xaml.cs` | Code-behind for the panel above |
|
||||
|
||||
The Add Layer menu exposes one standalone recent-alert Ticker per scene. The
|
||||
properties panel edits its independent `Ticker` settings; Stream Alerts retains
|
||||
its separate current-event ticker controls. Outside edit mode, the left panel
|
||||
offers Live Chat first and Logs second; Logs wraps and colors typed entries,
|
||||
pins explicit Class 1/2 errors above the timeline until acknowledged, then
|
||||
returns them to the timeline; it excludes earlier file contents.
|
||||
|
||||
## Window ↔ control contract
|
||||
|
||||
The shell (`MainWindow.xaml(.cs)`) wires up each control and routes the few
|
||||
|
||||
+22
-25
@@ -53,8 +53,8 @@ on the partner dashboard — the dashboard's Downloads page only re-offers the S
|
||||
| SDK zip (sign in to partner first) | `partner.steamgames.com/downloads/steamworks_sdk.zip` |
|
||||
| Docs home | `partner.steamgames.com/doc/home` |
|
||||
| SteamPipe upload (steamcmd + VDF fields) | `partner.steamgames.com/doc/sdk/uploading` |
|
||||
| DLC configuration | `partner.steamgames.com/doc/store/application/dlc` |
|
||||
| `BIsDlcInstalled` | `partner.steamgames.com/doc/api/ISteamApps` |
|
||||
| Demo configuration | `partner.steamgames.com/doc/store/application/demos` |
|
||||
| Paid app ownership (`BIsSubscribedApp`) | `partner.steamgames.com/doc/api/ISteamApps#BIsSubscribedApp` |
|
||||
| Store artwork sizes | `partner.steamgames.com/doc/store/assets/standard` |
|
||||
| Coming Soon / 2-week public window | `partner.steamgames.com/doc/store/coming_soon` |
|
||||
| Wallet rule (in-app purchases) | `partner.steamgames.com/doc/features/microtransactions` |
|
||||
@@ -64,17 +64,17 @@ on the partner dashboard — the dashboard's Downloads page only re-offers the S
|
||||
|
||||
1. **Steamworks account** → onboarding (NDA, identity, company details, tax interview, payout bank,
|
||||
then the **$100 Steam Direct fee** per app). The store page cannot be configured before the fee.
|
||||
2. **App IDs.** One **base app** (free) and one **DLC app** (the lifetime Pro). Note both numbers:
|
||||
the DLC app ID is what the app passes to `BIsDlcInstalled`.
|
||||
3. **Depot.** One depot holding the publish output; the base app and the DLC share the install
|
||||
directory, and the DLC package grants no files of its own — it is entitlement only.
|
||||
2. **App IDs.** One paid app and its associated free Demo. The Demo has its own App ID; it checks
|
||||
ownership of the paid app with `BIsSubscribedApp(paidAppId)`.
|
||||
3. **Builds.** Keep one codebase. Steam gives the Demo a separate release checklist/build setup;
|
||||
shared depots can avoid duplicating common files once the paid app is released.
|
||||
4. **Steamworks SDK.** `./scripts/steam-sdk.sh ~/Downloads/steamworks_sdk_165.zip` extracts
|
||||
`steam_api64.dll` from `sdk/redistributable_bin/win64/` into `steam/` (gitignored) and stages
|
||||
`steam_appid.txt` = 480 — Valve's Spacewar app, so `SteamAPI.Init()` works locally before
|
||||
LlamaCasty has an App ID. Add `--contentbuilder` on the machine that uploads a depot. The csproj
|
||||
copies the DLL **beside the exe** (`Link`) with `ExcludeFromSingleFile`, and copies
|
||||
`steam_appid.txt` in Debug only — never into a depot. The managed side is the `Steamworks.NET`
|
||||
package (`SteamApps.BIsDlcInstalled`).
|
||||
package (`SteamApps.BIsSubscribedApp`).
|
||||
5. **Store page** configured and made public **two weeks before release** — Valve's rule, and it is
|
||||
the window where wishlists accumulate.
|
||||
|
||||
@@ -89,31 +89,28 @@ publish → steam-stage → steamcmd + app_build.vdf → build upload → set li
|
||||
2. **Set the build live** in Steamworks → *Builds* → the new build → **Set Build Live** on the
|
||||
default branch. That *is* the release; there is no artifact to hash and no manifest to submit.
|
||||
3. **Announce** with Steam Events (release notes appear in the library and on the store page).
|
||||
4. **Verify**: install from a clean Steam account, confirm `BIsDlcInstalled` returns false, buy the
|
||||
DLC (or use a test licence), confirm the high tiers unlock, then refund the test purchase.
|
||||
4. **Verify**: install the Demo from a clean Steam account, confirm paid-app ownership is false,
|
||||
then test with an account that owns the paid app and confirm the higher tiers unlock.
|
||||
|
||||
**Updates** are the same upload with a higher build ID. The app keeps **no self-updater and no
|
||||
version banner** — Steam is the update path, and that policy has not changed.
|
||||
|
||||
## 4. Entitlement wiring
|
||||
|
||||
- `IEntitlement` (`Services/Entitlement/`) has one method, `IsPro`. **Steam DLC** answers it today
|
||||
(`SteamEntitlement`, `Steamworks.NET` + `steam_api64.dll`); the **Polar** monthly rail plugs in
|
||||
beside it. Resolution is `Pro = steamDlc || polarLease`, then `MainViewModel.HasProOutput` adds
|
||||
the Debug-only `DeveloperMode`; anything unresolved is the free profile.
|
||||
- **`SteamEntitlement.NotConfigured()` is the shipping default** until the DLC app id exists — it
|
||||
answers false without touching the native library. Set the id when Steamworks creates the DLC.
|
||||
- `IEntitlement` reports `IsAppOwned`. Debug uses a fake owned result; Release checks the paid app
|
||||
ID through `BIsSubscribedApp`. `MainViewModel.IsAppOwned` is the app-wide binary ownership flag.
|
||||
- The paid app ID is not yet configured. Until it is assigned, the Release path fails closed without
|
||||
touching the native library.
|
||||
- **Fail closed on every path:** no id, no client, missing DLL, thrown load, not owned.
|
||||
- **Never sell the monthly inside the Steam build.** Steamworks requires in-app purchases to use
|
||||
the Steam Wallet; the app may *state* that monthly plans exist on the site, with no checkout,
|
||||
no price and no payment field. Confirm the wording with Steamworks support before shipping it.
|
||||
- The EULA should expressly allow commercial/monetized streaming use; confirm with counsel or Valve
|
||||
that this satisfies the Steam Subscriber Agreement's software terms.
|
||||
- **Never gate a feature.** The only delta is the output profile.
|
||||
|
||||
## 5. Release checklist
|
||||
|
||||
- Build clean, 0 warnings (`./scripts/verify.sh`), tests green.
|
||||
- `steam_api64.dll` present; app starts **without** Steam and falls back to the free profile.
|
||||
- DLC ownership false → free profile; true → every tier offered.
|
||||
- Paid-app ownership false → free profile; true → higher output tiers offered.
|
||||
- `LlamaCasty.exe` rename landed (never rename a live exe).
|
||||
- Clean Steam install on a second machine, then an update pushed and observed.
|
||||
- Store page public for the full two weeks; support contact and announcements configured.
|
||||
@@ -123,15 +120,15 @@ version banner** — Steam is the update path, and that policy has not changed.
|
||||
|
||||
- **`LICENSE`** (EULA) forbids redistribution, resale, reverse-engineering and circumvention, and
|
||||
disclaims warranty.
|
||||
- **`PRIVACY.md`** must name any third party the app contacts — Google/YouTube today, **Polar** the
|
||||
day licence validation ships. Steam itself is named by Valve's own policy, not ours.
|
||||
- **`PRIVACY.md`** must name any third party the app contacts — Google/YouTube. Steam itself is
|
||||
named by Valve's own policy, not ours.
|
||||
- **`THIRD-PARTY-NOTICES.txt`** stays shipped and shown in About; the ffmpeg pin stays
|
||||
`lgpl-shared`; no GPL component may be linked (see `product.md` → "Licensing").
|
||||
- **DMCA**: a takedown needs only the notice — host, link, good-faith statement, signature.
|
||||
Statutory damages need a registration ($65). Worth doing once there is something to pirate.
|
||||
- **What actually protects the product** is in `commercial.md` → "Anti-piracy posture": a free tier
|
||||
good enough that a crack buys nothing, Steam's own delivery, short Polar leases, and a licence ID
|
||||
in About. **Not obfuscation, not a native DLL, not hardware locking.**
|
||||
- **What actually protects the product** is in `commercial.md` → "Anti-piracy posture": a useful
|
||||
free tier, Steam's own delivery, and Steam ownership of the paid app. **Not obfuscation, not a
|
||||
native DLL, not hardware locking.**
|
||||
|
||||
## 7. Effort, once
|
||||
|
||||
@@ -141,4 +138,4 @@ version banner** — Steam is the update path, and that policy has not changed.
|
||||
| Publish profile + `steam_api64.dll` + `IEntitlement` Steam path | 1 day |
|
||||
| Depot + first `steamcmd` upload | 0.5 day, then ~15 min per release |
|
||||
| Store page + assets (screenshots, capsule art) | 1–2 days |
|
||||
| Clean-account install + DLC purchase/refund test | 0.5 day |
|
||||
| Clean-account Demo install + paid-app ownership test | 0.5 day |
|
||||
|
||||
+14
-31
@@ -1,40 +1,23 @@
|
||||
# HANDOFF — 2026-11-01 (exe renamed, depot builds, output rule enforced)
|
||||
# HANDOFF — 2026-11-04 (TASK 3·17 — static text layers)
|
||||
|
||||
## State
|
||||
|
||||
- Branch `main`, level with `origin` through `8f79b20`; this unit is local.
|
||||
- **The shipped exe is `LlamaCasty.exe`** (`AssemblyName` renamed; four pack URIs fixed — the docs
|
||||
said three, `KeepBroadcastDialog.xaml:5` was the fourth). Guarded by `AssemblyIdentityTests`.
|
||||
- **`scripts/publish.sh`** stages `artifacts/steam-stage` and refuses to finish if `ffmpeg\`, the
|
||||
licence, privacy or notices file is missing, or test assemblies leaked in. Output 257 MB.
|
||||
- **The output-profile rule is built and enforced**: `Models/OutputProfile.cs` (921,600 px / 30 fps,
|
||||
fail closed) resolved once in `BuildEncoderOptions` — one ffmpeg process, two outputs.
|
||||
`MainViewModel.DeveloperMode` bypasses it in Debug only.
|
||||
- **Distribution is Steam-exclusive** (2026-10-31): free app + paid edition as a Steam **DLC**; the
|
||||
**$10/month** plan sold off-Steam by Polar, *stated, never sold*, in-app. Entitlement is
|
||||
`steamDlcOwned || polarKeyValid`; the app must start without Steam, falling back to free.
|
||||
- **Site live with Steam** (`a9d07ce`); `www.llamacasty.com` 301s to the apex.
|
||||
- `main`; TASK 53 is complete and TASK 3·17 is complete in three commits: `60c4a00`, `58eb640`,
|
||||
and the final output/compositing phase.
|
||||
- Text layers persist independently, edit as static multiline text, and share one cached raster between
|
||||
preview and output. The frame pump only reads the raster cache; no WPF render occurs on its thread.
|
||||
- The app clean build has 0 warnings. 524 tests passed with the documented physical mouse-injection test
|
||||
and known audio test excluded; the new text renderer, preview/output, and pump-cache tests pass.
|
||||
- The four untracked `Assets/llamacasty-*` files remain untouched.
|
||||
|
||||
## Next action
|
||||
|
||||
**Surge, 2026-11-02 → 11-07 — six days, all remaining code.** Day 1 is partly done ahead of time:
|
||||
`IEntitlement` + `SteamEntitlement` (Steamworks.NET, fail-closed on every path) + `FakeEntitlement`,
|
||||
with `HasProOutput` reading the rail. Undo/redo, the rail gear and the EDIT alignment also landed
|
||||
early. Remaining on day 1: greyed tiers, licence UI's four states, system messages, licence ID in
|
||||
About. Days 2–6 in `TASKS.md` → "The surge".
|
||||
- Next action: select the next queued task from `TASKS.md`.
|
||||
- Do not push absent a checkpoint request.
|
||||
|
||||
## Landmines
|
||||
|
||||
- **⛔ `ExcludeFromSingleFile="true"` on the ffmpeg content** — without it the publisher bundles
|
||||
libav\*.dll into the exe and the app ships unable to find ffmpeg. Recipe in `MyMistakes.md`.
|
||||
- **Dev affordances are compiled out, not hidden** — `DeveloperPanel` is removed from the Release
|
||||
build; `Helpers/DevBuild.cs` is the single source for the flag (never `const`).
|
||||
- **Nothing may gate a feature.** The only paid delta is the output profile; `ShowBrandFlash` stays
|
||||
`false` and entitlement must never enable it.
|
||||
- **No checkout inside a Steam build** — the Wallet rule. The monthly is named, never sold, in-app.
|
||||
- **The org access token never ships.** `organization_id` is public; the token is not.
|
||||
- **The site publishes the model, never the numbers**, and never takes payment or links a checkout.
|
||||
- **No self-updater and no version banner**: Steam updates the app.
|
||||
- Pre-existing: the **full test suite hangs** (WASAPI teardown) — per-class runs; `ytLive.Tests`
|
||||
carries 3 xUnit warnings the app-only gate does not see.
|
||||
- Not every hex is a theme colour — `BorderColorPalette`/chat defaults are USER DATA, not tokens.
|
||||
- `LayerReorderPersistenceTests.RealMouseDrag_OnTheLayerList_PersistsTheReorder` injects physical
|
||||
mouse input. Do not run it while the desktop is in use.
|
||||
- Integration tests constructing `MainWindow` must set a temporary `LayoutPathOverride` and clear it in `finally`.
|
||||
- Bundled FFmpeg stays outside the single-file exe (`ExcludeFromSingleFile="true"`).
|
||||
|
||||
+70
-2
@@ -2,6 +2,24 @@ using System.IO;
|
||||
|
||||
namespace ytLive.Helpers;
|
||||
|
||||
/// <summary>Presentation category; independent from the entry's attention class.</summary>
|
||||
public enum AppLogMessageType
|
||||
{
|
||||
Information,
|
||||
Success,
|
||||
Warning,
|
||||
Error,
|
||||
}
|
||||
|
||||
/// <summary>Explicit attention priority, assigned by the writer rather than inferred from text or source.</summary>
|
||||
public enum AppLogErrorClass
|
||||
{
|
||||
/// <summary>Fatal or unhandled failure; highest priority.</summary>
|
||||
Class1 = 1,
|
||||
/// <summary>Recoverable failure that still requires attention.</summary>
|
||||
Class2 = 2,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Minimal file logger used to diagnose startup/shutdown crashes on Windows,
|
||||
/// where the WPF app has no visible console. Writes to
|
||||
@@ -12,6 +30,7 @@ public static class AppLog
|
||||
{
|
||||
/// <summary>The cap for the live log file. Past this, it rolls to <c>startup.1.log</c>.</summary>
|
||||
private const long DefaultMaxBytes = 5 * 1024 * 1024;
|
||||
private const int CurrentLaunchEntryLimit = 500;
|
||||
|
||||
/// <summary>How many rolled copies to keep (startup.1.log, startup.2.log, …).</summary>
|
||||
private const int RetainedFiles = 1;
|
||||
@@ -20,6 +39,8 @@ public static class AppLog
|
||||
InstanceProfile.DataRoot, "startup.log");
|
||||
|
||||
private static readonly object Gate = new();
|
||||
private static readonly List<AppLogEntry> CurrentLaunchEntries = new();
|
||||
private static long _nextSequence;
|
||||
|
||||
/// <summary>Test seam: point the log somewhere else. Null = the real profile's log.</summary>
|
||||
internal static string? PathOverride { get; set; }
|
||||
@@ -29,16 +50,40 @@ public static class AppLog
|
||||
|
||||
private static string LogPath => PathOverride ?? DefaultLogPath;
|
||||
|
||||
internal static IReadOnlyList<AppLogEntry> SnapshotCurrentLaunch()
|
||||
{
|
||||
lock (Gate)
|
||||
return CurrentLaunchEntries.ToArray();
|
||||
}
|
||||
|
||||
public static void Write(string message)
|
||||
=> Write(message, AppLogMessageType.Information);
|
||||
|
||||
public static void WriteWarning(string message)
|
||||
=> Write(message, AppLogMessageType.Warning);
|
||||
|
||||
public static void WriteSuccess(string message)
|
||||
=> Write(message, AppLogMessageType.Success);
|
||||
|
||||
public static void Write(string message, AppLogMessageType type, AppLogErrorClass? errorClass = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
lock (Gate)
|
||||
{
|
||||
var entry = new AppLogEntry(
|
||||
++_nextSequence,
|
||||
$"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff} {message}",
|
||||
type,
|
||||
errorClass);
|
||||
CurrentLaunchEntries.Add(entry);
|
||||
if (CurrentLaunchEntries.Count > CurrentLaunchEntryLimit)
|
||||
CurrentLaunchEntries.RemoveAt(0);
|
||||
|
||||
var path = LogPath;
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
LogRotation.RollIfNeeded(path, MaxBytes, RetainedFiles);
|
||||
File.AppendAllText(path, $"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff} {message}{Environment.NewLine}");
|
||||
File.AppendAllText(path, $"{entry.Text}{Environment.NewLine}");
|
||||
}
|
||||
}
|
||||
catch
|
||||
@@ -49,6 +94,29 @@ public static class AppLog
|
||||
|
||||
public static void Write(Exception exception, string message)
|
||||
{
|
||||
Write($"{message}: {exception}");
|
||||
Write($"{message}: {exception}", AppLogMessageType.Error);
|
||||
}
|
||||
|
||||
public static void Write(Exception exception, string message, AppLogErrorClass errorClass)
|
||||
=> Write($"{message}: {exception}", AppLogMessageType.Error, errorClass);
|
||||
}
|
||||
|
||||
public sealed record AppLogEntry(long Sequence, string Text, AppLogMessageType Type,
|
||||
AppLogErrorClass? ErrorClass)
|
||||
{
|
||||
public string? ErrorClassLabel => ErrorClass switch
|
||||
{
|
||||
AppLogErrorClass.Class1 => "CLASS 1",
|
||||
AppLogErrorClass.Class2 => "CLASS 2",
|
||||
_ => null,
|
||||
};
|
||||
|
||||
public string TypeLabel => ErrorClassLabel ?? Type switch
|
||||
{
|
||||
AppLogMessageType.Information => "INFO",
|
||||
AppLogMessageType.Success => "SUCCESS",
|
||||
AppLogMessageType.Warning => "WARNING",
|
||||
AppLogMessageType.Error => "ERROR",
|
||||
_ => Type.ToString().ToUpperInvariant(),
|
||||
};
|
||||
}
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ Cross-cutting utilities. See [`schema.md`](../schema.md) for the memory-map conv
|
||||
|------|---------|
|
||||
| `ViewModelBase.cs` | `INotifyPropertyChanged` + `SetProperty<T>()` — base for all ViewModels |
|
||||
| `RelayCommand.cs` | `ICommand` implementation for all button actions |
|
||||
| `AppLog.cs` | File logger to `%APPDATA%\ytLlive\startup.log`; startup checkpoints + unhandled-exception capture (the crash this map is named for). Size-capped: rolls to `startup.1.log` at 5 MB (TASK 55) |
|
||||
| `AppLog.cs` | File logger to `%APPDATA%\ytLlive\startup.log`; startup checkpoints + unhandled-exception capture. Size-capped: rolls to `startup.1.log` at 5 MB (TASK 55). Also keeps the newest 500 formatted entries in a process-local buffer for the Logs tab, with independent message type and explicit Class 1/2 severity metadata; it never loads prior-launch file contents |
|
||||
| `LogRotation.cs` | The size-capped-rotation policy as a pure function over a path: roll `startup.log` → `startup.1.log` when it passes the cap, keeping N rolled copies. Separate from `AppLog` so the logger's 156-call-site surface never changes and the rule is testable alone (TASK 55) |
|
||||
| `FocusPreservingListBox.cs` | `ListBox` subclass that keeps selection/focus coherent when the selected item is deleted; `IsSelectable` predicate skips hidden scenes; drag-reorder guard |
|
||||
| `OAuthCredentials.cs` | Baked-in Google OAuth client ID/secret (desktop app; loopback callback) |
|
||||
|
||||
@@ -11,6 +11,8 @@ public partial class KeepBroadcastDialog : Window
|
||||
public KeepBroadcastDialog(string title)
|
||||
{
|
||||
InitializeComponent();
|
||||
if (Application.Current?.MainWindow is { } owner && !ReferenceEquals(owner, this))
|
||||
Owner = owner;
|
||||
MessageText.Text = string.IsNullOrWhiteSpace(title)
|
||||
? "Your private broadcast is on YouTube as a private video. Keep it, or delete it?"
|
||||
: $"\u201c{title}\u201d is on YouTube as a private video. Keep it, or delete it?";
|
||||
|
||||
+17
-7
@@ -137,12 +137,22 @@
|
||||
<TextBlock Grid.Row="4" Grid.ColumnSpan="2" Text="{Binding CurrentHealth.HealthMessage}"
|
||||
Foreground="{StaticResource Brush.Attention}" FontSize="12" TextWrapping="Wrap" Margin="0,0,0,6"/>
|
||||
|
||||
<ComboBox Grid.Row="5" Grid.ColumnSpan="2" VerticalAlignment="Bottom" Style="{StaticResource YtComboBox}"
|
||||
ItemsSource="{Binding QualityOptions}"
|
||||
SelectedItem="{Binding SelectedQuality}"
|
||||
DisplayMemberPath="Label"
|
||||
ToolTip="{Binding ResolutionHelp}"
|
||||
IsEnabled="{Binding IsOffline}"/>
|
||||
<StackPanel Grid.Row="5" Grid.ColumnSpan="2" VerticalAlignment="Bottom">
|
||||
<ComboBox x:Name="QualityPicker" Style="{StaticResource YtComboBox}"
|
||||
ItemsSource="{Binding QualityOptions}"
|
||||
SelectedItem="{Binding SelectedQuality}"
|
||||
ItemTemplate="{StaticResource YtQualityComboBoxItemTemplate}"
|
||||
ItemContainerStyle="{StaticResource YtQualityComboBoxItem}"
|
||||
ToolTip="{Binding ResolutionHelp}"
|
||||
IsEnabled="{Binding CanChangeQuality}"/>
|
||||
<Border x:Name="EntitlementErrorPanel" Margin="0,6,0,0" Padding="8,6"
|
||||
Background="{StaticResource Brush.ErrorContainer}"
|
||||
BorderBrush="{StaticResource Brush.Error}" BorderThickness="1" CornerRadius="4"
|
||||
Visibility="{Binding EntitlementErrorMessage, Converter={StaticResource NotNullToVis}}">
|
||||
<TextBlock x:Name="EntitlementErrorText" Text="{Binding EntitlementErrorMessage}"
|
||||
Foreground="{StaticResource Brush.Error}" FontSize="11" TextWrapping="Wrap"/>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
@@ -165,7 +175,7 @@
|
||||
|
||||
|
||||
<!-- ═══ OVERLAY: Settings / Bug / Feature / About ═══ -->
|
||||
<controls:OverlayHost Grid.RowSpan="3"/>
|
||||
<controls:OverlayHost x:Name="AboutOverlay" Grid.RowSpan="3"/>
|
||||
|
||||
<!-- ═══ TOASTS: non-blocking notifications (bottom-right, above footer) ═══ -->
|
||||
<notifications:NotificationArea x:Name="ToastArea"
|
||||
|
||||
+6
-12
@@ -3,11 +3,8 @@ namespace ytLive.Models;
|
||||
/// <summary>
|
||||
/// The one entitlement rule, as pure policy: what a build is allowed to emit.
|
||||
///
|
||||
/// <para><b>Why pixels and a frame rate rather than "720p".</b> The paid delta is output
|
||||
/// quality, and a resolution name is a marketing word, not a measurable bound — 720p60 is
|
||||
/// 921,600 pixels at twice the frame rate, and vertical 720×1280 is the same pixel count
|
||||
/// with a different shape. A pixel budget plus a frame-rate ceiling expresses the same
|
||||
/// promise without a lookup table that a new tier can quietly escape.</para>
|
||||
/// <para><b>Frame rate is the paid boundary.</b> Every supported resolution at 30 fps is free;
|
||||
/// 60 fps requires paid ownership, regardless of frame dimensions.</para>
|
||||
///
|
||||
/// <para><b>Fail closed.</b> Callers pass <c>pro: false</c> when entitlement is unknown,
|
||||
/// unreadable or unresolved; the free profile is the default state, never the fallback
|
||||
@@ -19,19 +16,16 @@ namespace ytLive.Models;
|
||||
/// </summary>
|
||||
public static class OutputProfile
|
||||
{
|
||||
/// <summary>Free ceiling: 921,600 px = 1280×720, i.e. one 720p frame.</summary>
|
||||
public const int FreeMaxPixels = 921_600;
|
||||
|
||||
/// <summary>Free ceiling: 30 frames per second.</summary>
|
||||
public const int FreeMaxFps = 30;
|
||||
|
||||
/// <summary>The named tier a capped build falls back to when nothing else qualifies.</summary>
|
||||
public const string FreeFallbackName = "720p30";
|
||||
/// <summary>The default free tier and fallback when no shipped tier is available.</summary>
|
||||
public const string FreeFallbackName = "1080p30";
|
||||
|
||||
/// <summary>Is this tier within the free budget? Pro may use anything shipped.</summary>
|
||||
public static bool IsAllowed(QualityOption tier, bool pro)
|
||||
=> pro
|
||||
|| (tier.Width * tier.Height <= FreeMaxPixels && tier.Fps <= FreeMaxFps);
|
||||
|| tier.Fps <= FreeMaxFps;
|
||||
|
||||
/// <summary>
|
||||
/// The tier a session will actually encode. A permitted selection passes through
|
||||
@@ -57,6 +51,6 @@ public static class OutputProfile
|
||||
|
||||
// No shipped tier fits the budget: build the floor explicitly rather than trusting
|
||||
// the table. A build whose tiers were all redefined must still emit a legal frame.
|
||||
return candidate ?? new QualityOption(FreeFallbackName, FreeMaxFps, 6.0, 1280, 720);
|
||||
return candidate ?? new QualityOption(FreeFallbackName, FreeMaxFps, 8.0, 1920, 1080);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,4 +9,6 @@ public sealed record QualityOption(string Display, int Fps, double Bitrate, int
|
||||
{
|
||||
/// <summary>Self-documenting label, e.g. "1080p60 (8Mbps)".</summary>
|
||||
public string Label => $"{Display} ({Bitrate:0.#}Mbps)";
|
||||
|
||||
public bool RequiresPro => !OutputProfile.IsAllowed(this, pro: false);
|
||||
}
|
||||
|
||||
+113
-11
@@ -21,7 +21,9 @@ public enum SourceType
|
||||
AlertBox,
|
||||
/// <summary>A privacy blur: blurs the frame beneath it inside a rect or ellipse. Live scene
|
||||
/// only, always below the brand logo, several allowed (strongest wins on overlap).</summary>
|
||||
Blur
|
||||
Blur,
|
||||
/// <summary>A scene-local feed of recent alert events.</summary>
|
||||
Ticker
|
||||
}
|
||||
|
||||
/// <summary>Distinguishes how a media source presents its content (TASK 21).</summary>
|
||||
@@ -39,6 +41,20 @@ public enum MediaPlaybackState
|
||||
Paused
|
||||
}
|
||||
|
||||
public enum TextLayerAlignment
|
||||
{
|
||||
Left,
|
||||
Center,
|
||||
Right
|
||||
}
|
||||
|
||||
public enum TextLayerVerticalAlignment
|
||||
{
|
||||
Top,
|
||||
Center,
|
||||
Bottom
|
||||
}
|
||||
|
||||
public enum ClipShape
|
||||
{
|
||||
Traditional,
|
||||
@@ -66,6 +82,19 @@ public enum AlertDisplayMethod
|
||||
/// </summary>
|
||||
public class Source : SceneElement
|
||||
{
|
||||
private readonly Ticker _ticker = new();
|
||||
|
||||
public Source()
|
||||
{
|
||||
_ticker.PropertyChanged += (_, e) =>
|
||||
{
|
||||
if (e.PropertyName == nameof(Ticker.IsEnabled)) Raise(nameof(AlertShowTicker));
|
||||
if (e.PropertyName == nameof(Ticker.DisplayMethod)) Raise(nameof(AlertDisplayMethod));
|
||||
};
|
||||
}
|
||||
|
||||
public Ticker Ticker => _ticker;
|
||||
|
||||
private SourceType _type;
|
||||
public SourceType Type
|
||||
{
|
||||
@@ -80,6 +109,7 @@ public class Source : SceneElement
|
||||
Raise(nameof(IsWebSource));
|
||||
Raise(nameof(IsTextOverlay));
|
||||
Raise(nameof(IsScoreboard));
|
||||
Raise(nameof(IsTicker));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -88,18 +118,19 @@ public class Source : SceneElement
|
||||
/// and sizes it in the preview, so it needs the same selection box, grab and resize handle
|
||||
/// (creator ruling 2026-11-01).</summary>
|
||||
public override bool IsDraggable => Type is SourceType.Image or SourceType.WebSource or SourceType.ChatBox
|
||||
or SourceType.AlertBox or SourceType.Blur;
|
||||
or SourceType.TextOverlay or SourceType.AlertBox or SourceType.Blur or SourceType.Ticker;
|
||||
|
||||
/// <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
|
||||
or SourceType.Blur
|
||||
or SourceType.Blur or SourceType.Ticker
|
||||
? ElementKind.Dynamic : ElementKind.Static;
|
||||
|
||||
public override bool IsWebSource => Type == SourceType.WebSource;
|
||||
public override bool IsTextOverlay => Type == SourceType.TextOverlay;
|
||||
|
||||
public override bool IsScoreboard => Type == SourceType.Scoreboard;
|
||||
public bool IsTicker => Type == SourceType.Ticker;
|
||||
|
||||
/// <summary>The permanent brand logo layer (one per scene, directly above the background).
|
||||
/// Locked against removal; its image comes from the App Settings brand logo.</summary>
|
||||
@@ -155,8 +186,8 @@ public class Source : SceneElement
|
||||
/// scoreboard joined this list 2026-10-30 — without it the strip was rendered, published to
|
||||
/// VideoImageSource, and never displayed (the same shape as the invisible-alert defect).</summary>
|
||||
private bool IsFrameSource =>
|
||||
Type is SourceType.ChatBox or SourceType.WebSource or SourceType.MediaSource
|
||||
or SourceType.AlertBox or SourceType.Scoreboard;
|
||||
Type is SourceType.TextOverlay or SourceType.ChatBox or SourceType.WebSource or SourceType.MediaSource
|
||||
or SourceType.AlertBox or SourceType.Scoreboard or SourceType.Ticker;
|
||||
|
||||
public override ImageSource? DisplaySource => IsLiveCapture
|
||||
? (VideoImageSource ?? _imageSource)
|
||||
@@ -259,11 +290,10 @@ public class Source : SceneElement
|
||||
set => Set(ref _alertUseDefaultVideo, value);
|
||||
}
|
||||
|
||||
private bool _alertShowTicker = true;
|
||||
public bool AlertShowTicker
|
||||
{
|
||||
get => _alertShowTicker;
|
||||
set => Set(ref _alertShowTicker, value);
|
||||
get => _ticker.IsEnabled;
|
||||
set => _ticker.IsEnabled = value;
|
||||
}
|
||||
|
||||
private double _alertVideoVolume = 1.0;
|
||||
@@ -295,11 +325,10 @@ public class Source : SceneElement
|
||||
/// <summary>True once a file is chosen — gates the loop switch and the clear button.</summary>
|
||||
public bool HasAlertAudio => !string.IsNullOrWhiteSpace(_alertAudioPath);
|
||||
|
||||
private AlertDisplayMethod _alertDisplayMethod = AlertDisplayMethod.TickerScroll;
|
||||
public AlertDisplayMethod AlertDisplayMethod
|
||||
{
|
||||
get => _alertDisplayMethod;
|
||||
set => Set(ref _alertDisplayMethod, value);
|
||||
get => _ticker.DisplayMethod;
|
||||
set => _ticker.DisplayMethod = value;
|
||||
}
|
||||
|
||||
public override bool IsAlertBox => Type == SourceType.AlertBox;
|
||||
@@ -376,6 +405,79 @@ public class Source : SceneElement
|
||||
|
||||
public override bool IsChatBox => Type == SourceType.ChatBox;
|
||||
|
||||
private string _textContent = "Text";
|
||||
public string TextContent
|
||||
{
|
||||
get => _textContent;
|
||||
set => Set(ref _textContent, value ?? string.Empty);
|
||||
}
|
||||
|
||||
private string _textFontFamily = "Segoe UI";
|
||||
public string TextFontFamily
|
||||
{
|
||||
get => _textFontFamily;
|
||||
set => Set(ref _textFontFamily, string.IsNullOrWhiteSpace(value) ? "Segoe UI" : value.Trim());
|
||||
}
|
||||
|
||||
private double _textFontSize = 48;
|
||||
public double TextFontSize
|
||||
{
|
||||
get => _textFontSize;
|
||||
set => Set(ref _textFontSize, double.IsFinite(value) ? Math.Clamp(value, 8, 300) : 48);
|
||||
}
|
||||
|
||||
private string _textColor = "#FFFFFF";
|
||||
public string TextColor
|
||||
{
|
||||
get => _textColor;
|
||||
set
|
||||
{
|
||||
if (!Set(ref _textColor, IsValidTextColor(value) ? value.ToUpperInvariant() : "#FFFFFF")) return;
|
||||
Raise(nameof(TextColorEntry));
|
||||
Raise(nameof(TextColorOptions));
|
||||
}
|
||||
}
|
||||
|
||||
public NamedColor TextColorEntry
|
||||
{
|
||||
get => BorderColorPalette.EntryFor(TextColor);
|
||||
set => TextColor = value.Hex;
|
||||
}
|
||||
|
||||
public IReadOnlyList<NamedColor> TextColorOptions => BorderColorPalette.OptionsFor(TextColor);
|
||||
|
||||
private bool _textBold;
|
||||
public bool TextBold { get => _textBold; set => Set(ref _textBold, value); }
|
||||
|
||||
private bool _textItalic;
|
||||
public bool TextItalic { get => _textItalic; set => Set(ref _textItalic, value); }
|
||||
|
||||
private bool _textUnderline;
|
||||
public bool TextUnderline { get => _textUnderline; set => Set(ref _textUnderline, value); }
|
||||
|
||||
private TextLayerAlignment _textAlignment;
|
||||
public TextLayerAlignment TextAlignment
|
||||
{
|
||||
get => _textAlignment;
|
||||
set => Set(ref _textAlignment, Enum.IsDefined(value) ? value : TextLayerAlignment.Left);
|
||||
}
|
||||
|
||||
private TextLayerVerticalAlignment _textVerticalAlignment;
|
||||
public TextLayerVerticalAlignment TextVerticalAlignment
|
||||
{
|
||||
get => _textVerticalAlignment;
|
||||
set => Set(ref _textVerticalAlignment,
|
||||
Enum.IsDefined(value) ? value : TextLayerVerticalAlignment.Top);
|
||||
}
|
||||
|
||||
private static bool IsValidTextColor(string? value)
|
||||
{
|
||||
if (value is not { Length: 7 or 9 } || value[0] != '#') return false;
|
||||
for (var i = 1; i < value.Length; i++)
|
||||
if (!Uri.IsHexDigit(value[i])) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
public override bool IsCountdown => Type == SourceType.Countdown;
|
||||
|
||||
private bool _chatPreviewEnabled = true;
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
using System.ComponentModel;
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace ytLive.Models;
|
||||
|
||||
public sealed class Ticker : INotifyPropertyChanged
|
||||
{
|
||||
private bool _isEnabled = true;
|
||||
private AlertDisplayMethod _displayMethod = AlertDisplayMethod.TickerScroll;
|
||||
|
||||
public bool IsEnabled
|
||||
{
|
||||
get => _isEnabled;
|
||||
set => Set(ref _isEnabled, value);
|
||||
}
|
||||
|
||||
public AlertDisplayMethod DisplayMethod
|
||||
{
|
||||
get => _displayMethod;
|
||||
set => Set(ref _displayMethod, value);
|
||||
}
|
||||
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
|
||||
private void Set<T>(ref T field, T value, [CallerMemberName] string? propertyName = null)
|
||||
{
|
||||
if (EqualityComparer<T>.Default.Equals(field, value)) return;
|
||||
field = value;
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
|
||||
}
|
||||
}
|
||||
+4
-3
@@ -7,7 +7,8 @@ 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/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`, 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 (one per layout)**/**Ticker (one per scene)**/Blur), `X`/`Y`/`Width`/`Height`, `Opacity`, `IsEnabled`, `ImageSource` (static) / `VideoImageSource` (live frames), `DisplaySource` (`IsLiveCapture` → video, else static), `ClipShape` enum (Traditional/Round), `IsMirrored` + `MirrorScale`, `MirrorButtonText`, asset identity, `IsImageSource` (XAML binds this, never `Type`), **TextOverlay content + font/size/color/emphasis/horizontal-alignment/vertical-alignment settings** (independent per source; static text), **backdrop surface**: `IsBackdrop` (permanent bottom layer, never removable), `CaptureKey` (persisted `monitor:<n>`/`window:<hwnd>`/`picker:<name>`), `IsLiveCapture` (`Type` is Display/WindowCapture); each source owns `Ticker` settings, with AlertBox compatibility properties persisted on that source row |
|
||||
| `Ticker.cs` | Shared per-source ticker settings: `IsEnabled` and `DisplayMethod`; one independent instance for each scene ticker and the AlertBox ticker |
|
||||
| `SceneElement.cs` | Base for anything placeable in a scene: shared layout/clip/mirror/border surface, `virtual IsWebcam`/`virtual IsImageSource`/`virtual IsZoomLayer` (TASK 52); `ToggleClipShape` + persisted `RectWidth`/`RectHeight` (pre-Round rect so round→rect restores after reload); **TASK 57:** the geometry setters (`X`/`Y`/`Width`/`Height`) are `virtual` so a derived element can CONSTRAIN every write (the webcam clamps itself into the vertical tier's band) instead of every writer re-implementing the rule; `ClipShape` + `ToggleClipShape()` are `virtual` too, so a derived element can CONSTRAIN the shape (the Chat scene's vertical camera is rect-only); **TASK 59:** `IsRectShape`/`IsRoundShape` — the shape as a CHOICE (the properties panel shows Rect | Circle with Rect selected by default), each a settable bool whose setter flips the shape through `ToggleClipShape()` and always re-raises, so the UI snaps back when a write is refused; **TASK 63:** `BorderColorEntry` (the palette entry for the current border colour — hex + NAME, a nameless entry for a custom hex; setting it writes the hex back) and `BorderColorOptions` (the palette + that entry), both raised when `BorderColor` changes — the drop-down binds to them |
|
||||
| `VerticalCameraLayout.cs` | **TASK 57 (the "vv" feature):** the vertical (9:16) tier's camera rules as pure math — `TileWidth`/`TileHeight` (270×152 master px = 480×270 in the 1080×1920 output), `Inset` (32 master px from the band's outer edge), `IsVerticalTier(QualityOption)` (portrait tier), `AreaFor(band, bottom)` (the band splits into two 607×540 areas), `MaxSizeFor(band)`, `SpotFor(band, w, h, centreX, centreY)` (the six spots — the area and column the tile's **centre** falls in, inset, never outside the band) and `DefaultSpot` (bottom-right, the corner the 16:9 default webcam uses), `FillsArea(band, width)` (the camera is wide enough to fill a whole area = "enlarged") and `SizeFor(band, requestedWidth)` (the Chat camera's TWO sizes - the small tile or one whole 607×540 area — whichever is nearer the requested width, so the resize handle has two detents) |
|
||||
| `ZoomSceneElement.cs` | **Transient zoom box (TASK 52, creator-corrected 2026-10-08):** carries the box rect AND the anchored `CaptureX`/`CaptureY` (the fixed box/2 capture window set on `ActivateZoom`; moving the box never touches it). `IsZoomActive` flips `Kind` Static→Dynamic (leaves the baked base while active); never persisted (LayoutStore save skips non-Source/non-WebcamSceneConfig rows), never shown in the layers list or the element template (`IsZoomLayer` gates both). The compositor SKIPS the active element in every layer loop and draws its 2x content LAST (layer n+1) via `ZoomMagnifier`. |
|
||||
@@ -15,8 +16,8 @@ Plain data types. No logic beyond what a property can carry. See
|
||||
| `BackgroundEffect.cs` | **TASK 51:** per-webcam background effect enum `None` / `Remove` / `Blur`, carried by `WebcamSceneConfig` |
|
||||
| `BorderColorPalette.cs` | **TASK 60:** the colours the webcam's border-colour drop-down offers — `NamedColor(Hex, Name)` + `BorderColorPalette.All` (the single source of truth; the drop-down is bound to it) and `NameOf(hex)` (case-insensitive; **empty** for a colour outside the palette — "no name" is the honest answer, TASK 62). The creator asked for a colour NAME next to each hex: `#e94560 Crimson`. **TASK 63:** `EntryFor(hex)` (the palette entry for a hex, case-insensitively; a nameless entry for a colour outside the palette) and `OptionsFor(hex)` (the palette + that entry) — the two the drop-down binds to; `NamedColor.ToString()` is `"Crimson (#e94560)"` |
|
||||
| `WebcamSceneConfig.cs` | Per-scene webcam placement (subclass of `SceneElement`): geometry + `IsVisible` + border (`BorderColor`/`BorderOpacity`/`BorderWidth`/`BorderAnimation`) + `VideoImageSource`; `WebcamId` links to `Webcam`; **TASK 51:** `BackgroundEffect` + `BackgroundStrength` (0..1, default 0.5) + the radio-pill surface `RemoveBackground`/`BlurBackground` (one on turns the other off; both off = None) + `HasBackgroundEffect` (shows the strength slider); **TASK 57 (the "vv" feature):** the vertical (9:16) tier's OWN placement per scene — `VerticalX/Y/Width/Height/VerticalClipShape/VerticalRectWidth/VerticalRectHeight` (persisted, NULL = no vertical placement yet) + the transient `VerticalBand`/`VerticalLayoutLive`/`LandscapeX…LandscapeRectHeight` (the parked 16:9 layout) + `BeginVerticalLayout`/`EndVerticalLayout`/`CommitLiveLayoutToVertical` (the tier swap) and the `X`/`Y`/`Width`/`Height` overrides that clamp every write into the band; `VerticalShapeLocked` (the Chat scene's vertical layout is rect-only) **overrides** `ClipShape` + `ToggleClipShape()` so no writer can slip a circle in |
|
||||
| `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 |
|
||||
| `QualityOption.cs` | Resolution tier record `(Display, Fps, Bitrate, Width, Height)`; `Label` formats as `1080p60 (8Mbps)`, `RequiresPro` derives from `OutputProfile` (current code uses pixel/FPS; the FPS-only policy correction is queued in `commercial.md`) — drives the quality dropdown and output-rect math |
|
||||
| `StreamConfig.cs` | Legacy `StreamConfig` (no production call sites; defaults do not drive output), `StreamHealth` (bitrate/FPS/dropped/duration), `StreamStatus` enum; encoder output comes from the selected quality tier |
|
||||
| `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) — 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) |
|
||||
|
||||
+26
-12
@@ -209,11 +209,11 @@ After: 127 MB exe, `ffmpeg\` present, depot ≈ 257 MB total.
|
||||
`scripts/publish.sh` fails loudly when `ffmpeg\`, `LICENSE`, `PRIVACY.md` or
|
||||
`THIRD-PARTY-NOTICES.txt` are missing, and when test assemblies leak in.
|
||||
|
||||
### STEAM IS THE DISTRIBUTOR — AND WHAT IT DOES NOT ALLOW (2026-10-31)
|
||||
### STEAM DISTRIBUTION, COMMERCIAL USE, AND THE DEMO OWNERSHIP CHECK (2026-11-02)
|
||||
|
||||
**Why recorded:** the distribution answer changed three times in one day (winget → zip+GitHub →
|
||||
Steam), and each pivot invalidated a doc written hours earlier. These are the facts that won't
|
||||
change.
|
||||
**Why recorded:** the Steam Subscriber Agreement's private-use wording was misread as a blocker
|
||||
for monetized streaming. Check the exception and the established Steam software precedent before
|
||||
raising it again.
|
||||
|
||||
- **Steam installs skip SmartScreen entirely** — Valve's client delivers the files, so there is no
|
||||
browser download, no Mark-of-the-Web and no publisher warning. An unsigned exe is *fine* in this
|
||||
@@ -221,16 +221,25 @@ change.
|
||||
- **Steam updates the app** — no self-updater, no version banner, no update endpoint.
|
||||
- **⛔ In-app purchases must use the Steam Wallet.** Steamworks, *Microtransactions (In-Game
|
||||
Purchases)*: *"For any in-game purchases, you'll need to use the microtransaction API so Steam
|
||||
customers can only make purchases from the Steam Wallet."* So a subscription sold through Polar
|
||||
inside a Steam build is a policy violation. **Lifetime = plain DLC bought on the store page**
|
||||
(the app only calls `BIsDlcInstalled`); the monthly is *stated*, never *sold*, in-app.
|
||||
- **Steam can technically do recurring billing** (`ISteamMicroTxn` + `ProcessAgreement`) but its own
|
||||
doc opens with *"Recurring Subscriptions is not a fully supported feature"* and requires a server
|
||||
of yours to poll renewals — more infrastructure than Polar, for a 30% cut.
|
||||
customers can only make purchases from the Steam Wallet."* LlamaCasty has no in-app checkout.
|
||||
- **The private-use wording is not, by itself, a commercial-use ban for LlamaCasty.** The Steam
|
||||
Subscriber Agreement §2.A permits commercial use when the applicable product terms expressly
|
||||
allow it; §5 has general private/personal-use wording for third-party software. OBS Studio is
|
||||
distributed on Steam as video-production software under GPL, and OBS confirms the Steam build is
|
||||
the same as its website build. GPL permits commercial use. LlamaCasty's EULA should expressly
|
||||
allow commercial and monetized streaming; confirm the wording with Valve or counsel. Do not call
|
||||
this a Steam blocker based only on §5.
|
||||
Sources: https://store.steampowered.com/subscriber_agreement/ ·
|
||||
https://obsproject.com/kb/steam-faq · https://www.gnu.org/licenses/gpl-faq.html
|
||||
- **Demo ownership check:** the free Demo uses its own App ID; `ISteamApps::BIsSubscribedApp`
|
||||
checks ownership of the paid app. The paid App ID must be configured; zero/missing ID fails closed.
|
||||
Valve reference: https://partner.steamgames.com/doc/api/ISteamApps#BIsSubscribedApp
|
||||
- **Steam sells one paid app and distributes its associated free Demo.** Same code; no GitHub
|
||||
binary. The demo links to the paid store page and can send a one-time wishlist notification.
|
||||
- **Renaming a live exe is painful** (Steamworks → *Tip – Renaming a live exe*). Do the
|
||||
`LlamaCasty.exe` rename **before** the first depot upload, not after.
|
||||
- **The Steam store page must be public two weeks before release**, and the $100 Steam Direct fee
|
||||
must be paid before the page can be configured at all.
|
||||
- **The paid app's Steam store page must be public two weeks before release.** The Steamworks
|
||||
application and $100 fee are already submitted; Valve's decision is pending.
|
||||
|
||||
### CONVERTING A MEDIAPIPE .tflite (WITH CUSTOM OPS) TO ONNX — AND PROVING IT (RECIPE, TASK 51)
|
||||
|
||||
@@ -1698,6 +1707,11 @@ afterward** — and I got it wrong in the very commit written to apply that idea
|
||||
|
||||
## Asked twice about a deliberate, already-documented decision (2026-10-04)
|
||||
|
||||
**Superseded 2026-11-04:** the creator clarified that forcing Private is a development/test safeguard,
|
||||
not a permanent product lock. Release go-live must let the creator choose Private/Unlisted/Public,
|
||||
default to Private, and allow live changes. Do not repeat the former “always Private until v1” rule
|
||||
as current policy; keep development and test runs Private.
|
||||
|
||||
**What happened:** the creator asked whether the test streams still upload private, and added "I
|
||||
don't think you're able to delete them". I answered, researched `videos.delete`, and then — in the
|
||||
very next exchange — *offered to build a cleanup tool*, which re-litigated a decision that has been
|
||||
|
||||
@@ -123,7 +123,7 @@ public sealed class AlertClipDecoder : IAlertClipDecoder
|
||||
// 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)})");
|
||||
AppLog.Write(ex, $"Alert clip decode failed ({Path.GetFileName(_path)})", AppLogErrorClass.Class2);
|
||||
}
|
||||
|
||||
if (!ct.IsCancellationRequested)
|
||||
@@ -224,4 +224,4 @@ public sealed class AlertClipDecoder : IAlertClipDecoder
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +58,8 @@ public sealed class AlertOverlayLayer : IDisposable
|
||||
|
||||
private ChatMessage? _current;
|
||||
private Source? _currentBox;
|
||||
private sealed record TickerFeed(string Text, double Duration);
|
||||
private volatile TickerFeed? _tickerFeed;
|
||||
private IAlertClipDecoder? _clip;
|
||||
private VideoFrame? _latestClipFrame;
|
||||
private bool _clipEof;
|
||||
@@ -66,8 +68,6 @@ public sealed class AlertOverlayLayer : IDisposable
|
||||
private int _clipHeight;
|
||||
private bool _clipSawFrame;
|
||||
private bool _clipAudioHeard;
|
||||
private string? _tickerText;
|
||||
private AlertDisplayMethod _tickerMethod = AlertDisplayMethod.TickerScroll;
|
||||
private double _elapsed;
|
||||
private IEnumerable<Scene>? _scenes;
|
||||
|
||||
@@ -139,6 +139,7 @@ public sealed class AlertOverlayLayer : IDisposable
|
||||
BeginClip();
|
||||
}
|
||||
_contentVersion++;
|
||||
RefreshTickerFeed();
|
||||
}
|
||||
|
||||
/// <summary>Advance the playback clock (the ticker's beat — and the test seam;
|
||||
@@ -172,6 +173,7 @@ public sealed class AlertOverlayLayer : IDisposable
|
||||
_current = null;
|
||||
_elapsed = 0;
|
||||
AdvanceToNext();
|
||||
RefreshTickerFeed();
|
||||
}
|
||||
_contentVersion++;
|
||||
return;
|
||||
@@ -184,6 +186,7 @@ public sealed class AlertOverlayLayer : IDisposable
|
||||
_elapsed = 0;
|
||||
BeginClip();
|
||||
_contentVersion++;
|
||||
RefreshTickerFeed();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -193,6 +196,7 @@ public sealed class AlertOverlayLayer : IDisposable
|
||||
_current = null;
|
||||
_elapsed = 0;
|
||||
AdvanceToNext();
|
||||
RefreshTickerFeed();
|
||||
}
|
||||
_contentVersion++;
|
||||
}
|
||||
@@ -249,27 +253,55 @@ public sealed class AlertOverlayLayer : IDisposable
|
||||
/// <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;
|
||||
public VideoFrame? AlertTickerFrame => AlertBoxTickerFrame(_currentBox);
|
||||
|
||||
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)),
|
||||
};
|
||||
public VideoFrame? AlertBoxTickerFrame(Source? alertBox)
|
||||
{
|
||||
if (_current == null || alertBox is not { Type: SourceType.AlertBox }) return null;
|
||||
|
||||
var w = (int)Math.Max(1, Math.Round(alertBox.Width));
|
||||
var h = (int)Math.Max(1, Math.Min(
|
||||
Math.Round(alertBox.Height), AlertTickerRenderer.Height));
|
||||
var frame = AlertTickerRenderer.Render(
|
||||
alertBox.Ticker, ComposeTicker(_current), _elapsed, AlertSeconds(),
|
||||
TickerReadsPerAlert, w, h);
|
||||
return frame == null
|
||||
? null
|
||||
: new VideoFrame(frame.Width, frame.Height, frame.BgraPixels)
|
||||
{
|
||||
Placement = ((int)Math.Round(alertBox.X), (int)Math.Round(alertBox.Y)),
|
||||
};
|
||||
}
|
||||
|
||||
public VideoFrame? RenderSceneTicker(Source tickerSource)
|
||||
{
|
||||
if (tickerSource.Type != SourceType.Ticker || !tickerSource.Ticker.IsEnabled)
|
||||
return null;
|
||||
|
||||
var feed = _tickerFeed;
|
||||
if (feed == null) return null;
|
||||
return AlertTickerRenderer.Render(
|
||||
tickerSource.Ticker,
|
||||
feed.Text,
|
||||
_elapsed,
|
||||
feed.Duration,
|
||||
TickerReadsPerAlert,
|
||||
(int)Math.Max(1, Math.Round(tickerSource.Width)),
|
||||
(int)Math.Max(1, Math.Round(tickerSource.Height)));
|
||||
}
|
||||
|
||||
private void RefreshTickerFeed()
|
||||
{
|
||||
if (_current == null)
|
||||
{
|
||||
_tickerFeed = null;
|
||||
return;
|
||||
}
|
||||
|
||||
var messages = new[] { _current }.Concat(_queue).ToArray();
|
||||
_tickerFeed = new TickerFeed(
|
||||
string.Join(" • ", messages.Select(ComposeTicker)),
|
||||
messages.Sum(DurationOf));
|
||||
}
|
||||
|
||||
/// <summary>How many times the marquee must cross the frame per alert, so the
|
||||
@@ -338,7 +370,7 @@ public sealed class AlertOverlayLayer : IDisposable
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLog.Write(ex, "Alert sound byte failed");
|
||||
AppLog.Write(ex, "Alert sound byte failed", AppLogErrorClass.Class2);
|
||||
StopSoundByte();
|
||||
}
|
||||
}
|
||||
@@ -354,17 +386,11 @@ public sealed class AlertOverlayLayer : IDisposable
|
||||
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) return;
|
||||
StartSoundByte(box);
|
||||
@@ -387,7 +413,7 @@ public sealed class AlertOverlayLayer : IDisposable
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLog.Write(ex, "Alert clip setup failed");
|
||||
AppLog.Write(ex, "Alert clip setup failed", AppLogErrorClass.Class2);
|
||||
StopClip(); // decode setup failed (bad path) — fall back to animations
|
||||
}
|
||||
}
|
||||
@@ -552,6 +578,9 @@ public sealed class AlertOverlayLayer : IDisposable
|
||||
};
|
||||
UpdatePreview(alertBox);
|
||||
}
|
||||
foreach (var ticker in scenes.SelectMany(s => s.Elements).OfType<Source>()
|
||||
.Where(s => s.Type == SourceType.Ticker))
|
||||
UpdateTickerPreview(ticker);
|
||||
}
|
||||
|
||||
private void RefreshAlertPreviews()
|
||||
@@ -566,6 +595,27 @@ public sealed class AlertOverlayLayer : IDisposable
|
||||
foreach (var alertBox in _scenes.SelectMany(s => s.Elements).OfType<Source>()
|
||||
.Where(s => s.Type == SourceType.AlertBox))
|
||||
UpdatePreview(alertBox);
|
||||
foreach (var ticker in _scenes.SelectMany(s => s.Elements).OfType<Source>()
|
||||
.Where(s => s.Type == SourceType.Ticker))
|
||||
UpdateTickerPreview(ticker);
|
||||
}
|
||||
|
||||
private void UpdateTickerPreview(Source ticker)
|
||||
{
|
||||
var frame = RenderSceneTicker(ticker);
|
||||
if (frame == null)
|
||||
{
|
||||
ticker.VideoImageSource = null;
|
||||
return;
|
||||
}
|
||||
|
||||
var bitmap = ticker.VideoImageSource;
|
||||
if (bitmap == null || bitmap.PixelWidth != frame.Width || bitmap.PixelHeight != frame.Height)
|
||||
{
|
||||
bitmap = new WriteableBitmap(frame.Width, frame.Height, 96, 96, PixelFormats.Bgra32, null);
|
||||
ticker.VideoImageSource = bitmap;
|
||||
}
|
||||
bitmap.WritePixels(new Int32Rect(0, 0, frame.Width, frame.Height), frame.BgraPixels, frame.Stride, 0);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
@@ -574,6 +624,7 @@ public sealed class AlertOverlayLayer : IDisposable
|
||||
_ticker?.Stop();
|
||||
_queue.Clear();
|
||||
_current = null;
|
||||
_tickerFeed = null;
|
||||
StopClip();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ public sealed class AlertAudioOutput : IDisposable
|
||||
{
|
||||
// No output device (a headless stream box, a locked-down session): the alert still
|
||||
// renders, it is simply silent. Never let audio take the alert down.
|
||||
AppLog.Write(ex, "Alert audio output failed");
|
||||
AppLog.Write(ex, "Alert audio output failed", AppLogErrorClass.Class2);
|
||||
Stop();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -422,7 +422,7 @@ public sealed class AudioMixer : IDisposable
|
||||
if (now - _lastLoopErrorLogged >= TimeSpan.FromSeconds(5))
|
||||
{
|
||||
_lastLoopErrorLogged = now;
|
||||
AppLog.Write(ex, "Audio live loop error");
|
||||
AppLog.Write(ex, "Audio live loop error", AppLogErrorClass.Class2);
|
||||
_log?.Invoke($"Audio live loop error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -317,7 +317,8 @@ public sealed class CameraManager : IDisposable
|
||||
if (suspects.Count > 0)
|
||||
enriched += $" Other camera apps running: {string.Join(", ", suspects)}.";
|
||||
|
||||
AppLog.Write($"CameraManager: camera '{session.DeviceId}' failed: {enriched}");
|
||||
AppLog.Write($"CameraManager: camera '{session.DeviceId}' failed: {enriched}",
|
||||
AppLogMessageType.Error, AppLogErrorClass.Class2);
|
||||
CameraFailed?.Invoke(session.DeviceId, enriched);
|
||||
_ = SafeStopAsync(session.Source);
|
||||
}
|
||||
@@ -335,7 +336,8 @@ public sealed class CameraManager : IDisposable
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLog.Write($"CameraManager: stopping camera failed: {ex.Message}");
|
||||
AppLog.Write($"CameraManager: stopping camera failed: {ex.Message}",
|
||||
AppLogMessageType.Error, AppLogErrorClass.Class2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -460,7 +462,8 @@ public sealed class CameraManager : IDisposable
|
||||
catch (Exception ex)
|
||||
{
|
||||
lock (_gate) session.EffectWorkerRunning = false;
|
||||
AppLog.Write($"CameraManager: background effects worker failed: {ex.GetType().Name}: {ex.Message}");
|
||||
AppLog.Write($"CameraManager: background effects worker failed: {ex.GetType().Name}: {ex.Message}",
|
||||
AppLogMessageType.Error, AppLogErrorClass.Class2);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using System.Net.Http;
|
||||
using System.Text.Json;
|
||||
using ytLive.Models;
|
||||
using AppLogErrorClass = ytLive.Helpers.AppLogErrorClass;
|
||||
using AppLogMessageType = ytLive.Helpers.AppLogMessageType;
|
||||
using AppLog = ytLive.Helpers.AppLog;
|
||||
|
||||
namespace ytLive.Services;
|
||||
@@ -54,7 +56,8 @@ public class ChannelStatsService
|
||||
// (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}");
|
||||
AppLog.Write($"YPP stats: channels.list failed ({(int)response.StatusCode}): {errorBody}",
|
||||
AppLogMessageType.Error, AppLogErrorClass.Class2);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -107,7 +110,8 @@ public class ChannelStatsService
|
||||
$"{ApiBase}/playlistItems?part=snippet&playlistId={Uri.EscapeDataString(uploadsPlaylistId)}&maxResults=50");
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
AppLog.Write($"YPP stats: playlistItems.list failed ({(int)response.StatusCode})");
|
||||
AppLog.Write($"YPP stats: playlistItems.list failed ({(int)response.StatusCode})",
|
||||
AppLogMessageType.Error, AppLogErrorClass.Class2);
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -129,4 +133,4 @@ public class ChannelStatsService
|
||||
}
|
||||
return count;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,15 @@ public static class AlertTickerRenderer
|
||||
/// (0.5s on, 0.5s off — a 1Hz pulse for the whole alert).</summary>
|
||||
public const double FlashHalfSeconds = 0.5;
|
||||
|
||||
public static VideoFrame? Render(Ticker ticker, string? text, double elapsedSeconds,
|
||||
double alertSeconds, int readsPerAlert, int width, int height)
|
||||
{
|
||||
if (ticker == null) throw new ArgumentNullException(nameof(ticker));
|
||||
return ticker.IsEnabled
|
||||
? Render(text, elapsedSeconds, ticker.DisplayMethod, alertSeconds, readsPerAlert, width, height)
|
||||
: null;
|
||||
}
|
||||
|
||||
private static readonly object Gate = new();
|
||||
private static readonly Dictionary<string, VideoFrame> StripCache = new();
|
||||
|
||||
@@ -179,4 +188,4 @@ public static class AlertTickerRenderer
|
||||
}
|
||||
return new VideoFrame(pillW, Height, pixels);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
namespace ytLive.Services.Compositor;
|
||||
|
||||
/// <summary>
|
||||
/// Where the compositor draws from (the active quality tier's output rect over the
|
||||
/// 1920×1080 master) and at what size. 16:9 tiers = the full master 1:1; the vertical
|
||||
/// 9:16 tier = the centered 607×1080 crop scaled up to 1080×1920. The source rect is
|
||||
/// integer-aligned — MainViewModel's <c>OutputRectX</c> can be 656.5, so the caller
|
||||
/// rounds before building these options.
|
||||
/// Where the compositor draws from (the active quality tier's crop over the
|
||||
/// 1920×1080 master) and the size of the BGRA frame it sends to the encoder. The
|
||||
/// compositor frame is the crop itself; FFmpeg scales it to the selected output
|
||||
/// dimensions. The source rect is integer-aligned — MainViewModel's
|
||||
/// <c>OutputRectX</c> can be 656.5, so the caller rounds before building these options.
|
||||
/// </summary>
|
||||
public sealed class CompositorOptions
|
||||
{
|
||||
|
||||
@@ -6,10 +6,11 @@ using ytLive.Models;
|
||||
namespace ytLive.Services.Compositor;
|
||||
|
||||
/// <summary>
|
||||
/// The output compositor: renders a scene into the encoder's master frame (tightly-packed
|
||||
/// The output compositor: renders a scene into the encoder's raw input frame (tightly-packed
|
||||
/// BGRA8 <see cref="VideoFrame"/>) exactly as the XAML preview renders it, minus the
|
||||
/// editing chrome (SelectionOverlay, DimRects, badge, placeholder). The preview stays the
|
||||
/// editing view; this is the output view — see TASKS.md "Ship step 1 — Scene compositor".
|
||||
/// editing chrome (SelectionOverlay, DimRects, badge, placeholder). FFmpeg scales that frame
|
||||
/// to the selected output dimensions. The preview stays the editing view; this is the output
|
||||
/// view — see TASKS.md "Ship step 1 — Scene compositor".
|
||||
///
|
||||
/// Frame sources are supplied by a <see cref="Func{SceneElement, VideoFrame}"/> resolver
|
||||
/// (the caller maps webcam → DeviceId, images → AssetId, background → CaptureKey), keeping
|
||||
@@ -163,7 +164,6 @@ public sealed class SceneCompositor
|
||||
{
|
||||
case Source { IsBackground: true }:
|
||||
case Source { Type: SourceType.Background }:
|
||||
case Source { Type: SourceType.TextOverlay }:
|
||||
case ZoomSceneElement { IsZoomActive: true }:
|
||||
continue;
|
||||
}
|
||||
@@ -186,7 +186,6 @@ public sealed class SceneCompositor
|
||||
{
|
||||
case Source { IsBackground: true }:
|
||||
case Source { Type: SourceType.Background }:
|
||||
case Source { Type: SourceType.TextOverlay }:
|
||||
// ⛔ The brand logo is drawn AFTER the blur group (creator ruling 2026-11-01):
|
||||
// the blur is universal and must never hide the brand.
|
||||
case Source { IsBrandLogo: true }:
|
||||
@@ -310,7 +309,6 @@ public sealed class SceneCompositor
|
||||
{
|
||||
case Source { IsBackground: true }:
|
||||
case Source { Type: SourceType.Background }:
|
||||
case Source { Type: SourceType.TextOverlay }:
|
||||
case ZoomSceneElement:
|
||||
continue;
|
||||
}
|
||||
@@ -369,7 +367,6 @@ public sealed class SceneCompositor
|
||||
{
|
||||
case Source { IsBackground: true }:
|
||||
case Source { Type: SourceType.Background }:
|
||||
case Source { Type: SourceType.TextOverlay }:
|
||||
// ⛔ The brand logo is drawn AFTER the blur group (creator ruling 2026-11-01):
|
||||
// the blur is universal and must never hide the brand.
|
||||
case Source { IsBrandLogo: true }:
|
||||
|
||||
@@ -14,8 +14,16 @@ public sealed class EncoderOptions
|
||||
public const string DefaultAudioPipeName = "ytllive_audio";
|
||||
|
||||
public string RtmpUrl { get; init; } = string.Empty;
|
||||
/// <summary>Final encoded output width and height.</summary>
|
||||
public int Width { get; init; } = 1920;
|
||||
public int Height { get; init; } = 1080;
|
||||
|
||||
/// <summary>Raw BGRA frame size from the compositor. Zero uses the final output size.</summary>
|
||||
public int InputWidth { get; init; }
|
||||
public int InputHeight { get; init; }
|
||||
|
||||
public int RawWidth => InputWidth > 0 ? InputWidth : Width;
|
||||
public int RawHeight => InputHeight > 0 ? InputHeight : Height;
|
||||
public int Fps { get; init; } = 60;
|
||||
public int BitrateKbps { get; init; } = 8000;
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ public static class FfmpegArgs
|
||||
"-hide_banner", "-loglevel", "info",
|
||||
"-stats", "-stats_period", "0.5",
|
||||
"-f", "rawvideo", "-pix_fmt", "bgra",
|
||||
"-video_size", $"{options.Width}x{options.Height}",
|
||||
"-video_size", $"{options.RawWidth}x{options.RawHeight}",
|
||||
"-framerate", options.Fps.ToString(),
|
||||
"-i", "pipe:0",
|
||||
"-f", "f32le", "-ar", options.AudioSampleRate.ToString(),
|
||||
@@ -53,6 +53,10 @@ public static class FfmpegArgs
|
||||
{
|
||||
var gop = options.GopSize;
|
||||
args.AddRange(["-map", "0:v", "-map", "1:a"]);
|
||||
if (options.RawWidth != options.Width || options.RawHeight != options.Height)
|
||||
{
|
||||
args.AddRange(["-vf", $"scale={options.Width}:{options.Height}:flags=bilinear,setsar=1"]);
|
||||
}
|
||||
args.AddRange(["-c:v", videoEncoder]);
|
||||
args.AddRange(["-b:v", $"{options.BitrateKbps}k", "-maxrate", $"{options.BitrateKbps}k",
|
||||
"-bufsize", $"{options.BitrateKbps * 2}k"]);
|
||||
|
||||
@@ -103,7 +103,7 @@ public sealed class FfmpegEncoder : IFfmpegEncoder
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLog.Write(ex, "FFmpeg encoder: failed to start subprocess");
|
||||
AppLog.Write(ex, "FFmpeg encoder: failed to start subprocess", AppLogErrorClass.Class2);
|
||||
lock (_gate) _options = null;
|
||||
throw;
|
||||
}
|
||||
@@ -175,7 +175,7 @@ public sealed class FfmpegEncoder : IFfmpegEncoder
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLog.Write(ex, "FFmpeg encoder: drain loop faulted during stop");
|
||||
AppLog.Write(ex, "FFmpeg encoder: drain loop faulted during stop", AppLogErrorClass.Class2);
|
||||
}
|
||||
|
||||
try
|
||||
@@ -186,12 +186,13 @@ public sealed class FfmpegEncoder : IFfmpegEncoder
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
AppLog.Write("FFmpeg encoder: did not exit after stdin EOF — killing");
|
||||
AppLog.Write("FFmpeg encoder: did not exit after stdin EOF — killing",
|
||||
AppLogMessageType.Error, AppLogErrorClass.Class2);
|
||||
process!.Kill();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLog.Write(ex, "FFmpeg encoder: waiting for exit failed");
|
||||
AppLog.Write(ex, "FFmpeg encoder: waiting for exit failed", AppLogErrorClass.Class2);
|
||||
process!.Kill();
|
||||
}
|
||||
|
||||
@@ -201,7 +202,7 @@ public sealed class FfmpegEncoder : IFfmpegEncoder
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLog.Write(ex, "FFmpeg encoder: stderr loop faulted during stop");
|
||||
AppLog.Write(ex, "FFmpeg encoder: stderr loop faulted during stop", AppLogErrorClass.Class2);
|
||||
}
|
||||
|
||||
process.Dispose();
|
||||
@@ -263,7 +264,7 @@ public sealed class FfmpegEncoder : IFfmpegEncoder
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLog.Write(ex, "FFmpeg encoder: drain loop write faulted");
|
||||
AppLog.Write(ex, "FFmpeg encoder: drain loop write faulted", AppLogErrorClass.Class2);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -274,7 +275,7 @@ public sealed class FfmpegEncoder : IFfmpegEncoder
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLog.Write(ex, "FFmpeg encoder: drain loop faulted");
|
||||
AppLog.Write(ex, "FFmpeg encoder: drain loop faulted", AppLogErrorClass.Class2);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -303,7 +304,8 @@ public sealed class FfmpegEncoder : IFfmpegEncoder
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLog.Write(ex, "FFmpeg encoder: encoder probe failed — falling back to software");
|
||||
AppLog.Write($"FFmpeg encoder: encoder probe failed — falling back to software: {ex}",
|
||||
AppLogMessageType.Warning);
|
||||
return FfmpegEncoderPicker.Preference[^1];
|
||||
}
|
||||
}
|
||||
@@ -347,14 +349,15 @@ public sealed class FfmpegEncoder : IFfmpegEncoder
|
||||
// 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})");
|
||||
AppLog.Write($"FFmpeg encoder: subprocess exited unexpectedly (code {exitCode})",
|
||||
AppLogMessageType.Error, AppLogErrorClass.Class2);
|
||||
_health.LastError = $"FFmpeg exited with code {exitCode}";
|
||||
ProcessFailed?.Invoke(this, $"FFmpeg exited with code {exitCode}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLog.Write(ex, "FFmpeg encoder: stderr loop faulted");
|
||||
AppLog.Write(ex, "FFmpeg encoder: stderr loop faulted", AppLogErrorClass.Class2);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ namespace ytLive.Services.Encoder;
|
||||
/// The live frame producer (TASK 4 ship step 5): the bridge between the capture
|
||||
/// managers + compositor and the encoder. While live it snapshots the active
|
||||
/// scene each tick, resolves every element to its latest frame, composites it
|
||||
/// into the tier's output frame, and paces frames into the encoder at the tier's
|
||||
/// into the tier's cropped input frame, and paces frames into the encoder at the tier's
|
||||
/// FPS. All collaborators are constructor-injected seams (scene, resolver,
|
||||
/// options, encoder factory, pacing delay) so the pump stays free of WPF and of
|
||||
/// the capture managers and is fully hermetic in tests.
|
||||
@@ -29,7 +29,7 @@ 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<Scene, VideoFrame?>? _alertTicker;
|
||||
private readonly Func<VideoFrame?>? _brandFlash;
|
||||
private readonly TransitionService? _transition;
|
||||
private VideoFrame? _lastOutput;
|
||||
@@ -138,7 +138,7 @@ 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<Scene, VideoFrame?>? alertTicker = null,
|
||||
Func<VideoFrame?>? brandFlash = null,
|
||||
TransitionService? transition = null,
|
||||
SceneGraph? sceneGraph = null)
|
||||
@@ -486,7 +486,7 @@ public sealed class FramePump : IDisposable
|
||||
}
|
||||
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
VideoFrame? tickerFrame = _alertTicker?.Invoke();
|
||||
VideoFrame? tickerFrame = _alertTicker?.Invoke(scene);
|
||||
tickerTicks += sw.ElapsedTicks;
|
||||
sw.Restart();
|
||||
VideoFrame? flashFrame = _brandFlash?.Invoke();
|
||||
@@ -722,11 +722,9 @@ public sealed class FramePump : IDisposable
|
||||
byte[]? scratch, Func<SceneElement, VideoFrame?> resolver,
|
||||
CompositeTimings? timings = null)
|
||||
{
|
||||
// 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.
|
||||
// The cache stores the compositor frame at crop size; FFmpeg scales that frame
|
||||
// to the final output dimensions. With product options, scratch and rendered
|
||||
// frames are therefore the same size, including for vertical output.
|
||||
var useCache = scratch != null && options.SourceRectWidth == options.OutputWidth
|
||||
&& options.SourceRectHeight == options.OutputHeight;
|
||||
var signature = useCache
|
||||
@@ -858,7 +856,8 @@ public sealed class FramePump : IDisposable
|
||||
}
|
||||
else if (element is Source { Type: SourceType.TextOverlay })
|
||||
{
|
||||
h = Mix(h, 2UL); // skipped kind — never resolved by the compositor
|
||||
h = Mix(h, 2UL); // static text is resolved from its raster-on-change frame cache
|
||||
if (element.IsVisible) frame = resolver(element);
|
||||
}
|
||||
else if (element is Source { Type: SourceType.Background })
|
||||
{
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
namespace ytLive.Services.Entitlement;
|
||||
|
||||
/// <summary>
|
||||
/// Test double for the entitlement rail. Production never uses it — a fake that could be reached
|
||||
/// in a shipped build is a licence bypass, so it lives here only because tests need to drive both
|
||||
/// answers without a Steam client or a network.
|
||||
/// Development/test ownership result. Release builds exclude this type.
|
||||
/// </summary>
|
||||
public sealed class FakeEntitlement : IEntitlement
|
||||
{
|
||||
public FakeEntitlement(bool isPro = false) => IsPro = isPro;
|
||||
public FakeEntitlement(bool isAppOwned = false, string? errorMessage = null)
|
||||
{
|
||||
IsAppOwned = isAppOwned;
|
||||
ErrorMessage = errorMessage;
|
||||
}
|
||||
|
||||
public bool IsPro { get; set; }
|
||||
public bool IsAppOwned { get; set; }
|
||||
public string? ErrorMessage { get; set; }
|
||||
}
|
||||
|
||||
@@ -1,19 +1,14 @@
|
||||
namespace ytLive.Services.Entitlement;
|
||||
|
||||
/// <summary>
|
||||
/// Where "is this copy licensed for Pro output?" comes from.
|
||||
/// Whether the current Steam account owns the paid app.
|
||||
///
|
||||
/// <para><b>One question, two sources.</b> The lifetime SKU is a Steam DLC; the monthly is a Polar
|
||||
/// lease. Both answer this interface, and everything downstream — the output profile, the greyed
|
||||
/// tier list, the notices — reads only this. Nothing outside <c>Services/Entitlement</c> may know
|
||||
/// which rail proved it.</para>
|
||||
///
|
||||
/// <para><b>Fail closed.</b> An implementation that cannot reach its source, cannot load its
|
||||
/// native library, or has no product id configured returns <c>false</c>. Pro is never a default,
|
||||
/// never a cached "probably", and never the result of an exception being swallowed as true.</para>
|
||||
/// <para>Steam is the source of truth. Failure to reach Steam, load its native library, or
|
||||
/// resolve the paid app ID returns <c>false</c>.</para>
|
||||
/// </summary>
|
||||
public interface IEntitlement
|
||||
{
|
||||
/// <summary>True when this session may emit above the free output budget.</summary>
|
||||
bool IsPro { get; }
|
||||
bool IsAppOwned { get; }
|
||||
|
||||
string? ErrorMessage { get; }
|
||||
}
|
||||
|
||||
@@ -3,41 +3,58 @@ using Steamworks;
|
||||
namespace ytLive.Services.Entitlement;
|
||||
|
||||
/// <summary>
|
||||
/// The lifetime SKU: a Steam **DLC** on the free app, read locally from the Steam client.
|
||||
///
|
||||
/// <para><b>This is the whole purchase story.</b> There is no key, no server and no lease — the
|
||||
/// customer buys the DLC on the store page and Steam already knows. The app asks once and caches
|
||||
/// the answer; a refunded DLC stops reporting ownership the next time the app starts.</para>
|
||||
///
|
||||
/// <para><b>⛔ Never sell the monthly plan through this.</b> Steamworks requires in-app purchases
|
||||
/// to use the Steam Wallet, so anything sold from inside the app would be a policy violation. The
|
||||
/// monthly lives on the website and is billed by Polar (<see cref="PolarEntitlement"/> when it
|
||||
/// lands); this class only ever reads what Valve already sold.</para>
|
||||
///
|
||||
/// <para><b>Steam absent is normal, not exceptional.</b> A copied folder, a shortcut run without
|
||||
/// the client, or a CI machine has no Steam — <see cref="SteamAPI.Init"/> returns false and the
|
||||
/// app runs as the free tier. It must never refuse to start over an entitlement lookup.</para>
|
||||
/// Reads ownership of the paid app from Steam. In the free Demo, this checks the associated paid
|
||||
/// app ID; no key, server, or LlamaCasty account is involved. A missing ID, Steam client, or native
|
||||
/// library fails closed so the app can continue with the free output profile.
|
||||
/// </summary>
|
||||
public sealed class SteamEntitlement : IEntitlement
|
||||
{
|
||||
/// <summary>The DLC's own app id, not the base app's. Zero = not configured yet, which reads
|
||||
/// as "no entitlement" rather than "try anyway".</summary>
|
||||
private readonly uint _dlcAppId;
|
||||
private readonly uint _paidAppId;
|
||||
private readonly Func<bool> _initializeSteam;
|
||||
private readonly Func<uint, bool> _isAppSubscribed;
|
||||
private readonly Action _shutdownSteam;
|
||||
|
||||
private bool _initAttempted;
|
||||
private bool _available;
|
||||
private bool? _cached;
|
||||
private string? _errorMessage;
|
||||
|
||||
public SteamEntitlement(uint dlcAppId) => _dlcAppId = dlcAppId;
|
||||
public SteamEntitlement(uint paidAppId)
|
||||
: this(
|
||||
paidAppId,
|
||||
SteamAPI.Init,
|
||||
appId => SteamApps.BIsSubscribedApp(new AppId_t(appId)),
|
||||
SteamAPI.Shutdown)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>Configured with no product id — the state before the Steamworks app exists.</summary>
|
||||
internal SteamEntitlement(
|
||||
uint paidAppId,
|
||||
Func<bool> initializeSteam,
|
||||
Func<uint, bool> isAppSubscribed,
|
||||
Action shutdownSteam)
|
||||
{
|
||||
_paidAppId = paidAppId;
|
||||
_initializeSteam = initializeSteam;
|
||||
_isAppSubscribed = isAppSubscribed;
|
||||
_shutdownSteam = shutdownSteam;
|
||||
}
|
||||
|
||||
/// <summary>Configured with no paid app ID; ownership fails closed without touching Steam.</summary>
|
||||
public static SteamEntitlement NotConfigured() => new(0);
|
||||
|
||||
public bool IsPro
|
||||
public string? ErrorMessage => _errorMessage;
|
||||
|
||||
public bool IsAppOwned
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_dlcAppId == 0) return false;
|
||||
if (_paidAppId == 0)
|
||||
{
|
||||
_cached = false;
|
||||
_errorMessage = "The paid Steam app ID is not configured for this build. Contact the creator or support.";
|
||||
return false;
|
||||
}
|
||||
if (_cached is { } known) return known;
|
||||
|
||||
if (!_initAttempted)
|
||||
@@ -45,38 +62,44 @@ public sealed class SteamEntitlement : IEntitlement
|
||||
_initAttempted = true;
|
||||
try
|
||||
{
|
||||
// No steam_appid.txt and no client → false. That is the documented free path.
|
||||
_available = SteamAPI.Init();
|
||||
_available = _initializeSteam();
|
||||
}
|
||||
catch (Exception)
|
||||
catch (Exception ex)
|
||||
{
|
||||
// A missing steam_api64.dll throws on first touch. Free tier, never a crash.
|
||||
_available = false;
|
||||
_errorMessage = $"Steam could not start ({ex.Message}). Open Steam, sign in, then restart LlamaCasty.";
|
||||
}
|
||||
}
|
||||
|
||||
if (!_available) { _cached = false; return false; }
|
||||
if (!_available)
|
||||
{
|
||||
_cached = false;
|
||||
_errorMessage ??= "Steam could not verify ownership. Open Steam, sign in, then restart LlamaCasty.";
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var owned = SteamApps.BIsDlcInstalled(new AppId_t(_dlcAppId));
|
||||
var owned = _isAppSubscribed(_paidAppId);
|
||||
_cached = owned;
|
||||
_errorMessage = null;
|
||||
return owned;
|
||||
}
|
||||
catch (Exception)
|
||||
catch (Exception ex)
|
||||
{
|
||||
_cached = false;
|
||||
_errorMessage = $"Steam could not check ownership ({ex.Message}). Open Steam, sign in, then restart LlamaCasty.";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Matches Steam's own lifecycle callbacks; called from the frame pump's pump thread
|
||||
/// would be wrong, so nothing here schedules it automatically.</summary>
|
||||
public void Shutdown()
|
||||
{
|
||||
if (_available) SteamAPI.Shutdown();
|
||||
if (_available) _shutdownSteam();
|
||||
_available = false;
|
||||
_cached = null;
|
||||
_initAttempted = false;
|
||||
_errorMessage = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,7 +111,9 @@ public partial class LayoutStore : IDisposable
|
||||
ChatShowTimestamps, ChatShowBadges, ChatMemberColor, ChatSuperChatColor,
|
||||
WebUri, MediaPath, MediaIsLooping, MediaVolume, MediaPlaybackState,
|
||||
AlertVideoAssetId, AlertVideoPath, AlertUseDefaultVideo, AlertShowTicker, AlertVideoVolume, SortOrder,
|
||||
AlertDisplayMethod, IsBrandLogo, BlurAmount, AlertAudioPath, AlertAudioLoop
|
||||
AlertDisplayMethod, IsBrandLogo, BlurAmount, AlertAudioPath, AlertAudioLoop,
|
||||
TextContent, TextFontFamily, TextFontSize, TextColor, TextBold, TextItalic,
|
||||
TextUnderline, TextAlignment, TextVerticalAlignment
|
||||
FROM Source ORDER BY SortOrder
|
||||
""";
|
||||
using var reader = cmd.ExecuteReader();
|
||||
@@ -167,6 +169,20 @@ public partial class LayoutStore : IDisposable
|
||||
BlurAmount = reader.IsDBNull(reader.GetOrdinal("BlurAmount")) ? 0.5 : reader.GetDouble(reader.GetOrdinal("BlurAmount")),
|
||||
AlertAudioPath = reader.IsDBNull(reader.GetOrdinal("AlertAudioPath")) ? null : reader.GetString(reader.GetOrdinal("AlertAudioPath")),
|
||||
AlertAudioLoop = reader.GetInt32(reader.GetOrdinal("AlertAudioLoop")) != 0,
|
||||
TextContent = reader.IsDBNull(41) ? "Text" : reader.GetString(41),
|
||||
TextFontFamily = reader.IsDBNull(42) ? "Segoe UI" : reader.GetString(42),
|
||||
TextFontSize = reader.IsDBNull(43) ? 48 : reader.GetDouble(43),
|
||||
TextColor = reader.IsDBNull(44) ? "#FFFFFF" : reader.GetString(44),
|
||||
TextBold = !reader.IsDBNull(45) && reader.GetInt32(45) != 0,
|
||||
TextItalic = !reader.IsDBNull(46) && reader.GetInt32(46) != 0,
|
||||
TextUnderline = !reader.IsDBNull(47) && reader.GetInt32(47) != 0,
|
||||
TextAlignment = reader.IsDBNull(48) || !Enum.IsDefined((TextLayerAlignment)reader.GetInt32(48))
|
||||
? TextLayerAlignment.Left
|
||||
: (TextLayerAlignment)reader.GetInt32(48),
|
||||
TextVerticalAlignment = reader.IsDBNull(49)
|
||||
|| !Enum.IsDefined((TextLayerVerticalAlignment)reader.GetInt32(49))
|
||||
? TextLayerVerticalAlignment.Top
|
||||
: (TextLayerVerticalAlignment)reader.GetInt32(49),
|
||||
};
|
||||
if (!sourcesByScene.TryGetValue(sceneId, out var list))
|
||||
sourcesByScene[sceneId] = list = new List<(Source, int)>();
|
||||
|
||||
@@ -290,6 +290,62 @@ public partial class LayoutStore : IDisposable
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
// TASK 3.17: independent static text-layer content and formatting.
|
||||
if (!columns.Contains("TextContent"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE Source ADD COLUMN TextContent TEXT NOT NULL DEFAULT 'Text';";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
if (!columns.Contains("TextFontFamily"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE Source ADD COLUMN TextFontFamily TEXT NOT NULL DEFAULT 'Segoe UI';";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
if (!columns.Contains("TextFontSize"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE Source ADD COLUMN TextFontSize REAL NOT NULL DEFAULT 48;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
if (!columns.Contains("TextColor"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE Source ADD COLUMN TextColor TEXT NOT NULL DEFAULT '#FFFFFF';";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
if (!columns.Contains("TextBold"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE Source ADD COLUMN TextBold INTEGER NOT NULL DEFAULT 0;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
if (!columns.Contains("TextItalic"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE Source ADD COLUMN TextItalic INTEGER NOT NULL DEFAULT 0;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
if (!columns.Contains("TextUnderline"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE Source ADD COLUMN TextUnderline INTEGER NOT NULL DEFAULT 0;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
if (!columns.Contains("TextAlignment"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE Source ADD COLUMN TextAlignment INTEGER NOT NULL DEFAULT 0;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
if (!columns.Contains("TextVerticalAlignment"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE Source ADD COLUMN TextVerticalAlignment INTEGER NOT NULL DEFAULT 0;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
if (!columns.Contains("WebUri"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
|
||||
@@ -92,7 +92,9 @@ public partial class LayoutStore : IDisposable
|
||||
ChatShowTimestamps, ChatShowBadges, ChatMemberColor, ChatSuperChatColor,
|
||||
WebUri, MediaPath, MediaIsLooping, MediaVolume, MediaPlaybackState,
|
||||
AlertVideoAssetId, AlertVideoPath, AlertUseDefaultVideo, AlertShowTicker, AlertVideoVolume,
|
||||
AlertDisplayMethod, IsBrandLogo, BlurAmount, AlertAudioPath, AlertAudioLoop)
|
||||
AlertDisplayMethod, IsBrandLogo, BlurAmount, AlertAudioPath, AlertAudioLoop,
|
||||
TextContent, TextFontFamily, TextFontSize, TextColor, TextBold, TextItalic,
|
||||
TextUnderline, TextAlignment, TextVerticalAlignment)
|
||||
VALUES ($id, $sceneId, $assetId, $type, $name, $isEnabled,
|
||||
$x, $y, $w, $h, $opacity, $monitor,
|
||||
$clip, $mirrored, $isBackground, $captureKey, $showDesktop, $sort,
|
||||
@@ -100,7 +102,9 @@ public partial class LayoutStore : IDisposable
|
||||
$chatTimestamps, $chatBadges, $chatMemberColor, $chatSuperChatColor,
|
||||
$webUri, $mediaPath, $mediaIsLooping, $mediaVolume, $mediaPlaybackState,
|
||||
$alertVideoAssetId, $alertVideoPath, $alertUseDefaultVideo, $alertShowTicker, $alertVideoVolume,
|
||||
$alertDisplayMethod, $isBrandLogo, $blurAmount, $alertAudioPath, $alertAudioLoop)
|
||||
$alertDisplayMethod, $isBrandLogo, $blurAmount, $alertAudioPath, $alertAudioLoop,
|
||||
$textContent, $textFontFamily, $textFontSize, $textColor, $textBold, $textItalic,
|
||||
$textUnderline, $textAlignment, $textVerticalAlignment)
|
||||
""";
|
||||
cmd.Transaction = tx;
|
||||
var idP = cmd.Parameters.Add("$id", SqliteType.Text);
|
||||
@@ -144,6 +148,15 @@ public partial class LayoutStore : IDisposable
|
||||
cmd.Parameters.Add("$blurAmount", SqliteType.Real);
|
||||
var alertAudioPathP = cmd.Parameters.Add("$alertAudioPath", SqliteType.Text);
|
||||
var alertAudioLoopP = cmd.Parameters.Add("$alertAudioLoop", SqliteType.Integer);
|
||||
var textContentP = cmd.Parameters.Add("$textContent", SqliteType.Text);
|
||||
var textFontFamilyP = cmd.Parameters.Add("$textFontFamily", SqliteType.Text);
|
||||
var textFontSizeP = cmd.Parameters.Add("$textFontSize", SqliteType.Real);
|
||||
var textColorP = cmd.Parameters.Add("$textColor", SqliteType.Text);
|
||||
var textBoldP = cmd.Parameters.Add("$textBold", SqliteType.Integer);
|
||||
var textItalicP = cmd.Parameters.Add("$textItalic", SqliteType.Integer);
|
||||
var textUnderlineP = cmd.Parameters.Add("$textUnderline", SqliteType.Integer);
|
||||
var textAlignmentP = cmd.Parameters.Add("$textAlignment", SqliteType.Integer);
|
||||
var textVerticalAlignmentP = cmd.Parameters.Add("$textVerticalAlignment", SqliteType.Integer);
|
||||
|
||||
// Source and WebcamSceneConfig share ONE z-order space per scene:
|
||||
// each element gets its index within scene.Elements regardless of
|
||||
@@ -196,6 +209,15 @@ public partial class LayoutStore : IDisposable
|
||||
cmd.Parameters["$blurAmount"].Value = source.BlurAmount;
|
||||
alertAudioPathP.Value = (object?)source.AlertAudioPath ?? DBNull.Value;
|
||||
alertAudioLoopP.Value = source.AlertAudioLoop ? 1 : 0;
|
||||
textContentP.Value = source.TextContent;
|
||||
textFontFamilyP.Value = source.TextFontFamily;
|
||||
textFontSizeP.Value = source.TextFontSize;
|
||||
textColorP.Value = source.TextColor;
|
||||
textBoldP.Value = source.TextBold ? 1 : 0;
|
||||
textItalicP.Value = source.TextItalic ? 1 : 0;
|
||||
textUnderlineP.Value = source.TextUnderline ? 1 : 0;
|
||||
textAlignmentP.Value = (int)source.TextAlignment;
|
||||
textVerticalAlignmentP.Value = (int)source.TextVerticalAlignment;
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,7 +79,8 @@ public sealed class ScreenCaptureManager : IDisposable
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLog.Write($"ScreenCaptureManager: creating capture '{key}' failed: {ex.Message}");
|
||||
AppLog.Write($"ScreenCaptureManager: creating capture '{key}' failed: {ex.Message}",
|
||||
AppLogMessageType.Error, AppLogErrorClass.Class2);
|
||||
CaptureFailed?.Invoke(key, ex.Message);
|
||||
return false;
|
||||
}
|
||||
@@ -109,7 +110,8 @@ public sealed class ScreenCaptureManager : IDisposable
|
||||
lock (_gate)
|
||||
_sessions.Remove(key);
|
||||
session.Source.FrameAvailable -= session.FrameHandler;
|
||||
AppLog.Write($"ScreenCaptureManager: failed to start capture '{key}': {ex.Message}");
|
||||
AppLog.Write($"ScreenCaptureManager: failed to start capture '{key}': {ex.Message}",
|
||||
AppLogMessageType.Error, AppLogErrorClass.Class2);
|
||||
CaptureFailed?.Invoke(key, ex.Message);
|
||||
await SafeStopAsync(session.Source);
|
||||
return false;
|
||||
@@ -189,7 +191,8 @@ public sealed class ScreenCaptureManager : IDisposable
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLog.Write($"ScreenCaptureManager: stopping capture failed: {ex.Message}");
|
||||
AppLog.Write($"ScreenCaptureManager: stopping capture failed: {ex.Message}",
|
||||
AppLogMessageType.Error, AppLogErrorClass.Class2);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Windows;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Imaging;
|
||||
using ytLive.Models;
|
||||
|
||||
namespace ytLive.Services;
|
||||
|
||||
public sealed class TextOverlayRenderer
|
||||
{
|
||||
private const int MaxRasterDimension = 4096;
|
||||
private readonly ConcurrentDictionary<Source, CacheEntry> _cache = new();
|
||||
|
||||
public VideoFrame? RenderFrame(Source source)
|
||||
=> _cache.TryGetValue(source, out var entry) ? entry.Frame : null;
|
||||
|
||||
public void Remove(Source source) => _cache.TryRemove(source, out _);
|
||||
|
||||
public void Retain(IEnumerable<Source> sources)
|
||||
{
|
||||
var active = new HashSet<Source>(sources);
|
||||
foreach (var source in _cache.Keys)
|
||||
if (!active.Contains(source))
|
||||
_cache.TryRemove(source, out _);
|
||||
}
|
||||
|
||||
public void UpdatePreview(Source source)
|
||||
{
|
||||
var dispatcher = Application.Current?.Dispatcher;
|
||||
if (dispatcher != null && !dispatcher.CheckAccess())
|
||||
{
|
||||
dispatcher.Invoke(() => UpdatePreview(source));
|
||||
return;
|
||||
}
|
||||
|
||||
var config = Capture(source);
|
||||
if (_cache.TryGetValue(source, out var cached) && cached.Config == config)
|
||||
{
|
||||
if (!ReferenceEquals(source.VideoImageSource, cached.Preview))
|
||||
source.VideoImageSource = cached.Preview;
|
||||
return;
|
||||
}
|
||||
|
||||
var frame = Rasterize(config);
|
||||
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);
|
||||
_cache[source] = new CacheEntry(config, frame, bitmap);
|
||||
source.VideoImageSource = bitmap;
|
||||
}
|
||||
|
||||
private static RenderConfig Capture(Source source) => new(
|
||||
source.TextContent,
|
||||
source.TextFontFamily,
|
||||
source.TextFontSize,
|
||||
source.TextColor,
|
||||
source.TextBold,
|
||||
source.TextItalic,
|
||||
source.TextUnderline,
|
||||
source.TextAlignment,
|
||||
source.TextVerticalAlignment,
|
||||
RasterDimension(source.Width),
|
||||
RasterDimension(source.Height));
|
||||
|
||||
private static int RasterDimension(double value) =>
|
||||
double.IsFinite(value) ? (int)Math.Clamp(Math.Ceiling(value), 1, MaxRasterDimension) : 1;
|
||||
|
||||
private static VideoFrame Rasterize(RenderConfig config)
|
||||
{
|
||||
var visual = new DrawingVisual();
|
||||
if (!string.IsNullOrEmpty(config.Content))
|
||||
{
|
||||
using var drawing = visual.RenderOpen();
|
||||
var family = new FontFamily(config.FontFamily);
|
||||
var typeface = new Typeface(
|
||||
family,
|
||||
config.Italic ? FontStyles.Italic : FontStyles.Normal,
|
||||
config.Bold ? FontWeights.Bold : FontWeights.Normal,
|
||||
FontStretches.Normal);
|
||||
var brush = new SolidColorBrush(ParseColor(config.Color));
|
||||
brush.Freeze();
|
||||
var formatted = new FormattedText(
|
||||
config.Content,
|
||||
CultureInfo.CurrentUICulture,
|
||||
FlowDirection.LeftToRight,
|
||||
typeface,
|
||||
config.FontSize,
|
||||
brush,
|
||||
1.0)
|
||||
{
|
||||
MaxTextWidth = config.Width,
|
||||
MaxTextHeight = config.Height,
|
||||
TextAlignment = config.Alignment switch
|
||||
{
|
||||
TextLayerAlignment.Center => System.Windows.TextAlignment.Center,
|
||||
TextLayerAlignment.Right => System.Windows.TextAlignment.Right,
|
||||
_ => System.Windows.TextAlignment.Left,
|
||||
},
|
||||
Trimming = TextTrimming.CharacterEllipsis,
|
||||
};
|
||||
if (config.Underline)
|
||||
formatted.SetTextDecorations(TextDecorations.Underline, 0, config.Content.Length);
|
||||
var top = config.VerticalAlignment switch
|
||||
{
|
||||
TextLayerVerticalAlignment.Center => Math.Max(0, (config.Height - formatted.Height) / 2),
|
||||
TextLayerVerticalAlignment.Bottom => Math.Max(0, config.Height - formatted.Height),
|
||||
_ => 0,
|
||||
};
|
||||
drawing.DrawText(formatted, new Point(0, top));
|
||||
}
|
||||
|
||||
var bitmap = new RenderTargetBitmap(config.Width, config.Height, 96, 96, PixelFormats.Pbgra32);
|
||||
bitmap.Render(visual);
|
||||
var pixels = new byte[config.Width * config.Height * 4];
|
||||
bitmap.CopyPixels(pixels, config.Width * 4, 0);
|
||||
UnpremultiplyAlpha(pixels);
|
||||
return new VideoFrame(config.Width, config.Height, pixels);
|
||||
}
|
||||
|
||||
private static Color ParseColor(string hex) =>
|
||||
(Color)ColorConverter.ConvertFromString(hex)!;
|
||||
|
||||
private static void UnpremultiplyAlpha(byte[] pixels)
|
||||
{
|
||||
for (var i = 0; i < pixels.Length; i += 4)
|
||||
{
|
||||
var alpha = pixels[i + 3];
|
||||
if (alpha == 0)
|
||||
{
|
||||
pixels[i] = pixels[i + 1] = pixels[i + 2] = 0;
|
||||
continue;
|
||||
}
|
||||
if (alpha == 255) continue;
|
||||
pixels[i] = Unpremultiply(pixels[i], alpha);
|
||||
pixels[i + 1] = Unpremultiply(pixels[i + 1], alpha);
|
||||
pixels[i + 2] = Unpremultiply(pixels[i + 2], alpha);
|
||||
}
|
||||
}
|
||||
|
||||
private static byte Unpremultiply(byte value, byte alpha) =>
|
||||
(byte)Math.Min(255, (value * 255 + alpha / 2) / alpha);
|
||||
|
||||
private sealed record CacheEntry(RenderConfig Config, VideoFrame Frame, WriteableBitmap Preview);
|
||||
|
||||
private readonly record struct RenderConfig(
|
||||
string Content,
|
||||
string FontFamily,
|
||||
double FontSize,
|
||||
string Color,
|
||||
bool Bold,
|
||||
bool Italic,
|
||||
bool Underline,
|
||||
TextLayerAlignment Alignment,
|
||||
TextLayerVerticalAlignment VerticalAlignment,
|
||||
int Width,
|
||||
int Height);
|
||||
}
|
||||
@@ -2,6 +2,8 @@ using System.Net.Http;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using ytLive.Models;
|
||||
using AppLogErrorClass = ytLive.Helpers.AppLogErrorClass;
|
||||
using AppLogMessageType = ytLive.Helpers.AppLogMessageType;
|
||||
using AppLog = ytLive.Helpers.AppLog;
|
||||
|
||||
namespace ytLive.Services;
|
||||
@@ -132,7 +134,8 @@ public class YouTubeStreamService
|
||||
|
||||
if (response.IsSuccessStatusCode) return null;
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
AppLog.Write($"Broadcast update failed ({(int)response.StatusCode}): {body}");
|
||||
AppLog.Write($"Broadcast update failed ({(int)response.StatusCode}): {body}",
|
||||
AppLogMessageType.Error, AppLogErrorClass.Class2);
|
||||
return $"YouTube rejected the update ({(int)response.StatusCode})";
|
||||
}
|
||||
|
||||
@@ -170,7 +173,8 @@ public class YouTubeStreamService
|
||||
|
||||
if (response.IsSuccessStatusCode) return null;
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
AppLog.Write($"Broadcast transition(complete) failed ({(int)response.StatusCode}): {body}");
|
||||
AppLog.Write($"Broadcast transition(complete) failed ({(int)response.StatusCode}): {body}",
|
||||
AppLogMessageType.Error, AppLogErrorClass.Class2);
|
||||
return $"YouTube rejected the end transition ({(int)response.StatusCode})";
|
||||
}
|
||||
|
||||
@@ -194,7 +198,8 @@ public class YouTubeStreamService
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLog.Write($"Broadcast privacy read failed: {ex.Message}");
|
||||
AppLog.Write($"Broadcast privacy read failed: {ex.Message}",
|
||||
AppLogMessageType.Error, AppLogErrorClass.Class2);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -220,16 +225,19 @@ public class YouTubeStreamService
|
||||
var broadcast = await _http.DeleteAsync($"{ApiBase}/liveBroadcasts?id={id}");
|
||||
if (broadcast.IsSuccessStatusCode) return null;
|
||||
var bBody = await broadcast.Content.ReadAsStringAsync();
|
||||
AppLog.Write($"liveBroadcasts.delete failed ({(int)broadcast.StatusCode}): {bBody}");
|
||||
AppLog.Write($"liveBroadcasts.delete failed ({(int)broadcast.StatusCode}): {bBody}",
|
||||
AppLogMessageType.Error, AppLogErrorClass.Class2);
|
||||
return $"YouTube refused to delete the broadcast ({(int)broadcast.StatusCode})";
|
||||
}
|
||||
var body = await video.Content.ReadAsStringAsync();
|
||||
AppLog.Write($"videos.delete failed ({(int)video.StatusCode}): {body}");
|
||||
AppLog.Write($"videos.delete failed ({(int)video.StatusCode}): {body}",
|
||||
AppLogMessageType.Error, AppLogErrorClass.Class2);
|
||||
return $"YouTube refused to delete the video ({(int)video.StatusCode})";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLog.Write($"Broadcast delete failed: {ex.Message}");
|
||||
AppLog.Write($"Broadcast delete failed: {ex.Message}",
|
||||
AppLogMessageType.Error, AppLogErrorClass.Class2);
|
||||
return ex.Message;
|
||||
}
|
||||
}
|
||||
@@ -306,7 +314,8 @@ public class YouTubeStreamService
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLog.Write($"Broadcast diag failed: {ex.Message}");
|
||||
AppLog.Write($"Broadcast diag failed: {ex.Message}",
|
||||
AppLogMessageType.Error, AppLogErrorClass.Class2);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -341,7 +350,8 @@ public class YouTubeStreamService
|
||||
var response = await _http.GetAsync($"{ApiBase}/{pathAndQuery}");
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
AppLog.Write($"Broadcast diag GET {pathAndQuery.Split('?')[0]} failed ({(int)response.StatusCode})");
|
||||
AppLog.Write($"Broadcast diag GET {pathAndQuery.Split('?')[0]} failed ({(int)response.StatusCode})",
|
||||
AppLogMessageType.Error, AppLogErrorClass.Class2);
|
||||
return default;
|
||||
}
|
||||
return JsonSerializer.Deserialize<JsonElement>(await response.Content.ReadAsStringAsync());
|
||||
@@ -401,7 +411,8 @@ public class YouTubeStreamService
|
||||
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
var reason = ErrorReason(body);
|
||||
AppLog.Write($"Broadcast bind failed ({(int)response.StatusCode}/{reason ?? "unknown"}) for {broadcastId} -> {streamId}: {body}");
|
||||
AppLog.Write($"Broadcast bind failed ({(int)response.StatusCode}/{reason ?? "unknown"}) for {broadcastId} -> {streamId}: {body}",
|
||||
AppLogMessageType.Error, AppLogErrorClass.Class2);
|
||||
return BindFailureMessage((int)response.StatusCode, reason);
|
||||
}
|
||||
|
||||
@@ -612,7 +623,8 @@ public class YouTubeStreamService
|
||||
}
|
||||
|
||||
var errorBody = await response.Content.ReadAsStringAsync();
|
||||
AppLog.Write($"Chat message insert failed ({(int)response.StatusCode}): {errorBody}");
|
||||
AppLog.Write($"Chat message insert failed ({(int)response.StatusCode}): {errorBody}",
|
||||
AppLogMessageType.Error, AppLogErrorClass.Class2);
|
||||
return $"YouTube rejected the message ({(int)response.StatusCode})";
|
||||
}
|
||||
|
||||
|
||||
+6
-5
@@ -12,10 +12,11 @@ External-facing logic: YouTube API, persistence. See
|
||||
| `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) |
|
||||
| `TextOverlayRenderer.cs` | TASK 3.17 static TextOverlay rasterizer/cache: WPF `FormattedText` runs on the dispatcher only when content/style/size changes; one straight-alpha BGRA `VideoFrame` drives both the XAML preview bitmap and compositor output, while pump reads are cache-only |
|
||||
| `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 |
|
||||
| `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. The shared ticker renderer serves the AlertBox's active event and scene-local `SourceType.Ticker` layers' current+queued feed (transient; no history) |
|
||||
| `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 51 adds `WebcamSceneConfig.BackgroundEffect`/`BackgroundStrength`; **TASK 57 (the "vv" feature) adds the seven `WebcamSceneConfig.Vertical*` columns** (`VerticalX`/`VerticalY`/`VerticalWidth`/`VerticalHeight`/`VerticalClipShape`/`VerticalRectWidth`/`VerticalRectHeight` — the vertical tier's own camera placement per scene; NULL = the scene keeps its 16:9 placement. The X/Y/W/H/ClipShape columns stay the **16:9** layout: while the vertical tier is live the save writes the model's parked `Landscape*` values there and the live ones into the `Vertical*` columns); 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) |
|
||||
| `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 51 adds `WebcamSceneConfig.BackgroundEffect`/`BackgroundStrength`; **TASK 57 (the "vv" feature) adds the seven `WebcamSceneConfig.Vertical*` columns** (`VerticalX`/`VerticalY`/`VerticalWidth`/`VerticalHeight`/`VerticalClipShape`/`VerticalRectWidth`/`VerticalRectHeight` — the vertical tier's own camera placement per scene; NULL = the scene keeps its 16:9 placement. The X/Y/W/H/ClipShape columns stay the **16:9** layout: while the vertical tier is live the save writes the model's parked `Landscape*` values there and the live ones into the `Vertical*` columns); TASK 47 adds the five alert `Source` columns (`AlertVideoAssetId`/`AlertVideoPath`/`AlertUseDefaultVideo`/`AlertShowTicker`/`AlertVideoVolume`) without a version bump; TASK 3.17 adds nine additive `Source` text-layer columns (`TextContent`/font family/size/color/bold/italic/underline/horizontal alignment/vertical alignment), also 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 |
|
||||
| `BackgroundEffects/IPersonSegmenter.cs` | **TASK 51 seam:** `Segment(VideoFrame) → SegmentationMask?` (`SegmentationMask(Width, Height, float[] Values)`, 0 = background, 1 = person); null = no mask → caller passes through. Tests inject fakes |
|
||||
| `BackgroundEffects/OnnxPersonSegmenter.cs` | **TASK 51 default segmenter:** MediaPipe Selfie Segmenter (landscape, Apache-2.0) as an embedded ONNX resource, run by onnxruntime on the CPU (2 intra-op threads); frame stretched to 256×144 RGB/255 NHWC; lazy session; never throws (fail → log once, null forever) |
|
||||
@@ -49,8 +50,8 @@ External-facing logic: YouTube API, persistence. See
|
||||
| `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) |
|
||||
| `Compositor/AlertTickerRenderer.cs` | **Shared ticker renderer (TASK 47):** marquee frame rendered per call by pure byte-math; the text pill is rasterized once per text on the UI thread (cached, unpremultiplied straight alpha). Supports AlertBox active-event tickers and ordinary scene-local ticker sources, with per-source `Ticker` settings. **Dynamic output — never baked** (unlike the social bar) |
|
||||
| `Encoder/EncoderOptions.cs` | One go-live's encoder config: full `RtmpUrl` (ingest + stream key), raw input W×H from the compositor crop, final output 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` |
|
||||
| `Encoder/IEncoderProcess.cs` | Seam around the spawned subprocess (probe + encoder): redirected stdin/stdout/stderr + exit control — the encoder never touches `Process` directly |
|
||||
@@ -58,7 +59,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`. **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 [`docs/ai/frame-pipeline.md`](../docs/ai/frame-pipeline.md) → "Live frame pipeline". **2026-10-16:** owns a `CompositeTimings` collector, passes it through `RenderScene`/`RenderFull`/`GetBakedBase`/`CompositeLayers`, logs `FramePump parts (N renders/5s): …` (per-stage breakdown, slowest first) beside the stats line each 5s window, and dumps the scene shape — `FramePump scene dump:` header + one line per element (z-index, name, kind, type, visibility, rect, opacity, clip) — at pump start and on every scene change |
|
||||
| `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 + scene ticker:** the `alertTicker: Func<Scene, VideoFrame?>?` seam selects the active scene's AlertBox ticker, threaded through `RenderScene`/`RenderFull` + **mixed into `BuildFullRenderSignature`**; standalone tickers are resolved as per-element dynamic sources. See [`docs/ai/frame-pipeline.md`](../docs/ai/frame-pipeline.md) → "Live frame pipeline". **2026-10-16:** owns a `CompositeTimings` collector, passes it through `RenderScene`/`RenderFull`/`GetBakedBase`/`CompositeLayers`, logs `FramePump parts (N renders/5s): …` (per-stage breakdown, slowest first) beside the stats line each 5s window, and dumps the scene shape — `FramePump scene dump:` header + one line per element (z-index, name, kind, type, visibility, rect, opacity, clip) — at pump start and on every scene change |
|
||||
| `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" |
|
||||
|
||||
@@ -17,8 +17,8 @@ corrected. No new scope mid-week; anything discovered goes on a list for day 6.
|
||||
|
||||
| Day | Units | Done when |
|
||||
|---|---|---|
|
||||
| **1 · Mon** — licensing | `IEntitlement` seam + Steam DLC impl + fake · `HasProOutput` on the seam · greyed tier entries · licence UI four states + 7/1/0-day notices + "monthly is on the website, not Steam" · licence ID in About · system messages (`SystemMessage`/`MessageCenter` strip) · Debug-only `InternalsVisibleTo` | Fake drives all four states; Steam-less launch is the free profile; About shows the licence ID |
|
||||
| **2 · Tue** — release blockers | Visibility picker + `UpdateBroadcast` · `StreamConfig` defaults · TASK 53 output contract (geometry test per tier, 9:16 e2e) · 9·5 webcam identity key · Move transitions (per-layer carry-across-scenes) | A public broadcast starts and changes while live; every tier pinned by a test |
|
||||
| **1 · Mon** — licensing | Paid-app ownership check (`BIsSubscribedApp`) + Debug fake + app-wide `IsAppOwned` · greyed tier entries · About conversion copy + llamacasty.com link · current-launch Logs tab (replaces the separate system-message strip) · Debug-only `InternalsVisibleTo` | A locked tier opens About without changing the selected free tier or leaving the app; the site opens only from its link |
|
||||
| **2 · Tue** — release blockers | Visibility flow (creator-selected at release; development/test stays Private) · `OutputProfile` entitlement: 30 fps free / 60 fps paid regardless of resolution · ~~`StreamConfig` defaults~~ retired (no production call sites) · TASK 53 (vertical resolution check ✅; remaining output contract open) · 9·5 webcam identity key · Move transitions (per-layer carry-across-scenes) | Visibility is selectable at creation and live-editable; the 30/60-fps gate is tested; remaining Day 2 items stay queued |
|
||||
| **3 · Wed** — resilience | TASK 32: blip retry → measured grace → amber RECONNECTING → one-click Back on air → fragmented MP4 → ingest pre-flight · 33 bandwidth step-down | Killing the network mid-broadcast recovers without ending it |
|
||||
| **4 · Thu** — content & media | 3·17 Text layer · 21 media acquisition wiring + file picker + loop/volume UI · 49 chat profanity filter · 34 scheduled streams | Text layer renders/persists; media file plays, loops, has volume; scheduled stream created |
|
||||
| **5 · Fri** — data & settings | 3·20 RewardEvent persistence + `superChatEvents.list` backfill · 39·2 YPP slice 2 · 37 defaults-vs-current split · 35 scene-linked audio · 40·A/C/D settings round · 55·2–3 disk hygiene | Session report reads persisted events; defaults can be reverted; temp clips cleaned |
|
||||
@@ -41,6 +41,8 @@ persisted; renderer mirrors `Services/ChatBoxRenderer.cs`.
|
||||
> 2026-11-07 are a failure of the plan, not a scheduling note.
|
||||
| Task | Status | Scope | Detail |
|
||||
|---|---|---|---|
|
||||
| 53 | ✅ | Output-format contract — six size/FPS modes, vertical YouTube playback, and recordings visually accepted | [`task-53`](TASKS/task-53-output-formats.md) |
|
||||
| — | ✅ | Output entitlement code: free at 30 fps / paid at 60 fps regardless of resolution; Steam App ID is still pending | [`commercial`](docs/ai/commercial.md) |
|
||||
| 3·17 | ☐ **NEXT** | **Text layer** — content, font family/size/weight/style, colour, alignment, drag/resize, persisted | [`task-03`](TASKS/task-03-capture-pipeline.md) |
|
||||
| 21 | 🔶 | Media source: acquisition wiring + file picker + loop/volume UI (decoder, manager, pacing, loop all shipped — **no `AcquireAsync` caller exists**) | [`task-21`](TASKS/task-21-media-source.md) |
|
||||
| — | ☐ | Transitions **Move**: per-layer carry-across-scenes flag, per-scene placement, fixed z band | [`ui-surface`](docs/ai/ui-surface.md) |
|
||||
@@ -56,18 +58,17 @@ persisted; renderer mirrors `Services/ChatBoxRenderer.cs`.
|
||||
| 39·2 | ☐ | YPP slice 2: Analytics scopes, watch-hours/Shorts velocity + ETA | [`task-39`](TASKS/task-39-ypp-journey-tracker.md) |
|
||||
| 49 | ☐ | Chat profanity filter: local, opt-in, **non-persistent**, user-supplied word list | [`task-49`](TASKS/task-49-chat-profanity-filter.md) |
|
||||
| 50 | ☐ | One-button publish of the local recording (resumable upload) | [`task-50`](TASKS/task-50-publish-recording.md) |
|
||||
| 53 | ☐ | Output-format contract: pin every tier with a geometry test, prove 9:16 end-to-end, recorded file matches output | [`task-53`](TASKS/task-53-output-formats.md) |
|
||||
| 54 | 🔶 | Freemium: EULA + privacy shipped; **no licence path built** — needs the entitlement seam, the output-profile cap and the licence UI below; site copy still says free forever | [`task-54`](TASKS/task-54-marcom-free-merch.md) |
|
||||
| — | 🔶 | **Steamworks entitlement** — `IEntitlement` + `SteamEntitlement` (Steamworks.NET) + fake **done**; needs the DLC app id from Steamworks. **Polar path retired** — one-time Steam DLC only | [`commercial`](docs/ai/commercial.md) |
|
||||
| 54 | 🔶 | Freemium: EULA + privacy shipped; free Steam Demo and paid app use one codebase; paid app ID and Steam Demo setup still pending | [`task-54`](TASKS/task-54-marcom-free-merch.md) |
|
||||
| — | 🔶 | **Steam ownership** — `BIsSubscribedApp(paidAppId)` + Debug fake + app-wide `IsAppOwned`; paid app ID awaits Steamworks | [`commercial`](docs/ai/commercial.md) |
|
||||
| — | ❌ | **Polar subscription path** — retired 2026-11-01 with the one-price model. Design kept in `decisions.md`, not built | [`decisions`](docs/ai/decisions.md) |
|
||||
| — | 🔶 | **OutputProfile gate** — resolver + `EncoderOptions` wiring **done** (`Models/OutputProfile.cs`, one ffmpeg/two outputs, dev override); remaining: greyed tier entries in the dropdown, entitlement wiring | [`commercial`](docs/ai/commercial.md) |
|
||||
| — | ☐ | **Licence UI** — two states only: owned → Pro · not owned → greyed tiers + "unlock on Steam" (opens the store overlay). No key entry, no notices, no portal | [`commercial`](docs/ai/commercial.md) |
|
||||
| — | ☐ | **System messages** — `SystemMessage` + `MessageCenter`, strip above the dock, dedupe by key, short history, never composited | [`ui-surface`](docs/ai/ui-surface.md) |
|
||||
| — | ☐ | **Steam packaging** — rename, `publish` output + `steam_api64.dll`, depot, `steamcmd` upload, store page + assets, DLC app | [`Distribution.md`](Distribution.md) |
|
||||
| — | ✅ | **OutputProfile gate** — resolver + `EncoderOptions` wiring (`Models/OutputProfile.cs`, one ffmpeg/two outputs, dev override); higher tiers follow `IsAppOwned` | [`commercial`](docs/ai/commercial.md) |
|
||||
| — | ✅ | **Demo conversion UI** — owned users get higher tiers; non-owners see greyed tiers, and selecting one opens About with conversion copy and an explicit llamacasty.com link. No browser opens until the link is clicked | [`commercial`](docs/ai/commercial.md) |
|
||||
| — | ✅ | **System-message strip replaced by Logs tab** — current launch's newest 500 raw AppLog entries, newest-first; no prior-launch import or logging-policy changes | [`ui-surface`](docs/ai/ui-surface.md) |
|
||||
| — | ☐ | **Steam packaging** — `publish` output + `steam_api64.dll`, paid app and associated Demo setup, depot, `steamcmd` upload, store pages + assets | [`Distribution.md`](Distribution.md) |
|
||||
| — | ☐ | **Training-video series** — capture the first 8–10 recurring questions as videos on `@llamacasty`; each is documentation, channel content and monetization at once | MARCOM → support→content loop |
|
||||
| 9·5 | ☐ | Webcam identity key reconciliation | [`task-09`](TASKS/task-09-live-stream-management.md) |
|
||||
| 14 | 🔶 | Creator feedback batch — branch 3+ | [`task-14`](TASKS/task-14-creator-feedback-batch.md) |
|
||||
| — | ☐ | Small: `StreamConfig` defaults (`TargetBitrate=6000`, `1920x1080`) are stale — the dropdown drives `StreamHealth` | this line |
|
||||
| — | ❌ | Retired: `StreamConfig` defaults are unused; encoder output comes from the selected quality tier | [`Models/index.md`](Models/index.md) |
|
||||
| 36 | ☐ | **Gold pass** — the v1 gate, always the last unit (see 1.0 gates) | [`task-36`](TASKS/task-36-gold-pass.md) |
|
||||
| 48 | ⏸ | Distribution & packaging — route settled 2026-10-31 (winget/portable first; Steam + Store parked). Build steps: [`Distribution.md`](Distribution.md); starts after the entitlement seam | [`task-48`](TASKS/task-48-distribution-msix.md) |
|
||||
| 10 | ❌ | Retired 2026-10-08; superseded by the TASK 54 rows above — the Polar design it described is rebuilt as a lease (`commercial.md`), not revived as written | [`task-10`](TASKS/task-10-monetization.md) |
|
||||
@@ -81,10 +82,19 @@ persisted; renderer mirrors `Services/ChatBoxRenderer.cs`.
|
||||
- ⏳ **Webcam cadence**: a Release take in the failing geometry (vertical tier first) blocks Units 1–2.
|
||||
No fix until a reading names the stage.
|
||||
|
||||
## Creator-requested follow-ups (park for Day 6)
|
||||
|
||||
- Group controls under **This PC** and **YouTube**.
|
||||
- ON-AIR opens a preflight using the saved visibility default and an explicit **Start Broadcast — [scope]** action.
|
||||
- Persistent Help icon immediately left of Edit opens the creator-triggered, app-only 1-2-3 first-broadcast guide. Keep copy top-aligned and high-contrast; never composite the guide into broadcast output.
|
||||
- Add YouTube category selection; send tags through the video API; support thumbnail upload.
|
||||
- Add tooltips to settings tabs and make Logs wrapping effective.
|
||||
|
||||
## 1.0 gates (before the first shipped build)
|
||||
|
||||
- **Visibility picker** — replace the always-Private lock, hide the TEST button, and wire
|
||||
`UpdateBroadcast` in the same unit (it is the only code that can change a broadcast's privacy).
|
||||
- **Release visibility flow** — default to Private; let the creator choose Private/Unlisted/Public
|
||||
in the ON-AIR preflight and change it live through `UpdateBroadcast`; keep development/test runs
|
||||
forced Private. Hide the separate TEST button as already decided for v1.
|
||||
- **Kill the dev multi-instance affordance** — `Helpers/InstanceProfile.cs` is already `#if DEBUG`;
|
||||
re-prove it on a Release build.
|
||||
- **Release engineering** — `scripts/publish.sh` **done** (self-contained win-x64, `ffmpeg\`
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
**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)
|
||||
### Status: 🔶 In progress — **Text source (item 17) SHIPPED 2026-11-04 via TASK 3·17; Alerts (item 20) SHIPPED 2026-09-24 via TASK 43**; still open: 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
|
||||
@@ -23,7 +23,7 @@
|
||||
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)
|
||||
17. ✅ **Text source (TASK 3·17, 2026-11-04)** — independent static multiline text layers with per-layer font, size, color, emphasis, alignment, position and size; additive layout persistence; editor and preview share the cached raster with compositor output
|
||||
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)** —
|
||||
@@ -126,5 +126,4 @@ Preview shows the transition too (WYSIWYG). No wipes/slides/LUTs beyond the four
|
||||
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
|
||||
|
||||
3. **Sources list:** drag rows to reorder sources (this *is* the z-order) — implemented
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
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)
|
||||
7. ✅ **One-click go live + initial Private development safeguard SHIPPED** (2026-08-14) — at the time, the Go Live dialog and `YouTubeStreamService.CreateBroadcast` forced `privacyStatus = "private"`; the broadcast-insert test pinned that behavior. The creator clarified 2026-11-04 that this is a development/test safeguard, not permanent product policy. See TASK 9 and `docs/ai/youtube-api.md` for the release visibility flow.
|
||||
|
||||
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**
|
||||
@@ -46,8 +46,8 @@ until a frame source exists, so the compositor is ship step 1. The pipeline is
|
||||
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.
|
||||
8. **Development/test privacy safeguard (creator clarification 2026-11-04)** — force `Private` during development and test runs so unfinished captures cannot publish accidentally. This is not a permanent product lock: the released creator flow offers Private/Unlisted/Public, defaults to Private, and supports live visibility changes. Verify the environment-specific guard and release flow in tests.
|
||||
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 checkbox cannot silently lapse.
|
||||
|
||||
#### Ship step 1 — Scene compositor (the frame source)
|
||||
|
||||
@@ -336,4 +336,3 @@ the validator → persisted), compositor bar overlay (top/bottom + above-flash),
|
||||
(`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`.
|
||||
|
||||
|
||||
@@ -5,23 +5,23 @@
|
||||
|
||||
**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
|
||||
### 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 is the Day 2 release-visibility work, with a development/test Private safeguard; 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
|
||||
6. ☐ **Release visibility flow** — persist a default of Private; offer Private/Unlisted/Public in the ON-AIR preflight with an explicit “Start Broadcast — [scope]” action; apply the selected value at creation and via `UpdateBroadcast` while live. Development/test runs stay forced Private.
|
||||
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, and made-for-kids; release visibility is item 6. **Scheduling:** ships as **TASK 34** (Scheduled checkbox + datetime in the same drawer). Advanced settings remain out of product: latency, DVR/record-from-start, embed, projection, and captions stay at fixed defaults. Category selection and tag updates through the video API, plus thumbnail upload, remain follow-ups; `categoryId` is video metadata, 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.
|
||||
4. **Go-live preflight** — ON-AIR opens a scope preflight using the saved visibility default; the creator explicitly confirms with “Start Broadcast — [scope]”.
|
||||
5. **Live edits in Release** — `liveBroadcasts.update` with part=`snippet,status` applies title/description/privacy changes. Development/test runs remain Private.
|
||||
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`).
|
||||
|
||||
@@ -34,4 +34,3 @@
|
||||
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
|
||||
|
||||
|
||||
@@ -54,5 +54,4 @@ The tasks below are ordered by dependency and risk. Each task builds on the prev
|
||||
14. **TASK 35** — Scene-linked audio ("scenes remember the room").
|
||||
15. **Text source (TASK 3 item 17)** + **the monetization-awareness chain + Alerts (TASK 10 related work → TASK 3 item 20)** — capture → report → journey → alerts, one slice per integration test.
|
||||
16. **TASK 13** — Social media launch kit (MARCOM) — after features, before gold.
|
||||
17. **TASK 36** — Gold pass, always last: visibility unlock + flash-live enable + screens/layers audit + native verification suite + expiry reminders + signing/installer/EULA/Velopack.
|
||||
|
||||
17. **TASK 36** — Gold pass, always last: verify the release visibility flow while development/test runs remain Private + flash-live enable + screens/layers audit + native verification suite + expiry reminders + signing/installer/EULA/Velopack.
|
||||
|
||||
@@ -28,7 +28,5 @@ channel countdown; come Friday, one click goes live into the scheduled broadcast
|
||||
confirmation. (b) delete semantics for a ready broadcast bound to a reusable stream.
|
||||
6. ☐ Test (the ONE): schedule → persists + inserts (fake service captures a future scheduledStartTime
|
||||
+ boundStreamId); start → adopts instead of inserting; uncheck → delete called.
|
||||
- **Test-phase note:** dark by design while the visibility lock holds (Private notifies nobody); the
|
||||
creator can still exercise insert/adopt/cancel. Full shine arrives with TASK 36's unlock — pleasing
|
||||
coupling, deliberate.
|
||||
|
||||
- **Visibility note (updated 2026-11-04):** development/test scheduled broadcasts stay Private; the
|
||||
released creator flow uses the saved default or scope selected in the ON-AIR preflight.
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
**Goal:** the single deliberate pass that turns the dev product into the shipped product. Nothing here
|
||||
may be done opportunistically mid-development — each item has a lock/comment pointing at this task.
|
||||
|
||||
1. ☐ **Visibility unlock** — remove the `CreateBroadcast` Private override (TASK 9 item 6); dialog
|
||||
selection rules. The channel-protection stance ends at GA, by hand, here.
|
||||
1. ☐ **Release visibility verification** — verify the Day 2 Private/Unlisted/Public creator flow,
|
||||
including live `UpdateBroadcast`; development/test runs must remain forced Private.
|
||||
2. ☐ **Branding flash goes live** — end the preview-only pre-GA posture: composite the escalating
|
||||
obnoxious-promo flash onto output + recordings for free users. Paid removes it; that stays the
|
||||
ONLY paid delta. (Escalation curve finalized here, first.)
|
||||
@@ -65,4 +65,3 @@ flash is preview-only (see ai.md → Monetization; TASK 36 flips it live).
|
||||
|
||||
*If a future session is tempted by anything on this list, the answer is already written. New ideas
|
||||
must survive this page before they get a task number.*
|
||||
|
||||
|
||||
@@ -24,10 +24,9 @@ inject mock chat, with alert/event triggers for subscriber, membership, and Supe
|
||||
|
||||
## Facts the build sits on (verified in code, 2026-09-22 research pass)
|
||||
|
||||
- **The app is already private-only go-live** — `YouTubeStreamService.CreateBroadcast` hardcodes
|
||||
`privacyStatus="private"` + `enableAutoStart/Stop` + `low latency`, no monitor stream
|
||||
(`enableMonitorStream=false`), and the Go Live dialog is locked to Private. "Test" is therefore a
|
||||
**session variant over the real pipeline**, not a new broadcast mode.
|
||||
- **Test mode is always Private** — it remains a **session variant over the real pipeline**, not a
|
||||
new broadcast mode. The creator clarified 2026-11-04 that normal Release Go Live offers a
|
||||
visibility choice; development/test runs stay forced Private.
|
||||
- The **liveChatId flow already exists**: `GetBroadcastLiveChatIdAsync` → `_youtubeChat.Start(liveChatId)`
|
||||
in `PrepareAndStartLiveAsync` — the "chat endpoint activation" the ask describes.
|
||||
- OAuth scope already includes `youtube` + `youtube.force-ssl` (`YouTubeAuthService.cs:18`) —
|
||||
@@ -79,4 +78,4 @@ inject mock chat, with alert/event triggers for subscriber, membership, and Supe
|
||||
## Follow-ups (NOT in this task)
|
||||
|
||||
- Real stinger/TTS alert widgets → TASK 3 items 16/17/20.
|
||||
- Test-mode health-panel depth (per-broadcast status inside the drawer) if ever wanted.
|
||||
- Test-mode health-panel depth (per-broadcast status inside the drawer) if ever wanted.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
> the creator picks their own clip (referenced from disk, never stored in the DB), the
|
||||
> clip fades in/out in ~0.3s (alpha envelope on the video + volume on the audio, mixed
|
||||
> into the live stream at unity — no duck), and an auto-composed **message ticker**
|
||||
> ("Funder — Super Chat · $10.00") scrolls across the very top of the frame, toggleable.
|
||||
> ("Funder — Super Chat · $10.00") appears inside the Stream Alerts box, toggleable.
|
||||
> The six TASK 43 animations remain the intrinsic fallback when no asset is playable.
|
||||
|
||||
## Provenance (recorded per the feature-provenance rule)
|
||||
@@ -25,8 +25,9 @@
|
||||
read-at-play clip lets the creator re-edit the file without a re-import). Decided
|
||||
with the creator: "never store the video in the layout." The built-in default is the
|
||||
one BLOB, stamped into the internal `Asset` table at startup.
|
||||
- **Creator rulings (asked once, held):** custom video + six-animation fallback
|
||||
(not either/or); ticker on top of the canvas (very top, full width); **no ducking** —
|
||||
- **Original creator rulings (asked once, held):** custom video + six-animation fallback
|
||||
(not either/or); ticker on top of the canvas (the placement was later corrected to
|
||||
inside Stream Alerts); **no ducking** —
|
||||
"don't lower my game audio for a tip" (the TASK 43 planner's auto-duck idea was
|
||||
rejected on the floor); a per-alert **Volume** slider instead.
|
||||
|
||||
@@ -309,3 +310,18 @@ Flash is on for half of each second; the panel shows and writes back the chosen
|
||||
risk by asserting all three alert fields together.
|
||||
|
||||
Full suite 339/339.
|
||||
|
||||
## Scene-local recent-alert ticker
|
||||
|
||||
The standalone `SourceType.Ticker` layer is addable once per scene. It shares the
|
||||
`Models/Ticker.cs` settings and `AlertTickerRenderer` with the AlertBox ticker, but
|
||||
each source has an independent `Ticker` object. The existing per-Source ticker DB
|
||||
columns persist both kinds without an alert-history table or schema migration. The
|
||||
standalone feed renders the active event followed by queued events; completed events
|
||||
are discarded. The AlertBox ticker remains limited to the active event and uses the
|
||||
active scene's AlertBox settings. Standalone tickers are ordinary dynamic sources in
|
||||
preview and output; the AlertBox ticker remains a positioned compositor overlay.
|
||||
|
||||
The integration guard in `AlertTickerPreviewDisplayTests` uses two scene tickers to
|
||||
prove settings are independent, then checks that the standalone feed differs from
|
||||
the AlertBox's active-only message while a second event is queued.
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# TASK 48 — Distribution & packaging: route decision, MSIX, signing
|
||||
|
||||
> ⛔ **THE ROUTE IN THIS FILE IS SUPERSEDED — 2026-10-31.** Distribution is **Steam exclusive**: a
|
||||
> free app on Steam with the paid edition as a Steam DLC; the monthly plan is sold off-Steam. There
|
||||
> is no direct download, no winget and no Store submission. Live policy:
|
||||
> ⛔ **THE ROUTE IN THIS FILE IS SUPERSEDED — 2026-10-31.** Distribution is **Steam exclusive**: one
|
||||
> paid app and its associated free Demo, using the same code. There is no subscription, GitHub
|
||||
> binary, direct download, winget or Store submission. Live policy:
|
||||
> [`docs/ai/commercial.md`](../docs/ai/commercial.md); mechanics: [`Distribution.md`](../Distribution.md).
|
||||
> The MSIX build steps below still work and are simply unused.
|
||||
|
||||
|
||||
@@ -1,34 +1,42 @@
|
||||
# TASK 53 — Output-format verification: Shorts / TikTok geometry (queued 2026-10-08)
|
||||
# TASK 53 — Output-format verification: vertical and landscape outputs (queued 2026-10-08)
|
||||
|
||||
> Catalog: [`TASKS.md`](../TASKS.md) — status and requirements live here.
|
||||
|
||||
**Why:** the output formats have never been tested end-to-end. The site claims "vertical 1080p60
|
||||
(9:16)" and Shorts-style streams, but nobody has verified that the geometry actually comes out
|
||||
correct for Shorts and TikTok — safe zones, caption clearance, and the text-safe inset all still
|
||||
need checking against a real capture.
|
||||
**Why:** the site claims vertical 1080p60 (9:16), but the full route from compositor crop through
|
||||
FFmpeg scaling to YouTube and the local recording needs verification.
|
||||
|
||||
**Goal:** prove the vertical/short-form path is geometrically correct, or fix the gaps and then
|
||||
prove it. Make the output formats a tested, locked contract instead of an assumption.
|
||||
**Goal:** prove each output tier has the expected dimensions and frame rate, and that vertical live
|
||||
and recorded outputs preserve the composed image. Creators control layer placement; this task does
|
||||
not promise platform-specific safe zones or keep content clear of every viewer overlay.
|
||||
|
||||
### Status: ✅ Complete — creator accepted 2026-11-04
|
||||
|
||||
**Slices (one integration test each, in order):**
|
||||
|
||||
1. ☐ **What we actually emit** — enumerate the real encoding configurations (widescreen 1080p60/
|
||||
1080p30/720p60/720p30 + vertical 9:16 1080p60) and pin them in one place (`FfmpegArgs`-adjacent),
|
||||
each with its own geometry contract test. This turns "formats exist" into "formats are defined".
|
||||
2. ☐ **9:16 geometry** — vertical render must be true 1080×1920 (or the GUP-negotiated size) with
|
||||
the composite laid out for a phone: viewer-safe top strip (TikTok/Shorts overlays live top-left
|
||||
and bottom), caption/text clearance, and the webcam not cropped off-centre. Test: composite a
|
||||
known layout, decode the output frame, assert dimensions + element placement vs the contract.
|
||||
3. ☐ **Live-shorts check on a real stream** — private Test Stream at 9:16, then pull the thumbnail/
|
||||
player quirk list from YouTube (Title visible, overlays); confirm nothing the streamer put on
|
||||
screen sits under YouTube's own chrome. This is the "never tested" item the creator flagged —
|
||||
test on a real vertical test stream and cite the observation.
|
||||
4. ☐ **Recorded .mp4 matches output** — the saved vertical .mp4 must carry the same 9:16 frame the
|
||||
stream showed, not a squeezed/wider master. Test: record vertical, decode first frames, assert.
|
||||
1. ✅ **What we actually emit** (2026-11-04; creator visually reviewed and accepted) — six
|
||||
creator-supplied recordings confirm the emitted configurations. All are H.264 with exact
|
||||
average frame rates and dimensions:
|
||||
|
||||
| Mode | File | Output |
|
||||
|---|---|---|
|
||||
| Landscape 1080p60 | `rec1-1080p60.mp4` | 1920×1080 @60 fps |
|
||||
| Landscape 1080p30 | `rec2-1080p30.mp4` | 1920×1080 @30 fps |
|
||||
| Landscape 720p60 | `rec3-720p60.mp4` | 1280×720 @60 fps |
|
||||
| Landscape 720p30 | `rec4-720p30.mp4` | 1280×720 @30 fps |
|
||||
| Vertical 1080p30 | `rec5-1080p30-vert.mp4` | 1080×1920 @30 fps |
|
||||
| Vertical 1080p60 | `rec6-1080p60-vert.mp4` | 1080×1920 @60 fps |
|
||||
2. ✅ **9:16 output resolution (resolution-only acceptance, 2026-11-04)** — creator-supplied
|
||||
`ty-20261104-0927-982-0000-2.mp4` reports H.264 1080×1920 at 60 fps. The creator accepted the
|
||||
resolution only; no platform-specific safe-zone promise is part of acceptance.
|
||||
3. ✅ **Vertical YouTube live-player check** (creator confirmed 2026-11-04) — the 9:16 stream
|
||||
displays vertically without distortion or letterboxing. Layer placement relative to YouTube
|
||||
controls remains creator-controlled; platform-specific safe zones are not a requirement.
|
||||
4. ✅ **Recorded .mp4 matches output** (creator accepted 2026-11-04) — the creator visually reviewed
|
||||
and accepted the six output recordings and the vertical YouTube live-player result.
|
||||
|
||||
**Design decisions:**
|
||||
|
||||
- Geometry is a contract with tests, not a hope. Derived ratios (stream vs composite vs canvas)
|
||||
get a derived-value test so one change can't silently wreck Shorts/TikTok output.
|
||||
- Keep widescreen untouched — only add what vertical needs (the existing test suite stays green
|
||||
for 16:9).
|
||||
for 16:9).
|
||||
|
||||
+222
-30
@@ -145,6 +145,168 @@
|
||||
<!-- Dark check box. The default WPF template is the light system one — it
|
||||
sticks out of the dark panels, so every check box carries this style
|
||||
(the guard test in ytLive.Tests/PropertiesStyleTests.cs enforces it). -->
|
||||
<!-- ═══ Property-panel typography (2026-11-01) ═══
|
||||
One hierarchy for every properties section, so no panel invents its own:
|
||||
PropertyHeader — the heading of a group (bright, 11px, semibold)
|
||||
PropertyFieldLabel — the label in a row with its control (dim, 10px, semibold)
|
||||
PropertyHint — the explanatory sentence under a control (dim, 10px, wrapped)
|
||||
ONE heading rank: a group is never sometimes a header and sometimes a sub-header, which
|
||||
is what put Ticker out of step with Geometry (creator ruling, 2026-11-01).
|
||||
Colour does the grouping: bright for the thing you are editing, dim for its labels and
|
||||
help. No new palette entries; these are the existing OnSurface / OnSurfaceVariant tokens. -->
|
||||
<Style x:Key="PropertyHeader" TargetType="TextBlock">
|
||||
<Setter Property="Foreground" Value="{StaticResource Brush.OnSurface}"/>
|
||||
<Setter Property="FontSize" Value="11"/>
|
||||
<Setter Property="FontWeight" Value="SemiBold"/>
|
||||
<Setter Property="Margin" Value="0,0,0,6"/>
|
||||
</Style>
|
||||
|
||||
<!-- A field label that sits in a row with its control (DockPanel.Dock="Left", fixed Width),
|
||||
or a value readout beside a slider. Vertically centred and marginless so the rows stay on
|
||||
a grid. -->
|
||||
<Style x:Key="PropertyFieldLabel" TargetType="TextBlock">
|
||||
<Setter Property="Foreground" Value="{StaticResource Brush.OnSurfaceVariant}"/>
|
||||
<Setter Property="FontSize" Value="10"/>
|
||||
<Setter Property="FontWeight" Value="SemiBold"/>
|
||||
<Setter Property="VerticalAlignment" Value="Center"/>
|
||||
</Style>
|
||||
|
||||
<Style x:Key="PropertyHint" TargetType="TextBlock">
|
||||
<Setter Property="Foreground" Value="{StaticResource Brush.OnSurfaceDisabled}"/>
|
||||
<Setter Property="FontSize" Value="10"/>
|
||||
<Setter Property="TextWrapping" Value="Wrap"/>
|
||||
<Setter Property="Margin" Value="10,0,0,6"/>
|
||||
</Style>
|
||||
|
||||
<Style x:Key="AppLogEntryText" TargetType="TextBlock">
|
||||
<Setter Property="Foreground" Value="{StaticResource Brush.OnSurfaceVariant}"/>
|
||||
<Setter Property="FontSize" Value="10"/>
|
||||
<Setter Property="TextWrapping" Value="Wrap"/>
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding Type}" Value="{x:Static Helpers:AppLogMessageType.Success}">
|
||||
<Setter Property="Foreground" Value="{StaticResource Brush.Positive}"/>
|
||||
</DataTrigger>
|
||||
<DataTrigger Binding="{Binding Type}" Value="{x:Static Helpers:AppLogMessageType.Warning}">
|
||||
<Setter Property="Foreground" Value="{StaticResource Brush.Secondary}"/>
|
||||
</DataTrigger>
|
||||
<DataTrigger Binding="{Binding Type}" Value="{x:Static Helpers:AppLogMessageType.Error}">
|
||||
<Setter Property="Foreground" Value="{StaticResource Brush.Error}"/>
|
||||
</DataTrigger>
|
||||
<MultiDataTrigger>
|
||||
<MultiDataTrigger.Conditions>
|
||||
<Condition Binding="{Binding Type}" Value="{x:Static Helpers:AppLogMessageType.Error}"/>
|
||||
<Condition Binding="{Binding ErrorClass}" Value="{x:Static Helpers:AppLogErrorClass.Class2}"/>
|
||||
</MultiDataTrigger.Conditions>
|
||||
<Setter Property="Foreground" Value="{StaticResource Brush.Attention}"/>
|
||||
</MultiDataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
|
||||
<!-- A property-panel action: bordered, quiet, 11px normal weight. The accent-filled YtButton
|
||||
(white semi-bold 13px) made secondary actions like "Reset to built-in" the loudest text in
|
||||
the panel, which is backwards. -->
|
||||
<Style x:Key="PropertyButton" TargetType="Button">
|
||||
<Setter Property="Background" Value="{StaticResource Brush.SurfaceContainerHigh}"/>
|
||||
<Setter Property="Foreground" Value="{StaticResource Brush.OnSurface}"/>
|
||||
<Setter Property="FontSize" Value="11"/>
|
||||
<Setter Property="FontWeight" Value="Normal"/>
|
||||
<Setter Property="Padding" Value="8,3"/>
|
||||
<Setter Property="Cursor" Value="Hand"/>
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="Button">
|
||||
<Border x:Name="Bd" Background="{TemplateBinding Background}"
|
||||
BorderBrush="{StaticResource Brush.Outline}" BorderThickness="1"
|
||||
CornerRadius="{StaticResource Radius.Sm}"
|
||||
Padding="{TemplateBinding Padding}">
|
||||
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="Bd" Property="Background" Value="{StaticResource Brush.SurfaceContainerHighest}"/>
|
||||
</Trigger>
|
||||
<Trigger Property="IsEnabled" Value="False">
|
||||
<Setter Property="Foreground" Value="{StaticResource Brush.OnSurfaceDisabled}"/>
|
||||
<Setter TargetName="Bd" Property="Opacity" Value="0.6"/>
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- Hairline between groups inside one properties category. -->
|
||||
<Style x:Key="PropertyDivider" TargetType="Border">
|
||||
<Setter Property="Height" Value="1"/>
|
||||
<Setter Property="Background" Value="{StaticResource Brush.OutlineVariant}"/>
|
||||
<Setter Property="Margin" Value="0,10,0,8"/>
|
||||
</Style>
|
||||
|
||||
<!-- Checkboxes are pill switches (creator ruling 2026-11-01): a 14px box in a properties panel
|
||||
reads as a form, and a switch reads as a setting. IsChecked semantics are untouched, so
|
||||
every existing binding still works. -->
|
||||
<Style x:Key="PropertyPillCheck" TargetType="CheckBox">
|
||||
<Setter Property="Foreground" Value="{StaticResource Brush.OnSurface}"/>
|
||||
<Setter Property="FontSize" Value="11"/>
|
||||
<Setter Property="Cursor" Value="Hand"/>
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="CheckBox">
|
||||
<Grid Background="Transparent">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<Border x:Name="Track" Grid.Column="0" Width="32" Height="16" CornerRadius="8"
|
||||
Background="{StaticResource Brush.SurfaceContainerHighest}"
|
||||
BorderBrush="{StaticResource Brush.Outline}" BorderThickness="1"
|
||||
VerticalAlignment="Center">
|
||||
<Ellipse x:Name="Thumb" Width="10" Height="10" Fill="{StaticResource Brush.OnSurfaceVariant}"
|
||||
HorizontalAlignment="Left" Margin="2,0,0,0">
|
||||
<Ellipse.RenderTransform>
|
||||
<TranslateTransform x:Name="ThumbShift" X="0"/>
|
||||
</Ellipse.RenderTransform>
|
||||
</Ellipse>
|
||||
</Border>
|
||||
<ContentPresenter Grid.Column="1" Margin="8,0,0,0" VerticalAlignment="Center"
|
||||
RecognizesAccessKey="True"/>
|
||||
</Grid>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsChecked" Value="True">
|
||||
<Setter TargetName="Track" Property="Background" Value="{StaticResource Brush.Tertiary}"/>
|
||||
<Setter TargetName="Track" Property="BorderBrush" Value="{StaticResource Brush.Tertiary}"/>
|
||||
<!-- 0.6 keeps the state readable without the neon fill the review flagged. -->
|
||||
<Setter TargetName="Track" Property="Opacity" Value="0.6"/>
|
||||
<Setter TargetName="Thumb" Property="Fill" Value="{StaticResource Brush.OnAccent}"/>
|
||||
<Trigger.EnterActions>
|
||||
<BeginStoryboard>
|
||||
<Storyboard>
|
||||
<DoubleAnimation Storyboard.TargetName="ThumbShift" Storyboard.TargetProperty="X"
|
||||
To="16" Duration="0:0:0.12"/>
|
||||
</Storyboard>
|
||||
</BeginStoryboard>
|
||||
</Trigger.EnterActions>
|
||||
<Trigger.ExitActions>
|
||||
<BeginStoryboard>
|
||||
<Storyboard>
|
||||
<DoubleAnimation Storyboard.TargetName="ThumbShift" Storyboard.TargetProperty="X"
|
||||
To="0" Duration="0:0:0.12"/>
|
||||
</Storyboard>
|
||||
</BeginStoryboard>
|
||||
</Trigger.ExitActions>
|
||||
</Trigger>
|
||||
<Trigger Property="IsEnabled" Value="False">
|
||||
<!-- Legible-but-dim, not invisible: the label stays readable and the
|
||||
track drops back so "disabled" reads as a state, not as a bug. -->
|
||||
<Setter Property="Foreground" Value="{StaticResource Brush.OnSurfaceVariant}"/>
|
||||
<Setter Property="Opacity" Value="0.55"/>
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style x:Key="YtCheckBox" TargetType="CheckBox">
|
||||
<Setter Property="Foreground" Value="{StaticResource Brush.OnSurface}"/>
|
||||
<Setter Property="FontSize" Value="11"/>
|
||||
@@ -191,6 +353,65 @@
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style x:Key="YtComboBoxItem" TargetType="ComboBoxItem">
|
||||
<Setter Property="Foreground" Value="{StaticResource Brush.OnSurface}"/>
|
||||
<Setter Property="FontSize" Value="13"/>
|
||||
<Setter Property="Padding" Value="10,7"/>
|
||||
<Setter Property="HorizontalContentAlignment" Value="Left"/>
|
||||
<Setter Property="Background" Value="Transparent"/>
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="ComboBoxItem">
|
||||
<Border x:Name="Bd" Background="{TemplateBinding Background}"
|
||||
CornerRadius="3" Margin="0,1" Padding="{TemplateBinding Padding}">
|
||||
<ContentPresenter/>
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsHighlighted" Value="True">
|
||||
<Setter TargetName="Bd" Property="Background" Value="{StaticResource Brush.ScrimStrong}"/>
|
||||
</Trigger>
|
||||
<Trigger Property="IsSelected" Value="True">
|
||||
<Setter TargetName="Bd" Property="Background" Value="{StaticResource Brush.SurfaceContainerHighest}"/>
|
||||
<Setter Property="Foreground" Value="White"/>
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<DataTemplate x:Key="YtQualityComboBoxItemTemplate">
|
||||
<TextBlock Text="{Binding Label}">
|
||||
<TextBlock.Style>
|
||||
<Style TargetType="TextBlock">
|
||||
<Style.Triggers>
|
||||
<MultiDataTrigger>
|
||||
<MultiDataTrigger.Conditions>
|
||||
<Condition Binding="{Binding RequiresPro}" Value="True"/>
|
||||
<Condition Binding="{Binding DataContext.HasProOutput, RelativeSource={RelativeSource AncestorType={x:Type ComboBox}}}" Value="False"/>
|
||||
</MultiDataTrigger.Conditions>
|
||||
<Setter Property="TextDecorations" Value="{x:Static TextDecorations.Strikethrough}"/>
|
||||
</MultiDataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</TextBlock.Style>
|
||||
</TextBlock>
|
||||
</DataTemplate>
|
||||
|
||||
<Style x:Key="YtQualityComboBoxItem" TargetType="ComboBoxItem" BasedOn="{StaticResource YtComboBoxItem}">
|
||||
<Style.Triggers>
|
||||
<MultiDataTrigger>
|
||||
<MultiDataTrigger.Conditions>
|
||||
<Condition Binding="{Binding RequiresPro}" Value="True"/>
|
||||
<Condition Binding="{Binding DataContext.HasProOutput, RelativeSource={RelativeSource AncestorType={x:Type ComboBox}}}" Value="False"/>
|
||||
</MultiDataTrigger.Conditions>
|
||||
<Setter Property="IsEnabled" Value="False"/>
|
||||
<Setter Property="Foreground" Value="{StaticResource Brush.OnSurface}"/>
|
||||
<Setter Property="Opacity" Value="1"/>
|
||||
</MultiDataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
|
||||
<Style x:Key="YtComboBox" TargetType="ComboBox">
|
||||
<Setter Property="Foreground" Value="{StaticResource Brush.OnSurface}"/>
|
||||
<Setter Property="FontSize" Value="13"/>
|
||||
@@ -202,36 +423,7 @@
|
||||
<Setter Property="HorizontalContentAlignment" Value="Left"/>
|
||||
<Setter Property="SnapsToDevicePixels" Value="True"/>
|
||||
<Setter Property="ScrollViewer.CanContentScroll" Value="True"/>
|
||||
<Setter Property="ItemContainerStyle">
|
||||
<Setter.Value>
|
||||
<Style TargetType="ComboBoxItem">
|
||||
<Setter Property="Foreground" Value="{StaticResource Brush.OnSurface}"/>
|
||||
<Setter Property="FontSize" Value="13"/>
|
||||
<Setter Property="Padding" Value="10,7"/>
|
||||
<Setter Property="HorizontalContentAlignment" Value="Left"/>
|
||||
<Setter Property="Background" Value="Transparent"/>
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="ComboBoxItem">
|
||||
<Border x:Name="Bd" Background="{TemplateBinding Background}"
|
||||
CornerRadius="3" Margin="0,1" Padding="{TemplateBinding Padding}">
|
||||
<ContentPresenter/>
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsHighlighted" Value="True">
|
||||
<Setter TargetName="Bd" Property="Background" Value="{StaticResource Brush.ScrimStrong}"/>
|
||||
</Trigger>
|
||||
<Trigger Property="IsSelected" Value="True">
|
||||
<Setter TargetName="Bd" Property="Background" Value="{StaticResource Brush.SurfaceContainerHighest}"/>
|
||||
<Setter Property="Foreground" Value="White"/>
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
<Setter Property="ItemContainerStyle" Value="{StaticResource YtComboBoxItem}"/>
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="ComboBox">
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ memory-map conventions.
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `Controls.xaml` | All custom control styles/templates: `YtButton`, `YtButtonSecondary`, `IconButton`, `YtTextBox`, `YtTextBoxCompact` (the dense variant the properties panel uses), `YtComboBox`, `YtCheckBox`, scrollbar/slider/tooltip/context-menu/menu-item/separator/list-item (implicit where app-wide), `YtListBoxItem`, `YtLabel`, `SectionHeader` |
|
||||
| `Controls.xaml` | All custom control styles/templates: `YtButton`, `YtButtonSecondary`, `IconButton`, `YtTextBox`, `YtTextBoxCompact` (the dense variant the properties panel uses), `YtComboBox`/`YtComboBoxItem` and `YtQualityComboBoxItem` (paid tiers dim but remain clickable), `YtCheckBox`, scrollbar/slider/tooltip/context-menu/menu-item/separator/list-item (implicit where app-wide), `YtListBoxItem`, `YtLabel`, `SectionHeader`, and `AppLogEntryText` (wraps and colors by message type and explicit error class) |
|
||||
|
||||
How it's wired:
|
||||
- Merged once in [`App.xaml`](../App.xaml) — app-wide. New windows get the theme
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using System.Linq;
|
||||
using ytLive.Models;
|
||||
using ytLive.Services;
|
||||
|
||||
namespace ytLive.ViewModels;
|
||||
@@ -44,8 +46,15 @@ public partial class MainViewModel
|
||||
/// Reuses one <see cref="System.Windows.Media.Imaging.WriteableBitmap"/> and
|
||||
/// overwrites its back buffer — a fresh bitmap every tick would churn the
|
||||
/// render cache for no reason.</summary>
|
||||
internal void PublishAlertTickerPreview(VideoFrame? frame)
|
||||
internal void PublishAlertTickerPreview(VideoFrame? incomingFrame)
|
||||
{
|
||||
var frame = incomingFrame;
|
||||
var previewScene = IsEditMode ? StagedScene : LiveScene ?? StagedScene;
|
||||
var alertBox = previewScene?.Elements.OfType<Source>()
|
||||
.FirstOrDefault(source => source.Type == SourceType.AlertBox && source.IsVisible);
|
||||
if (_alertLayer.IsPlaying)
|
||||
frame = _alertLayer.AlertBoxTickerFrame(alertBox);
|
||||
|
||||
if (frame == null)
|
||||
{
|
||||
if (_alertTickerBitmap != null)
|
||||
|
||||
@@ -44,6 +44,11 @@ public partial class MainViewModel : ViewModelBase
|
||||
=> StagedScene != null
|
||||
&& !Scenes.Any(s => s.Elements.OfType<Source>().Any(e => e.Type == SourceType.AlertBox));
|
||||
|
||||
/// <summary>A recent-alert ticker is an independent layer, one per scene.</summary>
|
||||
public bool CanAddTicker
|
||||
=> StagedScene != null
|
||||
&& !StagedScene.Elements.OfType<Source>().Any(e => e.Type == SourceType.Ticker);
|
||||
|
||||
/// <summary>Privacy blur is a Live-scene tool only (creator ruling 2026-11-01) — every other
|
||||
/// scene's art is authored, and there is no desktop there to hide.</summary>
|
||||
public bool CanAddBlur
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Linq;
|
||||
using System.Windows.Threading;
|
||||
using ytLive.Helpers;
|
||||
|
||||
namespace ytLive.ViewModels;
|
||||
|
||||
public partial class MainViewModel
|
||||
{
|
||||
private const int VisibleLogEntryLimit = 500;
|
||||
private readonly ObservableCollection<AppLogEntry> _currentLaunchLogEntries = new();
|
||||
private readonly ObservableCollection<AppLogEntry> _pinnedLogEntries = new();
|
||||
private DispatcherTimer? _currentLaunchLogTimer;
|
||||
private long _lastCurrentLaunchLogSequence;
|
||||
private bool _isLogsTabSelected;
|
||||
|
||||
public ObservableCollection<AppLogEntry> CurrentLaunchLogEntries => _currentLaunchLogEntries;
|
||||
public ObservableCollection<AppLogEntry> PinnedLogEntries => _pinnedLogEntries;
|
||||
public bool HasPinnedLogEntries => _pinnedLogEntries.Count > 0;
|
||||
public RelayCommand<AppLogEntry> AcknowledgeLogEntryCommand => new(AcknowledgeLogEntry);
|
||||
|
||||
public bool IsLiveChatTabSelected
|
||||
{
|
||||
get => !_isLogsTabSelected;
|
||||
set
|
||||
{
|
||||
if (value)
|
||||
SelectLogsTab(false);
|
||||
else if (!_isLogsTabSelected)
|
||||
OnPropertyChanged(nameof(IsLiveChatTabSelected));
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsLogsTabSelected
|
||||
{
|
||||
get => _isLogsTabSelected;
|
||||
set
|
||||
{
|
||||
if (value)
|
||||
SelectLogsTab(true);
|
||||
else if (_isLogsTabSelected)
|
||||
OnPropertyChanged(nameof(IsLogsTabSelected));
|
||||
}
|
||||
}
|
||||
|
||||
private void InitializeCurrentLaunchLogs()
|
||||
{
|
||||
var entries = AppLog.SnapshotCurrentLaunch()
|
||||
.OrderByDescending(entry => entry.Sequence)
|
||||
.Take(VisibleLogEntryLimit)
|
||||
.ToArray();
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
if (entry.ErrorClass.HasValue)
|
||||
InsertPinnedLogEntry(entry);
|
||||
else
|
||||
_currentLaunchLogEntries.Add(entry);
|
||||
}
|
||||
|
||||
_lastCurrentLaunchLogSequence = entries.FirstOrDefault()?.Sequence ?? 0;
|
||||
_currentLaunchLogTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(250) };
|
||||
_currentLaunchLogTimer.Tick += (_, _) => RefreshCurrentLaunchLogs();
|
||||
}
|
||||
|
||||
private void SelectLogsTab(bool selected)
|
||||
{
|
||||
if (_isLogsTabSelected == selected)
|
||||
{
|
||||
OnPropertyChanged(nameof(IsLiveChatTabSelected));
|
||||
OnPropertyChanged(nameof(IsLogsTabSelected));
|
||||
return;
|
||||
}
|
||||
|
||||
_isLogsTabSelected = selected;
|
||||
OnPropertyChanged(nameof(IsLiveChatTabSelected));
|
||||
OnPropertyChanged(nameof(IsLogsTabSelected));
|
||||
if (selected)
|
||||
{
|
||||
RefreshCurrentLaunchLogs();
|
||||
_currentLaunchLogTimer?.Start();
|
||||
}
|
||||
else
|
||||
{
|
||||
_currentLaunchLogTimer?.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
internal void RefreshCurrentLaunchLogs()
|
||||
{
|
||||
var newEntries = AppLog.SnapshotCurrentLaunch()
|
||||
.Where(entry => entry.Sequence > _lastCurrentLaunchLogSequence)
|
||||
.OrderBy(entry => entry.Sequence)
|
||||
.ToArray();
|
||||
|
||||
foreach (var entry in newEntries)
|
||||
{
|
||||
if (entry.ErrorClass.HasValue)
|
||||
InsertPinnedLogEntry(entry);
|
||||
else
|
||||
InsertChronologicalLogEntry(entry);
|
||||
_lastCurrentLaunchLogSequence = entry.Sequence;
|
||||
}
|
||||
}
|
||||
|
||||
private void InsertPinnedLogEntry(AppLogEntry entry)
|
||||
{
|
||||
var index = 0;
|
||||
while (index < _pinnedLogEntries.Count)
|
||||
{
|
||||
var current = _pinnedLogEntries[index];
|
||||
var currentClass = (int)current.ErrorClass!.Value;
|
||||
var entryClass = (int)entry.ErrorClass!.Value;
|
||||
if (currentClass < entryClass
|
||||
|| (currentClass == entryClass && current.Sequence > entry.Sequence))
|
||||
index++;
|
||||
else
|
||||
break;
|
||||
}
|
||||
|
||||
_pinnedLogEntries.Insert(index, entry);
|
||||
OnPropertyChanged(nameof(HasPinnedLogEntries));
|
||||
}
|
||||
|
||||
private void AcknowledgeLogEntry(AppLogEntry? entry)
|
||||
{
|
||||
if (entry?.ErrorClass == null || !_pinnedLogEntries.Remove(entry)) return;
|
||||
InsertChronologicalLogEntry(entry);
|
||||
OnPropertyChanged(nameof(HasPinnedLogEntries));
|
||||
}
|
||||
|
||||
private void InsertChronologicalLogEntry(AppLogEntry entry)
|
||||
{
|
||||
var index = 0;
|
||||
while (index < _currentLaunchLogEntries.Count
|
||||
&& _currentLaunchLogEntries[index].Sequence > entry.Sequence)
|
||||
index++;
|
||||
_currentLaunchLogEntries.Insert(index, entry);
|
||||
if (_currentLaunchLogEntries.Count > VisibleLogEntryLimit)
|
||||
_currentLaunchLogEntries.RemoveAt(_currentLaunchLogEntries.Count - 1);
|
||||
}
|
||||
|
||||
private void StopCurrentLaunchLogFeed()
|
||||
=> _currentLaunchLogTimer?.Stop();
|
||||
}
|
||||
@@ -1,47 +1,61 @@
|
||||
using ytLive.Helpers;
|
||||
using ytLive.Models;
|
||||
using ytLive.Services.Entitlement;
|
||||
|
||||
namespace ytLive.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// The entitlement surface: whether this session may emit output above the free budget.
|
||||
/// The app-wide Steam ownership flag and its output-profile consumer.
|
||||
///
|
||||
/// <para>Deliberately a separate partial from <c>License.cs</c>: that file owns *how* a
|
||||
/// licence is validated (Polar today), this one owns *what the answer means* for output.
|
||||
/// Steam's DLC check and Polar's lease both collapse into <see cref="IsPremium"/>; nothing
|
||||
/// downstream of <see cref="HasProOutput"/> cares which one proved it.</para>
|
||||
/// <para>Only the entitlement service decides whether the current Steam account owns the paid app.
|
||||
/// Development builds use a fake owned result; Release builds use Steam.</para>
|
||||
/// </summary>
|
||||
public partial class MainViewModel
|
||||
{
|
||||
/// <summary>
|
||||
/// The single input to <see cref="Models.OutputProfile.Resolve"/>.
|
||||
///
|
||||
/// <para><b>Fail closed:</b> an unresolved or unreadable licence is simply false, so a
|
||||
/// build with no entitlement emits the free profile. There is no "unknown" branch to get
|
||||
/// wrong.</para>
|
||||
///
|
||||
/// <para><b>Developer mode never ships:</b> <see cref="DeveloperMode"/> is only ever
|
||||
/// settable inside a development build (see <see cref="DevBuild"/>), so this expression
|
||||
/// reduces to <c>IsPremium</c> in a Release binary.</para>
|
||||
/// </summary>
|
||||
public bool HasProOutput => IsPremium || DeveloperMode || _entitlement.IsPro;
|
||||
private IEntitlement _entitlement = CreateEntitlement();
|
||||
private bool _isAppOwned;
|
||||
private string? _entitlementErrorMessage;
|
||||
|
||||
/// <summary>
|
||||
/// The entitlement rail for this session. Lifetime = Steam DLC; until the DLC app id exists it
|
||||
/// is <see cref="SteamEntitlement.NotConfigured"/>, which reads false (fail closed). Tests
|
||||
/// inject a fake through <see cref="EntitlementOverride"/>.
|
||||
/// </summary>
|
||||
private IEntitlement _entitlement = SteamEntitlement.NotConfigured();
|
||||
public bool IsAppOwned
|
||||
{
|
||||
get => _isAppOwned;
|
||||
private set
|
||||
{
|
||||
var changed = SetProperty(ref _isAppOwned, value);
|
||||
EntitlementErrorMessage = _entitlement.ErrorMessage;
|
||||
if (!changed) return;
|
||||
OnPropertyChanged(nameof(HasProOutput));
|
||||
if (!HasProOutput && _selectedQuality is { RequiresPro: true } && CanChangeQuality)
|
||||
SelectedQuality = QualityOptions.Single(option => option.Display == OutputProfile.FreeFallbackName);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Test seam: swap the rail without a Steam client. Internal — the test assembly is
|
||||
/// the only caller, and a public setter here would be a licence bypass surface.</summary>
|
||||
public string? EntitlementErrorMessage
|
||||
{
|
||||
get => _entitlementErrorMessage;
|
||||
private set => SetProperty(ref _entitlementErrorMessage, value);
|
||||
}
|
||||
|
||||
public bool HasProOutput => DeveloperMode || IsAppOwned;
|
||||
|
||||
private static IEntitlement CreateEntitlement()
|
||||
{
|
||||
#if DEBUG
|
||||
return new FakeEntitlement(true);
|
||||
#else
|
||||
return SteamEntitlement.NotConfigured();
|
||||
#endif
|
||||
}
|
||||
|
||||
/// <summary>Test seam: swap the ownership source without a Steam client.</summary>
|
||||
internal IEntitlement EntitlementOverride
|
||||
{
|
||||
get => _entitlement;
|
||||
set
|
||||
{
|
||||
_entitlement = value;
|
||||
OnPropertyChanged(nameof(HasProOutput));
|
||||
IsAppOwned = value.IsAppOwned;
|
||||
EntitlementErrorMessage = value.ErrorMessage;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -25,12 +25,11 @@ public partial class MainViewModel : ViewModelBase
|
||||
// integration test can assert they point at real destinations.
|
||||
public const string ChannelUrl = "https://youtube.com/@llamachileshop";
|
||||
public const string MastodonUrl = "https://mastodon.llamachile.tube/@gramps";
|
||||
public const string PremiumUrl = "https://buy.polar.sh/polar_cl_ueMy8AyAClO0o0yo19IroZwKTbD587gzUsjEW3bFapw";
|
||||
public const string PremiumUrl = "https://llamacasty.com";
|
||||
public const string CoffeeUrl = "https://buymeacoffee.com/llamachiley";
|
||||
public static string AppVersionLabel => $"Version {typeof(MainViewModel).Assembly.GetName().Version?.ToString(3)}";
|
||||
|
||||
/// <summary>The About hub's Unlock Premium button lights up only once the
|
||||
/// billing URL is set (Polar — polar.sh, pending TASK 10 implementation).</summary>
|
||||
/// <summary>The About hub's optional product-information link.</summary>
|
||||
public bool IsPremiumAvailable => PremiumUrl.Length > 0;
|
||||
|
||||
/// <summary>Full in-app licensing/legal text, loaded on demand from the
|
||||
@@ -176,6 +175,13 @@ public partial class MainViewModel : ViewModelBase
|
||||
IsLicensesOpen = false;
|
||||
}
|
||||
|
||||
private void ShowAbout()
|
||||
{
|
||||
IsLicensesOpen = false;
|
||||
IsLicenseEntryOpen = false;
|
||||
ShowOverlay(nameof(IsAboutOpen), "About");
|
||||
}
|
||||
|
||||
/// <summary>Shows the in-app licensing panel, loading the full texts from
|
||||
/// the shipped THIRD-PARTY-NOTICES.txt on first open (never the OS viewer).
|
||||
/// Falls back to a "not found" note if the file is missing so the panel
|
||||
|
||||
@@ -29,7 +29,7 @@ public partial class MainViewModel : ViewModelBase
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLog.Write(ex, "Recording: failed to prepare output path");
|
||||
AppLog.Write(ex, "Recording: failed to prepare output path", AppLogErrorClass.Class2);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -45,7 +45,8 @@ public partial class MainViewModel : ViewModelBase
|
||||
|
||||
if (!File.Exists(startPath))
|
||||
{
|
||||
AppLog.Write($"Recording: output not found for rename: {startPath}");
|
||||
AppLog.Write($"Recording: output not found for rename: {startPath}",
|
||||
AppLogMessageType.Error, AppLogErrorClass.Class2);
|
||||
return;
|
||||
}
|
||||
_recordLength = _liveElapsed;
|
||||
@@ -64,7 +65,7 @@ public partial class MainViewModel : ViewModelBase
|
||||
switch (result.Outcome)
|
||||
{
|
||||
case RecordingSaveOutcome.Saved:
|
||||
AppLog.Write($"Recording saved: {result.FinalPath}");
|
||||
AppLog.WriteSuccess($"Recording saved: {result.FinalPath}");
|
||||
_notifications.Info("Recording saved",
|
||||
Path.GetFileName(result.FinalPath!) +
|
||||
" — you can rename it in your videos folder at any time.");
|
||||
@@ -77,7 +78,8 @@ public partial class MainViewModel : ViewModelBase
|
||||
case RecordingSaveOutcome.DiscardFailed:
|
||||
// A "discarded" recording still sitting on disk is worse than no
|
||||
// report at all — name the file and the folder so it can be found.
|
||||
AppLog.Write($"Recording discard failed: {startPath}");
|
||||
AppLog.Write($"Recording discard failed: {startPath}",
|
||||
AppLogMessageType.Error, AppLogErrorClass.Class2);
|
||||
_notifications.Error("Recording discarded?",
|
||||
$"Could not delete {Path.GetFileName(startPath)} — it is still in {dir}.");
|
||||
break;
|
||||
@@ -85,7 +87,7 @@ public partial class MainViewModel : ViewModelBase
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLog.Write(ex, "Recording: rename-on-stop failed");
|
||||
AppLog.Write(ex, "Recording: rename-on-stop failed", AppLogErrorClass.Class2);
|
||||
_notifications.Error("Recording saved",
|
||||
$"The recording was written but couldn't be renamed: {Path.GetFileName(startPath)}");
|
||||
}
|
||||
|
||||
@@ -55,6 +55,7 @@ public partial class MainViewModel
|
||||
OnPropertyChanged(nameof(CanChangeBackground));
|
||||
OnPropertyChanged(nameof(CanAddWebcam));
|
||||
OnPropertyChanged(nameof(CanAddYouTubeChat));
|
||||
OnPropertyChanged(nameof(CanAddTicker));
|
||||
OnPropertyChanged(nameof(CanShowWebcamInStagedScene));
|
||||
OnPropertyChanged(nameof(WebcamAddToolTip));
|
||||
OnPropertyChanged(nameof(IsCountdownAvailable));
|
||||
@@ -280,6 +281,7 @@ public partial class MainViewModel
|
||||
UnwireScene(scene);
|
||||
OnPropertyChanged(nameof(MissingScenes));
|
||||
OnPropertyChanged(nameof(ShowAddScene));
|
||||
RetainTextOverlaySubscriptions();
|
||||
ScheduleSave();
|
||||
}
|
||||
|
||||
@@ -305,19 +307,40 @@ public partial class MainViewModel
|
||||
|
||||
private void OnElementsChanged(object? sender, NotifyCollectionChangedEventArgs e)
|
||||
{
|
||||
if (e.NewItems != null)
|
||||
foreach (SceneElement element in e.NewItems)
|
||||
element.PropertyChanged += OnElementPropertyChanged;
|
||||
if (e.OldItems != null)
|
||||
foreach (SceneElement element in e.OldItems)
|
||||
element.PropertyChanged -= OnElementPropertyChanged;
|
||||
if (e.Action != NotifyCollectionChangedAction.Move)
|
||||
{
|
||||
if (e.NewItems != null)
|
||||
foreach (SceneElement element in e.NewItems)
|
||||
{
|
||||
if (element is Source { Type: SourceType.TextOverlay } text)
|
||||
{
|
||||
WireTextOverlaySource(text);
|
||||
UpdateTextOverlayPreview(text);
|
||||
}
|
||||
else
|
||||
element.PropertyChanged += OnElementPropertyChanged;
|
||||
}
|
||||
if (e.OldItems != null)
|
||||
foreach (SceneElement element in e.OldItems)
|
||||
{
|
||||
if (element is Source { Type: SourceType.TextOverlay } text)
|
||||
RemoveTextOverlayPreview(text);
|
||||
else
|
||||
element.PropertyChanged -= OnElementPropertyChanged;
|
||||
}
|
||||
}
|
||||
RetainTextOverlaySubscriptions();
|
||||
OnPropertyChanged(nameof(CanAddWebcam));
|
||||
OnPropertyChanged(nameof(CanAddYouTubeChat));
|
||||
OnPropertyChanged(nameof(CanAddTicker));
|
||||
ScheduleSave();
|
||||
}
|
||||
|
||||
private void OnElementPropertyChanged(object? sender, PropertyChangedEventArgs e)
|
||||
{
|
||||
if (sender is Source { Type: SourceType.TextOverlay } text)
|
||||
OnTextOverlayPropertyChanged(text, e.PropertyName);
|
||||
|
||||
if (sender is WebcamSceneConfig && e.PropertyName == nameof(SceneElement.IsVisible))
|
||||
OnPropertyChanged(nameof(CanShowWebcamInStagedScene));
|
||||
|
||||
@@ -328,7 +351,11 @@ public partial class MainViewModel
|
||||
var bakedSurface = e.PropertyName is nameof(SceneElement.X) or nameof(SceneElement.Y)
|
||||
or nameof(SceneElement.Width) or nameof(SceneElement.Height)
|
||||
or nameof(SceneElement.Opacity) or nameof(SceneElement.IsVisible)
|
||||
or nameof(SceneElement.ClipShape) or nameof(SceneElement.IsMirrored);
|
||||
or nameof(SceneElement.ClipShape) or nameof(SceneElement.IsMirrored)
|
||||
or nameof(Source.TextContent) or nameof(Source.TextFontFamily)
|
||||
or nameof(Source.TextFontSize) or nameof(Source.TextColor) or nameof(Source.TextBold)
|
||||
or nameof(Source.TextItalic) or nameof(Source.TextUnderline) or nameof(Source.TextAlignment)
|
||||
or nameof(Source.TextVerticalAlignment);
|
||||
if (bakedSurface)
|
||||
{
|
||||
var containingScene = Scenes.FirstOrDefault(s => s.Elements.Contains(el));
|
||||
@@ -356,4 +383,4 @@ public partial class MainViewModel
|
||||
Scenes.Add(scene);
|
||||
StagedScene = scene;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ public partial class MainViewModel
|
||||
SourceType.WebSource => "Web Resource",
|
||||
SourceType.ChatBox => "YouTube Chat",
|
||||
SourceType.AlertBox => "Stream Alerts",
|
||||
SourceType.Ticker => "Ticker",
|
||||
SourceType.Scoreboard => "Scoreboard",
|
||||
_ => "Layer",
|
||||
};
|
||||
@@ -45,11 +46,12 @@ public partial class MainViewModel
|
||||
// The (+) menu only adds regular layers. The Background is a locked
|
||||
// per-screen layer managed by EnsureBackground/NormalizeBackgrounds —
|
||||
// never user-created, never deleted. YouTube Chat and Stream Alerts
|
||||
// are one-per-layout.
|
||||
// are one-per-layout; a ticker is one-per-scene.
|
||||
if (sourceType is SourceType.DisplayCapture or SourceType.WindowCapture or SourceType.Background)
|
||||
return;
|
||||
if ((sourceType == SourceType.ChatBox && !CanAddYouTubeChat)
|
||||
|| (sourceType == SourceType.AlertBox && !CanAddAlerts))
|
||||
|| (sourceType == SourceType.AlertBox && !CanAddAlerts)
|
||||
|| (sourceType == SourceType.Ticker && !CanAddTicker))
|
||||
return;
|
||||
|
||||
// Web resources use "Web Resource-N" naming (0-indexed per scene)
|
||||
@@ -60,24 +62,30 @@ public partial class MainViewModel
|
||||
? $"{baseName}-{webCount}"
|
||||
: NextSourceName(scene, baseName),
|
||||
Type = sourceType,
|
||||
X = sourceType == SourceType.ChatBox ? 1400
|
||||
X = sourceType == SourceType.TextOverlay ? 160
|
||||
: sourceType == SourceType.ChatBox ? 1400
|
||||
: sourceType == SourceType.AlertBox ? 620
|
||||
: sourceType == SourceType.Blur ? 700 : 0,
|
||||
// The scoreboard is a full-width bar: its Y comes from the edge it owns (resolved
|
||||
// against the social bar at render time), never from a drag.
|
||||
Y = sourceType == SourceType.Scoreboard ? 1024
|
||||
Y = sourceType == SourceType.TextOverlay ? 120
|
||||
: sourceType == SourceType.Scoreboard ? 1024
|
||||
: sourceType == SourceType.ChatBox ? 20
|
||||
: sourceType == SourceType.AlertBox ? 430
|
||||
: sourceType == SourceType.Blur ? 600 : 0,
|
||||
Width = sourceType == SourceType.WebSource ? 640
|
||||
Width = sourceType == SourceType.TextOverlay ? 900
|
||||
: sourceType == SourceType.WebSource ? 640
|
||||
: sourceType == SourceType.ChatBox ? 500
|
||||
: sourceType == SourceType.AlertBox ? 680
|
||||
: sourceType == SourceType.Scoreboard ? 1920
|
||||
: sourceType == SourceType.Ticker ? 1920
|
||||
: sourceType == SourceType.Blur ? 240 : 0,
|
||||
Height = sourceType == SourceType.WebSource ? 480
|
||||
Height = sourceType == SourceType.TextOverlay ? 140
|
||||
: sourceType == SourceType.WebSource ? 480
|
||||
: sourceType == SourceType.ChatBox ? 648
|
||||
: sourceType == SourceType.AlertBox ? 200
|
||||
: sourceType == SourceType.Scoreboard ? 56
|
||||
: sourceType == SourceType.Ticker ? 48
|
||||
: sourceType == SourceType.Blur ? 160 : 0,
|
||||
};
|
||||
|
||||
|
||||
@@ -159,7 +159,8 @@ public partial class MainViewModel : ViewModelBase
|
||||
var stream = await _youtubeStream.GetOrCreateReusableStreamAsync();
|
||||
if (stream == null)
|
||||
{
|
||||
AppLog.Write("Reusable stream unavailable; check the OAuth session");
|
||||
AppLog.Write("Reusable stream unavailable; check the OAuth session",
|
||||
AppLogMessageType.Error, AppLogErrorClass.Class2);
|
||||
_notifications.Error("Couldn't go live",
|
||||
"The reusable stream is unavailable — check the YouTube sign-in and try again.");
|
||||
StopStream(); // full rollback — audio loop + recording + pills, no Error-status zombie
|
||||
@@ -174,12 +175,13 @@ public partial class MainViewModel : ViewModelBase
|
||||
_broadcastSeenLive = false;
|
||||
if (_currentBroadcastId == null)
|
||||
{
|
||||
AppLog.Write("Broadcast creation failed; check the OAuth session");
|
||||
AppLog.Write("Broadcast creation failed; check the OAuth session",
|
||||
AppLogMessageType.Error, AppLogErrorClass.Class2);
|
||||
_notifications.Error("Couldn't go live", "YouTube rejected the broadcast — check the sign-in and try again.");
|
||||
StopStream(); // full rollback
|
||||
return;
|
||||
}
|
||||
AppLog.Write($"Broadcast created: {_currentBroadcastId}");
|
||||
AppLog.WriteSuccess($"Broadcast created: {_currentBroadcastId}");
|
||||
OnPropertyChanged(nameof(CurrentBroadcastId));
|
||||
|
||||
// ⛔ THE BINDING — the call whose absence is why nothing ever went live
|
||||
@@ -198,7 +200,8 @@ public partial class MainViewModel : ViewModelBase
|
||||
var bindError = await _youtubeStream.BindBroadcastAsync(_currentBroadcastId, stream.Id);
|
||||
if (bindError != null)
|
||||
{
|
||||
AppLog.Write($"Stream binding failed: {bindError}");
|
||||
AppLog.Write($"Stream binding failed: {bindError}",
|
||||
AppLogMessageType.Error, AppLogErrorClass.Class2);
|
||||
// Show the real reason. "Check the sign-in" would be wrong for five of the
|
||||
// six documented bind failures, and would hide our own ordering bug behind
|
||||
// a plausible excuse — see BindFailureMessage.
|
||||
@@ -216,7 +219,8 @@ public partial class MainViewModel : ViewModelBase
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLog.Write($"Go-live preparation failed: {ex.Message}");
|
||||
AppLog.Write($"Go-live preparation failed: {ex.Message}",
|
||||
AppLogMessageType.Error, AppLogErrorClass.Class2);
|
||||
_notifications.Error("Couldn't go live", ex.Message);
|
||||
StopStream(); // full rollback — a created broadcast (if any) gets the close-out
|
||||
return;
|
||||
@@ -230,6 +234,12 @@ public partial class MainViewModel : ViewModelBase
|
||||
EditingActive = false;
|
||||
await _framePump.StartAsync(); // never throws; failures log + surface via Failed
|
||||
|
||||
// Check promptly until YouTube confirms the broadcast is live. Health
|
||||
// polling returns to its normal interval after that confirmation.
|
||||
_healthPollTimer.Interval = TimeSpan.FromSeconds(2);
|
||||
_healthPollTimer.Start();
|
||||
_ = PollHealthAsync();
|
||||
|
||||
string? liveChatId = null;
|
||||
try
|
||||
{
|
||||
@@ -242,12 +252,14 @@ public partial class MainViewModel : ViewModelBase
|
||||
}
|
||||
else
|
||||
{
|
||||
AppLog.Write("Could not retrieve liveChatId; chat polling disabled");
|
||||
AppLog.Write("Could not retrieve liveChatId; chat polling disabled",
|
||||
AppLogMessageType.Error, AppLogErrorClass.Class2);
|
||||
}
|
||||
}
|
||||
catch (Exception chatEx)
|
||||
{
|
||||
AppLog.Write($"Chat start failed (non-fatal): {chatEx.Message}");
|
||||
AppLog.Write($"Chat start failed (non-fatal): {chatEx.Message}",
|
||||
AppLogMessageType.Error, AppLogErrorClass.Class2);
|
||||
}
|
||||
|
||||
// TASK 41: a test broadcast hands its session to the Test drawer once
|
||||
@@ -258,10 +270,6 @@ public partial class MainViewModel : ViewModelBase
|
||||
AppLog.Write($"Test session dock open for {_currentBroadcastId}");
|
||||
}
|
||||
|
||||
// Health polling (TASK 5 item 3): poll immediately, then every 30s while
|
||||
// live. Report-by-exception — a banner appears only on warning/error.
|
||||
_healthPollTimer.Start();
|
||||
_ = PollHealthAsync();
|
||||
}
|
||||
|
||||
private void OnHealthPollTick(object? sender, EventArgs e) => _ = PollHealthAsync();
|
||||
@@ -285,25 +293,29 @@ public partial class MainViewModel : ViewModelBase
|
||||
var health = await _youtubeStream.GetStreamHealthAsync(_reusableStream.Id);
|
||||
if (health == null)
|
||||
{
|
||||
AppLog.Write("Stream health poll failed; check the OAuth session");
|
||||
return;
|
||||
AppLog.Write("Stream health poll failed; check the OAuth session",
|
||||
AppLogMessageType.Error, AppLogErrorClass.Class2);
|
||||
}
|
||||
else
|
||||
{
|
||||
CurrentHealth.HealthStatus = health.HealthStatus;
|
||||
CurrentHealth.HealthMessage = health.HealthStatus;
|
||||
CurrentHealth.ConfigurationIssues.Clear();
|
||||
CurrentHealth.ConfigurationIssues.AddRange(health.ConfigurationIssues);
|
||||
OnPropertyChanged(nameof(CurrentHealth));
|
||||
ApplyHealthIssue(health.ConfigurationIssues);
|
||||
}
|
||||
CurrentHealth.HealthStatus = health.HealthStatus;
|
||||
CurrentHealth.HealthMessage = health.HealthStatus;
|
||||
CurrentHealth.ConfigurationIssues.Clear();
|
||||
CurrentHealth.ConfigurationIssues.AddRange(health.ConfigurationIssues);
|
||||
OnPropertyChanged(nameof(CurrentHealth));
|
||||
ApplyHealthIssue(health.ConfigurationIssues);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(_currentBroadcastId) && !_broadcastSeenLive)
|
||||
{
|
||||
var lifeCycle = await _youtubeStream.LogBroadcastDiagnosticsAsync(_currentBroadcastId, _reusableStream.Id);
|
||||
if (lifeCycle is "live" or "liveStarting") _broadcastSeenLive = true;
|
||||
ApplyBroadcastLifecycleStatus(lifeCycle);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLog.Write($"Stream health poll failed: {ex.Message}");
|
||||
AppLog.Write($"Stream health poll failed: {ex.Message}",
|
||||
AppLogMessageType.Error, AppLogErrorClass.Class2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -375,7 +387,8 @@ public partial class MainViewModel : ViewModelBase
|
||||
{
|
||||
var closeOutError = await _youtubeStream.EndBroadcastAsync(endedBroadcastId);
|
||||
if (closeOutError != null)
|
||||
AppLog.Write($"End close-out: {closeOutError} — enableAutoStop will finish the broadcast");
|
||||
AppLog.Write($"End close-out: {closeOutError} — enableAutoStop will finish the broadcast",
|
||||
AppLogMessageType.Error, AppLogErrorClass.Class2);
|
||||
}
|
||||
|
||||
_currentBroadcastId = null;
|
||||
@@ -413,6 +426,7 @@ public partial class MainViewModel : ViewModelBase
|
||||
{
|
||||
var url = _rtmpUrlProvider();
|
||||
var quality = OutputProfile.Resolve(SelectedQuality, QualityOptions, HasProOutput);
|
||||
var compositor = BuildCompositorOptions();
|
||||
return new EncoderOptions
|
||||
{
|
||||
RtmpUrl = url ?? string.Empty,
|
||||
@@ -421,6 +435,8 @@ public partial class MainViewModel : ViewModelBase
|
||||
RecordPath = _activeRecordPath,
|
||||
Width = quality.Width,
|
||||
Height = quality.Height,
|
||||
InputWidth = compositor.OutputWidth,
|
||||
InputHeight = compositor.OutputHeight,
|
||||
Fps = quality.Fps,
|
||||
BitrateKbps = (int)Math.Round(quality.Bitrate * 1000),
|
||||
};
|
||||
@@ -435,7 +451,7 @@ public partial class MainViewModel : ViewModelBase
|
||||
// with IsRecording=true and no encoder (first real launch proved it). The
|
||||
// failure now runs the NORMAL stop path on the UI thread (pills off, audio
|
||||
// loop closed, recording finalized, close-out if live), then explains itself.
|
||||
AppLog.Write($"Frame pump failed: {message}");
|
||||
AppLog.Write($"Frame pump failed: {message}", AppLogMessageType.Error, AppLogErrorClass.Class2);
|
||||
void Handle()
|
||||
{
|
||||
var live = IsLive;
|
||||
@@ -493,17 +509,23 @@ public partial class MainViewModel : ViewModelBase
|
||||
private void UpdateLiveVisuals()
|
||||
{
|
||||
var live = IsLive;
|
||||
// A live TEST broadcast wears gold, not the live red — the TEST badge +
|
||||
// gold are the "this is a rehearsal" cue (TASK 41).
|
||||
// TEST keeps its gold identity. For a normal ON-AIR session, green means
|
||||
// YouTube has confirmed live; before then it keeps the existing red color.
|
||||
TopBarBackground = live
|
||||
? ThemeBrush.Hex(IsTestStream ? "Brush.TestContainer" : "Brush.Primary")
|
||||
? IsTestStream
|
||||
? ThemeBrush.Hex("Brush.TestContainer")
|
||||
: ThemeBrush.Hex(_youtubeBroadcastLive ? "Brush.Positive" : "Brush.Primary")
|
||||
: ThemeBrush.Hex("Brush.SurfaceContainer");
|
||||
PreviewGlowBrush = live
|
||||
? ThemeBrush.Hex(IsTestStream ? "Brush.TestContainer" : "Brush.Primary")
|
||||
: "Transparent";
|
||||
PreviewGlowThickness = live ? new Thickness(3) : new Thickness(0);
|
||||
|
||||
if (!live) _healthPollTimer.Stop();
|
||||
if (!live)
|
||||
{
|
||||
_healthPollTimer.Stop();
|
||||
_healthPollTimer.Interval = TimeSpan.FromSeconds(30);
|
||||
}
|
||||
|
||||
// NOTE: the branding credit is deliberately NOT started/stopped here.
|
||||
// CREATOR RULING 2026-09-26 — it must appear in EVERY scene and in
|
||||
@@ -567,7 +589,8 @@ public partial class MainViewModel : ViewModelBase
|
||||
_notifications.Info("Broadcast deleted", "The broadcast was removed from YouTube.");
|
||||
break;
|
||||
case PrivateBroadcastOutcome.DeleteFailed:
|
||||
AppLog.Write($"Private broadcast delete failed: {broadcastId}: {error}");
|
||||
AppLog.Write($"Private broadcast delete failed: {broadcastId}: {error}",
|
||||
AppLogMessageType.Error, AppLogErrorClass.Class2);
|
||||
_notifications.Error("Couldn't delete the broadcast",
|
||||
$"{error}. It's still on YouTube as a private video — you can delete it in YouTube Studio.");
|
||||
break;
|
||||
|
||||
@@ -24,6 +24,7 @@ public partial class MainViewModel : ViewModelBase
|
||||
/// <summary>Latches once the broadcast reaches live so the per-poll broadcast
|
||||
/// diagnostic stops logging (2026-10-04). Reset at broadcast creation.</summary>
|
||||
private bool _broadcastSeenLive;
|
||||
private bool _youtubeBroadcastLive;
|
||||
private string? _reusableStreamUrl;
|
||||
private ReusableStream? _reusableStream;
|
||||
private string? _healthIssueBanner;
|
||||
@@ -63,7 +64,9 @@ public partial class MainViewModel : ViewModelBase
|
||||
{
|
||||
if (SetProperty(ref _streamStatus, value))
|
||||
{
|
||||
_youtubeBroadcastLive = false;
|
||||
OnPropertyChanged(nameof(IsOffline));
|
||||
OnPropertyChanged(nameof(CanChangeQuality));
|
||||
OnPropertyChanged(nameof(IsLive));
|
||||
OnPropertyChanged(nameof(IsEditMode));
|
||||
OnPropertyChanged(nameof(RecDotBrush));
|
||||
@@ -89,6 +92,7 @@ public partial class MainViewModel : ViewModelBase
|
||||
|
||||
public bool IsOffline => StreamStatus == StreamStatus.Offline;
|
||||
public bool IsLive => StreamStatus == StreamStatus.Streaming;
|
||||
public bool CanChangeQuality => IsOffline && !IsRecording;
|
||||
public bool IsLivePrivate => IsLive && string.Equals(StreamVisibility, "Private", StringComparison.OrdinalIgnoreCase);
|
||||
public string RecDotBrush => !IsRecording
|
||||
? ThemeBrush.Hex("Brush.OnSurfaceDisabled")
|
||||
@@ -149,6 +153,7 @@ public partial class MainViewModel : ViewModelBase
|
||||
internal set
|
||||
{
|
||||
if (!SetProperty(ref _isRecording, value)) return;
|
||||
OnPropertyChanged(nameof(CanChangeQuality));
|
||||
OnPropertyChanged(nameof(RecDotBrush));
|
||||
OnPropertyChanged(nameof(RecTextBrush));
|
||||
OnPropertyChanged(nameof(RecDotOpacity));
|
||||
@@ -373,6 +378,22 @@ public partial class MainViewModel : ViewModelBase
|
||||
set => SetProperty(ref _topBarBackground, value);
|
||||
}
|
||||
|
||||
internal void ApplyBroadcastLifecycleStatus(string? lifeCycleStatus)
|
||||
{
|
||||
if (!IsLive) return;
|
||||
if (!string.Equals(lifeCycleStatus, "live", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (!_broadcastSeenLive)
|
||||
_healthPollTimer.Interval = TimeSpan.FromSeconds(2);
|
||||
return;
|
||||
}
|
||||
|
||||
_broadcastSeenLive = true;
|
||||
_youtubeBroadcastLive = true;
|
||||
_healthPollTimer.Interval = TimeSpan.FromSeconds(30);
|
||||
UpdateLiveVisuals();
|
||||
}
|
||||
|
||||
public string PreviewGlowBrush
|
||||
{
|
||||
get => _previewGlowBrush;
|
||||
@@ -432,6 +453,18 @@ public partial class MainViewModel : ViewModelBase
|
||||
get => _selectedQuality;
|
||||
set
|
||||
{
|
||||
if (!CanChangeQuality)
|
||||
{
|
||||
OnPropertyChanged(nameof(SelectedQuality));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!HasProOutput && value.RequiresPro)
|
||||
{
|
||||
OnPropertyChanged(nameof(SelectedQuality));
|
||||
return;
|
||||
}
|
||||
|
||||
if (SetProperty(ref _selectedQuality, value))
|
||||
ApplyStreamQuality(value);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Windows.Media;
|
||||
using ytLive.Models;
|
||||
using ytLive.Services;
|
||||
|
||||
namespace ytLive.ViewModels;
|
||||
|
||||
public partial class MainViewModel
|
||||
{
|
||||
private static readonly HashSet<string> ExcludedLegacyFontFamilies = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"Bookshelf Symbol 7", "Marlett", "Symbol", "Webdings", "Wingdings", "Wingdings 2", "Wingdings 3",
|
||||
};
|
||||
private static readonly string[] SystemTextFontFamilies = Fonts.SystemFontFamilies
|
||||
.Select(font => font.Source)
|
||||
.Where(font => !ExcludedLegacyFontFamilies.Contains(font))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(name => name, StringComparer.CurrentCultureIgnoreCase)
|
||||
.ToArray();
|
||||
private static readonly double[] CommonTextFontSizes =
|
||||
{ 12, 16, 20, 24, 32, 36, 48, 60, 72, 96, 120, 144, 180, 240, 300 };
|
||||
private static readonly TextLayerAlignment[] HorizontalAlignmentOptions =
|
||||
Enum.GetValues<TextLayerAlignment>();
|
||||
private static readonly TextLayerVerticalAlignment[] VerticalAlignmentOptions =
|
||||
Enum.GetValues<TextLayerVerticalAlignment>();
|
||||
|
||||
private readonly TextOverlayRenderer _textOverlayRenderer = new();
|
||||
private readonly HashSet<Source> _wiredTextOverlays = new();
|
||||
|
||||
private string _selectedTextFontType = "Sans Serif";
|
||||
|
||||
public IReadOnlyList<string> TextFontTypeOptions { get; } =
|
||||
new[] { "Sans Serif", "Serif", "Monospace", "Display", "Handwriting", "Symbols", "International", "Other" };
|
||||
|
||||
public string SelectedTextFontType
|
||||
{
|
||||
get => _selectedTextFontType;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _selectedTextFontType, value))
|
||||
{
|
||||
OnPropertyChanged(nameof(TextFontFamilies));
|
||||
OnPropertyChanged(nameof(SelectedTextFontFamily));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<string> TextFontFamilies
|
||||
{
|
||||
get
|
||||
{
|
||||
var selected = SelectedElement is Source { Type: SourceType.TextOverlay } source
|
||||
? source.TextFontFamily
|
||||
: null;
|
||||
var options = SystemTextFontFamilies
|
||||
.Where(font => FontTypeFor(font) == SelectedTextFontType)
|
||||
.ToList();
|
||||
if (selected != null && FontTypeFor(selected) == SelectedTextFontType
|
||||
&& !options.Contains(selected, StringComparer.Ordinal))
|
||||
options.Add(selected);
|
||||
return options;
|
||||
}
|
||||
}
|
||||
|
||||
public string? SelectedTextFontFamily
|
||||
{
|
||||
get => (SelectedElement as Source)?.TextFontFamily;
|
||||
set
|
||||
{
|
||||
if (SelectedElement is Source { Type: SourceType.TextOverlay } source
|
||||
&& !string.IsNullOrWhiteSpace(value))
|
||||
source.TextFontFamily = value;
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<double> TextFontSizeOptions
|
||||
{
|
||||
get
|
||||
{
|
||||
var selected = (SelectedElement as Source)?.TextFontSize;
|
||||
return selected is { } size && !CommonTextFontSizes.Contains(size)
|
||||
? CommonTextFontSizes.Append(size).OrderBy(value => value).ToArray()
|
||||
: CommonTextFontSizes;
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<TextLayerAlignment> TextHorizontalAlignmentOptions =>
|
||||
HorizontalAlignmentOptions;
|
||||
|
||||
public IReadOnlyList<TextLayerVerticalAlignment> TextVerticalAlignmentOptions =>
|
||||
VerticalAlignmentOptions;
|
||||
|
||||
private void SyncTextFontCategory()
|
||||
{
|
||||
SelectedTextFontType = SelectedElement is Source { Type: SourceType.TextOverlay } source
|
||||
? FontTypeFor(source.TextFontFamily)
|
||||
: "Sans Serif";
|
||||
OnPropertyChanged(nameof(SelectedTextFontFamily));
|
||||
OnPropertyChanged(nameof(TextFontSizeOptions));
|
||||
}
|
||||
|
||||
private static string FontTypeFor(string font)
|
||||
{
|
||||
if (font.Contains("Mono", StringComparison.OrdinalIgnoreCase)
|
||||
|| font.Contains("Consolas", StringComparison.OrdinalIgnoreCase)
|
||||
|| font.Contains("Courier", StringComparison.OrdinalIgnoreCase)
|
||||
|| font.Contains("Fixedsys", StringComparison.OrdinalIgnoreCase)
|
||||
|| font.Contains("Terminal", StringComparison.OrdinalIgnoreCase))
|
||||
return "Monospace";
|
||||
|
||||
if (SymbolFonts.Contains(font, StringComparer.OrdinalIgnoreCase)
|
||||
|| SymbolFontTokens.Any(token => font.Contains(token, StringComparison.OrdinalIgnoreCase)))
|
||||
return "Symbols";
|
||||
|
||||
if (font.Contains("Script", StringComparison.OrdinalIgnoreCase)
|
||||
|| font.Contains("Hand", StringComparison.OrdinalIgnoreCase)
|
||||
|| font.Contains("Print", StringComparison.OrdinalIgnoreCase)
|
||||
|| HandwritingFonts.Contains(font, StringComparer.OrdinalIgnoreCase))
|
||||
return "Handwriting";
|
||||
|
||||
if (InternationalFonts.Contains(font, StringComparer.OrdinalIgnoreCase)
|
||||
|| InternationalFontTokens.Any(token => font.Contains(token, StringComparison.OrdinalIgnoreCase)))
|
||||
return "International";
|
||||
|
||||
if (SerifFonts.Contains(font, StringComparer.OrdinalIgnoreCase)
|
||||
|| (font.Contains("Serif", StringComparison.OrdinalIgnoreCase)
|
||||
&& !font.Contains("Sans", StringComparison.OrdinalIgnoreCase)))
|
||||
return "Serif";
|
||||
|
||||
if (font.Contains("Display", StringComparison.OrdinalIgnoreCase)
|
||||
|| DisplayFonts.Contains(font, StringComparer.OrdinalIgnoreCase))
|
||||
return "Display";
|
||||
|
||||
if (font.Contains("Sans", StringComparison.OrdinalIgnoreCase)
|
||||
|| SansSerifFonts.Contains(font, StringComparer.OrdinalIgnoreCase)
|
||||
|| font.StartsWith("Segoe UI", StringComparison.OrdinalIgnoreCase)
|
||||
|| font.StartsWith("Arial", StringComparison.OrdinalIgnoreCase))
|
||||
return "Sans Serif";
|
||||
|
||||
return "Other";
|
||||
}
|
||||
|
||||
private static readonly string[] SerifFonts =
|
||||
{
|
||||
"Cambria", "Constantia", "Georgia", "Garamond", "Palatino Linotype", "Times New Roman",
|
||||
"Book Antiqua", "Century Schoolbook", "Sitka Text", "Sitka Heading", "Bodoni MT", "Bell MT",
|
||||
};
|
||||
|
||||
private static readonly string[] DisplayFonts =
|
||||
{
|
||||
"Algerian", "Broadway", "Chiller", "Comic Sans MS", "Cooper Black", "Impact", "Jokerman",
|
||||
"Magneto", "Showcard Gothic", "Stencil",
|
||||
};
|
||||
|
||||
private static readonly string[] HandwritingFonts =
|
||||
{
|
||||
"Bradley Hand ITC", "Brush Script MT", "Edwardian Script ITC", "French Script MT",
|
||||
"Freestyle Script", "Kunstler Script", "Lucida Handwriting", "Mistral", "Monotype Corsiva",
|
||||
"Papyrus", "Pristina", "Segoe Print", "Segoe Script", "Viner Hand ITC", "Vladimir Script",
|
||||
};
|
||||
|
||||
private static readonly string[] SymbolFonts =
|
||||
{
|
||||
"Segoe MDL2 Assets", "Segoe Fluent Icons", "Segoe UI Symbol", "Segoe UI Emoji",
|
||||
};
|
||||
|
||||
private static readonly string[] SymbolFontTokens =
|
||||
{
|
||||
"Emoji", "Icon", "Symbol", "Wingding", "Webding", "Dingbat",
|
||||
};
|
||||
|
||||
private static readonly string[] InternationalFonts =
|
||||
{
|
||||
"Batang", "DengXian", "Dotum", "Ebrima", "FangSong", "Gulim", "Hiragino", "KaiTi",
|
||||
"Leelawadee", "Malgun Gothic", "Meiryo", "MingLiU", "Mongolian", "MS Gothic", "Nirmala UI",
|
||||
"NSimSun", "PMingLiU", "SimSun", "Yu Gothic", "Yu Mincho",
|
||||
};
|
||||
|
||||
private static readonly string[] InternationalFontTokens =
|
||||
{
|
||||
"Arabic", "Armenian", "Bengali", "Canadian Aboriginal", "Cherokee", "Devanagari", "Ethiopic",
|
||||
"Georgian", "Gujarati", "Gurmukhi", "Hebrew", "Javanese", "Kannada", "Khmer", "Lao",
|
||||
"Myanmar", "Oriya", "Sinhala", "Syriac", "Tamil", "Telugu", "Thai",
|
||||
};
|
||||
|
||||
private static readonly string[] SansSerifFonts =
|
||||
{
|
||||
"Arial", "Arial Nova", "Aptos", "Bahnschrift", "Calibri", "Candara", "Century Gothic",
|
||||
"Corbel", "Franklin Gothic Medium", "Microsoft Sans Serif", "Segoe UI", "Tahoma",
|
||||
"Trebuchet MS", "Verdana",
|
||||
};
|
||||
|
||||
private void UpdateConfiguredTextOverlays()
|
||||
{
|
||||
var sources = Scenes.SelectMany(scene => scene.Elements).OfType<Source>()
|
||||
.Where(source => source.Type == SourceType.TextOverlay).ToArray();
|
||||
RetainTextOverlaySubscriptions();
|
||||
_textOverlayRenderer.Retain(sources);
|
||||
foreach (var source in sources)
|
||||
{
|
||||
if (source.Width <= 0) source.Width = 900;
|
||||
if (source.Height <= 0) source.Height = 140;
|
||||
WireTextOverlaySource(source);
|
||||
_textOverlayRenderer.UpdatePreview(source);
|
||||
}
|
||||
}
|
||||
|
||||
private void WireTextOverlaySource(Source source)
|
||||
{
|
||||
if (_wiredTextOverlays.Add(source))
|
||||
source.PropertyChanged += OnElementPropertyChanged;
|
||||
}
|
||||
|
||||
private void UpdateTextOverlayPreview(Source source) => _textOverlayRenderer.UpdatePreview(source);
|
||||
|
||||
private void RemoveTextOverlayPreview(Source source)
|
||||
{
|
||||
if (_wiredTextOverlays.Remove(source))
|
||||
source.PropertyChanged -= OnElementPropertyChanged;
|
||||
_textOverlayRenderer.Remove(source);
|
||||
}
|
||||
|
||||
private void RetainTextOverlaySubscriptions()
|
||||
{
|
||||
var active = Scenes.SelectMany(scene => scene.Elements).OfType<Source>()
|
||||
.Where(source => source.Type == SourceType.TextOverlay).ToHashSet();
|
||||
foreach (var source in _wiredTextOverlays.ToArray())
|
||||
if (!active.Contains(source))
|
||||
RemoveTextOverlayPreview(source);
|
||||
}
|
||||
|
||||
private void OnTextOverlayPropertyChanged(Source source, string? propertyName)
|
||||
{
|
||||
if (propertyName is nameof(Source.TextContent) or nameof(Source.TextFontFamily)
|
||||
or nameof(Source.TextFontSize) or nameof(Source.TextColor) or nameof(Source.TextBold)
|
||||
or nameof(Source.TextItalic) or nameof(Source.TextUnderline) or nameof(Source.TextAlignment)
|
||||
or nameof(Source.TextVerticalAlignment)
|
||||
or nameof(Source.Width) or nameof(Source.Height))
|
||||
UpdateTextOverlayPreview(source);
|
||||
}
|
||||
|
||||
}
|
||||
+61
-10
@@ -47,6 +47,7 @@ public partial class MainViewModel : ViewModelBase
|
||||
// born yet (startup crash regression from TASK 31, 2026-09-01; headless RealApp
|
||||
// tests never ran it, so it waited for the first native launch).
|
||||
private SceneGraph _sceneGraph = new();
|
||||
internal SceneGraph SceneGraphForTests => _sceneGraph;
|
||||
|
||||
/// <summary>Alert audio plays on the default device (creator ruling 2026-11-01): the WASAPI
|
||||
/// loopback then picks it up, so it is audible AND visible in the desktop sound bar.</summary>
|
||||
@@ -73,6 +74,7 @@ public partial class MainViewModel : ViewModelBase
|
||||
OnPropertyChanged(nameof(IsBlurSelected));
|
||||
OnPropertyChanged(nameof(IsElementsPanelEmpty));
|
||||
OnPropertyChanged(nameof(CanToggleClipShape));
|
||||
SyncTextFontCategory();
|
||||
if (_selectedElement != null)
|
||||
_selectedElement.PropertyChanged += OnSelectedElementPropertyChanged;
|
||||
}
|
||||
@@ -105,10 +107,47 @@ public partial class MainViewModel : ViewModelBase
|
||||
|
||||
private void OnSelectedElementPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
|
||||
{
|
||||
// ⛔ Safety net for EVERY property control: a static layer's appearance is baked into the
|
||||
// scene cache keyed on element identity, so changing a property without invalidating the
|
||||
// bake repaints nothing — the control looks dead. The Background toggle was the first case
|
||||
// found (2026-11-01); the cause is generic, so the cure is here rather than one handler per
|
||||
// property. Cheap: the bake is recomputed on demand, once.
|
||||
if (StagedScene != null) _sceneGraph.InvalidateBake(StagedScene);
|
||||
|
||||
if (sender is Source text && text.Type == SourceType.TextOverlay)
|
||||
{
|
||||
if (e.PropertyName == nameof(Source.TextFontFamily))
|
||||
{
|
||||
SelectedTextFontType = FontTypeFor(text.TextFontFamily);
|
||||
OnPropertyChanged(nameof(SelectedTextFontFamily));
|
||||
}
|
||||
if (e.PropertyName == nameof(Source.TextFontSize))
|
||||
OnPropertyChanged(nameof(TextFontSizeOptions));
|
||||
}
|
||||
|
||||
if (e.PropertyName == nameof(SceneElement.UseDefaultBackground))
|
||||
{
|
||||
if (StagedScene != null) _sceneGraph.InvalidateBake(StagedScene);
|
||||
// ⛔ Turning the default back ON has to RE-STAMP the shipped art. Browse… writes the
|
||||
// custom image into both CustomBackgroundPath AND AssetId, so without this the toggle
|
||||
// flipped a bool while the preview kept showing the custom picture — the setting
|
||||
// appeared to do nothing (creator report, 2026-11-01).
|
||||
if (StagedScene is { } scene)
|
||||
{
|
||||
var background = scene.Elements.OfType<Source>().FirstOrDefault(s => s.IsBackground);
|
||||
if (background is { UseDefaultBackground: true })
|
||||
{
|
||||
background.CustomBackgroundPath = null;
|
||||
background.AssetId = null; // lets the stamp run
|
||||
StampDefaultBackgroundAsset(background, scene.Name);
|
||||
}
|
||||
}
|
||||
|
||||
// Both surfaces: UpdateBackgroundImage drives the preview's lower layer,
|
||||
// UpdateActiveBackground drives ActiveBackgroundImage (the art the preview draws above
|
||||
// it). Refreshing only the first is why the thumbnails changed while the preview did
|
||||
// not (creator report, 2026-11-01).
|
||||
UpdateBackgroundImage();
|
||||
UpdateActiveBackground();
|
||||
RefreshSnapshotsAsync();
|
||||
ScheduleSave();
|
||||
}
|
||||
@@ -166,13 +205,18 @@ public partial class MainViewModel : ViewModelBase
|
||||
public MainViewModel()
|
||||
{
|
||||
AppLog.Write("MainViewModel ctor begin");
|
||||
InitializeCurrentLaunchLogs();
|
||||
IsAppOwned = _entitlement.IsAppOwned;
|
||||
_youtubeAuth = new YouTubeAuthService(OAuthCredentials.ClientId, OAuthCredentials.ClientSecret,
|
||||
sessionChanged: ch => TokenStore.Save(ch));
|
||||
_youtubeStream = new YouTubeStreamService(_youtubeAuth);
|
||||
_youtubeChat = new YouTubeChatService(_youtubeAuth);
|
||||
AppLog.Write("MainViewModel ctor: services created");
|
||||
|
||||
_selectedQuality = QualityOptions[0];
|
||||
var defaultQuality = HasProOutput
|
||||
? QualityOptions[0]
|
||||
: QualityOptions.Single(option => option.Display == OutputProfile.FreeFallbackName);
|
||||
_selectedQuality = OutputProfile.Resolve(defaultQuality, QualityOptions, HasProOutput);
|
||||
ApplyStreamQuality(_selectedQuality);
|
||||
|
||||
_youtubeChat.MessageReceived += OnChatMessageReceived;
|
||||
@@ -247,7 +291,7 @@ public partial class MainViewModel : ViewModelBase
|
||||
TestAlertCommand = new RelayCommand(_ => TestAlert());
|
||||
OpenBugCommand = new RelayCommand(_ => ShowOverlay(nameof(IsBugOpen), "Report Bug"));
|
||||
OpenFeatureCommand = new RelayCommand(_ => ShowOverlay(nameof(IsFeatureOpen), "Feature Request"));
|
||||
OpenAboutCommand = new RelayCommand(_ => ShowOverlay(nameof(IsAboutOpen), "About"));
|
||||
OpenAboutCommand = new RelayCommand(_ => ShowAbout());
|
||||
CloseOverlayCommand = new RelayCommand(_ => ShowOverlay());
|
||||
SubmitBugCommand = new RelayCommand(_ => SubmitBug());
|
||||
SubmitFeatureCommand = new RelayCommand(_ => SubmitFeature());
|
||||
@@ -378,7 +422,8 @@ public partial class MainViewModel : ViewModelBase
|
||||
encoderFactory: () => new FfmpegEncoder(new FfmpegLocator()),
|
||||
log: message => AppLog.Write(message),
|
||||
socialBar: () => (_socialBarFrame, _socials?.BarPosition ?? SocialBarPosition.Bottom),
|
||||
alertTicker: () => _alertLayer.AlertTickerFrame,
|
||||
alertTicker: scene => _alertLayer.AlertBoxTickerFrame(
|
||||
scene.Elements.OfType<Source>().FirstOrDefault(source => source.Type == SourceType.AlertBox && source.IsVisible)),
|
||||
brandFlash: BrandFlashFrame,
|
||||
transition: _transition,
|
||||
sceneGraph: _sceneGraph);
|
||||
@@ -465,6 +510,7 @@ public partial class MainViewModel : ViewModelBase
|
||||
}
|
||||
ClearPreviewHistory();
|
||||
StagedScene = Scenes.FirstOrDefault(s => SceneCatalog.Is(s.Name, SceneCatalog.Starting)) ?? Scenes.FirstOrDefault();
|
||||
UpdateConfiguredTextOverlays();
|
||||
UpdateActiveBackground();
|
||||
UpdateActiveChatBoxPreviews();
|
||||
UpdateActiveAlertBoxPreviews();
|
||||
@@ -496,6 +542,7 @@ public partial class MainViewModel : ViewModelBase
|
||||
|
||||
public void Shutdown()
|
||||
{
|
||||
StopCurrentLaunchLogFeed();
|
||||
_saveDebounce?.Stop();
|
||||
SaveLayoutNow();
|
||||
_fullScreenDetector.StopWatching();
|
||||
@@ -552,7 +599,7 @@ public partial class MainViewModel : ViewModelBase
|
||||
// Scene-element → latest frame, for the live compositor. The map mirrors the
|
||||
// preview: webcam by DeviceId, live captures (the background) by CaptureKey,
|
||||
// images/background by AssetId. A null frame leaves the element transparent.
|
||||
private VideoFrame? ResolveOutputFrame(SceneElement element)
|
||||
internal VideoFrame? ResolveOutputFrame(SceneElement element)
|
||||
{
|
||||
var frame = ResolveOutputFrameCore(element);
|
||||
// Output-side eyes (2026-09-01): preview showed the webcam but the recording
|
||||
@@ -611,7 +658,9 @@ public partial class MainViewModel : ViewModelBase
|
||||
// (caught by the resolver logging, 2026-09-01 take two).
|
||||
WebcamSceneConfig webcam => _cameraManager.GetLatestFrame(DeviceKeyForWebcam(webcam.WebcamId), EffectKeyFor(webcam)),
|
||||
Source { IsLiveCapture: true, CaptureKey: not null } live => _screenCaptureManager.GetLatestFrame(live.CaptureKey),
|
||||
Source { Type: SourceType.TextOverlay } text => _textOverlayRenderer.RenderFrame(text),
|
||||
Source { Type: SourceType.ChatBox } chatBox => RenderChatBox(chatBox),
|
||||
Source { Type: SourceType.Ticker } ticker => _alertLayer.RenderSceneTicker(ticker),
|
||||
Source { Type: SourceType.Scoreboard } => _scoreboardPresenter?.Frame,
|
||||
Source { Type: SourceType.AlertBox } alertBox => RenderAlertBox(alertBox),
|
||||
Source { Type: SourceType.WebSource } web => _webView2Manager?.GetLatestFrame(web.Id),
|
||||
@@ -623,19 +672,21 @@ public partial class MainViewModel : ViewModelBase
|
||||
|
||||
// The tier's crop rect over the 1920x1080 master, integer-aligned (the VM's
|
||||
// OutputRect* are doubles — the vertical 607.5 half-pixel crop rounds to 608).
|
||||
// FFmpeg, not the compositor, scales this crop to the tier's final dimensions.
|
||||
// internal = test seam (the zoom layer must NOT change the output crop — the
|
||||
// box is a composited layer, never the crop source).
|
||||
internal CompositorOptions BuildCompositorOptions()
|
||||
{
|
||||
var quality = SelectedQuality;
|
||||
var sourceRectWidth = (int)Math.Round(OutputRectWidth);
|
||||
var sourceRectHeight = (int)Math.Round(OutputRectHeight);
|
||||
return new CompositorOptions
|
||||
{
|
||||
SourceRectX = (int)Math.Round(OutputRectX),
|
||||
SourceRectY = (int)Math.Round(OutputRectY),
|
||||
SourceRectWidth = (int)Math.Round(OutputRectWidth),
|
||||
SourceRectHeight = (int)Math.Round(OutputRectHeight),
|
||||
OutputWidth = quality.Width,
|
||||
OutputHeight = quality.Height,
|
||||
SourceRectWidth = sourceRectWidth,
|
||||
SourceRectHeight = sourceRectHeight,
|
||||
OutputWidth = sourceRectWidth,
|
||||
OutputHeight = sourceRectHeight,
|
||||
};
|
||||
} // The credit's cadence, timing, placement and licence gate all live in
|
||||
// BrandFlashPresenter; MainViewModel only routes it to the frame pump and the
|
||||
|
||||
+8
-3
@@ -8,7 +8,7 @@ areas' docs in their own rows keeps this index small to read.
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `MainViewModel.cs` | Core. `public partial class MainViewModel : ViewModelBase`. **Source commands + element rows:** scenes are pure selection rows; sources carry edit/visibility/trash (`EditElementCommand` sets `element.IsEditing`, `ToggleElementVisibilityCommand` flips `IsVisible`, `RemoveSourceCommand`; Backdrop trash slot reserved via `HiddenBoolToVisibilityConverter` — TASK 13); shared `NextSourceName` (Image, Image2, Image3… no-space increment from actual names — used by `AddSource` + `AddReusedImage`), `AddImageCommand`. **Lifecycle/ctor wiring:** fields + ctor wiring (all command assignments, mixer/camera/chat/hotkey/subscription hookups), `Shutdown`/`SaveLayoutNow`/`ScheduleSave`/`LoadLayout`, `CreateBrush` + shared `BarOnBrush`/`BarOffBrush` (referenced by Audio/Trax/Socials partials), window-geometry pass-through, `SelectedElement`/`OnSelectedElementPropertyChanged` (the selection change also raises `CanToggleClipShape` — TASK 57), `ShowPreviewPlaceholder`, `BrandFlash*`, WebView2 hosting (`InitWebView2`/`RegisterLoadedWebSources`/`OnWebView2PreviewBitmapChanged`), frame-pump wiring (construction + `_rtmpUrlProvider` seam + `Dispose`, plus `ResolveOutputFrame` → `RenderChatBox`/`RenderAlertBox` for chat + alert layers) — the pump's stream-state consumers live in the Streaming partial. |
|
||||
| `MainViewModel.cs` | Core. `public partial class MainViewModel : ViewModelBase`. **Source commands + element rows:** scenes are pure selection rows; sources carry edit/visibility/trash (`EditElementCommand` sets `element.IsEditing`, `ToggleElementVisibilityCommand` flips `IsVisible`, `RemoveSourceCommand`; Backdrop trash slot reserved via `HiddenBoolToVisibilityConverter` — TASK 13); shared `NextSourceName` (Image, Image2, Image3… no-space increment from actual names — used by `AddSource` + `AddReusedImage`), `AddImageCommand`. **Lifecycle/ctor wiring:** fields + ctor wiring (all command assignments, mixer/camera/chat/hotkey/subscription hookups), `Shutdown`/`SaveLayoutNow`/`ScheduleSave`/`LoadLayout`, `CreateBrush` + shared `BarOnBrush`/`BarOffBrush` (referenced by Audio/Trax/Socials partials), window-geometry pass-through, `SelectedElement`/`OnSelectedElementPropertyChanged` (the selection change also raises `CanToggleClipShape` — TASK 57), `ShowPreviewPlaceholder`, `BrandFlash*`, WebView2 hosting (`InitWebView2`/`RegisterLoadedWebSources`/`OnWebView2PreviewBitmapChanged`), frame-pump wiring (construction + `_rtmpUrlProvider` seam + `Dispose`, plus `ResolveOutputFrame` → cached TextOverlay frame / `RenderChatBox` / `RenderAlertBox` for text, chat, and alert layers) — the pump's stream-state consumers live in the Streaming partial. |
|
||||
| `MainViewModel.Account.cs` | Session + connected account: `LoadSavedSessionAsync` (DPAPI restore at startup), `SignInAsync`/`ChangeAccountAsync`/`SignOutYouTubeAsync`, `IsConnected`/`AccountAvatarUrl`/`AccountDisplayName`/`AccountStatusLightToolTip` via `SyncConnectedAccount` (cleared on End). |
|
||||
| `MainViewModel.License.cs` | Polar license (freemium): `LicenseKey`/`LicenseMessage`/`IsLicenseValidating`/`IsLicenseEntryOpen`/`IsHubVisible`/`IsPremium`, `OfflineGracePeriod`, `ValidateLicenseAtStartupAsync`/`ActivateLicenseAsync`. |
|
||||
| `MainViewModel.Scenes.cs` | Five-scene catalog: `Scenes` + `StagedScene`/`LiveScene`/`IsEditMode`, `AddScene` (canonical names only), `MissingScenes`/`ShowAddScene`/`AddSceneCommand`; scene wiring `OnScenesChanged`/`WireScene`/`UnwireScene`; transitions `SelectedTransitionType`/`TransitionDurationMs`/`TransitionToScene`/`ApplyTransitionToLive`; global hotkeys `HandleHotkey`/`OpenHotkeyConfig`/`RefreshHotkeyLabels`; scene-gating hints (`CanChangeBackground`). |
|
||||
@@ -19,8 +19,9 @@ areas' docs in their own rows keeps this index small to read.
|
||||
| `MainViewModel.Trax.cs` | Background music (TASK 9, rides the loopback into the stream): `_musicPlayer` (`MusicPlayer`), `IsMusicPlaying`/`TraxError`/`TraxDotBrush`/`TraxToolTip`, interaction `TraxLeftClick`/`TraxRightClick`/`PickTraxTrack`, playback `LoadTrax`/`PlayTrax`/`PauseTrax`/`OnTraxPlaybackEnded`/`RefreshTraxUi`, `UpdateTraxLocalGain` (mirrors the game bar's mute onto `MusicPlayer.LocalGain` so the track ducks/restores with desktop audio). |
|
||||
| `MainViewModel.Socials.cs` | Social bar (global resource, overlaid on every scene): `_socials`/`_socialBarFrame`/`_socialValidator` (`ISocialValidator`/`HttpSocialValidator`), `HasSocials`/`SocialBarVisible`/`SocialBarDotBrush`/`SocialBarGlowBrush`/`Socials`/`SocialBarTop`, `OpenSocialDialogCommand`/`OpenSocialDialog` (spawns `SocialsDialogViewModel` + window; saved copy replaces `_socials`), `NotifySocialsChanged`/`RenderSocialBarFrame` (`SocialBarRenderer` → immutable `_socialBarFrame` the frame pump reads), fediverse heal `HealFediverseSoftwareAsync` (static, test-facing) + `HealFediverseSoftwareInBackground`, bar edge toggle `SetSocialBarPosition`/`ToggleSocialBarPosition`. `BarOnBrush`/`BarOffBrush`/`CreateBrush` stay in core (shared tokens). |
|
||||
| `MainViewModel.Streaming.cs` | Go-live/record + health + quality tiers + session UI. **Stream state:** `StreamStatus`/`StreamVisibility` setters + `_streamStatus` field, `IsOffline`/`IsLive`/`IsLivePrivate`. **Go-live flow:** `StartSession` (routes by pill state) → `BeginRecordOnly` (signed-out local recording) or `BeginGoLive` (GoLive dialog → captures title/description/visibility + `BroadcastForm.CaptureGoLive` → `PrepareAndStartLiveAsync`: reusable stream → `CreateBroadcast` (stores `_currentBroadcastId`) → empty-metadata warn → chat poll start (liveChatId) → `_framePump.StartAsync()` + health poll; failures → `StreamStatus.Error`, never a crash); `StartRecordFile` (auto-named `ty-…-0000.mp4`) + `FinalizeRecordingAsync` (rename modal, `UniquePath`); `StopStream` (posts Offline, stops chat/audio pipe/pump, clears `_currentBroadcastId`; session/token deliberately NOT cleared — sign-out stays explicit). **Health:** `CurrentHealth`/`ResetHealth`/`ApplyHealth`/`OnFramePumpHealthUpdated` (marshal `FramePump.HealthUpdated` off the stderr thread), `PollHealthAsync`/`OnHealthPollTick`/`ApplyHealthIssue` (report-by-exception banner `HealthIssueBanner`/`HealthIssueBackground` via `StreamHealthReporter`). **Session timer:** `_liveTimer` (ctor-wired) `UpdateSessionTimer`/`OnLiveTimerTick`/`LiveElapsedText`/`_recDotPulse`. **Start/End UI:** mode switch `RecordPillOn`/`OnAirPillOn`, top-bar worlds (`ShowIdleWorld`/`ShowRecordAction`/`ShowStreamActions`/`ShowSignInButton`/`ShowAvatarButton`, reality line `RunningVisible`/`RunningText`/`RunningDotBrush`, Test gate `ShowTestButton`/`CanStartTest` — `RaiseTopBarModes()`), `ShowPrimaryStartButton`/`CanStartSession`/`ShowEndStreamButton`/`EndButtonText`, REC sign `RecDotBrush`/`RecTextBrush`/`RecDotOpacity`/`OnAirBrush`, live window tokens `WindowTitle`/`TopBarBackground`/`PreviewGlow*` via `UpdateLiveVisuals`; `ChooseRecordFolder`/`ResetRecordFolder`/`_recordFolder`/`RecordFolderDisplay`/`DefaultRecordFolder`. **Resolution dropdown:** `QualityOptions`/`SelectedQuality`/`ResolutionHelp`/`ApplyStreamQuality` + `OutputRect*`/`DimRects`/`IsOutputCropped`/`ResolutionBadgeText` over the 1920×1080 master; **TASK 57:** the tier change also resolves every scene's camera placement (the vertical tier brings the vertical placements in, 16:9 restores the desktop layout — see `MainViewModel.Webcam.cs`). **Pipeline bits:** `_framePump`/`_liveTimer`/`_healthPollTimer` fields, `BuildEncoderOptions`. Commands `StartStreamCommand`/`EndStreamCommand`/`ChooseRecordFolderCommand`/`ResetRecordFolderCommand`. Broadcast pull-out: `CurrentBroadcastId`/`BroadcastForm`. **TASK 57 (Unit 2):** the tier change also raises `CanToggleClipShape` (the circle option disappears in the vertical Chat layout). |
|
||||
| `MainViewModel.Overlays.cs` | Settings/bug/feature/about panels + creator-hub links: panel state `IsSettingsOpen`/`IsBugOpen`/`IsFeatureOpen`/`IsAboutOpen` (via `SetPanel` → `IsAnyOverlayOpen`), `IsLicensesOpen` (flips `IsHubVisible`), `OverlayTitle`, `ShowOverlay` (one-panel exclusivity) + `ShowLicenses` (loads shipped `THIRD-PARTY-NOTICES.txt` into `LicensesText` on first open, never the OS viewer); bug/feature report `BugReportText`/`BugReportEmail`/`FeatureRequestText`/`FeatureRequestEmail` + `SubmitBug`/`SubmitFeature`/`ComposeEmail`/`OpenUrl`; About hub consts `ChannelUrl`/`MastodonUrl`/`CoffeeUrl` (public, integration-test live) + `PremiumUrl` (tabled billing seam — TASK 12 → greyed "Unlock Premium" lights up when filled) + `IsPremiumAvailable` + `SupportEmail` + `AppVersionLabel`; commands `OpenSettingsCommand`/`OpenBugCommand`/`OpenFeatureCommand`/`OpenAboutCommand`/`CloseOverlayCommand`/`SubmitBugCommand`/`SubmitFeatureCommand`/`OpenChannelCommand`/`OpenMastodonCommand`/`OpenCoffeeCommand`/`OpenPremiumCommand`/`OpenLicensesCommand`. Ctor command assignments stay in core. |
|
||||
| `MainViewModel.Overlays.cs` | Settings/bug/feature/about panels + creator-hub links: panel state `IsSettingsOpen`/`IsBugOpen`/`IsFeatureOpen`/`IsAboutOpen` (via `SetPanel` → `IsAnyOverlayOpen`), `IsLicensesOpen` (flips `IsHubVisible`), `OverlayTitle`, `ShowOverlay` (one-panel exclusivity), `ShowAbout` (returns to the About hub) + `ShowLicenses` (loads shipped `THIRD-PARTY-NOTICES.txt` into `LicensesText` in-app); bug/feature report `BugReportText`/`BugReportEmail`/`FeatureRequestText`/`FeatureRequestEmail` + `SubmitBug`/`SubmitFeature`/`ComposeEmail`/`OpenUrl`; About hub consts `ChannelUrl`/`MastodonUrl`/`CoffeeUrl` + `PremiumUrl` (`https://llamacasty.com`, opens only when explicitly clicked) + `IsPremiumAvailable` + `SupportEmail` + `AppVersionLabel`; commands `OpenSettingsCommand`/`OpenBugCommand`/`OpenFeatureCommand`/`OpenAboutCommand`/`CloseOverlayCommand`/`SubmitBugCommand`/`SubmitFeatureCommand`/`OpenChannelCommand`/`OpenMastodonCommand`/`OpenCoffeeCommand`/`OpenPremiumCommand`/`OpenLicensesCommand`. A locked quality selection opens About without changing the tier. Ctor command assignments stay in core. |
|
||||
| `MainViewModel.Chat.cs` | Chat overlay + preview pipeline: `ChatMessages` (500-message cap; clear-on-fade), `OnChatMessageReceived` (marshal `_youtubeChat.MessageReceived` onto the dispatcher, switch previews mock→real, then feed **both** component layers), fade `_chatFadeTimer`/`ResetChatFadeTimer` (shortest `ChatFadeSeconds` across chat layers); live render `RenderChatBox` (called from `ResolveOutputFrame`, stays in core) + preview render `UpdateChatBoxPreview` (bitmap onto `chatBox.VideoImageSource`); mock preview `MockChatMessages`/`StartMockChatPreview`/`StopMockChatPreview`/`RunMockChatPreviewAsync`/`_mockChatCts`; `UpdateActiveChatBoxPreviews` (wires Chat* property-change subscriptions + first render); `CanAddYouTubeChat` (one-per-layout gate), `ShowChatInactiveMessage` (`!IsLive`). **Alerts (TASK 43):** same facade for the native alert box — `_alertLayer` (`AlertOverlayLayer`), `RenderAlertBox`/`UpdateAlertBoxPreview`, `UpdateActiveAlertBoxPreviews`, `CanAddAlerts` (one alert zone per layout). Ctor wiring (`MessageReceived` hookup, `new ChatBoxRenderer()`, `new AlertRenderer()`, `LoadLayout` preview start, `_youtubeChat.Dispose` + `_alertLayer.Dispose`) stays in core. |
|
||||
| `MainViewModel.Logs.cs` | Non-edit left-panel tab state and bounded current-launch log collections; refreshes the process-local AppLog snapshot while Logs is selected, pins Class 1 before Class 2 until acknowledged, then returns entries to the LIFO timeline; stops its dispatcher timer on shutdown. |
|
||||
| `GoLiveViewModel.cs` | Start Stream dialog: **account row** (saved channel shown with Change Account, or Sign in to YouTube; Start gated on `IsSignedIn`/`IsBusy`) + title/description + **locked Visibility (always "Private" — ship step 7, no dropdown)**, start/cancel requests |
|
||||
| **About hub (TASK 11, in `MainViewModel.Overlays.cs`)** | the About overlay is the in-app creator hub: `ChannelUrl`/`MastodonUrl`/`CoffeeUrl` (public consts, live) + `PremiumUrl` (tabled billing seam — TASK 12 → the greyed "Unlock Premium" button lights up when filled); `OpenLicensesCommand` → `ShowLicenses()` loads the shipped `THIRD-PARTY-NOTICES.txt` into `LicensesText` and sets `IsLicensesOpen` (in-app scrolling panel, never the OS viewer); `BackToAbout_Click` in `MainWindow.xaml.cs` flips it off. |
|
||||
| `ReuseImageViewModel.cs` | Add Image dialog: candidate list (`ReuseImageCandidate`), reuse/new/cancel |
|
||||
@@ -28,6 +29,10 @@ areas' docs in their own rows keeps this index small to read.
|
||||
| `MicPickerViewModel.cs` | Select Microphone dialog (voice source): async mic list (loading / has / none states), `UseRequested(MicrophoneDeviceInfo)`/`CancelRequested` |
|
||||
| `SocialsDialogViewModel.cs` | Social Media Site Promotion dialog (six fixed slots, `SocialSlotViewModel`): row 0 = YouTube sign-in gate (sign-in provider fills the channel, delete = sign-out); rows 1-5 validated entries / empty / freemium locks 2-5 (`isPremium` seam); `DetectService` → `ISocialValidator` on confirm (input snaps to text + real logo on success, red do-not + stays editable on failure); `CanSave` blocked while any slot edits/validates/errors; `BarEnabled` switch; commit via `CommittedEntries` + `BuildSocialsConfig()` (YouTube first) + `SaveRequested`/`CancelRequested`. WPF-free — the window owns the dialogs (delete confirm, sign-in gate) and calls `StartEdit`/`ConfirmEdit`/`DeleteSlotAsync`. |
|
||||
|
||||
| `MainViewModel.Sources.cs` | Source creation/removal; `AddSource` creates the standalone ticker layer with one-per-scene gating and its 1920×48 default geometry; TextOverlay starts as a visible 900×140 draggable text layer. |
|
||||
| `MainViewModel.TextOverlay.cs` | Raster-on-change TextOverlay preview hookup and settings/dimension invalidation; exposes font categories and selected-layer font/size options; output resolution reads its immutable cached frame. |
|
||||
| `MainViewModel.AlertTicker.cs` | AlertBox ticker preview routing: selects the preview scene's AlertBox settings and publishes the positioned box-sized frame to PreviewPane. |
|
||||
|
||||
Related: [`Models/index.md`](../Models/index.md) for the data; [`Services/index.md`](../Services/index.md)
|
||||
for what the ViewModels drive; base class in [`Helpers/ViewModelBase.cs`](../Helpers/ViewModelBase.cs).
|
||||
|
||||
@@ -75,4 +80,4 @@ commit, tag `refactor-commit-N`, no per-commit push).
|
||||
- [x] **Commit F · `refactor-commit-F`** — **Recording (context/concern)** → new **`MainViewModel.Recording.cs`** (121 lines). The encoder (`Services/Encoder/FfmpegEncoder.cs`) and frame pump (`Services/Encoder/FramePump.cs`) were ALREADY extracted services — the planned "extract FFmpegEncoder/StreamHealthMonitor/FramePump" was a no-op premise. The honest functional seam was the local recording-output concern: `StartRecordFile`, `FinalizeRecordingAsync`, `UniquePath`, `ChooseRecordFolder`, `ResetRecordFolder`, `DefaultRecordFolder` + fields `_recordFolder`/`RecordFolderDisplay`/`_activeRecordPath`/`_recordStartTime`/`_recordLength` moved out of the live-stream orchestration. `MainViewModel.Streaming.Operations.cs` 475 → **377**; `MainViewModel.Streaming.cs` 328 → **323**. Same partial class, MVVM glue untouched.
|
||||
- [x] **Commit G · `refactor-commit-G`** — **ChatOverlayLayer (true component)** → new **`Services/ChatOverlayLayer.cs`** (199 lines). The first REAL decomposition of the 4,105-line god-object: `MainViewModel.Chat.cs` 194 → **44** (thin delegation). The layer owns all chat behavior — `Messages` buffer, `ChatBoxRenderer`, fade timer, mock-preview timer, per-source preview renders, live-output `RenderFrame`. The VM keeps only the binding surface: `ChatMessages` (delegates to `_chatLayer.Messages`, so XAML bind + LeftPanel `CollectionChanged` hold), `CanAddYouTubeChat`/`ShowChatInactiveMessage` (computed + `OnPropertyChanged` from VM setters), and thin forwards. Scenes are handed in as args (no `Func` seam) so the layer stays scene-graph-free. Zero behavior change; 246 pass / 2 known.
|
||||
The partial `.cs` files land next to `MainViewModel.cs` in this folder as the split
|
||||
proceeds; each partial carries its own `using`s and re-declares nothing from core.
|
||||
proceeds; each partial carries its own `using`s and re-declares nothing from core.
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
|
||||
A Windows-only WPF (.NET 8) app that live-streams to YouTube. Five fixed scenes
|
||||
(Starting / Live / BRB / Chat / Ending), a pure C# software compositor over an always-1920×1080
|
||||
master, a bundled-ffmpeg encoder pushing RTMP, and a WASAPI audio chain. Freemium — free is the
|
||||
whole app at a 720p30 output profile, Pro buys higher output quality — under a proprietary EULA,
|
||||
master, a bundled-ffmpeg encoder pushing RTMP, and a WASAPI audio chain. Freemium — the whole app
|
||||
is free at 30 fps across supported resolutions; Pro unlocks 60 fps — under a proprietary EULA,
|
||||
with no account creation: it links an existing YouTube channel. One-click go-live is the product;
|
||||
OBS is the escape hatch for anything bigger.
|
||||
|
||||
|
||||
+51
-44
@@ -10,10 +10,11 @@ storefront, support or promo work. Cause of death for every retired path:
|
||||
|
||||
- **Free tier — every feature, every scene, every overlay**, free forever, no account, no
|
||||
watermark, nothing nagged inside the output. It is not a trial.
|
||||
- **Pro is a one-time Steam purchase: a DLC on the free app.**
|
||||
**$79 introductory → $99** on a stated date before launch. No subscription, no lifetime SKU, no
|
||||
third-party checkout.
|
||||
- **The only delta is output quality.** Features are never gated; a payment buys pixels.
|
||||
- **Pro is the paid Steam app; the free Demo is associated with it.** The paid app is **$79 during
|
||||
the announced launch period, then $99** on the announced date. No subscription or third-party
|
||||
checkout.
|
||||
- **The only delta is frame rate.** Features are never gated; every supported resolution is free at
|
||||
30 fps, and 60 fps is paid. Resolution does not decide entitlement.
|
||||
- **No watermark, ever**, in either edition. The branding flash is retired as a paid delta and
|
||||
stays off (`ShowBrandFlash`, default `false`).
|
||||
- **Clean numbers, no `.99`**, no discount codes, no countdowns. **The one dated exception** is the
|
||||
@@ -25,49 +26,57 @@ storefront, support or promo work. Cause of death for every retired path:
|
||||
|
||||
## Output profile — the one entitlement rule
|
||||
|
||||
The cap is a pixel budget plus a frame rate, not the word "720p":
|
||||
The product rule is frame rate, not pixel count or a resolution label:
|
||||
|
||||
| | Free | Pro |
|
||||
|---|---|---|
|
||||
| Pixels | ≤ **921,600** | any shipped tier |
|
||||
| Frame rate | ≤ **30 fps** | any shipped tier |
|
||||
| Landscape | 1280×720 @30 | 1280×720 @60 · 1920×1080 @30 · @60 |
|
||||
| Vertical 9:16 | 720×1280 @30 | 1080×1920 @60 |
|
||||
| Pixels | any supported tier | any supported tier |
|
||||
| Frame rate | ≤ **30 fps** | up to **60 fps** |
|
||||
| Landscape | 1280×720 @30 · 1920×1080 @30 | all Free tiers + 1280×720 @60 · 1920×1080 @60 |
|
||||
| Vertical 9:16 | 1080×1920 @30 | all Free tiers + 1080×1920 @60 |
|
||||
|
||||
- **Records too.** The cap governs the file the creator keeps as well as the stream; otherwise
|
||||
free 1080p recording is a re-upload hole. **There is one ffmpeg process with two output blocks**
|
||||
- **Implementation gap (2026-11-04):** `Models/OutputProfile.cs` still gates by pixel budget plus
|
||||
frame rate. Update that rule and its tests to use frame rate alone; this docs unit records the
|
||||
product decision but does not change code.
|
||||
|
||||
- **Records too.** The frame-rate rule governs the file the creator keeps as well as the stream;
|
||||
otherwise free 60-fps recording is a re-upload hole. **There is one ffmpeg process with two output blocks**
|
||||
(FLV to RTMP, MP4 to disk) fed by one `EncoderOptions`, and the profile is resolved once when
|
||||
those options are built (`MainViewModel.Streaming.Operations.cs` → `Models/OutputProfile.cs`).
|
||||
There is no second encoder to keep in step and therefore no second check to forget.
|
||||
- **Fail closed.** Unresolved, unreadable or unvalidated entitlement = free tier. Never Pro.
|
||||
- **The development override exists and never ships.** `MainViewModel.DeveloperMode` bypasses the
|
||||
budget in a Debug build only; the Settings row that toggles it is **excluded from the Release
|
||||
entitlement gate in a Debug build only; the Settings row that toggles it is **excluded from the Release
|
||||
compile** (`ytLive.csproj` removes its Page and code-behind), and the setter refuses the value
|
||||
when `Helpers/DevBuild.IsDevelopment` is false. Not a hidden row — an absent one.
|
||||
- **Never downgrade a running stream.** The gate applies to the next start; pulling quality out
|
||||
of a live broadcast is a worse bug than one free 1080p session.
|
||||
- **The greyed menu entry is the sales surface.** It stays clickable, says what it costs, opens
|
||||
checkout, and is labelled ("720p30 · free tier") — never silently missing, because silence
|
||||
reads as broken.
|
||||
- **Activation is live**, not restart-gated: activating enables the high tiers and a lapse
|
||||
(cancel, revoke, sign-out, channel switch) greys them again on the next refresh.
|
||||
- **Bitrate follows the profile** — a capped user is not offered a 1080p bitrate.
|
||||
- **Never downgrade a running stream.** The gate applies to the next start; pulling 60 fps out of a
|
||||
live broadcast is a worse bug than one session starting at an unentitled frame rate.
|
||||
- **The greyed menu entry is the sales surface.** It stays clickable; locked 60-fps tiers are marked
|
||||
as paid. Selecting a locked tier opens the in-app About panel; the browser opens only if the
|
||||
creator then clicks its llamacasty.com link.
|
||||
- **Ownership is checked at app startup** and cached in `IsAppOwned` for that run. Purchases and
|
||||
refunds are reflected on the next app start; there is no subscription lapse or sign-out path.
|
||||
- **Bitrate follows the selected profile** — free users may choose any supported 30-fps tier; paid
|
||||
users may choose supported 60-fps tiers.
|
||||
|
||||
## Entitlement — one source
|
||||
|
||||
**Pro is `steamDlcOwned`, and nothing else.** It resolves to the `OutputProfile`; it never touches a
|
||||
feature. Fail closed: anything unresolved is the free tier.
|
||||
**Paid-app ownership is the one entitlement.** Steam sells one paid app; its associated free demo is
|
||||
the purchase funnel. Both use the same code. The demo checks whether the current Steam account owns
|
||||
the paid app, then exposes the higher output tiers. The app-wide `IsAppOwned` value is the binary
|
||||
ownership result used by later feature checks.
|
||||
|
||||
- **Steam owns the SKU.** `BIsDlcInstalled(dlcAppId)` after `SteamAPI.Init`, behind
|
||||
`IEntitlement` (`Services/Entitlement/`). It is a local check against Steam's own state — no
|
||||
server, no key, no lease, no account, no email.
|
||||
- **Fail closed, four ways:** no DLC app id configured, `SteamAPI.Init()` false (no client), a
|
||||
- **Steam owns the purchase.** The demo calls `BIsSubscribedApp(paidAppId)` after `SteamAPI.Init`,
|
||||
behind `IEntitlement` (`Services/Entitlement/`). It is a local check against Steam's own state —
|
||||
no server, key, lease, or LlamaCasty account.
|
||||
- **Fail closed:** no paid app ID configured, `SteamAPI.Init()` false (no client), a
|
||||
thrown native load (missing `steam_api64.dll`), and "not owned" all resolve to the free tier.
|
||||
None of them may crash the app or grant Pro.
|
||||
- **Steam down or not running** → free profile, with a line explaining why, never a refusal to
|
||||
start. A Steam build launched outside Steam is the common case for people who pin shortcuts.
|
||||
- **No licence key exists**, so nothing to paste, email, lose or reclaim. A refunded DLC simply
|
||||
stops reporting ownership on the next start.
|
||||
- **Development builds fake ownership as true.** `FakeEntitlement` is excluded from Release builds.
|
||||
- **No licence key exists**, so nothing to paste, email, lose or reclaim. A refunded purchase stops
|
||||
reporting ownership on the next Steam check.
|
||||
- **The upgrade path is the Steam store overlay** (`ActivateGameOverlayToStore`) from the greyed
|
||||
tier menu — Valve's own surface, so the purchase stays inside the Steam Wallet rules. Never a
|
||||
checkout of ours, never an external payment page.
|
||||
@@ -80,20 +89,20 @@ re-derived.
|
||||
|
||||
## Channels — Steam only
|
||||
|
||||
**Steam is the distributor.** One free app on the store, the paid edition as a Steam **DLC**, and
|
||||
no other vector: no direct download, no winget, no Scoop/Chocolatey, no Microsoft Store. The store
|
||||
page is the single authoritative source for the download, the stats and the reviews.
|
||||
**Steam is the distributor.** One paid app and its associated free Demo; no direct download,
|
||||
GitHub binary, winget, Scoop/Chocolatey, or Microsoft Store. The store page is the single
|
||||
authoritative source for the download, stats, and reviews.
|
||||
|
||||
- **Pro = Steam DLC, one-time.** Bought on the **store page**; the app only reads ownership
|
||||
(`ISteamApps::BIsDlcInstalled`). No purchase flow, no Wallet code, nothing to reconcile.
|
||||
- **The paid app is one-time purchase.** The demo's App ID is separate, but it shares the code and
|
||||
checks ownership of the paid app (`ISteamApps::BIsSubscribedApp`). No in-app checkout or license
|
||||
server.
|
||||
- **The website sends people to Steam.** It publishes the model and links the store page; it takes
|
||||
no payment, collects no email, and hosts no binaries. One shop, one price, one receipt.
|
||||
- **No in-app checkout of ours, ever.** Steamworks requires in-app purchases to use the Steam
|
||||
Wallet, so the only purchase surface is Steam's own store overlay.
|
||||
- **Boot shape:** the app ships `steam_api64.dll` beside the exe and calls `SteamAPI.Init`. That
|
||||
is a functional dependency, not the rejected "native helper DLL for IP protection" — Steam
|
||||
ownership cannot be checked without it. Launched without Steam running, the app must fall back to
|
||||
the **free profile** and say so, never refuse to start.
|
||||
- **Boot shape:** the app ships `steam_api64.dll` beside the exe and calls `SteamAPI.Init`. Launched
|
||||
without Steam running, the app falls back to the **free profile** and says so; it never refuses to
|
||||
start.
|
||||
- **Updates are Steam's.** The depot push is the update; the app keeps no updater and no version
|
||||
banner (the old "no self-updater" rule stays, for the same reason: it works differently).
|
||||
- **Portable mode is no longer a distributable.** `portable.txt` remains as a feature for anyone who
|
||||
@@ -135,8 +144,8 @@ repo for bugs that survive the friction of needing an account.
|
||||
- **The issue tracker is a filter, deliberately.** Bugs go to the public releases repo on GitHub,
|
||||
which requires an account; Steam Discussions stay pinned and unread; Announcements carry known
|
||||
issues and release notes. Steam refunds are Valve's own (2 h / 14 days) and cost us nothing.
|
||||
- **Nobody is locked out over a licence.** A billing hiccup drops the app to the free profile with
|
||||
a banner and a portal link — never a blocked stream, never a modal.
|
||||
- **Nobody is locked out of streaming.** If Steam ownership is unavailable or not owned, the app
|
||||
remains usable at the free output profile; no modal or account portal.
|
||||
|
||||
## The introductory price — the only promotion
|
||||
|
||||
@@ -176,15 +185,13 @@ pointless and a leak that is traceable — not an unbreakable binary.
|
||||
|
||||
- **The free tier is the real defence.** If the only delta is pixels above 921,600, a cracked
|
||||
build buys almost nothing and forfeits updates, support and the publisher's signature.
|
||||
- **Steam DRM is optional and not needed.** The DLC check is already the entitlement; adding
|
||||
- **Steam DRM is optional and not needed.** The app ownership check is the entitlement; adding
|
||||
Valve's DRM wrapper buys nothing a determined crack respects, and it can break an exe on a
|
||||
machine that never runs Steam.
|
||||
- **The distribution is not a handout.** Steam deliveries go through Valve's client and account, so
|
||||
there is no public zip for a mirror site to repackage, and no hash pin for us to maintain.
|
||||
- **Steam is the licence state** — a refunded DLC stops reporting ownership the next time Steam is
|
||||
asked, and the app drops to the free profile on the next start. Nothing to revoke by hand.
|
||||
- **The Steam purchase is in About** — a leaked build traces back to a Steam account, which Valve
|
||||
can action; we hold nothing to trace.
|
||||
- **Steam is the ownership state** — a refunded purchase stops reporting ownership on the next
|
||||
check. The app holds no customer account or license key.
|
||||
|
||||
⛔ **Rejected, do not re-propose:** a native helper DLL for "IP protection", obfuscation, hardware
|
||||
locking, remote code that downloads and executes, and gating asset downloads as an auth check.
|
||||
|
||||
+13
-13
@@ -5,10 +5,11 @@ you are about to re-open one of them. **Live commercial rules (prices, entitleme
|
||||
signing, support, promo) are in [`commercial.md`](commercial.md)**; the other live rules live in
|
||||
the subsystem files. This is the graveyard with cause of death.
|
||||
|
||||
## The business model — one price, settled 2026-11-01
|
||||
## The business model — one paid app plus a free Demo, settled 2026-11-02
|
||||
|
||||
**$79 one-time, raising to $99 on a stated date. Sold only on Steam, as a DLC on the free app.**
|
||||
The website publishes the model and links the store page; it never takes payment.
|
||||
**$79 one-time during the announced launch period, then $99 on the announced date.** Sold only on
|
||||
Steam as one paid app, with an associated free Demo using the same code. Demo ownership checks the
|
||||
paid app's Steam App ID. The website links the store page; it never takes payment or hosts binaries.
|
||||
|
||||
**What died the same day:**
|
||||
|
||||
@@ -27,9 +28,10 @@ The website publishes the model and links the store page; it never takes payment
|
||||
nothing" proposal was rejected: Steamworks requires in-app purchases to use the Steam Wallet, so
|
||||
it is a listing-level risk to save ~$24 a sale.
|
||||
|
||||
**Kept:** the free tier is the whole app at a 720p30 output profile, the paid delta is output
|
||||
quality only, and the app never sells anything itself — the greyed tier opens the Steam store
|
||||
overlay.
|
||||
**Kept at the time; superseded 2026-11-04:** the free tier was described as a 720p30 profile. The
|
||||
current policy is the whole app free at any supported resolution up to 30 fps; Pro unlocks 60 fps,
|
||||
regardless of resolution. The app never sells anything itself — the greyed tier opens the Steam
|
||||
store overlay.
|
||||
|
||||
## Commercial mechanics — died 2026-10-31 (same week they were written)
|
||||
|
||||
@@ -49,10 +51,10 @@ overlay.
|
||||
remote is Gitea). The route is a separate **`llamacasty-releases`** Gitea repo push-mirrored to a
|
||||
public GitHub repo: issues and assets public, code private, one publish direction.
|
||||
|
||||
## Distribution — Steam exclusive (2026-10-31, final)
|
||||
## Distribution — Steam exclusive (2026-10-31; app structure updated 2026-11-02)
|
||||
|
||||
**Steam is the only vector.** A free app on Steam with the paid edition as a Steam **DLC**; the
|
||||
monthly plan is sold outside Steam and never purchasable in a Steam build. Live rules:
|
||||
**Steam is the only vector.** One paid app and its associated free Demo; same code, no GitHub
|
||||
download, subscription, or second paid SKU. Live rules:
|
||||
[`commercial.md`](commercial.md).
|
||||
|
||||
**Why the other vectors died, all on the same day:**
|
||||
@@ -68,10 +70,8 @@ monthly plan is sold outside Steam and never purchasable in a Steam build. Live
|
||||
- **The public `llamacasty-releases` repo** survives only as the **issue tracker**; it holds no
|
||||
binaries, no manifest and no version file.
|
||||
|
||||
**The constraint that shapes the app:** Steamworks requires in-app purchases to use the **Steam
|
||||
Wallet**. Therefore the monthly is *stated*, never *sold*, inside the Steam build — no checkout, no
|
||||
price, no payment fields. The lifetime is plain DLC bought on the store page, so it needs no
|
||||
transaction code at all.
|
||||
**The purchase happens on the paid app's Steam store page.** The Demo links there; Steam owns
|
||||
checkout, delivery, updates, and the paid-app ownership result. The app contains no checkout flow.
|
||||
|
||||
## Support — decided 2026-10-31
|
||||
|
||||
|
||||
@@ -254,9 +254,10 @@ stays XAML (editing view); the compositor is the output view.
|
||||
`VisualBrush` samples all of `CanvasGrid`, and if the dims were inside it the box would show the
|
||||
dim shading — especially after the box is dragged away and the fixed capture slides under the dims.
|
||||
|
||||
- **Render the active tier's output rect directly** (`CompositorOptions {SourceRectX/Y/W/H,
|
||||
OutputWidth, OutputHeight}`, fed from `MainViewModel.OutputRect*`): 16:9 = full 1920×1080 1:1; the
|
||||
vertical 9:16 tier = composite the centered 607×1080 crop then bilinear-upscale to 1080×1920.
|
||||
- **Composite the active tier's crop at crop size** (`CompositorOptions {SourceRectX/Y/W/H,
|
||||
OutputWidth, OutputHeight}`, fed from `MainViewModel.OutputRect*`): 16:9 = full 1920×1080; the
|
||||
vertical 9:16 tier = centered 608×1080 crop. FFmpeg's `scale` filter resizes the raw BGRA frame
|
||||
to the selected output tier after it leaves the frame pump; `setsar=1` keeps square pixels.
|
||||
- **CPU posture:** with FFmpeg as a subprocess the master crosses a CPU readback to the pipe every frame
|
||||
anyway, so GPU compositing buys little at this layer count (2-3 live layers; static layers
|
||||
pre-composite once into a cached base). GPU effort belongs to **NVENC** (the encoder), not composition;
|
||||
@@ -363,9 +364,11 @@ seam:** `Func<Scene?>`, `Func<SceneElement, VideoFrame?>` resolver, `Func<Compos
|
||||
pin every frame's content, repeated backing-array identity proves the pool recycles),
|
||||
`Composite_OpaqueFullCover_Backdrop_CopiesEveryPixel_Into_Scratch` (memcpy branch + scratch
|
||||
sentinel). `FakeEncoder.SubmitFrameAsync` now snapshots bytes like the real stdin write — holding the
|
||||
reference would race legitimate recycling. Take 5 must show `avg render ≤ ~10ms, ≈300/300`; the
|
||||
vertical tier's final 1080×1920 `BilinearScale` still allocates fresh per frame (same GC lesson when
|
||||
someone streams vertical — recorded as a follow-up, not silently "done").
|
||||
reference would race legitimate recycling. Take 5 must show `avg render ≤ ~10ms, ≈300/300`.
|
||||
**Vertical/output scaling moved to bundled FFmpeg (2026-11-04):** the C# compositor no longer
|
||||
enlarges the 608×1080 crop to 1080×1920 on every frame. The bundled FFmpeg `scale` filter processed
|
||||
300 synthetic BGRA frames in 0.447s on this machine; that is a scaler-only microbenchmark, not a
|
||||
live-stream result.
|
||||
- **Slice 3 — chat raster-on-change (2026-09-04, take 5):** take 5 measured `138/300, avg render
|
||||
25.5ms` — the per-tick blits were cheap now, but `ResolveOutputFrame` → `RenderChatBox` ran a FULL
|
||||
WPF raster (`ChatBoxRenderer`: FormattedText + `RenderTargetBitmap` + CopyPixels + channel swap)
|
||||
@@ -378,6 +381,14 @@ seam:** `Func<Scene?>`, `Func<SceneElement, VideoFrame?>` resolver, `Func<Compos
|
||||
renderer — `Same()` for unchanged inputs, `NotSame()` on message/config change, null on empty).
|
||||
Accepted cost pending take 6: one slow tick (~15-25ms) per arriving message; if chat-burst frame
|
||||
loss shows up, the next slice moves the re-render off-tick (debounced, dispatcher-side).
|
||||
- **TASK 3.17 — static TextOverlay raster-on-change:** `TextOverlayRenderer.UpdatePreview` runs WPF
|
||||
`FormattedText` + `RenderTargetBitmap` only on the dispatcher when text, formatting, or box size
|
||||
changes. It publishes one immutable straight-alpha BGRA `VideoFrame` and a `WriteableBitmap`
|
||||
copied from those same pixels; preview and output therefore share the raster. `FramePump` calls
|
||||
only `RenderFrame` (a concurrent cache read), never WPF. Text is a static `Source`, so its raster
|
||||
participates in the static bake or above-split path; property changes invalidate the bake, and the
|
||||
full-render signature includes the resolved text frame. Its premultiplied WPF readback is
|
||||
unpremultiplied before entering `VideoFrame`, whose compositor contract is straight-alpha BGRA.
|
||||
- **Slice 5 — paste cache for non-opaque layers (2026-09-04, take 7/8 data):** the render/resolve
|
||||
split (added in the stamp commit) finally named the last thing honestly: `resolve ≈ 0` but
|
||||
`render 26-27ms` (124-135/300, ≈2.2x) — the chat fix HAD worked; the remaining cost was the
|
||||
|
||||
+2
-2
@@ -14,8 +14,8 @@ Read the file whose row matches the task. There is no required reading order.
|
||||
| [`output.md`](output.md) | The bundled `ffmpeg.exe` and the encoder subprocess (args, probe order, seams), local recording (REC/ON-AIR, filenames, private-broadcast close-out), and the WASAPI capture → filter → mix → named-pipe audio chain (TRAX, sync offset, ducking, limiter) | Touching ffmpeg args/bundling, encoder or recording behaviour, REC/ON-AIR, the audio mix and its meters, or loudness/sync problems |
|
||||
| [`environment.md`](environment.md) | LAN hosts/IPs/SSH and the Gitea push recipe, the Windows `dotnet.exe` build + run commands, the test suite map, and the hermetic real-`MainWindow` test rule | Before building, running, adding a test, or reaching any box on the LAN |
|
||||
| [`product.md`](product.md) | Brand/naming split, AI transparency, the licensing prohibitions, the freemium posture, auth/account assumptions, and packaging behaviour | Branding, naming, packaging, or a legal/third-party obligation |
|
||||
| [`commercial.md`](commercial.md) | The live commercial policy: model and prices, the output-profile cap (stream **and** recording), entitlement (`steamDlcOwned \|\| polarKeyValid`; Steam DLC for lifetime, Polar for the monthly, no server), **Steam as the only channel**, signing (moot), the Founding 200 promo, costs, **support policy**, anti-piracy posture | Any question about money or support: pricing, licence keys, what free gets, how it ships, what a sale costs |
|
||||
| [`youtube-api.md`](youtube-api.md) | Go-live ordering + the broadcast-binding fix, the private-only lock, health parsing, end-stream pre-flight, Test mode, and chat-insert shape | Touching go-live, broadcast lifecycle, health polling, streaming close-out, or the chat service |
|
||||
| [`commercial.md`](commercial.md) | The live commercial policy: model and prices, the output-profile cap (stream **and** recording), paid-app ownership (`BIsSubscribedApp`), **Steam as the only channel**, signing (moot), costs, **support policy**, anti-piracy posture | Any question about money or support: pricing, what free gets, how it ships, what a sale costs |
|
||||
| [`youtube-api.md`](youtube-api.md) | Go-live ordering + broadcast binding, the development/test Private safeguard and release visibility flow, health parsing, end-stream pre-flight, Test mode, and chat-insert shape | Touching go-live, broadcast lifecycle, health polling, streaming close-out, or the chat service |
|
||||
| [`overlays.md`](overlays.md) | The social bar, the native alert box (six animations, alert clip, ticker) and the chat box conventions — everything composited over the frame | Adding or changing anything drawn on top of the output |
|
||||
| [`decisions.md`](decisions.md) | Retired paths with cause of death: the paid-tier history and pricing, the free-forever era, Store IAP, the Store ordering, rejected monetization and anti-piracy ideas, and the permanent out-of-product list | You are about to re-open a decision that was already settled |
|
||||
| [`ui-surface.md`](ui-surface.md) | The control surface, scene strip, preview rail, hotkeys, broadcast drawer, toasts, transitions, brand logo, launch geometry, and the theme/properties-panel conventions | Changing anything the creator sees: layout, controls, theming, properties panel |
|
||||
|
||||
+8
-4
@@ -67,7 +67,8 @@ a machine prerequisite (a manifest framework-dependency + graceful-failure path,
|
||||
### Live encoder + RTMP push (TASK 4 ship step 3 — shipped 2026-08-12, plan in TASKS.md)
|
||||
|
||||
The encoder is a **thin orchestrator over `ffmpeg.exe`** — no H.264/AAC code in the app. It spawns the
|
||||
subprocess (path from `IFfmpegLocator`), feeds raw BGRA master frames into stdin, and parses `-stats`
|
||||
subprocess (path from `IFfmpegLocator`), feeds raw BGRA compositor-crop frames into stdin, lets FFmpeg
|
||||
scale them to the selected output tier, and parses `-stats`
|
||||
stderr lines into `StreamHealth` (bitrate/FPS/duration, dropped-from-frame-count). `FfmpegEncoder`
|
||||
(`IFfmpegEncoder` seam) holds: `StartAsync` (locate → probe `-encoders` → spawn → stderr loop →
|
||||
drain loop), `SubmitFrameAsync` (slice 10: bounded-queue ENQUEUE — never a pipe write; the drain task
|
||||
@@ -80,8 +81,10 @@ integration test fakes the whole subprocess (probe + encoder) with a Channel-bac
|
||||
`Complete()` is EOF (`null`), never a `ChannelClosedException`.
|
||||
|
||||
**Decisions (locked):** args are pure (`FfmpegArgs.Build`, no string building in the encoder):
|
||||
`-f rawvideo -pix_fmt bgra -video_size WxH -framerate FPS -i pipe:0` (**NO `-re`** — the FramePump
|
||||
is the pacer since slice 9, 2026-09-10; `-re` added a second, fighting clock on the rawvideo demux)
|
||||
`-f rawvideo -pix_fmt bgra -video_size inputWxH -framerate FPS -i pipe:0` (**NO `-re`** — the FramePump
|
||||
is the pacer since slice 9, 2026-09-10; `-re` added a second, fighting clock on the rawvideo demux).
|
||||
When input and output dimensions differ, each output block adds `-vf scale=outputW:outputH:flags=bilinear,setsar=1`;
|
||||
otherwise frames pass through at their existing dimensions.
|
||||
+ a **real audio input — the
|
||||
mixer writes IEEE-float stereo to a Windows named pipe** (`-f f32le -ar 48000 -ac 2
|
||||
-i \\.\pipe\ytllive_audio`, name via `EncoderOptions.AudioPipeName`; replaced the old `-f lavfi -i
|
||||
@@ -99,7 +102,8 @@ other — while the dual-block machinery stays generic.**
|
||||
`FfmpegEncoder.StartAsync` throws unless at least one output is enabled.
|
||||
**Encoder choice is probed from the binary's `-encoders` listing** (`FfmpegEncoderPicker`, pure):
|
||||
NVENC → QSV → AMF → OpenH264 fallback, **never libx264** (GPL; see Licensing). `EncoderOptions.VideoEncoder`
|
||||
forces one and skips the probe. `EncoderOptions` also carries W×H/FPS/bitrate from the quality tier and
|
||||
forces one and skips the probe. `EncoderOptions` carries raw input W×H from the compositor crop, final
|
||||
output W×H/FPS/bitrate from the quality tier, and
|
||||
`GopSize` = FPS×4. Constructed by `MainViewModel` since ship step 5 (`new FfmpegEncoder(new FfmpegLocator())`),
|
||||
driven by the `FramePump` below.
|
||||
|
||||
|
||||
+21
-11
@@ -98,16 +98,24 @@ published decisions (recorded in `TASKS/task-43-native-alerts.md`):
|
||||
EOF → freeze-frame → fade-out → drain (the clip branch of `Advance` **clears
|
||||
`_current` before `AdvanceToNext`** — the miss that the TASK 47 test caught). Audio
|
||||
forwards to the mixer sink scaled by `volume × fade`.
|
||||
- **Alert ticker (announcement strip):** `AlertTickerFrame` (in the layer) composes
|
||||
- **Ticker settings and alert feed:** `Models/Ticker.cs` is shared by the
|
||||
AlertBox and standalone `SourceType.Ticker` layers. Each source owns a distinct
|
||||
settings instance (`IsEnabled` + `DisplayMethod`); the existing `AlertShowTicker`
|
||||
and `AlertDisplayMethod` columns persist those settings on their own Source row,
|
||||
so this is additive without a schema migration. The standalone ticker can be
|
||||
added once in each scene and shows the in-memory active event plus queued events.
|
||||
Completed events are not retained as history. The AlertBox ticker uses the same
|
||||
renderer/settings shape but shows only the active event.
|
||||
- **AlertBox ticker (announcement strip):** `AlertBoxTickerFrame` composes
|
||||
"Author — Kind · amount" and hands it to `Services/Compositor/AlertTickerRenderer.cs` —
|
||||
a strip rasterized **on the UI thread once per text** (cached, unpremultiplied
|
||||
straight-alpha pill) then positioned by pure byte-math per call, so the pump calls it
|
||||
from its own thread with no locking. **It is a dynamic overlay, NEVER baked** (the
|
||||
social bar IS baked into `BakeStaticBase`): `SceneCompositor.Render` +
|
||||
`CompositeLayers` take a trailing `tickerFrame` threaded from
|
||||
`FramePump._alertTicker` (`Func<VideoFrame?>` ctor seam) through
|
||||
`FramePump._alertTicker` (`Func<Scene, VideoFrame?>` ctor seam) through
|
||||
`RenderScene`/`RenderFull` and **mixed into `BuildFullRenderSignature`** so a scroll
|
||||
changes the cache key. Toggled by `AlertShowTicker`.
|
||||
changes the cache key. It reads the active scene's AlertBox settings.
|
||||
- **It draws INSIDE the Stream Alerts box (creator ruling 2026-09-26).** Was a global
|
||||
1920px bar pinned to the top edge: *"the ticker should appear over the stream alerts
|
||||
video, not over the entire preview window"*. The strip is now rendered at the
|
||||
@@ -135,14 +143,16 @@ published decisions (recorded in `TASKS/task-43-native-alerts.md`):
|
||||
old fixed `140px/s` took ~17s per pass, so a 10s alert showed the message once.
|
||||
Same convention as the incumbent (Streamlabs: "Alert Duration" + "Text Delay", not a
|
||||
scroll-speed slider). Flash's off half returns null.
|
||||
- **A ticker still needs its own preview element, not a `Source`.** It is a frame-pump
|
||||
producer, so it never rides a per-element `Image`; `AlertOverlayLayer` takes a
|
||||
`tickerPreviewSink` and publishes from `RefreshAlertPreviews()` (UI thread only —
|
||||
the pump thread must never touch a `WriteableBitmap`); the VM writes it into one
|
||||
reused `WriteableBitmap` (`AlertTickerImageSource`/`AlertTickerVisible`) bound to
|
||||
`AlertTickerElement` in `PreviewPane.xaml`, mirroring `SocialBarElement`. The rect
|
||||
is re-raised on **every** published frame, not just on a bitmap resize, so a box
|
||||
moved or resized mid-alert tracks live.
|
||||
- **AlertBox preview:** `tickerPreviewSink` publishes from
|
||||
`RefreshAlertPreviews()` (UI thread only — the pump thread must never touch a
|
||||
`WriteableBitmap`); the VM writes the box-sized frame into the reused bitmap bound
|
||||
to `AlertTickerElement` in `PreviewPane.xaml`. The rect is re-raised on every
|
||||
published frame so a box moved or resized mid-alert tracks live.
|
||||
- **Standalone ticker preview/output:** it is an ordinary dynamic Source with a
|
||||
per-element frame. `AlertOverlayLayer` updates each scene ticker's reusable
|
||||
`VideoImageSource` on its UI timer for preview; the output resolver renders the
|
||||
same source with that source's Ticker settings. It is not the AlertBox global
|
||||
ticker callback, so each scene's layer and settings remain independent.
|
||||
- **Two measurement traps here:** a seamless marquee never goes blank (a wrapped copy
|
||||
enters as the pill clears), so count a pass by the pill's leading edge resetting, not
|
||||
by an empty frame; and the run is phase-started half a frame in, or the first
|
||||
|
||||
+8
-10
@@ -199,9 +199,9 @@ Live channel policy, the artifact and costs: [`commercial.md`](commercial.md). B
|
||||
mechanics: [`Distribution.md`](../../Distribution.md). Retired routes and cause of death:
|
||||
[`decisions.md`](decisions.md).
|
||||
|
||||
**One vector: Steam.** A free app on Steam with the paid edition as a Steam **DLC**; the monthly
|
||||
plan is sold outside Steam and is never purchasable inside a Steam build (Steamworks requires the
|
||||
Steam Wallet for in-app purchases). No direct download, no winget, no Microsoft Store.
|
||||
**One vector: Steam.** One paid app plus its associated free Demo. They use the same code; the Demo
|
||||
checks ownership of the paid app to unlock higher output quality. No direct download, no GitHub
|
||||
binary, no winget, no Microsoft Store, and no subscription.
|
||||
|
||||
**Consequences that are architecture, not packaging detail:**
|
||||
|
||||
@@ -209,7 +209,7 @@ Steam Wallet for in-app purchases). No direct download, no winget, no Microsoft
|
||||
app keeps nothing that downloads or executes, for the same Store-policy reason it always had.
|
||||
`ytLive.Tests/StorePackagingTests.cs` must keep asserting that no self-updater returns.
|
||||
- **⛔ `steam_api64.dll` is a functional dependency**, not the rejected "native helper DLL for IP
|
||||
protection" — Steam DLC ownership cannot be read without it. The app must start without Steam
|
||||
protection" — Steam app ownership cannot be read without it. The app must start without Steam
|
||||
running and fall back to the free profile.
|
||||
- **The MSIX package stays buildable but is now unused** — `scripts/package-msix.sh` (TASK 48
|
||||
slice 2) still produces `artifacts/LlamaCasty.msix` from `Packaging/Package.appxmanifest` +
|
||||
@@ -265,11 +265,10 @@ a non-Steam download exists:
|
||||
|
||||
### Distribution and licensing are independent — the channel never decides billing
|
||||
|
||||
The Steam build sells the lifetime as **Steam DLC** and reads it with `BIsDlcInstalled`, while the
|
||||
monthly plan is billed by **Polar** outside Steam. Steamworks forbids taking payment inside the app
|
||||
with anything but the Steam Wallet, so the app states where the monthly lives and never sells it.
|
||||
The lesson worth keeping: the licensing choice rides on a **monetization** decision, not on a
|
||||
distribution decision.
|
||||
Steam distributes the paid app and its associated free demo. They use the same code; the demo asks
|
||||
Steam whether the current account owns the paid app, and the answer controls access to higher output
|
||||
quality. The paid app is a one-time purchase: $79 during the announced launch period, then $99 on
|
||||
the announced date. The app's EULA must expressly allow commercial and monetized streaming use.
|
||||
|
||||
### ⛔ "Only when the user says so" is enforced by Windows, not by us
|
||||
|
||||
@@ -311,4 +310,3 @@ nothing. COPPA is very unlikely to bind (not child-directed), but note the enfor
|
||||
(FTC Sept 2025 *Apitor*: operators are responsible for what third-party components collect) —
|
||||
so the answer is a documented inquiry, and ours is "no third-party analytics or ad SDKs, no
|
||||
persisted chat or reward payloads."
|
||||
|
||||
|
||||
+15
-3
@@ -41,7 +41,7 @@ Placement math itself is covered by `WindowPlacementTests` (pure function, no HW
|
||||
- Layout persists to SQLite (`Microsoft.Data.Sqlite`); scenes/sources/asset bytes stored in the DB, asset identity is a SHA-256 content hash (1:M reuse, no file paths — assets are always available). Loaded sources always derive `IsBackground` from `Type` (OR'd with the persisted column, so legacy DisplayCapture backdrops keep their flag) — pre-derivation rows with `IsBackground=0` heal on load
|
||||
- **Element z-order is a unified space, not per-table (2026-09-20):** `Source` and `WebcamSceneConfig` live in different tables, and a webcam can be interleaved between sources in a scene. Both tables' `SortOrder` is stamped from the SAME counter — the element's index within `scene.Elements` — and `Load` merges the two tables' rows per scene by that shared z (tie-break sources-first, so legacy per-table rows read back stacked the way they were saved). Never re-introduce per-type sort counters + "append all Sources then all configs", or a webcam slotted mid-stack reverts on reload (creator-reproduced bug).
|
||||
- **Five-scene catalog (`Models/SceneCatalog.cs`):** the product is exactly Starting/Live/BRB/Chat/Ending — work with less, never more (the escape hatch for "more" is OBS). Scenes are matched **by name** (`SceneCatalog.Is`, case-insensitive trim). Empty DBs seed all five; the scenes-header "+" (`ShowAddScene`/`MissingScenes` on `MainViewModel`) only appears while ≥1 canonical scene is missing and its menu lists only the missing ones, re-adding them by name (`AddSceneCommand`). Renaming a canonical scene makes it missing again; `AddScene` rejects non-canonical names.
|
||||
- Theming: all custom styles live in `Themes/Controls.xaml`, merged in `App.xaml` — never duplicate styles per-window (dialog duplicates were consolidated into this dictionary). **Every control in the app carries a theme style (TASK 58, 2026-10-21):** an unstyled `ComboBox`/`CheckBox` falls back to the **light WPF system template** (grey border, system arrow, white popup list) and sticks out of the dark panels — the properties panel's drop-downs had been hand-painted with a `Background`/`Foreground` instead of a style (the chat Font drop-down was a copy of that), and every check box was default. The rule now: `ComboBox` → `YtComboBox`, `CheckBox` → `YtCheckBox`, the dense properties fields → `YtTextBoxCompact` (`BasedOn YtTextBox`: 11px, padding 4,2) — **no hand-painted `Background`/`BorderBrush` on a themed control**. `ytLive.Tests/PropertiesStyleTests.cs` is the guard: it fails if an unstyled drop-down, check box or properties field comes back — **but that guard sees only those three control types**, so a picker built as something else (a `Button` + a code-built `ContextMenu`) slipped through it and the creator had to ask twice (TASK 63)
|
||||
- Theming: all custom styles live in `Themes/Controls.xaml`, merged in `App.xaml` — never duplicate styles per-window (dialog duplicates were consolidated into this dictionary). **Every control in the app carries a theme style (TASK 58, 2026-10-21):** an unstyled `ComboBox`/`CheckBox` falls back to the **light WPF system template** (grey border, system arrow, white popup list) and sticks out of the dark panels — the properties panel's drop-downs had been hand-painted with a `Background`/`Foreground` instead of a style (the chat Font drop-down was a copy of that), and every check box was default. The rule now: `ComboBox` → `YtComboBox`, property-panel switches → `PropertyPillCheck` (plain check boxes may use `YtCheckBox`), the dense properties fields → `YtTextBoxCompact` (`BasedOn YtTextBox`: 11px, padding 4,2) — **no hand-painted `Background`/`BorderBrush` on a themed control**. `ytLive.Tests/PropertiesStyleTests.cs` is the guard: it fails if an unstyled drop-down, check box or properties field comes back — **but that guard sees only those three control types**, so a picker built as something else (a `Button` + a code-built `ContextMenu`) slipped through it and the creator had to ask twice (TASK 63)
|
||||
- **The properties panel is a HIERARCHY (TASK 59, 2026-10-21):** a **title owns its content, and the content is indented one level (10px) under it** — the creator's ruling: *"the 'drag to move' help text needs to be indented under 'Mirror Rect' to show parentage … same with Geometry, Border Color, Border Thickness … Remove should be indented under Background as should Blur … Strength should be indented under Blur."* So the webcam's block reads `Shape` (the mirror switch + the shape choice) → `Geometry` → `Border Color` → `Border Thickness` → `Background` (Remove, Blur, and Strength one level deeper under Blur), each title SemiBold at 11px `#a0a0b0` like the existing ones. **The shape is a CHOICE, not a toggle button:** `IsRectShape`/`IsRoundShape` on `SceneElement` are two settable bools and the panel shows them as two `SegmentToggle` options (Rect | Circle) with **Rect selected by default** (an element that never picked a shape is a rect) — the old single button showed the *next* action ("Round"), which is why the creator could not tell what the camera was. The mutual exclusion lives in the MODEL (each setter flips through `ToggleClipShape()` and both props are re-raised), **not** in a WPF `RadioButton` group — a RadioButton group unchecks its siblings by setting `IsChecked` in code, which silently kills the binding (stackoverflow 7376373). Each setter always re-raises, so a refused write (the vertical Chat layout is rect-only) makes the UI snap back. Guard: `ytLive.Tests/PropertiesHierarchyTests.cs`
|
||||
- **One list for the offered colours (TASK 60):** the border-colour drop-down builds its menu from
|
||||
`Models/BorderColorPalette.cs` (`NamedColor(Hex, Name)` + `NameOf(hex)` — **empty** for a colour
|
||||
@@ -90,6 +90,7 @@ Placement math itself is covered by `WindowPlacementTests` (pure function, no HW
|
||||
`FormattedText` — **measure, don't guess**) plus the field's padding and border. Guard:
|
||||
`ytLive.Tests/GeometryFieldWidthTests.cs`
|
||||
- Resolution tiers: 1080p60@8 (default) → 1080p30@8 → 720p60@6 → 720p30@6 → **Vertical 1080p30@8 and 1080p60@8 (9:16, 1080×1920)**. The composition master frame is **always 1920×1080** — a tier is an output rect + target resolution over that master, so source geometry is never rewritten (no rounding drift). 16:9 tiers use the full frame; vertical tiers use a centered **607×1080** window and the preview dims the cropped side strips at 55% black with an accent outline (semi-crop — the cut area stays visible). A resolution badge in the preview corner shows the active tier. The resolution picker and live readouts (bitrate/FPS/dropped/duration) are in the top-left cell above the layers panel; the bottom bar is an empty height-preserving spacer. A **tooltip** explains finding upload bandwidth — an in-app speed test was deliberately dropped (unreliable). The future encoder crops the master to the rect and scales to the tier's Width×Height
|
||||
- **Locked output tiers stay visible and clickable.** They are dimmed for non-owners. Choosing one keeps the free tier selected and opens About in-app; only the explicit llamacasty.com link opens a browser.
|
||||
- Crash diagnosis: `AppLog` writes startup checkpoints to `%APPDATA%\ytLlive\startup.log`; `App.xaml.cs` logs `DispatcherUnhandledException`/`AppDomain.UnhandledException`. When WPF won't run from WSL, this log is how you find the failure (it caught the `MenuItemRole.Separator` XAML crash and the ComboBox SelectionBoxItem bug). **The log is SIZE-CAPPED (TASK 55, 2026-10-22):** at 5 MB `startup.log` rolls to `startup.1.log` (one rolled copy kept) — the policy lives in `Helpers/LogRotation.cs` as a pure function over a path, so `AppLog`'s 156-call-site surface never changes and the rule is testable on its own. Guard: `ytLive.Tests/AppLogRotationTests.cs`
|
||||
|
||||
## ⚠️ Known issue: screens/layers settings audit — RETIRED INTO THE GOLD PASS (2026-08-22, dispositioned 2026-09-01)
|
||||
@@ -102,9 +103,9 @@ The app is a **director's control surface**, not an editor. This replaces the OB
|
||||
|
||||
**Current layout (2026-10-26 redesign commits):** `MainWindow` has a top bar and health banner, then a two-column work area with a fixed 360px left column and preview column. Its top row holds stream readouts/resolution on the left and the five-scene strip above the preview; the lower row holds the left panel and central preview. The bottom bar is now only a height-preserving spacer.
|
||||
|
||||
- **Left panel:** `EditingActive` is an explicit `EDIT` toggle, not a mode inferred from `StreamStatus`. On = scene layers + properties; off = YouTube chat. Go Live turns editing off and stopping restores it; recording does not automatically switch it off. The staged scene is the preview/edit target; `LiveScene` is the program output.
|
||||
- **Left panel:** `EditingActive` is an explicit `EDIT` toggle, not a mode inferred from `StreamStatus`. On = scene layers + properties; off = a two-tab panel with **Live Chat** first/default and **Logs** second. Logs is a read-only view of up to 500 `AppLog` entries captured in this process; it does not load older `startup.log` contents or change file logging/rotation. Entries wrap and use the theme's type colors. Explicit Class 1/2 severity is independent of message type/source: Class 1 sorts above Class 2 in the pinned group; acknowledging unpins the entry but retains it in the chronological list. The staged scene is the preview/edit target; `LiveScene` is the program output.
|
||||
- **Settings is a right-edge drawer, not a modal (2026-11-01):** the gear rail tab opens `Controls/SettingsDrawer` in the same animated-width stack as Stream Settings / YPP / Socials / TEST (`SettingsDrawerOpen`, exclusive with the other four). Categories are section headings with their controls indented under them — Zoom holds *Zoom view shape* and *Zoom box follows*. The app menu (Report Bug / Feature Request / About) hangs off a **right-click on the LlamaCasty wordmark**; left-click still opens About. The modal shell keeps only Bug / Feature / About, which is why it no longer needs a scroll viewer.
|
||||
- **Top bar:** the `EDIT` toggle sits beside the REC/ON-AIR session controls; the brand and account surfaces remain at the sides. Its **right edge aligns with the layers panel below** (`TopBar` column 0 is a fixed **352** = work-area margin 8 + 360px column − the bar's own 16px padding), so the button and the panel share an edge. The **settings gear moved to the preview rail** (2026-11-01): first tab, orange (`Brush.Attention`), gear icon only — left-click opens the app menu (Settings / Bug / Feature / About), right-click goes straight to App Settings.
|
||||
- **Top bar:** the `EDIT` toggle sits beside the REC/ON-AIR session controls; the brand and account surfaces remain at the sides. Its background uses the idle surface color when stopped. Normal ON-AIR stays `Brush.Primary` red while connecting and turns `Brush.Positive` green only after YouTube reports the broadcast `live`; stopping restores the idle color. TEST stays gold for the entire test session. Its **right edge aligns with the layers panel below** (`TopBar` column 0 is a fixed **352** = work-area margin 8 + 360px column − the bar's own 16px padding), so the button and the panel share an edge. The **settings gear moved to the preview rail** (2026-11-01): first tab, orange (`Brush.Attention`), gear icon only — left-click opens the app menu (Settings / Bug / Feature / About), right-click goes straight to App Settings.
|
||||
- **Preview gestures (2026-11-01):** *edit mode* — left selects/drags/resizes, right opens the context menu, **Ctrl+right toggles the zoom box**. *Preview mode* — **left toggles the zoom box**, right opens the context menu; **Ctrl+right does nothing at all** (no chord while live or recording — one hand, always). ⚠️ **Both the Down and the Up event must be handled for a Ctrl+right-click**: WPF's `PopupControlService` opens a context menu on right-button-**up**, so suppressing only the down event leaves the menu appearing under the gesture (found 2026-11-01 in edit mode). Menus exist on the webcam layer and the backdrop; every other layer right-clicks through to the backdrop menu. Left-dragging with the zoom box open moves it, in both modes. **Ctrl-down closes it**: pressing Ctrl while the box is open dismisses it, so the same hand that opened it can put it away (`MainWindow_PreviewKeyDown`; the box cannot cancel itself on creation because Ctrl is already down before it exists). **Follow mode** is a Settings pill, default **off = Anchor**: with Follow on, dragging re-anchors the capture to the box centre (`ZoomFollowsBox`, persisted as the `Zoom.FollowBox` setting). **The gesture activates the box at the cursor**, not merely dismisses it: `ActivateZoom` no longer refuses when idle (it used to return unless `IsLive || IsRecording`, and `ToggleZoom` only ever dismissed — so the gesture did nothing at all outside a session until 2026-11-01). The social bar consumes its own click (toggle top ⇄ bottom), and the preview's tunnelling handler steps aside for it — otherwise the bar is unclickable in preview mode and drags the layer beneath it in edit mode.
|
||||
- **Preview undo/redo (2026-11-01):** `Ctrl+Z` / `Ctrl+Shift+Z`, window-level bindings, live only while `EditingActive` — undo with the preview showing program output would rewrite a scene nobody is looking at. `Models/PreviewHistory` snapshots element **references** plus geometry, visibility, order and membership, so move / resize / reorder / add / remove / hide all undo in one step per gesture (`PushPreviewUndo` is called once at drag or resize start, never inside the move loop). Property edits (colour, opacity, border, font) are **not** captured yet — stated in the type's doc comment so nobody assumes otherwise. The stack clears on layout load. Guard: `ytLive.Tests/PreviewHistoryTests.cs`.
|
||||
- **Developer affordances are compiled out of release, not hidden.** Any control that must never ship (today: the all-resolutions override) lives in its own `UserControl` whose `Page` and `Compile` items are removed by `ytLive.csproj` when `$(Configuration) != 'Debug'`, and is added to its host at runtime inside `#if DEBUG` (`OverlayHost`). A collapsed row still exists in the visual tree of every shipped copy; "not compiled" is the only claim worth making. Label such rows with `Brush.Attention` (orange) while they are visible in Debug. Guard: `ytLive.Tests/OutputProfileTests.cs` asserts the build rule, and `Helpers/DevBuild.cs` is the single source for "is this a dev build".
|
||||
@@ -185,3 +186,14 @@ skip (would spam every launch). Still modal by design: SocialsDialog's slot-dele
|
||||
- The image lives in the asset table; the settings table keeps its asset id and file name
|
||||
(`BrandLogoAssetId` / `BrandLogoFileName`). Eye visibility is the layer's ordinary per-scene `IsVisible`.
|
||||
- Default placement: top-left (32,32), 240x120 master px.
|
||||
- **Properties panel typography (2026-11-01):** one hierarchy for every section, defined in
|
||||
`Themes/Controls.xaml` — `PropertyHeader` (category: 11px semibold, `Brush.OnSurface`, the
|
||||
brightest text), `PropertySubHeader` (a field label inside it: 10px semibold, indented 10,
|
||||
`OnSurfaceVariant`), `PropertyFieldLabel` (a label sharing a row with its control: same
|
||||
typography, vertically centred, no margin), `PropertyHint` (the explanatory sentence: 10px,
|
||||
wrapped, dim). Colour carries the grouping — bright for the thing being edited, dim for its
|
||||
labels and help — and no new palette entries were needed. ⛔ No literal hex in the panel: the
|
||||
four `#888`/`#666`/`#555` values that had crept in are now `OnSurfaceVariant` /
|
||||
`OnSurfaceDisabled`. ⛔ **Every checkbox in the properties panel is a pill switch**
|
||||
(`PropertyPillCheck`, 32×16 track with an animated thumb) — a 14px box reads as a form, a switch
|
||||
reads as a setting; `IsChecked` semantics are untouched so all bindings still work.
|
||||
|
||||
+26
-78
@@ -1,7 +1,7 @@
|
||||
# YouTube Live API — design constraints (do not violate)
|
||||
|
||||
Go-live ordering and binding, the deliberate private-only lock, health parsing, end-stream
|
||||
pre-flight, Test mode and chat-insert rules. Read before touching go-live, broadcasts, health,
|
||||
Go-live ordering and binding, development/test privacy safeguards and release visibility, health
|
||||
parsing, end-stream pre-flight, Test mode and chat-insert rules. Read before touching go-live, broadcasts, health,
|
||||
or the chat service. The native alert box built on these events lives in
|
||||
[`overlays.md`](overlays.md).
|
||||
|
||||
@@ -107,77 +107,23 @@ These are the hard facts behind every decision. Full list in `TASKS.md`.
|
||||
register as active server-side, which is a genuine step forward — combined with the 99MB muxed
|
||||
in the earlier run, the stream half of the system is now verified healthy. The broadcast half is
|
||||
still unobserved pending a re-run.
|
||||
- **Visibility picker (TASK 9 item 6) — DELIBERATE TEST-PHASE LOCK, not drift (2026-09-01).**
|
||||
The "always Private" enforcement in `YouTubeStreamService.CreateBroadcast` stays while the creator
|
||||
runs multi-month real-world testing: non-private test streams would clutter the channel with VODs
|
||||
highlighting where the product breaks. `recordFromStart`/DVR stay ON — Private VODs are invisible
|
||||
to subscribers and serve as post-mortem review tapes; bulk-delete pre-GA. The unlock is a
|
||||
deliberate final-pass item in **TASK 36 (gold pass)**, wired with the dialog selection — never
|
||||
opportunistic. The PRIVATE badge keeps showing when the stream is actually private.
|
||||
- **⛔⛔ STOP. The always-Private lock is a DECISION, not a bug, not drift, not an open question.
|
||||
(Creator ruling, restated 2026-10-04 after being asked about it AGAIN.)**
|
||||
`privacyStatus = "private"` hardcoded in `YouTubeStreamService.CreateBroadcast` is
|
||||
**exactly right as written. Do not change it, do not "unlock" it, and — this is the part I keep
|
||||
getting wrong — do not raise it as a question, an observation, a suggestion, or a
|
||||
"heads-up".** The creator has now answered this more than once.
|
||||
- **What it becomes:** the hardcoded lock is replaced by a real, user-selectable
|
||||
visibility option in the Go-Live dialog — **at v1, not before.** Not at 1.0-candidate, not
|
||||
at GA prep, not opportunistically during any other unit.
|
||||
- **Why it exists:** pre-v1 test streams would otherwise publish VODs of a product still
|
||||
breaking, in public, to subscribers. Private keeps those tapes usable for post-mortem review
|
||||
while staying invisible to the channel's audience.
|
||||
- **Do not let these look like symptoms of the one they resemble.** All are working as designed:
|
||||
`privacy=private` in the broadcast diag line; the PRIVATE badge; `recordFromStart`/DVR ON;
|
||||
and the absence of any delete call in the codebase (there is no delete feature, by design at
|
||||
this stage — "can we delete the test VODs" is a *retired* question, answered 2026-10-04:
|
||||
`videos.delete` accepts `youtube.force-ssl` which we already hold, private is a visibility
|
||||
setting not a deletion lock, and anyway a broadcast that never reaches `live` never becomes a
|
||||
VOD at all).
|
||||
- **`UpdateBroadcast` is NOT the unlock — and it is also currently DEAD CODE. (Fact corrected
|
||||
2026-10-04: an earlier version of this note said `CreateBroadcast:112` /
|
||||
`UpdateBroadcastMetadataAsync`, both wrong.)** The method that can change a live broadcast's
|
||||
privacy is `YouTubeStreamService.UpdateBroadcast(broadcastId, meta)`, which sends
|
||||
`privacyStatus = meta.Visibility ?? "private"`. **It has no production caller** — the only
|
||||
caller in the repo is `BroadcastPullOutTests.cs:120`. So `CreateBroadcast` hardcoding private
|
||||
is not merely backed up by convention, it is the *only* thing enforcing it.
|
||||
- **⇒ Is a Go Live video private? YES — verified 2026-10-04, and this answers a standing
|
||||
creator doubt.** Three independent hardcodes (`CreateBroadcast` → `"private"`;
|
||||
`GoLiveViewModel.Visibility => "Private"`; `BeginTestStream` → `StreamVisibility = "Private"`)
|
||||
plus one unreachable method. There is no path by which a broadcast this app creates can become
|
||||
non-private today. The creator's actual concern — "do not populate my channel with videos
|
||||
showing the app at its worst" — is fully met.
|
||||
- **⛔ THE LANDMINE, and the reason the above matters:** `LiveBroadcastFormViewModel` still
|
||||
exposes `VisibilityOptions = {private, unlisted, public}` and `UpdateBroadcast` would happily
|
||||
honour it. **The v1 visibility option is the first thing that will ever wire `UpdateBroadcast`
|
||||
up — so v1 is exactly when this guarantee ends, by the creator's own choice.** When that
|
||||
happens, private stops being guaranteed and becomes a default the user can change. That is
|
||||
intended at v1; it is a disaster before v1. Do not wire `UpdateBroadcast` into any
|
||||
production path before v1, and do not "fix" the dead-code smell by calling it.
|
||||
- **⛔ The TEST button is HIDDEN at v1 — it exists only because of the lock above (creator
|
||||
ruling 2026-10-04).** This was **absent from the map entirely** until that date, which is how the
|
||||
dry-run framing below came to be the only recorded one. Both are true, and the second is the
|
||||
load-bearing one:
|
||||
- **Its real job is being the guaranteed-private path.** `BeginTestStream` hard-sets
|
||||
`StreamVisibility = "Private"` (`MainViewModel.Streaming.Operations.cs:105`), so Test can never
|
||||
publish. That is *why* it exists for the whole pre-v1 phase — a dry run you cannot leak.
|
||||
- **At v1 it is redundant and gets hidden**, because once visibility is a real dialog option the
|
||||
user picks Private themselves and a separate "safe" button no longer earns its place. Hiding it
|
||||
is part of the v1 visibility work, not a separate cleanup.
|
||||
- **The pre-existing "dry run" framing is now known to be incomplete** (`ShowTestButton`'s own
|
||||
comment calls it "a dry-run of the livestream"). Do not read that comment as the reason the
|
||||
button must persist, and do not re-derive the button's purpose from it. If a dry run is still
|
||||
wanted at v1 it must be re-justified on its own merits — that question is **open, and is for
|
||||
v1, not now.**
|
||||
- **It is a computed predicate, not a setting** — `ShowTestButton => ShowStreamActions &&
|
||||
IsConnected` (`MainViewModel.Streaming.cs:182`), feeding both `CanStartTest` and the XAML
|
||||
visibility. So the v1 hide is a one-line predicate change, not a settings migration.
|
||||
- **Not a v1 blocker:** the Test route is the *same* pipeline as Go Live —
|
||||
`BeginTestStream` → `StartStreamingSession(alsoRecord: false)` → `PrepareAndStartLiveAsync` —
|
||||
so Test and Go Live create identical broadcasts (`boundStreamId`, `enableAutoStart`). Test
|
||||
differs only in never recording (`alsoRecord: false`) and forcing Private. **This is why Test
|
||||
is the correct way to run any diagnostic stream**, and why a Test result is evidence about Go
|
||||
Live.
|
||||
- **Full broadcast form (TASK 9 item 7) — RESCOPED 2026-09-01** — the core editable fields ship (since 2026-08-24) as the always-visible **Text drawer**: title, description, tags, visibility, made-for-kids, live-editable; scheduling ships as **TASK 34** (drawer ☑ Scheduled + adoption at Start). The old **Advanced tab is permanently out of product** (the 10% margin — see TASKS.md → "Out of product"): latency locked `low`, DVR/record-from-start locked on, embed/projection/CC/region fixed at sane defaults, invisible — every exposed field is a support ticket. `categoryId` is removed from `CreateBroadcast` (not a `liveBroadcast` field, silently ignored). Monetization enablement (if ever needed) rides the reward-events chain via `liveBroadcasts.update`, not a form field. Go-live order (TASK 9, shipped 2026-08-16):
|
||||
- **Visibility policy (creator clarification 2026-11-04):** force Private during development and
|
||||
test runs so unfinished captures cannot accidentally publish. This is a safeguard for those
|
||||
environments, not a permanent product lock. The released creator flow offers Private, Unlisted,
|
||||
or Public, defaults to Private, and supports changing visibility while live. A PRIVATE badge is
|
||||
shown whenever the current broadcast is private.
|
||||
- **Release visibility flow (TASK 9, Day 2):** persist a default visibility (Private initially);
|
||||
ON-AIR opens a preflight with that default selected and an explicit “Start Broadcast — [scope]”
|
||||
action. Apply the selected scope when creating a broadcast and through
|
||||
`YouTubeStreamService.UpdateBroadcast` for live changes. Development/test runs stay forced Private.
|
||||
- **Implementation status:** current code still forces Private for all broadcasts. Do not mistake
|
||||
that description of today's code for the target Release behavior; the selectable flow is queued,
|
||||
not implemented. `UpdateBroadcast` currently has no production caller; Day 2 wires it for live
|
||||
visibility changes, while development/test runs remain Private.
|
||||
- **Test mode remains Private.** `BeginTestStream` forces `StreamVisibility = "Private"`, so retain
|
||||
that safeguard after normal Release go-live offers a choice. The existing v1 decision to hide the
|
||||
separate TEST button remains tracked with the release flow.
|
||||
- **Full broadcast form (TASK 9 item 7) — RESCOPED 2026-09-01** — the core editable fields ship (since 2026-08-24) as the always-visible **Text drawer**: title, description, tags, and made-for-kids; release visibility is the Day 2 flow above. Scheduling ships as **TASK 34** (drawer ☑ Scheduled + adoption at Start). The old **Advanced tab is permanently out of product**: latency locked `low`, DVR/record-from-start locked on, embed/projection/captions fixed at sane defaults, invisible. `categoryId` is video metadata, not a `liveBroadcast` field; category selection, tag updates through the video API, and thumbnail upload remain follow-ups. Monetization enablement (if ever needed) rides the reward-events chain via `liveBroadcasts.update`, not a form field. Go-live order (TASK 9, shipped 2026-08-16):
|
||||
`BeginGoLive` → `PrepareAndStartLiveAsync` — ensure the reusable stream (`GetOrCreateReusableStreamAsync`,
|
||||
cache it), create the broadcast bound to it (`CreateBroadcast(..., stream.Id)` → `boundStreamId`),
|
||||
THEN start the pump (the URL must exist before `FramePump.StartAsync`, which reads it once). Failure →
|
||||
@@ -196,8 +142,10 @@ These are the hard facts behind every decision. Full list in `TASKS.md`.
|
||||
polls `liveStreams.list?part=status`; render nothing on `good`/`ok`/`noData`, surface a banner only
|
||||
on `configurationIssues[]` with `warning`/`error` severity. The decision is the pure
|
||||
`Services/StreamHealthReporter.BannerFor` (null text = no banner; error beats warning). The VM polls
|
||||
every **30s while live** (`DispatcherTimer` `_healthPollTimer`, first poll right after go-live) and
|
||||
clears on End via `ResetHealth`; poll failures log only. **Parsing (fix 2026-09-22):** the real API
|
||||
every **2s until YouTube confirms `lifeCycleStatus=live`**, then every **30s** (`DispatcherTimer`
|
||||
`_healthPollTimer`, first poll immediately after the frame pump starts); the top bar turns green only
|
||||
on that confirmed `live` state. It clears on End via `ResetHealth`; poll failures log only.
|
||||
**Parsing (fix 2026-09-22):** the real API
|
||||
nests `status.healthStatus = {status, lastUpdateTimeSeconds, configurationIssues[]}` — reading it as
|
||||
a string crashed every poll (`requires an element of type 'String'`, seen 2026-09-22 on both test
|
||||
sessions); the parse now reads the nested `.status`/`configurationIssues` and tolerates the old
|
||||
@@ -213,9 +161,9 @@ These are the hard facts behind every decision. Full list in `TASKS.md`.
|
||||
mono/stereo only. YouTube flags violations via health status.
|
||||
- **Broadcast ID == Video ID** — one ID tracks status, health, and the auto-created VOD
|
||||
(`recordFromStart` + `enableDvr` default true).
|
||||
- **Test Stream mode (TASK 41, SHIPPED 2026-09-22)** — every broadcast is private-only anyway
|
||||
(see the visibility lock above), so "Test" is a **session variant over the real go-live
|
||||
pipeline**, not a new mode: `BeginTestStream` → `StartStreamingSession(alsoRecord:false)`
|
||||
- **Test Stream mode (TASK 41, SHIPPED 2026-09-22)** — Test remains forced Private even when
|
||||
normal Release go-live offers a visibility choice, so "Test" is a **session variant over the real
|
||||
go-live pipeline**, not a new mode: `BeginTestStream` → `StartStreamingSession(alsoRecord:false)`
|
||||
(the dialog path was extracted into this shared boot — no Go Live dialog for a Test, no
|
||||
`BroadcastForm.CaptureGoLive` "last live" stamp, no recording). `IsTestStream` flips the face:
|
||||
gold top bar + glow (`#9c6f1c`), a **TEST** badge, and the End button reads **"End Test"**;
|
||||
|
||||
@@ -72,4 +72,4 @@ PY
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Next: the entitlement seam (IEntitlement + SteamApps.BIsDlcInstalled)."
|
||||
echo "Next: configure the paid Steam App ID for IEntitlement (SteamApps.BIsSubscribedApp)."
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
using System;
|
||||
using System.Windows;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Threading;
|
||||
using Xunit;
|
||||
using ytLive.Controls;
|
||||
using ytLive.Models;
|
||||
using ytLive.Services.Entitlement;
|
||||
using ytLive.ViewModels;
|
||||
|
||||
namespace ytLive.Tests;
|
||||
@@ -41,11 +48,11 @@ public sealed class AboutHubTests
|
||||
Assert.True(vm.IsAboutOpen);
|
||||
Assert.False(vm.IsLicensesOpen);
|
||||
|
||||
// The hub links resolve to real destinations (channel + mastodon +
|
||||
// coffee are live; premium is the tabled billing seam).
|
||||
// The hub links resolve to real destinations.
|
||||
Assert.Equal("https://youtube.com/@llamachileshop", MainViewModel.ChannelUrl);
|
||||
Assert.Equal("https://mastodon.llamachile.tube/@gramps", MainViewModel.MastodonUrl);
|
||||
Assert.Equal("https://buymeacoffee.com/llamachiley", MainViewModel.CoffeeUrl);
|
||||
Assert.Equal("https://llamacasty.com", MainViewModel.PremiumUrl);
|
||||
|
||||
// The licensing panel loads the full shipped notices text in-app.
|
||||
vm.OpenLicensesCommand.Execute(null);
|
||||
@@ -67,4 +74,92 @@ public sealed class AboutHubTests
|
||||
try { System.IO.File.Delete(tempDb); } catch { /* best-effort cleanup */ }
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Free_user_gets_1080p30_and_cannot_select_or_change_to_60fps()
|
||||
{
|
||||
_app.Run(() =>
|
||||
{
|
||||
var tempDb = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"llamacasty-upgrade-{Guid.NewGuid():N}.db");
|
||||
MainViewModel.LayoutPathOverride = tempDb;
|
||||
MainWindow? window = null;
|
||||
try
|
||||
{
|
||||
window = new MainWindow();
|
||||
var vm = (MainViewModel)window.DataContext;
|
||||
vm.EntitlementOverride = new FakeEntitlement(false,
|
||||
"Steam could not verify ownership. Open Steam, sign in, then restart LlamaCasty.");
|
||||
window.Show();
|
||||
window.UpdateLayout();
|
||||
|
||||
var freeTier = vm.QualityOptions.Single(option => option.Display == "1080p30");
|
||||
var lockedTier = vm.QualityOptions.First(option => option.Fps == 60);
|
||||
Assert.Same(freeTier, vm.SelectedQuality);
|
||||
|
||||
var errorPanel = (Border)window.FindName("EntitlementErrorPanel");
|
||||
var errorText = (TextBlock)window.FindName("EntitlementErrorText");
|
||||
Assert.Equal(Visibility.Visible, errorPanel.Visibility);
|
||||
Assert.Contains("Open Steam, sign in", errorText.Text);
|
||||
|
||||
var picker = (ComboBox)window.FindName("QualityPicker");
|
||||
Assert.True(vm.CanChangeQuality);
|
||||
Assert.True(picker.IsEnabled);
|
||||
picker.IsDropDownOpen = true;
|
||||
picker.UpdateLayout();
|
||||
Dispatcher.CurrentDispatcher.Invoke(() => { }, DispatcherPriority.Loaded);
|
||||
|
||||
var lockedItem = (ComboBoxItem)picker.ItemContainerGenerator.ContainerFromItem(lockedTier)!;
|
||||
Assert.False(lockedItem.IsEnabled);
|
||||
var struckText = FindVisualChild<TextBlock>(lockedItem);
|
||||
Assert.NotNull(struckText);
|
||||
Assert.Contains(struckText!.TextDecorations, decoration =>
|
||||
decoration.Location == TextDecorationLocation.Strikethrough);
|
||||
|
||||
picker.SelectedItem = lockedTier;
|
||||
picker.UpdateLayout();
|
||||
|
||||
Assert.Same(freeTier, vm.SelectedQuality);
|
||||
Assert.False(vm.IsAboutOpen);
|
||||
|
||||
picker.IsDropDownOpen = false;
|
||||
vm.IsRecording = true;
|
||||
window.UpdateLayout();
|
||||
Assert.False(vm.CanChangeQuality);
|
||||
Assert.False(picker.IsEnabled);
|
||||
vm.SelectedQuality = vm.QualityOptions.Single(option => option.Display == "720p30");
|
||||
Assert.Same(freeTier, vm.SelectedQuality);
|
||||
|
||||
vm.IsRecording = false;
|
||||
vm.StreamStatus = StreamStatus.Streaming;
|
||||
window.UpdateLayout();
|
||||
Assert.False(vm.CanChangeQuality);
|
||||
Assert.False(picker.IsEnabled);
|
||||
vm.SelectedQuality = vm.QualityOptions.Single(option => option.Display == "720p30");
|
||||
Assert.Same(freeTier, vm.SelectedQuality);
|
||||
|
||||
vm.StreamStatus = StreamStatus.Offline;
|
||||
Assert.True(vm.CanChangeQuality);
|
||||
Assert.True(picker.IsEnabled);
|
||||
}
|
||||
finally
|
||||
{
|
||||
window?.Close();
|
||||
MainViewModel.LayoutPathOverride = null;
|
||||
SqliteConnection.ClearAllPools();
|
||||
try { System.IO.File.Delete(tempDb); } catch { /* best-effort cleanup */ }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static T? FindVisualChild<T>(DependencyObject parent) where T : DependencyObject
|
||||
{
|
||||
for (var i = 0; i < VisualTreeHelper.GetChildrenCount(parent); i++)
|
||||
{
|
||||
var child = VisualTreeHelper.GetChild(parent, i);
|
||||
if (child is T match) return match;
|
||||
var nested = FindVisualChild<T>(child);
|
||||
if (nested != null) return nested;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -436,6 +436,68 @@ public sealed class AlertTickerPreviewDisplayTests
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SceneTickersKeepIndependentSettings_AndShowQueueWhileAlertBoxShowsActiveEvent()
|
||||
{
|
||||
_app.Run(() =>
|
||||
{
|
||||
const int width = 680;
|
||||
const int height = 48;
|
||||
var alertBox = new Source
|
||||
{
|
||||
Name = "Stream Alerts",
|
||||
Type = SourceType.AlertBox,
|
||||
Width = width,
|
||||
Height = height,
|
||||
};
|
||||
var tickerA = new Source
|
||||
{
|
||||
Name = "Ticker A",
|
||||
Type = SourceType.Ticker,
|
||||
Width = width,
|
||||
Height = height,
|
||||
};
|
||||
var tickerB = new Source
|
||||
{
|
||||
Name = "Ticker B",
|
||||
Type = SourceType.Ticker,
|
||||
Width = width,
|
||||
Height = height,
|
||||
};
|
||||
tickerA.Ticker.DisplayMethod = AlertDisplayMethod.Solid;
|
||||
tickerB.Ticker.DisplayMethod = AlertDisplayMethod.Flash;
|
||||
alertBox.Ticker.DisplayMethod = AlertDisplayMethod.Solid;
|
||||
var firstScene = new Scene { Name = "Starting", Elements = { alertBox, tickerA } };
|
||||
var secondScene = new Scene { Name = "BRB", Elements = { tickerB } };
|
||||
using var layer = new AlertOverlayLayer(new AlertRenderer(), tickerPreviewSink: _ => { });
|
||||
layer.UpdateConfiguredBoxes(new[] { firstScene, secondScene });
|
||||
layer.Enqueue(new ChatMessage
|
||||
{
|
||||
AuthorName = "Active",
|
||||
Kind = ChatEventKind.SuperChat,
|
||||
SuperChatDisplayString = "$10.00",
|
||||
});
|
||||
layer.Enqueue(new ChatMessage
|
||||
{
|
||||
AuthorName = "Queued",
|
||||
Kind = ChatEventKind.NewMember,
|
||||
});
|
||||
layer.Advance(0.7);
|
||||
|
||||
var activeAlert = layer.AlertBoxTickerFrame(alertBox);
|
||||
var recentFeed = layer.RenderSceneTicker(tickerA);
|
||||
|
||||
Assert.NotSame(tickerA.Ticker, tickerB.Ticker);
|
||||
Assert.Equal(AlertDisplayMethod.Solid, tickerA.Ticker.DisplayMethod);
|
||||
Assert.Equal(AlertDisplayMethod.Flash, tickerB.Ticker.DisplayMethod);
|
||||
Assert.NotNull(activeAlert);
|
||||
Assert.NotNull(recentFeed);
|
||||
Assert.Null(layer.RenderSceneTicker(tickerB));
|
||||
Assert.False(activeAlert!.BgraPixels.SequenceEqual(recentFeed!.BgraPixels),
|
||||
"the standalone feed must include the queued event while Stream Alerts shows only the active event");
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>A short alert box must clip the strip's rows, not overrun the target
|
||||
/// buffer. The strip rasterises at its natural 48px, so a 20px-tall box used to be
|
||||
/// a heap-corrupting write once the strip was composited at the box's size.</summary>
|
||||
|
||||
@@ -13,6 +13,7 @@ namespace ytLive.Tests;
|
||||
/// <c>startup.1.log</c>, the live file starts fresh with the newest entry, and the SET of
|
||||
/// files stays bounded — a long stream cannot grow the log without limit. (The old
|
||||
/// behaviour appended forever; the file was 23 MB and growing every few seconds.)</para></summary>
|
||||
[Collection("RealApp")]
|
||||
public class AppLogRotationTests
|
||||
{
|
||||
[Fact]
|
||||
|
||||
@@ -40,6 +40,9 @@ public class ChannelStatsServiceTests
|
||||
[Fact]
|
||||
public async Task CaptureCurrent_RequestsNoAuditDetails_AndStillParsesTheSnapshot()
|
||||
{
|
||||
var recentUpload = DateTime.UtcNow.AddDays(-10).ToString("O");
|
||||
var olderUpload = DateTime.UtcNow.AddDays(-80).ToString("O");
|
||||
var outsideWindow = DateTime.UtcNow.AddDays(-100).ToString("O");
|
||||
var auth = new YouTubeAuthService("test-client", "test-secret");
|
||||
auth.SetSession(new Models.YouTubeChannel
|
||||
{
|
||||
@@ -55,11 +58,11 @@ public class ChannelStatsServiceTests
|
||||
{"items":[{"id":"UC-1","statistics":{"subscriberCount":"350","viewCount":"12345","videoCount":"42"},
|
||||
"contentDetails":{"relatedPlaylists":{"uploads":"UPL"}}}]}
|
||||
"""
|
||||
: """
|
||||
: $$$$"""
|
||||
{"items":[
|
||||
{"snippet":{"publishedAt":"2026-08-01T00:00:00Z"}},
|
||||
{"snippet":{"publishedAt":"2026-09-01T00:00:00Z"}},
|
||||
{"snippet":{"publishedAt":"2025-01-01T00:00:00Z"}}
|
||||
{"snippet":{"publishedAt":"{{{{recentUpload}}}}"}},
|
||||
{"snippet":{"publishedAt":"{{{{olderUpload}}}}"}},
|
||||
{"snippet":{"publishedAt":"{{{{outsideWindow}}}}"}}
|
||||
]}
|
||||
""",
|
||||
};
|
||||
@@ -81,4 +84,4 @@ public class ChannelStatsServiceTests
|
||||
Assert.Equal(2, snapshot.UploadsLast90Days);
|
||||
Assert.False(DateTime.UtcNow - snapshot.CapturedAtUtc > TimeSpan.FromMinutes(1));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,12 +7,10 @@ using ytLive.ViewModels;
|
||||
namespace ytLive.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The entitlement seam (2026-11-01): one question — "may this session emit Pro output?" — with a
|
||||
/// Steam DLC rail, a Polar rail to come, and a fail-closed default.
|
||||
/// Steam ownership is cached once into the app-wide binary flag.
|
||||
///
|
||||
/// <para>The rule that must never break: **no configured rail means the free profile**. A missing
|
||||
/// DLC id, a missing Steam client or a thrown native load all resolve to false; none of them may
|
||||
/// crash the app or quietly grant Pro.</para>
|
||||
/// <para>The rule that must never break: a missing paid app ID, Steam client, or native library
|
||||
/// fails closed.</para>
|
||||
/// </summary>
|
||||
// Shares the static MainViewModel.LayoutPathOverride with the sibling class; without a
|
||||
// shared collection xUnit runs them in parallel and the two view models fight over one temp DB.
|
||||
@@ -20,27 +18,57 @@ namespace ytLive.Tests;
|
||||
public class EntitlementTests
|
||||
{
|
||||
[Fact]
|
||||
public void No_configured_dlc_id_reads_as_no_entitlement()
|
||||
public void No_configured_paid_app_id_reads_as_not_owned()
|
||||
{
|
||||
// The state before the Steamworks app exists: pure false, no native call attempted.
|
||||
Assert.False(SteamEntitlement.NotConfigured().IsPro);
|
||||
var entitlement = SteamEntitlement.NotConfigured();
|
||||
Assert.False(entitlement.IsAppOwned);
|
||||
Assert.Contains("paid Steam app ID is not configured", entitlement.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_steam_entitlement_without_a_client_is_the_free_tier_not_a_crash()
|
||||
public void Steam_ownership_checks_the_paid_app_id()
|
||||
{
|
||||
// No steam_appid.txt in the test host and no client running: Init fails, we report false.
|
||||
var entitlement = new SteamEntitlement(1234567u);
|
||||
Assert.False(entitlement.IsPro);
|
||||
const uint paidAppId = 1234567u;
|
||||
uint queriedAppId = 0;
|
||||
var entitlement = new SteamEntitlement(
|
||||
paidAppId,
|
||||
initializeSteam: () => true,
|
||||
isAppSubscribed: appId =>
|
||||
{
|
||||
queriedAppId = appId;
|
||||
return true;
|
||||
},
|
||||
shutdownSteam: () => { });
|
||||
|
||||
Assert.True(entitlement.IsAppOwned);
|
||||
Assert.Equal(paidAppId, queriedAppId);
|
||||
Assert.Null(entitlement.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_steam_entitlement_without_a_client_fails_closed()
|
||||
{
|
||||
var entitlement = new SteamEntitlement(1234567u, () => false, _ => true, () => { });
|
||||
Assert.False(entitlement.IsAppOwned);
|
||||
Assert.Contains("Open Steam", entitlement.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_valid_steam_check_for_an_unowned_account_is_not_an_authentication_error()
|
||||
{
|
||||
var entitlement = new SteamEntitlement(1234567u, () => true, _ => false, () => { });
|
||||
|
||||
Assert.False(entitlement.IsAppOwned);
|
||||
Assert.Null(entitlement.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_fake_reports_what_a_test_sets()
|
||||
{
|
||||
var fake = new FakeEntitlement(true);
|
||||
Assert.True(fake.IsPro);
|
||||
fake.IsPro = false;
|
||||
Assert.False(fake.IsPro);
|
||||
Assert.True(fake.IsAppOwned);
|
||||
fake.IsAppOwned = false;
|
||||
Assert.False(fake.IsAppOwned);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -52,14 +80,16 @@ public class EntitlementTests
|
||||
{
|
||||
var vm = new MainViewModel();
|
||||
|
||||
// Default rail is NotConfigured → free output.
|
||||
Assert.False(vm.HasProOutput);
|
||||
|
||||
vm.EntitlementOverride = new FakeEntitlement(true);
|
||||
Assert.True(vm.IsAppOwned);
|
||||
Assert.True(vm.HasProOutput);
|
||||
|
||||
vm.EntitlementOverride = new FakeEntitlement(false);
|
||||
Assert.False(vm.IsAppOwned);
|
||||
Assert.False(vm.HasProOutput);
|
||||
|
||||
vm.EntitlementOverride = new FakeEntitlement(true);
|
||||
Assert.True(vm.IsAppOwned);
|
||||
Assert.True(vm.HasProOutput);
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
@@ -540,6 +540,36 @@ public class FramePumpTests
|
||||
await pump.StopAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FullRenderCache_TextOverlayFrameChange_Recomposites()
|
||||
{
|
||||
var scene = new Scene { Name = "Text cache" };
|
||||
scene.Elements.Add(new Source
|
||||
{
|
||||
Type = SourceType.TextOverlay,
|
||||
Width = 64,
|
||||
Height = 48,
|
||||
});
|
||||
var current = SceneCompositorTests.Solid(64, 48, 255, 0, 0);
|
||||
var encoder = new FakeEncoder();
|
||||
using var pump = NewPump(encoder, scene: () => scene, resolve: _ => current);
|
||||
|
||||
await pump.StartAsync();
|
||||
var deadline = DateTime.UtcNow.AddSeconds(5);
|
||||
while (DateTime.UtcNow < deadline && (encoder.Frames.Count < 6 || pump.CacheHits < 4))
|
||||
await Task.Delay(10);
|
||||
Assert.Equal(1, pump.CacheRenders);
|
||||
|
||||
current = SceneCompositorTests.Solid(64, 48, 0, 0, 255);
|
||||
deadline = DateTime.UtcNow.AddSeconds(5);
|
||||
while (DateTime.UtcNow < deadline && pump.CacheRenders < 2)
|
||||
await Task.Delay(10);
|
||||
|
||||
Assert.True(pump.CacheRenders >= 2, "the changed text raster did not invalidate the full-render cache");
|
||||
AssertColor(encoder.Frames[^1], 8, 8, 0, 0, 255);
|
||||
await pump.StopAsync();
|
||||
}
|
||||
|
||||
/// <summary>One integration test for the take-8 off-UI fix: the pump loop must NOT
|
||||
/// run on the starting thread's SynchronizationContext (StartAsync fires from a UI
|
||||
/// command handler; the old loop inherited it, so every frame competed with the
|
||||
|
||||
@@ -31,7 +31,7 @@ public class GeometryFieldWidthTests
|
||||
|
||||
var geometry = doc.Descendants()
|
||||
.Where(e => e.Name.LocalName == "TextBlock" && (string?)e.Attribute("Text") == "Geometry")
|
||||
.Single(e => ((string?)e.Parent?.Attribute("Visibility") ?? "").Contains("IsWebcamSelected"));
|
||||
.Single(e => ((string?)e.Parent?.Attribute("Visibility") ?? "").Contains("SelectedElement.IsDraggable"));
|
||||
|
||||
var rows = geometry.Parent!.Elements().Where(e => e.Name.LocalName == "DockPanel").ToList();
|
||||
Assert.Equal(2, rows.Count);
|
||||
|
||||
@@ -15,6 +15,97 @@ namespace ytLive.Tests;
|
||||
/// </summary>
|
||||
public class LayoutStorePersistenceTests
|
||||
{
|
||||
[Fact]
|
||||
public void Text_Layers_RoundTrip_Independently_And_Legacy_Rows_Get_Defaults()
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), $"ytLlive-layout-{Guid.NewGuid():N}.db");
|
||||
try
|
||||
{
|
||||
var scene = new Scene { Name = "Starting" };
|
||||
scene.Elements.Add(new Source
|
||||
{
|
||||
Name = "Headline",
|
||||
Type = SourceType.TextOverlay,
|
||||
TextContent = "First line\nSecond line",
|
||||
TextFontFamily = "Arial",
|
||||
TextFontSize = 72,
|
||||
TextColor = "#12AB34",
|
||||
TextBold = true,
|
||||
TextAlignment = TextLayerAlignment.Center,
|
||||
});
|
||||
scene.Elements.Add(new Source
|
||||
{
|
||||
Name = "Caption",
|
||||
Type = SourceType.TextOverlay,
|
||||
TextContent = "Independent caption",
|
||||
TextFontFamily = "Segoe UI",
|
||||
TextFontSize = 24,
|
||||
TextColor = "#80445566",
|
||||
TextItalic = true,
|
||||
TextUnderline = true,
|
||||
TextAlignment = TextLayerAlignment.Right,
|
||||
});
|
||||
|
||||
using (var store = new LayoutStore(path))
|
||||
store.Save(new[] { scene }, null, null);
|
||||
|
||||
using (var reopened = new LayoutStore(path))
|
||||
{
|
||||
var sources = Assert.Single(reopened.Load()).Elements.OfType<Source>()
|
||||
.Where(source => source.Type == SourceType.TextOverlay).ToArray();
|
||||
Assert.Equal(2, sources.Length);
|
||||
Assert.Equal("First line\nSecond line", sources[0].TextContent);
|
||||
Assert.Equal("Arial", sources[0].TextFontFamily);
|
||||
Assert.Equal(72, sources[0].TextFontSize);
|
||||
Assert.Equal("#12AB34", sources[0].TextColor);
|
||||
Assert.True(sources[0].TextBold);
|
||||
Assert.False(sources[0].TextItalic);
|
||||
Assert.False(sources[0].TextUnderline);
|
||||
Assert.Equal(TextLayerAlignment.Center, sources[0].TextAlignment);
|
||||
Assert.Equal("Independent caption", sources[1].TextContent);
|
||||
Assert.Equal(24, sources[1].TextFontSize);
|
||||
Assert.Equal("#80445566", sources[1].TextColor);
|
||||
Assert.False(sources[1].TextBold);
|
||||
Assert.True(sources[1].TextItalic);
|
||||
Assert.True(sources[1].TextUnderline);
|
||||
Assert.Equal(TextLayerAlignment.Right, sources[1].TextAlignment);
|
||||
}
|
||||
|
||||
SqliteConnection.ClearAllPools();
|
||||
using (var raw = new SqliteConnection($"Data Source={path}"))
|
||||
{
|
||||
raw.Open();
|
||||
using var cmd = raw.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE Source DROP COLUMN TextContent; ALTER TABLE Source DROP COLUMN TextFontFamily; ALTER TABLE Source DROP COLUMN TextFontSize; ALTER TABLE Source DROP COLUMN TextColor; ALTER TABLE Source DROP COLUMN TextBold; ALTER TABLE Source DROP COLUMN TextItalic; ALTER TABLE Source DROP COLUMN TextUnderline; ALTER TABLE Source DROP COLUMN TextAlignment;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
SqliteConnection.ClearAllPools();
|
||||
|
||||
using (var legacy = new LayoutStore(path))
|
||||
{
|
||||
var sources = Assert.Single(legacy.Load()).Elements.OfType<Source>()
|
||||
.Where(source => source.Type == SourceType.TextOverlay).ToArray();
|
||||
Assert.Equal(2, sources.Length);
|
||||
foreach (var source in sources)
|
||||
{
|
||||
Assert.Equal("Text", source.TextContent);
|
||||
Assert.Equal("Segoe UI", source.TextFontFamily);
|
||||
Assert.Equal(48, source.TextFontSize);
|
||||
Assert.Equal("#FFFFFF", source.TextColor);
|
||||
Assert.False(source.TextBold);
|
||||
Assert.False(source.TextItalic);
|
||||
Assert.False(source.TextUnderline);
|
||||
Assert.Equal(TextLayerAlignment.Left, source.TextAlignment);
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
SqliteConnection.ClearAllPools();
|
||||
try { File.Delete(path); } catch { /* best-effort cleanup */ }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>TASK 47: the alert announcement display method must survive a
|
||||
/// restart. This one is easy to get quietly wrong — the Source row is read back
|
||||
/// by POSITIONAL index, so a column inserted anywhere but the end silently
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Controls.Primitives;
|
||||
using System.Windows.Media;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Xunit;
|
||||
using ytLive.Helpers;
|
||||
using ytLive.ViewModels;
|
||||
|
||||
namespace ytLive.Tests;
|
||||
|
||||
[Collection("RealApp")]
|
||||
public sealed class LogsPanelTests
|
||||
{
|
||||
private readonly RealAppHost _app;
|
||||
|
||||
public LogsPanelTests(RealAppHost app) => _app = app;
|
||||
|
||||
[Fact]
|
||||
public void NonEditPanel_DefaultsToLiveChat_AndShowsCurrentLaunchLogsNewestFirst()
|
||||
{
|
||||
var directory = Path.Combine(Path.GetTempPath(), $"ytlive-logs-panel-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(directory);
|
||||
var databasePath = Path.Combine(directory, "layout.db");
|
||||
var logPath = Path.Combine(directory, "startup.log");
|
||||
File.WriteAllText(logPath, "previous-launch-sentinel" + Environment.NewLine);
|
||||
AppLog.PathOverride = logPath;
|
||||
MainViewModel.LayoutPathOverride = databasePath;
|
||||
|
||||
try
|
||||
{
|
||||
_app.Run(() =>
|
||||
{
|
||||
var window = new MainWindow();
|
||||
try
|
||||
{
|
||||
window.Show();
|
||||
window.UpdateLayout();
|
||||
var vm = (MainViewModel)window.DataContext;
|
||||
vm.EditingActive = false;
|
||||
window.UpdateLayout();
|
||||
var panel = (UserControl)window.FindName("LeftPanel")!;
|
||||
var chatTab = (ToggleButton)panel.FindName("LiveChatTabButton")!;
|
||||
var logsTab = (ToggleButton)panel.FindName("LogsTabButton")!;
|
||||
var logList = (ListBox)panel.FindName("LogListBox")!;
|
||||
|
||||
Assert.True(vm.IsLiveChatTabSelected);
|
||||
Assert.True(chatTab.IsChecked);
|
||||
Assert.False(logsTab.IsChecked);
|
||||
Assert.False(logList.IsVisible);
|
||||
|
||||
vm.IsLogsTabSelected = true;
|
||||
window.UpdateLayout();
|
||||
Assert.True(logsTab.IsChecked);
|
||||
Assert.False(chatTab.IsChecked);
|
||||
Assert.True(logList.IsVisible);
|
||||
|
||||
AppLog.Write("LogsPanel older marker");
|
||||
AppLog.Write("LogsPanel newer marker");
|
||||
AppLog.WriteWarning("LogsPanel warning marker");
|
||||
AppLog.WriteSuccess("LogsPanel success marker");
|
||||
AppLog.Write("LogsPanel class 2 marker " + new string('2', 240),
|
||||
AppLogMessageType.Error, AppLogErrorClass.Class2);
|
||||
AppLog.Write("LogsPanel class 1 marker " + new string('1', 240),
|
||||
AppLogMessageType.Error, AppLogErrorClass.Class1);
|
||||
vm.RefreshCurrentLaunchLogs();
|
||||
window.UpdateLayout();
|
||||
|
||||
var entries = vm.CurrentLaunchLogEntries.Select(entry => entry.Text).ToArray();
|
||||
var newerIndex = Array.FindIndex(entries, text => text.Contains("LogsPanel newer marker", StringComparison.Ordinal));
|
||||
var olderIndex = Array.FindIndex(entries, text => text.Contains("LogsPanel older marker", StringComparison.Ordinal));
|
||||
Assert.True(newerIndex >= 0 && olderIndex >= 0, "new log writes did not reach the Logs tab");
|
||||
Assert.True(newerIndex < olderIndex, "the Logs tab must put the newest entry first");
|
||||
Assert.DoesNotContain(entries, text => text.Contains("previous-launch-sentinel", StringComparison.Ordinal));
|
||||
Assert.Same(vm.CurrentLaunchLogEntries, logList.ItemsSource);
|
||||
|
||||
var class1 = vm.PinnedLogEntries.Single(entry => entry.Text.Contains("LogsPanel class 1 marker", StringComparison.Ordinal));
|
||||
var class2 = vm.PinnedLogEntries.Single(entry => entry.Text.Contains("LogsPanel class 2 marker", StringComparison.Ordinal));
|
||||
Assert.True(Array.IndexOf(vm.PinnedLogEntries.ToArray(), class1)
|
||||
< Array.IndexOf(vm.PinnedLogEntries.ToArray(), class2));
|
||||
Assert.Equal("CLASS 1", class1.ErrorClassLabel);
|
||||
Assert.Equal("CLASS 2", class2.ErrorClassLabel);
|
||||
Assert.Equal("CLASS 1", class1.TypeLabel);
|
||||
|
||||
var pinnedPanel = (ItemsControl)panel.FindName("PinnedLogPanel")!;
|
||||
Assert.Equal(System.Windows.Visibility.Visible, pinnedPanel.Visibility);
|
||||
var class1Text = Find(pinnedPanel, element => element is TextBlock text
|
||||
&& text.Text.Contains("LogsPanel class 1 marker", StringComparison.Ordinal)) as TextBlock;
|
||||
var class2Text = Find(pinnedPanel, element => element is TextBlock text
|
||||
&& text.Text.Contains("LogsPanel class 2 marker", StringComparison.Ordinal)) as TextBlock;
|
||||
Assert.NotNull(class1Text);
|
||||
Assert.NotNull(class2Text);
|
||||
Assert.Equal(TextWrapping.Wrap, class1Text!.TextWrapping);
|
||||
Assert.Equal(BrushColor("Brush.Error"), Assert.IsType<SolidColorBrush>(class1Text.Foreground).Color);
|
||||
Assert.Equal(BrushColor("Brush.Attention"), Assert.IsType<SolidColorBrush>(class2Text!.Foreground).Color);
|
||||
|
||||
var warningText = Find(logList, element => element is TextBlock text
|
||||
&& text.Text.Contains("LogsPanel warning marker", StringComparison.Ordinal)) as TextBlock;
|
||||
var successText = Find(logList, element => element is TextBlock text
|
||||
&& text.Text.Contains("LogsPanel success marker", StringComparison.Ordinal)) as TextBlock;
|
||||
var infoText = Find(logList, element => element is TextBlock text
|
||||
&& text.Text.Contains("LogsPanel newer marker", StringComparison.Ordinal)) as TextBlock;
|
||||
Assert.NotNull(warningText);
|
||||
Assert.NotNull(successText);
|
||||
Assert.NotNull(infoText);
|
||||
Assert.Equal(BrushColor("Brush.Secondary"), Assert.IsType<SolidColorBrush>(warningText!.Foreground).Color);
|
||||
Assert.Equal(BrushColor("Brush.Positive"), Assert.IsType<SolidColorBrush>(successText!.Foreground).Color);
|
||||
Assert.Equal(BrushColor("Brush.OnSurfaceVariant"), Assert.IsType<SolidColorBrush>(infoText!.Foreground).Color);
|
||||
|
||||
var class1Ack = Find(pinnedPanel, element => element is Button button
|
||||
&& button.DataContext is AppLogEntry entry && entry.Sequence == class1.Sequence) as Button;
|
||||
Assert.NotNull(class1Ack?.Command);
|
||||
Assert.Same(class1, class1Ack!.CommandParameter);
|
||||
class1Ack!.Command!.Execute(class1Ack.CommandParameter);
|
||||
Assert.DoesNotContain(class1, vm.PinnedLogEntries);
|
||||
Assert.Contains(class1, vm.CurrentLaunchLogEntries);
|
||||
}
|
||||
finally
|
||||
{
|
||||
window.Close();
|
||||
}
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
MainViewModel.LayoutPathOverride = null;
|
||||
AppLog.PathOverride = null;
|
||||
SqliteConnection.ClearAllPools();
|
||||
try { Directory.Delete(directory, recursive: true); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
private static System.Windows.Media.Color BrushColor(string key)
|
||||
=> Assert.IsType<SolidColorBrush>(Application.Current!.FindResource(key)).Color;
|
||||
|
||||
private static DependencyObject? Find(DependencyObject parent, Func<DependencyObject, bool> predicate)
|
||||
{
|
||||
if (predicate(parent)) return parent;
|
||||
for (var i = 0; i < VisualTreeHelper.GetChildrenCount(parent); i++)
|
||||
{
|
||||
var child = VisualTreeHelper.GetChild(parent, i);
|
||||
if (predicate(child)) return child;
|
||||
var found = Find(child, predicate);
|
||||
if (found != null) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ public class MicBayTests
|
||||
Assert.Contains("Command=\"{Binding ToggleMicMuteCommand}\"", dock);
|
||||
|
||||
// The fader's range is the scale's range, not an arbitrary slider span.
|
||||
Assert.Contains("Minimum=\"-60\" Maximum=\"6\"", dock);
|
||||
Assert.Contains("Minimum=\"-60\" Maximum=\"0\"", dock);
|
||||
|
||||
// The VM surface exists (no construction: this is a wiring check, not a behaviour one).
|
||||
var type = typeof(MainViewModel);
|
||||
|
||||
@@ -11,8 +11,13 @@ namespace ytLive.Tests;
|
||||
/// Errors must never auto-expire (sticky until dismissed); the rest get short
|
||||
/// lifetimes; everything routes to the in-window ToastArea.
|
||||
/// </summary>
|
||||
[Collection("RealApp")]
|
||||
public class NotificationServiceTests
|
||||
{
|
||||
private readonly RealAppHost _app;
|
||||
|
||||
public NotificationServiceTests(RealAppHost app) => _app = app;
|
||||
|
||||
[Theory]
|
||||
[InlineData(AppNotificationSeverity.Info, 4)]
|
||||
[InlineData(AppNotificationSeverity.Success, 4)]
|
||||
@@ -39,11 +44,14 @@ public class NotificationServiceTests
|
||||
[InlineData(AppNotificationSeverity.Info, "#3a3b52")]
|
||||
[InlineData(AppNotificationSeverity.Success, "#1d5c38")]
|
||||
[InlineData(AppNotificationSeverity.Warning, "#b8860b")]
|
||||
[InlineData(AppNotificationSeverity.Error, "#8f1f1f")]
|
||||
[InlineData(AppNotificationSeverity.Error, "#93000a")]
|
||||
public void Severities_Map_To_Dark_Card_Tints(AppNotificationSeverity severity, string expectedHex)
|
||||
{
|
||||
var request = NotificationService.BuildRequest("Title", "Body", severity);
|
||||
_app.Run(() =>
|
||||
{
|
||||
var request = NotificationService.BuildRequest("Title", "Body", severity);
|
||||
|
||||
Assert.Equal(expectedHex.ToLowerInvariant()[^6..], request.BackgroundColor!.Value.ToHex().ToLowerInvariant()[^6..]);
|
||||
Assert.Equal(expectedHex.ToLowerInvariant()[^6..], request.BackgroundColor!.Value.ToHex().ToLowerInvariant()[^6..]);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,21 @@
|
||||
using System;
|
||||
using System.ComponentModel;
|
||||
using Xunit;
|
||||
using ytLive.Helpers;
|
||||
using ytLive.Models;
|
||||
using ytLive.ViewModels;
|
||||
|
||||
namespace ytLive.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The ON-AIR sign (right of REC in the top bar): gray offline, red when
|
||||
/// streaming public, green when streaming private. Driven through the real
|
||||
/// MainViewModel so StreamStatus/StreamVisibility notification wiring is
|
||||
/// exercised, not just the computed property.
|
||||
/// The ON-AIR sign and menu-bar color follow the real MainViewModel state,
|
||||
/// including the YouTube lifecycle confirmation before the bar turns green.
|
||||
/// </summary>
|
||||
[Collection("RealApp")]
|
||||
public sealed class OnAirSignTests
|
||||
{
|
||||
[Fact]
|
||||
public void OnAirBrush_Follows_Offline_Public_And_Private()
|
||||
public void OnAirBrush_And_TopBarBackground_Follow_YouTube_Live_State()
|
||||
{
|
||||
_app.Run(Run);
|
||||
}
|
||||
@@ -38,6 +37,7 @@ public sealed class OnAirSignTests
|
||||
|
||||
Assert.Equal(StreamStatus.Offline, vm.StreamStatus);
|
||||
Assert.Equal("#5b6478", vm.OnAirBrush);
|
||||
var idleBackground = vm.TopBarBackground;
|
||||
|
||||
var raised = 0;
|
||||
vm.PropertyChanged += (_, e) => { if (e.PropertyName == nameof(MainViewModel.OnAirBrush)) raised++; };
|
||||
@@ -47,11 +47,28 @@ public sealed class OnAirSignTests
|
||||
|
||||
vm.StreamStatus = StreamStatus.Streaming;
|
||||
Assert.Equal("#ff5451", vm.OnAirBrush);
|
||||
Assert.Equal(ThemeBrush.Hex("Brush.Primary"), vm.TopBarBackground);
|
||||
|
||||
vm.ApplyBroadcastLifecycleStatus("liveStarting");
|
||||
Assert.Equal(ThemeBrush.Hex("Brush.Primary"), vm.TopBarBackground);
|
||||
|
||||
vm.StreamVisibility = "Private";
|
||||
Assert.Equal("#22c55e", vm.OnAirBrush);
|
||||
|
||||
Assert.Equal(3, raised);
|
||||
vm.ApplyBroadcastLifecycleStatus("live");
|
||||
Assert.Equal(ThemeBrush.Hex("Brush.Positive"), vm.TopBarBackground);
|
||||
|
||||
vm.StreamStatus = StreamStatus.Offline;
|
||||
Assert.Equal(idleBackground, vm.TopBarBackground);
|
||||
|
||||
vm.IsTestStream = true;
|
||||
vm.StreamStatus = StreamStatus.Streaming;
|
||||
Assert.Equal(ThemeBrush.Hex("Brush.TestContainer"), vm.TopBarBackground);
|
||||
vm.ApplyBroadcastLifecycleStatus("live");
|
||||
Assert.Equal(ThemeBrush.Hex("Brush.TestContainer"), vm.TopBarBackground);
|
||||
vm.StreamStatus = StreamStatus.Offline;
|
||||
Assert.Equal(idleBackground, vm.TopBarBackground);
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
@@ -8,8 +8,8 @@ using ytLive.Models;
|
||||
namespace ytLive.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The output-profile rule (2026-11-01): a build without Pro may emit at most 921,600 pixels
|
||||
/// at 30 fps, and this is the only place that decision is made.
|
||||
/// The output-profile rule: a free build may emit every shipped resolution at 30 fps;
|
||||
/// 60 fps requires Pro, independent of resolution.
|
||||
///
|
||||
/// <para>The one thing that must never break: a cracked or unlicensed build silently gets a
|
||||
/// *smaller frame*, never a smaller feature set. If a future tier is added above the budget,
|
||||
@@ -32,21 +32,21 @@ public class OutputProfileTests
|
||||
private static QualityOption Tier(string name) => Shipped.Single(t => t.Display == name);
|
||||
|
||||
[Fact]
|
||||
public void A_free_build_resolves_1080p60_down_to_720p30_with_its_own_bitrate()
|
||||
public void A_free_build_resolves_1080p60_to_1080p30()
|
||||
{
|
||||
var resolved = OutputProfile.Resolve(Tier("1080p60"), Shipped, pro: false);
|
||||
|
||||
Assert.Equal(1280, resolved.Width);
|
||||
Assert.Equal(720, resolved.Height);
|
||||
Assert.Equal("1080p30", resolved.Display);
|
||||
Assert.Equal(1920, resolved.Width);
|
||||
Assert.Equal(1080, resolved.Height);
|
||||
Assert.Equal(30, resolved.Fps);
|
||||
// The bitrate follows the profile: a capped user is not offered an 8 Mbps budget.
|
||||
Assert.Equal(6.0, resolved.Bitrate);
|
||||
Assert.Equal(8.0, resolved.Bitrate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_free_build_leaves_its_own_tier_untouched()
|
||||
{
|
||||
var requested = Tier("720p30");
|
||||
var requested = Tier("1080p30");
|
||||
Assert.Same(requested, OutputProfile.Resolve(requested, Shipped, pro: false));
|
||||
}
|
||||
|
||||
@@ -57,21 +57,15 @@ public class OutputProfileTests
|
||||
Assert.Same(tier, OutputProfile.Resolve(tier, Shipped, pro: true));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("1080p60")]
|
||||
[InlineData("1080p30")]
|
||||
[InlineData("720p60")]
|
||||
[InlineData("720p30")]
|
||||
[InlineData("Vertical 1080p30")]
|
||||
[InlineData("Vertical 1080p60")]
|
||||
public void No_free_selection_can_emit_more_than_the_budget(string name)
|
||||
[Fact]
|
||||
public void Free_access_depends_on_frame_rate_not_resolution()
|
||||
{
|
||||
var resolved = OutputProfile.Resolve(Tier(name), Shipped, pro: false);
|
||||
|
||||
Assert.True(resolved.Width * resolved.Height <= OutputProfile.FreeMaxPixels,
|
||||
$"{resolved.Display} resolves to {resolved.Width * resolved.Height} px on a free build");
|
||||
Assert.True(resolved.Fps <= OutputProfile.FreeMaxFps,
|
||||
$"{resolved.Display} resolves to {resolved.Fps} fps on a free build");
|
||||
foreach (var tier in Shipped)
|
||||
{
|
||||
var freeAllowed = tier.Fps <= OutputProfile.FreeMaxFps;
|
||||
Assert.Equal(freeAllowed, OutputProfile.IsAllowed(tier, pro: false));
|
||||
Assert.Equal(!freeAllowed, tier.RequiresPro);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -79,9 +73,11 @@ public class OutputProfileTests
|
||||
{
|
||||
var resolved = OutputProfile.Resolve(Tier("1080p60"), Array.Empty<QualityOption>(), pro: false);
|
||||
|
||||
Assert.Equal(1280, resolved.Width);
|
||||
Assert.Equal(720, resolved.Height);
|
||||
Assert.Equal("1080p30", resolved.Display);
|
||||
Assert.Equal(1920, resolved.Width);
|
||||
Assert.Equal(1080, resolved.Height);
|
||||
Assert.Equal(30, resolved.Fps);
|
||||
Assert.Equal(8.0, resolved.Bitrate);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -89,6 +89,7 @@ public sealed class PillRadioTests
|
||||
// The dev machine may hold a real saved OAuth session (loads synchronously
|
||||
// during the ctor), so the signed-out face is forced deterministically.
|
||||
vm.IsConnected = false;
|
||||
vm.EditingActive = false; // EDIT off selects the real Go Live route, not Test Stream.
|
||||
|
||||
// No sign-in: the ON-AIR pill MUST still be able to light — intent before
|
||||
// reality, armable signed-out (creator ruling 2026-09-20).
|
||||
@@ -119,4 +120,4 @@ public sealed class PillRadioTests
|
||||
try { File.Delete(tempDb); } catch { /* best-effort cleanup */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,20 +47,27 @@ public class PropertiesHierarchyTests
|
||||
|
||||
// ── The panel: a Shape title owns the options, the hint is indented ───
|
||||
var doc = XDocument.Load(SourceAssert.RepoPath("Controls", "LeftPanel.xaml"));
|
||||
XNamespace xaml = "http://schemas.microsoft.com/winfx/2006/xaml";
|
||||
var theme = XDocument.Load(SourceAssert.RepoPath("Themes", "Controls.xaml"));
|
||||
var propertyHeaderStyle = theme.Descendants().Single(e => e.Name.LocalName == "Style"
|
||||
&& (string?)e.Attribute(xaml + "Key") == "PropertyHeader");
|
||||
var fontWeight = propertyHeaderStyle.Elements().Single(e => e.Name.LocalName == "Setter"
|
||||
&& (string?)e.Attribute("Property") == "FontWeight");
|
||||
Assert.Equal("SemiBold", (string?)fontWeight.Attribute("Value"));
|
||||
|
||||
var shapeTitle = doc.Descendants().Single(e => e.Name.LocalName == "TextBlock"
|
||||
&& (string?)e.Attribute("Text") == "Shape");
|
||||
Assert.Equal("SemiBold", (string?)shapeTitle.Attribute("FontWeight"));
|
||||
Assert.Equal("{StaticResource PropertyHeader}", (string?)shapeTitle.Attribute("Style"));
|
||||
|
||||
var rectOption = OptionBoundTo(doc, "IsRectShape");
|
||||
var circleOption = OptionBoundTo(doc, "IsRoundShape");
|
||||
var shapeBlock = shapeTitle.Parent!;
|
||||
var rectOption = OptionBoundTo(shapeBlock, "IsRectShape");
|
||||
var circleOption = OptionBoundTo(shapeBlock, "IsRoundShape");
|
||||
Assert.Contains("TwoWay", (string?)rectOption.Attribute("IsChecked") ?? "");
|
||||
|
||||
// The CIRCLE is the option that is not offered in the vertical Chat layout.
|
||||
Assert.Contains("CanToggleClipShape", (string?)circleOption.Attribute("Visibility") ?? "");
|
||||
|
||||
// Both options live under the Shape title.
|
||||
var shapeBlock = shapeTitle.Parent!;
|
||||
Assert.Contains(rectOption, shapeBlock.Descendants());
|
||||
Assert.Contains(circleOption, shapeBlock.Descendants());
|
||||
|
||||
@@ -76,8 +83,8 @@ public class PropertiesHierarchyTests
|
||||
// ── Every titled block owns its content one indent deeper ────────────
|
||||
var geometryTitle = doc.Descendants()
|
||||
.Where(e => e.Name.LocalName == "TextBlock" && (string?)e.Attribute("Text") == "Geometry")
|
||||
.Single(e => ((string?)e.Parent?.Attribute("Visibility") ?? "").Contains("IsWebcamSelected"));
|
||||
Assert.Equal("SemiBold", (string?)geometryTitle.Attribute("FontWeight"));
|
||||
.Single(e => ((string?)e.Parent?.Attribute("Visibility") ?? "").Contains("SelectedElement.IsDraggable"));
|
||||
Assert.Equal("{StaticResource PropertyHeader}", (string?)geometryTitle.Attribute("Style"));
|
||||
foreach (var row in geometryTitle.Parent!.Elements().Where(e => e.Name.LocalName == "DockPanel"))
|
||||
Assert.Equal(10, LeftIndent(row));
|
||||
|
||||
@@ -89,7 +96,7 @@ public class PropertiesHierarchyTests
|
||||
{
|
||||
var label = webcamPanel.Descendants().Single(e => e.Name.LocalName == "TextBlock"
|
||||
&& (string?)e.Attribute("Text") == title);
|
||||
Assert.Equal("SemiBold", (string?)label.Attribute("FontWeight"));
|
||||
Assert.Equal("{StaticResource PropertyHeader}", (string?)label.Attribute("Style"));
|
||||
Assert.Equal(10, LeftIndent(RowAfter(label)));
|
||||
}
|
||||
|
||||
@@ -101,8 +108,8 @@ public class PropertiesHierarchyTests
|
||||
}
|
||||
|
||||
/// <summary>The shape option bound to the given model property.</summary>
|
||||
private static XElement OptionBoundTo(XDocument doc, string property) =>
|
||||
doc.Descendants().Single(e => e.Name.LocalName == "ToggleButton"
|
||||
private static XElement OptionBoundTo(XElement scope, string property) =>
|
||||
scope.Descendants().Single(e => e.Name.LocalName == "ToggleButton"
|
||||
&& ((string?)e.Attribute("IsChecked") ?? "").Contains(property));
|
||||
|
||||
/// <summary>The row (DockPanel) that holds the given label.</summary>
|
||||
|
||||
@@ -43,9 +43,17 @@ public class PropertiesStyleTests
|
||||
AssertStyled(combo, "YtComboBox",
|
||||
$"{where}: a ComboBox is not styled with YtComboBox — it renders with the light system template");
|
||||
|
||||
// Every check box is a pill switch (creator ruling, 2026-11-01: "all checkboxes as
|
||||
// pill switches"), so PropertyPillCheck is the theme style that matters here; YtCheckBox
|
||||
// is still accepted where a plain box is deliberate.
|
||||
foreach (var check in doc.Descendants().Where(e => e.Name.LocalName == "CheckBox"))
|
||||
AssertStyled(check, "YtCheckBox",
|
||||
$"{where}: a CheckBox is not styled with YtCheckBox — it renders with the light system template");
|
||||
{
|
||||
var style = (string?)check.Attribute("Style") ?? string.Empty;
|
||||
Assert.True(
|
||||
style.Contains("PropertyPillCheck", StringComparison.Ordinal)
|
||||
|| style.Contains("YtCheckBox", StringComparison.Ordinal),
|
||||
$"{where}: a CheckBox carries no theme style — it renders with the light system template");
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Every field in the properties panel carries the compact theme style and paints
|
||||
|
||||
@@ -2,15 +2,16 @@
|
||||
using ytLive.Models;
|
||||
using ytLive.Services;
|
||||
using ytLive.Services.Compositor;
|
||||
using ytLive.Services.Encoder;
|
||||
|
||||
namespace ytLive.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The output compositor (TASK 4 ship step 1): renders a scene into the encoder's
|
||||
/// master frame, mirroring the XAML preview minus the editing chrome. The integration
|
||||
/// raw input frame, mirroring the XAML preview minus the editing chrome. The integration
|
||||
/// test composites a full scene (background + round webcam + images + mirror + border)
|
||||
/// and asserts per-layer probe pixels; the vertical-tier test asserts the 9:16 crop
|
||||
/// and upscale. Frames are pushed by the test — no capture managers involved.
|
||||
/// and asserts per-layer probe pixels; the vertical-tier test checks the crop frame and
|
||||
/// FFmpeg's final 9:16 resize. Frames are pushed by the test — no capture managers involved.
|
||||
/// </summary>
|
||||
public class SceneCompositorTests
|
||||
{
|
||||
@@ -407,9 +408,18 @@ public class SceneCompositorTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Render_VerticalTier_Outputs_1080x1920_From_The_Center_Crop()
|
||||
public void Render_VerticalTier_Crops_And_Configures_Ffmpeg_1080x1920_Scale()
|
||||
{
|
||||
var red = Solid(1920, 1080, 255, 0, 0);
|
||||
var backgroundPixels = new byte[1920 * 1080 * 4];
|
||||
for (var y = 0; y < 1080; y++)
|
||||
for (var x = 0; x < 1920; x++)
|
||||
{
|
||||
var i = (y * 1920 + x) * 4;
|
||||
backgroundPixels[i] = (byte)(x * 255 / 1919);
|
||||
backgroundPixels[i + 1] = (byte)(y * 255 / 1079);
|
||||
backgroundPixels[i + 3] = 255;
|
||||
}
|
||||
var backgroundFrame = new VideoFrame(1920, 1080, backgroundPixels);
|
||||
var green = Solid(1280, 720, 0, 255, 0);
|
||||
|
||||
var background = new Source { Type = SourceType.DisplayCapture, IsBackground = true, CaptureKey = "monitor:0" };
|
||||
@@ -421,26 +431,40 @@ public class SceneCompositorTests
|
||||
VideoFrame? FrameFor(SceneElement e) => e switch
|
||||
{
|
||||
WebcamSceneConfig => green,
|
||||
Source { IsBackground: true } => red,
|
||||
Source { IsBackground: true } => backgroundFrame,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
var options = new CompositorOptions
|
||||
{
|
||||
SourceRectX = 656, SourceRectY = 0, SourceRectWidth = 607, SourceRectHeight = 1080,
|
||||
OutputWidth = 1080, OutputHeight = 1920,
|
||||
SourceRectX = 656, SourceRectY = 0, SourceRectWidth = 608, SourceRectHeight = 1080,
|
||||
OutputWidth = 608, OutputHeight = 1080,
|
||||
};
|
||||
|
||||
var output = new SceneCompositor().Render(scene, FrameFor, null, options);
|
||||
|
||||
Assert.Equal(1080, output.Width);
|
||||
Assert.Equal(1920, output.Height);
|
||||
// top-left of the crop is pure background (webcam starts at crop y=100, inset from the corner)
|
||||
AssertColor(output, 0, 0, 255, 0, 0);
|
||||
// the webcam center (crop 150,250) scales to output ~(267,444) and stays green
|
||||
AssertColor(output, 267, 444, 0, 255, 0);
|
||||
Assert.Equal(608, output.Width);
|
||||
Assert.Equal(1080, output.Height);
|
||||
// The centered crop and webcam are composed at source size.
|
||||
AssertColor(output, 0, 0, 0, 0, 87);
|
||||
AssertColor(output, 150, 250, 0, 255, 0);
|
||||
// far from the webcam, still background
|
||||
AssertColor(output, 978, 1688, 255, 0, 0);
|
||||
AssertColor(output, 550, 950, 0, 224, 160);
|
||||
|
||||
// FFmpeg receives the crop, then scales it to the tier's encoded dimensions.
|
||||
var args = FfmpegArgs.Build(new EncoderOptions
|
||||
{
|
||||
RtmpUrl = "rtmp://a.rtmp.youtube.com/live2/key",
|
||||
InputWidth = output.Width,
|
||||
InputHeight = output.Height,
|
||||
Width = 1080,
|
||||
Height = 1920,
|
||||
Fps = 60,
|
||||
}, "libopenh264").ToArray();
|
||||
var inputSize = Array.IndexOf(args, "-video_size");
|
||||
var scaleFilter = Array.IndexOf(args, "-vf");
|
||||
Assert.Equal("608x1080", args[inputSize + 1]);
|
||||
Assert.Equal("scale=1080:1920:flags=bilinear,setsar=1", args[scaleFilter + 1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -616,4 +640,3 @@ public class StretchMathTests
|
||||
Assert.Equal(255, scaled.BgraPixels[i + 1]); // solid green survives the scale
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -63,10 +63,10 @@ public class ScoreboardPropertiesTests
|
||||
})
|
||||
Assert.Contains(binding, panel);
|
||||
|
||||
// Theme widgets only: the colour pickers are the app's drop-down (YtComboBox), never a
|
||||
// hand-built menu, and the toggles are YtCheckBox.
|
||||
// Theme widgets only: colour pickers use YtComboBox and the property-panel toggles use
|
||||
// the themed pill-switch style.
|
||||
Assert.Contains("Style=\"{StaticResource YtComboBox}\"", block);
|
||||
Assert.Contains("Style=\"{StaticResource YtCheckBox}\"", block);
|
||||
Assert.Contains("Style=\"{StaticResource PropertyPillCheck}\"", block);
|
||||
Assert.DoesNotContain("ContextMenu", block);
|
||||
|
||||
// The VM surface exists for every binding above (a typo would bind to nothing silently).
|
||||
|
||||
@@ -42,9 +42,11 @@ public class ScoreboardSimulationTests
|
||||
var type = typeof(MainViewModel);
|
||||
Assert.NotNull(type.GetProperty("ScoreboardSimulate"));
|
||||
Assert.NotNull(type.GetProperty("IsDevelopmentBuild"));
|
||||
// DEBUG-gated at compile time, so a Release build has no dev control at all.
|
||||
Assert.Contains("#if DEBUG", vmSource);
|
||||
Assert.Contains("private const bool DevelopmentBuild = true;", vmSource);
|
||||
Assert.Contains("private const bool DevelopmentBuild = false;", vmSource);
|
||||
// DEBUG-gated at compile time through the shared build flag, not duplicated in the VM.
|
||||
var devBuild = SourceAssert.Text("Helpers", "DevBuild.cs");
|
||||
Assert.Contains("#if DEBUG", devBuild);
|
||||
Assert.Contains("true;", devBuild);
|
||||
Assert.Contains("#else", devBuild);
|
||||
Assert.Contains("false;", devBuild);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,26 +71,35 @@ public sealed class SelectedLayerPreviewHitTestingTests
|
||||
var pointInCanvas = new Point(logo.X + 100, logo.Y + 60);
|
||||
var pointInWindow = canvasGrid.TransformToVisual(window).Transform(pointInCanvas);
|
||||
|
||||
Assert.True(ReferenceEquals(webcam, ElementAt(window.InputHitTest(pointInWindow))));
|
||||
Assert.Same(webcam, preview.HitElementAt(vm, pointInCanvas));
|
||||
Assert.Equal(0, Panel.GetZIndex(logoContainer));
|
||||
// The brand logo is drawn in BrandLogoOverlay above the blur host (2026-11-01), so its
|
||||
// canvas presenter is collapsed and the overlay is what the eye sees. Hit-testing
|
||||
// follows the eye: the logo wins a click inside its bounds.
|
||||
Assert.Equal(Visibility.Collapsed, logoContainer.Visibility);
|
||||
Assert.Same(logo, preview.HitElementAt(vm, pointInCanvas));
|
||||
|
||||
sourceList.SelectedItem = logo;
|
||||
window.UpdateLayout();
|
||||
|
||||
Assert.True(ReferenceEquals(logo, vm.SelectedElement), "Selecting the layer-list row must select the brand logo.");
|
||||
Assert.True(Panel.GetZIndex(logoContainer) > Panel.GetZIndex(webcamContainer));
|
||||
Assert.Same(logo, ElementAt(window.InputHitTest(pointInWindow)));
|
||||
Assert.Same(logo, preview.HitElementAt(vm, pointInCanvas));
|
||||
|
||||
// …and with nothing selected the logo still wins inside its own bounds, because it is
|
||||
// drawn above every layer. The webcam shares that rect in this test, so it is the
|
||||
// covered case: you reach it from the layer list, not by clicking through the logo.
|
||||
vm.SelectedElement = null;
|
||||
window.UpdateLayout();
|
||||
Assert.Same(logo, preview.HitElementAt(vm, new Point(logo.X + 5, logo.Y + 5)));
|
||||
Assert.Equal(originalOrder, scene.Elements);
|
||||
|
||||
sourceList.SelectedItem = null;
|
||||
window.UpdateLayout();
|
||||
|
||||
Assert.Null(vm.SelectedElement);
|
||||
Assert.Equal(0, Panel.GetZIndex(logoContainer));
|
||||
Assert.Same(webcam, ElementAt(window.InputHitTest(pointInWindow)));
|
||||
Assert.Same(webcam, preview.HitElementAt(vm, pointInCanvas));
|
||||
// The logo still owns a click inside its bounds after deselection (it is the topmost
|
||||
// layer in both the compositor and the preview), and the canvas presenter stays
|
||||
// collapsed because BrandLogoOverlay is what draws it.
|
||||
Assert.Equal(Visibility.Collapsed, logoContainer.Visibility);
|
||||
Assert.Same(logo, preview.HitElementAt(vm, pointInCanvas));
|
||||
Assert.Equal(originalOrder, scene.Elements);
|
||||
}
|
||||
finally
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user