Multi-scene webcam (schema v3/v4): singleton Webcam + per-scene WebcamSceneConfig, right-click OBS-style border/context menu, persisted round-to-rect restore + legacy-square 16:9 heal, dark MenuItem template, tests (25 passing)

This commit is contained in:
2026-08-07 08:24:18 -07:00
parent f31ce9fdb7
commit e037ba027b
23 changed files with 1405 additions and 287 deletions
+62 -29
View File
@@ -37,7 +37,7 @@ public partial class MainWindow : Window
{
if (e.PropertyName == nameof(MainViewModel.IsLive))
UpdateTaskbarOverlay();
else if (e.PropertyName == nameof(MainViewModel.SelectedSource))
else if (e.PropertyName == nameof(MainViewModel.SelectedElement))
UpdateSelectionOverlay();
}
@@ -103,7 +103,7 @@ public partial class MainWindow : Window
if (IsDescendantOf(original, PreviewGrid)) return;
if (IsDescendantOf(original, SourceList)) return;
if (IsDescendantOf(original, OpacityChip)) return;
_viewModel.SelectedSource = null;
_viewModel.SelectedElement = null;
}
private static bool IsDescendantOf(DependencyObject? child, DependencyObject ancestor)
@@ -115,27 +115,55 @@ public partial class MainWindow : Window
private void UpdateSelectionOverlay()
{
var selected = _viewModel.SelectedSource is { } s && IsDraggableSource(s);
var selected = _viewModel.SelectedElement is { } s && IsDraggableElement(s);
SelectionOverlay.Visibility = selected ? Visibility.Visible : Visibility.Collapsed;
OpacityChip.Visibility = selected ? Visibility.Visible : Visibility.Collapsed;
if (selected)
OpacityValueText.Text = $"{Math.Round(_viewModel.SelectedSource!.Opacity * 100)}%";
OpacityValueText.Text = $"{Math.Round(_viewModel.SelectedElement!.Opacity * 100)}%";
}
// Static images and the webcam share the move/resize/selection behavior.
private static bool IsDraggableSource(Source source)
=> source.Type is SourceType.Image or SourceType.Webcam;
private static bool IsDraggableElement(SceneElement element)
=> element is Source { Type: SourceType.Image } or WebcamSceneConfig;
private void MirrorButton_Click(object sender, RoutedEventArgs e)
{
if (_viewModel.SelectedSource is { Type: SourceType.Webcam } source)
source.IsMirrored = !source.IsMirrored;
if (_viewModel.SelectedElement is WebcamSceneConfig webcam)
webcam.IsMirrored = !webcam.IsMirrored;
}
private void ShapeButton_Click(object sender, RoutedEventArgs e)
{
if (_viewModel.SelectedSource is { Type: SourceType.Webcam } source)
source.ClipShape = source.ClipShape == ClipShape.Traditional ? ClipShape.Round : ClipShape.Traditional;
if (_viewModel.SelectedElement is WebcamSceneConfig webcam)
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 MenuItem { DataContext: WebcamSceneConfig webcam, Tag: string tag }
&& Enum.TryParse<BorderAnimation>(tag, out var animation))
webcam.BorderAnimation = animation;
}
private void WebcamMenu_SetBorderColor(object sender, RoutedEventArgs e)
{
if (sender is MenuItem { DataContext: WebcamSceneConfig webcam, Tag: string color })
webcam.BorderColor = color;
}
private void WebcamMenu_HideInScene(object sender, RoutedEventArgs e)
{
if (sender is MenuItem { DataContext: WebcamSceneConfig webcam })
webcam.IsVisible = false;
}
private void WebcamMenu_Remove(object sender, RoutedEventArgs e)
{
if (sender is MenuItem { DataContext: SceneElement element })
_viewModel.RemoveSourceCommand.Execute(element);
}
private void OpacitySlider_ValueChanged(object sender, RoutedPropertyChangedEventArgs<double> e)
@@ -148,9 +176,9 @@ public partial class MainWindow : Window
var grid = (Grid)sender;
var toCanvas = CanvasGrid.TransformToVisual(grid).Inverse;
var p = toCanvas.Transform(e.GetPosition(grid));
var selected = _viewModel.SelectedSource;
var selected = _viewModel.SelectedElement;
if (selected is { } sel && IsDraggableSource(sel) && HitHandle(e.GetPosition(grid), sel))
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);
@@ -159,10 +187,10 @@ public partial class MainWindow : Window
return;
}
var hit = HitImage(p);
var hit = HitElement(p);
if (hit != null)
{
_viewModel.SelectedSource = hit;
_viewModel.SelectedElement = hit;
_isDraggingOverlay = true;
_grabOffset = new Point(p.X - hit.X, p.Y - hit.Y);
grid.CaptureMouse();
@@ -170,7 +198,7 @@ public partial class MainWindow : Window
return;
}
_viewModel.SelectedSource = null;
_viewModel.SelectedElement = null;
}
private void Preview_MouseMove(object sender, MouseEventArgs e)
@@ -180,7 +208,7 @@ public partial class MainWindow : Window
var grid = (Grid)sender;
var toCanvas = CanvasGrid.TransformToVisual(grid).Inverse;
var p = toCanvas.Transform(e.GetPosition(grid));
var selected = _viewModel.SelectedSource;
var selected = _viewModel.SelectedElement;
if (selected == null)
{
EndPreviewDrag(grid);
@@ -198,11 +226,14 @@ public partial class MainWindow : Window
}
else if (_isResizing)
{
var newW = Math.Clamp(p.X - selected.X, 32, 1920);
var isWebcam = selected is WebcamSceneConfig;
var maxW = isWebcam ? MainViewModel.WebcamMaxWidth : 1920;
var maxH = isWebcam ? MainViewModel.WebcamMaxHeight : 1080;
var newW = Math.Clamp(p.X - selected.X, 32, maxW);
var newH = newW / _resizeAspect;
if (newH > 1080)
if (newH > maxH)
{
newH = 1080;
newH = maxH;
newW = newH * _resizeAspect;
}
selected.Width = newW;
@@ -221,23 +252,25 @@ public partial class MainWindow : Window
_isResizing = false;
}
private bool HitHandle(Point mouseScreen, Source source)
private bool HitHandle(Point mouseScreen, SceneElement element)
{
var corner = CanvasGrid.TransformToVisual(PreviewGrid).Transform(new Point(source.X + source.Width, source.Y + source.Height));
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 Source? HitImage(Point p)
private SceneElement? HitElement(Point p)
{
var scene = _viewModel.ActiveScene;
if (scene == null) return null;
for (var i = scene.Sources.Count - 1; i >= 0; i--)
for (var i = scene.Elements.Count - 1; i >= 0; i--)
{
var source = scene.Sources[i];
if (!IsDraggableSource(source) || !source.IsEnabled) continue;
if (p.X >= source.X && p.X <= source.X + source.Width &&
p.Y >= source.Y && p.Y <= source.Y + source.Height)
return source;
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;
}
@@ -275,7 +308,7 @@ public partial class MainWindow : Window
if (item == null)
{
if (ReferenceEquals(listBox, SourceList))
_viewModel.SelectedSource = null;
_viewModel.SelectedElement = null;
_dragIndex = -1;
return;
}