Compare commits

...
39 Commits
Author SHA1 Message Date
gramps d195c29f79 fix(text): group controls and filter font families
Put size and color on one row, place emphasis labels before their toggles, and split the remaining font families into handwriting, symbols, and international groups while omitting legacy dingbat fonts. Text-layer properties follow the existing hierarchical layout and palette conventions.
2026-11-04 17:17:57 -08:00
gramps 5f7bcc2425 fix(text): show selected formatting and style controls
Use category-filtered font and selected-item dropdowns, separate horizontal and vertical alignment, and reuse the border-color palette templates for text color. Persist vertical alignment additively. Follow the established static text-source pattern: https://obsproject.com/kb/text-sources
2026-11-04 17:05:06 -08:00
gramps 5c4acf141b feat(text): composite shared text rasters in output
Raster static text on UI-thread changes and reuse the same transparent BGRA frame in preview and all compositor paths. Cache text frame identity in the frame-pump signature and invalidate static bakes on edits. Follow OBS text-source conventions: https://obsproject.com/kb/text-sources
2026-11-04 15:01:47 -08:00
gramps 58eb640c4e feat(text): add static text editor and preview
TextOverlay layers now have a multiline editor, Windows font and size controls, color/emphasis/alignment settings, and a visible draggable preview. Text sources follow OBS's static text-source conventions: https://obsproject.com/kb/text-sources
2026-11-04 14:28:47 -08:00
gramps 60c4a0032f feat(text): persist independent text layer settings
Add additive defaults and round-trip tests for per-layer text content and formatting. Model the static text source after OBS text-source conventions: https://obsproject.com/kb/text-sources
2026-11-04 14:22:32 -08:00
gramps 106896c408 docs: close Task 53 and advance queue 2026-11-04 13:49:39 -08:00
gramps 7216c95449 feat(entitlement): gate 60 fps output through Steam 2026-11-04 12:14:46 -08:00
gramps 1993e7cc8b docs(task-53): accept vertical live-player check 2026-11-04 11:37:10 -08:00
gramps ede6ecac8e docs(task-53): retire platform safe-zone requirement 2026-11-04 11:35:29 -08:00
gramps bde2a4cf4b docs(task-53): accept six output samples and advance queue 2026-11-04 11:32:44 -08:00
gramps 4b7e798757 feat(ui): show YouTube-confirmed live state in top bar
Normal ON-AIR stays red until YouTube reports lifeCycleStatus=live, then turns green; TEST stays gold and stop restores the idle color.

Status-color reference: https://www.carbondesignsystem.com/building-blocks/core/patterns/status-indicators
2026-11-04 11:19:40 -08:00
gramps f921c0174b docs: reconcile Day 2 release policy and output tasks 2026-11-04 11:18:16 -08:00
gramps 6310f50c27 perf(video): move tier scaling to ffmpeg
The frame pump now submits crop-sized BGRA frames; FFmpeg scales them to final tier dimensions. Bundled scaler benchmark: 300 608x1080 to 1080x1920 frames in 0.435s (scaler-only).

Reference: https://ffmpeg.org/ffmpeg-filters.html#scale-1
2026-11-04 10:52:35 -08:00
gramps 76e8c22815 fix(ui): own private broadcast close-out dialog 2026-11-04 10:11:39 -08:00
gramps a6aea5bf26 feat(ui): color and acknowledge pinned log errors
Add independent AppLog message types and explicit Class 1/2 severity. Wrap and color log rows, pin Class 1 above Class 2, and let acknowledgement return entries to the current-launch timeline without changing file format or rotation.

