4e0915a36e
Click-to-pin an open window as the full-bleed Live backdrop, via the existing
window:<hwnd> capture path (same render layer as game/desktop). Session-scoped:
window:/picker: keys heal to auto on reload (HWND-recycle hazard); pin wins
while the window enumerates, then auto-fallbacks (game → desktop → static); a
dead window's capture session heals to auto, no dead ends.
Fix: ScreenCaptureSourceFactory.Resolve hex-parsed "window:0x<Hwnd>" WITHOUT
stripping the 0x prefix — NumberStyles.HexNumber rejects it, so every pin
resolved null ("No capture target for this key") and the heal silently rolled
back. Same flaw in IsAliveWindowPin (pin-wins guard was dead). Both fixed.
Commit also carries an out-of-scope prerequisite: PillRadioTests.cs:105 had a
committed stray token ("...PrimaryStartButtonLabel soil;", CS1003) blocking the
whole test-project compile — token removed.
Plus the previous session's uncommitted pricing docs (one-time $29/$49) that
share TASKS.md/ai.md/HANDOFF.md.
323 lines
11 KiB
C#
323 lines
11 KiB
C#
using System.Collections;
|
|
using System.Windows;
|
|
using System.Windows.Controls;
|
|
using System.Windows.Input;
|
|
using System.Windows.Media;
|
|
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))
|
|
{
|
|
child = child is Visual or System.Windows.Media.Media3D.Visual3D
|
|
? VisualTreeHelper.GetParent(child)
|
|
: LogicalTreeHelper.GetParent(child);
|
|
}
|
|
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 ShapeButton_Click(object sender, RoutedEventArgs e)
|
|
{
|
|
if ((DataContext as MainViewModel)?.SelectedElement is WebcamSceneConfig webcam)
|
|
webcam.ToggleClipShape();
|
|
}
|
|
|
|
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 (Math.Abs(position.X - _dragStartPoint.X) < SystemParameters.MinimumHorizontalDragDistance &&
|
|
Math.Abs(position.Y - _dragStartPoint.Y) < 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);
|
|
if (targetIndex < 0) return;
|
|
|
|
var dragged = items[_dragIndex];
|
|
items.RemoveAt(_dragIndex);
|
|
items.Insert(targetIndex, dragged);
|
|
_dragIndex = targetIndex;
|
|
_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 WebcamBorderColorPicker_Click(object sender, RoutedEventArgs e)
|
|
{
|
|
if ((DataContext as MainViewModel)?.SelectedElement is not SceneElement src) return;
|
|
if (sender is FrameworkElement fe && fe.ContextMenu == null)
|
|
{
|
|
var menu = new System.Windows.Controls.ContextMenu();
|
|
string[] colors = ["#FFFFFF", "#000000", "#FF0000", "#00FF00", "#0000FF",
|
|
"#FFFF00", "#FF00FF", "#00FFFF", "#FF8800", "#8800FF",
|
|
"#e94560", "#22c55e", "#3b82f6", "#f59e0b"];
|
|
foreach (var c in colors)
|
|
{
|
|
var item = new System.Windows.Controls.MenuItem { Header = c, ToolTip = c };
|
|
var brush = new System.Windows.Media.SolidColorBrush(
|
|
(System.Windows.Media.Color)System.Windows.Media.ColorConverter.ConvertFromString(c));
|
|
item.Icon = new System.Windows.Controls.Border
|
|
{
|
|
Width = 16, Height = 16,
|
|
Background = brush,
|
|
CornerRadius = new System.Windows.CornerRadius(2)
|
|
};
|
|
var color = c;
|
|
item.Click += (_, _) => src.BorderColor = color;
|
|
menu.Items.Add(item);
|
|
}
|
|
fe.ContextMenu = menu;
|
|
}
|
|
if (sender is FrameworkElement fe2)
|
|
fe2.ContextMenu.IsOpen = true;
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|