refactor: split Webcam out of MainViewModel — Commit 5/12 (tag refactor-commit-5)
This commit is contained in:
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>The Add → Webcam menu item: enabled only while no webcam exists anywhere —
|
||||||
|
/// one camera identity app-wide; re-adding always opens the picker.</summary>
|
||||||
|
public bool CanAddWebcam => StagedScene != null && _webcam == null;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
|
public bool CanShowWebcamInStagedScene
|
||||||
|
=> StagedScene is { } scene && (scene.WebcamConfig == null || !scene.WebcamConfig.IsVisible);
|
||||||
|
|
||||||
|
/// <summary>Swap-the-device item: enabled once a camera has been picked at all.</summary>
|
||||||
|
public bool CanChangeWebcam => _webcam != null;
|
||||||
|
|
||||||
|
/// <summary>Shows the mirror / clip-shape row in the source chip when a webcam is selected.</summary>
|
||||||
|
public bool IsWebcamSelected => SelectedElement is WebcamSceneConfig;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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<WebcamSceneConfig>()))
|
||||||
|
_ = _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<WebcamSceneConfig>()))
|
||||||
|
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<WebcamSceneConfig>())
|
||||||
|
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<WebcamSceneConfig>()))
|
||||||
|
{
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
// news up the real service; the VM is built from XAML so no DI seam exists.
|
||||||
private readonly INotificationService _notifications = new NotificationService();
|
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 SocialsConfig? _socials;
|
||||||
private VideoFrame? _socialBarFrame;
|
private VideoFrame? _socialBarFrame;
|
||||||
private readonly IMicrophoneEnumerator _microphoneEnumerator;
|
private readonly IMicrophoneEnumerator _microphoneEnumerator;
|
||||||
@@ -202,26 +195,12 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>The Add → Webcam menu item: enabled only while no webcam exists anywhere —
|
|
||||||
/// one camera identity app-wide; re-adding always opens the picker.</summary>
|
|
||||||
public bool CanAddWebcam => StagedScene != null && _webcam == null;
|
|
||||||
|
|
||||||
/// <summary>The Add → YouTube Chat menu item: enabled only while no chat layer exists
|
/// <summary>The Add → YouTube Chat menu item: enabled only while no chat layer exists
|
||||||
/// anywhere — one chat overlay per layout.</summary>
|
/// anywhere — one chat overlay per layout.</summary>
|
||||||
public bool CanAddYouTubeChat
|
public bool CanAddYouTubeChat
|
||||||
=> StagedScene != null
|
=> StagedScene != null
|
||||||
&& !Scenes.Any(s => s.Elements.OfType<Source>().Any(e => e.Type == SourceType.ChatBox));
|
&& !Scenes.Any(s => s.Elements.OfType<Source>().Any(e => e.Type == SourceType.ChatBox));
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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).
|
|
||||||
/// </summary>
|
|
||||||
public bool CanShowWebcamInStagedScene
|
|
||||||
=> StagedScene is { } scene && (scene.WebcamConfig == null || !scene.WebcamConfig.IsVisible);
|
|
||||||
|
|
||||||
/// <summary>Swap-the-device item: enabled once a camera has been picked at all.</summary>
|
|
||||||
public bool CanChangeWebcam => _webcam != null;
|
|
||||||
|
|
||||||
// ─── Social bar (global resource, shown on every scene) ───
|
// ─── Social bar (global resource, shown on every scene) ───
|
||||||
|
|
||||||
private static readonly SolidColorBrush BarOnBrush = CreateBrush("#2ecc71");
|
private static readonly SolidColorBrush BarOnBrush = CreateBrush("#2ecc71");
|
||||||
@@ -249,25 +228,11 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
|
|
||||||
private readonly ISocialValidator _socialValidator = new HttpSocialValidator();
|
private readonly ISocialValidator _socialValidator = new HttpSocialValidator();
|
||||||
|
|
||||||
/// <summary>Shows the mirror / clip-shape row in the source chip when a webcam is selected.</summary>
|
|
||||||
public bool IsWebcamSelected => SelectedElement is WebcamSceneConfig;
|
|
||||||
|
|
||||||
/// <summary>True when the Elements panel should show the "No configurable elements" hint.</summary>
|
/// <summary>True when the Elements panel should show the "No configurable elements" hint.</summary>
|
||||||
public bool IsElementsPanelEmpty => SelectedElement is { } el
|
public bool IsElementsPanelEmpty => SelectedElement is { } el
|
||||||
&& !el.IsDraggable && !el.IsWebcam
|
&& !el.IsDraggable && !el.IsWebcam
|
||||||
&& el is not Source { Type: SourceType.Countdown or SourceType.WebSource or SourceType.ChatBox };
|
&& el is not Source { Type: SourceType.Countdown or SourceType.WebSource or SourceType.ChatBox };
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
public string? WebcamError
|
|
||||||
{
|
|
||||||
get => _webcamError;
|
|
||||||
private set => SetProperty(ref _webcamError, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
public StreamStatus StreamStatus
|
public StreamStatus StreamStatus
|
||||||
{
|
{
|
||||||
get => _streamStatus;
|
get => _streamStatus;
|
||||||
@@ -973,57 +938,6 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
private const double MasterFrameWidth = 1920;
|
private const double MasterFrameWidth = 1920;
|
||||||
private const double MasterFrameHeight = 1080;
|
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; } =
|
public QualityOption[] QualityOptions { get; } =
|
||||||
{
|
{
|
||||||
new("1080p60", 60, 8.0, 1920, 1080),
|
new("1080p60", 60, 8.0, 1920, 1080),
|
||||||
@@ -1123,13 +1037,10 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
// Commands
|
// Commands
|
||||||
public ICommand AddSceneCommand { get; }
|
public ICommand AddSceneCommand { get; }
|
||||||
public ICommand AddSourceCommand { get; }
|
public ICommand AddSourceCommand { get; }
|
||||||
public ICommand AddWebcamCommand { get; }
|
|
||||||
public ICommand AddImageCommand { get; }
|
public ICommand AddImageCommand { get; }
|
||||||
public ICommand RemoveSourceCommand { get; }
|
public ICommand RemoveSourceCommand { get; }
|
||||||
public ICommand EditElementCommand { get; }
|
public ICommand EditElementCommand { get; }
|
||||||
public ICommand ToggleElementVisibilityCommand { get; }
|
public ICommand ToggleElementVisibilityCommand { get; }
|
||||||
public ICommand ChangeWebcamCommand { get; }
|
|
||||||
public ICommand ShowWebcamCommand { get; }
|
|
||||||
public ICommand ToggleMicMuteCommand { get; }
|
public ICommand ToggleMicMuteCommand { get; }
|
||||||
public ICommand ToggleGameMuteCommand { get; }
|
public ICommand ToggleGameMuteCommand { get; }
|
||||||
public ICommand OpenMicPickerCommand { get; }
|
public ICommand OpenMicPickerCommand { get; }
|
||||||
@@ -1399,54 +1310,6 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
AppLog.Write("LoadLayout end");
|
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<WebcamSceneConfig>()))
|
|
||||||
_ = _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<WebcamSceneConfig>()))
|
|
||||||
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<WebcamSceneConfig>())
|
|
||||||
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()
|
private void RegisterLoadedWebSources()
|
||||||
{
|
{
|
||||||
if (_webView2Manager == null) return;
|
if (_webView2Manager == null) return;
|
||||||
@@ -1616,125 +1479,6 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
return $"{baseName}{i}";
|
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<WebcamSceneConfig>()))
|
|
||||||
{
|
|
||||||
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()
|
private void AddImage()
|
||||||
{
|
{
|
||||||
var scene = StagedScene;
|
var scene = StagedScene;
|
||||||
|
|||||||
+8
-1
@@ -50,12 +50,19 @@ Progress (Commit N/12 · tag):
|
|||||||
`ChangeCaptureCommand`/`RefreshCaptureCommand`/`SetBackgroundDisplayCommand`/
|
`ChangeCaptureCommand`/`RefreshCaptureCommand`/`SetBackgroundDisplayCommand`/
|
||||||
`ToggleShowDesktopCommand`/`BrowseBackgroundCommand`). `ShowPreviewPlaceholder`
|
`ToggleShowDesktopCommand`/`BrowseBackgroundCommand`). `ShowPreviewPlaceholder`
|
||||||
stays in core.
|
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`,
|
- Core file keeps: fields, ctor, `Shutdown`/`SaveLayoutNow`/`ScheduleSave`/`LoadLayout`,
|
||||||
`SetPanel`/`ShowOverlay`/`ShowLicenses`, `CreateBrush`, `OpenUrl`/`ComposeEmail`,
|
`SetPanel`/`ShowOverlay`/`ShowLicenses`, `CreateBrush`, `OpenUrl`/`ComposeEmail`,
|
||||||
`SubmitBug`/`SubmitFeature`, window-geometry pass-through.
|
`SubmitBug`/`SubmitFeature`, window-geometry pass-through.
|
||||||
- Partials: `Account` ✔ (Commit 1), `License` ✔ (Commit 2), `Scenes` ✔ (Commit 3), `Background` ✔ (Commit 4),
|
- 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),
|
`Audio` (mic + game bars + TRAX), `Trax` (music player), `Socials` (social bar + dialog),
|
||||||
`Streaming` (go-live/record + health + quality tiers + media source pipeline),
|
`Streaming` (go-live/record + health + quality tiers + media source pipeline),
|
||||||
`Chat` (YouTube chat + mock preview + chatbox render), `Overlays` (settings/bug/feature/about
|
`Chat` (YouTube chat + mock preview + chatbox render), `Overlays` (settings/bug/feature/about
|
||||||
|
|||||||
Reference in New Issue
Block a user