Ref: https://logtape.org/manual/levels
2026-11-02 18:17:18 -08:00
gramps 01a3fcaaf7 docs: replace system-message strip with Logs tab 2026-11-02 15:55:04 -08:00
gramps 264a90dae8 test(ui): align top bar mode guard with live decision 2026-11-02 11:43:57 -08:00
gramps c856d11fb5 test(ui): configure EDIT-off live start in pill guard 2026-11-02 11:38:17 -08:00
gramps c9bd7e6093 test(audio): match mic fader unity ceiling 2026-11-02 11:34:48 -08:00
gramps e6a7147dc3 test(ui): follow the relocated settings gear 2026-11-02 11:31:49 -08:00
gramps 0e1f948189 test(scoreboard): check shared dev-build gate 2026-11-02 11:28:11 -08:00
gramps caf91e6564 test(ui): read notification colors from app theme 2026-11-02 11:24:50 -08:00
gramps 82609a5046 test(ui): follow shared geometry panel gate 2026-11-02 11:20:54 -08:00
gramps 91e308e8e2 test(ui): inspect themed property hierarchy 2026-11-02 11:18:10 -08:00
gramps 221b81628a test(scoreboard): assert the themed pill switch 2026-11-02 11:14:15 -08:00
gramps 3985a413a7 test(ypp): keep upload window fixture current 2026-11-02 11:10:21 -08:00
gramps 9fa06c67c2 feat(ui): add current-launch logs tab
Add Live Chat and Logs tabs to the non-edit left panel. Expose a bounded process-local AppLog feed so the newest messages appear first without importing earlier startup.log contents or changing file logging.

Ref: https://obsproject.com/forum/resources/understanding-obs-studio-log.473
2026-11-02 11:06:43 -08:00
gramps 20f059422b feat(alerts): add scene-local recent ticker
Model the AlertBox and standalone ticker with shared settings; scene tickers render the in-memory active and queued events without saving history. Keep AlertBox content active-event-only.

Ref: https://obsproject.com/kb/obs-studio-overview
2026-11-02 10:47:48 -08:00
gramps 6208cb9409 feat(ui): route locked tiers through About
Keep non-owner resolution choices visible but muted. Selecting one preserves the free tier and opens About; external navigation is only the explicit llamacasty.com link. Ref: https://streamlabs.com/desktop
2026-11-02 09:41:38 -08:00
gramps ff5e8d94ae feat(entitlement): pivot to paid app ownership
Check Steam ownership of the paid app with BIsSubscribedApp; keep Debug fake ownership out of Release. Ref: https://partner.steamgames.com/doc/api/ISteamApps#BIsSubscribedApp
2026-11-02 09:18:03 -08:00
gramps 54902cb838 ui(properties): no raw typography left in the panel, and one heading rank
The earlier pass checked wiring only; this is the typography sweep. Raw Foreground/FontSize
labels are gone from the elements panel:

- Opacity, Timer (minutes) and URI are headings above their control (PropertyHeader), with the
  control row on the panel's 10px indent — the same shape as Border Thickness/Opacity.
- Scroll speed, scoreboard Opacity/Text size and their value readouts are PropertyFieldLabel.
- The drag hint and the scoreboard sign-in note are PropertyHint.
- Simulate (dev) keeps its orange, gains the field-label typography.
- Edge, Text colour and Background were PropertySubHeader/SubHeader: ONE heading rank now, so
  PropertySubHeader and the legacy SubHeader are deleted from Themes/Controls.xaml.
- DeveloperPanel — the last panel with raw typography and an unstyled CheckBox — now uses the
  same styles, and PropertiesStyleTests accepts PropertyPillCheck (the guard went stale when
  every check box became a pill switch). 18 tests pass; Release 0 warnings.
2026-11-01 16:26:20 -08:00
gramps 790cff45c2 ui(alerts): Video, Sound byte and Ticker are headings, not sub-headings
Same PropertyHeader style as Geometry and Stream Alerts — one heading rank in the properties
panel, not two.
2026-11-01 16:19:07 -08:00
gramps b3d7cf30b2 ui(properties): sub-headings sit flush left, level with the category header
Their rank is carried by size and colour, not by an indent — so Video, Sound byte and Ticker all
line up on the left edge exactly like each other, and only the controls under them tab in.
2026-11-01 16:17:38 -08:00
gramps 31b655cecb ui(alerts): a Ticker sub-heading, with Message ticker / Display / Volume tabbed under it 2026-11-01 16:15:41 -08:00
gramps 0fdd9d5faf fix(brand): one logo, not two — and hit-testing follows what you see
The brand exemption for the universal blur put a BrandLogoOverlay above the blur host while the
canvas ItemsControl kept drawing the same element, so two logos sat on top of each other.

