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
This commit is contained in:
+51
-42
@@ -2,8 +2,7 @@
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:Helpers="clr-namespace:ytLive.Helpers"
|
||||
xmlns:models="clr-namespace:ytLive.Models"
|
||||
xmlns:media="clr-namespace:System.Windows.Media;assembly=PresentationCore">
|
||||
xmlns:models="clr-namespace:ytLive.Models">
|
||||
<UserControl.Resources>
|
||||
<!-- TASK 63: the border-colour drop-down is the app's own YtComboBox, so the
|
||||
colour entries are drawn by TEMPLATES, in the theme's colours and with the
|
||||
@@ -321,47 +320,46 @@
|
||||
<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="0,0,0,6" TextWrapping="Wrap"/>
|
||||
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"
|
||||
VerticalScrollBarVisibility="Auto" MinHeight="72" MaxHeight="120" Margin="10,0,0,8"
|
||||
AutomationProperties.Name="Text layer content"/>
|
||||
<TextBlock Text="Font" Style="{StaticResource PropertyHeader}"/>
|
||||
<ComboBox IsEditable="True" ItemsSource="{x:Static media:Fonts.SystemFontFamilies}"
|
||||
DisplayMemberPath="Source"
|
||||
Text="{Binding SelectedElement.TextFontFamily, Mode=TwoWay, UpdateSourceTrigger=LostFocus}"
|
||||
Style="{StaticResource YtComboBox}" Height="26" Margin="0,0,0,6"
|
||||
AutomationProperties.Name="Text layer font"/>
|
||||
<DockPanel Margin="0,0,0,6" LastChildFill="False">
|
||||
<TextBlock DockPanel.Dock="Left" Text="Size" Style="{StaticResource PropertyFieldLabel}"
|
||||
Width="42" VerticalAlignment="Center"/>
|
||||
<ComboBox IsEditable="True"
|
||||
Text="{Binding SelectedElement.TextFontSize, Mode=TwoWay, UpdateSourceTrigger=LostFocus}"
|
||||
Style="{StaticResource YtComboBox}" Height="26" Width="76"
|
||||
AutomationProperties.Name="Text layer size">
|
||||
<ComboBoxItem Content="12"/><ComboBoxItem Content="16"/>
|
||||
<ComboBoxItem Content="20"/><ComboBoxItem Content="24"/>
|
||||
<ComboBoxItem Content="32"/><ComboBoxItem Content="36"/>
|
||||
<ComboBoxItem Content="48"/><ComboBoxItem Content="60"/>
|
||||
<ComboBoxItem Content="72"/><ComboBoxItem Content="96"/>
|
||||
<ComboBoxItem Content="120"/><ComboBoxItem Content="144"/>
|
||||
<ComboBoxItem Content="180"/><ComboBoxItem Content="240"/>
|
||||
<ComboBoxItem Content="300"/>
|
||||
</ComboBox>
|
||||
</DockPanel>
|
||||
<DockPanel Margin="0,0,0,6" LastChildFill="False">
|
||||
<TextBlock DockPanel.Dock="Left" Text="Color" Style="{StaticResource PropertyFieldLabel}"
|
||||
Width="42" VerticalAlignment="Center"/>
|
||||
<TextBlock Text="Typography" Style="{StaticResource PropertyHeader}"/>
|
||||
<StackPanel 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"/>
|
||||
<TextBlock Text="Size" Style="{StaticResource PropertyFieldLabel}" Margin="0,0,0,3"/>
|
||||
<ComboBox x:Name="TextLayerSizePicker"
|
||||
ItemsSource="{Binding TextFontSizeOptions}"
|
||||
SelectedItem="{Binding SelectedElement.TextFontSize, Mode=TwoWay}"
|
||||
Style="{StaticResource YtComboBox}" Height="30" Width="92"
|
||||
HorizontalAlignment="Left" AutomationProperties.Name="Text layer size"/>
|
||||
</StackPanel>
|
||||
<TextBlock Text="Color" Style="{StaticResource PropertyHeader}"/>
|
||||
<DockPanel Margin="10,0,0,6" LastChildFill="False">
|
||||
<TextBox x:Name="TextLayerColorEditor"
|
||||
Text="{Binding SelectedElement.TextColor, Mode=TwoWay, UpdateSourceTrigger=LostFocus}"
|
||||
Style="{StaticResource YtTextBoxCompact}" MaxLength="9" Width="82"
|
||||
ToolTip="#RRGGBB or #AARRGGBB" AutomationProperties.Name="Text layer color"/>
|
||||
<Border Width="18" Height="18" Margin="8,0,0,0" CornerRadius="2"
|
||||
Background="{Binding SelectedElement.TextColor, Converter={StaticResource HexToBrush}}"
|
||||
BorderBrush="{StaticResource Brush.Outline}" BorderThickness="1"
|
||||
VerticalAlignment="Center"/>
|
||||
<ComboBox x:Name="TextLayerColorPicker"
|
||||
ItemsSource="{Binding SelectedElement.TextColorOptions}"
|
||||
SelectedItem="{Binding SelectedElement.TextColorEntry, Mode=TwoWay}"
|
||||
ItemTemplateSelector="{StaticResource BorderColorTemplates}"
|
||||
Style="{StaticResource YtComboBox}" MinWidth="112" Margin="8,0,0,0"
|
||||
ToolTip="Choose a text color" AutomationProperties.Name="Text layer color palette"/>
|
||||
</DockPanel>
|
||||
<WrapPanel Margin="0,0,0,4">
|
||||
<TextBlock Text="Emphasis" Style="{StaticResource PropertyHeader}"/>
|
||||
<WrapPanel Margin="10,0,0,4">
|
||||
<CheckBox Style="{StaticResource PropertyPillCheck}" Content="Bold" Margin="0,0,8,4"
|
||||
IsChecked="{Binding SelectedElement.TextBold, Mode=TwoWay}"/>
|
||||
<CheckBox Style="{StaticResource PropertyPillCheck}" Content="Italic" Margin="0,0,8,4"
|
||||
@@ -370,13 +368,24 @@
|
||||
IsChecked="{Binding SelectedElement.TextUnderline, Mode=TwoWay}"/>
|
||||
</WrapPanel>
|
||||
<TextBlock Text="Alignment" Style="{StaticResource PropertyHeader}"/>
|
||||
<ComboBox SelectedValuePath="Tag"
|
||||
SelectedValue="{Binding SelectedElement.TextAlignment, Mode=TwoWay}"
|
||||
Style="{StaticResource YtComboBox}" Height="26">
|
||||
<ComboBoxItem Content="Left" Tag="{x:Static models:TextLayerAlignment.Left}"/>
|
||||
<ComboBoxItem Content="Center" Tag="{x:Static models:TextLayerAlignment.Center}"/>
|
||||
<ComboBoxItem Content="Right" Tag="{x:Static models:TextLayerAlignment.Right}"/>
|
||||
</ComboBox>
|
||||
<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) -->
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ See [`schema.md`](../schema.md) for the memory-map conventions.
|
||||
| `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`, 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; TextOverlay properties edit multiline content, font, size, color, emphasis, and alignment with a static-text notice. **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 |
|
||||
| `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 |
|
||||
|
||||
+29
-1
@@ -48,6 +48,13 @@ public enum TextLayerAlignment
|
||||
Right
|
||||
}
|
||||
|
||||
public enum TextLayerVerticalAlignment
|
||||
{
|
||||
Top,
|
||||
Center,
|
||||
Bottom
|
||||
}
|
||||
|
||||
public enum ClipShape
|
||||
{
|
||||
Traditional,
|
||||
@@ -423,9 +430,22 @@ public class Source : SceneElement
|
||||
public string TextColor
|
||||
{
|
||||
get => _textColor;
|
||||
set => Set(ref _textColor, IsValidTextColor(value) ? value.ToUpperInvariant() : "#FFFFFF");
|
||||
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); }
|
||||
|
||||
@@ -442,6 +462,14 @@ public class Source : SceneElement
|
||||
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;
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ 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 (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/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 |
|
||||
| `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) |
|
||||
|
||||
@@ -113,7 +113,7 @@ public partial class LayoutStore : IDisposable
|
||||
AlertVideoAssetId, AlertVideoPath, AlertUseDefaultVideo, AlertShowTicker, AlertVideoVolume, SortOrder,
|
||||
AlertDisplayMethod, IsBrandLogo, BlurAmount, AlertAudioPath, AlertAudioLoop,
|
||||
TextContent, TextFontFamily, TextFontSize, TextColor, TextBold, TextItalic,
|
||||
TextUnderline, TextAlignment
|
||||
TextUnderline, TextAlignment, TextVerticalAlignment
|
||||
FROM Source ORDER BY SortOrder
|
||||
""";
|
||||
using var reader = cmd.ExecuteReader();
|
||||
@@ -179,6 +179,10 @@ public partial class LayoutStore : IDisposable
|
||||
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)>();
|
||||
|
||||
@@ -339,6 +339,12 @@ public partial class LayoutStore : IDisposable
|
||||
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"))
|
||||
{
|
||||
|
||||
@@ -94,7 +94,7 @@ public partial class LayoutStore : IDisposable
|
||||
AlertVideoAssetId, AlertVideoPath, AlertUseDefaultVideo, AlertShowTicker, AlertVideoVolume,
|
||||
AlertDisplayMethod, IsBrandLogo, BlurAmount, AlertAudioPath, AlertAudioLoop,
|
||||
TextContent, TextFontFamily, TextFontSize, TextColor, TextBold, TextItalic,
|
||||
TextUnderline, TextAlignment)
|
||||
TextUnderline, TextAlignment, TextVerticalAlignment)
|
||||
VALUES ($id, $sceneId, $assetId, $type, $name, $isEnabled,
|
||||
$x, $y, $w, $h, $opacity, $monitor,
|
||||
$clip, $mirrored, $isBackground, $captureKey, $showDesktop, $sort,
|
||||
@@ -104,7 +104,7 @@ public partial class LayoutStore : IDisposable
|
||||
$alertVideoAssetId, $alertVideoPath, $alertUseDefaultVideo, $alertShowTicker, $alertVideoVolume,
|
||||
$alertDisplayMethod, $isBrandLogo, $blurAmount, $alertAudioPath, $alertAudioLoop,
|
||||
$textContent, $textFontFamily, $textFontSize, $textColor, $textBold, $textItalic,
|
||||
$textUnderline, $textAlignment)
|
||||
$textUnderline, $textAlignment, $textVerticalAlignment)
|
||||
""";
|
||||
cmd.Transaction = tx;
|
||||
var idP = cmd.Parameters.Add("$id", SqliteType.Text);
|
||||
@@ -156,6 +156,7 @@ public partial class LayoutStore : IDisposable
|
||||
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
|
||||
@@ -216,6 +217,7 @@ public partial class LayoutStore : IDisposable
|
||||
textItalicP.Value = source.TextItalic ? 1 : 0;
|
||||
textUnderlineP.Value = source.TextUnderline ? 1 : 0;
|
||||
textAlignmentP.Value = (int)source.TextAlignment;
|
||||
textVerticalAlignmentP.Value = (int)source.TextVerticalAlignment;
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@ public sealed class TextOverlayRenderer
|
||||
source.TextItalic,
|
||||
source.TextUnderline,
|
||||
source.TextAlignment,
|
||||
source.TextVerticalAlignment,
|
||||
RasterDimension(source.Width),
|
||||
RasterDimension(source.Height));
|
||||
|
||||
@@ -100,7 +101,13 @@ public sealed class TextOverlayRenderer
|
||||
};
|
||||
if (config.Underline)
|
||||
formatted.SetTextDecorations(TextDecorations.Underline, 0, config.Content.Length);
|
||||
drawing.DrawText(formatted, new Point(0, 0));
|
||||
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);
|
||||
@@ -145,6 +152,7 @@ public sealed class TextOverlayRenderer
|
||||
bool Italic,
|
||||
bool Underline,
|
||||
TextLayerAlignment Alignment,
|
||||
TextLayerVerticalAlignment VerticalAlignment,
|
||||
int Width,
|
||||
int Height);
|
||||
}
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ External-facing logic: YouTube API, persistence. See
|
||||
| `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. 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; TASK 3.17 adds eight additive `Source` text-layer columns (`TextContent`/font family/size/color/bold/italic/underline/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) |
|
||||
| `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) |
|
||||
|
||||
@@ -354,7 +354,8 @@ public partial class MainViewModel
|
||||
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.TextItalic) or nameof(Source.TextUnderline) or nameof(Source.TextAlignment)
|
||||
or nameof(Source.TextVerticalAlignment);
|
||||
if (bakedSurface)
|
||||
{
|
||||
var containingScene = Scenes.FirstOrDefault(s => s.Elements.Contains(el));
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Windows.Media;
|
||||
using ytLive.Models;
|
||||
using ytLive.Services;
|
||||
|
||||
@@ -7,9 +8,138 @@ namespace ytLive.ViewModels;
|
||||
|
||||
public partial class MainViewModel
|
||||
{
|
||||
private static readonly string[] SystemTextFontFamilies = Fonts.SystemFontFamilies
|
||||
.Select(font => font.Source)
|
||||
.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", "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 (SerifFonts.Contains(font, StringComparer.OrdinalIgnoreCase)
|
||||
|| (font.Contains("Serif", StringComparison.OrdinalIgnoreCase)
|
||||
&& !font.Contains("Sans", StringComparison.OrdinalIgnoreCase)))
|
||||
return "Serif";
|
||||
|
||||
if (font.Contains("Display", StringComparison.OrdinalIgnoreCase)
|
||||
|| font.Contains("Script", StringComparison.OrdinalIgnoreCase)
|
||||
|| DisplayFonts.Contains(font, StringComparer.OrdinalIgnoreCase))
|
||||
return "Display";
|
||||
|
||||
if (font.Contains("Sans", StringComparison.OrdinalIgnoreCase)
|
||||
|| SansSerifFonts.Contains(font, StringComparer.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", "Papyrus", "Showcard Gothic", "Stencil",
|
||||
};
|
||||
|
||||
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>()
|
||||
@@ -54,6 +184,7 @@ public partial class MainViewModel
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -74,6 +74,7 @@ public partial class MainViewModel : ViewModelBase
|
||||
OnPropertyChanged(nameof(IsBlurSelected));
|
||||
OnPropertyChanged(nameof(IsElementsPanelEmpty));
|
||||
OnPropertyChanged(nameof(CanToggleClipShape));
|
||||
SyncTextFontCategory();
|
||||
if (_selectedElement != null)
|
||||
_selectedElement.PropertyChanged += OnSelectedElementPropertyChanged;
|
||||
}
|
||||
@@ -113,6 +114,17 @@ public partial class MainViewModel : ViewModelBase
|
||||
// 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))
|
||||
{
|
||||
// ⛔ Turning the default back ON has to RE-STAMP the shipped art. Browse… writes the
|
||||
|
||||
+1
-1
@@ -30,7 +30,7 @@ areas' docs in their own rows keeps this index small to read.
|
||||
| `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; output resolution reads its immutable cached frame. |
|
||||
| `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)
|
||||
|
||||
@@ -18,6 +18,9 @@ public class SourceTextSettingsTests
|
||||
Assert.False(source.TextItalic);
|
||||
Assert.False(source.TextUnderline);
|
||||
Assert.Equal(TextLayerAlignment.Left, source.TextAlignment);
|
||||
Assert.Equal(TextLayerVerticalAlignment.Top, source.TextVerticalAlignment);
|
||||
Assert.Equal("#FFFFFF", source.TextColorEntry.Hex);
|
||||
Assert.Contains(source.TextColorOptions, color => color.Hex == "#FFFFFF");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -29,12 +32,14 @@ public class SourceTextSettingsTests
|
||||
TextFontSize = double.NaN,
|
||||
TextColor = "not-a-color",
|
||||
TextAlignment = (TextLayerAlignment)999,
|
||||
TextVerticalAlignment = (TextLayerVerticalAlignment)999,
|
||||
};
|
||||
|
||||
Assert.Equal("Segoe UI", source.TextFontFamily);
|
||||
Assert.Equal(48, source.TextFontSize);
|
||||
Assert.Equal("#FFFFFF", source.TextColor);
|
||||
Assert.Equal(TextLayerAlignment.Left, source.TextAlignment);
|
||||
Assert.Equal(TextLayerVerticalAlignment.Top, source.TextVerticalAlignment);
|
||||
|
||||
source.TextFontSize = 1;
|
||||
Assert.Equal(8, source.TextFontSize);
|
||||
@@ -42,5 +47,11 @@ public class SourceTextSettingsTests
|
||||
Assert.Equal(300, source.TextFontSize);
|
||||
source.TextColor = "#abcdef";
|
||||
Assert.Equal("#ABCDEF", source.TextColor);
|
||||
|
||||
source.TextColorEntry = BorderColorPalette.All.Single(color => color.Hex == "#FF0000");
|
||||
Assert.Equal("#FF0000", source.TextColor);
|
||||
Assert.Equal("Red", source.TextColorEntry.Name);
|
||||
source.TextColor = "#123456";
|
||||
Assert.Contains(source.TextColorOptions, color => color.Hex == "#123456");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Xunit;
|
||||
using ytLive.Controls;
|
||||
using ytLive.Models;
|
||||
using ytLive.ViewModels;
|
||||
|
||||
namespace ytLive.Tests;
|
||||
|
||||
[Collection("RealApp")]
|
||||
public sealed class TextLayerDropdownTests
|
||||
{
|
||||
private readonly RealAppHost _app;
|
||||
|
||||
public TextLayerDropdownTests(RealAppHost app) => _app = app;
|
||||
|
||||
[Fact]
|
||||
public void Text_Controls_Show_Defaults_Update_And_Restore_Selected_Values()
|
||||
{
|
||||
_app.Run(Run);
|
||||
}
|
||||
|
||||
private static void Run()
|
||||
{
|
||||
var dbPath = Path.Combine(Path.GetTempPath(), $"ytLlive-text-controls-{Guid.NewGuid():N}.db");
|
||||
MainViewModel.LayoutPathOverride = dbPath;
|
||||
MainWindow? window = null;
|
||||
try
|
||||
{
|
||||
window = new MainWindow();
|
||||
var vm = (MainViewModel)window.DataContext;
|
||||
vm.EditingActive = true;
|
||||
vm.StagedScene = vm.Scenes.Single(scene => SceneCatalog.Is(scene.Name, SceneCatalog.Live));
|
||||
vm.AddSourceCommand.Execute(SourceType.TextOverlay);
|
||||
var source = vm.StagedScene!.Elements.OfType<Source>()
|
||||
.Single(item => item.Type == SourceType.TextOverlay);
|
||||
vm.SelectedElement = source;
|
||||
|
||||
window.Show();
|
||||
window.UpdateLayout();
|
||||
|
||||
var panel = (LeftPanel)window.FindName("LeftPanel")!;
|
||||
var fontType = (ComboBox)panel.FindName("TextLayerFontTypePicker")!;
|
||||
var font = (ComboBox)panel.FindName("TextLayerFontPicker")!;
|
||||
var size = (ComboBox)panel.FindName("TextLayerSizePicker")!;
|
||||
var colorField = (TextBox)panel.FindName("TextLayerColorEditor")!;
|
||||
var color = (ComboBox)panel.FindName("TextLayerColorPicker")!;
|
||||
var horizontal = (ComboBox)panel.FindName("TextLayerHorizontalAlignmentPicker")!;
|
||||
var vertical = (ComboBox)panel.FindName("TextLayerVerticalAlignmentPicker")!;
|
||||
|
||||
Assert.Equal("Sans Serif", fontType.SelectedItem);
|
||||
Assert.Equal("Sans Serif", fontType.Text);
|
||||
Assert.Equal("Segoe UI", font.SelectedItem);
|
||||
Assert.Equal("Segoe UI", font.Text);
|
||||
Assert.Equal(48d, size.SelectedItem);
|
||||
Assert.Equal("48", size.Text);
|
||||
Assert.Equal("#FFFFFF", colorField.Text);
|
||||
Assert.Equal(source.TextColorEntry, color.SelectedItem);
|
||||
Assert.Equal(TextLayerAlignment.Left, horizontal.SelectedItem);
|
||||
Assert.Equal("Left", horizontal.Text);
|
||||
Assert.Equal(TextLayerVerticalAlignment.Top, vertical.SelectedItem);
|
||||
Assert.Equal("Top", vertical.Text);
|
||||
|
||||
Assert.Same(Application.Current.Resources["YtComboBox"], color.Style);
|
||||
Assert.Same(panel.Resources["BorderColorTemplates"], color.ItemTemplateSelector);
|
||||
Assert.Equal(10, ((FrameworkElement)fontType.Parent).Margin.Left);
|
||||
Assert.Equal(10, ((FrameworkElement)color.Parent).Margin.Left);
|
||||
Assert.Equal(10, ((FrameworkElement)horizontal.Parent).Margin.Left);
|
||||
Assert.Equal(10, ((FrameworkElement)vertical.Parent).Margin.Left);
|
||||
|
||||
fontType.SelectedItem = "Serif";
|
||||
window.UpdateLayout();
|
||||
Assert.Equal("Serif", vm.SelectedTextFontType);
|
||||
Assert.Equal("Segoe UI", source.TextFontFamily);
|
||||
Assert.NotEmpty(font.Items);
|
||||
var serifFont = font.Items.Cast<string>().First();
|
||||
font.SelectedItem = serifFont;
|
||||
size.SelectedItem = 36d;
|
||||
color.SelectedItem = source.TextColorOptions.Single(item => item.Hex == "#FF0000");
|
||||
horizontal.SelectedItem = TextLayerAlignment.Center;
|
||||
vertical.SelectedItem = TextLayerVerticalAlignment.Bottom;
|
||||
window.UpdateLayout();
|
||||
|
||||
Assert.Equal(serifFont, source.TextFontFamily);
|
||||
Assert.Equal(36, source.TextFontSize);
|
||||
Assert.Equal("#FF0000", source.TextColor);
|
||||
Assert.Equal(TextLayerAlignment.Center, source.TextAlignment);
|
||||
Assert.Equal(TextLayerVerticalAlignment.Bottom, source.TextVerticalAlignment);
|
||||
var raster = Assert.IsType<ytLive.Services.VideoFrame>(vm.ResolveOutputFrame(source));
|
||||
Assert.True(FirstInkRow(raster) > 50,
|
||||
"bottom alignment did not place the text lower inside its box");
|
||||
Assert.Equal("Serif", fontType.SelectedItem);
|
||||
Assert.Equal(serifFont, font.SelectedItem);
|
||||
Assert.Equal(serifFont, font.Text);
|
||||
Assert.Equal(36d, size.SelectedItem);
|
||||
Assert.Equal("36", size.Text);
|
||||
Assert.Equal("#FF0000", colorField.Text);
|
||||
Assert.Equal(source.TextColorEntry, color.SelectedItem);
|
||||
Assert.Equal(TextLayerAlignment.Center, horizontal.SelectedItem);
|
||||
Assert.Equal("Center", horizontal.Text);
|
||||
Assert.Equal(TextLayerVerticalAlignment.Bottom, vertical.SelectedItem);
|
||||
Assert.Equal("Bottom", vertical.Text);
|
||||
|
||||
vm.SaveLayoutNow();
|
||||
window.Close();
|
||||
window = null;
|
||||
SqliteConnection.ClearAllPools();
|
||||
|
||||
window = new MainWindow();
|
||||
var reloadedVm = (MainViewModel)window.DataContext;
|
||||
reloadedVm.EditingActive = true;
|
||||
reloadedVm.StagedScene = reloadedVm.Scenes.Single(scene => SceneCatalog.Is(scene.Name, SceneCatalog.Live));
|
||||
var reloaded = reloadedVm.StagedScene.Elements.OfType<Source>()
|
||||
.Single(item => item.Type == SourceType.TextOverlay);
|
||||
reloadedVm.SelectedElement = reloaded;
|
||||
window.Show();
|
||||
window.UpdateLayout();
|
||||
|
||||
var reloadedPanel = (LeftPanel)window.FindName("LeftPanel")!;
|
||||
Assert.Equal(reloadedVm.SelectedTextFontType, ((ComboBox)reloadedPanel.FindName("TextLayerFontTypePicker")!).SelectedItem);
|
||||
Assert.Equal(reloaded.TextFontFamily, ((ComboBox)reloadedPanel.FindName("TextLayerFontPicker")!).SelectedItem);
|
||||
Assert.Equal(reloaded.TextFontFamily, ((ComboBox)reloadedPanel.FindName("TextLayerFontPicker")!).Text);
|
||||
Assert.Equal(reloaded.TextFontSize, ((ComboBox)reloadedPanel.FindName("TextLayerSizePicker")!).SelectedItem);
|
||||
Assert.Equal(reloaded.TextFontSize.ToString(), ((ComboBox)reloadedPanel.FindName("TextLayerSizePicker")!).Text);
|
||||
Assert.Equal(reloaded.TextColor, ((TextBox)reloadedPanel.FindName("TextLayerColorEditor")!).Text);
|
||||
Assert.Equal(reloaded.TextColorEntry, ((ComboBox)reloadedPanel.FindName("TextLayerColorPicker")!).SelectedItem);
|
||||
Assert.Equal(reloaded.TextAlignment, ((ComboBox)reloadedPanel.FindName("TextLayerHorizontalAlignmentPicker")!).SelectedItem);
|
||||
Assert.Equal(reloaded.TextAlignment.ToString(), ((ComboBox)reloadedPanel.FindName("TextLayerHorizontalAlignmentPicker")!).Text);
|
||||
Assert.Equal(reloaded.TextVerticalAlignment, ((ComboBox)reloadedPanel.FindName("TextLayerVerticalAlignmentPicker")!).SelectedItem);
|
||||
Assert.Equal(reloaded.TextVerticalAlignment.ToString(), ((ComboBox)reloadedPanel.FindName("TextLayerVerticalAlignmentPicker")!).Text);
|
||||
}
|
||||
finally
|
||||
{
|
||||
window?.Close();
|
||||
MainViewModel.LayoutPathOverride = null;
|
||||
SqliteConnection.ClearAllPools();
|
||||
try { File.Delete(dbPath); } catch { /* best-effort cleanup */ }
|
||||
}
|
||||
}
|
||||
|
||||
private static int FirstInkRow(ytLive.Services.VideoFrame frame)
|
||||
{
|
||||
for (var y = 0; y < frame.Height; y++)
|
||||
for (var x = 0; x < frame.Width; x++)
|
||||
if (frame.BgraPixels[(y * frame.Width + x) * 4 + 3] > 0)
|
||||
return y;
|
||||
return frame.Height;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user