diff --git a/MainWindow.xaml b/MainWindow.xaml
index c3f3665..6e5b5e5 100644
--- a/MainWindow.xaml
+++ b/MainWindow.xaml
@@ -753,8 +753,70 @@
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -771,9 +833,20 @@
+ Foreground="White" Margin="0,0,24,0">
+
+
+
+
+
+
+
+
@@ -801,53 +874,6 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs
index 5dced42..8d74f4d 100644
--- a/MainWindow.xaml.cs
+++ b/MainWindow.xaml.cs
@@ -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)
diff --git a/MicPickerDialog.xaml b/MicPickerDialog.xaml
new file mode 100644
index 0000000..b5c376d
--- /dev/null
+++ b/MicPickerDialog.xaml
@@ -0,0 +1,90 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/MicPickerDialog.xaml.cs b/MicPickerDialog.xaml.cs
new file mode 100644
index 0000000..ebf1e27
--- /dev/null
+++ b/MicPickerDialog.xaml.cs
@@ -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;
+ }
+}
diff --git a/Services/IMicrophoneEnumerator.cs b/Services/IMicrophoneEnumerator.cs
new file mode 100644
index 0000000..f0e6a51
--- /dev/null
+++ b/Services/IMicrophoneEnumerator.cs
@@ -0,0 +1,10 @@
+namespace ytLive.Services;
+
+///
+/// Enumerates audio capture (microphone) devices. Seam so the picker never
+/// touches WinRT directly (tests inject fakes).
+///
+public interface IMicrophoneEnumerator
+{
+ Task> GetMicrophonesAsync();
+}
diff --git a/Services/MicrophoneDeviceInfo.cs b/Services/MicrophoneDeviceInfo.cs
new file mode 100644
index 0000000..0cab3cf
--- /dev/null
+++ b/Services/MicrophoneDeviceInfo.cs
@@ -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;
+ }
+}
diff --git a/Services/WinRtMicrophoneEnumerator.cs b/Services/WinRtMicrophoneEnumerator.cs
new file mode 100644
index 0000000..6c9fb6c
--- /dev/null
+++ b/Services/WinRtMicrophoneEnumerator.cs
@@ -0,0 +1,15 @@
+using Windows.Devices.Enumeration;
+
+namespace ytLive.Services;
+
+public sealed class WinRtMicrophoneEnumerator : IMicrophoneEnumerator
+{
+ public async Task> GetMicrophonesAsync()
+ {
+ var devices = await DeviceInformation.FindAllAsync(DeviceClass.AudioCapture);
+ var result = new List(devices.Count);
+ foreach (var device in devices)
+ result.Add(new MicrophoneDeviceInfo(device.Id, device.Name));
+ return result;
+ }
+}
diff --git a/Services/index.md b/Services/index.md
index b989445..ac55f9d 100644
--- a/Services/index.md
+++ b/Services/index.md
@@ -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 GetDisplays()` |
diff --git a/TASKS.md b/TASKS.md
index 865a991..05c623c 100644
--- a/TASKS.md
+++ b/TASKS.md
@@ -159,7 +159,7 @@ Preview shows the transition too (WYSIWYG). No wipes/slides/LUTs beyond the four
- **Scenes list:** drag rows to reorder scenes
- **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
---
diff --git a/Themes/Controls.xaml b/Themes/Controls.xaml
index 7f8e1a0..c64e0d4 100644
--- a/Themes/Controls.xaml
+++ b/Themes/Controls.xaml
@@ -268,7 +268,34 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+