- The canvas presenter for a brand logo is collapsed: BrandLogoOverlay is the only render, and it
  now carries the element's Opacity so the canvas copy is not missed.
- Hit-testing follows the eye: a click inside the logo's bounds selects the logo, because it is the
  topmost layer in the compositor too. Without that you saw the logo on top and grabbed the webcam
  underneath it.
- SelectedLayerPreviewHitTestingTests updated to the new contract (collapsed presenter, logo wins
  its bounds, list selection still works). 3 tests pass; Release 0 warnings.
2026-11-01 16:12:38 -08:00
gramps 41b3933b08 fix(background): refresh BOTH preview layers, not just the thumbnails
The preview draws two background images — BackgroundImage and, above it, ActiveBackgroundImage
(the static art) — while the mini-pane reads the scene snapshot. The default-restore path only
called UpdateBackgroundImage, so the thumbnails updated and the preview did not.

It now calls UpdateActiveBackground as well.

Also adds the generic cure behind the Background bug: OnSelectedElementPropertyChanged invalidates
the scene bake for ANY property change. A static layer's pixels are cached by element identity, so
a property edit without invalidation repaints nothing and the control looks dead — the failure was
never specific to UseDefaultBackground, only first noticed there.

Background + thumbnail tests pass (21); Release 0 warnings.
2026-11-01 16:01:41 -08:00
gramps d6088f8e35 fix(background): Use default background actually restores the shipped art
Browse… writes the chosen picture into BOTH CustomBackgroundPath and AssetId, and the default
stamp bails when AssetId is already set. So flipping "Use default background" back on changed a
bool while the preview kept showing the custom image — it looked like the switch did nothing.

Turning the default on now clears the custom pointer and the asset, re-runs
StampDefaultBackgroundAsset for the scene's shipped art, and only then invalidates the bake,
updates the preview and refreshes thumbnails.

BackgroundTests pass (17); Release 0 warnings.
2026-11-01 15:59:10 -08:00
gramps c297b3fb0f ui(properties): answer the visual review — quiet actions, real hint tone, dividers, legible disabled
Review findings from a screenshot of the Stream Alerts panel, each addressed:

- "Reset to built-in" and "Browse…" were YtButton-derived (white, semi-bold, 13px), so a reset
  action outranked the section header. Property-panel actions now use PropertyButton: bordered,
  11px, normal weight, OnSurface text.
- Explanation text and field labels were the same muted blue-gray. PropertyHint is now
  OnSurfaceDisabled, one clear step below a label.
- Groups inside a category ran together. PropertyDivider (1px OutlineVariant) separates Video,
  Sound byte and Message ticker.
- The disabled loop switch was near-invisible. Disabled now keeps the label at OnSurfaceVariant
  and dims the whole control to 0.55 — readable, obviously inactive.
- Switch "on" was full-strength accent, shouting about static state. The track now sits at 0.6
  opacity when on, which also stops it competing with the slider's accent fill.

No new palette entries: every change uses an existing token, so DESIGN.md stays the one colour
source. Release 0 warnings; token and typography tests pass.
2026-11-01 15:53:40 -08:00
gramps 8221bd6478 ui(properties): one typography hierarchy, pill switches, and tokens instead of hex
Creator directive 2026-11-01: the properties panels had 14 hand-rolled heading styles, 37
hand-rolled labels, four literal grey hex values, and 14 square checkboxes.

- PropertyHeader / PropertySubHeader / PropertyFieldLabel / PropertyHint in Themes/Controls.xaml
  now carry the whole hierarchy: category bright and 11px, labels and hints dim, size and weight
  doing the level. Colour groups the panel — bright for what you edit, dim for its labels and
  help — and no new palette entry was needed; DESIGN.md stays the only colour source.
