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