Files
LlamaCasty/Controls/LeftPanel.xaml.cs
T
gramps 0f4a035376 fix(preview): draw video layers, unlink Play from Loop, geometry last
Three reports from the creator, plus two bugs the real clip exposed.

1. A video layer was INVISIBLE in the preview. The pane's per-layer Image is Collapsed by
   default and switched on by a whitelist of DataTriggers; MediaSource matched none, so the clip
   decoded into a bitmap that nothing displayed. Same defect the alert box hit on 2026-09-26 and
   the scoreboard after it — now recorded at the top of docs/ai/ui-surface.md with a test that
   asserts the bound Image is Visible (`MediaPreviewDisplayTests`).

2. Play is no longer tied to Loop: the button is always enabled (creator ruling — Loop must not
   toggle Play). Loop still restarts a finished clip, which is the behaviour that was broken.

3. Geometry is now the LAST section of the properties list for every draggable element, with
   opacity at the top; the web source's duplicate geometry block is gone (it now uses the shared
   one, which had been sitting above the media panel).

4. The physical mouse-drag test is deleted. It injected real cursor movement, hijacked whatever
   machine ran it, and failed whenever the desktop was busy. The arithmetic it covered now lives
   in `Helpers/DragReorderMath.cs` — pure, and covered by `DragReorderMathTests` (threshold,
   up/down moves, no-op and out-of-range). The two persistence tests were already synthetic.

