feat(ui): Show Desktop toggle, avatar login, chat preview, backdrop seeding, border animations

- Source.ShowDesktop bool persisted in DB (schema migration + LayoutStore)
- ClearBackdropCaptureAsync clears CaptureKey + VideoImageSource for static fallback
- Top bar: red/green/pulsing status light, avatar via BitmapImage, Log In button
- ShowStartStream now requires IsConnected
- RunMockChatPreviewAsync: simple async loop, ChatPreviewEnabled property on Source
- EnsureDefaultBackdrop called on every scene switch, seeds all 5 scenes
- SeedEndingBackdrop replaces existing, SeedChatBackdrop added
- SceneCatalog: Chat scene display name corrected (layer = 'YouTube Chat')
- Left panel spacing consistency, context menu reordered
- Border animation rendering via code-behind Storyboard approach
- 3 new backdrop assets (ending-backdrop.jpg, chat-backdrop.jpg, live-backdrop.jpg)
- Build 0 warnings, 178 tests passing
This commit is contained in:
2026-08-20 18:02:41 -07:00
parent a76a3d2acd
commit 0bd17ab428
18 changed files with 1022 additions and 216 deletions
+166 -11
View File
@@ -4,6 +4,9 @@ using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Animation;
using System.Windows.Media.Imaging;
using System.Windows.Shapes;
using ytLive.Helpers;
using ytLive.Models;
using ytLive.ViewModels;
@@ -13,6 +16,7 @@ namespace ytLive;
public partial class MainWindow : Window
{
private readonly MainViewModel _viewModel;
private readonly Dictionary<string, Storyboard> _activeBorderAnimations = new();
public MainWindow()
{
@@ -26,6 +30,7 @@ public partial class MainWindow : Window
SceneList.IsSelectable = item => item is Scene { IsHidden: false };
UpdateTaskbarOverlay();
UpdateSelectionOverlay();
LoadAvatarImage();
AppLog.Write("MainWindow ctor: end");
}
@@ -40,6 +45,9 @@ public partial class MainWindow : Window
UpdateTaskbarOverlay();
else if (e.PropertyName == nameof(MainViewModel.SelectedElement))
UpdateSelectionOverlay();
else if (e.PropertyName == nameof(MainViewModel.AccountAvatarUrl)
|| e.PropertyName == nameof(MainViewModel.IsConnected))
LoadAvatarImage();
}
private void MainWindow_Closing(object? sender, CancelEventArgs e)
@@ -54,7 +62,6 @@ public partial class MainWindow : Window
private void MainWindow_Deactivated(object? sender, EventArgs e)
{
_viewModel.NoteBackgroundWindow();
}
private void UpdateTaskbarOverlay()
@@ -64,6 +71,28 @@ public partial class MainWindow : Window
: null;
}
private void LoadAvatarImage()
{
if (_viewModel.IsConnected && !string.IsNullOrWhiteSpace(_viewModel.AccountAvatarUrl))
{
try
{
var bitmap = new BitmapImage();
bitmap.BeginInit();
bitmap.UriSource = new Uri(_viewModel.AccountAvatarUrl, UriKind.Absolute);
bitmap.CacheOption = BitmapCacheOption.OnLoad;
bitmap.EndInit();
bitmap.Freeze();
AvatarImage.Source = bitmap;
}
catch { AvatarImage.Source = null; }
}
else
{
AvatarImage.Source = null;
}
}
private void SourceNameBox_IsVisibleChanged(object sender, DependencyPropertyChangedEventArgs e)
{
if (sender is TextBox { IsVisible: true } box)
@@ -124,7 +153,7 @@ public partial class MainWindow : Window
private static void SetSliderValueFromClick(Slider slider, MouseButtonEventArgs e)
{
if (e.OriginalSource is System.Windows.Controls.Primitives.Thumb)
if (FindParent<System.Windows.Controls.Primitives.Thumb>(e.OriginalSource as Visual) != null)
return;
var track = slider.Template.FindName("PART_Track", slider) as System.Windows.Controls.Primitives.Track;
if (track == null) return;
@@ -134,6 +163,16 @@ public partial class MainWindow : Window
e.Handled = true;
}
private static T? FindParent<T>(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 })
@@ -262,21 +301,135 @@ public partial class MainWindow : Window
private void WebcamMenu_SetBorderAnimation(object sender, RoutedEventArgs e)
{
if (sender is MenuItem { DataContext: WebcamSceneConfig webcam, Tag: string tag }
&& Enum.TryParse<BorderAnimation>(tag, out var animation))
webcam.BorderAnimation = animation;
if (sender is not MenuItem { DataContext: SceneElement element, Tag: string tag }
|| !Enum.TryParse<BorderAnimation>(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: WebcamSceneConfig webcam, Tag: string color })
webcam.BorderColor = color;
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: WebcamSceneConfig webcam })
webcam.IsVisible = false;
if (sender is MenuItem { DataContext: SceneElement element })
element.IsVisible = false;
}
private void WebcamMenu_Remove(object sender, RoutedEventArgs e)
@@ -369,11 +522,13 @@ public partial class MainWindow : Window
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.ActiveScene?.Name) : 1920;
var maxH = isWebcam ? MainViewModel.MaxWebcamHeightFor(_viewModel.ActiveScene?.Name) : 1080;
var newW = Math.Clamp(p.X - selected.X, 32, maxW);
var newH = newW / _resizeAspect;
if (newH > maxH)
var newH = proportional ? newW / _resizeAspect : Math.Clamp(p.Y - selected.Y, 32, maxH);
if (proportional && newH > maxH)
{
newH = maxH;
newW = newH * _resizeAspect;