TASK 3 milestone 1: webcam capture (MediaCapture CPU-first, CameraManager refcount, picker, clip/mirror, schema v2, tests)
This commit is contained in:
@@ -0,0 +1,90 @@
|
|||||||
|
<Window x:Class="ytLive.CameraPickerDialog"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
Title="Add Webcam" Height="340" Width="460"
|
||||||
|
Icon="/Assets/llama-logo-icon.png"
|
||||||
|
WindowStartupLocation="CenterOwner" ResizeMode="NoResize"
|
||||||
|
ShowInTaskbar="False" Background="#1a1a2e">
|
||||||
|
|
||||||
|
<Window.Resources>
|
||||||
|
<ResourceDictionary>
|
||||||
|
<BooleanToVisibilityConverter x:Key="BoolToVis"/>
|
||||||
|
|
||||||
|
<Style x:Key="CandidateItem" TargetType="ListBoxItem">
|
||||||
|
<Setter Property="HorizontalContentAlignment" Value="Stretch"/>
|
||||||
|
<Setter Property="Cursor" Value="Hand"/>
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate TargetType="ListBoxItem">
|
||||||
|
<Border x:Name="Bd" Background="Transparent" CornerRadius="4" Padding="6,5" Margin="0,2">
|
||||||
|
<ContentPresenter/>
|
||||||
|
</Border>
|
||||||
|
<ControlTemplate.Triggers>
|
||||||
|
<Trigger Property="IsMouseOver" Value="True">
|
||||||
|
<Setter TargetName="Bd" Property="Background" Value="#1c2a52"/>
|
||||||
|
</Trigger>
|
||||||
|
<Trigger Property="IsSelected" Value="True">
|
||||||
|
<Setter TargetName="Bd" Property="Background" Value="#2a2450"/>
|
||||||
|
</Trigger>
|
||||||
|
</ControlTemplate.Triggers>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
</ResourceDictionary>
|
||||||
|
</Window.Resources>
|
||||||
|
|
||||||
|
<Grid Margin="24">
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="*"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
|
<TextBlock Grid.Row="0" Text="Add Webcam" FontSize="18" FontWeight="Bold"
|
||||||
|
Foreground="#e0e0e0" Margin="0,0,0,14"/>
|
||||||
|
|
||||||
|
<TextBlock Grid.Row="1" Text="Pick the camera to add to your stream."
|
||||||
|
Foreground="#a0a0b0" FontSize="13" TextWrapping="Wrap" Margin="0,0,0,12"/>
|
||||||
|
|
||||||
|
<Grid Grid.Row="2">
|
||||||
|
<ListBox Background="Transparent" BorderThickness="0"
|
||||||
|
ItemContainerStyle="{StaticResource CandidateItem}"
|
||||||
|
ItemsSource="{Binding Cameras}"
|
||||||
|
SelectedItem="{Binding SelectedCamera, Mode=TwoWay}"
|
||||||
|
Visibility="{Binding HasCameras, Converter={StaticResource BoolToVis}}">
|
||||||
|
<ListBox.ItemTemplate>
|
||||||
|
<DataTemplate>
|
||||||
|
<StackPanel Orientation="Horizontal">
|
||||||
|
<TextBlock Text="📷" FontSize="15" VerticalAlignment="Center" Margin="0,0,10,0"/>
|
||||||
|
<TextBlock Text="{Binding Name}" Foreground="#e0e0e0" FontSize="13"
|
||||||
|
VerticalAlignment="Center" TextTrimming="CharacterEllipsis"/>
|
||||||
|
</StackPanel>
|
||||||
|
</DataTemplate>
|
||||||
|
</ListBox.ItemTemplate>
|
||||||
|
</ListBox>
|
||||||
|
|
||||||
|
<StackPanel VerticalAlignment="Center" HorizontalAlignment="Center"
|
||||||
|
Visibility="{Binding IsLoading, Converter={StaticResource BoolToVis}}">
|
||||||
|
<TextBlock Text="Searching for cameras…" Foreground="#a0a0b0" FontSize="13"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<StackPanel VerticalAlignment="Center"
|
||||||
|
Visibility="{Binding NoCameras, Converter={StaticResource BoolToVis}}">
|
||||||
|
<TextBlock Text="No cameras found" Foreground="#e0e0e0" FontSize="15"
|
||||||
|
FontWeight="SemiBold" HorizontalAlignment="Center"/>
|
||||||
|
<TextBlock Text="Check that a webcam is connected and that Windows camera access is enabled."
|
||||||
|
Foreground="#a0a0b0" FontSize="13" TextWrapping="Wrap" MaxWidth="360"
|
||||||
|
TextAlignment="Center" HorizontalAlignment="Center" Margin="0,8,0,0"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<StackPanel Grid.Row="3" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,12,0,0">
|
||||||
|
<Button Content="Cancel" Command="{Binding CancelCommand}"
|
||||||
|
Style="{StaticResource YtButtonSecondary}" Margin="0,0,8,0"/>
|
||||||
|
<Button Content="Use Selected" Command="{Binding UseSelectedCommand}"
|
||||||
|
Style="{StaticResource YtButton}" MinWidth="110"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</Window>
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
using System.Windows;
|
||||||
|
using ytLive.Helpers;
|
||||||
|
using ytLive.Services;
|
||||||
|
using ytLive.ViewModels;
|
||||||
|
|
||||||
|
namespace ytLive;
|
||||||
|
|
||||||
|
public partial class CameraPickerDialog : Window
|
||||||
|
{
|
||||||
|
public CameraDeviceInfo? PickedDevice { get; private set; }
|
||||||
|
|
||||||
|
public CameraPickerDialog(CameraPickerViewModel viewModel)
|
||||||
|
{
|
||||||
|
AppLog.Write("CameraPickerDialog ctor: before InitializeComponent");
|
||||||
|
InitializeComponent();
|
||||||
|
AppLog.Write("CameraPickerDialog ctor: after InitializeComponent");
|
||||||
|
DataContext = viewModel;
|
||||||
|
viewModel.UseRequested += device =>
|
||||||
|
{
|
||||||
|
PickedDevice = device;
|
||||||
|
DialogResult = true;
|
||||||
|
};
|
||||||
|
viewModel.CancelRequested += () => DialogResult = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
+47
-4
@@ -229,7 +229,9 @@
|
|||||||
Click="AddSourceButton_Click">
|
Click="AddSourceButton_Click">
|
||||||
<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}"
|
||||||
|
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"/>
|
||||||
<MenuItem Header="Image" Command="{Binding AddImageCommand}"/>
|
<MenuItem Header="Image" Command="{Binding AddImageCommand}"/>
|
||||||
@@ -319,9 +321,14 @@
|
|||||||
</ItemsControl.ItemContainerStyle>
|
</ItemsControl.ItemContainerStyle>
|
||||||
<ItemsControl.ItemTemplate>
|
<ItemsControl.ItemTemplate>
|
||||||
<DataTemplate>
|
<DataTemplate>
|
||||||
<Image Width="{Binding Width}" Height="{Binding Height}"
|
<Grid Width="{Binding Width}" Height="{Binding Height}"
|
||||||
Opacity="{Binding Opacity}" Stretch="UniformToFill"
|
Opacity="{Binding Opacity}"
|
||||||
Source="{Binding ImageSource}"
|
RenderTransformOrigin="0.5,0.5">
|
||||||
|
<Grid.RenderTransform>
|
||||||
|
<ScaleTransform ScaleX="{Binding MirrorScale}"/>
|
||||||
|
</Grid.RenderTransform>
|
||||||
|
<Image Stretch="UniformToFill"
|
||||||
|
Source="{Binding DisplaySource}"
|
||||||
RenderOptions.BitmapScalingMode="HighQuality">
|
RenderOptions.BitmapScalingMode="HighQuality">
|
||||||
<Image.Style>
|
<Image.Style>
|
||||||
<Style TargetType="Image">
|
<Style TargetType="Image">
|
||||||
@@ -330,10 +337,37 @@
|
|||||||
<DataTrigger Binding="{Binding Type}" Value="Image">
|
<DataTrigger Binding="{Binding Type}" Value="Image">
|
||||||
<Setter Property="Visibility" Value="Visible"/>
|
<Setter Property="Visibility" Value="Visible"/>
|
||||||
</DataTrigger>
|
</DataTrigger>
|
||||||
|
<MultiDataTrigger>
|
||||||
|
<MultiDataTrigger.Conditions>
|
||||||
|
<Condition Binding="{Binding Type}" Value="Webcam"/>
|
||||||
|
<Condition Binding="{Binding ClipShape}" Value="Traditional"/>
|
||||||
|
</MultiDataTrigger.Conditions>
|
||||||
|
<Setter Property="Visibility" Value="Visible"/>
|
||||||
|
</MultiDataTrigger>
|
||||||
</Style.Triggers>
|
</Style.Triggers>
|
||||||
</Style>
|
</Style>
|
||||||
</Image.Style>
|
</Image.Style>
|
||||||
</Image>
|
</Image>
|
||||||
|
<Ellipse>
|
||||||
|
<Ellipse.Style>
|
||||||
|
<Style TargetType="Ellipse">
|
||||||
|
<Setter Property="Visibility" Value="Collapsed"/>
|
||||||
|
<Style.Triggers>
|
||||||
|
<MultiDataTrigger>
|
||||||
|
<MultiDataTrigger.Conditions>
|
||||||
|
<Condition Binding="{Binding Type}" Value="Webcam"/>
|
||||||
|
<Condition Binding="{Binding ClipShape}" Value="Round"/>
|
||||||
|
</MultiDataTrigger.Conditions>
|
||||||
|
<Setter Property="Visibility" Value="Visible"/>
|
||||||
|
</MultiDataTrigger>
|
||||||
|
</Style.Triggers>
|
||||||
|
</Style>
|
||||||
|
</Ellipse.Style>
|
||||||
|
<Ellipse.Fill>
|
||||||
|
<ImageBrush ImageSource="{Binding DisplaySource}" Stretch="UniformToFill"/>
|
||||||
|
</Ellipse.Fill>
|
||||||
|
</Ellipse>
|
||||||
|
</Grid>
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
</ItemsControl.ItemTemplate>
|
</ItemsControl.ItemTemplate>
|
||||||
</ItemsControl>
|
</ItemsControl>
|
||||||
@@ -441,6 +475,15 @@
|
|||||||
<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"
|
||||||
|
Visibility="{Binding IsWebcamSelected, Converter={StaticResource BoolToVis}}">
|
||||||
|
<Button Content="{Binding SelectedSource.MirrorButtonText}"
|
||||||
|
Style="{StaticResource YtButtonSecondary}" Padding="10,4"
|
||||||
|
Click="MirrorButton_Click"/>
|
||||||
|
<Button Content="{Binding SelectedSource.ShapeButtonText}"
|
||||||
|
Style="{StaticResource YtButtonSecondary}" Padding="10,4"
|
||||||
|
Margin="8,0,0,0" Click="ShapeButton_Click"/>
|
||||||
|
</StackPanel>
|
||||||
<TextBlock Text="Drag to move · drag the corner dot to resize"
|
<TextBlock Text="Drag to move · drag the corner dot to resize"
|
||||||
Foreground="#a0a0b0" FontSize="11" Margin="0,4,0,0"/>
|
Foreground="#a0a0b0" FontSize="11" Margin="0,4,0,0"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|||||||
+20
-4
@@ -115,13 +115,29 @@ public partial class MainWindow : Window
|
|||||||
|
|
||||||
private void UpdateSelectionOverlay()
|
private void UpdateSelectionOverlay()
|
||||||
{
|
{
|
||||||
var selected = _viewModel.SelectedSource is { Type: SourceType.Image };
|
var selected = _viewModel.SelectedSource is { } s && IsDraggableSource(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.SelectedSource!.Opacity * 100)}%";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Static images and the webcam share the move/resize/selection behavior.
|
||||||
|
private static bool IsDraggableSource(Source source)
|
||||||
|
=> source.Type is SourceType.Image or SourceType.Webcam;
|
||||||
|
|
||||||
|
private void MirrorButton_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (_viewModel.SelectedSource is { Type: SourceType.Webcam } source)
|
||||||
|
source.IsMirrored = !source.IsMirrored;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ShapeButton_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (_viewModel.SelectedSource is { Type: SourceType.Webcam } source)
|
||||||
|
source.ClipShape = source.ClipShape == ClipShape.Traditional ? ClipShape.Round : ClipShape.Traditional;
|
||||||
|
}
|
||||||
|
|
||||||
private void OpacitySlider_ValueChanged(object sender, RoutedPropertyChangedEventArgs<double> e)
|
private void OpacitySlider_ValueChanged(object sender, RoutedPropertyChangedEventArgs<double> e)
|
||||||
=> OpacityValueText.Text = $"{Math.Round(e.NewValue * 100)}%";
|
=> OpacityValueText.Text = $"{Math.Round(e.NewValue * 100)}%";
|
||||||
|
|
||||||
@@ -134,10 +150,10 @@ public partial class MainWindow : Window
|
|||||||
var p = toCanvas.Transform(e.GetPosition(grid));
|
var p = toCanvas.Transform(e.GetPosition(grid));
|
||||||
var selected = _viewModel.SelectedSource;
|
var selected = _viewModel.SelectedSource;
|
||||||
|
|
||||||
if (selected is { Type: SourceType.Image } && HitHandle(e.GetPosition(grid), selected))
|
if (selected is { } sel && IsDraggableSource(sel) && HitHandle(e.GetPosition(grid), sel))
|
||||||
{
|
{
|
||||||
_isResizing = true;
|
_isResizing = true;
|
||||||
_resizeAspect = selected.Width / Math.Max(1, selected.Height);
|
_resizeAspect = sel.Width / Math.Max(1, sel.Height);
|
||||||
grid.CaptureMouse();
|
grid.CaptureMouse();
|
||||||
e.Handled = true;
|
e.Handled = true;
|
||||||
return;
|
return;
|
||||||
@@ -218,7 +234,7 @@ public partial class MainWindow : Window
|
|||||||
for (var i = scene.Sources.Count - 1; i >= 0; i--)
|
for (var i = scene.Sources.Count - 1; i >= 0; i--)
|
||||||
{
|
{
|
||||||
var source = scene.Sources[i];
|
var source = scene.Sources[i];
|
||||||
if (source.Type != SourceType.Image || !source.IsEnabled) continue;
|
if (!IsDraggableSource(source) || !source.IsEnabled) continue;
|
||||||
if (p.X >= source.X && p.X <= source.X + source.Width &&
|
if (p.X >= source.X && p.X <= source.X + source.Width &&
|
||||||
p.Y >= source.Y && p.Y <= source.Y + source.Height)
|
p.Y >= source.Y && p.Y <= source.Y + source.Height)
|
||||||
return source;
|
return source;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
using System.Runtime.CompilerServices;
|
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;
|
||||||
@@ -15,6 +16,12 @@ public enum SourceType
|
|||||||
TextOverlay
|
TextOverlay
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public enum ClipShape
|
||||||
|
{
|
||||||
|
Traditional,
|
||||||
|
Round
|
||||||
|
}
|
||||||
|
|
||||||
public class Source : INotifyPropertyChanged
|
public class Source : INotifyPropertyChanged
|
||||||
{
|
{
|
||||||
public event PropertyChangedEventHandler? PropertyChanged;
|
public event PropertyChangedEventHandler? PropertyChanged;
|
||||||
@@ -52,6 +59,54 @@ public class Source : INotifyPropertyChanged
|
|||||||
private string? _deviceId;
|
private string? _deviceId;
|
||||||
public string? DeviceId { get => _deviceId; set => Set(ref _deviceId, value); }
|
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;
|
||||||
@@ -65,6 +120,7 @@ public class Source : INotifyPropertyChanged
|
|||||||
{
|
{
|
||||||
_imageSource = ImageCache.Get(value ?? string.Empty);
|
_imageSource = ImageCache.Get(value ?? string.Empty);
|
||||||
Raise(nameof(ImageSource));
|
Raise(nameof(ImageSource));
|
||||||
|
Raise(nameof(DisplaySource));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,7 @@ 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`, `Sources` collection |
|
||||||
| `Source.cs` | A source: `SourceType` enum (Image/Webcam/Screen/Background/Text), `X`/`Y`/`Width`/`Height`, `Opacity`, `IsEnabled`, `ImageSource`, asset identity |
|
| `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` |
|
||||||
| `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) |
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
namespace ytLive.Services;
|
||||||
|
|
||||||
|
public sealed class CameraDeviceInfo
|
||||||
|
{
|
||||||
|
public string Id { get; }
|
||||||
|
public string DisplayName { get; }
|
||||||
|
|
||||||
|
public CameraDeviceInfo(string id, string displayName)
|
||||||
|
{
|
||||||
|
Id = id;
|
||||||
|
DisplayName = displayName;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Media;
|
||||||
|
using System.Windows.Media.Imaging;
|
||||||
|
using System.Windows.Threading;
|
||||||
|
using ytLive.Helpers;
|
||||||
|
|
||||||
|
namespace ytLive.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Owns webcam capture sessions app-wide, refcounted by DeviceId. One device =
|
||||||
|
/// at most one capture, one shared WriteableBitmap; the last release stops and
|
||||||
|
/// disposes the source. Frames arrive on a worker thread and are coalesced onto
|
||||||
|
/// the UI dispatcher (at most one pending copy per session, using the latest
|
||||||
|
/// frame) so a 60fps device doesn't drown the render thread.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CameraManager : IDisposable
|
||||||
|
{
|
||||||
|
private sealed class CameraSession
|
||||||
|
{
|
||||||
|
public string DeviceId { get; }
|
||||||
|
public ICameraFrameSource Source { get; }
|
||||||
|
public Action<VideoFrame>? FrameHandler;
|
||||||
|
public int RefCount;
|
||||||
|
public bool Started;
|
||||||
|
public WriteableBitmap? PreviewBitmap;
|
||||||
|
public VideoFrame? LatestFrame;
|
||||||
|
public bool FramePending;
|
||||||
|
|
||||||
|
public CameraSession(string deviceId, ICameraFrameSource source)
|
||||||
|
{
|
||||||
|
DeviceId = deviceId;
|
||||||
|
Source = source;
|
||||||
|
RefCount = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly ICameraEnumerator _enumerator;
|
||||||
|
private readonly Func<string, ICameraFrameSource> _frameSourceFactory;
|
||||||
|
private readonly Dispatcher? _uiDispatcher;
|
||||||
|
private readonly Dictionary<string, CameraSession> _sessions = new();
|
||||||
|
private readonly object _gate = new();
|
||||||
|
|
||||||
|
/// <summary>Raised on the UI thread when a camera's shared preview bitmap is first created.</summary>
|
||||||
|
public event Action<string, WriteableBitmap>? PreviewBitmapChanged;
|
||||||
|
|
||||||
|
/// <summary>Raised when a capture fails to start (device in use, access denied, no preview source).</summary>
|
||||||
|
public event Action<string, string>? CameraFailed;
|
||||||
|
|
||||||
|
public CameraManager(ICameraEnumerator enumerator, Func<string, ICameraFrameSource> frameSourceFactory,
|
||||||
|
Dispatcher? uiDispatcher = null)
|
||||||
|
{
|
||||||
|
_enumerator = enumerator;
|
||||||
|
_frameSourceFactory = frameSourceFactory;
|
||||||
|
_uiDispatcher = uiDispatcher;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ICameraEnumerator Enumerator => _enumerator;
|
||||||
|
|
||||||
|
/// <summary>Increments the refcount for a device, starting capture the first time.</summary>
|
||||||
|
public async Task<bool> AcquireAsync(string deviceId)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(deviceId)) return false;
|
||||||
|
|
||||||
|
CameraSession session;
|
||||||
|
bool shouldStart;
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (_sessions.TryGetValue(deviceId, out var existing))
|
||||||
|
{
|
||||||
|
existing.RefCount++;
|
||||||
|
shouldStart = false;
|
||||||
|
session = existing;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
session = new CameraSession(deviceId, _frameSourceFactory(deviceId));
|
||||||
|
session.FrameHandler = frame => OnFrameAvailable(session, frame);
|
||||||
|
session.Source.FrameAvailable += session.FrameHandler;
|
||||||
|
_sessions[deviceId] = session;
|
||||||
|
shouldStart = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!shouldStart) return session.Started;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await session.Source.StartAsync();
|
||||||
|
session.Started = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
_sessions.Remove(deviceId);
|
||||||
|
session.Source.FrameAvailable -= session.FrameHandler;
|
||||||
|
AppLog.Write($"CameraManager: failed to start camera '{deviceId}': {ex.Message}");
|
||||||
|
CameraFailed?.Invoke(deviceId, ex.Message);
|
||||||
|
await SafeStopAsync(session.Source);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Decrements the refcount; stops and disposes the source at zero.</summary>
|
||||||
|
public async Task ReleaseAsync(string deviceId)
|
||||||
|
{
|
||||||
|
CameraSession? toStop = null;
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (!_sessions.TryGetValue(deviceId, out var session)) return;
|
||||||
|
if (--session.RefCount > 0) return;
|
||||||
|
_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)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
return _sessions.TryGetValue(deviceId, out var session) ? session.LatestFrame : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
List<CameraSession> sessions;
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
sessions = new List<CameraSession>(_sessions.Values);
|
||||||
|
_sessions.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var session in sessions)
|
||||||
|
{
|
||||||
|
session.Source.FrameAvailable -= session.FrameHandler;
|
||||||
|
_ = SafeStopAsync(session.Source);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task SafeStopAsync(ICameraFrameSource source)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await source.StopAsync();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLog.Write($"CameraManager: stopping camera failed: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool TryGetActiveSession(CameraSession session)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
return _sessions.TryGetValue(session.DeviceId, out var current) && ReferenceEquals(current, session);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnFrameAvailable(CameraSession session, VideoFrame frame)
|
||||||
|
{
|
||||||
|
if (!TryGetActiveSession(session)) return;
|
||||||
|
session.LatestFrame = frame;
|
||||||
|
|
||||||
|
if (session.PreviewBitmap == null)
|
||||||
|
{
|
||||||
|
if (_uiDispatcher == null) return;
|
||||||
|
if (_uiDispatcher.CheckAccess())
|
||||||
|
EnsurePreviewBitmap(session);
|
||||||
|
else
|
||||||
|
_uiDispatcher.BeginInvoke(() =>
|
||||||
|
{
|
||||||
|
if (TryGetActiveSession(session))
|
||||||
|
EnsurePreviewBitmap(session);
|
||||||
|
}, DispatcherPriority.Render);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_uiDispatcher == null || _uiDispatcher.CheckAccess())
|
||||||
|
{
|
||||||
|
CopyFrame(session);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.FramePending) return;
|
||||||
|
session.FramePending = true;
|
||||||
|
_uiDispatcher.BeginInvoke(() =>
|
||||||
|
{
|
||||||
|
session.FramePending = false;
|
||||||
|
if (TryGetActiveSession(session) && session.PreviewBitmap != null)
|
||||||
|
CopyFrame(session);
|
||||||
|
}, DispatcherPriority.Render);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EnsurePreviewBitmap(CameraSession session)
|
||||||
|
{
|
||||||
|
if (session.PreviewBitmap != null || session.LatestFrame == null) return;
|
||||||
|
var frame = session.LatestFrame;
|
||||||
|
var bitmap = new WriteableBitmap(frame.Width, frame.Height, 96, 96, PixelFormats.Bgra32, null);
|
||||||
|
bitmap.WritePixels(new Int32Rect(0, 0, frame.Width, frame.Height), frame.BgraPixels, frame.Stride, 0);
|
||||||
|
session.PreviewBitmap = bitmap;
|
||||||
|
PreviewBitmapChanged?.Invoke(session.DeviceId, bitmap);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CopyFrame(CameraSession session)
|
||||||
|
{
|
||||||
|
var bitmap = session.PreviewBitmap;
|
||||||
|
var frame = session.LatestFrame;
|
||||||
|
if (bitmap == null || frame == null) return;
|
||||||
|
if (frame.Width != bitmap.PixelWidth || frame.Height != bitmap.PixelHeight) return;
|
||||||
|
bitmap.WritePixels(new Int32Rect(0, 0, frame.Width, frame.Height), frame.BgraPixels, frame.Stride, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace ytLive.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Enumerates physical capture devices. Seam so the picker and CameraManager
|
||||||
|
/// never touch WinRT directly (tests inject fakes).
|
||||||
|
/// </summary>
|
||||||
|
public interface ICameraEnumerator
|
||||||
|
{
|
||||||
|
Task<IReadOnlyList<CameraDeviceInfo>> GetCamerasAsync();
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
namespace ytLive.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A running capture source for one device. Raises normalized BGRA frames from
|
||||||
|
/// a worker thread; callers must marshal to the UI thread. Seam so CameraManager
|
||||||
|
/// is testable without WinRT.
|
||||||
|
/// </summary>
|
||||||
|
public interface ICameraFrameSource
|
||||||
|
{
|
||||||
|
string DeviceId { get; }
|
||||||
|
event Action<VideoFrame>? FrameAvailable;
|
||||||
|
Task StartAsync();
|
||||||
|
Task StopAsync();
|
||||||
|
}
|
||||||
+44
-4
@@ -31,7 +31,7 @@ public class LayoutStore : IDisposable
|
|||||||
{
|
{
|
||||||
string[] statements =
|
string[] statements =
|
||||||
{
|
{
|
||||||
"PRAGMA user_version = 1;",
|
"PRAGMA user_version = 2;",
|
||||||
"""
|
"""
|
||||||
CREATE TABLE IF NOT EXISTS Scene (
|
CREATE TABLE IF NOT EXISTS Scene (
|
||||||
Id TEXT PRIMARY KEY,
|
Id TEXT PRIMARY KEY,
|
||||||
@@ -65,6 +65,8 @@ public class LayoutStore : IDisposable
|
|||||||
Opacity REAL NOT NULL DEFAULT 1,
|
Opacity REAL NOT NULL DEFAULT 1,
|
||||||
MonitorIndex INTEGER,
|
MonitorIndex INTEGER,
|
||||||
DeviceId TEXT,
|
DeviceId TEXT,
|
||||||
|
ClipShape TEXT NOT NULL DEFAULT 'Traditional',
|
||||||
|
IsMirrored INTEGER NOT NULL DEFAULT 0,
|
||||||
SortOrder INTEGER NOT NULL DEFAULT 0
|
SortOrder INTEGER NOT NULL DEFAULT 0
|
||||||
);
|
);
|
||||||
""",
|
""",
|
||||||
@@ -75,6 +77,36 @@ public class LayoutStore : IDisposable
|
|||||||
cmd.CommandText = sql;
|
cmd.CommandText = sql;
|
||||||
cmd.ExecuteNonQuery();
|
cmd.ExecuteNonQuery();
|
||||||
}
|
}
|
||||||
|
MigrateSourceTable();
|
||||||
|
}
|
||||||
|
|
||||||
|
// v1 → v2: Source gains ClipShape + IsMirrored (webcam clip/mirror prefs).
|
||||||
|
// CREATE TABLE IF NOT EXISTS doesn't touch existing tables, so pre-v2 DBs
|
||||||
|
// get the columns here instead.
|
||||||
|
private void MigrateSourceTable()
|
||||||
|
{
|
||||||
|
var columns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
using (var cmd = _connection.CreateCommand())
|
||||||
|
{
|
||||||
|
cmd.CommandText = "PRAGMA table_info(Source);";
|
||||||
|
using var reader = cmd.ExecuteReader();
|
||||||
|
while (reader.Read())
|
||||||
|
columns.Add(reader.GetString(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!columns.Contains("ClipShape"))
|
||||||
|
{
|
||||||
|
using var cmd = _connection.CreateCommand();
|
||||||
|
cmd.CommandText = "ALTER TABLE Source ADD COLUMN ClipShape TEXT NOT NULL DEFAULT 'Traditional';";
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!columns.Contains("IsMirrored"))
|
||||||
|
{
|
||||||
|
using var cmd = _connection.CreateCommand();
|
||||||
|
cmd.CommandText = "ALTER TABLE Source ADD COLUMN IsMirrored INTEGER NOT NULL DEFAULT 0;";
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<Scene> Load()
|
public List<Scene> Load()
|
||||||
@@ -102,7 +134,7 @@ public class LayoutStore : IDisposable
|
|||||||
{
|
{
|
||||||
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
|
X, Y, Width, Height, Opacity, MonitorIndex, DeviceId, ClipShape, IsMirrored
|
||||||
FROM Source ORDER BY SortOrder
|
FROM Source ORDER BY SortOrder
|
||||||
""";
|
""";
|
||||||
using var reader = cmd.ExecuteReader();
|
using var reader = cmd.ExecuteReader();
|
||||||
@@ -123,6 +155,8 @@ public class LayoutStore : IDisposable
|
|||||||
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),
|
DeviceId = reader.IsDBNull(12) ? null : reader.GetString(12),
|
||||||
|
ClipShape = Enum.TryParse<ClipShape>(reader.GetString(13), out var clip) ? clip : ClipShape.Traditional,
|
||||||
|
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>();
|
||||||
@@ -185,9 +219,11 @@ 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, SortOrder)
|
X, Y, Width, Height, Opacity, MonitorIndex, DeviceId,
|
||||||
|
ClipShape, IsMirrored, SortOrder)
|
||||||
VALUES ($id, $sceneId, $assetId, $type, $name, $isEnabled,
|
VALUES ($id, $sceneId, $assetId, $type, $name, $isEnabled,
|
||||||
$x, $y, $w, $h, $opacity, $monitor, $device, $sort)
|
$x, $y, $w, $h, $opacity, $monitor, $device,
|
||||||
|
$clip, $mirrored, $sort)
|
||||||
""";
|
""";
|
||||||
cmd.Transaction = tx;
|
cmd.Transaction = tx;
|
||||||
var idP = cmd.Parameters.Add("$id", SqliteType.Text);
|
var idP = cmd.Parameters.Add("$id", SqliteType.Text);
|
||||||
@@ -203,6 +239,8 @@ public class LayoutStore : IDisposable
|
|||||||
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 deviceP = cmd.Parameters.Add("$device", SqliteType.Text);
|
||||||
|
var clipP = cmd.Parameters.Add("$clip", SqliteType.Text);
|
||||||
|
var mirroredP = cmd.Parameters.Add("$mirrored", SqliteType.Integer);
|
||||||
var sortP = cmd.Parameters.Add("$sort", SqliteType.Integer);
|
var sortP = cmd.Parameters.Add("$sort", SqliteType.Integer);
|
||||||
|
|
||||||
foreach (var scene in scenes)
|
foreach (var scene in scenes)
|
||||||
@@ -223,6 +261,8 @@ public class LayoutStore : IDisposable
|
|||||||
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;
|
deviceP.Value = (object?)source.DeviceId ?? DBNull.Value;
|
||||||
|
clipP.Value = source.ClipShape.ToString();
|
||||||
|
mirroredP.Value = source.IsMirrored ? 1 : 0;
|
||||||
sortP.Value = sort++;
|
sortP.Value = sort++;
|
||||||
cmd.ExecuteNonQuery();
|
cmd.ExecuteNonQuery();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
using Windows.Devices.Enumeration;
|
||||||
|
|
||||||
|
namespace ytLive.Services;
|
||||||
|
|
||||||
|
public sealed class MediaCaptureCameraEnumerator : ICameraEnumerator
|
||||||
|
{
|
||||||
|
public async Task<IReadOnlyList<CameraDeviceInfo>> GetCamerasAsync()
|
||||||
|
{
|
||||||
|
var devices = await DeviceInformation.FindAllAsync(DeviceClass.VideoCapture);
|
||||||
|
var result = new List<CameraDeviceInfo>(devices.Count);
|
||||||
|
foreach (var device in devices)
|
||||||
|
result.Add(new CameraDeviceInfo(device.Id, device.Name));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
using System;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Runtime.InteropServices.WindowsRuntime;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Windows.Graphics.Imaging;
|
||||||
|
using Windows.Media.Capture;
|
||||||
|
using Windows.Media.Capture.Frames;
|
||||||
|
using Windows.Media.MediaProperties;
|
||||||
|
using ytLive.Helpers;
|
||||||
|
|
||||||
|
namespace ytLive.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// CPU-first MediaCapture source. Requests BGRA8 frames from the camera's video
|
||||||
|
/// preview source; the capture pipeline does any format conversion, so every
|
||||||
|
/// frame arrives as a normalized <see cref="VideoFrame"/>. Frames arrive on a
|
||||||
|
/// worker thread — marshal before touching WPF.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MediaCaptureFrameSource : ICameraFrameSource
|
||||||
|
{
|
||||||
|
private readonly string _deviceId;
|
||||||
|
private MediaCapture? _capture;
|
||||||
|
private MediaFrameReader? _frameReader;
|
||||||
|
|
||||||
|
public string DeviceId => _deviceId;
|
||||||
|
|
||||||
|
public event Action<VideoFrame>? FrameAvailable;
|
||||||
|
|
||||||
|
public MediaCaptureFrameSource(string deviceId)
|
||||||
|
{
|
||||||
|
_deviceId = deviceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task StartAsync()
|
||||||
|
{
|
||||||
|
var capture = new MediaCapture();
|
||||||
|
MediaFrameReader? reader = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var settings = new MediaCaptureInitializationSettings
|
||||||
|
{
|
||||||
|
VideoDeviceId = _deviceId,
|
||||||
|
StreamingCaptureMode = StreamingCaptureMode.Video,
|
||||||
|
MemoryPreference = MediaCaptureMemoryPreference.Cpu,
|
||||||
|
};
|
||||||
|
await capture.InitializeAsync(settings);
|
||||||
|
|
||||||
|
var colorSource = capture.FrameSources
|
||||||
|
.FirstOrDefault(pair => pair.Value.Info.MediaStreamType == MediaStreamType.VideoPreview)
|
||||||
|
.Value;
|
||||||
|
if (colorSource == null)
|
||||||
|
throw new InvalidOperationException($"No video preview source on camera '{_deviceId}'.");
|
||||||
|
|
||||||
|
reader = await capture.CreateFrameReaderAsync(colorSource, MediaEncodingSubtypes.Bgra8);
|
||||||
|
reader.FrameArrived += OnFrameArrived;
|
||||||
|
await reader.StartAsync();
|
||||||
|
|
||||||
|
_capture = capture;
|
||||||
|
_frameReader = reader;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
if (reader != null)
|
||||||
|
{
|
||||||
|
reader.FrameArrived -= OnFrameArrived;
|
||||||
|
reader.Dispose();
|
||||||
|
}
|
||||||
|
capture.Dispose();
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task StopAsync()
|
||||||
|
{
|
||||||
|
var reader = _frameReader;
|
||||||
|
_frameReader = null;
|
||||||
|
if (reader != null)
|
||||||
|
{
|
||||||
|
reader.FrameArrived -= OnFrameArrived;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await reader.StopAsync();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLog.Write($"MediaCaptureFrameSource: stop frame reader failed: {ex.Message}");
|
||||||
|
}
|
||||||
|
reader.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
var capture = _capture;
|
||||||
|
_capture = null;
|
||||||
|
capture?.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnFrameArrived(MediaFrameReader sender, MediaFrameArrivedEventArgs args)
|
||||||
|
{
|
||||||
|
using var frame = sender.TryAcquireLatestFrame();
|
||||||
|
var videoFrame = frame?.VideoMediaFrame?.SoftwareBitmap;
|
||||||
|
if (videoFrame == null) return;
|
||||||
|
|
||||||
|
var bitmap = videoFrame.BitmapPixelFormat == BitmapPixelFormat.Bgra8
|
||||||
|
? videoFrame
|
||||||
|
: SoftwareBitmap.Convert(videoFrame, BitmapPixelFormat.Bgra8);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var buffer = bitmap.LockBuffer(BitmapBufferAccessMode.Read);
|
||||||
|
using var reference = buffer.CreateReference();
|
||||||
|
if (WindowsRuntimeMarshal.TryGetDataUnsafe(reference, out var pixelsPtr, out var capacity))
|
||||||
|
{
|
||||||
|
var pixels = new byte[capacity];
|
||||||
|
Marshal.Copy(pixelsPtr, pixels, 0, (int)capacity);
|
||||||
|
FrameAvailable?.Invoke(new VideoFrame(bitmap.PixelWidth, bitmap.PixelHeight, pixels));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLog.Write($"MediaCaptureFrameSource: frame copy failed: {ex.Message}");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (!ReferenceEquals(bitmap, videoFrame))
|
||||||
|
bitmap.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
namespace ytLive.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A normalized CPU frame (32bpp BGRA, tightly packed). The capture path hands
|
||||||
|
/// these to the UI thread, which copies them into the shared WriteableBitmap.
|
||||||
|
/// Deliberately the only pixel type the rest of the app knows about — every
|
||||||
|
/// future capture source (screen, background-removed webcam) feeds the same seam.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class VideoFrame
|
||||||
|
{
|
||||||
|
public int Width { get; }
|
||||||
|
public int Height { get; }
|
||||||
|
public byte[] BgraPixels { get; }
|
||||||
|
public int Stride => Width * 4;
|
||||||
|
|
||||||
|
public VideoFrame(int width, int height, byte[] bgraPixels)
|
||||||
|
{
|
||||||
|
Width = width;
|
||||||
|
Height = height;
|
||||||
|
BgraPixels = bgraPixels;
|
||||||
|
}
|
||||||
|
}
|
||||||
+8
-1
@@ -8,7 +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 |
|
| `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) |
|
||||||
|
| `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 |
|
||||||
|
| `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 |
|
||||||
|
| `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 |
|
||||||
|
| `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 |
|
||||||
|
|
||||||
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).
|
||||||
|
|||||||
@@ -136,7 +136,15 @@ Preview shows the transition too (WYSIWYG). No wipes/slides/LUTs beyond the four
|
|||||||
### Requirements:
|
### Requirements:
|
||||||
|
|
||||||
1. **Screen** — Windows.Graphics.Capture (WinRT), enumerate displays/windows, picker
|
1. **Screen** — Windows.Graphics.Capture (WinRT), enumerate displays/windows, picker
|
||||||
2. **Webcam** — MediaCapture (WinRT) with device enumeration
|
2. **Webcam** — MediaCapture (WinRT SDK projection) with device enumeration — ✅ **milestone 1 done**:
|
||||||
|
- TFM bumped to `net8.0-windows10.0.19041.0` (app **and** tests) so the WinRT projection resolves from the SDK reference packs — no NuGet package, no capability manifest (unpackaged desktop app)
|
||||||
|
- `MediaCaptureFrameSource` (CPU-first: `MemoryPreference = Cpu`, BGRA8 via `CreateFrameReaderAsync`), `MediaCaptureCameraEnumerator` (`DeviceInformation.FindAllAsync(DeviceClass.VideoCapture)`)
|
||||||
|
- `CameraManager`: refcounted by `DeviceId`, one shared `WriteableBitmap` app-wide, dispatcher-coalesced UI updates (~render rate, latest-frame drop), placeholder/`AppLog` + warning on failure
|
||||||
|
- `CameraPickerDialog` (mirror of `ReuseImageDialog`) — "Searching for cameras…" / list / "No cameras found" states
|
||||||
|
- One webcam app-wide: Add → Webcam greyed out once one exists ("it's already in your stream" tooltip); persisted `DeviceId` re-acquires after layout load
|
||||||
|
- Default placement 16:9 **480×270**, bottom-right, 32px margin; drag/resize/selection shared with Image sources
|
||||||
|
- **Clip shapes: Traditional + Round** (phone view dropped — the 9:16 phone output is the vertical output-crop tier); **mirror**; both persisted in the layout DB (schema v2) and toggled from the source chip
|
||||||
|
- **Background removal = milestone 2** (ONNX Runtime + DirectML, MediaPipe Selfie Segmentation) — not in this build
|
||||||
3. **Background / Image / Text** — static sources positioned/scaled/opacity
|
3. **Background / Image / Text** — static sources positioned/scaled/opacity
|
||||||
4. **Chat box** — rendered from the live chat poll (right panel is the same feed, raw)
|
4. **Chat box** — rendered from the live chat poll (right panel is the same feed, raw)
|
||||||
5. **Scene compositing** — per-scene source layering (z-order = sources list order, top-to-bottom
|
5. **Scene compositing** — per-scene source layering (z-order = sources list order, top-to-bottom
|
||||||
@@ -151,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 — scenes/sources UI built (add/reorder/rename, image + background overlays with move/resize/opacity/reuse); real capture/encoding pending
|
### 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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -231,8 +239,9 @@ Preview shows the transition too (WYSIWYG). No wipes/slides/LUTs beyond the four
|
|||||||
add/remove/reorder, and any source transform change; flush on window close.
|
add/remove/reorder, and any source transform change; flush on window close.
|
||||||
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, SortOrder). `WindowHandle` stays in-memory
|
X/Y/Width/Height/Opacity, MonitorIndex, DeviceId, ClipShape, IsMirrored, SortOrder) —
|
||||||
(per-session). Save = transactional rewrite; orphaned assets pruned.
|
`user_version` 2 (v1 → v2 = `ALTER TABLE` adds the two webcam columns). `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.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Windows.Input;
|
||||||
|
using ytLive.Helpers;
|
||||||
|
using ytLive.Services;
|
||||||
|
|
||||||
|
namespace ytLive.ViewModels;
|
||||||
|
|
||||||
|
public class CameraPickerCandidate
|
||||||
|
{
|
||||||
|
public CameraDeviceInfo Device { get; }
|
||||||
|
public string Name => Device.DisplayName;
|
||||||
|
|
||||||
|
public CameraPickerCandidate(CameraDeviceInfo device)
|
||||||
|
{
|
||||||
|
Device = device;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class CameraPickerViewModel : ViewModelBase
|
||||||
|
{
|
||||||
|
private readonly ICameraEnumerator _enumerator;
|
||||||
|
private CameraPickerCandidate? _selectedCamera;
|
||||||
|
private bool _isLoading = true;
|
||||||
|
|
||||||
|
public ObservableCollection<CameraPickerCandidate> Cameras { get; } = new();
|
||||||
|
|
||||||
|
public bool IsLoading
|
||||||
|
{
|
||||||
|
get => _isLoading;
|
||||||
|
private set => SetProperty(ref _isLoading, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool HasCameras => !IsLoading && Cameras.Count > 0;
|
||||||
|
public bool NoCameras => !IsLoading && Cameras.Count == 0;
|
||||||
|
|
||||||
|
public CameraPickerCandidate? SelectedCamera
|
||||||
|
{
|
||||||
|
get => _selectedCamera;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (SetProperty(ref _selectedCamera, value))
|
||||||
|
CommandManager.InvalidateRequerySuggested();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public ICommand UseSelectedCommand { get; }
|
||||||
|
public ICommand CancelCommand { get; }
|
||||||
|
|
||||||
|
public event Action<CameraDeviceInfo>? UseRequested;
|
||||||
|
public event Action? CancelRequested;
|
||||||
|
|
||||||
|
public CameraPickerViewModel(ICameraEnumerator enumerator)
|
||||||
|
{
|
||||||
|
_enumerator = enumerator;
|
||||||
|
UseSelectedCommand = new RelayCommand(
|
||||||
|
_ => { if (SelectedCamera != null) UseRequested?.Invoke(SelectedCamera.Device); },
|
||||||
|
_ => SelectedCamera != null);
|
||||||
|
CancelCommand = new RelayCommand(_ => CancelRequested?.Invoke());
|
||||||
|
_ = LoadAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadAsync()
|
||||||
|
{
|
||||||
|
IsLoading = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var devices = await _enumerator.GetCamerasAsync();
|
||||||
|
Cameras.Clear();
|
||||||
|
foreach (var device in devices)
|
||||||
|
Cameras.Add(new CameraPickerCandidate(device));
|
||||||
|
if (Cameras.Count > 0)
|
||||||
|
SelectedCamera = Cameras[0];
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLog.Write($"CameraPickerViewModel: enumerating cameras failed: {ex.Message}");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsLoading = false;
|
||||||
|
OnPropertyChanged(nameof(HasCameras));
|
||||||
|
OnPropertyChanged(nameof(NoCameras));
|
||||||
|
CommandManager.InvalidateRequerySuggested();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+106
-1
@@ -6,6 +6,7 @@ using System.IO;
|
|||||||
using System.Windows;
|
using System.Windows;
|
||||||
using System.Windows.Input;
|
using System.Windows.Input;
|
||||||
using System.Windows.Media;
|
using System.Windows.Media;
|
||||||
|
using System.Windows.Media.Imaging;
|
||||||
using System.Windows.Threading;
|
using System.Windows.Threading;
|
||||||
using Microsoft.Win32;
|
using Microsoft.Win32;
|
||||||
using ytLive.Helpers;
|
using ytLive.Helpers;
|
||||||
@@ -56,6 +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
|
||||||
|
// Add menu can be disabled and the live preview bitmap can be forwarded.
|
||||||
|
private readonly ICameraEnumerator _cameraEnumerator;
|
||||||
|
private readonly CameraManager _cameraManager;
|
||||||
|
private Source? _webcamSource;
|
||||||
|
|
||||||
// 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
|
||||||
// BrandFlashEnabled = false. See ai.md "Monetization".
|
// BrandFlashEnabled = false. See ai.md "Monetization".
|
||||||
@@ -99,8 +106,18 @@ public class MainViewModel : ViewModelBase
|
|||||||
public Source? SelectedSource
|
public Source? SelectedSource
|
||||||
{
|
{
|
||||||
get => _selectedSource;
|
get => _selectedSource;
|
||||||
set => SetProperty(ref _selectedSource, value);
|
set
|
||||||
|
{
|
||||||
|
if (SetProperty(ref _selectedSource, value))
|
||||||
|
OnPropertyChanged(nameof(IsWebcamSelected));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The Add → Webcam menu item. One webcam app-wide — once one exists it's greyed out.</summary>
|
||||||
|
public bool CanAddWebcam => _webcamSource == null;
|
||||||
|
|
||||||
|
/// <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 StreamStatus StreamStatus
|
public StreamStatus StreamStatus
|
||||||
{
|
{
|
||||||
@@ -466,6 +483,14 @@ public class MainViewModel : ViewModelBase
|
|||||||
|
|
||||||
_layoutStore = new LayoutStore(DefaultLayoutPath);
|
_layoutStore = new LayoutStore(DefaultLayoutPath);
|
||||||
_activeLayoutPath = _layoutStore.ActivePath;
|
_activeLayoutPath = _layoutStore.ActivePath;
|
||||||
|
|
||||||
|
_cameraEnumerator = new MediaCaptureCameraEnumerator();
|
||||||
|
_cameraManager = new CameraManager(
|
||||||
|
_cameraEnumerator,
|
||||||
|
id => new MediaCaptureFrameSource(id),
|
||||||
|
System.Windows.Application.Current?.Dispatcher);
|
||||||
|
_cameraManager.PreviewBitmapChanged += OnCameraPreviewBitmapChanged;
|
||||||
|
|
||||||
LoadLayout();
|
LoadLayout();
|
||||||
_ = LoadSavedSessionAsync();
|
_ = LoadSavedSessionAsync();
|
||||||
AppLog.Write("MainViewModel ctor end");
|
AppLog.Write("MainViewModel ctor end");
|
||||||
@@ -533,14 +558,41 @@ public class MainViewModel : ViewModelBase
|
|||||||
}
|
}
|
||||||
ActiveScene = Scenes.FirstOrDefault();
|
ActiveScene = Scenes.FirstOrDefault();
|
||||||
UpdateActiveBackground();
|
UpdateActiveBackground();
|
||||||
|
ReacquireWebcam();
|
||||||
ScheduleSave();
|
ScheduleSave();
|
||||||
AppLog.Write("LoadLayout end");
|
AppLog.Write("LoadLayout end");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Finds the persisted webcam source (at most one app-wide) and re-acquires
|
||||||
|
// its camera after a layout load / file open. Releasing the previous session
|
||||||
|
// unconditionally keeps the refcount honest even when the device is unchanged.
|
||||||
|
private void ReacquireWebcam()
|
||||||
|
{
|
||||||
|
var webcam = Scenes.SelectMany(s => s.Sources).FirstOrDefault(s => s.Type == SourceType.Webcam);
|
||||||
|
if (ReferenceEquals(_webcamSource, webcam)) return;
|
||||||
|
|
||||||
|
if (_webcamSource != null && !string.IsNullOrWhiteSpace(_webcamSource.DeviceId))
|
||||||
|
_ = _cameraManager.ReleaseAsync(_webcamSource.DeviceId);
|
||||||
|
|
||||||
|
_webcamSource = webcam;
|
||||||
|
OnPropertyChanged(nameof(CanAddWebcam));
|
||||||
|
if (webcam != null && !string.IsNullOrWhiteSpace(webcam.DeviceId))
|
||||||
|
_ = _cameraManager.AcquireAsync(webcam.DeviceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
private void OnCameraPreviewBitmapChanged(string deviceId, WriteableBitmap bitmap)
|
||||||
|
{
|
||||||
|
if (_webcamSource?.DeviceId == deviceId)
|
||||||
|
_webcamSource.VideoImageSource = bitmap;
|
||||||
|
}
|
||||||
|
|
||||||
public void Shutdown()
|
public void Shutdown()
|
||||||
{
|
{
|
||||||
_saveDebounce?.Stop();
|
_saveDebounce?.Stop();
|
||||||
SaveLayoutNow();
|
SaveLayoutNow();
|
||||||
|
_cameraManager.Dispose();
|
||||||
_layoutStore.Dispose();
|
_layoutStore.Dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -691,6 +743,12 @@ 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");
|
||||||
@@ -722,6 +780,46 @@ public class MainViewModel : ViewModelBase
|
|||||||
UpdateActiveBackground();
|
UpdateActiveBackground();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task AddWebcamSourceAsync()
|
||||||
|
{
|
||||||
|
var scene = ActiveScene;
|
||||||
|
if (scene == null || _webcamSource != null) return;
|
||||||
|
|
||||||
|
var dialog = new CameraPickerDialog(new CameraPickerViewModel(_cameraEnumerator))
|
||||||
|
{
|
||||||
|
Owner = Application.Current.MainWindow
|
||||||
|
};
|
||||||
|
if (dialog.ShowDialog() != true || dialog.PickedDevice == null) return;
|
||||||
|
|
||||||
|
var device = dialog.PickedDevice;
|
||||||
|
var source = new Source
|
||||||
|
{
|
||||||
|
Name = "Webcam",
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void AddImage()
|
private void AddImage()
|
||||||
{
|
{
|
||||||
var scene = ActiveScene;
|
var scene = ActiveScene;
|
||||||
@@ -842,6 +940,13 @@ public class MainViewModel : ViewModelBase
|
|||||||
{
|
{
|
||||||
var scene = ActiveScene;
|
var scene = ActiveScene;
|
||||||
if (scene == null || source == null) return;
|
if (scene == null || source == null) return;
|
||||||
|
if (source.Type == SourceType.Webcam && ReferenceEquals(source, _webcamSource))
|
||||||
|
{
|
||||||
|
_webcamSource = null;
|
||||||
|
OnPropertyChanged(nameof(CanAddWebcam));
|
||||||
|
if (!string.IsNullOrWhiteSpace(source.DeviceId))
|
||||||
|
_ = _cameraManager.ReleaseAsync(source.DeviceId);
|
||||||
|
}
|
||||||
scene.Sources.Remove(source);
|
scene.Sources.Remove(source);
|
||||||
OnPropertyChanged(nameof(ShowEmptySceneHint));
|
OnPropertyChanged(nameof(ShowEmptySceneHint));
|
||||||
OnPropertyChanged(nameof(ShowSourcesEmptyHint));
|
OnPropertyChanged(nameof(ShowSourcesEmptyHint));
|
||||||
|
|||||||
+2
-1
@@ -4,9 +4,10 @@ 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) |
|
| `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` |
|
||||||
| `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` |
|
||||||
|
|
||||||
Related: [`Models/index.md`](../Models/index.md) for the data; [`Services/index.md`](../Services/index.md)
|
Related: [`Models/index.md`](../Models/index.md) for the data; [`Services/index.md`](../Services/index.md)
|
||||||
for what the ViewModels drive; base class in [`Helpers/ViewModelBase.cs`](../Helpers/ViewModelBase.cs).
|
for what the ViewModels drive; base class in [`Helpers/ViewModelBase.cs`](../Helpers/ViewModelBase.cs).
|
||||||
|
|||||||
@@ -36,7 +36,16 @@ Note: `EnableWindowsTargeting=true` is set in `ytLive.csproj`, so the project ca
|
|||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
No test framework set up yet. When added: `dotnet test`.
|
xUnit in `ytLive.Tests` (net8.0-windows10.0.19041.0, matches the app TFM). Run on Windows —
|
||||||
|
WSL can't run net8.0-windows tests:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet.exe vstest "C:\...\ytLive.Tests\bin\Debug\net8.0-windows10.0.19041.0\ytLive.Tests.dll"
|
||||||
|
```
|
||||||
|
|
||||||
|
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 +
|
||||||
|
frame pump + failure handling (fakes for the WinRT seams) — 11 passing.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
@@ -44,9 +53,9 @@ C# / WPF (.NET 8) following MVVM:
|
|||||||
|
|
||||||
| Path | Role |
|
| Path | Role |
|
||||||
|------|------|
|
|------|------|
|
||||||
| `Models/` | Plain data types — Scene, Source, QualityOption, StreamConfig, StreamHealth, YouTubeChannel, ChatMessage |
|
| `Models/` | Plain data types — Scene, Source (incl. `ClipShape`, `IsMirrored`, `VideoImageSource`), QualityOption, StreamConfig, StreamHealth, YouTubeChannel, ChatMessage |
|
||||||
| `ViewModels/` | MainViewModel — exposes collections + commands for the UI; GoLiveViewModel, ReuseImageViewModel |
|
| `ViewModels/` | MainViewModel — exposes collections + commands for the UI; GoLiveViewModel, ReuseImageViewModel, CameraPickerViewModel |
|
||||||
| `Services/` | YouTube OAuth2, stream/broadcast management, live chat polling, LayoutStore (SQLite) |
|
| `Services/` | YouTube OAuth2, stream/broadcast management, live chat polling, LayoutStore (SQLite), **webcam: `VideoFrame` seam + `CameraDeviceInfo`/`ICameraEnumerator`/`ICameraFrameSource` interfaces + `MediaCaptureCameraEnumerator`/`MediaCaptureFrameSource` (WinRT) + `CameraManager`** |
|
||||||
| `Helpers/` | ViewModelBase (INotifyPropertyChanged), RelayCommand, ImageCache, AppLog (file logger), FocusPreservingListBox, OAuthCredentials, **TokenStore (DPAPI session persistence)**, visibility converters |
|
| `Helpers/` | ViewModelBase (INotifyPropertyChanged), RelayCommand, ImageCache, AppLog (file logger), FocusPreservingListBox, OAuthCredentials, **TokenStore (DPAPI session persistence)**, visibility converters |
|
||||||
| `Themes/` | `Controls.xaml` — the single dark-theme source, merged once in `App.xaml` (see `Themes/index.md`) |
|
| `Themes/` | `Controls.xaml` — the single dark-theme source, merged once in `App.xaml` (see `Themes/index.md`) |
|
||||||
| `MainWindow.xaml` | Dark theme; layout: top bar (controls), center (preview), left (scenes/sources), right (chat), bottom (health) |
|
| `MainWindow.xaml` | Dark theme; layout: top bar (controls), center (preview), left (scenes/sources), right (chat), bottom (health) |
|
||||||
@@ -65,11 +74,41 @@ 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); the OAuth session persists (DPAPI); the paid-unlock state does not (yet — itch.io key verification pending)
|
- 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)
|
||||||
- `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
|
||||||
- No capture/encoding/RTMP yet
|
- 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
|
||||||
|
|
||||||
|
### Webcam capture (TASK 3 milestone 1)
|
||||||
|
|
||||||
|
- **Seam-first:** everything above the WinRT layer speaks only `VideoFrame` (normalized tightly-packed
|
||||||
|
BGRA8) + `CameraDeviceInfo`/`ICameraEnumerator`/`ICameraFrameSource` interfaces. Tests inject fakes;
|
||||||
|
screen capture and background removal later feed the same seam.
|
||||||
|
- **CPU-first:** `MediaCaptureInitializationSettings { MemoryPreference = Cpu, StreamingCaptureMode = Video }`,
|
||||||
|
frames pulled via `CreateFrameReaderAsync(colorSource, MediaEncodingSubtypes.Bgra8)` — the pipeline does
|
||||||
|
any format conversion, so every `FrameArrived` yields a ready BGRA8 `SoftwareBitmap` (bytes read via
|
||||||
|
`WindowsRuntimeMarshal.TryGetDataUnsafe`, not marshalled copies).
|
||||||
|
- **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
|
||||||
|
camera toggle still applies). `EnableWindowsTargeting` keeps WSL builds working.
|
||||||
|
- **One camera app-wide:** `CameraManager` refcounts sessions by `DeviceId` (a session is created with
|
||||||
|
`RefCount = 1`; repeat acquire bumps it; the last release stops + disposes). The Add menu greys Webcam
|
||||||
|
out once a webcam source exists anywhere (`CanAddWebcam`); the "OBS time" story is a one-camera limit.
|
||||||
|
- **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
|
||||||
|
`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
|
||||||
|
device never drowns the render thread.
|
||||||
|
- **Clip/mirror:** per-Source `ClipShape` (Traditional rectangle / Round ellipse) + `IsMirrored`
|
||||||
|
(`ScaleX = -1`). Rendered in the preview DataTemplate (Image for Traditional, `ImageBrush` inside an
|
||||||
|
`Ellipse` for Round); toggled from the source chip; persisted in the layout DB. Round hit-testing is the
|
||||||
|
same rectangle as Traditional (selection overlay is rectangular) — acceptable for now.
|
||||||
|
- **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
|
||||||
|
to the encoder task.
|
||||||
|
- **Background removal = milestone 2** — ONNX Runtime + DirectML (CPU fallback), MediaPipe Selfie
|
||||||
|
Segmentation (Apache-2.0), wired into the same `VideoFrame` seam. Not part of milestone 1.
|
||||||
|
|
||||||
## Design Principle
|
## Design Principle
|
||||||
|
|
||||||
> This software is so intuitive that even the most right-brained person can easily intuit and use it.
|
> This software is so intuitive that even the most right-brained person can easily intuit and use it.
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
using Xunit;
|
||||||
|
using ytLive.Services;
|
||||||
|
|
||||||
|
namespace ytLive.Tests;
|
||||||
|
|
||||||
|
public class CameraManagerTests
|
||||||
|
{
|
||||||
|
private sealed class FakeFrameSource : ICameraFrameSource
|
||||||
|
{
|
||||||
|
private readonly List<string> _started;
|
||||||
|
private readonly List<string> _stopped;
|
||||||
|
|
||||||
|
public string DeviceId { get; }
|
||||||
|
public event Action<VideoFrame>? FrameAvailable;
|
||||||
|
|
||||||
|
public FakeFrameSource(string deviceId, List<string> started, List<string> stopped)
|
||||||
|
{
|
||||||
|
DeviceId = deviceId;
|
||||||
|
_started = started;
|
||||||
|
_stopped = stopped;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task StartAsync()
|
||||||
|
{
|
||||||
|
_started.Add(DeviceId);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task StopAsync()
|
||||||
|
{
|
||||||
|
_stopped.Add(DeviceId);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Pump(VideoFrame frame) => FrameAvailable?.Invoke(frame);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FailingFrameSource : ICameraFrameSource
|
||||||
|
{
|
||||||
|
public string DeviceId { get; }
|
||||||
|
public event Action<VideoFrame>? FrameAvailable;
|
||||||
|
public bool Stopped;
|
||||||
|
|
||||||
|
public FailingFrameSource(string deviceId) => DeviceId = deviceId;
|
||||||
|
|
||||||
|
public Task StartAsync() => throw new InvalidOperationException("camera in use");
|
||||||
|
|
||||||
|
public Task StopAsync()
|
||||||
|
{
|
||||||
|
Stopped = true;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Pump(VideoFrame frame) => FrameAvailable?.Invoke(frame);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CameraManager CreateManager(List<string> started, List<string> stopped)
|
||||||
|
=> new(new FakeEnumerator(), id => new FakeFrameSource(id, started, stopped));
|
||||||
|
|
||||||
|
private sealed class FakeEnumerator : ICameraEnumerator
|
||||||
|
{
|
||||||
|
public Task<IReadOnlyList<CameraDeviceInfo>> GetCamerasAsync()
|
||||||
|
=> Task.FromResult<IReadOnlyList<CameraDeviceInfo>>(new[]
|
||||||
|
{
|
||||||
|
new CameraDeviceInfo("dev1", "Logitech C920"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Refcount_TwoAcquires_OneCaptureUntilLastRelease()
|
||||||
|
{
|
||||||
|
var started = new List<string>();
|
||||||
|
var stopped = new List<string>();
|
||||||
|
var manager = CreateManager(started, stopped);
|
||||||
|
|
||||||
|
Assert.True(await manager.AcquireAsync("dev1"));
|
||||||
|
Assert.True(await manager.AcquireAsync("dev1"));
|
||||||
|
Assert.Single(started);
|
||||||
|
|
||||||
|
await manager.ReleaseAsync("dev1");
|
||||||
|
Assert.Empty(stopped);
|
||||||
|
|
||||||
|
await manager.ReleaseAsync("dev1");
|
||||||
|
Assert.Single(stopped);
|
||||||
|
Assert.Null(manager.GetLatestFrame("dev1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task FramePump_UpdatesLatestFrame()
|
||||||
|
{
|
||||||
|
var started = new List<string>();
|
||||||
|
var stopped = new List<string>();
|
||||||
|
FakeFrameSource? captured = null;
|
||||||
|
var manager = new CameraManager(
|
||||||
|
new FakeEnumerator(),
|
||||||
|
id => captured = new FakeFrameSource(id, started, stopped));
|
||||||
|
|
||||||
|
await manager.AcquireAsync("dev1");
|
||||||
|
var frame = new VideoFrame(2, 2, new byte[16]);
|
||||||
|
captured!.Pump(frame);
|
||||||
|
|
||||||
|
Assert.Same(frame, manager.GetLatestFrame("dev1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Acquire_FailingCamera_ReturnsFalseAndStops()
|
||||||
|
{
|
||||||
|
var manager = new CameraManager(new FakeEnumerator(), id => new FailingFrameSource(id));
|
||||||
|
string? failedDevice = null;
|
||||||
|
manager.CameraFailed += (device, _) => failedDevice = device;
|
||||||
|
|
||||||
|
Assert.False(await manager.AcquireAsync("dev1"));
|
||||||
|
Assert.Equal("dev1", failedDevice);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Acquire_EmptyDeviceId_ReturnsFalse()
|
||||||
|
{
|
||||||
|
var manager = CreateManager(new List<string>(), new List<string>());
|
||||||
|
Assert.False(await manager.AcquireAsync(" "));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net8.0-windows</TargetFramework>
|
<TargetFramework>net8.0-windows10.0.19041.0</TargetFramework>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<IsPackable>false</IsPackable>
|
<IsPackable>false</IsPackable>
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<OutputType>WinExe</OutputType>
|
<OutputType>WinExe</OutputType>
|
||||||
<TargetFramework>net8.0-windows</TargetFramework>
|
<TargetFramework>net8.0-windows10.0.19041.0</TargetFramework>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<UseWPF>true</UseWPF>
|
<UseWPF>true</UseWPF>
|
||||||
|
|||||||
Reference in New Issue
Block a user