From e2867198b9bfb58cf53ba84c3555eefc402a4a65 Mon Sep 17 00:00:00 2001 From: gramps Date: Sun, 30 Aug 2026 07:35:21 -0700 Subject: [PATCH] =?UTF-8?q?refactor:=20split=20Webcam=20out=20of=20MainVie?= =?UTF-8?q?wModel=20=E2=80=94=20Commit=205/12=20(tag=20refactor-commit-5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ViewModels/MainViewModel.Webcam.cs | 269 +++++++++++++++++++++++++++++ ViewModels/MainViewModel.cs | 256 --------------------------- ViewModels/index.md | 9 +- 3 files changed, 277 insertions(+), 257 deletions(-) create mode 100644 ViewModels/MainViewModel.Webcam.cs diff --git a/ViewModels/MainViewModel.Webcam.cs b/ViewModels/MainViewModel.Webcam.cs new file mode 100644 index 0000000..07d7847 --- /dev/null +++ b/ViewModels/MainViewModel.Webcam.cs @@ -0,0 +1,269 @@ +using System.Windows; +using System.Windows.Input; +using System.Windows.Media.Imaging; +using ytLive; +using ytLive.Models; +using ytLive.Services; + +namespace ytLive.ViewModels; + +public partial class MainViewModel +{ + // Webcam: one camera input app-wide. The identity (DeviceId) lives on the + // Webcam entity; each scene that shows the webcam has a WebcamSceneConfig + // (webcam.{scene}.config). CameraManager still owns the single session. + private readonly ICameraEnumerator _cameraEnumerator; + private readonly CameraManager _cameraManager; + private Webcam? _webcam; + private string? _webcamError; + + /// The Add → Webcam menu item: enabled only while no webcam exists anywhere — + /// one camera identity app-wide; re-adding always opens the picker. + public bool CanAddWebcam => StagedScene != null && _webcam == null; + + /// + /// Right-click-on-preview → "Show Webcam": offered when the active scene has no + /// webcam config (add one) or hides it (unhide — keeps the config row). + /// + public bool CanShowWebcamInStagedScene + => StagedScene is { } scene && (scene.WebcamConfig == null || !scene.WebcamConfig.IsVisible); + + /// Swap-the-device item: enabled once a camera has been picked at all. + public bool CanChangeWebcam => _webcam != null; + + /// Shows the mirror / clip-shape row in the source chip when a webcam is selected. + public bool IsWebcamSelected => SelectedElement is WebcamSceneConfig; + + /// + /// The reason the webcam feed is down (device in use, offline, locked, no frames), + /// or null when it's alive. Surfaced as a red chip so a dead camera is never a + /// silent empty box. Cleared the moment a real frame arrives. + /// + public string? WebcamError + { + get => _webcamError; + private set => SetProperty(ref _webcamError, value); + } + + // Webcam size safeguard: no more than half the frame in any dimension + // (960x540 over the 1920x1080 master), and no less than 10% of it + // (192x108). Enforced at resize and on layout load. The Chat scene is + // exempt from the per-dimension half — its webcam may take half the + // screen AREA (~1358x764 @16:9) so the viewer sees the creator better, + // matched by canonical name (SceneCatalog.IsChat). + public const double WebcamMaxWidth = 960; + public const double WebcamMaxHeight = 540; + public const double WebcamChatMaxWidth = 1358; + public const double WebcamChatMaxHeight = 764; + public const double WebcamMinWidth = MasterFrameWidth * 0.1; + public const double WebcamMinHeight = MasterFrameHeight * 0.1; + + public static double MaxWebcamWidthFor(string? sceneName) + => SceneCatalog.IsChat(sceneName) ? WebcamChatMaxWidth : WebcamMaxWidth; + + public static double MaxWebcamHeightFor(string? sceneName) + => SceneCatalog.IsChat(sceneName) ? WebcamChatMaxHeight : WebcamMaxHeight; + + internal static void ClampWebcamToBounds(WebcamSceneConfig config, string? sceneName) + { + var maxWidth = MaxWebcamWidthFor(sceneName); + var maxHeight = MaxWebcamHeightFor(sceneName); + var scale = Math.Min(maxWidth / config.Width, maxHeight / config.Height); + if (scale < 1) + { + config.Width = Math.Round(config.Width * scale); + config.Height = Math.Round(config.Height * scale); + return; + } + + var minScale = Math.Max(WebcamMinWidth / config.Width, WebcamMinHeight / config.Height); + if (minScale > 1) + { + config.Width = Math.Round(config.Width * minScale); + config.Height = Math.Round(config.Height * minScale); + } + } + + // One-time heal for layouts saved before the rect dims were persisted (v3→v4): + // a Traditional webcam that ended up square (Round resize then reload lost the + // pre-Round rect) gets widened to 16:9, keeping the height. Round is skipped — + // a square bounding box is correct there — and an explicit pre-Round rect wins. + internal static void HealLegacySquareRect(WebcamSceneConfig config) + { + if (config.ClipShape != ClipShape.Traditional) return; + if (config.RectWidth != null || config.RectHeight != null) return; + if (Math.Abs(config.Width - config.Height) >= 1) return; + config.Width = Math.Round(config.Height * 16.0 / 9.0); + } + + public ICommand AddWebcamCommand { get; } + public ICommand ChangeWebcamCommand { get; } + public ICommand ShowWebcamCommand { get; } + + // After a layout load / file open: re-point the webcam identity, stop the + // previous device if it changed, and acquire the current one once per scene + // that uses it (CameraManager refcounts by DeviceId — one camera session). + private void ReacquireWebcam() + { + var previousDevice = _webcam?.DeviceId; + _webcam = _layoutStore.Webcam; + var newDevice = _webcam?.DeviceId; + + if (!string.IsNullOrWhiteSpace(previousDevice) && previousDevice != newDevice) + _ = _cameraManager.ReleaseAllAsync(previousDevice); + + OnPropertyChanged(nameof(CanChangeWebcam)); + OnPropertyChanged(nameof(CanAddWebcam)); + + if (_webcam == null || string.IsNullOrWhiteSpace(newDevice)) return; + if (previousDevice != newDevice) + foreach (var unused in Scenes.SelectMany(s => s.Elements.OfType())) + _ = _cameraManager.AcquireAsync(newDevice); + + // A session already running (same device, or one that produced a first + // frame) has its shared bitmap; freshly loaded configs must adopt it here, + // because PreviewBitmapChanged never re-fires for an existing bitmap. + if (_cameraManager.GetPreviewBitmap(newDevice) is { } running) + foreach (var config in Scenes.SelectMany(s => s.Elements.OfType())) + config.VideoImageSource = running; + } + + // CameraManager creates the shared WriteableBitmap on the UI thread at the + // device's frame size; every scene's webcam config picks it up from here. A + // bitmap means a real frame arrived — the camera is provably alive. + private void OnCameraPreviewBitmapChanged(string deviceId, WriteableBitmap bitmap) + { + if (_webcam?.DeviceId != deviceId) return; + WebcamError = null; + foreach (var scene in Scenes) + foreach (var config in scene.Elements.OfType()) + config.VideoImageSource = bitmap; + } + + // The camera failed to start or died asynchronously (in use, offline, locked, + // no frames within the proof timeout) — surface it instead of a silent box. + private void OnCameraFailed(string deviceId, string message) + { + if (_webcam?.DeviceId != deviceId) return; + WebcamError = $"Webcam offline: {message}"; + } + + // Adds the webcam to the active scene. The creator ALWAYS picks from the + // cameras Windows has registered — never silently resurrects the previous + // camera (which is what happened after deleting one scene's webcam while + // another scene still used it). Picking a different camera than the current + // app-wide one swaps it everywhere, so the single-identity model stays honest; + // each scene's placement config is independent (webcam.{scene}.config). + private async Task AddWebcamToStagedSceneAsync() + { + var scene = StagedScene; + if (scene == null || scene.WebcamConfig != null) return; + + var dialog = new CameraPickerDialog(new CameraPickerViewModel(_cameraEnumerator)) + { + Owner = Application.Current.MainWindow + }; + if (dialog.ShowDialog() != true || dialog.PickedDevice == null) return; + var device = dialog.PickedDevice; + + if (_webcam == null) + { + _webcam = new Webcam { DeviceId = device.Id, Name = device.DisplayName }; + OnPropertyChanged(nameof(CanChangeWebcam)); + OnPropertyChanged(nameof(CanAddWebcam)); + } + else if (_webcam.DeviceId != device.Id) + { + await SwapWebcamIdentityAsync(device); + } + + var config = new WebcamSceneConfig + { + WebcamId = _webcam.Id, + Name = _webcam.Name, + Width = 480, + Height = 270, + X = MasterFrameWidth - 480 - 32, + Y = MasterFrameHeight - 270 - 32, + }; + + scene.Elements.Add(config); + SelectedElement = config; + OnPropertyChanged(nameof(ShowEmptySceneHint)); + OnPropertyChanged(nameof(ShowSourcesEmptyHint)); + UpdateActiveBackground(); + + // PreviewBitmapChanged fires only on the camera's first frame, so a config + // added while the session is already running must pick up the shared bitmap + // directly (it's written in place from then on). + if (_cameraManager.GetPreviewBitmap(_webcam.DeviceId) is { } running) + config.VideoImageSource = running; + + var started = await _cameraManager.AcquireAsync(_webcam.DeviceId); + if (!started) + { + _notifications.Warning("Camera unavailable", + WebcamError ?? "Couldn't start that camera. It may be in use by another app, or Windows camera access may be turned off."); + } + } + + // Swaps the device on the app-wide webcam identity. The old device is stopped + // unconditionally; each scene that uses the webcam re-acquires the new one so + // the per-config refcount stays honest. Only called when the device differs. + private async Task SwapWebcamIdentityAsync(CameraDeviceInfo device) + { + var oldDevice = _webcam!.DeviceId; + _webcam.DeviceId = device.Id; + _webcam.Name = device.DisplayName; + ScheduleSave(); + + if (!string.IsNullOrWhiteSpace(oldDevice) && oldDevice != device.Id) + await _cameraManager.ReleaseAllAsync(oldDevice); + + foreach (var unused in Scenes.SelectMany(s => s.Elements.OfType())) + { + var started = await _cameraManager.AcquireAsync(device.Id); + if (!started) + { + _notifications.Warning("Camera unavailable", + WebcamError ?? "Couldn't start that camera. It may be in use by another app, or Windows camera access may be turned off."); + break; + } + } + } + + // "Change Webcam…" from the webcam's context menu: picker, then swap the + // app-wide identity if a different device was chosen. + private async Task ChangeWebcamAsync() + { + if (_webcam == null) return; + + var dialog = new CameraPickerDialog(new CameraPickerViewModel(_cameraEnumerator)) + { + Owner = Application.Current.MainWindow + }; + if (dialog.ShowDialog() != true || dialog.PickedDevice == null) return; + if (dialog.PickedDevice.Id == _webcam.DeviceId) return; + + await SwapWebcamIdentityAsync(dialog.PickedDevice); + } + + // "Show Webcam" from a right-click on the empty preview. Unhides this scene's + // existing (hidden) config — the config row survives Hide in this scene — or + // adds the webcam here for the first time (which opens the camera picker). + private void ShowWebcamInStagedScene() + { + var scene = StagedScene; + if (scene == null) return; + + var config = scene.WebcamConfig; + if (config != null) + { + config.IsVisible = true; + SelectedElement = config; + return; + } + + _ = AddWebcamToStagedSceneAsync(); + } +} \ No newline at end of file diff --git a/ViewModels/MainViewModel.cs b/ViewModels/MainViewModel.cs index 345202d..8debead 100644 --- a/ViewModels/MainViewModel.cs +++ b/ViewModels/MainViewModel.cs @@ -106,13 +106,6 @@ public partial class MainViewModel : ViewModelBase // news up the real service; the VM is built from XAML so no DI seam exists. private readonly INotificationService _notifications = new NotificationService(); - // Webcam: one camera input app-wide. The identity (DeviceId) lives on the - // Webcam entity; each scene that shows the webcam has a WebcamSceneConfig - // (webcam.{scene}.config). CameraManager still owns the single session. - private readonly ICameraEnumerator _cameraEnumerator; - private readonly CameraManager _cameraManager; - private Webcam? _webcam; - private string? _webcamError; private SocialsConfig? _socials; private VideoFrame? _socialBarFrame; private readonly IMicrophoneEnumerator _microphoneEnumerator; @@ -202,26 +195,12 @@ public partial class MainViewModel : ViewModelBase } } - /// The Add → Webcam menu item: enabled only while no webcam exists anywhere — - /// one camera identity app-wide; re-adding always opens the picker. - public bool CanAddWebcam => StagedScene != null && _webcam == null; - /// The Add → YouTube Chat menu item: enabled only while no chat layer exists /// anywhere — one chat overlay per layout. public bool CanAddYouTubeChat => StagedScene != null && !Scenes.Any(s => s.Elements.OfType().Any(e => e.Type == SourceType.ChatBox)); - /// - /// Right-click-on-preview → "Show Webcam": offered when the active scene has no - /// webcam config (add one) or hides it (unhide — keeps the config row). - /// - public bool CanShowWebcamInStagedScene - => StagedScene is { } scene && (scene.WebcamConfig == null || !scene.WebcamConfig.IsVisible); - - /// Swap-the-device item: enabled once a camera has been picked at all. - public bool CanChangeWebcam => _webcam != null; - // ─── Social bar (global resource, shown on every scene) ─── private static readonly SolidColorBrush BarOnBrush = CreateBrush("#2ecc71"); @@ -249,25 +228,11 @@ public partial class MainViewModel : ViewModelBase private readonly ISocialValidator _socialValidator = new HttpSocialValidator(); - /// Shows the mirror / clip-shape row in the source chip when a webcam is selected. - public bool IsWebcamSelected => SelectedElement is WebcamSceneConfig; - /// True when the Elements panel should show the "No configurable elements" hint. public bool IsElementsPanelEmpty => SelectedElement is { } el && !el.IsDraggable && !el.IsWebcam && el is not Source { Type: SourceType.Countdown or SourceType.WebSource or SourceType.ChatBox }; - /// - /// The reason the webcam feed is down (device in use, offline, locked, no frames), - /// or null when it's alive. Surfaced as a red chip so a dead camera is never a - /// silent empty box. Cleared the moment a real frame arrives. - /// - public string? WebcamError - { - get => _webcamError; - private set => SetProperty(ref _webcamError, value); - } - public StreamStatus StreamStatus { get => _streamStatus; @@ -973,57 +938,6 @@ public partial class MainViewModel : ViewModelBase private const double MasterFrameWidth = 1920; private const double MasterFrameHeight = 1080; - // Webcam size safeguard: no more than half the frame in any dimension - // (960x540 over the 1920x1080 master), and no less than 10% of it - // (192x108). Enforced at resize and on layout load. The Chat scene is - // exempt from the per-dimension half — its webcam may take half the - // screen AREA (~1358x764 @16:9) so the viewer sees the creator better, - // matched by canonical name (SceneCatalog.IsChat). - public const double WebcamMaxWidth = 960; - public const double WebcamMaxHeight = 540; - public const double WebcamChatMaxWidth = 1358; - public const double WebcamChatMaxHeight = 764; - public const double WebcamMinWidth = MasterFrameWidth * 0.1; - public const double WebcamMinHeight = MasterFrameHeight * 0.1; - - public static double MaxWebcamWidthFor(string? sceneName) - => SceneCatalog.IsChat(sceneName) ? WebcamChatMaxWidth : WebcamMaxWidth; - - public static double MaxWebcamHeightFor(string? sceneName) - => SceneCatalog.IsChat(sceneName) ? WebcamChatMaxHeight : WebcamMaxHeight; - - internal static void ClampWebcamToBounds(WebcamSceneConfig config, string? sceneName) - { - var maxWidth = MaxWebcamWidthFor(sceneName); - var maxHeight = MaxWebcamHeightFor(sceneName); - var scale = Math.Min(maxWidth / config.Width, maxHeight / config.Height); - if (scale < 1) - { - config.Width = Math.Round(config.Width * scale); - config.Height = Math.Round(config.Height * scale); - return; - } - - var minScale = Math.Max(WebcamMinWidth / config.Width, WebcamMinHeight / config.Height); - if (minScale > 1) - { - config.Width = Math.Round(config.Width * minScale); - config.Height = Math.Round(config.Height * minScale); - } - } - - // One-time heal for layouts saved before the rect dims were persisted (v3→v4): - // a Traditional webcam that ended up square (Round resize then reload lost the - // pre-Round rect) gets widened to 16:9, keeping the height. Round is skipped — - // a square bounding box is correct there — and an explicit pre-Round rect wins. - internal static void HealLegacySquareRect(WebcamSceneConfig config) - { - if (config.ClipShape != ClipShape.Traditional) return; - if (config.RectWidth != null || config.RectHeight != null) return; - if (Math.Abs(config.Width - config.Height) >= 1) return; - config.Width = Math.Round(config.Height * 16.0 / 9.0); - } - public QualityOption[] QualityOptions { get; } = { new("1080p60", 60, 8.0, 1920, 1080), @@ -1123,13 +1037,10 @@ public partial class MainViewModel : ViewModelBase // Commands public ICommand AddSceneCommand { get; } public ICommand AddSourceCommand { get; } - public ICommand AddWebcamCommand { get; } public ICommand AddImageCommand { get; } public ICommand RemoveSourceCommand { get; } public ICommand EditElementCommand { get; } public ICommand ToggleElementVisibilityCommand { get; } - public ICommand ChangeWebcamCommand { get; } - public ICommand ShowWebcamCommand { get; } public ICommand ToggleMicMuteCommand { get; } public ICommand ToggleGameMuteCommand { get; } public ICommand OpenMicPickerCommand { get; } @@ -1399,54 +1310,6 @@ public partial class MainViewModel : ViewModelBase AppLog.Write("LoadLayout end"); } - // After a layout load / file open: re-point the webcam identity, stop the - // previous device if it changed, and acquire the current one once per scene - // that uses it (CameraManager refcounts by DeviceId — one camera session). - private void ReacquireWebcam() - { - var previousDevice = _webcam?.DeviceId; - _webcam = _layoutStore.Webcam; - var newDevice = _webcam?.DeviceId; - - if (!string.IsNullOrWhiteSpace(previousDevice) && previousDevice != newDevice) - _ = _cameraManager.ReleaseAllAsync(previousDevice); - - OnPropertyChanged(nameof(CanChangeWebcam)); - OnPropertyChanged(nameof(CanAddWebcam)); - - if (_webcam == null || string.IsNullOrWhiteSpace(newDevice)) return; - if (previousDevice != newDevice) - foreach (var unused in Scenes.SelectMany(s => s.Elements.OfType())) - _ = _cameraManager.AcquireAsync(newDevice); - - // A session already running (same device, or one that produced a first - // frame) has its shared bitmap; freshly loaded configs must adopt it here, - // because PreviewBitmapChanged never re-fires for an existing bitmap. - if (_cameraManager.GetPreviewBitmap(newDevice) is { } running) - foreach (var config in Scenes.SelectMany(s => s.Elements.OfType())) - config.VideoImageSource = running; - } - - // CameraManager creates the shared WriteableBitmap on the UI thread at the - // device's frame size; every scene's webcam config picks it up from here. A - // bitmap means a real frame arrived — the camera is provably alive. - private void OnCameraPreviewBitmapChanged(string deviceId, WriteableBitmap bitmap) - { - if (_webcam?.DeviceId != deviceId) return; - WebcamError = null; - foreach (var scene in Scenes) - foreach (var config in scene.Elements.OfType()) - config.VideoImageSource = bitmap; - } - - // The camera failed to start or died asynchronously (in use, offline, locked, - // no frames within the proof timeout) — surface it instead of a silent box. - private void OnCameraFailed(string deviceId, string message) - { - if (_webcam?.DeviceId != deviceId) return; - WebcamError = $"Webcam offline: {message}"; - } - private void RegisterLoadedWebSources() { if (_webView2Manager == null) return; @@ -1616,125 +1479,6 @@ public partial class MainViewModel : ViewModelBase return $"{baseName}{i}"; } - // Adds the webcam to the active scene. The creator ALWAYS picks from the - // cameras Windows has registered — never silently resurrects the previous - // camera (which is what happened after deleting one scene's webcam while - // another scene still used it). Picking a different camera than the current - // app-wide one swaps it everywhere, so the single-identity model stays honest; - // each scene's placement config is independent (webcam.{scene}.config). - private async Task AddWebcamToStagedSceneAsync() - { - var scene = StagedScene; - if (scene == null || scene.WebcamConfig != null) return; - - var dialog = new CameraPickerDialog(new CameraPickerViewModel(_cameraEnumerator)) - { - Owner = Application.Current.MainWindow - }; - if (dialog.ShowDialog() != true || dialog.PickedDevice == null) return; - var device = dialog.PickedDevice; - - if (_webcam == null) - { - _webcam = new Webcam { DeviceId = device.Id, Name = device.DisplayName }; - OnPropertyChanged(nameof(CanChangeWebcam)); - OnPropertyChanged(nameof(CanAddWebcam)); - } - else if (_webcam.DeviceId != device.Id) - { - await SwapWebcamIdentityAsync(device); - } - - var config = new WebcamSceneConfig - { - WebcamId = _webcam.Id, - Name = _webcam.Name, - Width = 480, - Height = 270, - X = MasterFrameWidth - 480 - 32, - Y = MasterFrameHeight - 270 - 32, - }; - - scene.Elements.Add(config); - SelectedElement = config; - OnPropertyChanged(nameof(ShowEmptySceneHint)); - OnPropertyChanged(nameof(ShowSourcesEmptyHint)); - UpdateActiveBackground(); - - // PreviewBitmapChanged fires only on the camera's first frame, so a config - // added while the session is already running must pick up the shared bitmap - // directly (it's written in place from then on). - if (_cameraManager.GetPreviewBitmap(_webcam.DeviceId) is { } running) - config.VideoImageSource = running; - - var started = await _cameraManager.AcquireAsync(_webcam.DeviceId); - if (!started) - { - _notifications.Warning("Camera unavailable", - WebcamError ?? "Couldn't start that camera. It may be in use by another app, or Windows camera access may be turned off."); - } - } - - // Swaps the device on the app-wide webcam identity. The old device is stopped - // unconditionally; each scene that uses the webcam re-acquires the new one so - // the per-config refcount stays honest. Only called when the device differs. - private async Task SwapWebcamIdentityAsync(CameraDeviceInfo device) - { - var oldDevice = _webcam!.DeviceId; - _webcam.DeviceId = device.Id; - _webcam.Name = device.DisplayName; - ScheduleSave(); - - if (!string.IsNullOrWhiteSpace(oldDevice) && oldDevice != device.Id) - await _cameraManager.ReleaseAllAsync(oldDevice); - - foreach (var unused in Scenes.SelectMany(s => s.Elements.OfType())) - { - var started = await _cameraManager.AcquireAsync(device.Id); - if (!started) - { - _notifications.Warning("Camera unavailable", - WebcamError ?? "Couldn't start that camera. It may be in use by another app, or Windows camera access may be turned off."); - break; - } - } - } - - // "Change Webcam…" from the webcam's context menu: picker, then swap the - // app-wide identity if a different device was chosen. - private async Task ChangeWebcamAsync() - { - if (_webcam == null) return; - - var dialog = new CameraPickerDialog(new CameraPickerViewModel(_cameraEnumerator)) - { - Owner = Application.Current.MainWindow - }; - if (dialog.ShowDialog() != true || dialog.PickedDevice == null) return; - if (dialog.PickedDevice.Id == _webcam.DeviceId) return; - - await SwapWebcamIdentityAsync(dialog.PickedDevice); - } - - // "Show Webcam" from a right-click on the empty preview. Unhides this scene's - // existing (hidden) config — the config row survives Hide in this scene — or - // adds the webcam here for the first time (which opens the camera picker). - private void ShowWebcamInStagedScene() - { - var scene = StagedScene; - if (scene == null) return; - - var config = scene.WebcamConfig; - if (config != null) - { - config.IsVisible = true; - SelectedElement = config; - return; - } - - _ = AddWebcamToStagedSceneAsync(); - } - private void AddImage() { var scene = StagedScene; diff --git a/ViewModels/index.md b/ViewModels/index.md index 0c03f39..0c3de11 100644 --- a/ViewModels/index.md +++ b/ViewModels/index.md @@ -50,12 +50,19 @@ Progress (Commit N/12 · tag): `ChangeCaptureCommand`/`RefreshCaptureCommand`/`SetBackgroundDisplayCommand`/ `ToggleShowDesktopCommand`/`BrowseBackgroundCommand`). `ShowPreviewPlaceholder` stays in core. +- **Commit 5/12 · `refactor-commit-5`: Webcam landed** → `MainViewModel.Webcam.cs` + (webcam identity `_webcam`/`ReacquireWebcam`/`SwapWebcamIdentityAsync`, per-scene + configs `AddWebcamToStagedSceneAsync`/`ShowWebcamInStagedScene`, capture lifetime + `OnCameraPreviewBitmapChanged`/`OnCameraFailed`, gating `CanAddWebcam`/ + `CanChangeWebcam`/`CanShowWebcamInStagedScene`/`IsWebcamSelected`/`WebcamError`, + size safeguard `WebcamMax*`/`ClampWebcamToBounds`/`HealLegacySquareRect`, + `AddWebcamCommand`/`ChangeWebcamCommand`/`ShowWebcamCommand`). - Core file keeps: fields, ctor, `Shutdown`/`SaveLayoutNow`/`ScheduleSave`/`LoadLayout`, `SetPanel`/`ShowOverlay`/`ShowLicenses`, `CreateBrush`, `OpenUrl`/`ComposeEmail`, `SubmitBug`/`SubmitFeature`, window-geometry pass-through. - Partials: `Account` ✔ (Commit 1), `License` ✔ (Commit 2), `Scenes` ✔ (Commit 3), `Background` ✔ (Commit 4), - `Webcam` (webcam identity + per-scene configs + captures), + `Webcam` ✔ (Commit 5), `Audio` (mic + game bars + TRAX), `Trax` (music player), `Socials` (social bar + dialog), `Streaming` (go-live/record + health + quality tiers + media source pipeline), `Chat` (YouTube chat + mock preview + chatbox render), `Overlays` (settings/bug/feature/about