- Every checkbox in the panel is a pill switch (PropertyPillCheck, 32x16 animated thumb).
  IsChecked semantics unchanged, so no binding moved.
- Literal #888/#666/#555 became OnSurfaceVariant / OnSurfaceDisabled.
- 13 headings, 2 sub-headers and 31 in-row labels now reference a style instead of repeating
  colour + size + alignment inline.

Controls audited for backing: every Button/Slider/ComboBox/CheckBox/ToggleButton/TextBox in the
panel has a Command or Click, a Value binding, an ItemsSource or Selected*, IsChecked, or Text —
no dead controls. Release 0 warnings; typography and token tests pass.
2026-11-01 15:51:33 -08:00
109 changed files with 3692 additions and 949 deletions
+3 -2
View File
@@ -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
View File
@@ -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
+5 -5
View File
@@ -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
View File
@@ -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 &amp; 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="&#x2B50;" 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="&#x2B50;" 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
View File
@@ -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 &quot;made with LlamaCasty!&quot; 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 &amp; 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>
+16
View File
@@ -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>
+7
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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) |
+2
View File
@@ -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
View File
@@ -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
View File
@@ -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);
}
}
+2
View File
@@ -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
View File
@@ -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;
+31
View File
@@ -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
View File
@@ -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
View File
@@ -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
+2 -2
View File
@@ -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;
}
}
}
+81 -30
View File
@@ -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();
}
}
}
+1 -1
View File
@@ -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();
}
}
+1 -1
View File
@@ -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}");
}
}
+6 -3
View File
@@ -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);
}
}
+7 -3
View File
@@ -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;
}
}
}
+10 -1
View File
@@ -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);
}
}
}
+5 -5
View File
@@ -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
{
+4 -7
View File
@@ -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 }:
+8
View File
@@ -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;
+5 -1
View File
@@ -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"]);
+13 -10
View File
@@ -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);
}
});
}
+9 -10
View File
@@ -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 })
{
+8 -5
View File
@@ -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; }
}
+6 -11
View File
@@ -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; }
}
+54 -31
View File
@@ -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;
}
}
+17 -1
View File
@@ -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)>();
+56
View File
@@ -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();
+24 -2
View File
@@ -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();
}
}
+6 -3
View File
@@ -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);
}
}
+158
View File
@@ -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);
}
+22 -10
View File
@@ -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
View File
@@ -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" |
+22 -12
View File
@@ -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\`
+3 -4
View File
@@ -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
+3 -4
View File
@@ -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 -6
View File
@@ -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
+1 -2
View File
@@ -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.
+2 -4
View File
@@ -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.
+2 -3
View File
@@ -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.*
+4 -5
View File
@@ -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.
+19 -3
View File
@@ -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.
+3 -3
View File
@@ -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.
+29 -21
View File
@@ -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
View File
@@ -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
View File
@@ -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
+10 -1
View File
@@ -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)
+5
View File
@@ -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
+144
View File
@@ -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();
}
+40 -26
View File
@@ -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;
}
}
+9 -3
View File
@@ -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
+7 -5
View File
@@ -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)}");
}
+35 -8
View File
@@ -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;
}
}
}
+14 -6
View File
@@ -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;
+33
View File
@@ -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);
}
+243
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
+17 -6
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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"**;
+1 -1
View File
@@ -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)."
+97 -2
View File
@@ -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>
+1
View File
@@ -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]
+8 -5
View File
@@ -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));
}
}
}
+49 -19
View File
@@ -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
{
+30
View File
@@ -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
+1 -1
View File
@@ -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
+151
View File
@@ -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;
}
}
+1 -1
View File
@@ -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 -3
View File
@@ -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..]);
});
}
}
+22 -5
View File
@@ -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
{
+20 -24
View File
@@ -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>
+2 -1
View File
@@ -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 */ }
}
}
}
}
+16 -9
View File
@@ -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>
+10 -2
View File
@@ -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
+39 -16
View File
@@ -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
}
}
+3 -3
View File
@@ -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).
+6 -4
View File
@@ -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