5. Real-clip check (creator's `test_greenscreen_moving_subject.mp4`, 1920×1080): the detector
   REJECTED it — a fully saturated green sits at value 1.0 and the detector's 0.98 brightness
   ceiling threw it away. White highlights are excluded by the saturation floor, which is the
   property that actually separates them, so the ceiling is gone. With it fixed, the clip is
   detected as green (high confidence), and keyed through our own pipe 97.8% of pixels go
   transparent. Measured: keying with the canonical #00B140 keys 0.0% of this clip — which is why
   the key colour comes from the measured screen.

Tests: 567 passed, 0 failed, clean build 0 warnings. The gate's scope check flags
`Assets/Videos/test_greenscreen_moving_subject.mp4` — the creator's working copy, deliberately not
committed; the fixture lives under ytLive.Tests/Fixtures.
2026-11-05 09:38:18 -08:00

290 lines
9.3 KiB
C#

using System.Collections;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
using ytLive.Helpers;
using ytLive.Models;
using ytLive.ViewModels;
namespace ytLive.Controls;
public partial class LeftPanel : UserControl
{
private Models.SceneElement? _snapshotElement;
private string _snapshotBorderColor = string.Empty;
private int _snapshotBorderWidth;
private int _snapshotTimerMinutes = 15;
private string? _snapshotWebUri;
private Point _dragStartPoint;
private int _dragIndex = -1;
private bool _isDragging;
private object? _lastHoveredItem;
private bool _dragReordered;
public LeftPanel()
{
InitializeComponent();
Loaded += (_, _) =>
{
if (DataContext is MainViewModel vm)
vm.ChatMessages.CollectionChanged += OnChatMessagesChanged;
};
Unloaded += (_, _) =>
{
if (DataContext is MainViewModel vm)
vm.ChatMessages.CollectionChanged -= OnChatMessagesChanged;
};
}
// Called by the window's UpdateSelectionOverlay (edit mode only).
public void OnSelectionChanged(MainViewModel vm)
{
if (vm.SelectedElement is { } s && PreviewPane.IsDraggableElement(s))
OpacityValueText.Text = $"{Math.Round(s.Opacity * 100)}%";
TakeSnapshot(vm);
}
// Deselection guard: true when the click is inside the source list or the
// element property scroller (window's Window_PreviewMouseLeftButtonDown).
public bool IsClickInside(DependencyObject original)
=> IsDescendantOf(original, SourceList) || IsDescendantOf(original, ElementsScrollViewer);
private static bool IsDescendantOf(DependencyObject? child, DependencyObject ancestor)
{
while (child != null && !ReferenceEquals(child, ancestor))
{
var next = child is Visual or System.Windows.Media.Media3D.Visual3D
? VisualTreeHelper.GetParent(child)
: null;
child = next
?? LogicalTreeHelper.GetParent(child)
?? (child as FrameworkElement)?.TemplatedParent;
}
return child != null;
}
private void SourceNameBox_IsVisibleChanged(object sender, DependencyPropertyChangedEventArgs e)
{
if (sender is TextBox { IsVisible: true } box)
{
box.Focus();
box.SelectAll();
}
}
private void AddSourceButton_Click(object sender, RoutedEventArgs e)
{
if (sender is Button { ContextMenu: { } menu } button)
{
menu.PlacementTarget = button;
menu.Placement = System.Windows.Controls.Primitives.PlacementMode.Bottom;
menu.IsOpen = true;
}
}
private void OnChatMessagesChanged(object? sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
{
if (e.Action == System.Collections.Specialized.NotifyCollectionChangedAction.Add)
{
ChatListBox.ScrollIntoView(ChatListBox.Items[^1]);
}
}
private void MirrorButton_Click(object sender, RoutedEventArgs e)
{
if ((DataContext as MainViewModel)?.SelectedElement is WebcamSceneConfig webcam)
webcam.IsMirrored = !webcam.IsMirrored;
}
private void SourceMenu_Remove(object sender, RoutedEventArgs e)
{
if (sender is MenuItem { DataContext: SceneElement element } && DataContext is MainViewModel vm)
vm.RemoveSourceCommand.Execute(element);
}
private void BackgroundMenu_ChangeCapture(object sender, RoutedEventArgs e)
{
_ = (DataContext as MainViewModel)?.ChangeBackgroundCaptureAsync();
}
private void BackgroundMenu_RefreshCapture(object sender, RoutedEventArgs e)
{
(DataContext as MainViewModel)?.RefreshBackgroundAutoCapture();
}
private void BackgroundMenu_RefreshWindowList(object sender, RoutedEventArgs e)
{
(DataContext as MainViewModel)?.RefreshWindowsCommand.Execute(null);
}
private void OpacitySlider_ValueChanged(object sender, RoutedPropertyChangedEventArgs<double> e)
=> OpacityValueText.Text = $"{Math.Round(e.NewValue * 100)}%";
private void SourceNameBox_KeyDown(object sender, KeyEventArgs e)
{
if (e.Key is Key.Enter or Key.Escape)
{
CommitSourceEdit(sender);
e.Handled = true;
}
}
private void SourceNameBox_LostFocus(object sender, RoutedEventArgs e)
{
CommitSourceEdit(sender);
}
private static void CommitSourceEdit(object sender)
{
if (sender is FrameworkElement { DataContext: SceneElement element })
element.IsEditing = false;
}
// ─── List drag-to-reorder (scenes list + sources list) ───
private void List_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
var listBox = (ListBox)sender;
var item = FindItemContainerAt(listBox, e.GetPosition(listBox));
if (item == null)
{
if (ReferenceEquals(listBox, SourceList))
(DataContext as MainViewModel)!.SelectedElement = null;
_dragIndex = -1;
return;
}
if (listBox.ItemsSource is not IList items || items.Count == 0)
{
_dragIndex = -1;
return;
}
_dragIndex = listBox.Items.IndexOf(item.DataContext);
_dragStartPoint = e.GetPosition(listBox);
_isDragging = false;
_lastHoveredItem = null;
_dragReordered = false;
}
private void List_PreviewMouseMove(object sender, MouseEventArgs e)
{
if (_dragIndex < 0) return;
var listBox = (ListBox)sender;
if (e.LeftButton != MouseButtonState.Pressed || listBox.ItemsSource is not IList items)
{
EndListDrag(listBox);
return;
}
var position = e.GetPosition(listBox);
if (!_isDragging)
{
if (!DragReorderMath.ExceededThreshold(
position.X - _dragStartPoint.X, position.Y - _dragStartPoint.Y,
SystemParameters.MinimumHorizontalDragDistance, SystemParameters.MinimumVerticalDragDistance))
return;
_isDragging = true;
listBox.CaptureMouse();
}
var item = FindItemContainerAt(listBox, position);
if (item == null) return;
var targetItem = item.DataContext;
if (ReferenceEquals(targetItem, items[_dragIndex]) || ReferenceEquals(targetItem, _lastHoveredItem))
return;
var targetIndex = listBox.Items.IndexOf(targetItem);
var landed = DragReorderMath.TryMove(items, _dragIndex, targetIndex);
if (landed < 0) return;
_dragIndex = landed;
_lastHoveredItem = targetItem;
_dragReordered = true;
}
private void List_PreviewMouseLeftButtonUp(object sender, MouseButtonEventArgs e)
{
EndListDrag((ListBox)sender);
}
private void EndListDrag(ListBox listBox)
{
if (_isDragging)
listBox.ReleaseMouseCapture();
var reordered = _dragReordered;
_dragIndex = -1;
_isDragging = false;
_lastHoveredItem = null;
_dragReordered = false;
if (reordered && DataContext is MainViewModel vm)
vm.OnSceneElementsReordered();
}
private static ListBoxItem? FindItemContainerAt(ListBox listBox, Point position)
{
var hit = listBox.InputHitTest(position) as DependencyObject;
while (hit != null && hit != listBox)
{
if (hit is ListBoxItem item) return item;
hit = VisualTreeHelper.GetParent(hit);
}
return null;
}
private void TakeSnapshot(MainViewModel vm)
{
if (vm.SelectedElement is Models.Source src)
{
_snapshotElement = src;
_snapshotBorderColor = src.BorderColor;
_snapshotBorderWidth = src.BorderWidth;
_snapshotTimerMinutes = src.TimerMinutes;
_snapshotWebUri = src.WebUri;
}
}
private void WebcamBorderRevert_Click(object sender, RoutedEventArgs e)
{
if (_snapshotElement is Models.Source src)
{
src.BorderColor = _snapshotBorderColor;
src.BorderWidth = _snapshotBorderWidth;
}
}
private void WebcamBorderAccept_Click(object sender, RoutedEventArgs e)
{
if (_snapshotElement is Models.Source src)
{
_snapshotBorderColor = src.BorderColor;
_snapshotBorderWidth = src.BorderWidth;
}
}
private void CountdownRevert_Click(object sender, RoutedEventArgs e)
{
if (_snapshotElement is Models.Source src)
src.TimerMinutes = _snapshotTimerMinutes;
}
private void CountdownAccept_Click(object sender, RoutedEventArgs e)
{
if (_snapshotElement is Models.Source src)
_snapshotTimerMinutes = src.TimerMinutes;
}
private void WebRevert_Click(object sender, RoutedEventArgs e)
{
if (_snapshotElement is Models.Source src)
src.WebUri = string.Empty;
_snapshotWebUri = string.Empty;
}
private void WebAccept_Click(object sender, RoutedEventArgs e)
{
if (_snapshotElement is Models.Source src)
_snapshotWebUri = src.WebUri;
}
}