Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
769c616706 | ||
|
|
7da287244b | ||
|
|
841c905c06 | ||
|
|
b86b6bb601 | ||
|
|
f67a263ccd | ||
|
|
90cd039d98 |
@@ -2,7 +2,8 @@
|
||||
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:Models="clr-namespace:ytLive.Models"
|
||||
xmlns:services="clr-namespace:ytLive.Services">
|
||||
<Grid Grid.RowSpan="3"
|
||||
Visibility="{Binding IsAnyOverlayOpen, Converter={StaticResource BoolToVis}}">
|
||||
<Button Background="#99000000" BorderThickness="0" Cursor="Hand"
|
||||
@@ -55,6 +56,29 @@
|
||||
<ComboBoxItem Content="Phone (Vertical Rectangle)" Tag="{x:Static Models:ZoomShape.VerticalRectangle}"/>
|
||||
<ComboBoxItem Content="Tablet (Horizontal Rectangle)" Tag="{x:Static Models:ZoomShape.HorizontalRectangle}"/>
|
||||
</ComboBox>
|
||||
<TextBlock Text="Brand Logo" Style="{StaticResource YtLabel}" Margin="0,12,0,4"/>
|
||||
<Grid Margin="0,0,0,4">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBox Style="{StaticResource YtTextBox}" Text="{Binding BrandLogoFileDisplay, Mode=OneWay}"
|
||||
IsReadOnly="True" Foreground="#a0a0b0"/>
|
||||
<Button Grid.Column="1" Content="Browse…" Command="{Binding BrowseBrandLogoCommand}"
|
||||
Style="{StaticResource YtButtonSecondary}" Margin="8,0,0,0"/>
|
||||
</Grid>
|
||||
<TextBlock Text="Scene Transition" Style="{StaticResource YtLabel}" Margin="0,12,0,4"/>
|
||||
<ComboBox Style="{StaticResource YtComboBox}" SelectedValue="{Binding SelectedTransitionType}" SelectedValuePath="Tag">
|
||||
<ComboBoxItem Content="Cut" Tag="{x:Static services:TransitionType.Cut}"/>
|
||||
<ComboBoxItem Content="Fade" Tag="{x:Static services:TransitionType.Fade}"/>
|
||||
<ComboBoxItem Content="Move" Tag="{x:Static services:TransitionType.Move}"/>
|
||||
<ComboBoxItem Content="Wipe" Tag="{x:Static services:TransitionType.Wipe}"/>
|
||||
</ComboBox>
|
||||
<TextBlock Style="{StaticResource YtLabel}" Margin="0,8,0,4">
|
||||
<Run Text="Transition length: "/><Run Text="{Binding TransitionDurationMs, StringFormat={}{0:0} ms}"/>
|
||||
</TextBlock>
|
||||
<Slider Minimum="100" Maximum="2000" TickFrequency="50" IsSnapToTickEnabled="True"
|
||||
Value="{Binding TransitionDurationMs, Mode=TwoWay}"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Report Bug -->
|
||||
|
||||
@@ -71,8 +71,10 @@
|
||||
Visibility="{Binding BackgroundVisible, Converter={StaticResource BoolToVis}}"/>
|
||||
<Image Source="{Binding ActiveBackgroundImage}" Stretch="UniformToFill"/>
|
||||
</Grid>
|
||||
<Image x:Name="TransitionOverlay" Width="1920" Height="1080" Stretch="Fill"
|
||||
IsHitTestVisible="False" Visibility="Collapsed"/>
|
||||
<Canvas x:Name="OverlayCanvas" Width="1920" Height="1080">
|
||||
<ItemsControl ItemsSource="{Binding StagedScene.Elements}"
|
||||
<ItemsControl x:Name="OverlayElements" ItemsSource="{Binding StagedScene.Elements}"
|
||||
Width="1920" Height="1080">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
|
||||
@@ -31,27 +31,90 @@ public partial class PreviewPane : UserControl
|
||||
{
|
||||
InitializeComponent();
|
||||
DataContextChanged += PreviewPane_DataContextChanged;
|
||||
OverlayElements.ItemContainerGenerator.StatusChanged += (_, _) => UpdateSelectedLayerZOrder();
|
||||
}
|
||||
|
||||
private void PreviewPane_DataContextChanged(object sender, DependencyPropertyChangedEventArgs e)
|
||||
{
|
||||
if (e.OldValue is MainViewModel oldVm)
|
||||
{
|
||||
oldVm.PropertyChanged -= ViewModel_PropertyChanged;
|
||||
oldVm.PreviewTransition -= PlayPreviewTransition;
|
||||
}
|
||||
if (e.NewValue is MainViewModel newVm)
|
||||
{
|
||||
newVm.PropertyChanged += ViewModel_PropertyChanged;
|
||||
newVm.PreviewTransition += PlayPreviewTransition;
|
||||
UpdateZoomBox();
|
||||
UpdateSelectedLayerZOrder();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Animates the outgoing picture away over the new scene: a fade for Fade and Move,
|
||||
/// a right-to-left shrink for Wipe. Cut plays nothing.</summary>
|
||||
private void PlayPreviewTransition(ytLive.Services.TransitionType type, double milliseconds)
|
||||
{
|
||||
TransitionOverlay.BeginAnimation(System.Windows.UIElement.OpacityProperty, null);
|
||||
TransitionOverlay.Clip = null;
|
||||
if (type == ytLive.Services.TransitionType.Cut || milliseconds <= 0)
|
||||
{
|
||||
TransitionOverlay.Visibility = Visibility.Collapsed;
|
||||
return;
|
||||
}
|
||||
|
||||
var snapshot = new System.Windows.Media.Imaging.RenderTargetBitmap(1920, 1080, 96, 96,
|
||||
System.Windows.Media.PixelFormats.Pbgra32);
|
||||
snapshot.Render(PreviewCanvas);
|
||||
TransitionOverlay.Source = snapshot;
|
||||
TransitionOverlay.Opacity = 1;
|
||||
TransitionOverlay.Visibility = Visibility.Visible;
|
||||
var duration = TimeSpan.FromMilliseconds(milliseconds);
|
||||
|
||||
if (type == ytLive.Services.TransitionType.Wipe)
|
||||
{
|
||||
var clip = new System.Windows.Media.RectangleGeometry(new Rect(0, 0, 1920, 1080));
|
||||
TransitionOverlay.Clip = clip;
|
||||
var reveal = new System.Windows.Media.Animation.RectAnimation(new Rect(0, 0, 0, 1080), duration)
|
||||
{
|
||||
FillBehavior = System.Windows.Media.Animation.FillBehavior.Stop,
|
||||
};
|
||||
reveal.Completed += (_, _) => TransitionOverlay.Visibility = Visibility.Collapsed;
|
||||
clip.BeginAnimation(System.Windows.Media.RectangleGeometry.RectProperty, reveal);
|
||||
}
|
||||
else
|
||||
{
|
||||
var fade = new System.Windows.Media.Animation.DoubleAnimation(1, 0, duration)
|
||||
{
|
||||
FillBehavior = System.Windows.Media.Animation.FillBehavior.Stop,
|
||||
};
|
||||
fade.Completed += (_, _) => TransitionOverlay.Visibility = Visibility.Collapsed;
|
||||
TransitionOverlay.BeginAnimation(System.Windows.UIElement.OpacityProperty, fade);
|
||||
}
|
||||
}
|
||||
|
||||
private void ViewModel_PropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
|
||||
{
|
||||
if (e.PropertyName == nameof(MainViewModel.SelectedElement))
|
||||
UpdateSelectedLayerZOrder();
|
||||
if (e.PropertyName == nameof(MainViewModel.IsZoomActive) || e.PropertyName == nameof(MainViewModel.ZoomState) || e.PropertyName == nameof(MainViewModel.ZoomShape))
|
||||
{
|
||||
UpdateZoomBox();
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateSelectedLayerZOrder()
|
||||
{
|
||||
var selected = (DataContext as MainViewModel)?.SelectedElement;
|
||||
if (selected != null && !IsDraggableElement(selected))
|
||||
selected = null;
|
||||
|
||||
for (var i = 0; i < OverlayElements.Items.Count; i++)
|
||||
{
|
||||
if (OverlayElements.ItemContainerGenerator.ContainerFromIndex(i) is UIElement container)
|
||||
Panel.SetZIndex(container, ReferenceEquals(OverlayElements.Items[i], selected) ? 1 : 0);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Static images and the webcam share the move/resize/selection behavior.
|
||||
@@ -393,7 +456,7 @@ public partial class PreviewPane : UserControl
|
||||
return;
|
||||
}
|
||||
|
||||
var hit = HitElement(vm, p);
|
||||
var hit = HitElementAt(vm, p);
|
||||
if (hit != null)
|
||||
{
|
||||
vm.SelectedElement = hit;
|
||||
@@ -547,23 +610,34 @@ public partial class PreviewPane : UserControl
|
||||
return Math.Abs(mouseScreen.X - corner.X) <= 20 && Math.Abs(mouseScreen.Y - corner.Y) <= 20;
|
||||
}
|
||||
|
||||
private SceneElement? HitElement(MainViewModel vm, Point p)
|
||||
internal SceneElement? HitElementAt(MainViewModel vm, Point p)
|
||||
{
|
||||
var scene = vm.StagedScene;
|
||||
if (scene == null) return null;
|
||||
|
||||
// A layer selected from the list temporarily wins both preview stacking and hit-testing;
|
||||
// the scene's persistent element order remains unchanged.
|
||||
if (vm.SelectedElement is { } selected && IsHitTestable(selected) && Contains(selected, p))
|
||||
return selected;
|
||||
|
||||
for (var i = scene.Elements.Count - 1; i >= 0; i--)
|
||||
{
|
||||
var element = scene.Elements[i];
|
||||
if (!IsDraggableElement(element)) continue;
|
||||
if (element is Source { IsEnabled: false }) continue;
|
||||
if (element is WebcamSceneConfig { IsVisible: false }) continue;
|
||||
if (p.X >= element.X && p.X <= element.X + element.Width &&
|
||||
p.Y >= element.Y && p.Y <= element.Y + element.Height)
|
||||
if (IsHitTestable(element) && Contains(element, p))
|
||||
return element;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool IsHitTestable(SceneElement element)
|
||||
=> IsDraggableElement(element)
|
||||
&& element is not Source { IsEnabled: false }
|
||||
&& element is not WebcamSceneConfig { IsVisible: false };
|
||||
|
||||
private static bool Contains(SceneElement element, Point point)
|
||||
=> point.X >= element.X && point.X <= element.X + element.Width
|
||||
&& point.Y >= element.Y && point.Y <= element.Y + element.Height;
|
||||
|
||||
private void UpdateZoomBox()
|
||||
{
|
||||
if (DataContext is not MainViewModel vm) return;
|
||||
@@ -599,4 +673,4 @@ public partial class PreviewPane : UserControl
|
||||
ZoomVisualBrush.Visual = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+6
-6
@@ -7,13 +7,13 @@ See [`schema.md`](../schema.md) for the memory-map conventions.
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `BottomBar.xaml(.cs)` | Footer (Row 3): stream stats (bitrate/fps/dropped/duration/health), resolution dropdown (gear menu moved up to TopBar 2026-09-22) |
|
||||
| `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 (2026-09-22 redesign): brand + gear menu (left), one segmented REC|ON-AIR mode switch + world actions (Record: Start Recording · Stream: Go Live + Test) + reality line while running (dot/word/elapsed) + End (center), account zone — Sign In or avatar w/ Change Account/Logout context menu (right). `RefreshAvatar()`, `GearButton_Click` |
|
||||
| `PreviewPane.xaml(.cs)` | Row 1 Col 1 center: the 1920×1080 `OverlayCanvas`/`SelectionOverlay`/`BrandFlashLayer`/`SocialBarElement`, text pull-out drawer, and the live-controls row (TRAX / game / mic meters + sliders + speakers + socials). **TASK 57:** dragging a webcam under the vertical tier routes through `MainViewModel.MoveWebcamInVerticalTier` (the six-spot snap inside the cropped band) instead of free-floating over the master, and a resize re-snaps the tile onto its spot (TASK 57 Unit 2: the resize routes through `MainViewModel.ResizeWebcamInVerticalTier` — the Chat camera's two sizes, then the snap) |
|
||||
| `SceneThumbnailStrip.xaml(.cs)` | Row 0 scene-picker strip (`ItemsControl` over `Scenes`), staged-red / live-green border highlight |
|
||||
| `LeftPanel.xaml(.cs)` | Row 1 Col 0 left panel: edit mode → layers+properties; live → YouTube chat. Source add/edit/remove, list drag-to-reorder, snapshot revert-accept. **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 overlay dialogs: Settings / Report Bug / Feature Request / About (+ licenses + license-key entry). `BackToAbout_Click` lives 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; direct right-click-to-settings is active uncommitted work. `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. |
|
||||
| `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 |
|
||||
| `SceneThumbnailStrip.xaml.cs` | Code-behind for the strip above |
|
||||
| `LeftPanel.xaml.cs` | Code-behind for the panel above |
|
||||
|
||||
|
||||
+62
-4
@@ -1,6 +1,64 @@
|
||||
# HANDOFF — 2026-10-26 (ai.md memory split, 3 units landed; see below — everything after this block is the prior 2026-10-22 state, still accurate)
|
||||
# HANDOFF — 2026-10-28 (selected-layer preview stacking; suite green)
|
||||
|
||||
## At a glance (2026-10-26 docs session)
|
||||
## Current state
|
||||
- **Branch `main`; `origin/main` remains at `7ae30d4`.** The five
|
||||
feature commits since that point are `90cd039` (fade starts from outgoing picture), `f67a263`
|
||||
(transition picker), `b86b6bb` (Wipe), `841c905` (brand logo layer), and `7da2872` (preview and
|
||||
program transitions). They are not pushed; no push was requested.
|
||||
- **Four UI-redesign commits already on `origin/main` were missing from the older handoff:**
|
||||
`24aca7d` moved the scene strip above the preview and removed the default CRT/on-air image seeds;
|
||||
`3239808` added the Scene Layers header/count and layer icons plus scene-card names, hotkeys, and
|
||||
PROGRAM badge; `a8814b4` added the explicit EDIT toggle and fixed the scene-card/left-column
|
||||
sizing; `7ae30d4` moved readouts/resolution into the top-left cell, added the Socials rail drawer,
|
||||
and restored webcam/chat border controls and vertical 1080p30. `b7ab169` added [`DESIGN.md`](DESIGN.md)
|
||||
with exploratory color/type tokens; it explicitly is not a layout spec or a theme implementation.
|
||||
`ai.md` and `Controls/index.md` now describe the landed surface. No new task was added to the queue.
|
||||
- **Shipped in those commits:** type/duration picker persisted with the layout; CPU left-to-right
|
||||
Wipe; one locked Brand logo image layer per scene; preview transition animation in every mode;
|
||||
program transitions while recording or streaming. The 7da2872 commit omitted a memory update;
|
||||
`TASKS.md` and `ai.md` now reflect it.
|
||||
- **Current work unit:** when a draggable element is selected from the layer list, `PreviewPane`
|
||||
temporarily raises its item container and gives it priority in the custom preview hit test. On
|
||||
deselection, normal preview stacking returns; the scene collection order and saved/program z-order
|
||||
never change. `SelectedLayerPreviewHitTestingTests` verifies the overlapping Brand logo/webcam
|
||||
visual hit, custom hit priority, and deselection restore.
|
||||
- **The six prior suite failures are resolved in their tests, not by reverting current behavior:**
|
||||
LayerReorder now accounts for the permanent Brand logo after Background; BorderColorPicker validates
|
||||
every palette picker and scopes its rendered-widget lookup to the visible webcam control; both
|
||||
thumbnail cases persist explicit tile assets instead of relying on removed default seed files;
|
||||
SourceNaming only considers user-created Text overlays; ZoomBox locates its placeholder by type,
|
||||
not a list index changed by Brand logo.
|
||||
- **The worktree already had three uncommitted user edits before this docs sync; they are preserved:**
|
||||
`Controls/OverlayHost.xaml` groups App Settings into Recordings, Brand logo, Preview, and Scene
|
||||
transition sections with compact controls; `Controls/TopBar.xaml` wires right-click on the gear;
|
||||
`Controls/TopBar.xaml.cs` opens App Settings directly on right-click while left-click keeps the
|
||||
menu. These are active UX-redesign work, not part of the selected-layer fix. On-screen verification is
|
||||
still pending.
|
||||
- **This work unit is committed locally, not pushed.** The three pre-existing user edits above
|
||||
remain uncommitted and untouched.
|
||||
- **Next:** continue the creator's UX redesign and verify the current App Settings/gear changes,
|
||||
transitions, and Brand logo on-screen. The earlier vertical-webcam hands-on check is also still
|
||||
pending unless the creator confirms it was completed. Move's persistent-layer behavior and Cube
|
||||
remain queued; distribution remains deferred until near v1.
|
||||
- **Verification state:** `verify.sh` passed: clean build with 0 warnings, **444/444 tests passed**,
|
||||
and scope check passed. The new selected-layer integration test also passes by itself.
|
||||
- **Follow-up, not fixed here:** `ytLive.csproj`'s `GenerateBuildStamp` comment still describes a
|
||||
commit-count-based `+1 per commit` counter, but the target now reads/writes local `BuildCounter.txt`
|
||||
and increments it per build. `ai.md` records the code's current behavior; correct the stale source
|
||||
comment in a separately scoped change. `ViewModels/MainViewModel.Socials.cs` also still calls the
|
||||
Socials surface a modal dialog in its method comment; the committed UI is now a preview-rail drawer.
|
||||
Correct that source comment separately if the file is next in scope. The new deterministic WPF
|
||||
hit-test recipe is in `MyMistakes.md`; the final commit cites its Microsoft reference.
|
||||
- **WPF stacking reference:** [`Panel.ZIndex`](https://learn.microsoft.com/en-us/dotnet/api/system.windows.controls.panel.zindex?view=windowsdesktop-10.0)
|
||||
governs preview container order. The code also prioritizes the selected element in `HitElementAt`,
|
||||
because the custom hit test does not consult WPF's visual z-order. The deterministic test recipe and
|
||||
hit-testing reference are recorded in `MyMistakes.md`; cite that Microsoft reference in the commit.
|
||||
|
||||
---
|
||||
|
||||
## Historical notes (retained reference; current checkout state is above)
|
||||
|
||||
### At a glance (2026-10-26 docs session)
|
||||
- **`ai.md` is being split into `docs/ai/*.md`** (user-approved: verbatim move, staged units, "ai.md
|
||||
only"). Three units landed as separate commits, **docs only, not pushed**:
|
||||
`804b1d2` (frame pipeline: cadence/timing/budget/compositor/pump),
|
||||
@@ -20,7 +78,7 @@
|
||||
|
||||
---
|
||||
|
||||
## Prior state (2026-10-22) — the creator's properties-panel asks shipped, incl. TASK 63, and pushed; the vv webcam Units 1+2 state below
|
||||
### Earlier detailed state (2026-10-22) — properties-panel asks shipped, incl. TASK 63, and pushed; vv webcam Units 1+2 state below
|
||||
|
||||
## At a glance
|
||||
- **Branch** main, tracking `origin/main`. **The six-commit properties-panel batch is PUSHED**
|
||||
@@ -264,7 +322,7 @@ Zombie entities deleted from the registry: `script.leak_strobe`, `script.doorbel
|
||||
*Test:* `ytLive.Tests/VerticalChatCameraLayoutTests.cs` — in Chat under the vertical tier, enlarge sets
|
||||
the full top area and the circle option is not offered. **PASSES.**
|
||||
|
||||
## Next step
|
||||
## Historical next step (2026-10-22; superseded by Current state above)
|
||||
- **✅ TASK 63 + popup-click fix verified by creator (2026-10-22)** — clicking a colour in the
|
||||
Border Color `YtComboBox` dropdown now stays inside `LeftPanel` (`IsDescendantOf` crosses
|
||||
`PopupRoot` → `PART_Popup` via `LogicalTreeHelper.GetParent`), updates the webcam border colour,
|
||||
|
||||
@@ -88,6 +88,10 @@ public class Source : SceneElement
|
||||
public override bool IsWebSource => Type == SourceType.WebSource;
|
||||
public override bool IsTextOverlay => Type == SourceType.TextOverlay;
|
||||
|
||||
/// <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>
|
||||
public bool IsBrandLogo { get; set; }
|
||||
|
||||
/// <summary>The permanent scene background (live desktop/game capture). Never removable/reorderable.</summary>
|
||||
private bool _isBackground;
|
||||
public override bool IsBackground { get => _isBackground; set => Set(ref _isBackground, value); }
|
||||
|
||||
@@ -775,6 +775,22 @@ with `#Name`) + panel properties (`IsHitTestVisible/Opacity/children/actual size
|
||||
`camera 'test-camera' failed` are test noise, not DB state — to check pollution, query
|
||||
the DB directly (`python3 sqlite3`, `SELECT DeviceId FROM Webcam`), not the log.
|
||||
|
||||
## Deterministic test for a selected layer above an overlap (2026-10-28)
|
||||
|
||||
When preview rendering uses WPF z-order but dragging uses an additional custom hit-test, test both
|
||||
contracts without injecting a physical cursor: in a real `MainWindow` on the `RealApp` STA, overlap
|
||||
two scene elements, select the lower one through the layer list, assert `Panel.GetZIndex` raises its
|
||||
container, assert `window.InputHitTest(point)` resolves the selected visual, and call the same
|
||||
production hit-test method used by the drag handler to assert it chooses that selected model. Clear
|
||||
selection and assert both hit paths return the original top layer and the scene's element order is
|
||||
unchanged. WPF documents that input hit testing returns the input element at a point and that visual
|
||||
hit-test results follow z-order: <https://learn.microsoft.com/en-us/dotnet/desktop/wpf/graphics-multimedia/hit-testing-in-the-visual-layer>.
|
||||
|
||||
Do not use `SetCursorPos`/`mouse_event` for this specific assertion: the test passed alone but was
|
||||
intermittent in the full suite, with a cursor move landing at the canvas edge (`X=1900`) rather than
|
||||
the intended offset. Physical injection remains appropriate only when the behavior under test is the
|
||||
actual OS mouse-routing/drag path (as in the layer-list reorder test).
|
||||
|
||||
## A "known failure" label without a recorded cause = a bug on life support
|
||||
|
||||
(2026-09-01, the audio triple-take) One line — `_delayedMix` (nullable, added by TASK 22,
|
||||
|
||||
@@ -32,6 +32,7 @@ public sealed class FramePump : IDisposable
|
||||
private readonly Func<VideoFrame?>? _alertTicker;
|
||||
private readonly Func<VideoFrame?>? _brandFlash;
|
||||
private readonly TransitionService? _transition;
|
||||
private VideoFrame? _lastOutput;
|
||||
private readonly SceneGraph? _sceneGraph;
|
||||
private readonly SceneCompositor _compositor = new();
|
||||
|
||||
@@ -497,6 +498,9 @@ public sealed class FramePump : IDisposable
|
||||
socialBarTicks += sw.ElapsedTicks;
|
||||
|
||||
VideoFrame frame;
|
||||
if (_transition is { CaptureRequested: true } capture && _lastOutput != null)
|
||||
capture.SupplyFromFrame(new VideoFrame(_lastOutput.Width, _lastOutput.Height,
|
||||
(byte[])_lastOutput.BgraPixels.Clone()));
|
||||
var scratchSize = compositorOptions.SourceRectWidth
|
||||
* compositorOptions.SourceRectHeight * 4;
|
||||
renderSw.Restart();
|
||||
@@ -567,6 +571,7 @@ public sealed class FramePump : IDisposable
|
||||
// tick's buffers are recyclable once the enqueue snapshot them.
|
||||
// The free-list Contains guard keeps the Cut path (BlendFrame
|
||||
// returns toFrame itself, aliasing scratch) safe.
|
||||
_lastOutput = frame;
|
||||
ReleaseScratch(frame.BgraPixels);
|
||||
ReleaseScratch(scratch);
|
||||
|
||||
|
||||
@@ -102,7 +102,7 @@ public partial class LayoutStore : IDisposable
|
||||
ChatShowTimestamps, ChatShowBadges, ChatMemberColor, ChatSuperChatColor,
|
||||
WebUri, MediaPath, MediaIsLooping, MediaVolume, MediaPlaybackState,
|
||||
AlertVideoAssetId, AlertVideoPath, AlertUseDefaultVideo, AlertShowTicker, AlertVideoVolume, SortOrder,
|
||||
AlertDisplayMethod
|
||||
AlertDisplayMethod, IsBrandLogo
|
||||
FROM Source ORDER BY SortOrder
|
||||
""";
|
||||
using var reader = cmd.ExecuteReader();
|
||||
@@ -154,6 +154,7 @@ public partial class LayoutStore : IDisposable
|
||||
AlertShowTicker = reader.IsDBNull(33) ? true : reader.GetInt32(33) != 0,
|
||||
AlertVideoVolume = reader.IsDBNull(34) ? 1.0 : reader.GetDouble(34),
|
||||
AlertDisplayMethod = reader.IsDBNull(36) ? AlertDisplayMethod.TickerScroll : (AlertDisplayMethod)reader.GetInt32(36),
|
||||
IsBrandLogo = reader.GetInt32(reader.GetOrdinal("IsBrandLogo")) != 0,
|
||||
};
|
||||
if (!sourcesByScene.TryGetValue(sceneId, out var list))
|
||||
sourcesByScene[sceneId] = list = new List<(Source, int)>();
|
||||
|
||||
@@ -199,6 +199,13 @@ public partial class LayoutStore : IDisposable
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
if (!columns.Contains("IsBrandLogo"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE Source ADD COLUMN IsBrandLogo INTEGER NOT NULL DEFAULT 0;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
if (!columns.Contains("CaptureKey"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
|
||||
@@ -92,7 +92,7 @@ public partial class LayoutStore : IDisposable
|
||||
ChatShowTimestamps, ChatShowBadges, ChatMemberColor, ChatSuperChatColor,
|
||||
WebUri, MediaPath, MediaIsLooping, MediaVolume, MediaPlaybackState,
|
||||
AlertVideoAssetId, AlertVideoPath, AlertUseDefaultVideo, AlertShowTicker, AlertVideoVolume,
|
||||
AlertDisplayMethod)
|
||||
AlertDisplayMethod, IsBrandLogo)
|
||||
VALUES ($id, $sceneId, $assetId, $type, $name, $isEnabled,
|
||||
$x, $y, $w, $h, $opacity, $monitor,
|
||||
$clip, $mirrored, $isBackground, $captureKey, $showDesktop, $sort,
|
||||
@@ -100,7 +100,7 @@ public partial class LayoutStore : IDisposable
|
||||
$chatTimestamps, $chatBadges, $chatMemberColor, $chatSuperChatColor,
|
||||
$webUri, $mediaPath, $mediaIsLooping, $mediaVolume, $mediaPlaybackState,
|
||||
$alertVideoAssetId, $alertVideoPath, $alertUseDefaultVideo, $alertShowTicker, $alertVideoVolume,
|
||||
$alertDisplayMethod)
|
||||
$alertDisplayMethod, $isBrandLogo)
|
||||
""";
|
||||
cmd.Transaction = tx;
|
||||
var idP = cmd.Parameters.Add("$id", SqliteType.Text);
|
||||
@@ -140,6 +140,7 @@ public partial class LayoutStore : IDisposable
|
||||
var alertShowTickerP = cmd.Parameters.Add("$alertShowTicker", SqliteType.Integer);
|
||||
var alertVideoVolumeP = cmd.Parameters.Add("$alertVideoVolume", SqliteType.Real);
|
||||
var alertDisplayMethodP = cmd.Parameters.Add("$alertDisplayMethod", SqliteType.Integer);
|
||||
var isBrandLogoP = cmd.Parameters.Add("$isBrandLogo", SqliteType.Integer);
|
||||
|
||||
// Source and WebcamSceneConfig share ONE z-order space per scene:
|
||||
// each element gets its index within scene.Elements regardless of
|
||||
@@ -188,6 +189,7 @@ public partial class LayoutStore : IDisposable
|
||||
alertShowTickerP.Value = source.AlertShowTicker ? 1 : 0;
|
||||
alertVideoVolumeP.Value = source.AlertVideoVolume;
|
||||
alertDisplayMethodP.Value = (int)source.AlertDisplayMethod;
|
||||
isBrandLogoP.Value = source.IsBrandLogo ? 1 : 0;
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -348,4 +348,20 @@ public partial class LayoutStore : IDisposable
|
||||
|
||||
/// <summary>The stored on-air image's Asset id, or null when never stamped.</summary>
|
||||
public string? GetOnAirImageId() => GetSetting(OnAirImageKey);
|
||||
|
||||
private const string BrandLogoAssetKey = "BrandLogoAssetId";
|
||||
private const string BrandLogoNameKey = "BrandLogoFileName";
|
||||
|
||||
public string? GetBrandLogoAssetId() => GetSetting(BrandLogoAssetKey);
|
||||
|
||||
public string? GetBrandLogoFileName() => GetSetting(BrandLogoNameKey);
|
||||
|
||||
/// <summary>Stores the brand logo image as an asset and records its file name for display.</summary>
|
||||
public string SetBrandLogo(byte[] data, string fileName)
|
||||
{
|
||||
var id = UpsertAsset(data, 0, 0);
|
||||
UpsertSetting(BrandLogoAssetKey, id);
|
||||
UpsertSetting(BrandLogoNameKey, fileName);
|
||||
return id;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,8 @@ public enum TransitionType
|
||||
{
|
||||
Cut,
|
||||
Fade,
|
||||
Move
|
||||
Move,
|
||||
Wipe
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -45,12 +46,23 @@ public sealed class TransitionService
|
||||
/// </summary>
|
||||
public VideoFrame? FromFrame { get; private set; }
|
||||
|
||||
/// <summary>True when a transition started without its "from" picture; the frame pump
|
||||
/// supplies the outgoing program frame on its next tick via <see cref="SupplyFromFrame"/>.</summary>
|
||||
public bool CaptureRequested { get; private set; }
|
||||
|
||||
public void SupplyFromFrame(VideoFrame frame)
|
||||
{
|
||||
FromFrame = frame;
|
||||
CaptureRequested = false;
|
||||
}
|
||||
|
||||
/// <summary>Begin a transition. Caller should capture FromFrame from the current LiveScene render.</summary>
|
||||
public void Start(TransitionType type, double durationMs, VideoFrame? fromFrame)
|
||||
{
|
||||
CurrentType = type;
|
||||
DurationMs = durationMs > 0 ? durationMs : DefaultDurationMs;
|
||||
FromFrame = fromFrame;
|
||||
CaptureRequested = fromFrame == null && type != TransitionType.Cut;
|
||||
ElapsedMs = 0;
|
||||
Active = true;
|
||||
|
||||
@@ -91,10 +103,33 @@ public sealed class TransitionService
|
||||
{
|
||||
TransitionType.Fade => BlendFade(FromFrame, toFrame, t),
|
||||
TransitionType.Move => BlendMove(FromFrame, toFrame, t),
|
||||
TransitionType.Wipe => BlendWipe(FromFrame, toFrame, t),
|
||||
_ => toFrame
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Left-to-right reveal: the new scene sweeps in across the frame.</summary>
|
||||
private static VideoFrame BlendWipe(VideoFrame from, VideoFrame to, float t)
|
||||
{
|
||||
var w = from.Width;
|
||||
var h = from.Height;
|
||||
var output = new byte[w * h * 4];
|
||||
var edge = (int)Math.Round(Math.Clamp(t, 0f, 1f) * w);
|
||||
for (var y = 0; y < h; y++)
|
||||
{
|
||||
for (var x = 0; x < w; x++)
|
||||
{
|
||||
var src = x < edge ? to.BgraPixels : from.BgraPixels;
|
||||
var i = (y * w + x) * 4;
|
||||
output[i] = src[i];
|
||||
output[i + 1] = src[i + 1];
|
||||
output[i + 2] = src[i + 2];
|
||||
output[i + 3] = src[i + 3];
|
||||
}
|
||||
}
|
||||
return new VideoFrame(w, h, output);
|
||||
}
|
||||
|
||||
private static VideoFrame BlendFade(VideoFrame from, VideoFrame to, float t)
|
||||
{
|
||||
var w = from.Width;
|
||||
|
||||
@@ -321,8 +321,9 @@ second encoder path needing a "redirect". Record+simulcast is one ffmpeg with tw
|
||||
- **⏸ TASK 48 / ALL distribution & packaging work — DEFERRED 2026-10-26 (creator ruling). Do not
|
||||
start, plan, or re-litigate any of it until we are close to v1.** Verbatim reason: *"we're not
|
||||
anywhere close to release just considering the remaining programming tasks"* and *"I am getting
|
||||
ready to do a complete UX redesign"* — packaging, signing, installer and updater decisions would
|
||||
be chasing UI and scope that are about to change, so the urgency is withdrawn, not the task.
|
||||
ready to do a complete UX redesign"* — that redesign began 2026-10-26 (see [`HANDOFF.md`](HANDOFF.md)
|
||||
for landed UI slices and current worktree). Packaging, signing, installer and updater decisions
|
||||
would chase UI and scope that are still changing, so the urgency is withdrawn, not the task.
|
||||
What is frozen:
|
||||
- **TASK 48 items 6-8** (recording invariant comment, disqualifier re-check, pre-submission gates)
|
||||
and the creator's items 4/5 (Certification Kit, Partner Center listing).
|
||||
@@ -451,3 +452,16 @@ These were explicitly ruled out:
|
||||
- **OBS-style source expansion** (game capture, browser source, media playlist, VLC, color-key, MIDI — the minimal source set is deliberate; creators who need more have graduated to OBS)
|
||||
- **Multi-layout** (auto-save, single layout; multi-layout = OBS territory)
|
||||
- **Cross-process stream resume** (API makes it impossible; creator ruling 2026-09-01)
|
||||
|
||||
## Transitions & brand logo (status updated 2026-10-28)
|
||||
|
||||
- ✅ **Fade fix** — swaps fade from the outgoing picture (`TransitionFadeTests`). On-screen check pending.
|
||||
- ✅ **Transition picker** in App Settings (type + duration), saved with the layout (`TransitionPickerTests`).
|
||||
- ✅ **Wipe** — CPU blend, left-to-right reveal (`WipeTransitionTests`). Direction setting not yet added.
|
||||
- ✅ **Transitions in edit mode and on output** — preview animates the outgoing picture for scene changes in every mode; program output transitions while recording or streaming. Fade/Move fade out, Wipe shrinks the outgoing picture, Cut is immediate. Guard: `PreviewTransitionTests`.
|
||||
- ☐ **Move** — persistent layers (per-layer "carry across scenes" flag), per-scene placement, fixed z band.
|
||||
- ✅ **Brand logo** — one locked "Brand logo" image layer per scene, directly above the background; file in
|
||||
App Settings (imported to the asset table, the setting keeps the asset id); per-scene eye toggle; top-left
|
||||
default. Tests: `BrandLogoTests`. On-screen check pending.
|
||||
- ☐ **Cube** — GPU path gated on capability; CPU path only if it holds 60fps.
|
||||
- Gear: right-click opens App Settings (built, on-screen check pending). Left-click menu stays for now.
|
||||
|
||||
@@ -438,6 +438,7 @@ public partial class MainViewModel
|
||||
StampDefaultBackgroundAsset(background, scene.Name);
|
||||
}
|
||||
SeedOnAirImage();
|
||||
EnsureBrandLogos();
|
||||
OnPropertyChanged(nameof(ShowSourcesEmptyHint));
|
||||
OnPropertyChanged(nameof(LayerCount));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
using System.IO;
|
||||
using System.Windows.Input;
|
||||
using ytLive.Helpers;
|
||||
using ytLive.Models;
|
||||
|
||||
namespace ytLive.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// The permanent brand logo: one image layer in every scene, directly above the background.
|
||||
/// Its image comes from App Settings; each scene keeps its own visibility (the eye toggle).
|
||||
/// </summary>
|
||||
public partial class MainViewModel
|
||||
{
|
||||
internal const double BrandLogoX = 32;
|
||||
internal const double BrandLogoY = 32;
|
||||
internal const double BrandLogoWidth = 240;
|
||||
internal const double BrandLogoHeight = 120;
|
||||
|
||||
private ICommand? _browseBrandLogo;
|
||||
|
||||
public ICommand BrowseBrandLogoCommand => _browseBrandLogo ??= new RelayCommand(_ => BrowseBrandLogo());
|
||||
|
||||
public string BrandLogoFileDisplay => _layoutStore.GetBrandLogoFileName() ?? "None";
|
||||
|
||||
private void BrowseBrandLogo()
|
||||
{
|
||||
var dialog = new Microsoft.Win32.OpenFileDialog
|
||||
{
|
||||
Title = "Choose brand logo",
|
||||
Filter = "Images|*.png;*.jpg;*.jpeg;*.bmp;*.gif",
|
||||
};
|
||||
if (dialog.ShowDialog() != true) return;
|
||||
_layoutStore.SetBrandLogo(File.ReadAllBytes(dialog.FileName), Path.GetFileName(dialog.FileName));
|
||||
EnsureBrandLogos();
|
||||
OnPropertyChanged(nameof(BrandLogoFileDisplay));
|
||||
ScheduleSave();
|
||||
}
|
||||
|
||||
internal static Source CreateBrandLogo(string? assetId) => new()
|
||||
{
|
||||
Name = "Brand logo",
|
||||
Type = SourceType.Image,
|
||||
IsBrandLogo = true,
|
||||
IsEnabled = true,
|
||||
AssetId = assetId,
|
||||
X = BrandLogoX,
|
||||
Y = BrandLogoY,
|
||||
Width = BrandLogoWidth,
|
||||
Height = BrandLogoHeight,
|
||||
};
|
||||
|
||||
/// <summary>Exactly one brand logo per scene, placed directly above the background, carrying
|
||||
/// the current App Settings image.</summary>
|
||||
internal void EnsureBrandLogos()
|
||||
{
|
||||
var assetId = _layoutStore.GetBrandLogoAssetId();
|
||||
foreach (var scene in Scenes)
|
||||
{
|
||||
var logos = scene.Elements.OfType<Source>().Where(s => s.IsBrandLogo).ToList();
|
||||
foreach (var extra in logos.Skip(1))
|
||||
scene.Elements.Remove(extra);
|
||||
var logo = logos.FirstOrDefault();
|
||||
if (logo == null)
|
||||
{
|
||||
var background = scene.Elements.OfType<Source>().FirstOrDefault(s => s.IsBackground);
|
||||
var index = background == null ? 0 : scene.Elements.IndexOf(background) + 1;
|
||||
logo = CreateBrandLogo(assetId);
|
||||
scene.Elements.Insert(index, logo);
|
||||
}
|
||||
else
|
||||
{
|
||||
logo.AssetId = assetId;
|
||||
}
|
||||
_sceneGraph.InvalidateBake(scene);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -97,14 +97,14 @@ public partial class MainViewModel
|
||||
public TransitionType SelectedTransitionType
|
||||
{
|
||||
get => _selectedTransitionType;
|
||||
set => SetProperty(ref _selectedTransitionType, value);
|
||||
set { if (SetProperty(ref _selectedTransitionType, value)) ScheduleSave(); }
|
||||
}
|
||||
private TransitionType _selectedTransitionType = TransitionType.Fade;
|
||||
|
||||
public double TransitionDurationMs
|
||||
{
|
||||
get => _transitionDurationMs;
|
||||
set => SetProperty(ref _transitionDurationMs, value);
|
||||
set { if (SetProperty(ref _transitionDurationMs, value)) ScheduleSave(); }
|
||||
}
|
||||
private double _transitionDurationMs = 300;
|
||||
|
||||
@@ -115,14 +115,20 @@ public partial class MainViewModel
|
||||
public void TransitionToScene(Scene scene)
|
||||
{
|
||||
if (scene.IsHidden || scene == StagedScene) return;
|
||||
// The preview animates the outgoing picture before the switch, in every mode.
|
||||
PreviewTransition?.Invoke(SelectedTransitionType, TransitionDurationMs);
|
||||
StagedScene = scene;
|
||||
if (IsLive)
|
||||
// The program output transitions whenever it is running: recording or streaming.
|
||||
if (IsLive || IsRecording)
|
||||
{
|
||||
LiveScene = scene;
|
||||
_transition.Start(SelectedTransitionType, TransitionDurationMs, null);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Raised before a scene switch so the preview can animate the old picture away.</summary>
|
||||
public event Action<TransitionType, double>? PreviewTransition;
|
||||
|
||||
/// <summary>
|
||||
/// Apply the current transition type to the next StagedScene change while live.
|
||||
/// Called from XAML when user picks a transition from the dropdown.
|
||||
|
||||
@@ -237,11 +237,11 @@ public partial class MainViewModel
|
||||
// session when the last using scene lets go). The webcam identity survives —
|
||||
// it's the app-level default, so removing a scene's placement re-enables Add
|
||||
// for the same camera instead of dropping it.
|
||||
private void RemoveElement(SceneElement? element)
|
||||
internal void RemoveElement(SceneElement? element)
|
||||
{
|
||||
var scene = StagedScene;
|
||||
if (scene == null || element == null) return;
|
||||
if (element is Source { IsBackground: true }) return;
|
||||
if (element is Source { IsBackground: true } or Source { IsBrandLogo: true }) return;
|
||||
if (element is WebcamSceneConfig && _webcam != null)
|
||||
{
|
||||
if (SelectedElement == element)
|
||||
|
||||
@@ -69,16 +69,20 @@ public sealed class BorderColorPickerTests
|
||||
|
||||
// ── 2. the panel: the app's drop-down, no hand-rolled menu ───────────────
|
||||
var xaml = XDocument.Load(SourceAssert.RepoPath("Controls", "LeftPanel.xaml"));
|
||||
var picker = xaml.Descendants()
|
||||
var pickers = xaml.Descendants()
|
||||
.Where(e => e.Name.LocalName == "ComboBox")
|
||||
.SingleOrDefault(c => (string?)c.Attribute("ItemsSource")
|
||||
== "{Binding SelectedElement.BorderColorOptions}");
|
||||
Assert.True(picker != null, "the border colour is not a drop-down over the palette");
|
||||
Assert.True(((string?)picker!.Attribute("Style") ?? string.Empty)
|
||||
.Contains("YtComboBox", StringComparison.Ordinal),
|
||||
"the border-colour drop-down is not styled with YtComboBox");
|
||||
Assert.Equal("{Binding SelectedElement.BorderColorEntry, Mode=TwoWay}",
|
||||
(string?)picker.Attribute("SelectedItem"));
|
||||
.Where(c => (string?)c.Attribute("ItemsSource")
|
||||
== "{Binding SelectedElement.BorderColorOptions}")
|
||||
.ToArray();
|
||||
Assert.NotEmpty(pickers);
|
||||
foreach (var picker in pickers)
|
||||
{
|
||||
Assert.True(((string?)picker.Attribute("Style") ?? string.Empty)
|
||||
.Contains("YtComboBox", StringComparison.Ordinal),
|
||||
"a border-colour drop-down is not styled with YtComboBox");
|
||||
Assert.Equal("{Binding SelectedElement.BorderColorEntry, Mode=TwoWay}",
|
||||
(string?)picker.Attribute("SelectedItem"));
|
||||
}
|
||||
|
||||
var hexField = xaml.Descendants()
|
||||
.Where(e => e.Name.LocalName == "TextBox")
|
||||
@@ -146,7 +150,9 @@ public sealed class BorderColorPickerTests
|
||||
Dispatcher.CurrentDispatcher.Invoke(() => { }, DispatcherPriority.Loaded);
|
||||
|
||||
var realCombo = FindVisualChildren<ComboBox>(panel)
|
||||
.Single(c => c.ItemsSource is IEnumerable<NamedColor> opts && opts.Any(o => o.Hex == "#e94560"));
|
||||
.Single(c => c.IsVisible
|
||||
&& c.ItemsSource is IEnumerable<NamedColor> opts
|
||||
&& opts.Any(o => o.Hex == "#e94560"));
|
||||
realCombo.IsDropDownOpen = true;
|
||||
realCombo.UpdateLayout();
|
||||
Dispatcher.CurrentDispatcher.Invoke(() => { }, DispatcherPriority.Loaded);
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using Xunit;
|
||||
using ytLive.Models;
|
||||
using ytLive.Services;
|
||||
using ytLive.ViewModels;
|
||||
|
||||
namespace ytLive.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The brand logo is one locked image layer per scene, directly above the background.
|
||||
/// </summary>
|
||||
[Collection("RealApp")]
|
||||
public sealed class BrandLogoTests
|
||||
{
|
||||
private readonly RealAppHost _app;
|
||||
|
||||
public BrandLogoTests(RealAppHost app) => _app = app;
|
||||
|
||||
[Fact]
|
||||
public void Every_Scene_Gets_One_Brand_Logo_Directly_Above_The_Background()
|
||||
{
|
||||
_app.Run(Run);
|
||||
}
|
||||
|
||||
private static void Run()
|
||||
{
|
||||
var tempDb = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"ytLlive-brand-{Guid.NewGuid():N}.db");
|
||||
MainViewModel.LayoutPathOverride = tempDb;
|
||||
try
|
||||
{
|
||||
using (var schema = new LayoutStore(tempDb)) { }
|
||||
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
|
||||
|
||||
var vm = new MainViewModel();
|
||||
|
||||
foreach (var scene in vm.Scenes)
|
||||
{
|
||||
var logos = scene.Elements.OfType<Source>().Where(s => s.IsBrandLogo).ToList();
|
||||
Assert.Single(logos);
|
||||
var index = scene.Elements.IndexOf(logos[0]);
|
||||
var background = scene.Elements.OfType<Source>().First(s => s.IsBackground);
|
||||
Assert.Equal(scene.Elements.IndexOf(background) + 1, index);
|
||||
}
|
||||
|
||||
var staged = vm.StagedScene!;
|
||||
var logo = staged.Elements.OfType<Source>().First(s => s.IsBrandLogo);
|
||||
vm.RemoveElement(logo);
|
||||
Assert.Contains(logo, staged.Elements);
|
||||
}
|
||||
finally
|
||||
{
|
||||
MainViewModel.LayoutPathOverride = null;
|
||||
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
|
||||
try { System.IO.File.Delete(tempDb); } catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -83,7 +83,7 @@ public sealed class LayerReorderPersistenceTests
|
||||
var vm = (MainViewModel)window.DataContext;
|
||||
vm.StagedScene = vm.Scenes.Single(s => SceneCatalog.Is(s.Name, SceneCatalog.Live));
|
||||
Assert.True(vm.StagedScene!.Elements.OfType<Source>().Count() >= 4,
|
||||
"3 seeded layers + the healed Background must load");
|
||||
"3 seeded layers + the healed Background + Brand logo must load");
|
||||
|
||||
window.Show();
|
||||
window.Activate();
|
||||
@@ -94,12 +94,9 @@ public sealed class LayerReorderPersistenceTests
|
||||
var list = (FocusPreservingListBox)panel.FindName("SourceList")!;
|
||||
list.UpdateLayout();
|
||||
|
||||
// Elements = [Background, Zoom(placeholder, collapsed), ImgA, ImgB,
|
||||
// ImgC]. Physically drag ImgC up onto ImgA: the handlers reorder the
|
||||
// live elements to [Background, Zoom, ImgC, ImgA, ImgB] and EndListDrag
|
||||
// must persist that. Rows are resolved BY ITEM — the collapsed zoom row
|
||||
// shifts container indices, but ItemContainerGenerator maps items to
|
||||
// their (collapsed = zero-height) containers transparently.
|
||||
// Brand logo is a permanent layer between Background and user layers.
|
||||
// Physically drag ImgC above ImgA; the background and logo keep their
|
||||
// relative positions while EndListDrag persists the user-layer reorder.
|
||||
var sources = vm.StagedScene.Elements.OfType<Source>().ToArray();
|
||||
var from = RowScreenPoint(list, sources.First(s => s.Name == "ImgC"));
|
||||
var to = RowScreenPoint(list, sources.First(s => s.Name == "ImgA"));
|
||||
@@ -115,7 +112,9 @@ public sealed class LayerReorderPersistenceTests
|
||||
|
||||
var expected = vm.StagedScene.Elements.OfType<Source>().Select(s => s.Name).ToArray();
|
||||
Assert.Equal("Background", expected[0]);
|
||||
Assert.Equal("ImgC", expected[1]);
|
||||
Assert.Equal("Brand logo", expected[1]);
|
||||
Assert.Equal("ImgC", expected[2]);
|
||||
Assert.True(Array.IndexOf(expected, "ImgC") < Array.IndexOf(expected, "ImgA"));
|
||||
|
||||
Assert.True(PumpUntil(() => SourceNamesInDb(tempDb).SequenceEqual(expected),
|
||||
TimeSpan.FromSeconds(10)),
|
||||
@@ -466,4 +465,4 @@ public sealed class LayerReorderPersistenceTests
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using Xunit;
|
||||
using ytLive.Models;
|
||||
using ytLive.Services;
|
||||
using ytLive.ViewModels;
|
||||
|
||||
namespace ytLive.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// A scene switch in edit mode raises the preview transition with the chosen type and
|
||||
/// length, before the staged scene changes, so the preview can animate the old picture.
|
||||
/// </summary>
|
||||
[Collection("RealApp")]
|
||||
public sealed class PreviewTransitionTests
|
||||
{
|
||||
private readonly RealAppHost _app;
|
||||
|
||||
public PreviewTransitionTests(RealAppHost app) => _app = app;
|
||||
|
||||
[Fact]
|
||||
public void Scene_Switch_In_Edit_Mode_Raises_The_Preview_Transition_Before_The_Switch()
|
||||
{
|
||||
_app.Run(Run);
|
||||
}
|
||||
|
||||
private static void Run()
|
||||
{
|
||||
var tempDb = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"ytLlive-preview-{Guid.NewGuid():N}.db");
|
||||
MainViewModel.LayoutPathOverride = tempDb;
|
||||
try
|
||||
{
|
||||
using (var schema = new LayoutStore(tempDb)) { }
|
||||
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
|
||||
|
||||
var vm = new MainViewModel();
|
||||
var from = vm.StagedScene!;
|
||||
var to = vm.Scenes.First(s => s != from && !s.IsHidden);
|
||||
vm.SelectedTransitionType = TransitionType.Fade;
|
||||
vm.TransitionDurationMs = 800;
|
||||
|
||||
Scene? stagedWhenRaised = null;
|
||||
TransitionType? typeRaised = null;
|
||||
double? msRaised = null;
|
||||
vm.PreviewTransition += (type, ms) =>
|
||||
{
|
||||
stagedWhenRaised = vm.StagedScene;
|
||||
typeRaised = type;
|
||||
msRaised = ms;
|
||||
};
|
||||
|
||||
vm.TransitionToScene(to);
|
||||
|
||||
Assert.Same(from, stagedWhenRaised);
|
||||
Assert.Equal(TransitionType.Fade, typeRaised);
|
||||
Assert.Equal(800, msRaised);
|
||||
Assert.Same(to, vm.StagedScene);
|
||||
}
|
||||
finally
|
||||
{
|
||||
MainViewModel.LayoutPathOverride = null;
|
||||
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
|
||||
try { System.IO.File.Delete(tempDb); } catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,9 +15,8 @@ using ytLive.ViewModels;
|
||||
namespace ytLive.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Scene strip tile artwork (creator ruling 2026-10-10). Each canonical scene's
|
||||
/// top-strip tile is its own image, stored as an Asset row and seeded from the
|
||||
/// creator's CRT-styled renders. The preview pane keeps rendering each scene's
|
||||
/// Scene strip tile artwork (creator ruling 2026-10-10). A persisted scene tile
|
||||
/// is stored as an Asset row; the preview pane keeps rendering each scene's
|
||||
/// ORIGINAL background — the tiles never appear as preview backgrounds.
|
||||
/// </summary>
|
||||
[Collection("RealApp")]
|
||||
@@ -28,7 +27,7 @@ public sealed class SceneThumbnailTests
|
||||
public SceneThumbnailTests(RealAppHost app) => _app = app;
|
||||
|
||||
[Fact]
|
||||
public void Strip_Shows_The_Seeded_Tile_And_The_Preview_Keeps_The_Original_Background()
|
||||
public void Strip_Shows_The_Persisted_Tile_And_The_Preview_Keeps_The_Original_Background()
|
||||
{
|
||||
_app.Run(() =>
|
||||
{
|
||||
@@ -36,10 +35,19 @@ public sealed class SceneThumbnailTests
|
||||
try
|
||||
{
|
||||
env = ArrangedDb(out var file);
|
||||
env.SeedPaths[SceneCatalog.Starting] = file.TilePath;
|
||||
env.SeedPaths[SceneCatalog.Live] = MissingPath();
|
||||
env.Apply();
|
||||
|
||||
var tileAssetId = LayoutStore.Instance!.UpsertAsset(File.ReadAllBytes(file.TilePath), 320, 180);
|
||||
using (var connection = new SqliteConnection($"Data Source={env.DbPath}"))
|
||||
{
|
||||
connection.Open();
|
||||
using var cmd = connection.CreateCommand();
|
||||
cmd.CommandText = "UPDATE Scene SET ThumbnailAssetId = $asset WHERE Name = $scene;";
|
||||
cmd.Parameters.AddWithValue("$asset", tileAssetId);
|
||||
cmd.Parameters.AddWithValue("$scene", SceneCatalog.Starting);
|
||||
Assert.Equal(1, cmd.ExecuteNonQuery());
|
||||
}
|
||||
|
||||
var vm = new MainViewModel();
|
||||
var starting = SceneByName(vm, SceneCatalog.Starting);
|
||||
|
||||
@@ -91,7 +99,7 @@ public sealed class SceneThumbnailTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Live_Staged_Mini_Shows_Its_Tile_Not_The_Capture_Placeholder()
|
||||
public void Live_Staged_Mini_Shows_Its_Persisted_Tile_Not_The_Capture_Placeholder()
|
||||
{
|
||||
_app.Run(() =>
|
||||
{
|
||||
@@ -99,9 +107,19 @@ public sealed class SceneThumbnailTests
|
||||
try
|
||||
{
|
||||
env = ArrangedDb(out var file);
|
||||
env.SeedPaths[SceneCatalog.Live] = file.TilePath;
|
||||
env.Apply();
|
||||
|
||||
var tileAssetId = LayoutStore.Instance!.UpsertAsset(File.ReadAllBytes(file.TilePath), 320, 180);
|
||||
using (var connection = new SqliteConnection($"Data Source={env.DbPath}"))
|
||||
{
|
||||
connection.Open();
|
||||
using var cmd = connection.CreateCommand();
|
||||
cmd.CommandText = "UPDATE Scene SET ThumbnailAssetId = $asset WHERE Name = $scene;";
|
||||
cmd.Parameters.AddWithValue("$asset", tileAssetId);
|
||||
cmd.Parameters.AddWithValue("$scene", SceneCatalog.Live);
|
||||
Assert.Equal(1, cmd.ExecuteNonQuery());
|
||||
}
|
||||
|
||||
var vm = new MainViewModel();
|
||||
var live = SceneByName(vm, SceneCatalog.Live);
|
||||
Assert.False(string.IsNullOrWhiteSpace(live.ThumbnailAssetId));
|
||||
@@ -355,4 +373,4 @@ public sealed class SceneThumbnailTests
|
||||
|
||||
private static string MissingPath()
|
||||
=> Path.Combine(Path.GetTempPath(), $"ytLive-no-such-{Guid.NewGuid():N}.png");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Media;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Xunit;
|
||||
using ytLive.Controls;
|
||||
using ytLive.Models;
|
||||
using ytLive.Services;
|
||||
using ytLive.ViewModels;
|
||||
|
||||
namespace ytLive.Tests;
|
||||
|
||||
[Collection("RealApp")]
|
||||
public sealed class SelectedLayerPreviewHitTestingTests
|
||||
{
|
||||
private readonly RealAppHost _app;
|
||||
|
||||
public SelectedLayerPreviewHitTestingTests(RealAppHost app) => _app = app;
|
||||
|
||||
[Fact]
|
||||
public void ListSelectedBrandLogo_WinsPreviewHitTesting_UntilDeselected()
|
||||
{
|
||||
_app.Run(Run);
|
||||
}
|
||||
|
||||
private static void Run()
|
||||
{
|
||||
var tempDb = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"ytLlive-selected-layer-{Guid.NewGuid():N}.db");
|
||||
MainViewModel.LayoutPathOverride = tempDb;
|
||||
MainWindow? window = null;
|
||||
try
|
||||
{
|
||||
using (var schema = new LayoutStore(tempDb)) { }
|
||||
SqliteConnection.ClearAllPools();
|
||||
|
||||
window = new MainWindow();
|
||||
var vm = (MainViewModel)window.DataContext;
|
||||
var scene = vm.StagedScene!;
|
||||
var logo = scene.Elements.OfType<Source>().Single(source => source.IsBrandLogo);
|
||||
logo.X = 420;
|
||||
logo.Y = 300;
|
||||
logo.Width = 300;
|
||||
logo.Height = 180;
|
||||
|
||||
var webcam = new WebcamSceneConfig
|
||||
{
|
||||
WebcamId = "test-camera",
|
||||
Name = "Webcam",
|
||||
X = logo.X,
|
||||
Y = logo.Y,
|
||||
Width = logo.Width,
|
||||
Height = logo.Height,
|
||||
};
|
||||
scene.Elements.Add(webcam);
|
||||
var originalOrder = scene.Elements.ToArray();
|
||||
|
||||
window.Show();
|
||||
window.Activate();
|
||||
window.Topmost = true;
|
||||
window.UpdateLayout();
|
||||
|
||||
var leftPanel = (LeftPanel)window.FindName("LeftPanel")!;
|
||||
var sourceList = (ListBox)leftPanel.FindName("SourceList")!;
|
||||
var preview = (PreviewPane)window.FindName("PreviewPane")!;
|
||||
var canvasGrid = (FrameworkElement)preview.FindName("CanvasGrid")!;
|
||||
var overlayElements = (ItemsControl)preview.FindName("OverlayElements")!;
|
||||
var logoContainer = (UIElement)overlayElements.ItemContainerGenerator.ContainerFromItem(logo)!;
|
||||
var webcamContainer = (UIElement)overlayElements.ItemContainerGenerator.ContainerFromItem(webcam)!;
|
||||
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));
|
||||
|
||||
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));
|
||||
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));
|
||||
Assert.Equal(originalOrder, scene.Elements);
|
||||
}
|
||||
finally
|
||||
{
|
||||
window?.Close();
|
||||
MainViewModel.LayoutPathOverride = null;
|
||||
SqliteConnection.ClearAllPools();
|
||||
try { System.IO.File.Delete(tempDb); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
private static SceneElement? ElementAt(System.Windows.IInputElement? hit)
|
||||
{
|
||||
DependencyObject? current = hit as DependencyObject;
|
||||
while (current != null)
|
||||
{
|
||||
if (current is FrameworkElement { DataContext: SceneElement element })
|
||||
return element;
|
||||
current = current is Visual or System.Windows.Media.Media3D.Visual3D
|
||||
? VisualTreeHelper.GetParent(current)
|
||||
: LogicalTreeHelper.GetParent(current);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -44,8 +44,8 @@ public sealed class SourceNamingTests
|
||||
vm.AddSourceCommand.Execute(SourceType.TextOverlay);
|
||||
vm.AddSourceCommand.Execute(SourceType.TextOverlay);
|
||||
|
||||
// The locked Background layer is always present but never numbered.
|
||||
var names = scene.Elements.OfType<Source>().Where(s => !s.IsBackground)
|
||||
// Persistent scene layers are not part of the user-created Text sequence.
|
||||
var names = scene.Elements.OfType<Source>().Where(s => s.Type == SourceType.TextOverlay)
|
||||
.Select(s => s.Name).ToList();
|
||||
Assert.Equal(new[] { "Text", "Text2", "Text3" }, names);
|
||||
|
||||
@@ -53,7 +53,7 @@ public sealed class SourceNamingTests
|
||||
scene.Elements.Remove(middle);
|
||||
vm.AddSourceCommand.Execute(SourceType.TextOverlay);
|
||||
|
||||
var survivors = scene.Elements.OfType<Source>().Where(s => !s.IsBackground)
|
||||
var survivors = scene.Elements.OfType<Source>().Where(s => s.Type == SourceType.TextOverlay)
|
||||
.Select(s => s.Name).ToList();
|
||||
Assert.Equal(new[] { "Text", "Text3", "Text2" }, survivors);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
using ytLive.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace ytLive.Tests;
|
||||
|
||||
public class TransitionFadeTests
|
||||
{
|
||||
[Fact]
|
||||
public void Fade_Without_A_From_Frame_Requests_Capture_And_Blends_Once_Supplied()
|
||||
{
|
||||
var transition = new TransitionService();
|
||||
var to = new VideoFrame(2, 2, Enumerable.Repeat((byte)255, 16).ToArray());
|
||||
var from = new VideoFrame(2, 2, new byte[16]);
|
||||
|
||||
transition.Start(TransitionType.Fade, 300, null);
|
||||
Assert.True(transition.CaptureRequested);
|
||||
|
||||
transition.SupplyFromFrame(from);
|
||||
Assert.False(transition.CaptureRequested);
|
||||
|
||||
transition.Tick(150);
|
||||
var blended = transition.BlendFrame(to);
|
||||
Assert.NotSame(to, blended);
|
||||
Assert.NotEqual((byte)255, blended.BgraPixels[0]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using System;
|
||||
using System.ComponentModel;
|
||||
using Xunit;
|
||||
using ytLive.Services;
|
||||
using ytLive.ViewModels;
|
||||
|
||||
namespace ytLive.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The App Settings transition picker drives the view model: type and length changes
|
||||
/// notify the bound controls.
|
||||
/// </summary>
|
||||
[Collection("RealApp")]
|
||||
public sealed class TransitionPickerTests
|
||||
{
|
||||
private readonly RealAppHost _app;
|
||||
|
||||
public TransitionPickerTests(RealAppHost app) => _app = app;
|
||||
|
||||
[Fact]
|
||||
public void Transition_Type_And_Length_Notify_The_Settings_Picker()
|
||||
{
|
||||
_app.Run(Run);
|
||||
}
|
||||
|
||||
private static void Run()
|
||||
{
|
||||
var tempDb = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"ytLlive-transition-{Guid.NewGuid():N}.db");
|
||||
MainViewModel.LayoutPathOverride = tempDb;
|
||||
try
|
||||
{
|
||||
using (var schema = new LayoutStore(tempDb)) { }
|
||||
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
|
||||
|
||||
var vm = new MainViewModel();
|
||||
var raised = new System.Collections.Generic.List<string>();
|
||||
((INotifyPropertyChanged)vm).PropertyChanged += (_, e) => raised.Add(e.PropertyName ?? "");
|
||||
|
||||
vm.SelectedTransitionType = TransitionType.Move;
|
||||
vm.TransitionDurationMs = 750;
|
||||
|
||||
Assert.Equal(TransitionType.Move, vm.SelectedTransitionType);
|
||||
Assert.Equal(750, vm.TransitionDurationMs);
|
||||
Assert.Contains(nameof(MainViewModel.SelectedTransitionType), raised);
|
||||
Assert.Contains(nameof(MainViewModel.TransitionDurationMs), raised);
|
||||
}
|
||||
finally
|
||||
{
|
||||
MainViewModel.LayoutPathOverride = null;
|
||||
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
|
||||
try { System.IO.File.Delete(tempDb); } catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using System.Linq;
|
||||
using Xunit;
|
||||
using ytLive.Services;
|
||||
|
||||
namespace ytLive.Tests;
|
||||
|
||||
public class WipeTransitionTests
|
||||
{
|
||||
[Fact]
|
||||
public void Wipe_At_Half_Reveals_The_New_Scene_On_The_Left_Half()
|
||||
{
|
||||
var transition = new TransitionService();
|
||||
var to = new VideoFrame(4, 1, Enumerable.Repeat((byte)255, 16).ToArray());
|
||||
var from = new VideoFrame(4, 1, new byte[16]);
|
||||
|
||||
transition.Start(TransitionType.Wipe, 300, from);
|
||||
transition.Tick(150);
|
||||
var blended = transition.BlendFrame(to);
|
||||
|
||||
Assert.Equal((byte)255, blended.BgraPixels[0]);
|
||||
Assert.Equal((byte)255, blended.BgraPixels[4]);
|
||||
Assert.Equal((byte)0, blended.BgraPixels[8]);
|
||||
Assert.Equal((byte)0, blended.BgraPixels[12]);
|
||||
}
|
||||
}
|
||||
@@ -49,8 +49,9 @@ public sealed class ZoomBoxInteractionTests
|
||||
window.Show();
|
||||
window.UpdateLayout();
|
||||
Assert.NotNull(vm.StagedScene);
|
||||
// The healed zoom placeholder sits at z-index 1, inert.
|
||||
var placeholder = (ZoomSceneElement)vm.StagedScene.Elements[1];
|
||||
// The healed zoom placeholder is present and inert; persistent scene
|
||||
// layers may precede it, so locate it by type rather than list index.
|
||||
var placeholder = vm.StagedScene.Elements.OfType<ZoomSceneElement>().Single();
|
||||
Assert.False(placeholder.IsZoomActive);
|
||||
Assert.Equal(ElementKind.Static, placeholder.Kind);
|
||||
|
||||
@@ -137,7 +138,7 @@ public sealed class ZoomBoxInteractionTests
|
||||
Assert.True(Math.Abs(vm.ZoomState.Y - boxY) >= 100, "the box must have moved");
|
||||
Assert.Equal(captureX, vm.ZoomState.CaptureX);
|
||||
Assert.Equal(captureY, vm.ZoomState.CaptureY);
|
||||
var zoom = Assert.IsType<ZoomSceneElement>(vm.StagedScene.Elements[1]);
|
||||
var zoom = vm.StagedScene.Elements.OfType<ZoomSceneElement>().Single();
|
||||
Assert.Equal(vm.ZoomState.X, zoom.X, 3);
|
||||
Assert.Equal(vm.ZoomState.Y, zoom.Y, 3);
|
||||
Assert.Equal(captureX, zoom.CaptureX, 3);
|
||||
@@ -179,7 +180,7 @@ public sealed class ZoomBoxInteractionTests
|
||||
|
||||
private static void AssertTrueActiveLayer(MainViewModel vm)
|
||||
{
|
||||
var zoom = Assert.IsType<ZoomSceneElement>(vm.StagedScene!.Elements[1]);
|
||||
var zoom = vm.StagedScene!.Elements.OfType<ZoomSceneElement>().Single();
|
||||
Assert.True(zoom.IsZoomActive);
|
||||
Assert.True(zoom.IsVisible);
|
||||
Assert.Equal(ElementKind.Dynamic, zoom.Kind);
|
||||
@@ -191,4 +192,4 @@ public sealed class ZoomBoxInteractionTests
|
||||
Assert.Equal(vm.ZoomState.CaptureY, zoom.CaptureY, 3);
|
||||
Assert.Equal(vm.ZoomShape == ZoomShape.Circle ? ClipShape.Round : ClipShape.Traditional, zoom.ClipShape);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user