diff --git a/Controls/PreviewPane.xaml b/Controls/PreviewPane.xaml
new file mode 100644
index 0000000..983fd5a
--- /dev/null
+++ b/Controls/PreviewPane.xaml
@@ -0,0 +1,664 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Controls/PreviewPane.xaml.cs b/Controls/PreviewPane.xaml.cs
new file mode 100644
index 0000000..b620c54
--- /dev/null
+++ b/Controls/PreviewPane.xaml.cs
@@ -0,0 +1,464 @@
+using System;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Input;
+using System.Windows.Media;
+using System.Windows.Media.Effects;
+using System.Windows.Media.Animation;
+using System.Windows.Shapes;
+using System.Windows.Threading;
+using ytLive.Helpers;
+using ytLive.Models;
+using ytLive.ViewModels;
+
+namespace ytLive.Controls;
+
+public partial class PreviewPane : UserControl
+{
+ private bool _isDraggingOverlay;
+ private bool _isResizing;
+ private Point _grabOffset;
+ private double _resizeAspect;
+
+ private readonly System.Collections.Generic.Dictionary _activeBorderAnimations =
+ new();
+
+ private DispatcherTimer? _socialGlowTimer;
+ private double _socialGlowAngle;
+
+ public PreviewPane()
+ {
+ InitializeComponent();
+ }
+
+ // Static images and the webcam share the move/resize/selection behavior.
+ public static bool IsDraggableElement(SceneElement element)
+ => element.IsDraggable;
+
+ public void UpdateSelectionOverlay(bool selected)
+ {
+ SelectionOverlay.Visibility = selected ? Visibility.Visible : Visibility.Collapsed;
+ }
+
+ // βββ Clicking inside the preview / pull-out drawer (used by the window's
+ // Window_PreviewMouseLeftButtonDown deselection logic) βββ
+ public bool IsClickInsidePreview(DependencyObject original)
+ => IsDescendantOf(original, PreviewGrid);
+
+ public bool IsClickInsideDrawer(DependencyObject original)
+ => IsDescendantOf(original, TextPullOutHost);
+
+ 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;
+ }
+
+ // βββ Audio meters + TRAX + socials (ROW 2 of the preview pane) βββ
+ private void VolumeSlider_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
+ {
+ if (sender is Slider slider)
+ SetSliderValueFromClick(slider, e);
+ if (sender is FrameworkElement { DataContext: MainViewModel vm })
+ vm.SetVolumeAdjusting(true);
+ }
+
+ private void VolumeSlider_PreviewMouseLeftButtonUp(object sender, MouseButtonEventArgs e)
+ {
+ if (sender is FrameworkElement { DataContext: MainViewModel vm })
+ vm.SetVolumeAdjusting(false);
+ }
+
+ private void VolumeSlider_LostMouseCapture(object sender, MouseEventArgs e)
+ {
+ if (sender is FrameworkElement { DataContext: MainViewModel vm })
+ vm.SetVolumeAdjusting(false);
+ }
+
+ private void GameVolumeSlider_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
+ {
+ if (sender is Slider slider)
+ SetSliderValueFromClick(slider, e);
+ if (sender is FrameworkElement { DataContext: MainViewModel vm })
+ vm.SetGameVolumeAdjusting(true);
+ }
+
+ private void GameVolumeSlider_PreviewMouseLeftButtonUp(object sender, MouseButtonEventArgs e)
+ {
+ if (sender is FrameworkElement { DataContext: MainViewModel vm })
+ vm.SetGameVolumeAdjusting(false);
+ }
+
+ private void GameVolumeSlider_LostMouseCapture(object sender, MouseEventArgs e)
+ {
+ if (sender is FrameworkElement { DataContext: MainViewModel vm })
+ vm.SetGameVolumeAdjusting(false);
+ }
+
+ private static void SetSliderValueFromClick(Slider slider, MouseButtonEventArgs e)
+ {
+ if (FindParent(e.OriginalSource as Visual) != null)
+ return;
+ var track = slider.Template.FindName("PART_Track", slider) as System.Windows.Controls.Primitives.Track;
+ if (track == null) return;
+ var pos = e.GetPosition(track);
+ var fraction = Math.Clamp(pos.X / track.RenderSize.Width, 0, 1);
+ slider.Value = slider.Minimum + fraction * (slider.Maximum - slider.Minimum);
+ e.Handled = true;
+ }
+
+ private static T? FindParent(Visual? visual) where T : Visual
+ {
+ while (visual != null)
+ {
+ if (visual is T match) return match;
+ visual = VisualTreeHelper.GetParent(visual) as Visual;
+ }
+ return null;
+ }
+
+ private void MicSpeaker_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
+ {
+ if (sender is FrameworkElement { DataContext: MainViewModel vm })
+ {
+ vm.ToggleMicMuteCommand.Execute(null);
+ }
+ }
+
+ private void GameSpeaker_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
+ {
+ if (sender is FrameworkElement { DataContext: MainViewModel vm })
+ {
+ vm.ToggleGameMuteCommand.Execute(null);
+ }
+ }
+
+ // TRAX (TASK 9): left-click toggles play/pause (picks a track when none is
+ // loaded); right-click always opens the picker. The picker is the OS file
+ // dialog β the "never the OS viewer" rule covers the preview, not pickers.
+ private void TraxButton_Click(object sender, RoutedEventArgs e)
+ {
+ if (DataContext is ViewModels.MainViewModel vm)
+ vm.TraxLeftClick();
+ }
+
+ private void TraxButton_PreviewMouseRightButtonUp(object sender, MouseButtonEventArgs e)
+ {
+ if (DataContext is ViewModels.MainViewModel vm)
+ vm.TraxRightClick();
+ e.Handled = true; // the button's right-click context menu must not fire
+ }
+
+ // βββ Webcam / background context menus (right-click on overlay elements) βββ
+ private void WebcamMenu_ChangeWebcam(object sender, RoutedEventArgs e)
+ => (DataContext as MainViewModel)?.ChangeWebcamCommand.Execute(null);
+
+ private void WebcamMenu_SetBorderAnimation(object sender, RoutedEventArgs e)
+ {
+ if (sender is not MenuItem { DataContext: SceneElement element, Tag: string tag }
+ || !Enum.TryParse(tag, out var animation))
+ return;
+
+ element.BorderAnimation = animation;
+
+ StopBorderAnimation(element.Id);
+
+ if (animation == BorderAnimation.None) return;
+
+ var shape = FindBorderShape(sender as DependencyObject);
+ if (shape == null) return;
+
+ var sb = CreateBorderAnimation(animation, shape);
+ if (sb == null) return;
+
+ sb.Freeze();
+ _activeBorderAnimations[element.Id] = sb;
+ shape.BeginAnimation(Shape.OpacityProperty, null);
+ Storyboard.SetTarget(sb, shape);
+ sb.Begin();
+ }
+
+ private void StopBorderAnimation(string elementId)
+ {
+ if (_activeBorderAnimations.TryGetValue(elementId, out var old))
+ {
+ old.Stop();
+ _activeBorderAnimations.Remove(elementId);
+ }
+ }
+
+ private static Shape? FindBorderShape(DependencyObject? start)
+ {
+ var current = start;
+ while (current != null)
+ {
+ if (current is ContextMenu cm && cm.PlacementTarget is FrameworkElement target)
+ {
+ for (var i = 0; i < VisualTreeHelper.GetChildrenCount(target); i++)
+ {
+ var child = VisualTreeHelper.GetChild(target, i);
+ if (child is Shape s && (s is Rectangle or Ellipse) && !s.IsHitTestVisible)
+ return s;
+ }
+ return null;
+ }
+ current = VisualTreeHelper.GetParent(current);
+ }
+ return null;
+ }
+
+ private static Storyboard? CreateBorderAnimation(BorderAnimation animation, Shape shape)
+ {
+ var sb = new Storyboard();
+ switch (animation)
+ {
+ case BorderAnimation.Pulse:
+ AddDoubleAnimation(sb, Shape.OpacityProperty, 1.0, 0.3, TimeSpan.FromSeconds(0.8), true);
+ break;
+ case BorderAnimation.Glow:
+ AddDoubleAnimation(sb, Shape.OpacityProperty, 1.0, 0.5, TimeSpan.FromSeconds(1.2), true);
+ break;
+ case BorderAnimation.Shimmer:
+ AddDoubleAnimation(sb, Shape.OpacityProperty, 1.0, 0.15, TimeSpan.FromSeconds(0.5), true);
+ break;
+ case BorderAnimation.Electricity:
+ AddDoubleAnimation(sb, Shape.OpacityProperty, 1.0, 0.2, TimeSpan.FromSeconds(0.1), true);
+ break;
+ case BorderAnimation.Sparkles:
+ AddDoubleAnimation(sb, Shape.OpacityProperty, 1.0, 0.4, TimeSpan.FromSeconds(0.3), true);
+ break;
+ case BorderAnimation.MarchingAnts:
+ shape.StrokeDashArray = new DoubleCollection { 4, 2 };
+ AddDoubleAnimation(sb, Shape.StrokeDashOffsetProperty, 0, -20, TimeSpan.FromSeconds(0.8), false);
+ break;
+ case BorderAnimation.Chase:
+ shape.StrokeDashArray = new DoubleCollection { 8, 4 };
+ AddDoubleAnimation(sb, Shape.StrokeDashOffsetProperty, 0, -30, TimeSpan.FromSeconds(1.5), false);
+ break;
+ case BorderAnimation.Rainbow:
+ if (shape.Stroke is SolidColorBrush brush)
+ {
+ var frozen = brush.IsFrozen;
+ if (frozen) brush = brush.Clone();
+ shape.Stroke = brush;
+ var colorAnim = new ColorAnimationUsingKeyFrames
+ {
+ Duration = new Duration(TimeSpan.FromSeconds(3)),
+ RepeatBehavior = RepeatBehavior.Forever
+ };
+ colorAnim.KeyFrames.Add(new LinearColorKeyFrame(Colors.Red, KeyTime.FromTimeSpan(TimeSpan.Zero)));
+ colorAnim.KeyFrames.Add(new LinearColorKeyFrame(Colors.Yellow, KeyTime.FromTimeSpan(TimeSpan.FromSeconds(0.5))));
+ colorAnim.KeyFrames.Add(new LinearColorKeyFrame(Colors.Green, KeyTime.FromTimeSpan(TimeSpan.FromSeconds(1))));
+ colorAnim.KeyFrames.Add(new LinearColorKeyFrame(Colors.DodgerBlue, KeyTime.FromTimeSpan(TimeSpan.FromSeconds(1.5))));
+ colorAnim.KeyFrames.Add(new LinearColorKeyFrame(Colors.MediumPurple, KeyTime.FromTimeSpan(TimeSpan.FromSeconds(2))));
+ colorAnim.KeyFrames.Add(new LinearColorKeyFrame(Colors.Red, KeyTime.FromTimeSpan(TimeSpan.FromSeconds(2.5))));
+ Storyboard.SetTargetProperty(colorAnim, new PropertyPath("(Shape.Stroke).(SolidColorBrush.Color)"));
+ sb.Children.Add(colorAnim);
+ }
+ break;
+ default:
+ return null;
+ }
+ return sb.Children.Count > 0 ? sb : null;
+ }
+
+ private static void AddDoubleAnimation(Storyboard sb, DependencyProperty prop,
+ double from, double to, Duration duration, bool autoReverse)
+ {
+ var anim = new DoubleAnimation(from, to, duration)
+ {
+ AutoReverse = autoReverse,
+ RepeatBehavior = RepeatBehavior.Forever
+ };
+ Storyboard.SetTargetProperty(anim, new PropertyPath(prop));
+ sb.Children.Add(anim);
+ }
+
+ private void WebcamMenu_SetBorderColor(object sender, RoutedEventArgs e)
+ {
+ if (sender is MenuItem { DataContext: SceneElement element, Tag: string color })
+ element.BorderColor = color;
+ }
+
+ private void WebcamMenu_HideInScene(object sender, RoutedEventArgs e)
+ {
+ if (sender is MenuItem { DataContext: SceneElement element })
+ element.IsVisible = false;
+ }
+
+ private void WebcamMenu_Remove(object sender, RoutedEventArgs e)
+ {
+ if (sender is MenuItem { DataContext: SceneElement element })
+ (DataContext as MainViewModel)?.RemoveSourceCommand.Execute(element);
+ }
+
+ // βββ Social bar: click toggles top β bottom. KISS β the drag variant was
+ // unusable for shaky hands (jitter around the direction deadzone), so the bar
+ // rides on {Binding SocialBarTop} alone and a click flips it. βββ
+ private void SocialBar_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
+ {
+ (DataContext as MainViewModel)?.ToggleSocialBarPosition();
+ e.Handled = true;
+ }
+
+ private void SocialsButton_MouseEnter(object sender, MouseEventArgs e)
+ {
+ if (SocialBarElement == null) return;
+
+ // Kick the social bar to full brightness so it's immediately visible
+ var effect = SocialBarElement.Effect as DropShadowEffect;
+ if (effect != null)
+ {
+ effect.BlurRadius = 40;
+ effect.Opacity = 1.0;
+ }
+
+ // Pulse the glow so it catches the eye
+ _socialGlowAngle = 0;
+ _socialGlowTimer?.Stop();
+ _socialGlowTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(30) };
+ _socialGlowTimer.Tick += SocialGlowTick;
+ _socialGlowTimer.Start();
+ }
+
+ private void SocialsButton_MouseLeave(object sender, MouseEventArgs e)
+ {
+ _socialGlowTimer?.Stop();
+ _socialGlowTimer = null;
+
+ if (SocialBarElement?.Effect is DropShadowEffect effect)
+ {
+ effect.BlurRadius = 18;
+ effect.Opacity = 0.9;
+ }
+ }
+
+ private void SocialGlowTick(object? sender, EventArgs e)
+ {
+ if (SocialBarElement?.Effect is not DropShadowEffect effect) return;
+
+ _socialGlowAngle += 0.18;
+ if (_socialGlowAngle > Math.PI * 2) _socialGlowAngle -= Math.PI * 2;
+
+ // Smooth sine pulse: BlurRadius 24β48, Opacity 0.7β1.0
+ double t = (Math.Sin(_socialGlowAngle) + 1.0) / 2.0; // 0..1
+ effect.BlurRadius = 24 + t * 24;
+ effect.Opacity = 0.7 + t * 0.3;
+ }
+
+ // βββ Preview: element selection / move / resize / hit-testing βββ
+ private void Preview_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
+ {
+ var vm = DataContext as MainViewModel;
+ if (vm is null || !vm.IsEditMode) return;
+ var grid = (Grid)sender;
+ var toCanvas = CanvasGrid.TransformToVisual(grid).Inverse;
+ var p = toCanvas.Transform(e.GetPosition(grid));
+ var selected = vm.SelectedElement;
+
+ if (selected is { } sel && IsDraggableElement(sel) && HitHandle(e.GetPosition(grid), sel))
+ {
+ _isResizing = true;
+ _resizeAspect = sel.ClipShape == ClipShape.Round ? 1 : sel.Width / Math.Max(1, sel.Height);
+ grid.CaptureMouse();
+ e.Handled = true;
+ return;
+ }
+
+ var hit = HitElement(vm, p);
+ if (hit != null)
+ {
+ vm.SelectedElement = hit;
+ _isDraggingOverlay = true;
+ _grabOffset = new Point(p.X - hit.X, p.Y - hit.Y);
+ grid.CaptureMouse();
+ e.Handled = true;
+ return;
+ }
+
+ vm.SelectedElement = null;
+ }
+
+ private void Preview_MouseMove(object sender, MouseEventArgs e)
+ {
+ var vm = DataContext as MainViewModel;
+ if (vm is null || !vm.IsEditMode) return;
+ if (!_isDraggingOverlay && !_isResizing) return;
+
+ var grid = (Grid)sender;
+ var toCanvas = CanvasGrid.TransformToVisual(grid).Inverse;
+ var p = toCanvas.Transform(e.GetPosition(grid));
+ var selected = vm.SelectedElement;
+ if (selected == null)
+ {
+ EndPreviewDrag(grid);
+ return;
+ }
+
+ if (_isDraggingOverlay)
+ {
+ var minX = -(selected.Width - 20);
+ var maxX = 1920 - 20;
+ var minY = -(selected.Height - 20);
+ var maxY = 1080 - 20;
+ selected.X = Math.Clamp(p.X - _grabOffset.X, Math.Min(minX, maxX), Math.Max(minX, maxX));
+ selected.Y = Math.Clamp(p.Y - _grabOffset.Y, Math.Min(minY, maxY), Math.Max(minY, maxY));
+ }
+ else if (_isResizing)
+ {
+ var isWebcam = selected is WebcamSceneConfig;
+ var isRound = selected.ClipShape == ClipShape.Round;
+ var proportional = isRound || Keyboard.Modifiers.HasFlag(ModifierKeys.Shift);
+ var maxW = isWebcam ? MainViewModel.MaxWebcamWidthFor(vm.StagedScene?.Name) : 1920;
+ var maxH = isWebcam ? MainViewModel.MaxWebcamHeightFor(vm.StagedScene?.Name) : 1080;
+ var newW = Math.Clamp(p.X - selected.X, 32, maxW);
+ var newH = proportional ? newW / _resizeAspect : Math.Clamp(p.Y - selected.Y, 32, maxH);
+ if (proportional && newH > maxH)
+ {
+ newH = maxH;
+ newW = newH * _resizeAspect;
+ }
+ selected.Width = newW;
+ selected.Height = newH;
+ }
+ }
+
+ private void Preview_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
+ => EndPreviewDrag((Grid)sender);
+
+ private void EndPreviewDrag(Grid grid)
+ {
+ if (_isDraggingOverlay || _isResizing)
+ grid.ReleaseMouseCapture();
+ _isDraggingOverlay = false;
+ _isResizing = false;
+ }
+
+ private bool HitHandle(Point mouseScreen, SceneElement element)
+ {
+ var corner = CanvasGrid.TransformToVisual(PreviewGrid).Transform(new Point(element.X + element.Width, element.Y + element.Height));
+ return Math.Abs(mouseScreen.X - corner.X) <= 20 && Math.Abs(mouseScreen.Y - corner.Y) <= 20;
+ }
+
+ private SceneElement? HitElement(MainViewModel vm, Point p)
+ {
+ var scene = vm.StagedScene;
+ if (scene == null) return null;
+ 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)
+ return element;
+ }
+ return null;
+ }
+}
diff --git a/MainWindow.xaml b/MainWindow.xaml
index 8e8f28a..fff5c87 100644
--- a/MainWindow.xaml
+++ b/MainWindow.xaml
@@ -643,665 +643,7 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs
index b111425..6236d6d 100644
--- a/MainWindow.xaml.cs
+++ b/MainWindow.xaml.cs
@@ -1,16 +1,10 @@
ο»Ώusing System;
using System.Collections;
using System.ComponentModel;
-using System.Net.Http;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
-using System.Windows.Media.Effects;
-using System.Windows.Threading;
-using System.Windows.Media.Animation;
-using System.Windows.Media.Imaging;
-using System.Windows.Shapes;
using ytLive.Helpers;
using ytLive.Models;
using ytLive.ViewModels;
@@ -20,8 +14,8 @@ namespace ytLive;
public partial class MainWindow : Window
{
private readonly MainViewModel _viewModel;
- private readonly Dictionary _activeBorderAnimations = new();
private Controls.TopBar _topBar = null!;
+ private Controls.PreviewPane _previewPane = null!;
public MainWindow()
{
@@ -32,9 +26,10 @@ public partial class MainWindow : Window
_viewModel.InitWebView2(WebViewHostPanel);
_viewModel.PropertyChanged += OnViewModelPropertyChanged;
_viewModel.ChatMessages.CollectionChanged += OnChatMessagesChanged;
+ _topBar = (Controls.TopBar)FindName("TopBar")!;
+ _previewPane = (Controls.PreviewPane)FindName("PreviewPane")!;
UpdateTaskbarOverlay();
UpdateSelectionOverlay();
- _topBar = (Controls.TopBar)FindName("TopBar")!;
_topBar.RefreshAvatar();
RestoreWindowGeometry();
AppLog.Write("MainWindow ctor: end");
@@ -150,100 +145,6 @@ public partial class MainWindow : Window
}
}
- private void VolumeSlider_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
- {
- if (sender is Slider slider)
- SetSliderValueFromClick(slider, e);
- if (sender is FrameworkElement { DataContext: MainViewModel vm })
- vm.SetVolumeAdjusting(true);
- }
-
- private void VolumeSlider_PreviewMouseLeftButtonUp(object sender, MouseButtonEventArgs e)
- {
- if (sender is FrameworkElement { DataContext: MainViewModel vm })
- vm.SetVolumeAdjusting(false);
- }
-
- private void VolumeSlider_LostMouseCapture(object sender, MouseEventArgs e)
- {
- if (sender is FrameworkElement { DataContext: MainViewModel vm })
- vm.SetVolumeAdjusting(false);
- }
-
- private void GameVolumeSlider_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
- {
- if (sender is Slider slider)
- SetSliderValueFromClick(slider, e);
- if (sender is FrameworkElement { DataContext: MainViewModel vm })
- vm.SetGameVolumeAdjusting(true);
- }
-
- private void GameVolumeSlider_PreviewMouseLeftButtonUp(object sender, MouseButtonEventArgs e)
- {
- if (sender is FrameworkElement { DataContext: MainViewModel vm })
- vm.SetGameVolumeAdjusting(false);
- }
-
- private void GameVolumeSlider_LostMouseCapture(object sender, MouseEventArgs e)
- {
- if (sender is FrameworkElement { DataContext: MainViewModel vm })
- vm.SetGameVolumeAdjusting(false);
- }
-
- private static void SetSliderValueFromClick(Slider slider, MouseButtonEventArgs e)
- {
- if (FindParent(e.OriginalSource as Visual) != null)
- return;
- var track = slider.Template.FindName("PART_Track", slider) as System.Windows.Controls.Primitives.Track;
- if (track == null) return;
- var pos = e.GetPosition(track);
- var fraction = Math.Clamp(pos.X / track.RenderSize.Width, 0, 1);
- slider.Value = slider.Minimum + fraction * (slider.Maximum - slider.Minimum);
- e.Handled = true;
- }
-
- private static T? FindParent(Visual? visual) where T : Visual
- {
- while (visual != null)
- {
- if (visual is T match) return match;
- visual = VisualTreeHelper.GetParent(visual) as Visual;
- }
- return null;
- }
-
- private void MicSpeaker_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
- {
- if (sender is FrameworkElement { DataContext: MainViewModel vm })
- {
- vm.ToggleMicMuteCommand.Execute(null);
- }
- }
-
- private void GameSpeaker_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
- {
- if (sender is FrameworkElement { DataContext: MainViewModel vm })
- {
- vm.ToggleGameMuteCommand.Execute(null);
- }
- }
-
- // TRAX (TASK 9): left-click toggles play/pause (picks a track when none is
- // loaded); right-click always opens the picker. The picker is the OS file
- // dialog β the "never the OS viewer" rule covers the preview, not pickers.
- private void TraxButton_Click(object sender, RoutedEventArgs e)
- {
- if (DataContext is ViewModels.MainViewModel vm)
- vm.TraxLeftClick();
- }
-
- private void TraxButton_PreviewMouseRightButtonUp(object sender, MouseButtonEventArgs e)
- {
- if (DataContext is ViewModels.MainViewModel vm)
- vm.TraxRightClick();
- e.Handled = true; // the button's right-click context menu must not fire
- }
-
private void BackToAbout_Click(object sender, RoutedEventArgs e)
{
if (DataContext is ViewModels.MainViewModel vm)
@@ -270,18 +171,14 @@ public partial class MainWindow : Window
_viewModel.TransitionToScene(scene);
}
- private bool _isDraggingOverlay;
- private bool _isResizing;
- private Point _grabOffset;
- private double _resizeAspect;
private void Window_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
if (e.OriginalSource is not DependencyObject original) return;
var form = _viewModel.BroadcastForm;
- if (form.IsDrawerOpen && !IsDescendantOf(original, TextPullOutHost))
+ if (form.IsDrawerOpen && !_previewPane.IsClickInsideDrawer(original))
form.CloseDrawerCommand.Execute(null);
- if (IsDescendantOf(original, PreviewGrid)) return;
+ if (_previewPane.IsClickInsidePreview(original)) return;
if (IsDescendantOf(original, SourceList)) return;
if (IsDescendantOf(original, ElementsScrollViewer)) return;
_viewModel.SelectedElement = null;
@@ -302,11 +199,11 @@ public partial class MainWindow : Window
{
if (!_viewModel.IsEditMode)
{
- SelectionOverlay.Visibility = Visibility.Collapsed;
+ _previewPane.UpdateSelectionOverlay(false);
return;
}
- var selected = _viewModel.SelectedElement is { } s && IsDraggableElement(s);
- SelectionOverlay.Visibility = selected ? Visibility.Visible : Visibility.Collapsed;
+ var selected = _viewModel.SelectedElement is { } s && Controls.PreviewPane.IsDraggableElement(s);
+ _previewPane.UpdateSelectionOverlay(selected);
if (selected)
OpacityValueText.Text = $"{Math.Round(_viewModel.SelectedElement!.Opacity * 100)}%";
TakeSnapshot();
@@ -320,10 +217,6 @@ public partial class MainWindow : Window
}
}
- // Static images and the webcam share the move/resize/selection behavior.
- private static bool IsDraggableElement(SceneElement element)
- => element.IsDraggable;
-
private void MirrorButton_Click(object sender, RoutedEventArgs e)
{
if (_viewModel.SelectedElement is WebcamSceneConfig webcam)
@@ -336,143 +229,7 @@ public partial class MainWindow : Window
webcam.ToggleClipShape();
}
- private void WebcamMenu_ChangeWebcam(object sender, RoutedEventArgs e)
- => _viewModel.ChangeWebcamCommand.Execute(null);
-
- private void WebcamMenu_SetBorderAnimation(object sender, RoutedEventArgs e)
- {
- if (sender is not MenuItem { DataContext: SceneElement element, Tag: string tag }
- || !Enum.TryParse(tag, out var animation))
- return;
-
- element.BorderAnimation = animation;
-
- StopBorderAnimation(element.Id);
-
- if (animation == BorderAnimation.None) return;
-
- var shape = FindBorderShape(sender as DependencyObject);
- if (shape == null) return;
-
- var sb = CreateBorderAnimation(animation, shape);
- if (sb == null) return;
-
- sb.Freeze();
- _activeBorderAnimations[element.Id] = sb;
- shape.BeginAnimation(Shape.OpacityProperty, null);
- Storyboard.SetTarget(sb, shape);
- sb.Begin();
- }
-
- private void StopBorderAnimation(string elementId)
- {
- if (_activeBorderAnimations.TryGetValue(elementId, out var old))
- {
- old.Stop();
- _activeBorderAnimations.Remove(elementId);
- }
- }
-
- private static Shape? FindBorderShape(DependencyObject? start)
- {
- var current = start;
- while (current != null)
- {
- if (current is ContextMenu cm && cm.PlacementTarget is FrameworkElement target)
- {
- for (var i = 0; i < VisualTreeHelper.GetChildrenCount(target); i++)
- {
- var child = VisualTreeHelper.GetChild(target, i);
- if (child is Shape s && (s is Rectangle or Ellipse) && !s.IsHitTestVisible)
- return s;
- }
- return null;
- }
- current = VisualTreeHelper.GetParent(current);
- }
- return null;
- }
-
- private static Storyboard? CreateBorderAnimation(BorderAnimation animation, Shape shape)
- {
- var sb = new Storyboard();
- switch (animation)
- {
- case BorderAnimation.Pulse:
- AddDoubleAnimation(sb, Shape.OpacityProperty, 1.0, 0.3, TimeSpan.FromSeconds(0.8), true);
- break;
- case BorderAnimation.Glow:
- AddDoubleAnimation(sb, Shape.OpacityProperty, 1.0, 0.5, TimeSpan.FromSeconds(1.2), true);
- break;
- case BorderAnimation.Shimmer:
- AddDoubleAnimation(sb, Shape.OpacityProperty, 1.0, 0.15, TimeSpan.FromSeconds(0.5), true);
- break;
- case BorderAnimation.Electricity:
- AddDoubleAnimation(sb, Shape.OpacityProperty, 1.0, 0.2, TimeSpan.FromSeconds(0.1), true);
- break;
- case BorderAnimation.Sparkles:
- AddDoubleAnimation(sb, Shape.OpacityProperty, 1.0, 0.4, TimeSpan.FromSeconds(0.3), true);
- break;
- case BorderAnimation.MarchingAnts:
- shape.StrokeDashArray = new DoubleCollection { 4, 2 };
- AddDoubleAnimation(sb, Shape.StrokeDashOffsetProperty, 0, -20, TimeSpan.FromSeconds(0.8), false);
- break;
- case BorderAnimation.Chase:
- shape.StrokeDashArray = new DoubleCollection { 8, 4 };
- AddDoubleAnimation(sb, Shape.StrokeDashOffsetProperty, 0, -30, TimeSpan.FromSeconds(1.5), false);
- break;
- case BorderAnimation.Rainbow:
- if (shape.Stroke is SolidColorBrush brush)
- {
- var frozen = brush.IsFrozen;
- if (frozen) brush = brush.Clone();
- shape.Stroke = brush;
- var colorAnim = new ColorAnimationUsingKeyFrames
- {
- Duration = new Duration(TimeSpan.FromSeconds(3)),
- RepeatBehavior = RepeatBehavior.Forever
- };
- colorAnim.KeyFrames.Add(new LinearColorKeyFrame(Colors.Red, KeyTime.FromTimeSpan(TimeSpan.Zero)));
- colorAnim.KeyFrames.Add(new LinearColorKeyFrame(Colors.Yellow, KeyTime.FromTimeSpan(TimeSpan.FromSeconds(0.5))));
- colorAnim.KeyFrames.Add(new LinearColorKeyFrame(Colors.Green, KeyTime.FromTimeSpan(TimeSpan.FromSeconds(1))));
- colorAnim.KeyFrames.Add(new LinearColorKeyFrame(Colors.DodgerBlue, KeyTime.FromTimeSpan(TimeSpan.FromSeconds(1.5))));
- colorAnim.KeyFrames.Add(new LinearColorKeyFrame(Colors.MediumPurple, KeyTime.FromTimeSpan(TimeSpan.FromSeconds(2))));
- colorAnim.KeyFrames.Add(new LinearColorKeyFrame(Colors.Red, KeyTime.FromTimeSpan(TimeSpan.FromSeconds(2.5))));
- Storyboard.SetTargetProperty(colorAnim, new PropertyPath("(Shape.Stroke).(SolidColorBrush.Color)"));
- sb.Children.Add(colorAnim);
- }
- break;
- default:
- return null;
- }
- return sb.Children.Count > 0 ? sb : null;
- }
-
- private static void AddDoubleAnimation(Storyboard sb, DependencyProperty prop,
- double from, double to, Duration duration, bool autoReverse)
- {
- var anim = new DoubleAnimation(from, to, duration)
- {
- AutoReverse = autoReverse,
- RepeatBehavior = RepeatBehavior.Forever
- };
- Storyboard.SetTargetProperty(anim, new PropertyPath(prop));
- sb.Children.Add(anim);
- }
-
- private void WebcamMenu_SetBorderColor(object sender, RoutedEventArgs e)
- {
- if (sender is MenuItem { DataContext: SceneElement element, Tag: string color })
- element.BorderColor = color;
- }
-
- private void WebcamMenu_HideInScene(object sender, RoutedEventArgs e)
- {
- if (sender is MenuItem { DataContext: SceneElement element })
- element.IsVisible = false;
- }
-
- private void WebcamMenu_Remove(object sender, RoutedEventArgs e)
+ private void SourceMenu_Remove(object sender, RoutedEventArgs e)
{
if (sender is MenuItem { DataContext: SceneElement element })
_viewModel.RemoveSourceCommand.Execute(element);
@@ -488,179 +245,9 @@ public partial class MainWindow : Window
_viewModel.RefreshBackgroundAutoCapture();
}
- private void SourceMenu_Remove(object sender, RoutedEventArgs e)
- {
- if (sender is MenuItem { DataContext: SceneElement element })
- _viewModel.RemoveSourceCommand.Execute(element);
- }
-
private void OpacitySlider_ValueChanged(object sender, RoutedPropertyChangedEventArgs e)
=> OpacityValueText.Text = $"{Math.Round(e.NewValue * 100)}%";
- // βββ Social bar: click toggles top β bottom. KISS β the drag variant was
- // unusable for shaky hands (jitter around the direction deadzone), so the bar
- // rides on {Binding SocialBarTop} alone and a click flips it. βββ
- private void SocialBar_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
- {
- _viewModel.ToggleSocialBarPosition();
- e.Handled = true;
- }
-
- private DispatcherTimer? _socialGlowTimer;
- private double _socialGlowAngle;
-
- private void SocialsButton_MouseEnter(object sender, MouseEventArgs e)
- {
- if (SocialBarElement == null) return;
-
- // Kick the social bar to full brightness so it's immediately visible
- var effect = SocialBarElement.Effect as DropShadowEffect;
- if (effect != null)
- {
- effect.BlurRadius = 40;
- effect.Opacity = 1.0;
- }
-
- // Pulse the glow so it catches the eye
- _socialGlowAngle = 0;
- _socialGlowTimer?.Stop();
- _socialGlowTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(30) };
- _socialGlowTimer.Tick += SocialGlowTick;
- _socialGlowTimer.Start();
- }
-
- private void SocialsButton_MouseLeave(object sender, MouseEventArgs e)
- {
- _socialGlowTimer?.Stop();
- _socialGlowTimer = null;
-
- if (SocialBarElement?.Effect is DropShadowEffect effect)
- {
- effect.BlurRadius = 18;
- effect.Opacity = 0.9;
- }
- }
-
- private void SocialGlowTick(object? sender, EventArgs e)
- {
- if (SocialBarElement?.Effect is not DropShadowEffect effect) return;
-
- _socialGlowAngle += 0.18;
- if (_socialGlowAngle > Math.PI * 2) _socialGlowAngle -= Math.PI * 2;
-
- // Smooth sine pulse: BlurRadius 24β48, Opacity 0.7β1.0
- double t = (Math.Sin(_socialGlowAngle) + 1.0) / 2.0; // 0..1
- effect.BlurRadius = 24 + t * 24;
- effect.Opacity = 0.7 + t * 0.3;
- }
-
- private void Preview_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
- {
- if (!_viewModel.IsEditMode) return;
- var grid = (Grid)sender;
- var toCanvas = CanvasGrid.TransformToVisual(grid).Inverse;
- var p = toCanvas.Transform(e.GetPosition(grid));
- var selected = _viewModel.SelectedElement;
-
- if (selected is { } sel && IsDraggableElement(sel) && HitHandle(e.GetPosition(grid), sel))
- {
- _isResizing = true;
- _resizeAspect = sel.ClipShape == ClipShape.Round ? 1 : sel.Width / Math.Max(1, sel.Height);
- grid.CaptureMouse();
- e.Handled = true;
- return;
- }
-
- var hit = HitElement(p);
- if (hit != null)
- {
- _viewModel.SelectedElement = hit;
- _isDraggingOverlay = true;
- _grabOffset = new Point(p.X - hit.X, p.Y - hit.Y);
- grid.CaptureMouse();
- e.Handled = true;
- return;
- }
-
- _viewModel.SelectedElement = null;
- }
-
- private void Preview_MouseMove(object sender, MouseEventArgs e)
- {
- if (!_viewModel.IsEditMode) return;
- if (!_isDraggingOverlay && !_isResizing) return;
-
- var grid = (Grid)sender;
- var toCanvas = CanvasGrid.TransformToVisual(grid).Inverse;
- var p = toCanvas.Transform(e.GetPosition(grid));
- var selected = _viewModel.SelectedElement;
- if (selected == null)
- {
- EndPreviewDrag(grid);
- return;
- }
-
- if (_isDraggingOverlay)
- {
- var minX = -(selected.Width - 20);
- var maxX = 1920 - 20;
- var minY = -(selected.Height - 20);
- var maxY = 1080 - 20;
- selected.X = Math.Clamp(p.X - _grabOffset.X, Math.Min(minX, maxX), Math.Max(minX, maxX));
- selected.Y = Math.Clamp(p.Y - _grabOffset.Y, Math.Min(minY, maxY), Math.Max(minY, maxY));
- }
- else if (_isResizing)
- {
- var isWebcam = selected is WebcamSceneConfig;
- var isRound = selected.ClipShape == ClipShape.Round;
- var proportional = isRound || Keyboard.Modifiers.HasFlag(ModifierKeys.Shift);
- var maxW = isWebcam ? MainViewModel.MaxWebcamWidthFor(_viewModel.StagedScene?.Name) : 1920;
- var maxH = isWebcam ? MainViewModel.MaxWebcamHeightFor(_viewModel.StagedScene?.Name) : 1080;
- var newW = Math.Clamp(p.X - selected.X, 32, maxW);
- var newH = proportional ? newW / _resizeAspect : Math.Clamp(p.Y - selected.Y, 32, maxH);
- if (proportional && newH > maxH)
- {
- newH = maxH;
- newW = newH * _resizeAspect;
- }
- selected.Width = newW;
- selected.Height = newH;
- }
- }
-
- private void Preview_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
- => EndPreviewDrag((Grid)sender);
-
- private void EndPreviewDrag(Grid grid)
- {
- if (_isDraggingOverlay || _isResizing)
- grid.ReleaseMouseCapture();
- _isDraggingOverlay = false;
- _isResizing = false;
- }
-
- private bool HitHandle(Point mouseScreen, SceneElement element)
- {
- var corner = CanvasGrid.TransformToVisual(PreviewGrid).Transform(new Point(element.X + element.Width, element.Y + element.Height));
- return Math.Abs(mouseScreen.X - corner.X) <= 20 && Math.Abs(mouseScreen.Y - corner.Y) <= 20;
- }
-
- private SceneElement? HitElement(Point p)
- {
- var scene = _viewModel.StagedScene;
- if (scene == null) return null;
- 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)
- return element;
- }
- return null;
- }
private void SourceNameBox_KeyDown(object sender, KeyEventArgs e)
{
diff --git a/ViewModels/index.md b/ViewModels/index.md
index e185ded..e84239a 100644
--- a/ViewModels/index.md
+++ b/ViewModels/index.md
@@ -56,7 +56,8 @@ Progress (Commit N/12 Β· tag):
**Phase 2 β `MainWindow.xaml` split into UserControls** (new `Controls/` folder, namespace `ytLive.Controls`):
- [x] **Commit 12 Β· `refactor-commit-12`** β **BottomBar** β `Controls/BottomBar.xaml(.cs)` (gear menu + stream stats + quality dropdown; `GearButton_Click` moved to the control's code-behind).
- [x] **Commit 13 Β· `refactor-commit-13`** β **HealthBanner** β `Controls/HealthBanner.xaml` (Row 1). Also promoted the 7 valueβvisibility converters out of `MainWindow.Window.Resources` into `Themes/Controls.xaml` so extracted controls can resolve them (app-level, not window-level).
-- [x] **Commit 14 Β· `refactor-commit-14`** β **TopBar** β `Controls/TopBar.xaml(.cs)` (Row 0: logo, REC/ON-AIR pills + signs, avatar, Sign In/Start/End buttons). Avatar fetch moved (async-preserved) into the control; window exposes `_topBar.RefreshAvatar()` invoked on `IsConnected`/`AccountAvatarUrl` changes. Pending: left scene/chat panel, preview pane, overlay host.
+- [x] **Commit 14 Β· `refactor-commit-14`** β **TopBar** β `Controls/TopBar.xaml(.cs)` (Row 0: logo, REC/ON-AIR pills + signs, avatar, Sign In/Start/End buttons). Avatar fetch moved (async-preserved) into the control; window exposes `_topBar.RefreshAvatar()` invoked on `IsConnected`/`AccountAvatarUrl` changes.
+- [x] **Commit 15 Β· `refactor-commit-15`** β **PreviewPane** β `Controls/PreviewPane.xaml(.cs)` (Row 1 Col 1 center preview pane). Moved the 1920Γ1080 canvas (OverlayCanvas/SelectionOverlay/BrandFlashLayer/SocialBarElement), text pull-out, and the live-controls row (TRAX/game/mic meters + sliders + speakers + socials button). Code-behind moved: preview drag/select/resize/hit-test (Preview_Mouse*, HitHandle, HitElement), webcam-context-menu handlers (WebcamMenu_*), social-bar glow (SocialBar_*, SocialsButton_*, SocialGlowTick), audio sliders/speakers (VolumeSlider_*, GameVolumeSlider_*, MicSpeaker_*, GameSpeaker_*, SetSliderValueFromClick, FindParent), TraxButton_*. Window keeps the source-list left panel + thumbnail strip + edit UI, and routes selection-overlay via `_previewPane.UpdateSelectionOverlay(bool)` plus `_previewPane.IsClickInsidePreview/Drawer`. `BackgroundMenu_*` stayed in the window (used by the left-panel source context menu). Pending: thumbnail strip, left scene/chat panel, overlay host.
The partial `.cs` files land next to `MainViewModel.cs` in this folder as the split
proceeds; each partial carries its own `using`s and re-declares nothing from core.
\ No newline at end of file