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:
@@ -51,8 +51,14 @@ conventions live here and in `ai.md`.
|
|||||||
|
|
||||||
## Build
|
## Build
|
||||||
|
|
||||||
|
From WSL, ALWAYS use the Windows dotnet host — Linux `dotnet` re-downloads the
|
||||||
|
`windowsdesktop.app.*` packs over the slow 9p bridge and re-restores twice (WPF
|
||||||
|
`_wpftmp`), and `--no-restore` right after an interrupted restore produces bogus
|
||||||
|
`NETSDK1064` errors. See `ai.md` → Run for the exact commands and why.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
dotnet build # Windows-only WPF; builds from WSL via EnableWindowsTargeting
|
"/mnt/c/Program Files/dotnet/dotnet.exe" build "C:\Users\gramp\Documents\Code\projects\ytLive\ytLive.csproj"
|
||||||
|
"/mnt/c/Program Files/dotnet/dotnet.exe" vstest "C:\Users\gramp\Documents\Code\projects\ytLive\ytLive.Tests\bin\Debug\net8.0-windows10.0.19041.0\ytLive.Tests.dll"
|
||||||
```
|
```
|
||||||
|
|
||||||
Keep it at **0 warnings**. Running requires Windows. On a silent startup crash,
|
Keep it at **0 warnings**. Running requires Windows. On a silent startup crash,
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Windows.Data;
|
||||||
|
|
||||||
|
namespace ytLive.Helpers;
|
||||||
|
|
||||||
|
/// <summary>True when the bound value's ToString() equals the ConverterParameter string.</summary>
|
||||||
|
public class EnumToBoolConverter : IValueConverter
|
||||||
|
{
|
||||||
|
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||||
|
=> value?.ToString() == parameter?.ToString();
|
||||||
|
|
||||||
|
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||||
|
=> Binding.DoNothing;
|
||||||
|
}
|
||||||
+149
-13
@@ -30,6 +30,8 @@
|
|||||||
xmlns:Helpers="clr-namespace:ytLive.Helpers"/>
|
xmlns:Helpers="clr-namespace:ytLive.Helpers"/>
|
||||||
<Helpers:NotNullToVisibilityConverter x:Key="NotNullToVis"
|
<Helpers:NotNullToVisibilityConverter x:Key="NotNullToVis"
|
||||||
xmlns:Helpers="clr-namespace:ytLive.Helpers"/>
|
xmlns:Helpers="clr-namespace:ytLive.Helpers"/>
|
||||||
|
<Helpers:EnumToBoolConverter x:Key="EnumToBool"
|
||||||
|
xmlns:Helpers="clr-namespace:ytLive.Helpers"/>
|
||||||
|
|
||||||
<!-- Red dot shown in the taskbar icon while live -->
|
<!-- Red dot shown in the taskbar icon while live -->
|
||||||
<DrawingImage x:Key="LiveOverlay">
|
<DrawingImage x:Key="LiveOverlay">
|
||||||
@@ -230,7 +232,7 @@
|
|||||||
<Button.ContextMenu>
|
<Button.ContextMenu>
|
||||||
<ContextMenu DataContext="{Binding PlacementTarget.DataContext, RelativeSource={RelativeSource Self}}">
|
<ContextMenu DataContext="{Binding PlacementTarget.DataContext, RelativeSource={RelativeSource Self}}">
|
||||||
<MenuItem Header="Webcam" Command="{Binding AddSourceCommand}" CommandParameter="webcam"
|
<MenuItem Header="Webcam" Command="{Binding AddSourceCommand}" CommandParameter="webcam"
|
||||||
IsEnabled="{Binding CanAddWebcam}"
|
IsEnabled="{Binding CanAddWebcamToActiveScene}"
|
||||||
ToolTip="One webcam at a time — it's already in your stream"/>
|
ToolTip="One webcam at a time — it's already in your stream"/>
|
||||||
<MenuItem Header="Screen" Command="{Binding AddSourceCommand}" CommandParameter="screen"/>
|
<MenuItem Header="Screen" Command="{Binding AddSourceCommand}" CommandParameter="screen"/>
|
||||||
<MenuItem Header="Background" Command="{Binding AddSourceCommand}" CommandParameter="background"/>
|
<MenuItem Header="Background" Command="{Binding AddSourceCommand}" CommandParameter="background"/>
|
||||||
@@ -244,8 +246,8 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<Helpers:FocusPreservingListBox x:Name="SourceList" Grid.Row="3" Background="Transparent" BorderThickness="0"
|
<Helpers:FocusPreservingListBox x:Name="SourceList" Grid.Row="3" Background="Transparent" BorderThickness="0"
|
||||||
ItemsSource="{Binding ActiveScene.Sources}"
|
ItemsSource="{Binding ActiveScene.Elements}"
|
||||||
SelectedItem="{Binding SelectedSource, Mode=TwoWay}"
|
SelectedItem="{Binding SelectedElement, Mode=TwoWay}"
|
||||||
PreviewMouseLeftButtonDown="List_PreviewMouseLeftButtonDown"
|
PreviewMouseLeftButtonDown="List_PreviewMouseLeftButtonDown"
|
||||||
PreviewMouseMove="List_PreviewMouseMove"
|
PreviewMouseMove="List_PreviewMouseMove"
|
||||||
PreviewMouseLeftButtonUp="List_PreviewMouseLeftButtonUp"
|
PreviewMouseLeftButtonUp="List_PreviewMouseLeftButtonUp"
|
||||||
@@ -301,12 +303,17 @@
|
|||||||
PreviewMouseMove="Preview_MouseMove"
|
PreviewMouseMove="Preview_MouseMove"
|
||||||
PreviewMouseLeftButtonUp="Preview_MouseLeftButtonUp">
|
PreviewMouseLeftButtonUp="Preview_MouseLeftButtonUp">
|
||||||
<Viewbox Stretch="Uniform">
|
<Viewbox Stretch="Uniform">
|
||||||
<Grid x:Name="CanvasGrid" Width="1920" Height="1080" ClipToBounds="True">
|
<Grid x:Name="CanvasGrid" Width="1920" Height="1080" ClipToBounds="True" Background="Transparent">
|
||||||
|
<Grid.ContextMenu>
|
||||||
|
<ContextMenu>
|
||||||
|
<MenuItem Header="Show Webcam" Command="{Binding ShowWebcamCommand}"/>
|
||||||
|
</ContextMenu>
|
||||||
|
</Grid.ContextMenu>
|
||||||
<Image Source="{Binding ActiveBackgroundImage}" Stretch="UniformToFill"
|
<Image Source="{Binding ActiveBackgroundImage}" Stretch="UniformToFill"
|
||||||
IsHitTestVisible="False"/>
|
IsHitTestVisible="False"/>
|
||||||
<Rectangle Stroke="#22c55e" StrokeThickness="6" IsHitTestVisible="False"/>
|
<Rectangle Stroke="#22c55e" StrokeThickness="6" IsHitTestVisible="False"/>
|
||||||
<Canvas x:Name="OverlayCanvas" Width="1920" Height="1080">
|
<Canvas x:Name="OverlayCanvas" Width="1920" Height="1080">
|
||||||
<ItemsControl ItemsSource="{Binding ActiveScene.Sources}"
|
<ItemsControl ItemsSource="{Binding ActiveScene.Elements}"
|
||||||
Width="1920" Height="1080">
|
Width="1920" Height="1080">
|
||||||
<ItemsControl.ItemsPanel>
|
<ItemsControl.ItemsPanel>
|
||||||
<ItemsPanelTemplate>
|
<ItemsPanelTemplate>
|
||||||
@@ -325,6 +332,16 @@
|
|||||||
Opacity="{Binding Opacity}"
|
Opacity="{Binding Opacity}"
|
||||||
Background="Transparent"
|
Background="Transparent"
|
||||||
RenderTransformOrigin="0.5,0.5">
|
RenderTransformOrigin="0.5,0.5">
|
||||||
|
<Grid.Style>
|
||||||
|
<Style TargetType="Grid">
|
||||||
|
<Setter Property="Visibility" Value="Collapsed"/>
|
||||||
|
<Style.Triggers>
|
||||||
|
<DataTrigger Binding="{Binding IsVisible}" Value="True">
|
||||||
|
<Setter Property="Visibility" Value="Visible"/>
|
||||||
|
</DataTrigger>
|
||||||
|
</Style.Triggers>
|
||||||
|
</Style>
|
||||||
|
</Grid.Style>
|
||||||
<Grid.RenderTransform>
|
<Grid.RenderTransform>
|
||||||
<ScaleTransform ScaleX="{Binding MirrorScale}"/>
|
<ScaleTransform ScaleX="{Binding MirrorScale}"/>
|
||||||
</Grid.RenderTransform>
|
</Grid.RenderTransform>
|
||||||
@@ -335,12 +352,12 @@
|
|||||||
<Style TargetType="Image">
|
<Style TargetType="Image">
|
||||||
<Setter Property="Visibility" Value="Collapsed"/>
|
<Setter Property="Visibility" Value="Collapsed"/>
|
||||||
<Style.Triggers>
|
<Style.Triggers>
|
||||||
<DataTrigger Binding="{Binding Type}" Value="Image">
|
<DataTrigger Binding="{Binding IsImageSource}" Value="True">
|
||||||
<Setter Property="Visibility" Value="Visible"/>
|
<Setter Property="Visibility" Value="Visible"/>
|
||||||
</DataTrigger>
|
</DataTrigger>
|
||||||
<MultiDataTrigger>
|
<MultiDataTrigger>
|
||||||
<MultiDataTrigger.Conditions>
|
<MultiDataTrigger.Conditions>
|
||||||
<Condition Binding="{Binding Type}" Value="Webcam"/>
|
<Condition Binding="{Binding IsWebcam}" Value="True"/>
|
||||||
<Condition Binding="{Binding ClipShape}" Value="Traditional"/>
|
<Condition Binding="{Binding ClipShape}" Value="Traditional"/>
|
||||||
</MultiDataTrigger.Conditions>
|
</MultiDataTrigger.Conditions>
|
||||||
<Setter Property="Visibility" Value="Visible"/>
|
<Setter Property="Visibility" Value="Visible"/>
|
||||||
@@ -358,7 +375,7 @@
|
|||||||
<Style.Triggers>
|
<Style.Triggers>
|
||||||
<MultiDataTrigger>
|
<MultiDataTrigger>
|
||||||
<MultiDataTrigger.Conditions>
|
<MultiDataTrigger.Conditions>
|
||||||
<Condition Binding="{Binding Type}" Value="Webcam"/>
|
<Condition Binding="{Binding IsWebcam}" Value="True"/>
|
||||||
<Condition Binding="{Binding ClipShape}" Value="Round"/>
|
<Condition Binding="{Binding ClipShape}" Value="Round"/>
|
||||||
</MultiDataTrigger.Conditions>
|
</MultiDataTrigger.Conditions>
|
||||||
<Setter Property="Visibility" Value="Visible"/>
|
<Setter Property="Visibility" Value="Visible"/>
|
||||||
@@ -372,14 +389,133 @@
|
|||||||
</Ellipse>
|
</Ellipse>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Viewbox>
|
</Viewbox>
|
||||||
|
|
||||||
|
<!-- Static OBS-style border: stroked rect (Traditional) / centered circle (Round). -->
|
||||||
|
<Rectangle IsHitTestVisible="False"
|
||||||
|
Stroke="{Binding BorderBrush}"
|
||||||
|
StrokeThickness="{Binding BorderWidth}">
|
||||||
|
<Rectangle.Style>
|
||||||
|
<Style TargetType="Rectangle">
|
||||||
|
<Setter Property="Visibility" Value="Collapsed"/>
|
||||||
|
<Style.Triggers>
|
||||||
|
<MultiDataTrigger>
|
||||||
|
<MultiDataTrigger.Conditions>
|
||||||
|
<Condition Binding="{Binding HasBorder}" Value="True"/>
|
||||||
|
<Condition Binding="{Binding ClipShape}" Value="Traditional"/>
|
||||||
|
</MultiDataTrigger.Conditions>
|
||||||
|
<Setter Property="Visibility" Value="Visible"/>
|
||||||
|
</MultiDataTrigger>
|
||||||
|
</Style.Triggers>
|
||||||
|
</Style>
|
||||||
|
</Rectangle.Style>
|
||||||
|
</Rectangle>
|
||||||
|
<Ellipse IsHitTestVisible="False"
|
||||||
|
Stroke="{Binding BorderBrush}"
|
||||||
|
StrokeThickness="{Binding BorderWidth}"
|
||||||
|
Width="{Binding RoundBorderSize}"
|
||||||
|
Height="{Binding RoundBorderSize}"
|
||||||
|
HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||||
|
<Ellipse.Style>
|
||||||
|
<Style TargetType="Ellipse">
|
||||||
|
<Setter Property="Visibility" Value="Collapsed"/>
|
||||||
|
<Style.Triggers>
|
||||||
|
<MultiDataTrigger>
|
||||||
|
<MultiDataTrigger.Conditions>
|
||||||
|
<Condition Binding="{Binding HasBorder}" Value="True"/>
|
||||||
|
<Condition Binding="{Binding ClipShape}" Value="Round"/>
|
||||||
|
</MultiDataTrigger.Conditions>
|
||||||
|
<Setter Property="Visibility" Value="Visible"/>
|
||||||
|
</MultiDataTrigger>
|
||||||
|
</Style.Triggers>
|
||||||
|
</Style>
|
||||||
|
</Ellipse.Style>
|
||||||
|
</Ellipse>
|
||||||
|
|
||||||
|
<Grid.ContextMenu>
|
||||||
|
<ContextMenu DataContext="{Binding PlacementTarget.DataContext, RelativeSource={RelativeSource Self}}">
|
||||||
|
<MenuItem Header="Change Webcam…" Click="WebcamMenu_ChangeWebcam"/>
|
||||||
|
<Separator/>
|
||||||
|
<MenuItem Header="Border Effect">
|
||||||
|
<MenuItem Header="None" Tag="None" IsCheckable="True"
|
||||||
|
IsChecked="{Binding BorderAnimation, Converter={StaticResource EnumToBool}, ConverterParameter=None}"
|
||||||
|
Click="WebcamMenu_SetBorderAnimation"/>
|
||||||
|
<MenuItem Header="Pulse" Tag="Pulse" IsCheckable="True"
|
||||||
|
IsChecked="{Binding BorderAnimation, Converter={StaticResource EnumToBool}, ConverterParameter=Pulse}"
|
||||||
|
Click="WebcamMenu_SetBorderAnimation"/>
|
||||||
|
<MenuItem Header="Chase" Tag="Chase" IsCheckable="True"
|
||||||
|
IsChecked="{Binding BorderAnimation, Converter={StaticResource EnumToBool}, ConverterParameter=Chase}"
|
||||||
|
Click="WebcamMenu_SetBorderAnimation"/>
|
||||||
|
<MenuItem Header="Rainbow" Tag="Rainbow" IsCheckable="True"
|
||||||
|
IsChecked="{Binding BorderAnimation, Converter={StaticResource EnumToBool}, ConverterParameter=Rainbow}"
|
||||||
|
Click="WebcamMenu_SetBorderAnimation"/>
|
||||||
|
<MenuItem Header="Shimmer" Tag="Shimmer" IsCheckable="True"
|
||||||
|
IsChecked="{Binding BorderAnimation, Converter={StaticResource EnumToBool}, ConverterParameter=Shimmer}"
|
||||||
|
Click="WebcamMenu_SetBorderAnimation"/>
|
||||||
|
<MenuItem Header="Marching Ants" Tag="MarchingAnts" IsCheckable="True"
|
||||||
|
IsChecked="{Binding BorderAnimation, Converter={StaticResource EnumToBool}, ConverterParameter=MarchingAnts}"
|
||||||
|
Click="WebcamMenu_SetBorderAnimation"/>
|
||||||
|
<MenuItem Header="Glow" Tag="Glow" IsCheckable="True"
|
||||||
|
IsChecked="{Binding BorderAnimation, Converter={StaticResource EnumToBool}, ConverterParameter=Glow}"
|
||||||
|
Click="WebcamMenu_SetBorderAnimation"/>
|
||||||
|
<MenuItem Header="Electricity" Tag="Electricity" IsCheckable="True"
|
||||||
|
IsChecked="{Binding BorderAnimation, Converter={StaticResource EnumToBool}, ConverterParameter=Electricity}"
|
||||||
|
Click="WebcamMenu_SetBorderAnimation"/>
|
||||||
|
<MenuItem Header="Sparkles" Tag="Sparkles" IsCheckable="True"
|
||||||
|
IsChecked="{Binding BorderAnimation, Converter={StaticResource EnumToBool}, ConverterParameter=Sparkles}"
|
||||||
|
Click="WebcamMenu_SetBorderAnimation"/>
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem Header="Border Color">
|
||||||
|
<MenuItem Header="None" Tag="" IsCheckable="True"
|
||||||
|
IsChecked="{Binding BorderColor, Converter={StaticResource EnumToBool}, ConverterParameter=''}"
|
||||||
|
Click="WebcamMenu_SetBorderColor"/>
|
||||||
|
<MenuItem Header="#ffffff" Tag="#ffffff" IsCheckable="True"
|
||||||
|
IsChecked="{Binding BorderColor, Converter={StaticResource EnumToBool}, ConverterParameter=#ffffff}"
|
||||||
|
Click="WebcamMenu_SetBorderColor"/>
|
||||||
|
<MenuItem Header="#000000" Tag="#000000" IsCheckable="True"
|
||||||
|
IsChecked="{Binding BorderColor, Converter={StaticResource EnumToBool}, ConverterParameter=#000000}"
|
||||||
|
Click="WebcamMenu_SetBorderColor"/>
|
||||||
|
<MenuItem Header="#e94560" Tag="#e94560" IsCheckable="True"
|
||||||
|
IsChecked="{Binding BorderColor, Converter={StaticResource EnumToBool}, ConverterParameter=#e94560}"
|
||||||
|
Click="WebcamMenu_SetBorderColor"/>
|
||||||
|
<MenuItem Header="#22c55e" Tag="#22c55e" IsCheckable="True"
|
||||||
|
IsChecked="{Binding BorderColor, Converter={StaticResource EnumToBool}, ConverterParameter=#22c55e}"
|
||||||
|
Click="WebcamMenu_SetBorderColor"/>
|
||||||
|
<MenuItem Header="#eab308" Tag="#eab308" IsCheckable="True"
|
||||||
|
IsChecked="{Binding BorderColor, Converter={StaticResource EnumToBool}, ConverterParameter=#eab308}"
|
||||||
|
Click="WebcamMenu_SetBorderColor"/>
|
||||||
|
<MenuItem Header="#38bdf8" Tag="#38bdf8" IsCheckable="True"
|
||||||
|
IsChecked="{Binding BorderColor, Converter={StaticResource EnumToBool}, ConverterParameter=#38bdf8}"
|
||||||
|
Click="WebcamMenu_SetBorderColor"/>
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem Header="Border Opacity">
|
||||||
|
<StackPanel Orientation="Horizontal" Margin="8,2">
|
||||||
|
<TextBlock Text="Opacity" Foreground="#e0e0e0" VerticalAlignment="Center"/>
|
||||||
|
<Slider Width="120" Minimum="0" Maximum="1" Margin="8,0,0,0"
|
||||||
|
Value="{Binding BorderOpacity, Mode=TwoWay}"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
</StackPanel>
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem Header="Border Thickness">
|
||||||
|
<StackPanel Orientation="Horizontal" Margin="8,2">
|
||||||
|
<TextBlock Text="Thickness" Foreground="#e0e0e0" VerticalAlignment="Center"/>
|
||||||
|
<Slider Width="120" Minimum="0" Maximum="20" Margin="8,0,0,0"
|
||||||
|
Value="{Binding BorderWidth, Mode=TwoWay}"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
</StackPanel>
|
||||||
|
</MenuItem>
|
||||||
|
<Separator/>
|
||||||
|
<MenuItem Header="Hide in this scene" Click="WebcamMenu_HideInScene"/>
|
||||||
|
<MenuItem Header="Remove" Click="WebcamMenu_Remove"/>
|
||||||
|
</ContextMenu>
|
||||||
|
</Grid.ContextMenu>
|
||||||
</Grid>
|
</Grid>
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
</ItemsControl.ItemTemplate>
|
</ItemsControl.ItemTemplate>
|
||||||
</ItemsControl>
|
</ItemsControl>
|
||||||
|
|
||||||
<Grid x:Name="SelectionOverlay" IsHitTestVisible="False"
|
<Grid x:Name="SelectionOverlay" IsHitTestVisible="False"
|
||||||
Canvas.Left="{Binding SelectedSource.X}" Canvas.Top="{Binding SelectedSource.Y}"
|
Canvas.Left="{Binding SelectedElement.X}" Canvas.Top="{Binding SelectedElement.Y}"
|
||||||
Width="{Binding SelectedSource.Width}" Height="{Binding SelectedSource.Height}">
|
Width="{Binding SelectedElement.Width}" Height="{Binding SelectedElement.Height}">
|
||||||
<Rectangle Stroke="#e94560" StrokeDashArray="4 3" StrokeThickness="3"/>
|
<Rectangle Stroke="#e94560" StrokeDashArray="4 3" StrokeThickness="3"/>
|
||||||
<Ellipse Width="26" Height="26" Fill="#e94560" Stroke="White" StrokeThickness="2"
|
<Ellipse Width="26" Height="26" Fill="#e94560" Stroke="White" StrokeThickness="2"
|
||||||
HorizontalAlignment="Right" VerticalAlignment="Bottom" Margin="0,0,-13,-13"/>
|
HorizontalAlignment="Right" VerticalAlignment="Bottom" Margin="0,0,-13,-13"/>
|
||||||
@@ -475,17 +611,17 @@
|
|||||||
<StackPanel Orientation="Horizontal">
|
<StackPanel Orientation="Horizontal">
|
||||||
<TextBlock Text="Opacity" Foreground="#e0e0e0" FontSize="13" VerticalAlignment="Center"/>
|
<TextBlock Text="Opacity" Foreground="#e0e0e0" FontSize="13" VerticalAlignment="Center"/>
|
||||||
<Slider x:Name="OpacitySlider" Width="120" Minimum="0" Maximum="1" Margin="10,0,0,0"
|
<Slider x:Name="OpacitySlider" Width="120" Minimum="0" Maximum="1" Margin="10,0,0,0"
|
||||||
Value="{Binding SelectedSource.Opacity, Mode=TwoWay}"
|
Value="{Binding SelectedElement.Opacity, Mode=TwoWay}"
|
||||||
ValueChanged="OpacitySlider_ValueChanged" VerticalAlignment="Center"/>
|
ValueChanged="OpacitySlider_ValueChanged" VerticalAlignment="Center"/>
|
||||||
<TextBlock x:Name="OpacityValueText" Text="100%" Foreground="#e0e0e0" FontSize="13"
|
<TextBlock x:Name="OpacityValueText" Text="100%" Foreground="#e0e0e0" FontSize="13"
|
||||||
Width="40" Margin="8,0,0,0" VerticalAlignment="Center"/>
|
Width="40" Margin="8,0,0,0" VerticalAlignment="Center"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<StackPanel Orientation="Horizontal" Margin="0,10,0,0"
|
<StackPanel Orientation="Horizontal" Margin="0,10,0,0"
|
||||||
Visibility="{Binding IsWebcamSelected, Converter={StaticResource BoolToVis}}">
|
Visibility="{Binding IsWebcamSelected, Converter={StaticResource BoolToVis}}">
|
||||||
<Button Content="{Binding SelectedSource.MirrorButtonText}"
|
<Button Content="{Binding SelectedElement.MirrorButtonText}"
|
||||||
Style="{StaticResource YtButtonSecondary}" Padding="10,4"
|
Style="{StaticResource YtButtonSecondary}" Padding="10,4"
|
||||||
Click="MirrorButton_Click"/>
|
Click="MirrorButton_Click"/>
|
||||||
<Button Content="{Binding SelectedSource.ShapeButtonText}"
|
<Button Content="{Binding SelectedElement.ShapeButtonText}"
|
||||||
Style="{StaticResource YtButtonSecondary}" Padding="10,4"
|
Style="{StaticResource YtButtonSecondary}" Padding="10,4"
|
||||||
Margin="8,0,0,0" Click="ShapeButton_Click"/>
|
Margin="8,0,0,0" Click="ShapeButton_Click"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|||||||
+62
-29
@@ -37,7 +37,7 @@ public partial class MainWindow : Window
|
|||||||
{
|
{
|
||||||
if (e.PropertyName == nameof(MainViewModel.IsLive))
|
if (e.PropertyName == nameof(MainViewModel.IsLive))
|
||||||
UpdateTaskbarOverlay();
|
UpdateTaskbarOverlay();
|
||||||
else if (e.PropertyName == nameof(MainViewModel.SelectedSource))
|
else if (e.PropertyName == nameof(MainViewModel.SelectedElement))
|
||||||
UpdateSelectionOverlay();
|
UpdateSelectionOverlay();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,7 +103,7 @@ public partial class MainWindow : Window
|
|||||||
if (IsDescendantOf(original, PreviewGrid)) return;
|
if (IsDescendantOf(original, PreviewGrid)) return;
|
||||||
if (IsDescendantOf(original, SourceList)) return;
|
if (IsDescendantOf(original, SourceList)) return;
|
||||||
if (IsDescendantOf(original, OpacityChip)) return;
|
if (IsDescendantOf(original, OpacityChip)) return;
|
||||||
_viewModel.SelectedSource = null;
|
_viewModel.SelectedElement = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool IsDescendantOf(DependencyObject? child, DependencyObject ancestor)
|
private static bool IsDescendantOf(DependencyObject? child, DependencyObject ancestor)
|
||||||
@@ -115,27 +115,55 @@ public partial class MainWindow : Window
|
|||||||
|
|
||||||
private void UpdateSelectionOverlay()
|
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;
|
SelectionOverlay.Visibility = selected ? Visibility.Visible : Visibility.Collapsed;
|
||||||
OpacityChip.Visibility = selected ? Visibility.Visible : Visibility.Collapsed;
|
OpacityChip.Visibility = selected ? Visibility.Visible : Visibility.Collapsed;
|
||||||
if (selected)
|
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.
|
// Static images and the webcam share the move/resize/selection behavior.
|
||||||
private static bool IsDraggableSource(Source source)
|
private static bool IsDraggableElement(SceneElement element)
|
||||||
=> source.Type is SourceType.Image or SourceType.Webcam;
|
=> element is Source { Type: SourceType.Image } or WebcamSceneConfig;
|
||||||
|
|
||||||
private void MirrorButton_Click(object sender, RoutedEventArgs e)
|
private void MirrorButton_Click(object sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
if (_viewModel.SelectedSource is { Type: SourceType.Webcam } source)
|
if (_viewModel.SelectedElement is WebcamSceneConfig webcam)
|
||||||
source.IsMirrored = !source.IsMirrored;
|
webcam.IsMirrored = !webcam.IsMirrored;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ShapeButton_Click(object sender, RoutedEventArgs e)
|
private void ShapeButton_Click(object sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
if (_viewModel.SelectedSource is { Type: SourceType.Webcam } source)
|
if (_viewModel.SelectedElement is WebcamSceneConfig webcam)
|
||||||
source.ClipShape = source.ClipShape == ClipShape.Traditional ? ClipShape.Round : ClipShape.Traditional;
|
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)
|
private void OpacitySlider_ValueChanged(object sender, RoutedPropertyChangedEventArgs<double> e)
|
||||||
@@ -148,9 +176,9 @@ public partial class MainWindow : Window
|
|||||||
var grid = (Grid)sender;
|
var grid = (Grid)sender;
|
||||||
var toCanvas = CanvasGrid.TransformToVisual(grid).Inverse;
|
var toCanvas = CanvasGrid.TransformToVisual(grid).Inverse;
|
||||||
var p = toCanvas.Transform(e.GetPosition(grid));
|
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;
|
_isResizing = true;
|
||||||
_resizeAspect = sel.ClipShape == ClipShape.Round ? 1 : sel.Width / Math.Max(1, sel.Height);
|
_resizeAspect = sel.ClipShape == ClipShape.Round ? 1 : sel.Width / Math.Max(1, sel.Height);
|
||||||
@@ -159,10 +187,10 @@ public partial class MainWindow : Window
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var hit = HitImage(p);
|
var hit = HitElement(p);
|
||||||
if (hit != null)
|
if (hit != null)
|
||||||
{
|
{
|
||||||
_viewModel.SelectedSource = hit;
|
_viewModel.SelectedElement = hit;
|
||||||
_isDraggingOverlay = true;
|
_isDraggingOverlay = true;
|
||||||
_grabOffset = new Point(p.X - hit.X, p.Y - hit.Y);
|
_grabOffset = new Point(p.X - hit.X, p.Y - hit.Y);
|
||||||
grid.CaptureMouse();
|
grid.CaptureMouse();
|
||||||
@@ -170,7 +198,7 @@ public partial class MainWindow : Window
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_viewModel.SelectedSource = null;
|
_viewModel.SelectedElement = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void Preview_MouseMove(object sender, MouseEventArgs e)
|
private void Preview_MouseMove(object sender, MouseEventArgs e)
|
||||||
@@ -180,7 +208,7 @@ public partial class MainWindow : Window
|
|||||||
var grid = (Grid)sender;
|
var grid = (Grid)sender;
|
||||||
var toCanvas = CanvasGrid.TransformToVisual(grid).Inverse;
|
var toCanvas = CanvasGrid.TransformToVisual(grid).Inverse;
|
||||||
var p = toCanvas.Transform(e.GetPosition(grid));
|
var p = toCanvas.Transform(e.GetPosition(grid));
|
||||||
var selected = _viewModel.SelectedSource;
|
var selected = _viewModel.SelectedElement;
|
||||||
if (selected == null)
|
if (selected == null)
|
||||||
{
|
{
|
||||||
EndPreviewDrag(grid);
|
EndPreviewDrag(grid);
|
||||||
@@ -198,11 +226,14 @@ public partial class MainWindow : Window
|
|||||||
}
|
}
|
||||||
else if (_isResizing)
|
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;
|
var newH = newW / _resizeAspect;
|
||||||
if (newH > 1080)
|
if (newH > maxH)
|
||||||
{
|
{
|
||||||
newH = 1080;
|
newH = maxH;
|
||||||
newW = newH * _resizeAspect;
|
newW = newH * _resizeAspect;
|
||||||
}
|
}
|
||||||
selected.Width = newW;
|
selected.Width = newW;
|
||||||
@@ -221,23 +252,25 @@ public partial class MainWindow : Window
|
|||||||
_isResizing = false;
|
_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;
|
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;
|
var scene = _viewModel.ActiveScene;
|
||||||
if (scene == null) return null;
|
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];
|
var element = scene.Elements[i];
|
||||||
if (!IsDraggableSource(source) || !source.IsEnabled) continue;
|
if (!IsDraggableElement(element)) continue;
|
||||||
if (p.X >= source.X && p.X <= source.X + source.Width &&
|
if (element is Source { IsEnabled: false }) continue;
|
||||||
p.Y >= source.Y && p.Y <= source.Y + source.Height)
|
if (element is WebcamSceneConfig { IsVisible: false }) continue;
|
||||||
return source;
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -275,7 +308,7 @@ public partial class MainWindow : Window
|
|||||||
if (item == null)
|
if (item == null)
|
||||||
{
|
{
|
||||||
if (ReferenceEquals(listBox, SourceList))
|
if (ReferenceEquals(listBox, SourceList))
|
||||||
_viewModel.SelectedSource = null;
|
_viewModel.SelectedElement = null;
|
||||||
_dragIndex = -1;
|
_dragIndex = -1;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-1
@@ -30,7 +30,15 @@ public class Scene : INotifyPropertyChanged
|
|||||||
set => Set(ref _isHidden, value);
|
set => Set(ref _isHidden, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
public ObservableCollection<Source> Sources { get; } = new();
|
/// <summary>
|
||||||
|
/// The scene's rendered elements in z-order (back to front): multi-instance
|
||||||
|
/// Sources plus this scene's webcam usage (<see cref="WebcamConfig"/>), if any.
|
||||||
|
/// </summary>
|
||||||
|
public ObservableCollection<SceneElement> Elements { get; } = new();
|
||||||
|
|
||||||
|
/// <summary>This scene's webcam usage, or null if the creator hasn't added the webcam here.</summary>
|
||||||
|
public WebcamSceneConfig? WebcamConfig => Elements.OfType<WebcamSceneConfig>().FirstOrDefault();
|
||||||
|
|
||||||
public bool IsChatScene { get; init; }
|
public bool IsChatScene { get; init; }
|
||||||
|
|
||||||
public event PropertyChangedEventHandler? PropertyChanged;
|
public event PropertyChangedEventHandler? PropertyChanged;
|
||||||
|
|||||||
@@ -0,0 +1,239 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using System.Windows.Media;
|
||||||
|
using System.Windows.Media.Imaging;
|
||||||
|
|
||||||
|
namespace ytLive.Models;
|
||||||
|
|
||||||
|
public enum BorderAnimation
|
||||||
|
{
|
||||||
|
None,
|
||||||
|
Pulse,
|
||||||
|
Chase,
|
||||||
|
Rainbow,
|
||||||
|
Shimmer,
|
||||||
|
MarchingAnts,
|
||||||
|
Glow,
|
||||||
|
Electricity,
|
||||||
|
Sparkles
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A thing rendered in a scene: a multi-instance Source (image/background/text)
|
||||||
|
/// or a singleton resource's per-scene config (webcam). Carries the shared
|
||||||
|
/// layout + clip/mirror + border surface the preview pipeline renders from.
|
||||||
|
/// </summary>
|
||||||
|
public abstract class SceneElement : INotifyPropertyChanged
|
||||||
|
{
|
||||||
|
public event PropertyChangedEventHandler? PropertyChanged;
|
||||||
|
|
||||||
|
protected void Raise([CallerMemberName] string? name = null)
|
||||||
|
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
|
||||||
|
|
||||||
|
protected bool Set<T>(ref T field, T value, [CallerMemberName] string? name = null)
|
||||||
|
{
|
||||||
|
if (EqualityComparer<T>.Default.Equals(field, value)) return false;
|
||||||
|
field = value;
|
||||||
|
Raise(name);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Id { get; init; } = Guid.NewGuid().ToString();
|
||||||
|
|
||||||
|
private string _name = string.Empty;
|
||||||
|
public string Name { get => _name; set => Set(ref _name, value); }
|
||||||
|
|
||||||
|
/// <summary>True only for singleton resources (webcam). Gates webcam-only UI.</summary>
|
||||||
|
public virtual bool IsWebcam => false;
|
||||||
|
|
||||||
|
/// <summary>True only for image sources — the raw preview renderer's discriminator.</summary>
|
||||||
|
public virtual bool IsImageSource => false;
|
||||||
|
|
||||||
|
private bool _isVisible = true;
|
||||||
|
public bool IsVisible { get => _isVisible; set => Set(ref _isVisible, value); }
|
||||||
|
|
||||||
|
private ClipShape _clipShape = ClipShape.Traditional;
|
||||||
|
public ClipShape ClipShape
|
||||||
|
{
|
||||||
|
get => _clipShape;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (Set(ref _clipShape, value))
|
||||||
|
Raise(nameof(ShapeButtonText));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The rectangular dimensions before a Round toggle, so switching back restores
|
||||||
|
// the aspect instead of staying locked to the square Round used. Persisted so
|
||||||
|
// the restore survives a reload of a Round element.
|
||||||
|
private double? _rectWidth;
|
||||||
|
private double? _rectHeight;
|
||||||
|
|
||||||
|
public double? RectWidth { get => _rectWidth; set => Set(ref _rectWidth, value); }
|
||||||
|
public double? RectHeight { get => _rectHeight; set => Set(ref _rectHeight, value); }
|
||||||
|
|
||||||
|
public void ToggleClipShape()
|
||||||
|
{
|
||||||
|
if (ClipShape == ClipShape.Traditional)
|
||||||
|
{
|
||||||
|
RectWidth = Width;
|
||||||
|
RectHeight = Height;
|
||||||
|
ClipShape = ClipShape.Round;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ClipShape = ClipShape.Traditional;
|
||||||
|
if (RectWidth is { } rw && RectHeight is { } rh)
|
||||||
|
{
|
||||||
|
Width = rw;
|
||||||
|
Height = rh;
|
||||||
|
RectWidth = null;
|
||||||
|
RectHeight = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool _isMirrored;
|
||||||
|
public bool IsMirrored
|
||||||
|
{
|
||||||
|
get => _isMirrored;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (Set(ref _isMirrored, value))
|
||||||
|
{
|
||||||
|
Raise(nameof(MirrorScale));
|
||||||
|
Raise(nameof(MirrorButtonText));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public double MirrorScale => IsMirrored ? -1 : 1;
|
||||||
|
public string MirrorButtonText => IsMirrored ? "Unmirror" : "Mirror";
|
||||||
|
public string ShapeButtonText => ClipShape == ClipShape.Round ? "Rect" : "Round";
|
||||||
|
|
||||||
|
// Live camera frames: the shared WriteableBitmap owned by CameraManager.
|
||||||
|
private WriteableBitmap? _videoImageSource;
|
||||||
|
public WriteableBitmap? VideoImageSource
|
||||||
|
{
|
||||||
|
get => _videoImageSource;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (Set(ref _videoImageSource, value))
|
||||||
|
Raise(nameof(DisplaySource));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>What the preview shows: live frames (webcam) or a static image (sources).</summary>
|
||||||
|
public abstract ImageSource? DisplaySource { get; }
|
||||||
|
|
||||||
|
// Position/transform (per-scene usage)
|
||||||
|
private double _x;
|
||||||
|
public double X { get => _x; set => Set(ref _x, value); }
|
||||||
|
|
||||||
|
private double _y;
|
||||||
|
public double Y { get => _y; set => Set(ref _y, value); }
|
||||||
|
|
||||||
|
private double _width;
|
||||||
|
public double Width
|
||||||
|
{
|
||||||
|
get => _width;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (Set(ref _width, value))
|
||||||
|
Raise(nameof(RoundBorderSize));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private double _height;
|
||||||
|
public double Height
|
||||||
|
{
|
||||||
|
get => _height;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (Set(ref _height, value))
|
||||||
|
Raise(nameof(RoundBorderSize));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private double _opacity = 1.0;
|
||||||
|
public double Opacity { get => _opacity; set => Set(ref _opacity, value); }
|
||||||
|
|
||||||
|
/// <summary>Round clip/border diameter = the shorter dimension (true circle).</summary>
|
||||||
|
public double RoundBorderSize => Math.Min(Width, Height);
|
||||||
|
|
||||||
|
// Border (OBS-style): #RRGGBB color, alpha opacity, pixel width. Off by default.
|
||||||
|
private string _borderColor = string.Empty;
|
||||||
|
public string BorderColor
|
||||||
|
{
|
||||||
|
get => _borderColor;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (Set(ref _borderColor, value))
|
||||||
|
{
|
||||||
|
Raise(nameof(HasBorder));
|
||||||
|
Raise(nameof(BorderBrush));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private double _borderOpacity = 1.0;
|
||||||
|
public double BorderOpacity
|
||||||
|
{
|
||||||
|
get => _borderOpacity;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (Set(ref _borderOpacity, value))
|
||||||
|
{
|
||||||
|
Raise(nameof(HasBorder));
|
||||||
|
Raise(nameof(BorderBrush));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private int _borderWidth;
|
||||||
|
public int BorderWidth
|
||||||
|
{
|
||||||
|
get => _borderWidth;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (Set(ref _borderWidth, value))
|
||||||
|
{
|
||||||
|
Raise(nameof(HasBorder));
|
||||||
|
Raise(nameof(BorderBrush));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private BorderAnimation _borderAnimation = BorderAnimation.None;
|
||||||
|
public BorderAnimation BorderAnimation { get => _borderAnimation; set => Set(ref _borderAnimation, value); }
|
||||||
|
|
||||||
|
public bool HasBorder
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
if (BorderWidth <= 0) return false;
|
||||||
|
return TryGetBorderColor(out _, out _, out _);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Brush? BorderBrush
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
if (!HasBorder || !TryGetBorderColor(out var r, out var g, out var b)) return null;
|
||||||
|
var alpha = (byte)Math.Round(Math.Clamp(BorderOpacity, 0, 1) * 255);
|
||||||
|
return new SolidColorBrush(Color.FromArgb(alpha, r, g, b));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool TryGetBorderColor(out byte r, out byte g, out byte b)
|
||||||
|
{
|
||||||
|
r = g = b = 0;
|
||||||
|
var hex = BorderColor.Trim().TrimStart('#');
|
||||||
|
if (hex.Length != 6) return false;
|
||||||
|
return byte.TryParse(hex.AsSpan(0, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out r)
|
||||||
|
&& byte.TryParse(hex.AsSpan(2, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out g)
|
||||||
|
&& byte.TryParse(hex.AsSpan(4, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out b);
|
||||||
|
}
|
||||||
|
}
|
||||||
+9
-89
@@ -1,7 +1,4 @@
|
|||||||
using System.ComponentModel;
|
|
||||||
using System.Runtime.CompilerServices;
|
|
||||||
using System.Windows.Media;
|
using System.Windows.Media;
|
||||||
using System.Windows.Media.Imaging;
|
|
||||||
using ytLive.Helpers;
|
using ytLive.Helpers;
|
||||||
|
|
||||||
namespace ytLive.Models;
|
namespace ytLive.Models;
|
||||||
@@ -10,7 +7,6 @@ public enum SourceType
|
|||||||
{
|
{
|
||||||
DisplayCapture,
|
DisplayCapture,
|
||||||
WindowCapture,
|
WindowCapture,
|
||||||
Webcam,
|
|
||||||
Background,
|
Background,
|
||||||
Image,
|
Image,
|
||||||
TextOverlay
|
TextOverlay
|
||||||
@@ -22,26 +18,14 @@ public enum ClipShape
|
|||||||
Round
|
Round
|
||||||
}
|
}
|
||||||
|
|
||||||
public class Source : INotifyPropertyChanged
|
/// <summary>
|
||||||
|
/// A multi-instance scene object: image/background/text (screen/window later).
|
||||||
|
/// The webcam is NOT a Source — it's a singleton resource whose per-scene usage
|
||||||
|
/// is a <see cref="WebcamSceneConfig"/>. See the singleton-vs-multi-instance
|
||||||
|
/// discriminator in ai.md.
|
||||||
|
/// </summary>
|
||||||
|
public class Source : SceneElement
|
||||||
{
|
{
|
||||||
public event PropertyChangedEventHandler? PropertyChanged;
|
|
||||||
|
|
||||||
private void Raise([CallerMemberName] string? name = null)
|
|
||||||
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
|
|
||||||
|
|
||||||
private bool Set<T>(ref T field, T value, [CallerMemberName] string? name = null)
|
|
||||||
{
|
|
||||||
if (EqualityComparer<T>.Default.Equals(field, value)) return false;
|
|
||||||
field = value;
|
|
||||||
Raise(name);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
public string Id { get; init; } = Guid.NewGuid().ToString();
|
|
||||||
|
|
||||||
private string _name = string.Empty;
|
|
||||||
public string Name { get => _name; set => Set(ref _name, value); }
|
|
||||||
|
|
||||||
private SourceType _type;
|
private SourceType _type;
|
||||||
public SourceType Type { get => _type; set => Set(ref _type, value); }
|
public SourceType Type { get => _type; set => Set(ref _type, value); }
|
||||||
|
|
||||||
@@ -55,58 +39,6 @@ public class Source : INotifyPropertyChanged
|
|||||||
private IntPtr? _windowHandle;
|
private IntPtr? _windowHandle;
|
||||||
public IntPtr? WindowHandle { get => _windowHandle; set => Set(ref _windowHandle, value); }
|
public IntPtr? WindowHandle { get => _windowHandle; set => Set(ref _windowHandle, value); }
|
||||||
|
|
||||||
// Webcam
|
|
||||||
private string? _deviceId;
|
|
||||||
public string? DeviceId { get => _deviceId; set => Set(ref _deviceId, value); }
|
|
||||||
|
|
||||||
// Webcam live preview: the shared WriteableBitmap owned by CameraManager.
|
|
||||||
private WriteableBitmap? _videoImageSource;
|
|
||||||
public WriteableBitmap? VideoImageSource
|
|
||||||
{
|
|
||||||
get => _videoImageSource;
|
|
||||||
set
|
|
||||||
{
|
|
||||||
if (Set(ref _videoImageSource, value))
|
|
||||||
Raise(nameof(DisplaySource));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private ClipShape _clipShape = ClipShape.Traditional;
|
|
||||||
public ClipShape ClipShape
|
|
||||||
{
|
|
||||||
get => _clipShape;
|
|
||||||
set
|
|
||||||
{
|
|
||||||
if (Set(ref _clipShape, value))
|
|
||||||
Raise(nameof(ShapeButtonText));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private bool _isMirrored;
|
|
||||||
public bool IsMirrored
|
|
||||||
{
|
|
||||||
get => _isMirrored;
|
|
||||||
set
|
|
||||||
{
|
|
||||||
if (Set(ref _isMirrored, value))
|
|
||||||
{
|
|
||||||
Raise(nameof(MirrorScale));
|
|
||||||
Raise(nameof(MirrorButtonText));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public double MirrorScale => IsMirrored ? -1 : 1;
|
|
||||||
|
|
||||||
/// <summary>Mirror toggle label (mirrored → "Unmirror").</summary>
|
|
||||||
public string MirrorButtonText => IsMirrored ? "Unmirror" : "Mirror";
|
|
||||||
|
|
||||||
/// <summary>Clip-shape toggle label (round → "Rect").</summary>
|
|
||||||
public string ShapeButtonText => ClipShape == ClipShape.Round ? "Rect" : "Round";
|
|
||||||
|
|
||||||
/// <summary>What the preview shows: static image for image/background, live frames for webcam.</summary>
|
|
||||||
public ImageSource? DisplaySource => Type == SourceType.Webcam ? _videoImageSource : _imageSource;
|
|
||||||
|
|
||||||
// Image (asset stored in the layout database)
|
// Image (asset stored in the layout database)
|
||||||
private string? _assetId;
|
private string? _assetId;
|
||||||
private ImageSource? _imageSource;
|
private ImageSource? _imageSource;
|
||||||
@@ -127,19 +59,7 @@ public class Source : INotifyPropertyChanged
|
|||||||
|
|
||||||
public ImageSource? ImageSource => _imageSource;
|
public ImageSource? ImageSource => _imageSource;
|
||||||
|
|
||||||
// Position/transform (per-scene usage)
|
public override ImageSource? DisplaySource => _imageSource;
|
||||||
private double _x;
|
|
||||||
public double X { get => _x; set => Set(ref _x, value); }
|
|
||||||
|
|
||||||
private double _y;
|
public override bool IsImageSource => Type == SourceType.Image;
|
||||||
public double Y { get => _y; set => Set(ref _y, value); }
|
|
||||||
|
|
||||||
private double _width;
|
|
||||||
public double Width { get => _width; set => Set(ref _width, value); }
|
|
||||||
|
|
||||||
private double _height;
|
|
||||||
public double Height { get => _height; set => Set(ref _height, value); }
|
|
||||||
|
|
||||||
private double _opacity = 1.0;
|
|
||||||
public double Opacity { get => _opacity; set => Set(ref _opacity, value); }
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
namespace ytLive.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The app-wide webcam identity — one camera input (the limit is the hardware,
|
||||||
|
/// not us). Scenes reference it via <see cref="WebcamSceneConfig"/>; the DeviceId
|
||||||
|
/// lives only here, so the camera is a Windows-controlled singleton.
|
||||||
|
/// </summary>
|
||||||
|
public class Webcam
|
||||||
|
{
|
||||||
|
public string Id { get; init; } = Guid.NewGuid().ToString();
|
||||||
|
public string DeviceId { get; set; } = string.Empty;
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
using System.Windows.Media;
|
||||||
|
|
||||||
|
namespace ytLive.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A scene's usage of the app-wide webcam — conceptually `webcam.{scene}.config`.
|
||||||
|
/// The identity lives once on the <see cref="Webcam"/> entity; this object is that
|
||||||
|
/// scene's placement, clip/mirror, border, and visibility for it. Exists only in
|
||||||
|
/// scenes where the creator added the webcam.
|
||||||
|
/// </summary>
|
||||||
|
public class WebcamSceneConfig : SceneElement
|
||||||
|
{
|
||||||
|
public string WebcamId { get; init; } = string.Empty;
|
||||||
|
public override bool IsWebcam => true;
|
||||||
|
public override ImageSource? DisplaySource => VideoImageSource;
|
||||||
|
}
|
||||||
+5
-2
@@ -5,8 +5,11 @@ Plain data types. No logic beyond what a property can carry. See
|
|||||||
|
|
||||||
| File | Purpose |
|
| File | Purpose |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| `Scene.cs` | A scene: `Name`, `IsHidden`, `IsChatScene`, `IsEditing`, `Sources` collection |
|
| `Scene.cs` | A scene: `Name`, `IsHidden`, `IsChatScene`, `IsEditing`, `Elements` collection (images + webcam config), `WebcamConfig` accessor |
|
||||||
| `Source.cs` | A source: `SourceType` enum (Image/Webcam/Screen/Background/Text), `X`/`Y`/`Width`/`Height`, `Opacity`, `IsEnabled`, `ImageSource` (static) / `VideoImageSource` (webcam live frames), `DisplaySource` (whichever the preview shows), `ClipShape` enum (Traditional/Round), `IsMirrored` + `MirrorScale`, `MirrorButtonText`/`ShapeButtonText` toggle labels, asset identity, webcam `DeviceId` |
|
| `Source.cs` | An image source: `SourceType` enum (Image/Screen/Background/Text — **Webcam removed**), `X`/`Y`/`Width`/`Height`, `Opacity`, `IsEnabled`, `ImageSource` (static) / `VideoImageSource` (live frames), `DisplaySource`, `ClipShape` enum (Traditional/Round), `IsMirrored` + `MirrorScale`, `MirrorButtonText`/`ShapeButtonText`, asset identity, `IsImageSource` (XAML binds this, never `Type`) |
|
||||||
|
| `SceneElement.cs` | Base for anything placeable in a scene: shared layout/clip/mirror/border surface, `virtual IsWebcam`/`virtual IsImageSource`; `ToggleClipShape` + persisted `RectWidth`/`RectHeight` (pre-Round rect so round→rect restores after reload) |
|
||||||
|
| `Webcam.cs` | Singleton webcam identity: `Id`, `DeviceId`, `Name` (one row app-wide) |
|
||||||
|
| `WebcamSceneConfig.cs` | Per-scene webcam placement (subclass of `SceneElement`): geometry + `IsVisible` + border (`BorderColor`/`BorderOpacity`/`BorderWidth`/`BorderAnimation`) + `VideoImageSource`; `WebcamId` links to `Webcam` |
|
||||||
| `QualityOption.cs` | Resolution tier record `(Display, Fps, Bitrate, Width, Height)`; `Label` formats as `1080p60 (8Mbps)` — drives the bottom-bar dropdown and the output-rect math |
|
| `QualityOption.cs` | Resolution tier record `(Display, Fps, Bitrate, Width, Height)`; `Label` formats as `1080p60 (8Mbps)` — drives the bottom-bar dropdown and the output-rect math |
|
||||||
| `StreamConfig.cs` | `StreamConfig` (note: `TargetBitrate`/`Resolution` defaults are stale — not yet wired to the dropdown), `StreamHealth` (bitrate/FPS/dropped/duration), `StreamStatus` enum |
|
| `StreamConfig.cs` | `StreamConfig` (note: `TargetBitrate`/`Resolution` defaults are stale — not yet wired to the dropdown), `StreamHealth` (bitrate/FPS/dropped/duration), `StreamStatus` enum |
|
||||||
| `YouTube.cs` | `YouTubeChannel` and `ChatMessage` (chat feed) |
|
| `YouTube.cs` | `YouTubeChannel` and `ChatMessage` (chat feed) |
|
||||||
|
|||||||
@@ -122,6 +122,28 @@ public sealed class CameraManager : IDisposable
|
|||||||
toStop.PreviewBitmap = null;
|
toStop.PreviewBitmap = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Releases a device unconditionally (every ref), regardless of how many scenes
|
||||||
|
/// hold it. Used on identity changes (webcam swap, layout reload) where the old
|
||||||
|
/// device's refcount isn't known after the scenes are replaced.
|
||||||
|
/// </summary>
|
||||||
|
public async Task ReleaseAllAsync(string deviceId)
|
||||||
|
{
|
||||||
|
CameraSession? toStop = null;
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (!_sessions.TryGetValue(deviceId, out var session)) return;
|
||||||
|
session.RefCount = 0;
|
||||||
|
_sessions.Remove(deviceId);
|
||||||
|
toStop = session;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toStop == null) return;
|
||||||
|
toStop.Source.FrameAvailable -= toStop.FrameHandler;
|
||||||
|
await SafeStopAsync(toStop.Source);
|
||||||
|
toStop.PreviewBitmap = null;
|
||||||
|
}
|
||||||
|
|
||||||
public VideoFrame? GetLatestFrame(string deviceId)
|
public VideoFrame? GetLatestFrame(string deviceId)
|
||||||
{
|
{
|
||||||
lock (_gate)
|
lock (_gate)
|
||||||
|
|||||||
+302
-14
@@ -12,6 +12,9 @@ public class LayoutStore : IDisposable
|
|||||||
private readonly SqliteConnection _connection;
|
private readonly SqliteConnection _connection;
|
||||||
public string ActivePath { get; }
|
public string ActivePath { get; }
|
||||||
|
|
||||||
|
/// <summary>The app-wide webcam identity loaded with the last Load() (null = never picked).</summary>
|
||||||
|
public Webcam? Webcam { get; private set; }
|
||||||
|
|
||||||
public LayoutStore(string path)
|
public LayoutStore(string path)
|
||||||
{
|
{
|
||||||
ActivePath = path;
|
ActivePath = path;
|
||||||
@@ -31,7 +34,6 @@ public class LayoutStore : IDisposable
|
|||||||
{
|
{
|
||||||
string[] statements =
|
string[] statements =
|
||||||
{
|
{
|
||||||
"PRAGMA user_version = 2;",
|
|
||||||
"""
|
"""
|
||||||
CREATE TABLE IF NOT EXISTS Scene (
|
CREATE TABLE IF NOT EXISTS Scene (
|
||||||
Id TEXT PRIMARY KEY,
|
Id TEXT PRIMARY KEY,
|
||||||
@@ -70,6 +72,35 @@ public class LayoutStore : IDisposable
|
|||||||
SortOrder INTEGER NOT NULL DEFAULT 0
|
SortOrder INTEGER NOT NULL DEFAULT 0
|
||||||
);
|
);
|
||||||
""",
|
""",
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS Webcam (
|
||||||
|
Id TEXT PRIMARY KEY,
|
||||||
|
DeviceId TEXT NOT NULL UNIQUE,
|
||||||
|
Name TEXT NOT NULL
|
||||||
|
);
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS WebcamSceneConfig (
|
||||||
|
SceneId TEXT NOT NULL REFERENCES Scene(Id) ON DELETE CASCADE,
|
||||||
|
WebcamId TEXT NOT NULL REFERENCES Webcam(Id) ON DELETE CASCADE,
|
||||||
|
IsVisible INTEGER NOT NULL DEFAULT 1,
|
||||||
|
X REAL NOT NULL DEFAULT 0,
|
||||||
|
Y REAL NOT NULL DEFAULT 0,
|
||||||
|
Width REAL NOT NULL DEFAULT 0,
|
||||||
|
Height REAL NOT NULL DEFAULT 0,
|
||||||
|
Opacity REAL NOT NULL DEFAULT 1,
|
||||||
|
ClipShape TEXT NOT NULL DEFAULT 'Traditional',
|
||||||
|
IsMirrored INTEGER NOT NULL DEFAULT 0,
|
||||||
|
RectWidth REAL,
|
||||||
|
RectHeight REAL,
|
||||||
|
BorderColor TEXT,
|
||||||
|
BorderOpacity REAL NOT NULL DEFAULT 1,
|
||||||
|
BorderWidth INTEGER NOT NULL DEFAULT 0,
|
||||||
|
BorderAnimation TEXT NOT NULL DEFAULT 'None',
|
||||||
|
SortOrder INTEGER NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (SceneId, WebcamId)
|
||||||
|
);
|
||||||
|
""",
|
||||||
};
|
};
|
||||||
foreach (var sql in statements)
|
foreach (var sql in statements)
|
||||||
{
|
{
|
||||||
@@ -78,6 +109,21 @@ public class LayoutStore : IDisposable
|
|||||||
cmd.ExecuteNonQuery();
|
cmd.ExecuteNonQuery();
|
||||||
}
|
}
|
||||||
MigrateSourceTable();
|
MigrateSourceTable();
|
||||||
|
MigrateWebcamConfigTable();
|
||||||
|
if (GetUserVersion() < 3)
|
||||||
|
MigrateToV3();
|
||||||
|
using (var cmd = _connection.CreateCommand())
|
||||||
|
{
|
||||||
|
cmd.CommandText = "PRAGMA user_version = 4;";
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private int GetUserVersion()
|
||||||
|
{
|
||||||
|
using var cmd = _connection.CreateCommand();
|
||||||
|
cmd.CommandText = "PRAGMA user_version;";
|
||||||
|
return Convert.ToInt32(cmd.ExecuteScalar());
|
||||||
}
|
}
|
||||||
|
|
||||||
// v1 → v2: Source gains ClipShape + IsMirrored (webcam clip/mirror prefs).
|
// v1 → v2: Source gains ClipShape + IsMirrored (webcam clip/mirror prefs).
|
||||||
@@ -109,10 +155,117 @@ public class LayoutStore : IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v3 → v4: WebcamSceneConfig gains RectWidth/RectHeight (the pre-Round rect,
|
||||||
|
// so a reloaded Round webcam restores its aspect on toggle-back).
|
||||||
|
private void MigrateWebcamConfigTable()
|
||||||
|
{
|
||||||
|
var columns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
using (var cmd = _connection.CreateCommand())
|
||||||
|
{
|
||||||
|
cmd.CommandText = "PRAGMA table_info(WebcamSceneConfig);";
|
||||||
|
using var reader = cmd.ExecuteReader();
|
||||||
|
while (reader.Read())
|
||||||
|
columns.Add(reader.GetString(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!columns.Contains("RectWidth"))
|
||||||
|
{
|
||||||
|
using var cmd = _connection.CreateCommand();
|
||||||
|
cmd.CommandText = "ALTER TABLE WebcamSceneConfig ADD COLUMN RectWidth REAL;";
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!columns.Contains("RectHeight"))
|
||||||
|
{
|
||||||
|
using var cmd = _connection.CreateCommand();
|
||||||
|
cmd.CommandText = "ALTER TABLE WebcamSceneConfig ADD COLUMN RectHeight REAL;";
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// v2 → v3: the webcam leaves the Source table. Any webcam Source rows become
|
||||||
|
// one Webcam identity (first row — one camera app-wide) + a WebcamSceneConfig
|
||||||
|
// per scene that had one, then the webcam rows are deleted. No backfill:
|
||||||
|
// scenes without the webcam stay webcam-free.
|
||||||
|
private void MigrateToV3()
|
||||||
|
{
|
||||||
|
var rows = new List<(string Id, string SceneId, string Name, double X, double Y, double W, double H, double Opacity, string DeviceId, string ClipShape, bool IsMirrored)>();
|
||||||
|
using (var cmd = _connection.CreateCommand())
|
||||||
|
{
|
||||||
|
cmd.CommandText = """
|
||||||
|
SELECT Id, SceneId, Name, X, Y, Width, Height, Opacity, DeviceId, ClipShape, IsMirrored
|
||||||
|
FROM Source WHERE Type = 'Webcam' ORDER BY SortOrder;
|
||||||
|
""";
|
||||||
|
using var reader = cmd.ExecuteReader();
|
||||||
|
while (reader.Read())
|
||||||
|
{
|
||||||
|
rows.Add((
|
||||||
|
reader.GetString(0),
|
||||||
|
reader.GetString(1),
|
||||||
|
reader.GetString(2),
|
||||||
|
reader.GetDouble(3),
|
||||||
|
reader.GetDouble(4),
|
||||||
|
reader.GetDouble(5),
|
||||||
|
reader.GetDouble(6),
|
||||||
|
reader.GetDouble(7),
|
||||||
|
reader.IsDBNull(8) ? string.Empty : reader.GetString(8),
|
||||||
|
reader.GetString(9),
|
||||||
|
reader.GetInt32(10) != 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (rows.Count == 0) return;
|
||||||
|
|
||||||
|
using var tx = _connection.BeginTransaction();
|
||||||
|
var webcamId = Guid.NewGuid().ToString();
|
||||||
|
|
||||||
|
using (var cmd = _connection.CreateCommand())
|
||||||
|
{
|
||||||
|
cmd.CommandText = "INSERT INTO Webcam (Id, DeviceId, Name) VALUES ($id, $device, $name);";
|
||||||
|
cmd.Transaction = tx;
|
||||||
|
cmd.Parameters.AddWithValue("$id", webcamId);
|
||||||
|
cmd.Parameters.AddWithValue("$device", rows[0].DeviceId);
|
||||||
|
cmd.Parameters.AddWithValue("$name", rows[0].Name);
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var row in rows)
|
||||||
|
{
|
||||||
|
using var cmd = _connection.CreateCommand();
|
||||||
|
cmd.CommandText = """
|
||||||
|
INSERT INTO WebcamSceneConfig
|
||||||
|
(SceneId, WebcamId, IsVisible, X, Y, Width, Height, Opacity,
|
||||||
|
ClipShape, IsMirrored, BorderColor, BorderOpacity, BorderWidth, BorderAnimation, SortOrder)
|
||||||
|
VALUES ($sceneId, $webcamId, 1, $x, $y, $w, $h, $opacity,
|
||||||
|
$clip, $mirrored, NULL, 1, 0, 'None', 0);
|
||||||
|
""";
|
||||||
|
cmd.Transaction = tx;
|
||||||
|
cmd.Parameters.AddWithValue("$sceneId", row.SceneId);
|
||||||
|
cmd.Parameters.AddWithValue("$webcamId", webcamId);
|
||||||
|
cmd.Parameters.AddWithValue("$x", row.X);
|
||||||
|
cmd.Parameters.AddWithValue("$y", row.Y);
|
||||||
|
cmd.Parameters.AddWithValue("$w", row.W);
|
||||||
|
cmd.Parameters.AddWithValue("$h", row.H);
|
||||||
|
cmd.Parameters.AddWithValue("$opacity", row.Opacity);
|
||||||
|
cmd.Parameters.AddWithValue("$clip", row.ClipShape);
|
||||||
|
cmd.Parameters.AddWithValue("$mirrored", row.IsMirrored ? 1 : 0);
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
using (var cmd = _connection.CreateCommand())
|
||||||
|
{
|
||||||
|
cmd.CommandText = "DELETE FROM Source WHERE Type = 'Webcam';";
|
||||||
|
cmd.Transaction = tx;
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
tx.Commit();
|
||||||
|
}
|
||||||
|
|
||||||
public List<Scene> Load()
|
public List<Scene> Load()
|
||||||
{
|
{
|
||||||
|
Webcam = null;
|
||||||
var scenes = new List<Scene>();
|
var scenes = new List<Scene>();
|
||||||
var sourcesByScene = new Dictionary<string, List<Source>>();
|
var sourcesByScene = new Dictionary<string, List<Source>>();
|
||||||
|
var configsByScene = new Dictionary<string, List<WebcamSceneConfig>>();
|
||||||
|
|
||||||
using (var cmd = _connection.CreateCommand())
|
using (var cmd = _connection.CreateCommand())
|
||||||
{
|
{
|
||||||
@@ -130,11 +283,26 @@ public class LayoutStore : IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
using (var cmd = _connection.CreateCommand())
|
||||||
|
{
|
||||||
|
cmd.CommandText = "SELECT Id, DeviceId, Name FROM Webcam;";
|
||||||
|
using var reader = cmd.ExecuteReader();
|
||||||
|
if (reader.Read())
|
||||||
|
{
|
||||||
|
Webcam = new Webcam
|
||||||
|
{
|
||||||
|
Id = reader.GetString(0),
|
||||||
|
DeviceId = reader.GetString(1),
|
||||||
|
Name = reader.GetString(2),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
using (var cmd = _connection.CreateCommand())
|
using (var cmd = _connection.CreateCommand())
|
||||||
{
|
{
|
||||||
cmd.CommandText = """
|
cmd.CommandText = """
|
||||||
SELECT Id, SceneId, AssetId, Type, Name, IsEnabled,
|
SELECT Id, SceneId, AssetId, Type, Name, IsEnabled,
|
||||||
X, Y, Width, Height, Opacity, MonitorIndex, DeviceId, ClipShape, IsMirrored
|
X, Y, Width, Height, Opacity, MonitorIndex, ClipShape, IsMirrored
|
||||||
FROM Source ORDER BY SortOrder
|
FROM Source ORDER BY SortOrder
|
||||||
""";
|
""";
|
||||||
using var reader = cmd.ExecuteReader();
|
using var reader = cmd.ExecuteReader();
|
||||||
@@ -154,9 +322,8 @@ public class LayoutStore : IDisposable
|
|||||||
Height = reader.GetDouble(9),
|
Height = reader.GetDouble(9),
|
||||||
Opacity = reader.GetDouble(10),
|
Opacity = reader.GetDouble(10),
|
||||||
MonitorIndex = reader.IsDBNull(11) ? null : reader.GetInt32(11),
|
MonitorIndex = reader.IsDBNull(11) ? null : reader.GetInt32(11),
|
||||||
DeviceId = reader.IsDBNull(12) ? null : reader.GetString(12),
|
ClipShape = Enum.TryParse<ClipShape>(reader.GetString(12), out var clip) ? clip : ClipShape.Traditional,
|
||||||
ClipShape = Enum.TryParse<ClipShape>(reader.GetString(13), out var clip) ? clip : ClipShape.Traditional,
|
IsMirrored = reader.GetInt32(13) != 0,
|
||||||
IsMirrored = reader.GetInt32(14) != 0,
|
|
||||||
};
|
};
|
||||||
if (!sourcesByScene.TryGetValue(sceneId, out var list))
|
if (!sourcesByScene.TryGetValue(sceneId, out var list))
|
||||||
sourcesByScene[sceneId] = list = new List<Source>();
|
sourcesByScene[sceneId] = list = new List<Source>();
|
||||||
@@ -164,26 +331,78 @@ public class LayoutStore : IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
using (var cmd = _connection.CreateCommand())
|
||||||
|
{
|
||||||
|
cmd.CommandText = """
|
||||||
|
SELECT SceneId, WebcamId, IsVisible, X, Y, Width, Height, Opacity,
|
||||||
|
ClipShape, IsMirrored, BorderColor, BorderOpacity, BorderWidth, BorderAnimation,
|
||||||
|
RectWidth, RectHeight
|
||||||
|
FROM WebcamSceneConfig ORDER BY SortOrder
|
||||||
|
""";
|
||||||
|
using var reader = cmd.ExecuteReader();
|
||||||
|
while (reader.Read())
|
||||||
|
{
|
||||||
|
var sceneId = reader.GetString(0);
|
||||||
|
var config = new WebcamSceneConfig
|
||||||
|
{
|
||||||
|
WebcamId = reader.GetString(1),
|
||||||
|
Name = Webcam?.Name ?? "Webcam",
|
||||||
|
IsVisible = reader.GetInt32(2) != 0,
|
||||||
|
X = reader.GetDouble(3),
|
||||||
|
Y = reader.GetDouble(4),
|
||||||
|
Width = reader.GetDouble(5),
|
||||||
|
Height = reader.GetDouble(6),
|
||||||
|
Opacity = reader.GetDouble(7),
|
||||||
|
ClipShape = Enum.TryParse<ClipShape>(reader.GetString(8), out var clip) ? clip : ClipShape.Traditional,
|
||||||
|
IsMirrored = reader.GetInt32(9) != 0,
|
||||||
|
BorderColor = reader.IsDBNull(10) ? string.Empty : reader.GetString(10),
|
||||||
|
BorderOpacity = reader.IsDBNull(11) ? 1.0 : reader.GetDouble(11),
|
||||||
|
BorderWidth = reader.IsDBNull(12) ? 0 : reader.GetInt32(12),
|
||||||
|
BorderAnimation = Enum.TryParse<BorderAnimation>(reader.GetString(13), out var anim) ? anim : BorderAnimation.None,
|
||||||
|
RectWidth = reader.IsDBNull(14) ? null : reader.GetDouble(14),
|
||||||
|
RectHeight = reader.IsDBNull(15) ? null : reader.GetDouble(15),
|
||||||
|
};
|
||||||
|
if (!configsByScene.TryGetValue(sceneId, out var list))
|
||||||
|
configsByScene[sceneId] = list = new List<WebcamSceneConfig>();
|
||||||
|
list.Add(config);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
foreach (var scene in scenes)
|
foreach (var scene in scenes)
|
||||||
{
|
{
|
||||||
if (sourcesByScene.TryGetValue(scene.Id, out var list))
|
if (sourcesByScene.TryGetValue(scene.Id, out var sources))
|
||||||
foreach (var source in list)
|
foreach (var source in sources)
|
||||||
scene.Sources.Add(source);
|
scene.Elements.Add(source);
|
||||||
|
if (configsByScene.TryGetValue(scene.Id, out var configs))
|
||||||
|
foreach (var config in configs)
|
||||||
|
scene.Elements.Add(config);
|
||||||
}
|
}
|
||||||
|
|
||||||
return scenes;
|
return scenes;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Save(IEnumerable<Scene> scenes)
|
public void Save(IEnumerable<Scene> scenes, Webcam? webcam)
|
||||||
{
|
{
|
||||||
using var tx = _connection.BeginTransaction();
|
using var tx = _connection.BeginTransaction();
|
||||||
using (var cmd = _connection.CreateCommand())
|
using (var cmd = _connection.CreateCommand())
|
||||||
|
{
|
||||||
|
cmd.CommandText = "DELETE FROM WebcamSceneConfig;";
|
||||||
|
cmd.Transaction = tx;
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
using (var cmd = _connection.CreateCommand())
|
||||||
{
|
{
|
||||||
cmd.CommandText = "DELETE FROM Source;";
|
cmd.CommandText = "DELETE FROM Source;";
|
||||||
cmd.Transaction = tx;
|
cmd.Transaction = tx;
|
||||||
cmd.ExecuteNonQuery();
|
cmd.ExecuteNonQuery();
|
||||||
}
|
}
|
||||||
using (var cmd = _connection.CreateCommand())
|
using (var cmd = _connection.CreateCommand())
|
||||||
|
{
|
||||||
|
cmd.CommandText = "DELETE FROM Webcam;";
|
||||||
|
cmd.Transaction = tx;
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
using (var cmd = _connection.CreateCommand())
|
||||||
{
|
{
|
||||||
cmd.CommandText = "DELETE FROM Scene;";
|
cmd.CommandText = "DELETE FROM Scene;";
|
||||||
cmd.Transaction = tx;
|
cmd.Transaction = tx;
|
||||||
@@ -219,10 +438,10 @@ public class LayoutStore : IDisposable
|
|||||||
{
|
{
|
||||||
cmd.CommandText = """
|
cmd.CommandText = """
|
||||||
INSERT INTO Source (Id, SceneId, AssetId, Type, Name, IsEnabled,
|
INSERT INTO Source (Id, SceneId, AssetId, Type, Name, IsEnabled,
|
||||||
X, Y, Width, Height, Opacity, MonitorIndex, DeviceId,
|
X, Y, Width, Height, Opacity, MonitorIndex,
|
||||||
ClipShape, IsMirrored, SortOrder)
|
ClipShape, IsMirrored, SortOrder)
|
||||||
VALUES ($id, $sceneId, $assetId, $type, $name, $isEnabled,
|
VALUES ($id, $sceneId, $assetId, $type, $name, $isEnabled,
|
||||||
$x, $y, $w, $h, $opacity, $monitor, $device,
|
$x, $y, $w, $h, $opacity, $monitor,
|
||||||
$clip, $mirrored, $sort)
|
$clip, $mirrored, $sort)
|
||||||
""";
|
""";
|
||||||
cmd.Transaction = tx;
|
cmd.Transaction = tx;
|
||||||
@@ -238,7 +457,6 @@ public class LayoutStore : IDisposable
|
|||||||
var hP = cmd.Parameters.Add("$h", SqliteType.Real);
|
var hP = cmd.Parameters.Add("$h", SqliteType.Real);
|
||||||
var opacityP = cmd.Parameters.Add("$opacity", SqliteType.Real);
|
var opacityP = cmd.Parameters.Add("$opacity", SqliteType.Real);
|
||||||
var monitorP = cmd.Parameters.Add("$monitor", SqliteType.Integer);
|
var monitorP = cmd.Parameters.Add("$monitor", SqliteType.Integer);
|
||||||
var deviceP = cmd.Parameters.Add("$device", SqliteType.Text);
|
|
||||||
var clipP = cmd.Parameters.Add("$clip", SqliteType.Text);
|
var clipP = cmd.Parameters.Add("$clip", SqliteType.Text);
|
||||||
var mirroredP = cmd.Parameters.Add("$mirrored", SqliteType.Integer);
|
var mirroredP = cmd.Parameters.Add("$mirrored", SqliteType.Integer);
|
||||||
var sortP = cmd.Parameters.Add("$sort", SqliteType.Integer);
|
var sortP = cmd.Parameters.Add("$sort", SqliteType.Integer);
|
||||||
@@ -246,8 +464,9 @@ public class LayoutStore : IDisposable
|
|||||||
foreach (var scene in scenes)
|
foreach (var scene in scenes)
|
||||||
{
|
{
|
||||||
var sort = 0;
|
var sort = 0;
|
||||||
foreach (var source in scene.Sources)
|
foreach (var element in scene.Elements)
|
||||||
{
|
{
|
||||||
|
if (element is not Source source) continue;
|
||||||
idP.Value = source.Id;
|
idP.Value = source.Id;
|
||||||
sceneIdP.Value = scene.Id;
|
sceneIdP.Value = scene.Id;
|
||||||
assetIdP.Value = (object?)source.AssetId ?? DBNull.Value;
|
assetIdP.Value = (object?)source.AssetId ?? DBNull.Value;
|
||||||
@@ -260,7 +479,6 @@ public class LayoutStore : IDisposable
|
|||||||
hP.Value = source.Height;
|
hP.Value = source.Height;
|
||||||
opacityP.Value = source.Opacity;
|
opacityP.Value = source.Opacity;
|
||||||
monitorP.Value = (object?)source.MonitorIndex ?? DBNull.Value;
|
monitorP.Value = (object?)source.MonitorIndex ?? DBNull.Value;
|
||||||
deviceP.Value = (object?)source.DeviceId ?? DBNull.Value;
|
|
||||||
clipP.Value = source.ClipShape.ToString();
|
clipP.Value = source.ClipShape.ToString();
|
||||||
mirroredP.Value = source.IsMirrored ? 1 : 0;
|
mirroredP.Value = source.IsMirrored ? 1 : 0;
|
||||||
sortP.Value = sort++;
|
sortP.Value = sort++;
|
||||||
@@ -269,6 +487,76 @@ public class LayoutStore : IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (webcam != null)
|
||||||
|
{
|
||||||
|
using (var cmd = _connection.CreateCommand())
|
||||||
|
{
|
||||||
|
cmd.CommandText = "INSERT INTO Webcam (Id, DeviceId, Name) VALUES ($id, $device, $name);";
|
||||||
|
cmd.Transaction = tx;
|
||||||
|
cmd.Parameters.AddWithValue("$id", webcam.Id);
|
||||||
|
cmd.Parameters.AddWithValue("$device", webcam.DeviceId);
|
||||||
|
cmd.Parameters.AddWithValue("$name", webcam.Name);
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
using (var cmd = _connection.CreateCommand())
|
||||||
|
{
|
||||||
|
cmd.CommandText = """
|
||||||
|
INSERT INTO WebcamSceneConfig
|
||||||
|
(SceneId, WebcamId, IsVisible, X, Y, Width, Height, Opacity,
|
||||||
|
ClipShape, IsMirrored, BorderColor, BorderOpacity, BorderWidth, BorderAnimation,
|
||||||
|
RectWidth, RectHeight, SortOrder)
|
||||||
|
VALUES ($sceneId, $webcamId, $isVisible, $x, $y, $w, $h, $opacity,
|
||||||
|
$clip, $mirrored, $borderColor, $borderOpacity, $borderWidth, $borderAnimation,
|
||||||
|
$rectWidth, $rectHeight, $sort)
|
||||||
|
""";
|
||||||
|
cmd.Transaction = tx;
|
||||||
|
var sceneIdP = cmd.Parameters.Add("$sceneId", SqliteType.Text);
|
||||||
|
var webcamIdP = cmd.Parameters.Add("$webcamId", SqliteType.Text);
|
||||||
|
var visibleP = cmd.Parameters.Add("$isVisible", SqliteType.Integer);
|
||||||
|
var xP = cmd.Parameters.Add("$x", SqliteType.Real);
|
||||||
|
var yP = cmd.Parameters.Add("$y", SqliteType.Real);
|
||||||
|
var wP = cmd.Parameters.Add("$w", SqliteType.Real);
|
||||||
|
var hP = cmd.Parameters.Add("$h", SqliteType.Real);
|
||||||
|
var opacityP = cmd.Parameters.Add("$opacity", SqliteType.Real);
|
||||||
|
var clipP = cmd.Parameters.Add("$clip", SqliteType.Text);
|
||||||
|
var mirroredP = cmd.Parameters.Add("$mirrored", SqliteType.Integer);
|
||||||
|
var colorP = cmd.Parameters.Add("$borderColor", SqliteType.Text);
|
||||||
|
var borderOpacityP = cmd.Parameters.Add("$borderOpacity", SqliteType.Real);
|
||||||
|
var borderWidthP = cmd.Parameters.Add("$borderWidth", SqliteType.Integer);
|
||||||
|
var animationP = cmd.Parameters.Add("$borderAnimation", SqliteType.Text);
|
||||||
|
var rectWidthP = cmd.Parameters.Add("$rectWidth", SqliteType.Real);
|
||||||
|
var rectHeightP = cmd.Parameters.Add("$rectHeight", SqliteType.Real);
|
||||||
|
var sortP = cmd.Parameters.Add("$sort", SqliteType.Integer);
|
||||||
|
|
||||||
|
foreach (var scene in scenes)
|
||||||
|
{
|
||||||
|
var sort = 0;
|
||||||
|
foreach (var config in scene.Elements.OfType<WebcamSceneConfig>())
|
||||||
|
{
|
||||||
|
sceneIdP.Value = scene.Id;
|
||||||
|
webcamIdP.Value = config.WebcamId;
|
||||||
|
visibleP.Value = config.IsVisible ? 1 : 0;
|
||||||
|
xP.Value = config.X;
|
||||||
|
yP.Value = config.Y;
|
||||||
|
wP.Value = config.Width;
|
||||||
|
hP.Value = config.Height;
|
||||||
|
opacityP.Value = config.Opacity;
|
||||||
|
clipP.Value = config.ClipShape.ToString();
|
||||||
|
mirroredP.Value = config.IsMirrored ? 1 : 0;
|
||||||
|
colorP.Value = (object?)(string.IsNullOrEmpty(config.BorderColor) ? null : config.BorderColor) ?? DBNull.Value;
|
||||||
|
borderOpacityP.Value = config.BorderOpacity;
|
||||||
|
borderWidthP.Value = config.BorderWidth;
|
||||||
|
animationP.Value = config.BorderAnimation.ToString();
|
||||||
|
rectWidthP.Value = (object?)config.RectWidth ?? DBNull.Value;
|
||||||
|
rectHeightP.Value = (object?)config.RectHeight ?? DBNull.Value;
|
||||||
|
sortP.Value = sort++;
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
using (var cmd = _connection.CreateCommand())
|
using (var cmd = _connection.CreateCommand())
|
||||||
{
|
{
|
||||||
cmd.CommandText = "DELETE FROM Asset WHERE Id NOT IN (SELECT AssetId FROM Source WHERE AssetId IS NOT NULL);";
|
cmd.CommandText = "DELETE FROM Asset WHERE Id NOT IN (SELECT AssetId FROM Source WHERE AssetId IS NOT NULL);";
|
||||||
|
|||||||
+2
-2
@@ -8,14 +8,14 @@ External-facing logic: YouTube API, persistence. See
|
|||||||
| `YouTubeAuthService.cs` | OAuth2 via Google: loopback callback (`http://localhost:8765/oauth2/callback`), token exchange, refresh, channel fetch. Constructor takes optional `HttpClient` + `sessionChanged` callback (test seam + save hook); session persists via `Helpers/TokenStore` (DPAPI); `ClearSession()` signs out (called by `MainViewModel.StopStream` on End Livestream) |
|
| `YouTubeAuthService.cs` | OAuth2 via Google: loopback callback (`http://localhost:8765/oauth2/callback`), token exchange, refresh, channel fetch. Constructor takes optional `HttpClient` + `sessionChanged` callback (test seam + save hook); session persists via `Helpers/TokenStore` (DPAPI); `ClearSession()` signs out (called by `MainViewModel.StopStream` on End Livestream) |
|
||||||
| `YouTubeStreamService.cs` | Broadcast/stream management via the v3 API (`enableAutoStart/Stop`). **Not yet switched to the `variable` reusable stream** |
|
| `YouTubeStreamService.cs` | Broadcast/stream management via the v3 API (`enableAutoStart/Stop`). **Not yet switched to the `variable` reusable stream** |
|
||||||
| `YouTubeChatService.cs` | Polls `liveChat/messages`, raises `MessageReceived`; `IDisposable` |
|
| `YouTubeChatService.cs` | Polls `liveChat/messages`, raises `MessageReceived`; `IDisposable` |
|
||||||
| `LayoutStore.cs` | SQLite persistence (`Microsoft.Data.Sqlite`) at `%APPDATA%\ytLlive\ytLlive.db`; assets stored as BLOBs keyed by SHA-256 content hash; save/open layout files; schema `user_version` 2 (`Source.ClipShape`/`IsMirrored` — added by `ALTER TABLE` for pre-v2 DBs) |
|
| `LayoutStore.cs` | SQLite persistence (`Microsoft.Data.Sqlite`) at `%APPDATA%\ytLlive\ytLlive.db`; assets stored as BLOBs keyed by SHA-256 content hash; save/open layout files; schema `user_version` 4 (`Source.ClipShape`/`IsMirrored` via `ALTER TABLE` for pre-v2 DBs; v3 = singleton `Webcam` + per-scene `WebcamSceneConfig`, migrated idempotently **without backfill** — the stale `Source.DeviceId` column remains but is no longer read/written; v4 = `WebcamSceneConfig.RectWidth`/`RectHeight`, the pre-Round rect for the round-to-rect restore) |
|
||||||
| `VideoFrame.cs` | Normalized CPU frame seam (`Width`/`Height`/tightly-packed BGRA `byte[]`) — the only pixel type the rest of the app knows about; future capture sources (screen, background-removed webcam) feed the same seam |
|
| `VideoFrame.cs` | Normalized CPU frame seam (`Width`/`Height`/tightly-packed BGRA `byte[]`) — the only pixel type the rest of the app knows about; future capture sources (screen, background-removed webcam) feed the same seam |
|
||||||
| `CameraDeviceInfo.cs` | `(Id, DisplayName)` for a physical capture device |
|
| `CameraDeviceInfo.cs` | `(Id, DisplayName)` for a physical capture device |
|
||||||
| `ICameraEnumerator.cs` | `GetCamerasAsync()` — seam so the picker/`CameraManager` never touch WinRT (tests inject fakes) |
|
| `ICameraEnumerator.cs` | `GetCamerasAsync()` — seam so the picker/`CameraManager` never touch WinRT (tests inject fakes) |
|
||||||
| `ICameraFrameSource.cs` | `StartAsync`/`StopAsync`/`FrameAvailable(VideoFrame)` — seam for a running capture source |
|
| `ICameraFrameSource.cs` | `StartAsync`/`StopAsync`/`FrameAvailable(VideoFrame)` — seam for a running capture source |
|
||||||
| `MediaCaptureCameraEnumerator.cs` | WinRT enumeration via `DeviceInformation.FindAllAsync(DeviceClass.VideoCapture)` |
|
| `MediaCaptureCameraEnumerator.cs` | WinRT enumeration via `DeviceInformation.FindAllAsync(DeviceClass.VideoCapture)` |
|
||||||
| `MediaCaptureFrameSource.cs` | CPU-first MediaCapture source (`MemoryPreference = Cpu`, BGRA8 via `CreateFrameReaderAsync`); frames arrive on a worker thread |
|
| `MediaCaptureFrameSource.cs` | CPU-first MediaCapture source (`MemoryPreference = Cpu`, BGRA8 via `CreateFrameReaderAsync`); frames arrive on a worker thread |
|
||||||
| `CameraManager.cs` | Webcam capture ownership: refcounted by `DeviceId`, one shared `WriteableBitmap` per camera, dispatcher-coalesced ~render-rate UI updates (latest-frame drop); `PreviewBitmapChanged`/`CameraFailed` events |
|
| `CameraManager.cs` | Webcam capture ownership: refcounted by `DeviceId`, one shared `WriteableBitmap` per camera, dispatcher-coalesced ~render-rate UI updates (latest-frame drop); `PreviewBitmapChanged`/`CameraFailed` events. `ReleaseAsync` decrements a ref (stops at zero); `ReleaseAllAsync` force-drops every ref + stops the source (webcam swap / layout reload) |
|
||||||
|
|
||||||
Related: constructed in [`ViewModels/MainViewModel.cs`](../ViewModels/MainViewModel.cs)
|
Related: constructed in [`ViewModels/MainViewModel.cs`](../ViewModels/MainViewModel.cs)
|
||||||
(no DI container yet). Models in [`Models/index.md`](../Models/index.md).
|
(no DI container yet). Models in [`Models/index.md`](../Models/index.md).
|
||||||
|
|||||||
@@ -159,7 +159,7 @@ Preview shows the transition too (WYSIWYG). No wipes/slides/LUTs beyond the four
|
|||||||
- **Scenes list:** drag rows to reorder scenes
|
- **Scenes list:** drag rows to reorder scenes
|
||||||
- **Sources list:** drag rows to reorder sources (this *is* the z-order) — implemented
|
- **Sources list:** drag rows to reorder sources (this *is* the z-order) — implemented
|
||||||
|
|
||||||
### Status: 🔶 In progress — milestone 1 (webcam) shipped; scenes/sources UI (add/reorder/rename, image + background overlays with move/resize/opacity/reuse) built; screen capture, compositing, and encoding pending
|
### Status: 🔶 In progress — milestone 1 (webcam) shipped; **schema v3 (Ship Branch A) shipped**: multi-scene webcam (singleton `Webcam` + per-scene `WebcamSceneConfig`), right-click border/context menu, static OSB-style borders, 50%-per-dimension webcam size cap, device-swap (`ReleaseAllAsync`); **schema v4**: round→rect restore persisted (`WebcamSceneConfig.RectWidth`/`RectHeight`) + one-time legacy-square 16:9 heal on load — 25 tests passing; scenes/sources UI (add/reorder/rename, image + background overlays with move/resize/opacity/reuse) built; screen capture, compositing, encoding pending
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -240,8 +240,10 @@ Preview shows the transition too (WYSIWYG). No wipes/slides/LUTs beyond the four
|
|||||||
5. **Schema** — `Scene` (Id, Name, IsHidden, IsChatScene, SortOrder), `Asset` (Id, Hash, Data,
|
5. **Schema** — `Scene` (Id, Name, IsHidden, IsChatScene, SortOrder), `Asset` (Id, Hash, Data,
|
||||||
PixelWidth, PixelHeight), `Source` (Id, SceneId FK cascade, AssetId FK, Type, Name, IsEnabled,
|
PixelWidth, PixelHeight), `Source` (Id, SceneId FK cascade, AssetId FK, Type, Name, IsEnabled,
|
||||||
X/Y/Width/Height/Opacity, MonitorIndex, DeviceId, ClipShape, IsMirrored, SortOrder) —
|
X/Y/Width/Height/Opacity, MonitorIndex, DeviceId, ClipShape, IsMirrored, SortOrder) —
|
||||||
`user_version` 2 (v1 → v2 = `ALTER TABLE` adds the two webcam columns). `WindowHandle` stays
|
`user_version` **4** (v1 → v2 = `ALTER TABLE` adds the two webcam columns; v3 = singleton
|
||||||
in-memory (per-session). Save = transactional rewrite; orphaned assets pruned.
|
`Webcam` + per-scene `WebcamSceneConfig`; v4 = `WebcamSceneConfig.RectWidth`/`RectHeight` for the
|
||||||
|
round-to-rect restore). `WindowHandle` stays in-memory (per-session). Save = transactional
|
||||||
|
rewrite; orphaned assets pruned.
|
||||||
6. **Startup** — load the active file; seed the default five scenes (Starting/Live/BRB/Chat/Ending)
|
6. **Startup** — load the active file; seed the default five scenes (Starting/Live/BRB/Chat/Ending)
|
||||||
only when the DB is empty.
|
only when the DB is empty.
|
||||||
|
|
||||||
|
|||||||
+47
-3
@@ -373,7 +373,9 @@
|
|||||||
BorderBrush="{TemplateBinding BorderBrush}"
|
BorderBrush="{TemplateBinding BorderBrush}"
|
||||||
BorderThickness="{TemplateBinding BorderThickness}"
|
BorderThickness="{TemplateBinding BorderThickness}"
|
||||||
CornerRadius="5" Padding="{TemplateBinding Padding}">
|
CornerRadius="5" Padding="{TemplateBinding Padding}">
|
||||||
|
<Grid IsSharedSizeScope="True">
|
||||||
<ItemsPresenter/>
|
<ItemsPresenter/>
|
||||||
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
</ControlTemplate>
|
</ControlTemplate>
|
||||||
</Setter.Value>
|
</Setter.Value>
|
||||||
@@ -387,18 +389,60 @@
|
|||||||
<Setter Property="Template">
|
<Setter Property="Template">
|
||||||
<Setter.Value>
|
<Setter.Value>
|
||||||
<ControlTemplate TargetType="MenuItem">
|
<ControlTemplate TargetType="MenuItem">
|
||||||
<Border x:Name="Bd" Background="{TemplateBinding Background}" CornerRadius="3"
|
<Grid x:Name="Grid" SnapsToDevicePixels="True">
|
||||||
Padding="{TemplateBinding Padding}" Margin="0,1">
|
<Grid.ColumnDefinitions>
|
||||||
<ContentPresenter ContentSource="Header" VerticalAlignment="Center"
|
<ColumnDefinition x:Name="Col0" MinWidth="17" Width="Auto"
|
||||||
|
SharedSizeGroup="MenuItemIconColumnGroup"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
<ColumnDefinition Width="14"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<Border x:Name="Bd" Grid.ColumnSpan="3"
|
||||||
|
Background="{TemplateBinding Background}"
|
||||||
|
CornerRadius="3" Margin="0,1"/>
|
||||||
|
<TextBlock x:Name="CheckGlyph" Grid.Column="0" Text="✓"
|
||||||
|
Margin="4,0,6,0" HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Center" Visibility="Collapsed"/>
|
||||||
|
<ContentPresenter x:Name="Content" Grid.Column="1"
|
||||||
|
ContentSource="Header"
|
||||||
|
Margin="{TemplateBinding Padding}"
|
||||||
|
VerticalAlignment="Center"
|
||||||
HorizontalAlignment="Left"/>
|
HorizontalAlignment="Left"/>
|
||||||
|
<TextBlock x:Name="Arrow" Grid.Column="2" Text="›"
|
||||||
|
Margin="6,0,0,0" HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Center" Visibility="Collapsed"/>
|
||||||
|
<Popup x:Name="PART_Popup" Placement="Right"
|
||||||
|
HorizontalOffset="-1" VerticalOffset="-1"
|
||||||
|
IsOpen="{TemplateBinding IsSubmenuOpen}"
|
||||||
|
AllowsTransparency="True"
|
||||||
|
PopupAnimation="{DynamicResource {x:Static SystemParameters.MenuPopupAnimationKey}}"
|
||||||
|
Focusable="False">
|
||||||
|
<Border Background="#16213e" BorderBrush="#2a3a5e"
|
||||||
|
BorderThickness="1" CornerRadius="5" Padding="3">
|
||||||
|
<Grid IsSharedSizeScope="True">
|
||||||
|
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||||
|
<ItemsPresenter/>
|
||||||
|
</ScrollViewer>
|
||||||
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
</Popup>
|
||||||
|
</Grid>
|
||||||
<ControlTemplate.Triggers>
|
<ControlTemplate.Triggers>
|
||||||
<Trigger Property="IsHighlighted" Value="True">
|
<Trigger Property="IsHighlighted" Value="True">
|
||||||
<Setter TargetName="Bd" Property="Background" Value="#26ffffff"/>
|
<Setter TargetName="Bd" Property="Background" Value="#26ffffff"/>
|
||||||
</Trigger>
|
</Trigger>
|
||||||
|
<Trigger Property="IsSubmenuOpen" Value="True">
|
||||||
|
<Setter TargetName="Bd" Property="Background" Value="#26ffffff"/>
|
||||||
|
</Trigger>
|
||||||
|
<Trigger Property="IsChecked" Value="True">
|
||||||
|
<Setter TargetName="CheckGlyph" Property="Visibility" Value="Visible"/>
|
||||||
|
</Trigger>
|
||||||
<Trigger Property="IsEnabled" Value="False">
|
<Trigger Property="IsEnabled" Value="False">
|
||||||
<Setter Property="Opacity" Value="0.45"/>
|
<Setter Property="Opacity" Value="0.45"/>
|
||||||
</Trigger>
|
</Trigger>
|
||||||
|
<DataTrigger Binding="{Binding HasItems, RelativeSource={RelativeSource TemplatedParent}}"
|
||||||
|
Value="True">
|
||||||
|
<Setter TargetName="Arrow" Property="Visibility" Value="Visible"/>
|
||||||
|
</DataTrigger>
|
||||||
</ControlTemplate.Triggers>
|
</ControlTemplate.Triggers>
|
||||||
</ControlTemplate>
|
</ControlTemplate>
|
||||||
</Setter.Value>
|
</Setter.Value>
|
||||||
|
|||||||
+230
-85
@@ -23,7 +23,7 @@ public class MainViewModel : ViewModelBase
|
|||||||
private readonly DispatcherTimer _liveTimer;
|
private readonly DispatcherTimer _liveTimer;
|
||||||
|
|
||||||
private Scene? _activeScene;
|
private Scene? _activeScene;
|
||||||
private Source? _selectedSource;
|
private SceneElement? _selectedElement;
|
||||||
private ImageSource? _activeBackgroundImage;
|
private ImageSource? _activeBackgroundImage;
|
||||||
private bool _isConnected;
|
private bool _isConnected;
|
||||||
private StreamStatus _streamStatus = StreamStatus.Offline;
|
private StreamStatus _streamStatus = StreamStatus.Offline;
|
||||||
@@ -57,11 +57,12 @@ public class MainViewModel : ViewModelBase
|
|||||||
private DispatcherTimer? _saveDebounce;
|
private DispatcherTimer? _saveDebounce;
|
||||||
private bool _isLoading;
|
private bool _isLoading;
|
||||||
|
|
||||||
// Webcam: one camera app-wide. The single Source is tracked here so the
|
// Webcam: one camera input app-wide. The identity (DeviceId) lives on the
|
||||||
// Add menu can be disabled and the live preview bitmap can be forwarded.
|
// Webcam entity; each scene that shows the webcam has a WebcamSceneConfig
|
||||||
|
// (webcam.{scene}.config). CameraManager still owns the single session.
|
||||||
private readonly ICameraEnumerator _cameraEnumerator;
|
private readonly ICameraEnumerator _cameraEnumerator;
|
||||||
private readonly CameraManager _cameraManager;
|
private readonly CameraManager _cameraManager;
|
||||||
private Source? _webcamSource;
|
private Webcam? _webcam;
|
||||||
|
|
||||||
// Branding flash (monetization): a full-frame "made with ytLlive!" shown
|
// Branding flash (monetization): a full-frame "made with ytLlive!" shown
|
||||||
// for ~1s every 300s on the live output, ~25% opacity. Paid unlock sets
|
// for ~1s every 300s on the live output, ~25% opacity. Paid unlock sets
|
||||||
@@ -87,11 +88,13 @@ public class MainViewModel : ViewModelBase
|
|||||||
if (value != null && value.IsHidden) return;
|
if (value != null && value.IsHidden) return;
|
||||||
if (SetProperty(ref _activeScene, value))
|
if (SetProperty(ref _activeScene, value))
|
||||||
{
|
{
|
||||||
SelectedSource = null;
|
SelectedElement = null;
|
||||||
OnPropertyChanged(nameof(ShowChatInactiveMessage));
|
OnPropertyChanged(nameof(ShowChatInactiveMessage));
|
||||||
OnPropertyChanged(nameof(ShowEmptySceneHint));
|
OnPropertyChanged(nameof(ShowEmptySceneHint));
|
||||||
OnPropertyChanged(nameof(ShowPreviewPlaceholder));
|
OnPropertyChanged(nameof(ShowPreviewPlaceholder));
|
||||||
OnPropertyChanged(nameof(ShowSourcesEmptyHint));
|
OnPropertyChanged(nameof(ShowSourcesEmptyHint));
|
||||||
|
OnPropertyChanged(nameof(CanAddWebcamToActiveScene));
|
||||||
|
OnPropertyChanged(nameof(CanShowWebcamInActiveScene));
|
||||||
UpdateActiveBackground();
|
UpdateActiveBackground();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -103,21 +106,31 @@ public class MainViewModel : ViewModelBase
|
|||||||
private set => SetProperty(ref _activeBackgroundImage, value);
|
private set => SetProperty(ref _activeBackgroundImage, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Source? SelectedSource
|
public SceneElement? SelectedElement
|
||||||
{
|
{
|
||||||
get => _selectedSource;
|
get => _selectedElement;
|
||||||
set
|
set
|
||||||
{
|
{
|
||||||
if (SetProperty(ref _selectedSource, value))
|
if (SetProperty(ref _selectedElement, value))
|
||||||
OnPropertyChanged(nameof(IsWebcamSelected));
|
OnPropertyChanged(nameof(IsWebcamSelected));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>The Add → Webcam menu item. One webcam app-wide — once one exists it's greyed out.</summary>
|
/// <summary>The Add → Webcam menu item: enabled when the active scene doesn't show the webcam yet.</summary>
|
||||||
public bool CanAddWebcam => _webcamSource == null;
|
public bool CanAddWebcamToActiveScene => ActiveScene?.WebcamConfig == null;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Right-click-on-preview → "Show Webcam": offered when the active scene has no
|
||||||
|
/// webcam config (add one) or hides it (unhide — keeps the config row).
|
||||||
|
/// </summary>
|
||||||
|
public bool CanShowWebcamInActiveScene
|
||||||
|
=> ActiveScene is { } scene && (scene.WebcamConfig == null || !scene.WebcamConfig.IsVisible);
|
||||||
|
|
||||||
|
/// <summary>Swap-the-device item: enabled once a camera has been picked at all.</summary>
|
||||||
|
public bool CanChangeWebcam => _webcam != null;
|
||||||
|
|
||||||
/// <summary>Shows the mirror / clip-shape row in the source chip when a webcam is selected.</summary>
|
/// <summary>Shows the mirror / clip-shape row in the source chip when a webcam is selected.</summary>
|
||||||
public bool IsWebcamSelected => SelectedSource?.Type == SourceType.Webcam;
|
public bool IsWebcamSelected => SelectedElement is WebcamSceneConfig;
|
||||||
|
|
||||||
public StreamStatus StreamStatus
|
public StreamStatus StreamStatus
|
||||||
{
|
{
|
||||||
@@ -152,9 +165,9 @@ public class MainViewModel : ViewModelBase
|
|||||||
public bool LiveIndicatorVisible => IsLive;
|
public bool LiveIndicatorVisible => IsLive;
|
||||||
public bool ShowStartStream => IsOffline;
|
public bool ShowStartStream => IsOffline;
|
||||||
public bool ShowChatInactiveMessage => !IsLive;
|
public bool ShowChatInactiveMessage => !IsLive;
|
||||||
public bool ShowEmptySceneHint => ActiveScene != null && ActiveScene.Sources.Count == 0;
|
public bool ShowEmptySceneHint => ActiveScene != null && ActiveScene.Elements.Count == 0;
|
||||||
public bool ShowPreviewPlaceholder => !ShowEmptySceneHint && ActiveBackgroundImage == null;
|
public bool ShowPreviewPlaceholder => !ShowEmptySceneHint && ActiveBackgroundImage == null;
|
||||||
public bool ShowSourcesEmptyHint => ActiveScene == null || ActiveScene.Sources.Count == 0;
|
public bool ShowSourcesEmptyHint => ActiveScene == null || ActiveScene.Elements.Count == 0;
|
||||||
|
|
||||||
// Paid unlock flips this off (see ai.md "Monetization"). When disabled the
|
// Paid unlock flips this off (see ai.md "Monetization"). When disabled the
|
||||||
// cadence timer is stopped and any active flash is hidden immediately.
|
// cadence timer is stopped and any active flash is hidden immediately.
|
||||||
@@ -185,7 +198,7 @@ public class MainViewModel : ViewModelBase
|
|||||||
|
|
||||||
private void UpdateActiveBackground()
|
private void UpdateActiveBackground()
|
||||||
{
|
{
|
||||||
var background = ActiveScene?.Sources.FirstOrDefault(s => s.Type == SourceType.Background);
|
var background = ActiveScene?.Elements.OfType<Source>().FirstOrDefault(s => s.Type == SourceType.Background);
|
||||||
ActiveBackgroundImage = background != null && !string.IsNullOrWhiteSpace(background.AssetId)
|
ActiveBackgroundImage = background != null && !string.IsNullOrWhiteSpace(background.AssetId)
|
||||||
? ImageCache.Get(background.AssetId)
|
? ImageCache.Get(background.AssetId)
|
||||||
: null;
|
: null;
|
||||||
@@ -315,6 +328,44 @@ public class MainViewModel : ViewModelBase
|
|||||||
private const double MasterFrameWidth = 1920;
|
private const double MasterFrameWidth = 1920;
|
||||||
private const double MasterFrameHeight = 1080;
|
private const double MasterFrameHeight = 1080;
|
||||||
|
|
||||||
|
// Webcam size safeguard: no more than half the frame in any dimension
|
||||||
|
// (960x540 over the 1920x1080 master), and no less than 10% of it
|
||||||
|
// (192x108). Enforced at resize and on layout load.
|
||||||
|
public const double WebcamMaxWidth = 960;
|
||||||
|
public const double WebcamMaxHeight = 540;
|
||||||
|
public const double WebcamMinWidth = MasterFrameWidth * 0.1;
|
||||||
|
public const double WebcamMinHeight = MasterFrameHeight * 0.1;
|
||||||
|
|
||||||
|
internal static void ClampWebcamToBounds(WebcamSceneConfig config)
|
||||||
|
{
|
||||||
|
var scale = Math.Min(WebcamMaxWidth / config.Width, WebcamMaxHeight / config.Height);
|
||||||
|
if (scale < 1)
|
||||||
|
{
|
||||||
|
config.Width = Math.Round(config.Width * scale);
|
||||||
|
config.Height = Math.Round(config.Height * scale);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var minScale = Math.Max(WebcamMinWidth / config.Width, WebcamMinHeight / config.Height);
|
||||||
|
if (minScale > 1)
|
||||||
|
{
|
||||||
|
config.Width = Math.Round(config.Width * minScale);
|
||||||
|
config.Height = Math.Round(config.Height * minScale);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// One-time heal for layouts saved before the rect dims were persisted (v3→v4):
|
||||||
|
// a Traditional webcam that ended up square (Round resize then reload lost the
|
||||||
|
// pre-Round rect) gets widened to 16:9, keeping the height. Round is skipped —
|
||||||
|
// a square bounding box is correct there — and an explicit pre-Round rect wins.
|
||||||
|
internal static void HealLegacySquareRect(WebcamSceneConfig config)
|
||||||
|
{
|
||||||
|
if (config.ClipShape != ClipShape.Traditional) return;
|
||||||
|
if (config.RectWidth != null || config.RectHeight != null) return;
|
||||||
|
if (Math.Abs(config.Width - config.Height) >= 1) return;
|
||||||
|
config.Width = Math.Round(config.Height * 16.0 / 9.0);
|
||||||
|
}
|
||||||
|
|
||||||
public QualityOption[] QualityOptions { get; } =
|
public QualityOption[] QualityOptions { get; } =
|
||||||
{
|
{
|
||||||
new("1080p60", 60, 8.0, 1920, 1080),
|
new("1080p60", 60, 8.0, 1920, 1080),
|
||||||
@@ -419,6 +470,8 @@ public class MainViewModel : ViewModelBase
|
|||||||
public ICommand AddSourceCommand { get; }
|
public ICommand AddSourceCommand { get; }
|
||||||
public ICommand AddImageCommand { get; }
|
public ICommand AddImageCommand { get; }
|
||||||
public ICommand RemoveSourceCommand { get; }
|
public ICommand RemoveSourceCommand { get; }
|
||||||
|
public ICommand ChangeWebcamCommand { get; }
|
||||||
|
public ICommand ShowWebcamCommand { get; }
|
||||||
public ICommand StartStreamCommand { get; }
|
public ICommand StartStreamCommand { get; }
|
||||||
public ICommand EndStreamCommand { get; }
|
public ICommand EndStreamCommand { get; }
|
||||||
public ICommand OpenSettingsCommand { get; }
|
public ICommand OpenSettingsCommand { get; }
|
||||||
@@ -466,7 +519,9 @@ public class MainViewModel : ViewModelBase
|
|||||||
ToggleSceneVisibilityCommand = new RelayCommand(scene => ToggleSceneVisibility(scene as Scene));
|
ToggleSceneVisibilityCommand = new RelayCommand(scene => ToggleSceneVisibility(scene as Scene));
|
||||||
AddSourceCommand = new RelayCommand(type => AddSource(type as string));
|
AddSourceCommand = new RelayCommand(type => AddSource(type as string));
|
||||||
AddImageCommand = new RelayCommand(_ => AddImage());
|
AddImageCommand = new RelayCommand(_ => AddImage());
|
||||||
RemoveSourceCommand = new RelayCommand(source => RemoveSource(source as Source));
|
RemoveSourceCommand = new RelayCommand(element => RemoveElement(element as SceneElement));
|
||||||
|
ChangeWebcamCommand = new RelayCommand(_ => _ = ChangeWebcamAsync(), _ => CanChangeWebcam);
|
||||||
|
ShowWebcamCommand = new RelayCommand(_ => ShowWebcamInActiveScene(), _ => CanShowWebcamInActiveScene);
|
||||||
OpenSettingsCommand = new RelayCommand(_ => ShowOverlay(nameof(IsSettingsOpen), "App Settings"));
|
OpenSettingsCommand = new RelayCommand(_ => ShowOverlay(nameof(IsSettingsOpen), "App Settings"));
|
||||||
OpenBugCommand = new RelayCommand(_ => ShowOverlay(nameof(IsBugOpen), "Report Bug"));
|
OpenBugCommand = new RelayCommand(_ => ShowOverlay(nameof(IsBugOpen), "Report Bug"));
|
||||||
OpenFeatureCommand = new RelayCommand(_ => ShowOverlay(nameof(IsFeatureOpen), "Feature Request"));
|
OpenFeatureCommand = new RelayCommand(_ => ShowOverlay(nameof(IsFeatureOpen), "Feature Request"));
|
||||||
@@ -555,6 +610,12 @@ public class MainViewModel : ViewModelBase
|
|||||||
AddScene("Chat", isChatScene: true);
|
AddScene("Chat", isChatScene: true);
|
||||||
AddScene("Ending");
|
AddScene("Ending");
|
||||||
}
|
}
|
||||||
|
foreach (var scene in Scenes)
|
||||||
|
foreach (var config in scene.Elements.OfType<WebcamSceneConfig>())
|
||||||
|
{
|
||||||
|
ClampWebcamToBounds(config);
|
||||||
|
HealLegacySquareRect(config);
|
||||||
|
}
|
||||||
AppLog.Write($"LoadLayout: {Scenes.Count} scenes loaded");
|
AppLog.Write($"LoadLayout: {Scenes.Count} scenes loaded");
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
@@ -568,29 +629,35 @@ public class MainViewModel : ViewModelBase
|
|||||||
AppLog.Write("LoadLayout end");
|
AppLog.Write("LoadLayout end");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Finds the persisted webcam source (at most one app-wide) and re-acquires
|
// After a layout load / file open: re-point the webcam identity, stop the
|
||||||
// its camera after a layout load / file open. Releasing the previous session
|
// previous device if it changed, and acquire the current one once per scene
|
||||||
// unconditionally keeps the refcount honest even when the device is unchanged.
|
// that uses it (CameraManager refcounts by DeviceId — one camera session).
|
||||||
private void ReacquireWebcam()
|
private void ReacquireWebcam()
|
||||||
{
|
{
|
||||||
var webcam = Scenes.SelectMany(s => s.Sources).FirstOrDefault(s => s.Type == SourceType.Webcam);
|
var previousDevice = _webcam?.DeviceId;
|
||||||
if (ReferenceEquals(_webcamSource, webcam)) return;
|
_webcam = _layoutStore.Webcam;
|
||||||
|
var newDevice = _webcam?.DeviceId;
|
||||||
|
|
||||||
if (_webcamSource != null && !string.IsNullOrWhiteSpace(_webcamSource.DeviceId))
|
if (!string.IsNullOrWhiteSpace(previousDevice) && previousDevice != newDevice)
|
||||||
_ = _cameraManager.ReleaseAsync(_webcamSource.DeviceId);
|
_ = _cameraManager.ReleaseAllAsync(previousDevice);
|
||||||
|
|
||||||
_webcamSource = webcam;
|
OnPropertyChanged(nameof(CanChangeWebcam));
|
||||||
OnPropertyChanged(nameof(CanAddWebcam));
|
OnPropertyChanged(nameof(CanAddWebcamToActiveScene));
|
||||||
if (webcam != null && !string.IsNullOrWhiteSpace(webcam.DeviceId))
|
|
||||||
_ = _cameraManager.AcquireAsync(webcam.DeviceId);
|
if (_webcam == null || string.IsNullOrWhiteSpace(newDevice)) return;
|
||||||
|
if (previousDevice == newDevice) return;
|
||||||
|
foreach (var unused in Scenes.SelectMany(s => s.Elements.OfType<WebcamSceneConfig>()))
|
||||||
|
_ = _cameraManager.AcquireAsync(newDevice);
|
||||||
}
|
}
|
||||||
|
|
||||||
// CameraManager creates the shared WriteableBitmap on the UI thread at the
|
// CameraManager creates the shared WriteableBitmap on the UI thread at the
|
||||||
// device's frame size; the webcam Source's preview picks it up from here.
|
// device's frame size; every scene's webcam config picks it up from here.
|
||||||
private void OnCameraPreviewBitmapChanged(string deviceId, WriteableBitmap bitmap)
|
private void OnCameraPreviewBitmapChanged(string deviceId, WriteableBitmap bitmap)
|
||||||
{
|
{
|
||||||
if (_webcamSource?.DeviceId == deviceId)
|
if (_webcam?.DeviceId != deviceId) return;
|
||||||
_webcamSource.VideoImageSource = bitmap;
|
foreach (var scene in Scenes)
|
||||||
|
foreach (var config in scene.Elements.OfType<WebcamSceneConfig>())
|
||||||
|
config.VideoImageSource = bitmap;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Shutdown()
|
public void Shutdown()
|
||||||
@@ -606,7 +673,7 @@ public class MainViewModel : ViewModelBase
|
|||||||
_saveDebounce?.Stop();
|
_saveDebounce?.Stop();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_layoutStore.Save(Scenes);
|
_layoutStore.Save(Scenes, _webcam);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -661,31 +728,36 @@ public class MainViewModel : ViewModelBase
|
|||||||
private void WireScene(Scene scene)
|
private void WireScene(Scene scene)
|
||||||
{
|
{
|
||||||
scene.PropertyChanged += OnScenePropertyChanged;
|
scene.PropertyChanged += OnScenePropertyChanged;
|
||||||
scene.Sources.CollectionChanged += OnSourcesChanged;
|
scene.Elements.CollectionChanged += OnElementsChanged;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void UnwireScene(Scene scene)
|
private void UnwireScene(Scene scene)
|
||||||
{
|
{
|
||||||
scene.PropertyChanged -= OnScenePropertyChanged;
|
scene.PropertyChanged -= OnScenePropertyChanged;
|
||||||
scene.Sources.CollectionChanged -= OnSourcesChanged;
|
scene.Elements.CollectionChanged -= OnElementsChanged;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnScenePropertyChanged(object? sender, PropertyChangedEventArgs e)
|
private void OnScenePropertyChanged(object? sender, PropertyChangedEventArgs e)
|
||||||
=> ScheduleSave();
|
=> ScheduleSave();
|
||||||
|
|
||||||
private void OnSourcesChanged(object? sender, NotifyCollectionChangedEventArgs e)
|
private void OnElementsChanged(object? sender, NotifyCollectionChangedEventArgs e)
|
||||||
{
|
{
|
||||||
if (e.NewItems != null)
|
if (e.NewItems != null)
|
||||||
foreach (Source source in e.NewItems)
|
foreach (SceneElement element in e.NewItems)
|
||||||
source.PropertyChanged += OnSourcePropertyChanged;
|
element.PropertyChanged += OnElementPropertyChanged;
|
||||||
if (e.OldItems != null)
|
if (e.OldItems != null)
|
||||||
foreach (Source source in e.OldItems)
|
foreach (SceneElement element in e.OldItems)
|
||||||
source.PropertyChanged -= OnSourcePropertyChanged;
|
element.PropertyChanged -= OnElementPropertyChanged;
|
||||||
|
OnPropertyChanged(nameof(CanAddWebcamToActiveScene));
|
||||||
ScheduleSave();
|
ScheduleSave();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnSourcePropertyChanged(object? sender, PropertyChangedEventArgs e)
|
private void OnElementPropertyChanged(object? sender, PropertyChangedEventArgs e)
|
||||||
=> ScheduleSave();
|
{
|
||||||
|
if (sender is WebcamSceneConfig && e.PropertyName == nameof(SceneElement.IsVisible))
|
||||||
|
OnPropertyChanged(nameof(CanShowWebcamInActiveScene));
|
||||||
|
ScheduleSave();
|
||||||
|
}
|
||||||
|
|
||||||
private void ScheduleSave()
|
private void ScheduleSave()
|
||||||
{
|
{
|
||||||
@@ -728,9 +800,14 @@ public class MainViewModel : ViewModelBase
|
|||||||
var scene = ActiveScene;
|
var scene = ActiveScene;
|
||||||
if (scene == null) return;
|
if (scene == null) return;
|
||||||
|
|
||||||
|
if (string.Equals(type, "webcam", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
_ = AddWebcamToActiveSceneAsync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var sourceType = type?.ToLowerInvariant() switch
|
var sourceType = type?.ToLowerInvariant() switch
|
||||||
{
|
{
|
||||||
"webcam" => SourceType.Webcam,
|
|
||||||
"screen" => SourceType.DisplayCapture,
|
"screen" => SourceType.DisplayCapture,
|
||||||
"window" => SourceType.WindowCapture,
|
"window" => SourceType.WindowCapture,
|
||||||
"background" => SourceType.Background,
|
"background" => SourceType.Background,
|
||||||
@@ -739,7 +816,6 @@ public class MainViewModel : ViewModelBase
|
|||||||
};
|
};
|
||||||
var baseName = sourceType switch
|
var baseName = sourceType switch
|
||||||
{
|
{
|
||||||
SourceType.Webcam => "Webcam",
|
|
||||||
SourceType.DisplayCapture => "Screen",
|
SourceType.DisplayCapture => "Screen",
|
||||||
SourceType.WindowCapture => "Window",
|
SourceType.WindowCapture => "Window",
|
||||||
SourceType.Background => "Background",
|
SourceType.Background => "Background",
|
||||||
@@ -748,12 +824,6 @@ public class MainViewModel : ViewModelBase
|
|||||||
_ => "Source",
|
_ => "Source",
|
||||||
};
|
};
|
||||||
|
|
||||||
if (sourceType == SourceType.Webcam)
|
|
||||||
{
|
|
||||||
_ = AddWebcamSourceAsync();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sourceType == SourceType.Background)
|
if (sourceType == SourceType.Background)
|
||||||
{
|
{
|
||||||
var bytes = PickImageBytes("Choose a backdrop image");
|
var bytes = PickImageBytes("Choose a backdrop image");
|
||||||
@@ -761,7 +831,7 @@ public class MainViewModel : ViewModelBase
|
|||||||
var assetId = AddAsset(bytes);
|
var assetId = AddAsset(bytes);
|
||||||
if (assetId == null) return;
|
if (assetId == null) return;
|
||||||
|
|
||||||
var existing = scene.Sources.FirstOrDefault(s => s.Type == SourceType.Background);
|
var existing = scene.Elements.OfType<Source>().FirstOrDefault(s => s.Type == SourceType.Background);
|
||||||
if (existing != null)
|
if (existing != null)
|
||||||
{
|
{
|
||||||
existing.AssetId = assetId;
|
existing.AssetId = assetId;
|
||||||
@@ -769,26 +839,73 @@ public class MainViewModel : ViewModelBase
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
scene.Sources.Add(new Source { Name = baseName, Type = SourceType.Background, AssetId = assetId });
|
scene.Elements.Add(new Source { Name = baseName, Type = SourceType.Background, AssetId = assetId });
|
||||||
OnPropertyChanged(nameof(ShowEmptySceneHint));
|
OnPropertyChanged(nameof(ShowEmptySceneHint));
|
||||||
OnPropertyChanged(nameof(ShowSourcesEmptyHint));
|
OnPropertyChanged(nameof(ShowSourcesEmptyHint));
|
||||||
UpdateActiveBackground();
|
UpdateActiveBackground();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var count = scene.Sources.Count(s => s.Type == sourceType);
|
var count = scene.Elements.OfType<Source>().Count(s => s.Type == sourceType);
|
||||||
var name = count == 0 ? baseName : $"{baseName} {count + 1}";
|
var name = count == 0 ? baseName : $"{baseName} {count + 1}";
|
||||||
|
|
||||||
scene.Sources.Add(new Source { Name = name, Type = sourceType });
|
scene.Elements.Add(new Source { Name = name, Type = sourceType });
|
||||||
OnPropertyChanged(nameof(ShowEmptySceneHint));
|
OnPropertyChanged(nameof(ShowEmptySceneHint));
|
||||||
OnPropertyChanged(nameof(ShowSourcesEmptyHint));
|
OnPropertyChanged(nameof(ShowSourcesEmptyHint));
|
||||||
UpdateActiveBackground();
|
UpdateActiveBackground();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task AddWebcamSourceAsync()
|
// Adds the webcam to the active scene. The camera is picked once app-wide
|
||||||
|
// (first add); afterwards "Add Webcam" just places the existing webcam here
|
||||||
|
// at the default spot — each scene's config is independent (webcam.{scene}.config).
|
||||||
|
private async Task AddWebcamToActiveSceneAsync()
|
||||||
{
|
{
|
||||||
var scene = ActiveScene;
|
var scene = ActiveScene;
|
||||||
if (scene == null || _webcamSource != null) return;
|
if (scene == null || scene.WebcamConfig != null) return;
|
||||||
|
|
||||||
|
if (_webcam == null)
|
||||||
|
{
|
||||||
|
var dialog = new CameraPickerDialog(new CameraPickerViewModel(_cameraEnumerator))
|
||||||
|
{
|
||||||
|
Owner = Application.Current.MainWindow
|
||||||
|
};
|
||||||
|
if (dialog.ShowDialog() != true || dialog.PickedDevice == null) return;
|
||||||
|
var device = dialog.PickedDevice;
|
||||||
|
_webcam = new Webcam { DeviceId = device.Id, Name = device.DisplayName };
|
||||||
|
OnPropertyChanged(nameof(CanChangeWebcam));
|
||||||
|
}
|
||||||
|
|
||||||
|
var config = new WebcamSceneConfig
|
||||||
|
{
|
||||||
|
WebcamId = _webcam.Id,
|
||||||
|
Name = _webcam.Name,
|
||||||
|
Width = 480,
|
||||||
|
Height = 270,
|
||||||
|
X = MasterFrameWidth - 480 - 32,
|
||||||
|
Y = MasterFrameHeight - 270 - 32,
|
||||||
|
};
|
||||||
|
|
||||||
|
scene.Elements.Add(config);
|
||||||
|
SelectedElement = config;
|
||||||
|
OnPropertyChanged(nameof(ShowEmptySceneHint));
|
||||||
|
OnPropertyChanged(nameof(ShowSourcesEmptyHint));
|
||||||
|
UpdateActiveBackground();
|
||||||
|
|
||||||
|
var started = await _cameraManager.AcquireAsync(_webcam.DeviceId);
|
||||||
|
if (!started)
|
||||||
|
{
|
||||||
|
MessageBox.Show(
|
||||||
|
"Couldn't start that camera. It may be in use by another app, or Windows camera access may be turned off.",
|
||||||
|
"ytLlive", MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Swaps the device on the app-wide webcam identity. The old device is stopped
|
||||||
|
// unconditionally; each scene that uses the webcam re-acquires the new one so
|
||||||
|
// the per-config refcount stays honest.
|
||||||
|
private async Task ChangeWebcamAsync()
|
||||||
|
{
|
||||||
|
if (_webcam == null) return;
|
||||||
|
|
||||||
var dialog = new CameraPickerDialog(new CameraPickerViewModel(_cameraEnumerator))
|
var dialog = new CameraPickerDialog(new CameraPickerViewModel(_cameraEnumerator))
|
||||||
{
|
{
|
||||||
@@ -796,34 +913,48 @@ public class MainViewModel : ViewModelBase
|
|||||||
};
|
};
|
||||||
if (dialog.ShowDialog() != true || dialog.PickedDevice == null) return;
|
if (dialog.ShowDialog() != true || dialog.PickedDevice == null) return;
|
||||||
|
|
||||||
var device = dialog.PickedDevice;
|
var oldDevice = _webcam.DeviceId;
|
||||||
var source = new Source
|
var newDevice = dialog.PickedDevice.Id;
|
||||||
|
if (oldDevice == newDevice) return;
|
||||||
|
|
||||||
|
_webcam.DeviceId = newDevice;
|
||||||
|
_webcam.Name = dialog.PickedDevice.DisplayName;
|
||||||
|
ScheduleSave();
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(oldDevice))
|
||||||
|
await _cameraManager.ReleaseAllAsync(oldDevice);
|
||||||
|
|
||||||
|
foreach (var unused in Scenes.SelectMany(s => s.Elements.OfType<WebcamSceneConfig>()))
|
||||||
{
|
{
|
||||||
Name = "Webcam",
|
var started = await _cameraManager.AcquireAsync(newDevice);
|
||||||
Type = SourceType.Webcam,
|
|
||||||
DeviceId = device.Id,
|
|
||||||
Width = 480,
|
|
||||||
Height = 270,
|
|
||||||
X = 1920 - 480 - 32,
|
|
||||||
Y = 1080 - 270 - 32,
|
|
||||||
};
|
|
||||||
|
|
||||||
_webcamSource = source;
|
|
||||||
OnPropertyChanged(nameof(CanAddWebcam));
|
|
||||||
scene.Sources.Add(source);
|
|
||||||
SelectedSource = source;
|
|
||||||
OnPropertyChanged(nameof(ShowEmptySceneHint));
|
|
||||||
OnPropertyChanged(nameof(ShowSourcesEmptyHint));
|
|
||||||
UpdateActiveBackground();
|
|
||||||
|
|
||||||
var started = await _cameraManager.AcquireAsync(device.Id);
|
|
||||||
if (!started)
|
if (!started)
|
||||||
{
|
{
|
||||||
MessageBox.Show(
|
MessageBox.Show(
|
||||||
"Couldn't start that camera. It may be in use by another app, or Windows camera access may be turned off.",
|
"Couldn't start that camera. It may be in use by another app, or Windows camera access may be turned off.",
|
||||||
"ytLlive", MessageBoxButton.OK, MessageBoxImage.Warning);
|
"ytLlive", MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Show Webcam" from a right-click on the empty preview. Unhides this scene's
|
||||||
|
// existing (hidden) config — the config row survives Hide in this scene — or
|
||||||
|
// adds the webcam here for the first time (which opens the camera picker).
|
||||||
|
private void ShowWebcamInActiveScene()
|
||||||
|
{
|
||||||
|
var scene = ActiveScene;
|
||||||
|
if (scene == null) return;
|
||||||
|
|
||||||
|
var config = scene.WebcamConfig;
|
||||||
|
if (config != null)
|
||||||
|
{
|
||||||
|
config.IsVisible = true;
|
||||||
|
SelectedElement = config;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = AddWebcamToActiveSceneAsync();
|
||||||
|
}
|
||||||
|
|
||||||
private void AddImage()
|
private void AddImage()
|
||||||
{
|
{
|
||||||
@@ -859,7 +990,7 @@ public class MainViewModel : ViewModelBase
|
|||||||
{
|
{
|
||||||
var candidates = new List<ReuseImageCandidate>();
|
var candidates = new List<ReuseImageCandidate>();
|
||||||
foreach (var scene in Scenes)
|
foreach (var scene in Scenes)
|
||||||
foreach (var source in scene.Sources.Where(s => s.Type == SourceType.Image && !string.IsNullOrWhiteSpace(s.AssetId)))
|
foreach (var source in scene.Elements.OfType<Source>().Where(s => s.Type == SourceType.Image && !string.IsNullOrWhiteSpace(s.AssetId)))
|
||||||
{
|
{
|
||||||
candidates.Add(new ReuseImageCandidate
|
candidates.Add(new ReuseImageCandidate
|
||||||
{
|
{
|
||||||
@@ -919,7 +1050,7 @@ public class MainViewModel : ViewModelBase
|
|||||||
var scene = ActiveScene;
|
var scene = ActiveScene;
|
||||||
if (scene == null || string.IsNullOrWhiteSpace(assetId)) return;
|
if (scene == null || string.IsNullOrWhiteSpace(assetId)) return;
|
||||||
|
|
||||||
var count = scene.Sources.Count(s => s.Type == SourceType.Image);
|
var count = scene.Elements.OfType<Source>().Count(s => s.Type == SourceType.Image);
|
||||||
var name = count == 0 ? "Image" : $"Image {count + 1}";
|
var name = count == 0 ? "Image" : $"Image {count + 1}";
|
||||||
|
|
||||||
var source = new Source { Name = name, Type = SourceType.Image, AssetId = assetId };
|
var source = new Source { Name = name, Type = SourceType.Image, AssetId = assetId };
|
||||||
@@ -934,25 +1065,39 @@ public class MainViewModel : ViewModelBase
|
|||||||
source.Y = (1080 - source.Height) / 2;
|
source.Y = (1080 - source.Height) / 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
scene.Sources.Add(source);
|
scene.Elements.Add(source);
|
||||||
SelectedSource = source;
|
SelectedElement = source;
|
||||||
OnPropertyChanged(nameof(ShowEmptySceneHint));
|
OnPropertyChanged(nameof(ShowEmptySceneHint));
|
||||||
OnPropertyChanged(nameof(ShowSourcesEmptyHint));
|
OnPropertyChanged(nameof(ShowSourcesEmptyHint));
|
||||||
UpdateActiveBackground();
|
UpdateActiveBackground();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void RemoveSource(Source? source)
|
// Removes an element from the active scene. For a webcam config this drops the
|
||||||
|
// scene's usage and releases one camera reference (CameraManager stops the
|
||||||
|
// session when the last using scene lets go). When the last config anywhere is
|
||||||
|
// removed, the webcam identity is cleared too — re-adding opens the picker
|
||||||
|
// again instead of silently resurrecting the old camera.
|
||||||
|
private void RemoveElement(SceneElement? element)
|
||||||
{
|
{
|
||||||
var scene = ActiveScene;
|
var scene = ActiveScene;
|
||||||
if (scene == null || source == null) return;
|
if (scene == null || element == null) return;
|
||||||
if (source.Type == SourceType.Webcam && ReferenceEquals(source, _webcamSource))
|
if (element is WebcamSceneConfig && _webcam != null)
|
||||||
{
|
{
|
||||||
_webcamSource = null;
|
if (SelectedElement == element)
|
||||||
OnPropertyChanged(nameof(CanAddWebcam));
|
SelectedElement = null;
|
||||||
if (!string.IsNullOrWhiteSpace(source.DeviceId))
|
_ = _cameraManager.ReleaseAsync(_webcam.DeviceId);
|
||||||
_ = _cameraManager.ReleaseAsync(source.DeviceId);
|
|
||||||
}
|
}
|
||||||
scene.Sources.Remove(source);
|
scene.Elements.Remove(element);
|
||||||
|
|
||||||
|
if (element is WebcamSceneConfig && _webcam != null
|
||||||
|
&& !Scenes.Any(s => s.Elements.OfType<WebcamSceneConfig>().Any()))
|
||||||
|
{
|
||||||
|
_webcam = null;
|
||||||
|
OnPropertyChanged(nameof(CanChangeWebcam));
|
||||||
|
OnPropertyChanged(nameof(CanAddWebcamToActiveScene));
|
||||||
|
OnPropertyChanged(nameof(CanShowWebcamInActiveScene));
|
||||||
|
}
|
||||||
|
|
||||||
OnPropertyChanged(nameof(ShowEmptySceneHint));
|
OnPropertyChanged(nameof(ShowEmptySceneHint));
|
||||||
OnPropertyChanged(nameof(ShowSourcesEmptyHint));
|
OnPropertyChanged(nameof(ShowSourcesEmptyHint));
|
||||||
UpdateActiveBackground();
|
UpdateActiveBackground();
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ MVVM layer. See [`schema.md`](../schema.md) for the memory-map conventions.
|
|||||||
|
|
||||||
| File | Purpose |
|
| File | Purpose |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| `MainViewModel.cs` | The app brain: scenes/sources collections + commands, stream state (`IsLive`/`IsOffline`/`IsConnected`), chat feed, overlays, layout save/open, **resolution dropdown** (`QualityOptions`, `SelectedQuality`, `ResolutionHelp`, `ApplyStreamQuality`) that computes the output rect over the 1920×1080 master (`OutputRectX/Y/W/H`, `DimRects`, `IsOutputCropped`, `ResolutionBadgeText`). Auth: loads the saved DPAPI session at startup (`LoadSavedSessionAsync`), `SignInAsync` feeds the GoLive dialog; **End Livestream signs out** (`StopStream` clears session + token — crash-safe). **Webcam:** owns `CameraManager` (MediaCapture), `AddWebcamSourceAsync` (picker → default 480×270 bottom-right placement → acquire), one-camera app-wide (`CanAddWebcam` greys the menu), `ReacquireWebcam` after layout load, `OnCameraPreviewBitmapChanged` forwards the shared bitmap into `Source.VideoImageSource` |
|
| `MainViewModel.cs` | The app brain: scenes/elements collections + commands, stream state (`IsLive`/`IsOffline`/`IsConnected`), chat feed, overlays, layout save/open, **resolution dropdown** (`QualityOptions`, `SelectedQuality`, `ResolutionHelp`, `ApplyStreamQuality`) that computes the output rect over the 1920×1080 master (`OutputRectX/Y/W/H`, `DimRects`, `IsOutputCropped`, `ResolutionBadgeText`). Auth: loads the saved DPAPI session at startup (`LoadSavedSessionAsync`), `SignInAsync` feeds the GoLive dialog; **End Livestream signs out** (`StopStream` clears session + token — crash-safe). **Webcam:** owns `CameraManager` (MediaCapture), `AddWebcamToActiveSceneAsync` (picker → default 480×270 bottom-right placement → acquire; greys out when the active scene already has a config via `CanAddWebcamToActiveScene`), `ChangeWebcamAsync` (device swap — `ReleaseAllAsync` old + re-acquire), `ShowWebcamInActiveScene` (reveal hidden config / empty-canvas right-click), `ReacquireWebcam` after layout load, `OnCameraPreviewBitmapChanged` forwards the shared bitmap into every `WebcamSceneConfig.VideoImageSource`; `SelectedElement` drives the preview overlay + `internal ClampWebcamToBounds` (50% cap seam) |
|
||||||
| `GoLiveViewModel.cs` | Start Stream dialog: **account row** (saved channel shown with Change Account, or Sign in to YouTube; Start gated on `IsSignedIn`/`IsBusy`) + title/description/visibility, start/cancel requests |
|
| `GoLiveViewModel.cs` | Start Stream dialog: **account row** (saved channel shown with Change Account, or Sign in to YouTube; Start gated on `IsSignedIn`/`IsBusy`) + title/description/visibility, start/cancel requests |
|
||||||
| `ReuseImageViewModel.cs` | Add Image dialog: candidate list (`ReuseImageCandidate`), reuse/new/cancel |
|
| `ReuseImageViewModel.cs` | Add Image dialog: candidate list (`ReuseImageCandidate`), reuse/new/cancel |
|
||||||
| `CameraPickerViewModel.cs` | Add Webcam dialog: async camera list (loading / has / none states), `UseRequested(CameraDeviceInfo)`/`CancelRequested` |
|
| `CameraPickerViewModel.cs` | Add Webcam dialog: async camera list (loading / has / none states), `UseRequested(CameraDeviceInfo)`/`CancelRequested` |
|
||||||
|
|||||||
@@ -28,11 +28,18 @@ default response style above stays in effect unless invoked.
|
|||||||
## Run
|
## Run
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
dotnet build # Windows only — WPF requires Windows target
|
# From WSL, ALWAYS use the Windows dotnet host — never Linux `dotnet` for this project:
|
||||||
dotnet run
|
"/mnt/c/Program Files/dotnet/dotnet.exe" build "C:\Users\gramp\Documents\Code\projects\ytLive\ytLive.csproj"
|
||||||
|
"/mnt/c/Program Files/dotnet/dotnet.exe" run
|
||||||
```
|
```
|
||||||
|
|
||||||
Note: `EnableWindowsTargeting=true` is set in `ytLive.csproj`, so the project can be restored/built from WSL, but running requires Windows.
|
`EnableWindowsTargeting=true` in `ytLive.csproj` lets a cold restore work from WSL, but a Linux
|
||||||
|
`dotnet run`/`build` re-downloads 100M+ of `windowsdesktop.app.*` packs into the Linux NuGet cache
|
||||||
|
(which lacks them) over the slow 9p `/mnt/c` bridge — twice, because the WPF `_wpftmp` generated
|
||||||
|
project triggers a second restore (203s observed). The Windows cache has the SQLite packages and the
|
||||||
|
packs resolve from `C:\Program Files\dotnet\packs`, so the Windows host never re-downloads.
|
||||||
|
Never use `--no-restore` right after an interrupted restore — the stale `project.assets.json`
|
||||||
|
produces misleading `NETSDK1064` "package not found" errors. Running requires Windows anyway.
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
@@ -46,7 +53,8 @@ dotnet.exe vstest "C:\...\ytLive.Tests\bin\Debug\net8.0-windows10.0.19041.0\ytLi
|
|||||||
Good Dog Rule: ONE integration test per branch, ONE test per PR. Current: TokenStore DPAPI
|
Good Dog Rule: ONE integration test per branch, ONE test per PR. Current: TokenStore DPAPI
|
||||||
roundtrip/corrupt/missing/clear, OAuth exchange/refresh/ClearSession, CameraManager refcount +
|
roundtrip/corrupt/missing/clear, OAuth exchange/refresh/ClearSession, CameraManager refcount +
|
||||||
frame pump + failure handling (fakes for the WinRT seams), real-`MainWindow` round-clip
|
frame pump + failure handling (fakes for the WinRT seams), real-`MainWindow` round-clip
|
||||||
interaction test, LayoutStore delete roundtrip — 13 passing.
|
interaction test, LayoutStore delete roundtrip, LayoutStore pre-round-rect-dims roundtrip —
|
||||||
|
25 passing.
|
||||||
|
|
||||||
### Real-MainWindow tests MUST be hermetic (DB pollution bug)
|
### Real-MainWindow tests MUST be hermetic (DB pollution bug)
|
||||||
|
|
||||||
@@ -61,8 +69,8 @@ test's fake `test-camera`). Rule: a test that constructs `MainWindow` MUST first
|
|||||||
`ytLive.csproj` has `InternalsVisibleTo("ytLive.Tests")`.
|
`ytLive.csproj` has `InternalsVisibleTo("ytLive.Tests")`.
|
||||||
|
|
||||||
The layout DB is a **full rewrite per save** (delete all, re-insert from memory), so
|
The layout DB is a **full rewrite per save** (delete all, re-insert from memory), so
|
||||||
save/load round trips are exact: a source removed in the UI (`RemoveSource` →
|
save/load round trips are exact: an element removed in the UI (`RemoveElement` →
|
||||||
`scene.Sources.Remove` → `OnSourcesChanged` → debounced `ScheduleSave`, plus `Shutdown` on
|
`scene.Elements.Remove` → `OnElementsChanged` → debounced `ScheduleSave`, plus `Shutdown` on
|
||||||
close) does **not** come back after reload (`LayoutStorePersistenceTests` guards this).
|
close) does **not** come back after reload (`LayoutStorePersistenceTests` guards this).
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
@@ -92,7 +100,7 @@ C# / WPF (.NET 8) following MVVM:
|
|||||||
### Current limitations / TODOs
|
### Current limitations / TODOs
|
||||||
|
|
||||||
- `Helpers/OAuthCredentials.cs` contains the real ClientId/ClientSecret. Auth is complete and the session **persists via Windows DPAPI** (`Helpers/TokenStore.cs` → `%APPDATA%\ytLlive\ytLlive.auth`, CurrentUser scope), reloaded best-effort at startup with a proactive refresh of a near-expiry access token. Sign-in/Change Account lives **inside the Start Stream dialog** (two-state flow — no separate Connect button). A **graceful End Livestream signs out**: `StopStream()` clears the session + token, so the next go-live needs a fresh sign-in; a crash never runs End, so the token survives and the creator stays signed in. `YouTubeAuthService` takes an optional `HttpClient` + `sessionChanged` callback (test seam + save hook; services are still constructed in `MainViewModel`)
|
- `Helpers/OAuthCredentials.cs` contains the real ClientId/ClientSecret. Auth is complete and the session **persists via Windows DPAPI** (`Helpers/TokenStore.cs` → `%APPDATA%\ytLlive\ytLlive.auth`, CurrentUser scope), reloaded best-effort at startup with a proactive refresh of a near-expiry access token. Sign-in/Change Account lives **inside the Start Stream dialog** (two-state flow — no separate Connect button). A **graceful End Livestream signs out**: `StopStream()` clears the session + token, so the next go-live needs a fresh sign-in; a crash never runs End, so the token survives and the creator stays signed in. `YouTubeAuthService` takes an optional `HttpClient` + `sessionChanged` callback (test seam + save hook; services are still constructed in `MainViewModel`)
|
||||||
- Scene/source/asset layout persists (SQLite, schema v2); the OAuth session persists (DPAPI); the paid-unlock state does not (yet — itch.io key verification pending)
|
- Scene/source/asset layout persists (SQLite, schema v4); the OAuth session persists (DPAPI); the paid-unlock state does not (yet — itch.io key verification pending)
|
||||||
- `YouTubeStreamService` uses hardcoded `1080p`/`60fps` and per-broadcast streams — must switch to the v3 `variable` reusable stream
|
- `YouTubeStreamService` uses hardcoded `1080p`/`60fps` and per-broadcast streams — must switch to the v3 `variable` reusable stream
|
||||||
- Webcam capture is shipped (milestone 1); **screen capture, scene compositing/encoding, RTMP are next**
|
- Webcam capture is shipped (milestone 1); **screen capture, scene compositing/encoding, RTMP are next**
|
||||||
- `StreamConfig` defaults (`TargetBitrate=6000`, `Resolution="1920x1080"`) are stale — the live dropdown drives `StreamHealth.CurrentBitrate`/`FPS` instead
|
- `StreamConfig` defaults (`TargetBitrate=6000`, `Resolution="1920x1080"`) are stale — the live dropdown drives `StreamHealth.CurrentBitrate`/`FPS` instead
|
||||||
@@ -118,26 +126,62 @@ C# / WPF (.NET 8) following MVVM:
|
|||||||
- **TFM:** `net8.0-windows10.0.19041.0` (app + tests) pulls the WinRT projection from the SDK reference
|
- **TFM:** `net8.0-windows10.0.19041.0` (app + tests) pulls the WinRT projection from the SDK reference
|
||||||
packs — no NuGet package, no capability manifest (unpackaged desktop app works; the Windows privacy
|
packs — no NuGet package, no capability manifest (unpackaged desktop app works; the Windows privacy
|
||||||
camera toggle still applies). `EnableWindowsTargeting` keeps WSL builds working.
|
camera toggle still applies). `EnableWindowsTargeting` keeps WSL builds working.
|
||||||
- **One camera app-wide:** `CameraManager` refcounts sessions by `DeviceId` (a session is created with
|
- **One webcam, many scenes (schema v3):** a singleton `Webcam` row holds the identity
|
||||||
`RefCount = 1`; repeat acquire bumps it; the last release stops + disposes). The Add menu greys Webcam
|
(`Id`/`DeviceId`/`Name`); each scene gets its own `WebcamSceneConfig` (position/size/clip/mirror/
|
||||||
out once a webcam source exists anywhere (`CanAddWebcam`); the "OBS time" story is a one-camera limit.
|
border/`IsVisible`). `Scene.Elements` holds images (`Source`) and, at most once, the webcam
|
||||||
|
(`WebcamSceneConfig`); `Scene.WebcamConfig` is the accessor. `CameraManager` refcounts capture
|
||||||
|
sessions by `DeviceId` (a session starts at `RefCount = 1`; repeat acquire bumps it; the last
|
||||||
|
release stops + disposes). The Add Webcam menu greys out when the **active** scene already has a
|
||||||
|
config (`CanAddWebcamToActiveScene`); showing a hidden webcam reuses the existing config
|
||||||
|
(`CanShowWebcamInActiveScene` / empty-canvas right-click "Show Webcam"). **Removing the last webcam
|
||||||
|
config anywhere clears the identity** (`_webcam = null`), so re-adding opens the picker again
|
||||||
|
instead of resurrecting the old camera.
|
||||||
|
- **Round→rect restores the aspect (persisted, schema v4):** `SceneElement.ToggleClipShape()`
|
||||||
|
snapshots the rectangular Width/Height into public `RectWidth`/`RectHeight` before going Round and
|
||||||
|
restores them when switching back — otherwise the Round resize lock (square) would leave a square
|
||||||
|
behind. The rect dims are **persisted** (`WebcamSceneConfig.RectWidth`/`RectHeight`, nullable), so a
|
||||||
|
reloaded Round webcam still restores its pre-Round aspect instead of staying square. A one-time
|
||||||
|
`HealLegacySquareRect` (load only) widens a pre-v4 `Traditional` config that ended up square to 16:9
|
||||||
|
(keeps height; Round and explicit rect dims are untouched).
|
||||||
|
- **Device swap / layout reload:** `ChangeWebcamAsync` (picker) and `ReacquireWebcam` (after load)
|
||||||
|
release the old device with `ReleaseAllAsync` — a forced full drop that zeroes the refcount and
|
||||||
|
stops the source regardless of how many scenes held it (the per-config count isn't known once the
|
||||||
|
scenes are replaced) — then `AcquireAsync` the new device once per config.
|
||||||
- **Shared bitmap, coalesced updates:** one `WriteableBitmap` per active camera, created on the UI thread
|
- **Shared bitmap, coalesced updates:** one `WriteableBitmap` per active camera, created on the UI thread
|
||||||
at the device's frame size (first frame), forwarded to the single webcam `Source.VideoImageSource` via
|
at the device's frame size (first frame), forwarded to every `WebcamSceneConfig.VideoImageSource` via
|
||||||
`PreviewBitmapChanged`. Frames arrive on a worker thread; `CameraManager` coalesces onto the dispatcher
|
`PreviewBitmapChanged`. Frames arrive on a worker thread; `CameraManager` coalesces onto the dispatcher
|
||||||
(at most one pending copy per session, at `Render` priority, always copying the latest frame) so a 60fps
|
(at most one pending copy per session, at `Render` priority, always copying the latest frame) so a 60fps
|
||||||
device never drowns the render thread.
|
device never drowns the render thread.
|
||||||
- **Clip/mirror:** per-Source `ClipShape` (Traditional rectangle / Round ellipse) + `IsMirrored`
|
- **Clip/mirror/border:** per-element `ClipShape` (Traditional rectangle / Round ellipse) + `IsMirrored`
|
||||||
(`ScaleX = -1`). Rendered in the preview DataTemplate (Image for Traditional, `ImageBrush` inside an
|
(`ScaleX = -1`) + the OSB-standard static border (`BorderColor` `#RRGGBB` or `""`=none, `BorderOpacity`
|
||||||
`Ellipse` for Round); toggled from the source chip; persisted in the layout DB.
|
0–1, `BorderWidth` 0–20, `BorderAnimation` `None|Pulse|Chase|Rainbow|Shimmer|MarchingAnts|Glow|
|
||||||
|
Electricity|Sparkles`). Rendered in the preview DataTemplate; toggled from the element's right-click
|
||||||
|
context menu (webcam menu: Change Webcam…, Border Effect submenu — all 9 items enabled, values persist,
|
||||||
|
rendering stays static until the animation tier ships — Border Color, Opacity/Thickness sliders, Hide in
|
||||||
|
this scene, Remove); persisted in the layout DB. The Add menu shows when no webcam exists; the empty
|
||||||
|
preview canvas has its own Show Webcam entry.
|
||||||
- The Round `Ellipse` is wrapped in a `Viewbox Stretch="Uniform"` holding a `1x1` Grid, so it renders
|
- The Round `Ellipse` is wrapped in a `Viewbox Stretch="Uniform"` holding a `1x1` Grid, so it renders
|
||||||
as a true circle (diameter = the shorter source dimension) instead of an oval stretched to the
|
as a true circle (diameter = the shorter element dimension) instead of an oval stretched to the
|
||||||
source rect — and the traditional `Image` keeps `UniformToFill` over the full rect.
|
element rect — and the traditional `Image` keeps `UniformToFill` over the full rect. The Round
|
||||||
|
border is a centered `Ellipse` at `Width/Height = RoundBorderSize`.
|
||||||
- Resizing locks to a square (`_resizeAspect = 1`) while `ClipShape == Round`.
|
- Resizing locks to a square (`_resizeAspect = 1`) while `ClipShape == Round`.
|
||||||
|
- **Webcam size clamp:** `ClampWebcamToBounds` (internal — test seam) enforces 50% of the 1920×1080
|
||||||
|
master per dimension (960×540 max) and no less than 10% (192×108) at resize + load;
|
||||||
|
`RoundBorderSize` follows the clamped height. `WebcamSafeguardTests` guards the clamp.
|
||||||
- **Hit-testing:** a `Grid` without `Background` only hit-tests where its children draw, so clicks in
|
- **Hit-testing:** a `Grid` without `Background` only hit-tests where its children draw, so clicks in
|
||||||
the empty corners of a round clip fell through to `Window_PreviewMouseLeftButtonDown` and deselected
|
the empty corners of a round clip fell through to `Window_PreviewMouseLeftButtonDown` and deselected
|
||||||
the source — making the corner handle ungrabbable. The source Grid carries `Background="Transparent"`
|
the element — making the corner handle ungrabbable. The element Grid carries
|
||||||
(whole rect draggable) and the `SelectionOverlay` (dashed border + corner dot) is
|
`Background="Transparent"` (whole rect draggable; the empty canvas Grid uses the same trick for
|
||||||
`IsHitTestVisible="False"` so it never intercepts the click.
|
right-click Show Webcam) and the `SelectionOverlay` (dashed border + corner dot) is
|
||||||
|
`IsHitTestVisible="False"` so it never intercepts the click. Two things make the webcam menu work:
|
||||||
|
(1) the `ContextMenu` pins its own `DataContext` to `PlacementTarget.DataContext` — a `ContextMenu`
|
||||||
|
isn't in the visual tree, so without it the Click-handler `DataContext:` patterns (and the
|
||||||
|
IsChecked/slider bindings) silently fail; (2) `Themes/Controls.xaml` ships a full dark `MenuItem`
|
||||||
|
template — `PART_Popup` (submenu popups), a popup `ItemsPresenter` (the Border Opacity/Thickness
|
||||||
|
sliders live in Items, so they render in a hover flyout), a `✓` checkmark column, and a `›` arrow
|
||||||
|
driven by `HasItems`. An earlier bare `Border + Header` template dropped all three: submenus never
|
||||||
|
opened, sliders never rendered, checkmarks never showed — the menu looked dead even though the
|
||||||
|
Click handlers were fine.
|
||||||
- **GPU posture:** webcam frames are CPU (GPU-agnostic; WPF hardware-presents the preview anyway). Hardware
|
- **GPU posture:** webcam frames are CPU (GPU-agnostic; WPF hardware-presents the preview anyway). Hardware
|
||||||
encoders (NVENC/AMF/QSV) matter for the encoder task, not capture. D3DImage GPU compositing is deferred
|
encoders (NVENC/AMF/QSV) matter for the encoder task, not capture. D3DImage GPU compositing is deferred
|
||||||
to the encoder task.
|
to the encoder task.
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
WINPATH=$(wslpath -w "$PWD")
|
||||||
|
"/mnt/c/Program Files/dotnet/dotnet.exe" run --project "$WINPATH\\ytLive.csproj"
|
||||||
@@ -9,37 +9,79 @@ namespace ytLive.Tests;
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The layout DB is a full rewrite on every save (DELETE all scenes/sources,
|
/// The layout DB is a full rewrite on every save (DELETE all scenes/sources,
|
||||||
/// re-insert from memory). This guards the round trip: sources the user deletes
|
/// re-insert from memory). This guards the round trip: webcam configs the user
|
||||||
/// in the UI must not come back after a save + reload.
|
/// removes in the UI must not come back after a save + reload.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class LayoutStorePersistenceTests
|
public class LayoutStorePersistenceTests
|
||||||
{
|
{
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Deleted_Webcam_Source_Does_Not_Return_After_Save_And_Reload()
|
public void Deleted_Webcam_Config_Does_Not_Return_After_Save_And_Reload()
|
||||||
{
|
{
|
||||||
var path = Path.Combine(Path.GetTempPath(), $"ytLlive-layout-{Guid.NewGuid():N}.db");
|
var path = Path.Combine(Path.GetTempPath(), $"ytLlive-layout-{Guid.NewGuid():N}.db");
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
using var store = new LayoutStore(path);
|
using var store = new LayoutStore(path);
|
||||||
|
var webcam = new Webcam { DeviceId = "real-device", Name = "Logitech" };
|
||||||
var scene = new Scene { Name = "Starting" };
|
var scene = new Scene { Name = "Starting" };
|
||||||
scene.Sources.Add(new Source
|
scene.Elements.Add(new WebcamSceneConfig
|
||||||
{
|
{
|
||||||
Name = "Webcam",
|
WebcamId = webcam.Id,
|
||||||
Type = SourceType.Webcam,
|
Name = webcam.Name,
|
||||||
DeviceId = "real-device",
|
|
||||||
Width = 480,
|
Width = 480,
|
||||||
Height = 270,
|
Height = 270,
|
||||||
});
|
});
|
||||||
store.Save(new[] { scene });
|
store.Save(new[] { scene }, webcam);
|
||||||
|
|
||||||
var reloaded = store.Load();
|
var reloaded = store.Load();
|
||||||
Assert.Single(reloaded[0].Sources);
|
var config = Assert.Single(reloaded[0].Elements);
|
||||||
|
Assert.IsType<WebcamSceneConfig>(config);
|
||||||
|
|
||||||
reloaded[0].Sources.RemoveAt(0);
|
reloaded[0].Elements.RemoveAt(0);
|
||||||
store.Save(reloaded);
|
store.Save(reloaded, store.Webcam);
|
||||||
|
|
||||||
var afterDelete = store.Load();
|
var afterDelete = store.Load();
|
||||||
Assert.Empty(afterDelete[0].Sources);
|
Assert.Empty(afterDelete[0].Elements);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
SqliteConnection.ClearAllPools();
|
||||||
|
try { File.Delete(path); } catch { /* best-effort cleanup */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Round-to-rect restore is persisted (schema v4): a Round webcam resized to a
|
||||||
|
// square saves its pre-Round rect dims, and a reloaded config restores them on
|
||||||
|
// toggle-back instead of staying square.
|
||||||
|
[Fact]
|
||||||
|
public void Pre_Round_Rect_Dims_Survive_Save_And_Reload()
|
||||||
|
{
|
||||||
|
var path = Path.Combine(Path.GetTempPath(), $"ytLlive-layout-{Guid.NewGuid():N}.db");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var store = new LayoutStore(path);
|
||||||
|
var webcam = new Webcam { DeviceId = "real-device", Name = "Logitech" };
|
||||||
|
var scene = new Scene { Name = "Starting" };
|
||||||
|
scene.Elements.Add(new WebcamSceneConfig
|
||||||
|
{
|
||||||
|
WebcamId = webcam.Id,
|
||||||
|
Name = webcam.Name,
|
||||||
|
Width = 400,
|
||||||
|
Height = 400,
|
||||||
|
ClipShape = ClipShape.Round,
|
||||||
|
RectWidth = 480,
|
||||||
|
RectHeight = 270,
|
||||||
|
});
|
||||||
|
store.Save(new[] { scene }, webcam);
|
||||||
|
|
||||||
|
var reloaded = store.Load();
|
||||||
|
var config = Assert.IsType<WebcamSceneConfig>(Assert.Single(reloaded[0].Elements));
|
||||||
|
|
||||||
|
config.ToggleClipShape();
|
||||||
|
Assert.Equal(ClipShape.Traditional, config.ClipShape);
|
||||||
|
Assert.Equal(480, config.Width);
|
||||||
|
Assert.Equal(270, config.Height);
|
||||||
|
Assert.Null(config.RectWidth);
|
||||||
|
Assert.Null(config.RectHeight);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -61,29 +61,28 @@ public sealed class RoundClipInteractionTests
|
|||||||
window.UpdateLayout();
|
window.UpdateLayout();
|
||||||
|
|
||||||
var scene = vm.ActiveScene!;
|
var scene = vm.ActiveScene!;
|
||||||
var source = new Source
|
var webcam = new WebcamSceneConfig
|
||||||
{
|
{
|
||||||
|
WebcamId = "test-camera",
|
||||||
Name = "Webcam",
|
Name = "Webcam",
|
||||||
Type = SourceType.Webcam,
|
|
||||||
DeviceId = "test-camera",
|
|
||||||
X = 1408,
|
X = 1408,
|
||||||
Y = 778,
|
Y = 778,
|
||||||
Width = 480,
|
Width = 480,
|
||||||
Height = 270,
|
Height = 270,
|
||||||
};
|
};
|
||||||
scene.Sources.Add(source);
|
scene.Elements.Add(webcam);
|
||||||
vm.SelectedSource = source;
|
vm.SelectedElement = webcam;
|
||||||
window.UpdateLayout();
|
window.UpdateLayout();
|
||||||
|
|
||||||
var previewGrid = (Grid)window.FindName("PreviewGrid")!;
|
var previewGrid = (Grid)window.FindName("PreviewGrid")!;
|
||||||
var canvasGrid = (Grid)window.FindName("CanvasGrid")!;
|
var canvasGrid = (Grid)window.FindName("CanvasGrid")!;
|
||||||
var toWindow = canvasGrid.TransformToVisual(window);
|
var toWindow = canvasGrid.TransformToVisual(window);
|
||||||
|
|
||||||
var corner = new Point(source.X + source.Width, source.Y + source.Height);
|
var corner = new Point(webcam.X + webcam.Width, webcam.Y + webcam.Height);
|
||||||
|
|
||||||
foreach (var shape in new[] { ClipShape.Traditional, ClipShape.Round })
|
foreach (var shape in new[] { ClipShape.Traditional, ClipShape.Round })
|
||||||
{
|
{
|
||||||
source.ClipShape = shape;
|
webcam.ClipShape = shape;
|
||||||
window.UpdateLayout();
|
window.UpdateLayout();
|
||||||
|
|
||||||
var cornerInWindow = toWindow.Transform(corner);
|
var cornerInWindow = toWindow.Transform(corner);
|
||||||
@@ -100,7 +99,7 @@ public sealed class RoundClipInteractionTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The round clip must render as a circle (square bounding box), not an oval.
|
// The round clip must render as a circle (square bounding box), not an oval.
|
||||||
source.ClipShape = ClipShape.Round;
|
webcam.ClipShape = ClipShape.Round;
|
||||||
window.UpdateLayout();
|
window.UpdateLayout();
|
||||||
var ellipse = FindRoundEllipse(window);
|
var ellipse = FindRoundEllipse(window);
|
||||||
Assert.NotNull(ellipse);
|
Assert.NotNull(ellipse);
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
using Xunit;
|
||||||
|
using ytLive.Models;
|
||||||
|
using ytLive.ViewModels;
|
||||||
|
|
||||||
|
namespace ytLive.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The webcam size safeguard: no scene placement may exceed half the 1920×1080
|
||||||
|
/// master frame (960×540), nor drop below 10% of it (192×108). Enforced at
|
||||||
|
/// resize (MainWindow) and defensively again on every layout load.
|
||||||
|
/// </summary>
|
||||||
|
public class WebcamSafeguardTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Oversize_Webcam_Is_Capped_To_Half_The_Frame()
|
||||||
|
{
|
||||||
|
var webcam = new WebcamSceneConfig { Width = 1920, Height = 1080 };
|
||||||
|
MainViewModel.ClampWebcamToBounds(webcam);
|
||||||
|
Assert.Equal(MainViewModel.WebcamMaxWidth, webcam.Width);
|
||||||
|
Assert.Equal(MainViewModel.WebcamMaxHeight, webcam.Height);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Wide_Webcam_Scales_To_Fit_Both_Dimensions()
|
||||||
|
{
|
||||||
|
var webcam = new WebcamSceneConfig { Width = 1000, Height = 500 };
|
||||||
|
MainViewModel.ClampWebcamToBounds(webcam);
|
||||||
|
Assert.Equal(960, webcam.Width);
|
||||||
|
Assert.Equal(480, webcam.Height);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Tiny_Webcam_Is_Brought_Up_To_The_Minimum()
|
||||||
|
{
|
||||||
|
var webcam = new WebcamSceneConfig { Width = 10, Height = 10 };
|
||||||
|
MainViewModel.ClampWebcamToBounds(webcam);
|
||||||
|
Assert.Equal(MainViewModel.WebcamMinWidth, webcam.Width);
|
||||||
|
Assert.Equal(MainViewModel.WebcamMinWidth, webcam.Height);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Short_Wide_Webcam_Meets_Both_Minimums()
|
||||||
|
{
|
||||||
|
var webcam = new WebcamSceneConfig { Width = 100, Height = 20 };
|
||||||
|
MainViewModel.ClampWebcamToBounds(webcam);
|
||||||
|
Assert.True(webcam.Width >= MainViewModel.WebcamMinWidth);
|
||||||
|
Assert.Equal(MainViewModel.WebcamMinHeight, webcam.Height);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Round_Border_Size_Tracks_The_Shorter_Clamped_Dimension()
|
||||||
|
{
|
||||||
|
var webcam = new WebcamSceneConfig { Width = 1920, Height = 500 };
|
||||||
|
MainViewModel.ClampWebcamToBounds(webcam);
|
||||||
|
Assert.Equal(960, webcam.Width);
|
||||||
|
Assert.Equal(250, webcam.Height);
|
||||||
|
Assert.Equal(250, webcam.RoundBorderSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Round_Then_Back_To_Rect_Restores_The_Original_Aspect()
|
||||||
|
{
|
||||||
|
var webcam = new WebcamSceneConfig { Width = 480, Height = 270 };
|
||||||
|
webcam.ToggleClipShape();
|
||||||
|
Assert.Equal(ClipShape.Round, webcam.ClipShape);
|
||||||
|
Assert.Equal(480, webcam.Width);
|
||||||
|
Assert.Equal(270, webcam.Height);
|
||||||
|
Assert.Equal(480, webcam.RectWidth);
|
||||||
|
Assert.Equal(270, webcam.RectHeight);
|
||||||
|
|
||||||
|
webcam.ToggleClipShape();
|
||||||
|
Assert.Equal(ClipShape.Traditional, webcam.ClipShape);
|
||||||
|
Assert.Equal(480, webcam.Width);
|
||||||
|
Assert.Equal(270, webcam.Height);
|
||||||
|
Assert.Null(webcam.RectWidth);
|
||||||
|
Assert.Null(webcam.RectHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The persisted-reload repro: Round + square dims (from a resize while Round),
|
||||||
|
// no in-memory snapshot — exactly what a relaunch produces. The persisted rect
|
||||||
|
// dims must restore the 16:9 on toggle-back.
|
||||||
|
[Fact]
|
||||||
|
public void Reloaded_Round_Config_Restores_Persisted_Rect_Dims()
|
||||||
|
{
|
||||||
|
var webcam = new WebcamSceneConfig
|
||||||
|
{
|
||||||
|
Width = 400,
|
||||||
|
Height = 400,
|
||||||
|
ClipShape = ClipShape.Round,
|
||||||
|
RectWidth = 480,
|
||||||
|
RectHeight = 270,
|
||||||
|
};
|
||||||
|
|
||||||
|
webcam.ToggleClipShape();
|
||||||
|
Assert.Equal(ClipShape.Traditional, webcam.ClipShape);
|
||||||
|
Assert.Equal(480, webcam.Width);
|
||||||
|
Assert.Equal(270, webcam.Height);
|
||||||
|
Assert.Null(webcam.RectWidth);
|
||||||
|
Assert.Null(webcam.RectHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Legacy_Square_Rect_Is_Widened_To_16x9()
|
||||||
|
{
|
||||||
|
var webcam = new WebcamSceneConfig { Width = 414.92, Height = 414.92 };
|
||||||
|
MainViewModel.HealLegacySquareRect(webcam);
|
||||||
|
Assert.Equal(738, webcam.Width);
|
||||||
|
Assert.Equal(414.92, webcam.Height);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Legacy_Square_Heal_Leaves_Non_Square_Untouched()
|
||||||
|
{
|
||||||
|
var webcam = new WebcamSceneConfig { Width = 480, Height = 270 };
|
||||||
|
MainViewModel.HealLegacySquareRect(webcam);
|
||||||
|
Assert.Equal(480, webcam.Width);
|
||||||
|
Assert.Equal(270, webcam.Height);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Legacy_Square_Heal_Leaves_Round_Untouched()
|
||||||
|
{
|
||||||
|
var webcam = new WebcamSceneConfig { Width = 400, Height = 400, ClipShape = ClipShape.Round };
|
||||||
|
MainViewModel.HealLegacySquareRect(webcam);
|
||||||
|
Assert.Equal(400, webcam.Width);
|
||||||
|
Assert.Equal(400, webcam.Height);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Legacy_Square_Heal_Respects_Explicit_Rect_Dims()
|
||||||
|
{
|
||||||
|
var webcam = new WebcamSceneConfig { Width = 400, Height = 400, RectWidth = 480, RectHeight = 270 };
|
||||||
|
MainViewModel.HealLegacySquareRect(webcam);
|
||||||
|
Assert.Equal(400, webcam.Width);
|
||||||
|
Assert.Equal(400, webcam.Height);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user