Mic audio UX: realtime read-only meter + mic picker
- Meter is a READ-ONLY realtime level display: fill = Math.Min(1, AudioLevel * MicVolume) (AudioLevel = live input from the future mixer, 0 with no input; MicVolume is gain on ambient noise). While the slider is dragged the bar previews the slider position (PreviewMouseLeftButtonDown/Up + LostMouseCapture -> SetVolumeAdjusting); on release it returns to the live level (bounces to 0 with no input). Clicking the meter does nothing. - MicVolume drives MicMuted (read-only, muted <=> volume 0): sliding to 0 flips the speaker to the red slashed mute icon, sliding up from 0 clears it; speaker button runs volume to 0 or restores the prior level (_volumeBeforeMute, default 0.8) and flashes the meter to the restored position for ~300ms (BeginVolumeFlash/EndVolumeFlash DispatcherTimer, cancelled if the slider is grabbed). - MIC label opens MicPickerDialog (WinRT audio-capture device enumeration, mirrors camera picker, no new packages); the chosen voice source name shows left-justified INSIDE the meter bar (fill at 75% opacity so text + ruler markings show through). - New files: Services/IMicrophoneEnumerator, MicrophoneDeviceInfo, WinRtMicrophoneEnumerator; ViewModels/MicPickerViewModel; MicPickerDialog. - Docs updated (ai.md, TASKS.md, Services/index.md, ViewModels/index.md); build 0 warnings, 65 tests passing
This commit is contained in:
+76
-50
@@ -753,8 +753,70 @@
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- Line 1: bitrate / fps · quality -->
|
||||
<Grid Grid.Row="0">
|
||||
<!-- 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>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
@@ -771,9 +833,20 @@
|
||||
|
||||
<TextBlock Text="FPS:" Style="{StaticResource YtLabel}" Margin="0,0,4,0"/>
|
||||
<TextBlock Style="{StaticResource YtLabel}"
|
||||
Foreground="White">
|
||||
Foreground="White" Margin="0,0,24,0">
|
||||
<Run Text="{Binding CurrentHealth.FPS, Mode=OneWay, StringFormat={}{0:0}}"/>
|
||||
</TextBlock>
|
||||
|
||||
<TextBlock Text="Dropped:" Style="{StaticResource YtLabel}" Margin="0,0,4,0"/>
|
||||
<TextBlock Text="{Binding CurrentHealth.DroppedFrames}" Style="{StaticResource YtLabel}"
|
||||
Foreground="White" Margin="0,0,20,0"/>
|
||||
|
||||
<TextBlock Text="Duration:" Style="{StaticResource YtLabel}" Margin="0,0,4,0"/>
|
||||
<TextBlock Text="{Binding CurrentHealth.StreamDuration}" Style="{StaticResource YtLabel}"
|
||||
Foreground="White"/>
|
||||
|
||||
<TextBlock Text="{Binding CurrentHealth.HealthMessage}" Style="{StaticResource YtLabel}"
|
||||
Foreground="#e94560" Margin="20,0,0,0"/>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Grid.Column="2" Orientation="Horizontal" VerticalAlignment="Center">
|
||||
@@ -801,53 +874,6 @@
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<!-- Line 2: dropped / duration (under bitrate / fps) + the MIC chip.
|
||||
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. -->
|
||||
<Grid Grid.Row="1" Margin="0,6,0,0">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<StackPanel Grid.Column="0" Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<TextBlock Text="Dropped:" Style="{StaticResource YtLabel}" Margin="0,0,4,0"/>
|
||||
<TextBlock Text="{Binding CurrentHealth.DroppedFrames}" Style="{StaticResource YtLabel}"
|
||||
Foreground="White" Margin="0,0,20,0"/>
|
||||
|
||||
<TextBlock Text="Duration:" Style="{StaticResource YtLabel}" Margin="0,0,4,0"/>
|
||||
<TextBlock Text="{Binding CurrentHealth.StreamDuration}" Style="{StaticResource YtLabel}"
|
||||
Foreground="White"/>
|
||||
|
||||
<TextBlock Text="{Binding CurrentHealth.HealthMessage}" Style="{StaticResource YtLabel}"
|
||||
Foreground="#e94560" Margin="20,0,0,0"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Sound meter: plain filled bar, blank → green → yellow → red,
|
||||
with zone markers at the yellow (60%) and red (80%) starts. -->
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||
<TextBlock Text="MIC" Foreground="#a0a0b0" FontSize="11" FontWeight="SemiBold" VerticalAlignment="Center"/>
|
||||
<Border Width="360" Height="14" CornerRadius="7" Background="#1a1a2e" Margin="8,0,8,0"
|
||||
ClipToBounds="True" VerticalAlignment="Center">
|
||||
<Grid>
|
||||
<Border HorizontalAlignment="Left" Width="{Binding MeterFillWidth}"
|
||||
Background="{Binding MeterBrush}"/>
|
||||
<Rectangle Width="2" Fill="#00000080" HorizontalAlignment="Left" Margin="216,0,0,0"
|
||||
VerticalAlignment="Stretch"/>
|
||||
<Rectangle Width="2" Fill="#00000080" HorizontalAlignment="Left" Margin="288,0,0,0"
|
||||
VerticalAlignment="Stretch"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
<Slider Width="130" Minimum="0" Maximum="1"
|
||||
Value="{Binding MicVolume, Mode=TwoWay}" VerticalAlignment="Center"/>
|
||||
<Button Content="{Binding MicMuteText}" Style="{StaticResource YtButtonSecondary}"
|
||||
Padding="10,3" Margin="8,0,0,0" Command="{Binding ToggleMicMuteCommand}"
|
||||
VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
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) |
|
||||
| `ICameraFrameSource.cs` | `StartAsync`/`StopAsync`/`FrameAvailable(VideoFrame)` — seam for a running capture source |
|
||||
| `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 |
|
||||
| `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()` |
|
||||
|
||||
@@ -159,7 +159,7 @@ Preview shows the transition too (WYSIWYG). No wipes/slides/LUTs beyond the four
|
||||
- **Scenes list:** drag rows to reorder scenes
|
||||
- **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; **audio UX shipped (UI)**: the bottom-bar footer is now two lines (dropped/duration moved under bitrate/fps), with a centered sound meter (plain filled bar, blank → green → yellow → red, zone markers at 60%/80%) + a MIC chip (volume slider + mute) — 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
|
||||
### 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>
|
||||
|
||||
<!-- 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">
|
||||
<Setter Property="Focusable" Value="False"/>
|
||||
<Setter Property="Template">
|
||||
@@ -280,19 +307,40 @@
|
||||
</Setter>
|
||||
</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">
|
||||
<Setter Property="IsTabStop" Value="False"/>
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="Thumb">
|
||||
<Ellipse x:Name="Knob" Width="16" Height="16" Fill="#e94560"
|
||||
Stroke="#ff8fa3" StrokeThickness="1"/>
|
||||
<Grid Width="13" Height="13">
|
||||
<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>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="Knob" Property="Fill" Value="#ff5b78"/>
|
||||
<Setter TargetName="Knob" Property="Fill" Value="{StaticResource YtSliderKnobHoverBrush}"/>
|
||||
</Trigger>
|
||||
<Trigger Property="IsDragging" Value="True">
|
||||
<Setter TargetName="Knob" Property="Fill" Value="#ff8fa3"/>
|
||||
<Setter TargetName="Knob" Property="Fill" Value="{StaticResource YtSliderKnobHoverBrush}"/>
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
@@ -307,12 +355,13 @@
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="Slider">
|
||||
<Grid VerticalAlignment="Center">
|
||||
<Border Height="4" CornerRadius="2" Background="#2a3a5e"
|
||||
VerticalAlignment="Center"/>
|
||||
<Border Height="5" CornerRadius="2.5" VerticalAlignment="Center"
|
||||
Background="{StaticResource YtSliderTrackBrush}"
|
||||
BorderBrush="#0a1424" BorderThickness="1"/>
|
||||
<Track x:Name="PART_Track" Height="18">
|
||||
<Track.DecreaseRepeatButton>
|
||||
<RepeatButton Command="{x:Static Slider.DecreaseLarge}"
|
||||
Style="{StaticResource YtSliderTrackButton}" Tag="#e94560"/>
|
||||
Style="{StaticResource YtSliderFillButton}"/>
|
||||
</Track.DecreaseRepeatButton>
|
||||
<Track.IncreaseRepeatButton>
|
||||
<RepeatButton Command="{x:Static Slider.IncreaseLarge}"
|
||||
|
||||
+125
-17
@@ -22,6 +22,7 @@ public class MainViewModel : ViewModelBase
|
||||
private readonly YouTubeStreamService _youtubeStream;
|
||||
private readonly YouTubeChatService _youtubeChat;
|
||||
private readonly DispatcherTimer _liveTimer;
|
||||
private readonly DispatcherTimer _volumeFlashTimer;
|
||||
|
||||
private Scene? _activeScene;
|
||||
private SceneElement? _selectedElement;
|
||||
@@ -30,8 +31,12 @@ public class MainViewModel : ViewModelBase
|
||||
private string _accountAvatarUrl = string.Empty;
|
||||
private string _accountDisplayName = string.Empty;
|
||||
private double _audioLevel;
|
||||
private double _micVolume = 1.0;
|
||||
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 StreamHealth _currentHealth = new();
|
||||
private string _streamTitle = string.Empty;
|
||||
@@ -70,6 +75,8 @@ public class MainViewModel : ViewModelBase
|
||||
private readonly CameraManager _cameraManager;
|
||||
private Webcam? _webcam;
|
||||
|
||||
private readonly IMicrophoneEnumerator _microphoneEnumerator;
|
||||
|
||||
// Screen backdrop: a permanent live capture (desktop/game) that every scene
|
||||
// shows at the bottom layer. One shared capture session per key — the
|
||||
// ScreenCaptureManager refcounts by key, mirroring CameraManager.
|
||||
@@ -209,8 +216,8 @@ public class MainViewModel : ViewModelBase
|
||||
// ─── Audio (KISS: desktop/game audio is automatic — zero UI. The creator's
|
||||
// ─── only audio control is the mic: meter + volume + mute.) ───
|
||||
|
||||
/// <summary>Current level 0..1 — fed by the audio mixer once capture lands.
|
||||
/// Drives the sound meter's fill width + zone color.</summary>
|
||||
/// <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;
|
||||
@@ -224,33 +231,127 @@ public class MainViewModel : ViewModelBase
|
||||
}
|
||||
}
|
||||
|
||||
public double MeterFillWidth => AudioLevel * 360;
|
||||
/// <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 string MeterBrush => AudioLevel switch
|
||||
public double MeterFillWidth => MeterLevel * 288;
|
||||
|
||||
public string MeterBrush => MeterLevel switch
|
||||
{
|
||||
< 0.6 => "#22c55e",
|
||||
< 0.8 => "#eab308",
|
||||
_ => "#ef4444",
|
||||
};
|
||||
|
||||
public double MicVolume
|
||||
/// <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)
|
||||
{
|
||||
get => _micVolume;
|
||||
set => SetProperty(ref _micVolume, Math.Clamp(value, 0, 1));
|
||||
}
|
||||
|
||||
public bool MicMuted
|
||||
{
|
||||
get => _micMuted;
|
||||
set
|
||||
if (adjusting)
|
||||
CancelVolumeFlash();
|
||||
if (SetProperty(ref _volumeAdjusting, adjusting))
|
||||
{
|
||||
if (SetProperty(ref _micMuted, value))
|
||||
OnPropertyChanged(nameof(MicMuteText));
|
||||
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 IsLive => StreamStatus == StreamStatus.Streaming;
|
||||
public bool LiveIndicatorVisible => IsLive;
|
||||
@@ -598,6 +699,7 @@ public class MainViewModel : ViewModelBase
|
||||
public ICommand RefreshCaptureCommand { get; }
|
||||
public ICommand SetBackdropDisplayCommand { get; }
|
||||
public ICommand ToggleMicMuteCommand { get; }
|
||||
public ICommand OpenMicPickerCommand { get; }
|
||||
public ICommand StartStreamCommand { get; }
|
||||
public ICommand EndStreamCommand { get; }
|
||||
public ICommand OpenSettingsCommand { get; }
|
||||
@@ -629,6 +731,9 @@ public class MainViewModel : ViewModelBase
|
||||
_liveTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };
|
||||
_liveTimer.Tick += OnLiveTimerTick;
|
||||
|
||||
_volumeFlashTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(300) };
|
||||
_volumeFlashTimer.Tick += (_, _) => EndVolumeFlash();
|
||||
|
||||
_saveDebounce = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(1500) };
|
||||
_saveDebounce.Tick += (_, _) => SaveLayoutNow();
|
||||
|
||||
@@ -652,7 +757,8 @@ public class MainViewModel : ViewModelBase
|
||||
ChangeCaptureCommand = new RelayCommand(_ => _ = ChangeBackdropCaptureAsync());
|
||||
RefreshCaptureCommand = new RelayCommand(_ => RefreshBackdropAutoCapture());
|
||||
SetBackdropDisplayCommand = new RelayCommand(display => SetBackdropCapture(display as DisplayInfo));
|
||||
ToggleMicMuteCommand = new RelayCommand(_ => MicMuted = !MicMuted);
|
||||
ToggleMicMuteCommand = new RelayCommand(_ => ToggleMicMute());
|
||||
OpenMicPickerCommand = new RelayCommand(_ => PickMicrophone());
|
||||
OpenSettingsCommand = new RelayCommand(_ => ShowOverlay(nameof(IsSettingsOpen), "App Settings"));
|
||||
OpenBugCommand = new RelayCommand(_ => ShowOverlay(nameof(IsBugOpen), "Report Bug"));
|
||||
OpenFeatureCommand = new RelayCommand(_ => ShowOverlay(nameof(IsFeatureOpen), "Feature Request"));
|
||||
@@ -677,6 +783,8 @@ public class MainViewModel : ViewModelBase
|
||||
System.Windows.Application.Current?.Dispatcher);
|
||||
_cameraManager.PreviewBitmapChanged += OnCameraPreviewBitmapChanged;
|
||||
|
||||
_microphoneEnumerator = new WinRtMicrophoneEnumerator();
|
||||
|
||||
_fullScreenDetector = new Win32FullScreenDetector();
|
||||
foreach (var display in _fullScreenDetector.GetDisplays())
|
||||
Displays.Add(display);
|
||||
|
||||
@@ -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 |
|
||||
|------|---------|
|
||||
| `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):** `AudioLevel` (0..1) drives the bottom-bar sound meter (`MeterFillWidth`/`MeterBrush`, green→yellow→red with zone markers at 60%/80%), `MicVolume` slider + `MicMuteText`/`ToggleMicMuteCommand`; 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`) |
|
||||
| `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 |
|
||||
| `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` |
|
||||
| `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)
|
||||
for what the ViewModels drive; base class in [`Helpers/ViewModelBase.cs`](../Helpers/ViewModelBase.cs).
|
||||
|
||||
@@ -96,7 +96,7 @@ C# / WPF (.NET 8) following MVVM:
|
||||
|
||||
- `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)
|
||||
- **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**, a single bottom-bar MIC chip: sound meter (blank → green → yellow → red with zone markers at 60%/80%, `MeterFillWidth`/`MeterBrush` from `MainViewModel.AudioLevel`) + volume slider (`MicVolume`) + mute (`MicMuteText`). 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.
|
||||
- **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)
|
||||
- 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)
|
||||
|
||||
Reference in New Issue
Block a user