Compare commits
2 Commits
4d10b23714
...
b00a4cbd5e
| Author | SHA1 | Date | |
|---|---|---|---|
| b00a4cbd5e | |||
| 4b3fb04322 |
+84
-7
@@ -105,6 +105,12 @@
|
|||||||
|
|
||||||
<!-- Single three-state action button -->
|
<!-- Single three-state action button -->
|
||||||
<StackPanel Grid.Column="2" Orientation="Horizontal" VerticalAlignment="Center">
|
<StackPanel Grid.Column="2" Orientation="Horizontal" VerticalAlignment="Center">
|
||||||
|
<Border Width="26" Height="26" CornerRadius="13" Background="#16213e" ClipToBounds="True"
|
||||||
|
Margin="0,0,8,0" VerticalAlignment="Center"
|
||||||
|
ToolTip="{Binding AccountDisplayName}"
|
||||||
|
Visibility="{Binding IsConnected, Converter={StaticResource BoolToVis}}">
|
||||||
|
<Image Source="{Binding AccountAvatarUrl}" Stretch="UniformToFill"/>
|
||||||
|
</Border>
|
||||||
<Button Content="Start Stream" Style="{StaticResource YtButton}"
|
<Button Content="Start Stream" Style="{StaticResource YtButton}"
|
||||||
Command="{Binding StartStreamCommand}"
|
Command="{Binding StartStreamCommand}"
|
||||||
Visibility="{Binding ShowStartStream, Converter={StaticResource BoolToVis}}"/>
|
Visibility="{Binding ShowStartStream, Converter={StaticResource BoolToVis}}"/>
|
||||||
@@ -128,9 +134,9 @@
|
|||||||
<Grid>
|
<Grid>
|
||||||
<Grid.RowDefinitions>
|
<Grid.RowDefinitions>
|
||||||
<RowDefinition Height="Auto"/>
|
<RowDefinition Height="Auto"/>
|
||||||
<RowDefinition Height="*"/>
|
|
||||||
<RowDefinition Height="Auto"/>
|
<RowDefinition Height="Auto"/>
|
||||||
<RowDefinition Height="180"/>
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="*"/>
|
||||||
</Grid.RowDefinitions>
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
<!-- Scenes: the app has five canonical scenes. Work with less,
|
<!-- Scenes: the app has five canonical scenes. Work with less,
|
||||||
@@ -244,7 +250,7 @@
|
|||||||
</Helpers:FocusPreservingListBox>
|
</Helpers:FocusPreservingListBox>
|
||||||
|
|
||||||
<!-- Sources (for active scene) -->
|
<!-- Sources (for active scene) -->
|
||||||
<StackPanel Grid.Row="2" Orientation="Horizontal" Margin="0,12,0,0">
|
<StackPanel Grid.Row="2" Orientation="Horizontal" Margin="0,8,0,0">
|
||||||
<TextBlock Text="SOURCES" Style="{StaticResource SectionHeader}" VerticalAlignment="Center"/>
|
<TextBlock Text="SOURCES" Style="{StaticResource SectionHeader}" VerticalAlignment="Center"/>
|
||||||
<Button ToolTip="Add Source" Style="{StaticResource IconButton}" Margin="4,-4,0,0"
|
<Button ToolTip="Add Source" Style="{StaticResource IconButton}" Margin="4,-4,0,0"
|
||||||
Click="AddSourceButton_Click">
|
Click="AddSourceButton_Click">
|
||||||
@@ -739,15 +745,85 @@
|
|||||||
</Border>
|
</Border>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<!-- ═══ BOTTOM BAR: Stream Health ═══ -->
|
<!-- ═══ BOTTOM BAR: Stream Health + Audio ═══ -->
|
||||||
<Border Grid.Row="2" Background="#0f3460" Padding="16,6">
|
<Border Grid.Row="2" Background="#0f3460" Padding="16,6">
|
||||||
<Grid>
|
<Grid>
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
|
<!-- Line 1: sound meter + volume control, centered beneath the
|
||||||
|
middle (preview) panel. Audio is KISS: desktop/game audio is
|
||||||
|
automatic ("it just is" — WASAPI loopback at unity, zero UI);
|
||||||
|
the creator's only audio control is the mic — meter, volume, mute. -->
|
||||||
|
<StackPanel Grid.Row="0" Orientation="Horizontal"
|
||||||
|
HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||||
|
<TextBlock Text="MIC" Foreground="#a0a0b0" FontSize="11" FontWeight="SemiBold"
|
||||||
|
VerticalAlignment="Center" Margin="0,0,8,0" Cursor="Hand"
|
||||||
|
ToolTip="Choose the microphone (voice source)"
|
||||||
|
MouseLeftButtonUp="MicLabel_MouseLeftButtonUp"/>
|
||||||
|
<!-- Sound meter: muted track with a ruler scale, zone tints at
|
||||||
|
the yellow (60%) and red (80%) starts. READ-ONLY realtime
|
||||||
|
level display (fill = live level scaled by volume). -->
|
||||||
|
<Border Width="288" Height="14" CornerRadius="7" Background="#3a3b52" Margin="0,0,8,0"
|
||||||
|
ClipToBounds="True" VerticalAlignment="Center">
|
||||||
|
<Grid>
|
||||||
|
<Rectangle Width="57" Fill="#4a4520" HorizontalAlignment="Left" Margin="173,0,0,0"
|
||||||
|
VerticalAlignment="Stretch"/>
|
||||||
|
<Rectangle Width="58" Fill="#4a2222" HorizontalAlignment="Left" Margin="230,0,0,0"
|
||||||
|
VerticalAlignment="Stretch"/>
|
||||||
|
<!-- Mic source name, left-justified inside the bar; the
|
||||||
|
semi-transparent fill lets it (and the markings) show through. -->
|
||||||
|
<TextBlock Text="{Binding MicSourceName}" Foreground="#b8b8c8" FontSize="10"
|
||||||
|
VerticalAlignment="Center" Margin="6,0,0,0" MaxWidth="270"
|
||||||
|
TextTrimming="CharacterEllipsis"
|
||||||
|
Visibility="{Binding MicSourceName, Converter={StaticResource NotNullToVis}}"/>
|
||||||
|
<Border HorizontalAlignment="Left" Width="{Binding MeterFillWidth}"
|
||||||
|
Background="{Binding MeterBrush}" Opacity="0.75"/>
|
||||||
|
<Rectangle Width="1" Height="10" Fill="#26000000" VerticalAlignment="Center" HorizontalAlignment="Left" Margin="36,0,0,0"/>
|
||||||
|
<Rectangle Width="1" Height="10" Fill="#26000000" VerticalAlignment="Center" HorizontalAlignment="Left" Margin="72,0,0,0"/>
|
||||||
|
<Rectangle Width="1" Height="10" Fill="#26000000" VerticalAlignment="Center" HorizontalAlignment="Left" Margin="108,0,0,0"/>
|
||||||
|
<Rectangle Width="1" Height="10" Fill="#26000000" VerticalAlignment="Center" HorizontalAlignment="Left" Margin="144,0,0,0"/>
|
||||||
|
<Rectangle Width="1" Height="10" Fill="#26000000" VerticalAlignment="Center" HorizontalAlignment="Left" Margin="180,0,0,0"/>
|
||||||
|
<Rectangle Width="1" Height="10" Fill="#26000000" VerticalAlignment="Center" HorizontalAlignment="Left" Margin="216,0,0,0"/>
|
||||||
|
<Rectangle Width="1" Height="10" Fill="#26000000" VerticalAlignment="Center" HorizontalAlignment="Left" Margin="252,0,0,0"/>
|
||||||
|
<Rectangle Width="2" Fill="#00000080" HorizontalAlignment="Left" Margin="173,0,0,0"
|
||||||
|
VerticalAlignment="Stretch"/>
|
||||||
|
<Rectangle Width="2" Fill="#00000080" HorizontalAlignment="Left" Margin="230,0,0,0"
|
||||||
|
VerticalAlignment="Stretch"/>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
<Grid Width="16" Height="16" Cursor="Hand" Margin="0,0,8,0"
|
||||||
|
VerticalAlignment="Center" ToolTip="{Binding MicMuteText}"
|
||||||
|
MouseLeftButtonUp="MicSpeaker_MouseLeftButtonUp">
|
||||||
|
<Path Fill="#d0d0d0" Stretch="Uniform"
|
||||||
|
Data="M3,9v6h4l5,5V4L7,9H3zM16.5,12c0,-1.77 -1,-3.29 -2.5,-4.03v8.05c1.5,-0.73 2.5,-2.25 2.5,-4.02zM14,3.23v2.06c2.89,0.86 5,3.54 5,6.71s-2.11,5.85 -5,6.71v2.06c4.01,-0.91 7,-4.49 7,-8.77s-2.99,-7.86 -7,-8.77z"
|
||||||
|
Visibility="{Binding MicMuted, Converter={StaticResource InverseBoolToVis}}"/>
|
||||||
|
<Path Fill="#ef4444" Stretch="Uniform"
|
||||||
|
Data="M3,9v6h4l5,5V4L7,9H3zM16.5,12c0,-1.77 -1,-3.29 -2.5,-4.03v8.05c1.5,-0.73 2.5,-2.25 2.5,-4.02zM14,3.23v2.06c2.89,0.86 5,3.54 5,6.71s-2.11,5.85 -5,6.71v2.06c4.01,-0.91 7,-4.49 7,-8.77s-2.99,-7.86 -7,-8.77z"
|
||||||
|
Visibility="{Binding MicMuted, Converter={StaticResource BoolToVis}}"/>
|
||||||
|
<Path Stroke="#ef4444" StrokeThickness="2.5" StrokeEndLineCap="Round" Stretch="Uniform"
|
||||||
|
Data="M21,3 L3,21"
|
||||||
|
Visibility="{Binding MicMuted, Converter={StaticResource BoolToVis}}"/>
|
||||||
|
</Grid>
|
||||||
|
<Slider Width="130" Minimum="0" Maximum="1"
|
||||||
|
Value="{Binding MicVolume, Mode=TwoWay}" VerticalAlignment="Center"
|
||||||
|
PreviewMouseLeftButtonDown="VolumeSlider_PreviewMouseLeftButtonDown"
|
||||||
|
PreviewMouseLeftButtonUp="VolumeSlider_PreviewMouseLeftButtonUp"
|
||||||
|
LostMouseCapture="VolumeSlider_LostMouseCapture"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- Line 2: everything else — stream stats on the left, quality +
|
||||||
|
gear on the right. -->
|
||||||
|
<Grid Grid.Row="1" Margin="0,6,0,0">
|
||||||
<Grid.ColumnDefinitions>
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
<ColumnDefinition Width="*"/>
|
<ColumnDefinition Width="*"/>
|
||||||
<ColumnDefinition Width="Auto"/>
|
<ColumnDefinition Width="Auto"/>
|
||||||
</Grid.ColumnDefinitions>
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
<StackPanel Orientation="Horizontal">
|
<StackPanel Grid.Column="0" Orientation="Horizontal" VerticalAlignment="Center">
|
||||||
<TextBlock Text="Bitrate:" Style="{StaticResource YtLabel}" Margin="0,0,4,0"/>
|
<TextBlock Text="Bitrate:" Style="{StaticResource YtLabel}" Margin="0,0,4,0"/>
|
||||||
<TextBlock Style="{StaticResource YtLabel}"
|
<TextBlock Style="{StaticResource YtLabel}"
|
||||||
Foreground="White" Margin="0,0,20,0">
|
Foreground="White" Margin="0,0,20,0">
|
||||||
@@ -757,7 +833,7 @@
|
|||||||
|
|
||||||
<TextBlock Text="FPS:" Style="{StaticResource YtLabel}" Margin="0,0,4,0"/>
|
<TextBlock Text="FPS:" Style="{StaticResource YtLabel}" Margin="0,0,4,0"/>
|
||||||
<TextBlock Style="{StaticResource YtLabel}"
|
<TextBlock Style="{StaticResource YtLabel}"
|
||||||
Foreground="White" Margin="0,0,20,0">
|
Foreground="White" Margin="0,0,24,0">
|
||||||
<Run Text="{Binding CurrentHealth.FPS, Mode=OneWay, StringFormat={}{0:0}}"/>
|
<Run Text="{Binding CurrentHealth.FPS, Mode=OneWay, StringFormat={}{0:0}}"/>
|
||||||
</TextBlock>
|
</TextBlock>
|
||||||
|
|
||||||
@@ -773,7 +849,7 @@
|
|||||||
Foreground="#e94560" Margin="20,0,0,0"/>
|
Foreground="#e94560" Margin="20,0,0,0"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<StackPanel Grid.Column="1" Orientation="Horizontal" VerticalAlignment="Center">
|
<StackPanel Grid.Column="2" Orientation="Horizontal" VerticalAlignment="Center">
|
||||||
<ComboBox Style="{StaticResource YtComboBox}" MinWidth="150"
|
<ComboBox Style="{StaticResource YtComboBox}" MinWidth="150"
|
||||||
ItemsSource="{Binding QualityOptions}"
|
ItemsSource="{Binding QualityOptions}"
|
||||||
SelectedItem="{Binding SelectedQuality}"
|
SelectedItem="{Binding SelectedQuality}"
|
||||||
@@ -798,6 +874,7 @@
|
|||||||
</Button>
|
</Button>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<!-- ═══ OVERLAY: Settings / Bug / Feature / About ═══ -->
|
<!-- ═══ OVERLAY: Settings / Bug / Feature / About ═══ -->
|
||||||
|
|||||||
@@ -81,6 +81,38 @@ public partial class MainWindow : Window
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void MicLabel_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
|
||||||
|
{
|
||||||
|
if (sender is FrameworkElement { DataContext: MainViewModel vm })
|
||||||
|
vm.OpenMicPickerCommand.Execute(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void VolumeSlider_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
|
||||||
|
{
|
||||||
|
if (sender is FrameworkElement { DataContext: MainViewModel vm })
|
||||||
|
vm.SetVolumeAdjusting(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void VolumeSlider_PreviewMouseLeftButtonUp(object sender, MouseButtonEventArgs e)
|
||||||
|
{
|
||||||
|
if (sender is FrameworkElement { DataContext: MainViewModel vm })
|
||||||
|
vm.SetVolumeAdjusting(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void VolumeSlider_LostMouseCapture(object sender, MouseEventArgs e)
|
||||||
|
{
|
||||||
|
if (sender is FrameworkElement { DataContext: MainViewModel vm })
|
||||||
|
vm.SetVolumeAdjusting(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void MicSpeaker_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
|
||||||
|
{
|
||||||
|
if (sender is FrameworkElement { DataContext: MainViewModel vm })
|
||||||
|
{
|
||||||
|
vm.ToggleMicMuteCommand.Execute(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void GearButton_Click(object sender, RoutedEventArgs e)
|
private void GearButton_Click(object sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
if (sender is Button { ContextMenu: { } menu } button)
|
if (sender is Button { ContextMenu: { } menu } button)
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
<Window x:Class="ytLive.MicPickerDialog"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
Title="Select Microphone" 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="Select Microphone" FontSize="18" FontWeight="Bold"
|
||||||
|
Foreground="#e0e0e0" Margin="0,0,0,14"/>
|
||||||
|
|
||||||
|
<TextBlock Grid.Row="1" Text="Pick the microphone to use as your voice source."
|
||||||
|
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 Microphones}"
|
||||||
|
SelectedItem="{Binding SelectedMic, Mode=TwoWay}"
|
||||||
|
Visibility="{Binding HasMicrophones, 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 microphones…" Foreground="#a0a0b0" FontSize="13"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<StackPanel VerticalAlignment="Center"
|
||||||
|
Visibility="{Binding NoMicrophones, Converter={StaticResource BoolToVis}}">
|
||||||
|
<TextBlock Text="No microphones found" Foreground="#e0e0e0" FontSize="15"
|
||||||
|
FontWeight="SemiBold" HorizontalAlignment="Center"/>
|
||||||
|
<TextBlock Text="Check that a microphone is connected and that Windows microphone 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 MicPickerDialog : Window
|
||||||
|
{
|
||||||
|
public MicrophoneDeviceInfo? PickedDevice { get; private set; }
|
||||||
|
|
||||||
|
public MicPickerDialog(MicPickerViewModel viewModel)
|
||||||
|
{
|
||||||
|
AppLog.Write("MicPickerDialog ctor: before InitializeComponent");
|
||||||
|
InitializeComponent();
|
||||||
|
AppLog.Write("MicPickerDialog ctor: after InitializeComponent");
|
||||||
|
DataContext = viewModel;
|
||||||
|
viewModel.UseRequested += device =>
|
||||||
|
{
|
||||||
|
PickedDevice = device;
|
||||||
|
DialogResult = true;
|
||||||
|
};
|
||||||
|
viewModel.CancelRequested += () => DialogResult = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace ytLive.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Enumerates audio capture (microphone) devices. Seam so the picker never
|
||||||
|
/// touches WinRT directly (tests inject fakes).
|
||||||
|
/// </summary>
|
||||||
|
public interface IMicrophoneEnumerator
|
||||||
|
{
|
||||||
|
Task<IReadOnlyList<MicrophoneDeviceInfo>> GetMicrophonesAsync();
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
namespace ytLive.Services;
|
||||||
|
|
||||||
|
public sealed class MicrophoneDeviceInfo
|
||||||
|
{
|
||||||
|
public string Id { get; }
|
||||||
|
public string DisplayName { get; }
|
||||||
|
|
||||||
|
public MicrophoneDeviceInfo(string id, string displayName)
|
||||||
|
{
|
||||||
|
Id = id;
|
||||||
|
DisplayName = displayName;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
using Windows.Devices.Enumeration;
|
||||||
|
|
||||||
|
namespace ytLive.Services;
|
||||||
|
|
||||||
|
public sealed class WinRtMicrophoneEnumerator : IMicrophoneEnumerator
|
||||||
|
{
|
||||||
|
public async Task<IReadOnlyList<MicrophoneDeviceInfo>> GetMicrophonesAsync()
|
||||||
|
{
|
||||||
|
var devices = await DeviceInformation.FindAllAsync(DeviceClass.AudioCapture);
|
||||||
|
var result = new List<MicrophoneDeviceInfo>(devices.Count);
|
||||||
|
foreach (var device in devices)
|
||||||
|
result.Add(new MicrophoneDeviceInfo(device.Id, device.Name));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,9 @@ External-facing logic: YouTube API, persistence. See
|
|||||||
| `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)` |
|
||||||
|
| `MicrophoneDeviceInfo.cs` | `(Id, DisplayName)` for an audio capture (mic) device |
|
||||||
|
| `IMicrophoneEnumerator.cs` | `GetMicrophonesAsync()` — seam so the mic picker never touches WinRT (tests inject fakes) |
|
||||||
|
| `WinRtMicrophoneEnumerator.cs` | WinRT mic enumeration via `DeviceInformation.FindAllAsync(DeviceClass.AudioCapture)` (no NAudio needed — capture libs stay deferred to the audio pipeline milestone) |
|
||||||
| `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. `ReleaseAsync` decrements a ref (stops at zero); `ReleaseAllAsync` force-drops every ref + stops the source (webcam swap / layout reload). `GetPreviewBitmap(deviceId)` returns the current shared bitmap so a `WebcamSceneConfig` added mid-session (after the first frame already created the bitmap) still receives the live frames |
|
| `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). `GetPreviewBitmap(deviceId)` returns the current shared bitmap so a `WebcamSceneConfig` added mid-session (after the first frame already created the bitmap) still receives the live frames |
|
||||||
| `IFullScreenDetector.cs` | Seam for the win32 full-screen detector: `int? GetForegroundFullScreenMonitorIndex()`, `int PrimaryMonitorIndex()`, `IReadOnlyList<DisplayInfo> GetDisplays()` |
|
| `IFullScreenDetector.cs` | Seam for the win32 full-screen detector: `int? GetForegroundFullScreenMonitorIndex()`, `int PrimaryMonitorIndex()`, `IReadOnlyList<DisplayInfo> GetDisplays()` |
|
||||||
|
|||||||
@@ -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; **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; **screen backdrop (ship task #1) shipped**: live desktop/game capture as a permanent, non-deletable bottom layer (`Source.IsBackdrop`, schema v5), auto-detecting the full-screen game at launch/focus (else the **primary display** — never assumed monitor 0) via `Win32FullScreenDetector` (now with `GetDisplays()`/`PrimaryMonitorIndex()` for the in-app display picker), content re-designated via the OS `GraphicsCapturePicker` ("Change Capture…") or the in-app "Capture Display" submenu, refcounted/shared capture sessions in `ScreenCaptureManager` mirroring `CameraManager` — 45 tests passing; **schema v6**: `Scene.HasBackdrop`, now **Live-only by policy** — the backdrop is enforced by scene name on every load (`EnforceBackdropPolicy`: Starting/BRB/Chat/Ending never carry one; the one-time v5→v6 backfill covers all four), the scene context-menu "Backdrop" checkbox is gone (policy owns the flag), preview watermark hides when the backdrop renders, capture changed to `WindowsRuntimeMarshal.TryGetDataUnsafe` (the CsWinRT-safe frame-read) + downscale to the 1920×1080 master + 5s-throttled error logging (was flooding `startup.log` with 5 MB of cast errors and burning CPU), round webcam no longer re-rasterizes an `ImageBrush` every frame (Image + EllipseGeometry clip) — the live-mode stutter fix; **the five-scene catalog (`SceneCatalog`)**: Starting/Live/BRB/Chat/Ending is the product — work with less, never more; the (+) button only shows when a canonical scene is missing and re-adds it (its menu lists only the missing ones); **webcam-after-session-start fix**: a webcam added to a scene after the camera was already running (e.g. Chat) previously rendered a transparent container — `CameraManager.GetPreviewBitmap` + propagation in `AddWebcamToActiveSceneAsync`/`ReacquireWebcam` now hands the running shared frames to any newly added `WebcamSceneConfig` — 62 tests passing; the **Chat scene's webcam size cap** is raised from 50%-per-dimension (960×540) to half the screen AREA (~1358×764 @16:9, `MaxWebcamWidthFor`/`MaxWebcamHeightFor` keyed by canonical name) so the viewer sees the creator better — 65 tests passing; **"Add Webcam" always opens the camera picker** (deleting one scene's webcam then re-adding used to resurrect the old camera when another scene still used it — `SwapWebcamIdentityAsync` now swaps the app-wide identity if a different camera is chosen, same path as "Change Webcam…"); scenes/sources UI (add/reorder/rename, image + background overlays with move/resize/opacity/reuse) built; window capture, compositing, 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; **screen backdrop (ship task #1) shipped**: live desktop/game capture as a permanent, non-deletable bottom layer (`Source.IsBackdrop`, schema v5), auto-detecting the full-screen game at launch/focus (else the **primary display** — never assumed monitor 0) via `Win32FullScreenDetector` (now with `GetDisplays()`/`PrimaryMonitorIndex()` for the in-app display picker), content re-designated via the OS `GraphicsCapturePicker` ("Change Capture…") or the in-app "Capture Display" submenu, refcounted/shared capture sessions in `ScreenCaptureManager` mirroring `CameraManager` — 45 tests passing; **schema v6**: `Scene.HasBackdrop`, now **Live-only by policy** — the backdrop is enforced by scene name on every load (`EnforceBackdropPolicy`: Starting/BRB/Chat/Ending never carry one; the one-time v5→v6 backfill covers all four), the scene context-menu "Backdrop" checkbox is gone (policy owns the flag), preview watermark hides when the backdrop renders, capture changed to `WindowsRuntimeMarshal.TryGetDataUnsafe` (the CsWinRT-safe frame-read) + downscale to the 1920×1080 master + 5s-throttled error logging (was flooding `startup.log` with 5 MB of cast errors and burning CPU), round webcam no longer re-rasterizes an `ImageBrush` every frame (Image + EllipseGeometry clip) — the live-mode stutter fix; **the five-scene catalog (`SceneCatalog`)**: Starting/Live/BRB/Chat/Ending is the product — work with less, never more; the (+) button only shows when a canonical scene is missing and re-adds it (its menu lists only the missing ones); **webcam-after-session-start fix**: a webcam added to a scene after the camera was already running (e.g. Chat) previously rendered a transparent container — `CameraManager.GetPreviewBitmap` + propagation in `AddWebcamToActiveSceneAsync`/`ReacquireWebcam` now hands the running shared frames to any newly added `WebcamSceneConfig` — 62 tests passing; the **Chat scene's webcam size cap** is raised from 50%-per-dimension (960×540) to half the screen AREA (~1358×764 @16:9, `MaxWebcamWidthFor`/`MaxWebcamHeightFor` keyed by canonical name) so the viewer sees the creator better — 65 tests passing; **"Add Webcam" always opens the camera picker** (deleting one scene's webcam then re-adding used to resurrect the old camera when another scene still used it — `SwapWebcamIdentityAsync` now swaps the app-wide identity if a different camera is chosen, same path as "Change Webcam…"); scenes/sources UI (add/reorder/rename, image + background overlays with move/resize/opacity/reuse) built; **audio UX shipped (UI)**: the bottom-bar footer is now two lines (dropped/duration moved under bitrate/fps), with the mic's sound meter + mute button + volume slider grouped CENTERED on the footer's top line, beneath the preview panel (meter: 288px, muted slate track with ruler graduations + muted yellow/red zone tints, green→yellow→red fill; mute = speaker icon → red do-not-symbol when muted, and the slider and speaker stay in sync (volume 0 ⇔ muted — sliding off flips the speaker to muted, sliding up from 0 clears it); mic volume defaults to 80%, muting zeroes the meter and restores the prior volume on unmute (which flashes the meter to the restored position ~300ms before it returns to the live level); the meter is a READ-ONLY realtime level display (fill = live level × volume — volume is a gain on ambient noise; while the slider is dragged the bar previews the slider position and bounces back to the live level on release, which is 0 with no input — clicking the meter does nothing), clicking the MIC label opens a microphone picker whose chosen source shows left-justified inside the meter bar (fill at 75% opacity so the name + ruler markings show through); slim dimensional slider — gradient track/fill, gloss-sphere thumb; the old flat pink 18px-filled one is gone), everything else on line 2 (bitrate/fps/dropped/duration/health left, quality + gear right) — the creator's only audio control, desktop/game audio is automatic (KISS rule); the connected YouTube account's avatar/name shows in the top bar next to Start Stream (`SyncConnectedAccount`); the scenes list is content-height now (no dead space before SOURCES); window capture, compositing, encoding pending
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+57
-8
@@ -268,7 +268,34 @@
|
|||||||
</Style.Triggers>
|
</Style.Triggers>
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
<!-- Slider -->
|
<!-- Slider: slim and dimensional — gradient track, beveled fill, gloss-sphere
|
||||||
|
thumb. The filled side sits on a 5px pill aligned with the track; the
|
||||||
|
click target stays the full track height. -->
|
||||||
|
<LinearGradientBrush x:Key="YtSliderTrackBrush" StartPoint="0,0" EndPoint="0,1">
|
||||||
|
<GradientStop Color="#0f1b33" Offset="0"/>
|
||||||
|
<GradientStop Color="#283b60" Offset="1"/>
|
||||||
|
</LinearGradientBrush>
|
||||||
|
|
||||||
|
<LinearGradientBrush x:Key="YtSliderFillBrush" StartPoint="0,0" EndPoint="0,1">
|
||||||
|
<GradientStop Color="#4fe097" Offset="0"/>
|
||||||
|
<GradientStop Color="#2dbd7a" Offset="0.5"/>
|
||||||
|
<GradientStop Color="#1a9e63" Offset="1"/>
|
||||||
|
</LinearGradientBrush>
|
||||||
|
|
||||||
|
<RadialGradientBrush x:Key="YtSliderKnobBrush" Center="0.35,0.3" GradientOrigin="0.35,0.3">
|
||||||
|
<GradientStop Color="#ffffff" Offset="0"/>
|
||||||
|
<GradientStop Color="#d7f5e6" Offset="0.22"/>
|
||||||
|
<GradientStop Color="#3ed588" Offset="0.72"/>
|
||||||
|
<GradientStop Color="#137a4d" Offset="1"/>
|
||||||
|
</RadialGradientBrush>
|
||||||
|
|
||||||
|
<RadialGradientBrush x:Key="YtSliderKnobHoverBrush" Center="0.35,0.3" GradientOrigin="0.35,0.3">
|
||||||
|
<GradientStop Color="#ffffff" Offset="0"/>
|
||||||
|
<GradientStop Color="#e6faef" Offset="0.22"/>
|
||||||
|
<GradientStop Color="#57e9a1" Offset="0.72"/>
|
||||||
|
<GradientStop Color="#1b9a63" Offset="1"/>
|
||||||
|
</RadialGradientBrush>
|
||||||
|
|
||||||
<Style x:Key="YtSliderTrackButton" TargetType="RepeatButton">
|
<Style x:Key="YtSliderTrackButton" TargetType="RepeatButton">
|
||||||
<Setter Property="Focusable" Value="False"/>
|
<Setter Property="Focusable" Value="False"/>
|
||||||
<Setter Property="Template">
|
<Setter Property="Template">
|
||||||
@@ -280,19 +307,40 @@
|
|||||||
</Setter>
|
</Setter>
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
|
<Style x:Key="YtSliderFillButton" TargetType="RepeatButton">
|
||||||
|
<Setter Property="Focusable" Value="False"/>
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate TargetType="RepeatButton">
|
||||||
|
<Grid>
|
||||||
|
<Border Height="5" VerticalAlignment="Center"
|
||||||
|
Background="{StaticResource YtSliderFillBrush}"
|
||||||
|
BorderBrush="#0f7a4e" BorderThickness="1" CornerRadius="2.5"/>
|
||||||
|
</Grid>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
|
||||||
<Style x:Key="YtSliderThumb" TargetType="Thumb">
|
<Style x:Key="YtSliderThumb" TargetType="Thumb">
|
||||||
<Setter Property="IsTabStop" Value="False"/>
|
<Setter Property="IsTabStop" Value="False"/>
|
||||||
<Setter Property="Template">
|
<Setter Property="Template">
|
||||||
<Setter.Value>
|
<Setter.Value>
|
||||||
<ControlTemplate TargetType="Thumb">
|
<ControlTemplate TargetType="Thumb">
|
||||||
<Ellipse x:Name="Knob" Width="16" Height="16" Fill="#e94560"
|
<Grid Width="13" Height="13">
|
||||||
Stroke="#ff8fa3" StrokeThickness="1"/>
|
<Ellipse x:Name="Knob" Fill="{StaticResource YtSliderKnobBrush}"
|
||||||
|
Stroke="#0b1626" StrokeThickness="1">
|
||||||
|
<Ellipse.Effect>
|
||||||
|
<DropShadowEffect BlurRadius="3" ShadowDepth="1" Opacity="0.55"/>
|
||||||
|
</Ellipse.Effect>
|
||||||
|
</Ellipse>
|
||||||
|
</Grid>
|
||||||
<ControlTemplate.Triggers>
|
<ControlTemplate.Triggers>
|
||||||
<Trigger Property="IsMouseOver" Value="True">
|
<Trigger Property="IsMouseOver" Value="True">
|
||||||
<Setter TargetName="Knob" Property="Fill" Value="#ff5b78"/>
|
<Setter TargetName="Knob" Property="Fill" Value="{StaticResource YtSliderKnobHoverBrush}"/>
|
||||||
</Trigger>
|
</Trigger>
|
||||||
<Trigger Property="IsDragging" Value="True">
|
<Trigger Property="IsDragging" Value="True">
|
||||||
<Setter TargetName="Knob" Property="Fill" Value="#ff8fa3"/>
|
<Setter TargetName="Knob" Property="Fill" Value="{StaticResource YtSliderKnobHoverBrush}"/>
|
||||||
</Trigger>
|
</Trigger>
|
||||||
</ControlTemplate.Triggers>
|
</ControlTemplate.Triggers>
|
||||||
</ControlTemplate>
|
</ControlTemplate>
|
||||||
@@ -307,12 +355,13 @@
|
|||||||
<Setter.Value>
|
<Setter.Value>
|
||||||
<ControlTemplate TargetType="Slider">
|
<ControlTemplate TargetType="Slider">
|
||||||
<Grid VerticalAlignment="Center">
|
<Grid VerticalAlignment="Center">
|
||||||
<Border Height="4" CornerRadius="2" Background="#2a3a5e"
|
<Border Height="5" CornerRadius="2.5" VerticalAlignment="Center"
|
||||||
VerticalAlignment="Center"/>
|
Background="{StaticResource YtSliderTrackBrush}"
|
||||||
|
BorderBrush="#0a1424" BorderThickness="1"/>
|
||||||
<Track x:Name="PART_Track" Height="18">
|
<Track x:Name="PART_Track" Height="18">
|
||||||
<Track.DecreaseRepeatButton>
|
<Track.DecreaseRepeatButton>
|
||||||
<RepeatButton Command="{x:Static Slider.DecreaseLarge}"
|
<RepeatButton Command="{x:Static Slider.DecreaseLarge}"
|
||||||
Style="{StaticResource YtSliderTrackButton}" Tag="#e94560"/>
|
Style="{StaticResource YtSliderFillButton}"/>
|
||||||
</Track.DecreaseRepeatButton>
|
</Track.DecreaseRepeatButton>
|
||||||
<Track.IncreaseRepeatButton>
|
<Track.IncreaseRepeatButton>
|
||||||
<RepeatButton Command="{x:Static Slider.IncreaseLarge}"
|
<RepeatButton Command="{x:Static Slider.IncreaseLarge}"
|
||||||
|
|||||||
@@ -22,11 +22,21 @@ public class MainViewModel : ViewModelBase
|
|||||||
private readonly YouTubeStreamService _youtubeStream;
|
private readonly YouTubeStreamService _youtubeStream;
|
||||||
private readonly YouTubeChatService _youtubeChat;
|
private readonly YouTubeChatService _youtubeChat;
|
||||||
private readonly DispatcherTimer _liveTimer;
|
private readonly DispatcherTimer _liveTimer;
|
||||||
|
private readonly DispatcherTimer _volumeFlashTimer;
|
||||||
|
|
||||||
private Scene? _activeScene;
|
private Scene? _activeScene;
|
||||||
private SceneElement? _selectedElement;
|
private SceneElement? _selectedElement;
|
||||||
private ImageSource? _activeBackgroundImage;
|
private ImageSource? _activeBackgroundImage;
|
||||||
private bool _isConnected;
|
private bool _isConnected;
|
||||||
|
private string _accountAvatarUrl = string.Empty;
|
||||||
|
private string _accountDisplayName = string.Empty;
|
||||||
|
private double _audioLevel;
|
||||||
|
private bool _volumeAdjusting;
|
||||||
|
private bool _volumeFlash;
|
||||||
|
private double _micVolume = 0.8;
|
||||||
|
private bool _micMuted;
|
||||||
|
private double? _volumeBeforeMute;
|
||||||
|
private string? _micSourceName;
|
||||||
private StreamStatus _streamStatus = StreamStatus.Offline;
|
private StreamStatus _streamStatus = StreamStatus.Offline;
|
||||||
private StreamHealth _currentHealth = new();
|
private StreamHealth _currentHealth = new();
|
||||||
private string _streamTitle = string.Empty;
|
private string _streamTitle = string.Empty;
|
||||||
@@ -65,6 +75,8 @@ public class MainViewModel : ViewModelBase
|
|||||||
private readonly CameraManager _cameraManager;
|
private readonly CameraManager _cameraManager;
|
||||||
private Webcam? _webcam;
|
private Webcam? _webcam;
|
||||||
|
|
||||||
|
private readonly IMicrophoneEnumerator _microphoneEnumerator;
|
||||||
|
|
||||||
// Screen backdrop: a permanent live capture (desktop/game) that every scene
|
// Screen backdrop: a permanent live capture (desktop/game) that every scene
|
||||||
// shows at the bottom layer. One shared capture session per key — the
|
// shows at the bottom layer. One shared capture session per key — the
|
||||||
// ScreenCaptureManager refcounts by key, mirroring CameraManager.
|
// ScreenCaptureManager refcounts by key, mirroring CameraManager.
|
||||||
@@ -187,6 +199,159 @@ public class MainViewModel : ViewModelBase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>The connected YouTube account's avatar + name — shown in the top
|
||||||
|
/// bar so the creator always sees WHICH account is about to go live.</summary>
|
||||||
|
public string AccountAvatarUrl
|
||||||
|
{
|
||||||
|
get => _accountAvatarUrl;
|
||||||
|
private set => SetProperty(ref _accountAvatarUrl, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string AccountDisplayName
|
||||||
|
{
|
||||||
|
get => _accountDisplayName;
|
||||||
|
private set => SetProperty(ref _accountDisplayName, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Audio (KISS: desktop/game audio is automatic — zero UI. The creator's
|
||||||
|
// ─── only audio control is the mic: meter + volume + mute.) ───
|
||||||
|
|
||||||
|
/// <summary>Live mic input level (0..1) — fed by the audio mixer once capture
|
||||||
|
/// lands; 0 with no input. Read by the meter, scaled by MicVolume.</summary>
|
||||||
|
public double AudioLevel
|
||||||
|
{
|
||||||
|
get => _audioLevel;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (SetProperty(ref _audioLevel, Math.Clamp(value, 0, 1)))
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(MeterFillWidth));
|
||||||
|
OnPropertyChanged(nameof(MeterBrush));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Displayed meter level: 0 while muted; the volume position while
|
||||||
|
/// the slider is being dragged (or briefly after an unmute, so the restored
|
||||||
|
/// level flashes on the bar) so the creator sees where they're setting it;
|
||||||
|
/// otherwise the realtime live level scaled by volume (raising the volume
|
||||||
|
/// moves ambient noise up the bar). Read-only meter.</summary>
|
||||||
|
private double MeterLevel => MicMuted ? 0 : _volumeAdjusting || _volumeFlash ? MicVolume : Math.Min(1, AudioLevel * MicVolume);
|
||||||
|
|
||||||
|
public double MeterFillWidth => MeterLevel * 288;
|
||||||
|
|
||||||
|
public string MeterBrush => MeterLevel switch
|
||||||
|
{
|
||||||
|
< 0.6 => "#22c55e",
|
||||||
|
< 0.8 => "#eab308",
|
||||||
|
_ => "#ef4444",
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>Previewes the volume position while the slider is dragged; the
|
||||||
|
/// bar returns to the live level on release (empty when there is no input).
|
||||||
|
/// Starting a drag cancels any pending unmute flash.</summary>
|
||||||
|
public void SetVolumeAdjusting(bool adjusting)
|
||||||
|
{
|
||||||
|
if (adjusting)
|
||||||
|
CancelVolumeFlash();
|
||||||
|
if (SetProperty(ref _volumeAdjusting, adjusting))
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(MeterFillWidth));
|
||||||
|
OnPropertyChanged(nameof(MeterBrush));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BeginVolumeFlash()
|
||||||
|
{
|
||||||
|
_volumeFlash = true;
|
||||||
|
OnPropertyChanged(nameof(MeterFillWidth));
|
||||||
|
OnPropertyChanged(nameof(MeterBrush));
|
||||||
|
_volumeFlashTimer.Stop();
|
||||||
|
_volumeFlashTimer.Start();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EndVolumeFlash()
|
||||||
|
{
|
||||||
|
_volumeFlashTimer.Stop();
|
||||||
|
if (_volumeFlash)
|
||||||
|
{
|
||||||
|
_volumeFlash = false;
|
||||||
|
OnPropertyChanged(nameof(MeterFillWidth));
|
||||||
|
OnPropertyChanged(nameof(MeterBrush));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CancelVolumeFlash()
|
||||||
|
{
|
||||||
|
_volumeFlashTimer.Stop();
|
||||||
|
_volumeFlash = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Mic gain (0..1). Running to 0 mutes (the speaker flips to muted);
|
||||||
|
/// moving up from 0 unmutes (the mute indicator clears). The level being left
|
||||||
|
/// is remembered so the speaker button can restore it.</summary>
|
||||||
|
public double MicVolume
|
||||||
|
{
|
||||||
|
get => _micVolume;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
var clamped = Math.Clamp(value, 0, 1);
|
||||||
|
if (clamped == 0 && !_micMuted)
|
||||||
|
_volumeBeforeMute ??= _micVolume;
|
||||||
|
if (SetProperty(ref _micVolume, clamped))
|
||||||
|
{
|
||||||
|
var muted = clamped == 0;
|
||||||
|
if (_micMuted != muted)
|
||||||
|
{
|
||||||
|
_micMuted = muted;
|
||||||
|
OnPropertyChanged(nameof(MicMuted));
|
||||||
|
OnPropertyChanged(nameof(MicMuteText));
|
||||||
|
}
|
||||||
|
if (!muted)
|
||||||
|
_volumeBeforeMute = null;
|
||||||
|
OnPropertyChanged(nameof(MeterFillWidth));
|
||||||
|
OnPropertyChanged(nameof(MeterBrush));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Read-only: true whenever the volume is 0. Only MicVolume may
|
||||||
|
/// change it, so the slider and the speaker can never disagree.</summary>
|
||||||
|
public bool MicMuted => _micMuted;
|
||||||
|
|
||||||
|
public string MicMuteText => MicMuted ? "Unmute" : "Mute";
|
||||||
|
|
||||||
|
/// <summary>Name of the picked voice source, shown under the meter on line 2.</summary>
|
||||||
|
public string? MicSourceName
|
||||||
|
{
|
||||||
|
get => _micSourceName;
|
||||||
|
private set => SetProperty(ref _micSourceName, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ToggleMicMute()
|
||||||
|
{
|
||||||
|
if (MicMuted)
|
||||||
|
{
|
||||||
|
MicVolume = _volumeBeforeMute ?? 0.8;
|
||||||
|
BeginVolumeFlash();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_volumeBeforeMute = MicVolume;
|
||||||
|
MicVolume = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void PickMicrophone()
|
||||||
|
{
|
||||||
|
var dialog = new MicPickerDialog(new MicPickerViewModel(_microphoneEnumerator))
|
||||||
|
{
|
||||||
|
Owner = System.Windows.Application.Current?.MainWindow
|
||||||
|
};
|
||||||
|
if (dialog.ShowDialog() == true && dialog.PickedDevice != null)
|
||||||
|
MicSourceName = dialog.PickedDevice.DisplayName;
|
||||||
|
}
|
||||||
|
|
||||||
public bool IsOffline => StreamStatus == StreamStatus.Offline;
|
public bool IsOffline => StreamStatus == StreamStatus.Offline;
|
||||||
public bool IsLive => StreamStatus == StreamStatus.Streaming;
|
public bool IsLive => StreamStatus == StreamStatus.Streaming;
|
||||||
public bool LiveIndicatorVisible => IsLive;
|
public bool LiveIndicatorVisible => IsLive;
|
||||||
@@ -533,6 +698,8 @@ public class MainViewModel : ViewModelBase
|
|||||||
public ICommand ChangeCaptureCommand { get; }
|
public ICommand ChangeCaptureCommand { get; }
|
||||||
public ICommand RefreshCaptureCommand { get; }
|
public ICommand RefreshCaptureCommand { get; }
|
||||||
public ICommand SetBackdropDisplayCommand { get; }
|
public ICommand SetBackdropDisplayCommand { get; }
|
||||||
|
public ICommand ToggleMicMuteCommand { get; }
|
||||||
|
public ICommand OpenMicPickerCommand { 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; }
|
||||||
@@ -564,6 +731,9 @@ public class MainViewModel : ViewModelBase
|
|||||||
_liveTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };
|
_liveTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };
|
||||||
_liveTimer.Tick += OnLiveTimerTick;
|
_liveTimer.Tick += OnLiveTimerTick;
|
||||||
|
|
||||||
|
_volumeFlashTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(300) };
|
||||||
|
_volumeFlashTimer.Tick += (_, _) => EndVolumeFlash();
|
||||||
|
|
||||||
_saveDebounce = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(1500) };
|
_saveDebounce = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(1500) };
|
||||||
_saveDebounce.Tick += (_, _) => SaveLayoutNow();
|
_saveDebounce.Tick += (_, _) => SaveLayoutNow();
|
||||||
|
|
||||||
@@ -587,6 +757,8 @@ public class MainViewModel : ViewModelBase
|
|||||||
ChangeCaptureCommand = new RelayCommand(_ => _ = ChangeBackdropCaptureAsync());
|
ChangeCaptureCommand = new RelayCommand(_ => _ = ChangeBackdropCaptureAsync());
|
||||||
RefreshCaptureCommand = new RelayCommand(_ => RefreshBackdropAutoCapture());
|
RefreshCaptureCommand = new RelayCommand(_ => RefreshBackdropAutoCapture());
|
||||||
SetBackdropDisplayCommand = new RelayCommand(display => SetBackdropCapture(display as DisplayInfo));
|
SetBackdropDisplayCommand = new RelayCommand(display => SetBackdropCapture(display as DisplayInfo));
|
||||||
|
ToggleMicMuteCommand = new RelayCommand(_ => ToggleMicMute());
|
||||||
|
OpenMicPickerCommand = new RelayCommand(_ => PickMicrophone());
|
||||||
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"));
|
||||||
@@ -611,6 +783,8 @@ public class MainViewModel : ViewModelBase
|
|||||||
System.Windows.Application.Current?.Dispatcher);
|
System.Windows.Application.Current?.Dispatcher);
|
||||||
_cameraManager.PreviewBitmapChanged += OnCameraPreviewBitmapChanged;
|
_cameraManager.PreviewBitmapChanged += OnCameraPreviewBitmapChanged;
|
||||||
|
|
||||||
|
_microphoneEnumerator = new WinRtMicrophoneEnumerator();
|
||||||
|
|
||||||
_fullScreenDetector = new Win32FullScreenDetector();
|
_fullScreenDetector = new Win32FullScreenDetector();
|
||||||
foreach (var display in _fullScreenDetector.GetDisplays())
|
foreach (var display in _fullScreenDetector.GetDisplays())
|
||||||
Displays.Add(display);
|
Displays.Add(display);
|
||||||
@@ -651,6 +825,7 @@ public class MainViewModel : ViewModelBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
IsConnected = true;
|
IsConnected = true;
|
||||||
|
SyncConnectedAccount();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -658,6 +833,13 @@ public class MainViewModel : ViewModelBase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void SyncConnectedAccount()
|
||||||
|
{
|
||||||
|
var channel = _youtubeAuth.CurrentChannel;
|
||||||
|
AccountAvatarUrl = channel?.ProfileImageUrl ?? string.Empty;
|
||||||
|
AccountDisplayName = channel?.DisplayName ?? string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
private static string DefaultLayoutPath => Path.Combine(
|
private static string DefaultLayoutPath => Path.Combine(
|
||||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||||
"ytLlive",
|
"ytLlive",
|
||||||
@@ -1443,6 +1625,7 @@ public class MainViewModel : ViewModelBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
IsConnected = true;
|
IsConnected = true;
|
||||||
|
SyncConnectedAccount();
|
||||||
return channel;
|
return channel;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -1494,6 +1677,7 @@ public class MainViewModel : ViewModelBase
|
|||||||
_youtubeAuth.ClearSession();
|
_youtubeAuth.ClearSession();
|
||||||
TokenStore.Clear();
|
TokenStore.Clear();
|
||||||
IsConnected = false;
|
IsConnected = false;
|
||||||
|
SyncConnectedAccount();
|
||||||
AppLog.Write("Stream ended; session signed out");
|
AppLog.Write("Stream ended; session signed out");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Windows.Input;
|
||||||
|
using ytLive.Helpers;
|
||||||
|
using ytLive.Services;
|
||||||
|
|
||||||
|
namespace ytLive.ViewModels;
|
||||||
|
|
||||||
|
public class MicPickerCandidate
|
||||||
|
{
|
||||||
|
public MicrophoneDeviceInfo Device { get; }
|
||||||
|
public string Name => Device.DisplayName;
|
||||||
|
|
||||||
|
public MicPickerCandidate(MicrophoneDeviceInfo device)
|
||||||
|
{
|
||||||
|
Device = device;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class MicPickerViewModel : ViewModelBase
|
||||||
|
{
|
||||||
|
private readonly IMicrophoneEnumerator _enumerator;
|
||||||
|
private MicPickerCandidate? _selectedMic;
|
||||||
|
private bool _isLoading = true;
|
||||||
|
|
||||||
|
public ObservableCollection<MicPickerCandidate> Microphones { get; } = new();
|
||||||
|
|
||||||
|
public bool IsLoading
|
||||||
|
{
|
||||||
|
get => _isLoading;
|
||||||
|
private set => SetProperty(ref _isLoading, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool HasMicrophones => !IsLoading && Microphones.Count > 0;
|
||||||
|
public bool NoMicrophones => !IsLoading && Microphones.Count == 0;
|
||||||
|
|
||||||
|
public MicPickerCandidate? SelectedMic
|
||||||
|
{
|
||||||
|
get => _selectedMic;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (SetProperty(ref _selectedMic, value))
|
||||||
|
CommandManager.InvalidateRequerySuggested();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public ICommand UseSelectedCommand { get; }
|
||||||
|
public ICommand CancelCommand { get; }
|
||||||
|
|
||||||
|
public event Action<MicrophoneDeviceInfo>? UseRequested;
|
||||||
|
public event Action? CancelRequested;
|
||||||
|
|
||||||
|
public MicPickerViewModel(IMicrophoneEnumerator enumerator)
|
||||||
|
{
|
||||||
|
_enumerator = enumerator;
|
||||||
|
UseSelectedCommand = new RelayCommand(
|
||||||
|
_ => { if (SelectedMic != null) UseRequested?.Invoke(SelectedMic.Device); },
|
||||||
|
_ => SelectedMic != null);
|
||||||
|
CancelCommand = new RelayCommand(_ => CancelRequested?.Invoke());
|
||||||
|
_ = LoadAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadAsync()
|
||||||
|
{
|
||||||
|
IsLoading = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var devices = await _enumerator.GetMicrophonesAsync();
|
||||||
|
Microphones.Clear();
|
||||||
|
foreach (var device in devices)
|
||||||
|
Microphones.Add(new MicPickerCandidate(device));
|
||||||
|
if (Microphones.Count > 0)
|
||||||
|
SelectedMic = Microphones[0];
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLog.Write($"MicPickerViewModel: enumerating microphones failed: {ex.Message}");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsLoading = false;
|
||||||
|
OnPropertyChanged(nameof(HasMicrophones));
|
||||||
|
OnPropertyChanged(nameof(NoMicrophones));
|
||||||
|
CommandManager.InvalidateRequerySuggested();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-1
@@ -4,10 +4,11 @@ MVVM layer. See [`schema.md`](../schema.md) for the memory-map conventions.
|
|||||||
|
|
||||||
| File | Purpose |
|
| File | Purpose |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| `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; the Windows camera picker ALWAYS opens so the creator chooses — never silently reuses the previous camera; picking a different camera swaps the app-wide identity via `SwapWebcamIdentityAsync`, same path as `ChangeWebcamAsync`; propagates the running shared bitmap via `CameraManager.GetPreviewBitmap` first, so a webcam added mid-session never renders a transparent container; 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 (re-propagates the shared bitmap to every config), `OnCameraPreviewBitmapChanged` forwards the shared bitmap into every `WebcamSceneConfig.VideoImageSource`; `SelectedElement` drives the preview overlay + `internal ClampWebcamToBounds(config, sceneName)` (per-scene size cap seam: 50%-per-dimension everywhere, half-screen-AREA in Chat via `MaxWebcamWidthFor`/`MaxWebcamHeightFor`). **Scenes (five-scene catalog):** empty DB seeds Starting/Live/BRB/Chat/Ending (`SceneCatalog.All`); `AddScene(name)` accepts only canonical, missing names; `MissingScenes`/`ShowAddScene` drive the "+" button (hidden once all five exist; its menu lists only the missing scenes via `AddSceneCommand`). **Screen backdrop (Live-only by policy):** owns `ScreenCaptureManager` + `ScreenCaptureSourceFactory` (WinRT GraphicsCapture), `internal static EnsureBackdrop` heals one per backdrop-enabled scene (gated on `Scene.HasBackdrop`), `ReacquireScreenCaptures`/`RefreshBackdropAutoCapture`/`NoteBackgroundWindow` key by full-screen game monitor or the **primary display** via `Win32FullScreenDetector` (`GetDisplays()`/`PrimaryMonitorIndex()`), `ChangeBackdropCaptureAsync` (OS picker) + `SetBackdropCapture(DisplayInfo)` (in-app "Capture Display"), `RedesignateBackdropAsync` re-targets all backdrops and releases orphaned sessions, `internal static EnforceBackdropPolicy` normalizes `Scene.HasBackdrop` by name on every load + strips lingering non-Live backdrops, `CanChangeBackdrop` gates the capture menu to Live (the only scene with a backdrop), `BackdropImage` hides the preview watermark (`ShowPreviewPlaceholder`) |
|
| `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). **Connected account:** top-bar avatar/name (`AccountAvatarUrl`/`AccountDisplayName` via `SyncConnectedAccount`, cleared on End). **Audio (KISS — the mic is the creator's only audio control; capture pipeline still pending):** the sound meter is a **READ-ONLY realtime level display**: fill = `Math.Min(1, AudioLevel * MicVolume)` (`MeterFillWidth`/`MeterBrush`, green→yellow→red, zone markers at 60%/80%), where `AudioLevel` (live input, 0 with no input) is fed by the audio mixer once capture lands and `MicVolume` acts as a gain on ambient noise; while the slider is dragged the bar previews the slider position (`SetVolumeAdjusting`), returning to the live level on release (0 with no input — clicking the meter does nothing); `MicVolume` (default 0.8) + read-only `MicMuted`/`MicMuteText`/`ToggleMicMuteCommand` — **MicVolume drives MicMuted** (muted ⇔ volume 0): sliding to 0 flips the speaker to muted, sliding up from 0 clears it; muting stores the prior volume, unmuting restores it (default 0.8 if unknown) and flashes the meter to the restored position ~300ms (`BeginVolumeFlash`/`EndVolumeFlash`); `MicSourceName` = picked voice source, shown left-justified inside the meter bar (the fill runs at 75% opacity so the text + ruler markings show through); `OpenMicPickerCommand`/`PickMicrophone()` open the `MicPickerDialog`; desktop/game audio is automatic (WASAPI loopback at unity, zero UI). **Webcam:** owns `CameraManager` (MediaCapture), `AddWebcamToActiveSceneAsync` (picker → default 480×270 bottom-right placement → acquire; the Windows camera picker ALWAYS opens so the creator chooses — never silently reuses the previous camera; picking a different camera swaps the app-wide identity via `SwapWebcamIdentityAsync`, same path as `ChangeWebcamAsync`; propagates the running shared bitmap via `CameraManager.GetPreviewBitmap` first, so a webcam added mid-session never renders a transparent container; 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 (re-propagates the shared bitmap to every config), `OnCameraPreviewBitmapChanged` forwards the shared bitmap into every `WebcamSceneConfig.VideoImageSource`; `SelectedElement` drives the preview overlay + `internal ClampWebcamToBounds(config, sceneName)` (per-scene size cap seam: 50%-per-dimension everywhere, half-screen-AREA in Chat via `MaxWebcamWidthFor`/`MaxWebcamHeightFor`). **Scenes (five-scene catalog):** empty DB seeds Starting/Live/BRB/Chat/Ending (`SceneCatalog.All`); `AddScene(name)` accepts only canonical, missing names; `MissingScenes`/`ShowAddScene` drive the "+" button (hidden once all five exist; its menu lists only the missing scenes via `AddSceneCommand`). **Screen backdrop (Live-only by policy):** owns `ScreenCaptureManager` + `ScreenCaptureSourceFactory` (WinRT GraphicsCapture), `internal static EnsureBackdrop` heals one per backdrop-enabled scene (gated on `Scene.HasBackdrop`), `ReacquireScreenCaptures`/`RefreshBackdropAutoCapture`/`NoteBackgroundWindow` key by full-screen game monitor or the **primary display** via `Win32FullScreenDetector` (`GetDisplays()`/`PrimaryMonitorIndex()`), `ChangeBackdropCaptureAsync` (OS picker) + `SetBackdropCapture(DisplayInfo)` (in-app "Capture Display"), `RedesignateBackdropAsync` re-targets all backdrops and releases orphaned sessions, `internal static EnforceBackdropPolicy` normalizes `Scene.HasBackdrop` by name on every load + strips lingering non-Live backdrops, `CanChangeBackdrop` gates the capture menu to Live (the only scene with a backdrop), `BackdropImage` hides the preview watermark (`ShowPreviewPlaceholder`) |
|
||||||
| `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` |
|
||||||
|
| `MicPickerViewModel.cs` | Select Microphone dialog (voice source): async mic list (loading / has / none states), `UseRequested(MicrophoneDeviceInfo)`/`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).
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ C# / WPF (.NET 8) following MVVM:
|
|||||||
|
|
||||||
- `ViewModelBase.SetProperty<T>()` for property change notifications
|
- `ViewModelBase.SetProperty<T>()` for property change notifications
|
||||||
- `RelayCommand` for all button actions; commands gate on state (e.g. Start only when Offline). **Typed `CommandParameter`s — no stringly-typed command tokens:** menu items that pick a source type pass the enum value itself (`CommandParameter="{x:Static models:SourceType.DisplayCapture}"`), so a typo breaks the build instead of silently adding an Image; `AddSource` still falls back to `Enum.TryParse<SourceType>(..., true)` for safety. The webcam item is its own `AddWebcamCommand` (it greys out via `CanAddWebcamToActiveScene` and isn't a `SourceType` — webcams are `WebcamSceneConfig`, not `Source` rows)
|
- `RelayCommand` for all button actions; commands gate on state (e.g. Start only when Offline). **Typed `CommandParameter`s — no stringly-typed command tokens:** menu items that pick a source type pass the enum value itself (`CommandParameter="{x:Static models:SourceType.DisplayCapture}"`), so a typo breaks the build instead of silently adding an Image; `AddSource` still falls back to `Enum.TryParse<SourceType>(..., true)` for safety. The webcam item is its own `AddWebcamCommand` (it greys out via `CanAddWebcamToActiveScene` and isn't a `SourceType` — webcams are `WebcamSceneConfig`, not `Source` rows)
|
||||||
|
- **Audio is KISS by rule** — the whole of audio is *one knob*: **desktop/game audio is automatic** (WASAPI loopback from the default output at unity, zero UI — "it just is"); the **mic is the creator's only audio control** — sound meter + mute button + volume slider (`MicVolume`, defaults to 0.8) all sit together on the footer's top line, CENTERED beneath the preview panel. Meter: 288px, muted slate track (`#3a3b52`) with ruler graduations and muted yellow/red zone tints at 60%/80%; fill = green → yellow → red via `MeterFillWidth`/`MeterBrush`; the meter is a **READ-ONLY realtime level display** — it shows the live input level scaled by the volume (raising the volume moves ambient noise up the bar), NOT the volume setting: the fill is `Math.Min(1, AudioLevel * MicVolume)` (`AudioLevel` is fed by the audio mixer once capture lands, 0 with no input) and 0 while muted. While the volume slider is being dragged the bar previews the slider position (`SetVolumeAdjusting`, from `PreviewMouseLeftButtonDown/Up` + `LostMouseCapture` handlers) so the creator sees where they're setting it; on release it returns to the live level — with no input it bounces back to 0, exactly as it does today. Clicking the meter does nothing; **clicking the MIC label opens the mic picker** (`OpenMicPickerCommand`), and the picked voice source name (`MicSourceName`) is shown left-justified INSIDE the meter bar (FontSize 10, ellipsized to the bar) — the fill runs at 75% opacity so the text and the ruler markings stay visible through it. Mute (`ToggleMicMuteCommand`/`MicMuted`) is a plain clickable speaker icon (`MicSpeaker_MouseLeftButtonUp` code-behind handler — not a Button, `Stretch="Uniform"` so the glyph is never clipped) that swaps to a red do-not-symbol (slashed speaker) when muted. **The slider and the speaker can never disagree:** `MicMuted` is read-only, derived from `MicVolume == 0` — sliding the volume off flips the speaker to muted (storing the prior level in `_volumeBeforeMute`), sliding it up from 0 clears the mute indicator (and the stored level); the speaker button just runs the volume to 0 or restores it (default 0.8 if unknown). Muting zeroes the meter; **unmuting flashes the meter to the restored position for ~300ms** (`BeginVolumeFlash`/`EndVolumeFlash` on a DispatcherTimer, cancelled if the slider is grabbed) before it returns to the live level. Line 2 of the footer holds everything else: stream stats (bitrate/fps/dropped/duration/health) on the left, quality dropdown + gear on the right. The slider is a slim dimensional style in `Themes/Controls.xaml` (gradient track, beveled green fill on a 5px pill, gloss-sphere thumb with drop shadow — deliberately NOT flat). No device pickers (never show device names — no "install a device you didn't know existed"), no filter stacks, no monitoring, no routing — OBS's confusion (dynamic mixer, unintuitive names, four required filters) is deliberately absent. A production-ready mic chain (high-pass → noise gate → compressor) will be applied invisibly in the mixer, unconfigurable. Capture runs only while live (privacy indicator stays off otherwise). Capture pipeline = `IAudioSource` seam + NAudio `WasapiCapture`/`WasapiLoopbackCapture` + `AudioMixer` (pending — the UI is in place now). The connected account's avatar/name shows in the top bar next to Start Stream (`AccountAvatarUrl`/`AccountDisplayName` via `SyncConnectedAccount`), so the creator always sees WHICH account will go live.
|
||||||
- ViewModels are constructed in XAML (`<vm:MainViewModel/>` as DataContext)
|
- ViewModels are constructed in XAML (`<vm:MainViewModel/>` as DataContext)
|
||||||
- Services are currently instantiated in MainViewModel's constructor — no DI container yet
|
- Services are currently instantiated in MainViewModel's constructor — no DI container yet
|
||||||
- Layout persists to SQLite (`Microsoft.Data.Sqlite`); scenes/sources/asset bytes stored in the DB, asset identity is a SHA-256 content hash (1:M reuse, no file paths — assets are always available)
|
- Layout persists to SQLite (`Microsoft.Data.Sqlite`); scenes/sources/asset bytes stored in the DB, asset identity is a SHA-256 content hash (1:M reuse, no file paths — assets are always available)
|
||||||
|
|||||||
Reference in New Issue
Block a user