75723acc1b
Creator refinement: 'offered iff there's not one already configured & attainable'. CanAddWebcam now requires IsWebcamAttainable = identity present AND a RUNNING session (CameraManager.IsRunning) — an identity whose camera was unplugged or whose lock keeps failing leaves the row greyed with reason 'No webcam is currently available…', and it un-greys the moment a session is live. Gate re-raised at every attainability flip: staging, removal, startup lock success, first frame, camera failure, identity swap. Root cause the old test surfaced: the startup pass skipped acquiring when the loaded identity's configs already held the session, so there was no independent app base ref — removing the last placement dropped RefCount to 0 and killed the session. The single-camera branch now ALWAYS acquires (a running session just bumps), laying the app-wide base hold so the default outlives the scenes. Good Dog: WebcamMenuGateTests second fact — identity loaded, session can't start → row NOT offered + 'No webcam is currently available…' tooltip. Positive fact waits for WebcamStartupValidationTask to make the IsRunning read deterministic. Docs same commit (ai.md gate + base-lock, TASKS.md, HANDOFF.md incl. proven pre-existing audio flake). 305 tests (304 pass + known flake), 0 warnings.
397 lines
17 KiB
C#
397 lines
17 KiB
C#
using System.Collections.Generic;
|
|
using System.Windows;
|
|
using System.Windows.Input;
|
|
using System.Windows.Media.Imaging;
|
|
using System.Windows.Threading;
|
|
using ytLive;
|
|
using ytLive.Helpers;
|
|
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;
|
|
private string? _webcamLockAlert;
|
|
private DispatcherTimer? _webcamLockPollTimer;
|
|
|
|
/// <summary>
|
|
/// Add → Webcam menu item. The row is offered iff the SCENE has no webcam
|
|
/// config yet AND a camera is attainable — an app default is selected and
|
|
/// its session is RUNNING right now (identity alone is not enough: the
|
|
/// device may have been unplugged, or the lock may still be failing, in
|
|
/// which case the row stays grey and the persistent alert says why).
|
|
/// Per-scene max is per-scene: another scene holding its own placement does
|
|
/// NOT grey this one.
|
|
/// </summary>
|
|
public bool CanAddWebcam => StagedScene is { WebcamConfig: null } && IsWebcamAttainable;
|
|
|
|
private bool IsWebcamAttainable => _webcam != null && _cameraManager.IsRunning(_webcam.DeviceId);
|
|
|
|
/// <summary>
|
|
/// Why the Webcam row is greyed, when it is — never a silent bool. The max-1
|
|
/// reason gets the gentle "graduated to OBS" line; no attainable camera gets
|
|
/// its own wording. Shown as the row's tooltip.
|
|
/// </summary>
|
|
public string WebcamAddToolTip
|
|
{
|
|
get
|
|
{
|
|
if (StagedScene?.WebcamConfig != null)
|
|
return "Already in this scene — one webcam per stream. A second camera means you've graduated to OBS.";
|
|
if (!IsWebcamAttainable)
|
|
return "No webcam is currently available — plug one in, or allow camera access in Windows.";
|
|
return "Adds the app default webcam to this scene.";
|
|
}
|
|
}
|
|
|
|
// Re-raise the rows that read live CameraManager session state: the offer
|
|
// flips the moment a session starts (lock acquired, first frame) or dies.
|
|
private void RaiseWebcamGateChanged()
|
|
{
|
|
OnPropertyChanged(nameof(CanAddWebcam));
|
|
OnPropertyChanged(nameof(WebcamAddToolTip));
|
|
}
|
|
|
|
/// <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);
|
|
}
|
|
|
|
// Startup resource validation keeps its task for tests to await (the real
|
|
// app fires it fire-and-forget from the ctor).
|
|
internal Task? WebcamStartupValidationTask { get; private set; }
|
|
|
|
/// <summary>
|
|
/// Red alert shown at the bottom of the Layers panel when the one-and-only
|
|
/// detected webcam could NOT be locked at startup. Persistent: while set, a
|
|
/// light re-poll re-runs the lock check so the moment the camera frees up
|
|
/// (another app closed) the alert clears itself; a Retry button does it now.
|
|
/// </summary>
|
|
public string? WebcamLockAlert
|
|
{
|
|
get => _webcamLockAlert;
|
|
private set
|
|
{
|
|
if (!SetProperty(ref _webcamLockAlert, value)) return;
|
|
if (value == null)
|
|
{
|
|
_webcamLockPollTimer?.Stop();
|
|
}
|
|
else if (_webcamLockPollTimer == null)
|
|
{
|
|
_webcamLockPollTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(5) };
|
|
_webcamLockPollTimer.Tick += (_, _) => _ = ValidateWebcamResourceStartupAsync();
|
|
_webcamLockPollTimer.Start();
|
|
}
|
|
}
|
|
}
|
|
|
|
public ICommand RetryWebcamLockCommand { get; }
|
|
|
|
// 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;
|
|
WebcamLockAlert = null; // the camera is provably alive — alert resolved.
|
|
RaiseWebcamGateChanged();
|
|
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}";
|
|
RaiseWebcamGateChanged();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Runs exactly once at startup (and on Retry): enumerate the OS webcams,
|
|
/// then tri-state:
|
|
/// 0 → no alarm, layer inactive.
|
|
/// 1 → try to lock; failure → persistent red alert (re-poll every 5 s).
|
|
/// ≥2 → leave to the settings-dialog selector (next slice).
|
|
/// Re-running after a failure re-enumerates then retries the lock; a real
|
|
/// frame arriving (first-frame proof) also clears the alert.
|
|
/// </summary>
|
|
internal async Task ValidateWebcamResourceStartupAsync()
|
|
{
|
|
IReadOnlyList<CameraDeviceInfo> cameras;
|
|
try
|
|
{
|
|
cameras = await _cameraEnumerator.GetCamerasAsync();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
AppLog.Write($"WebcamResource: enumeration failed: {ex.Message}");
|
|
WebcamLockAlert = "Web Cam unavailable — couldn't enumerate cameras.";
|
|
return;
|
|
}
|
|
|
|
var count = cameras?.Count ?? 0;
|
|
if (count != 1)
|
|
{
|
|
// 0: no camera; ≥2: settings-dialog selector (next slice).
|
|
WebcamLockAlert = null;
|
|
return;
|
|
}
|
|
|
|
var device = cameras![0];
|
|
|
|
// Lay the app-wide base lock. AcquireAsync on an already-running session
|
|
// (loaded identity's configs) just bumps its refcount — that bump is the
|
|
// app's own hold, so removing EVERY scene's placement keeps the session
|
|
// alive and the Web Cam row offered (the app default outlives the scenes).
|
|
var started = await _cameraManager.AcquireAsync(device.Id);
|
|
if (started)
|
|
{
|
|
// The camera is now provably attainable: adopt the new identity when
|
|
// there wasn't one, then re-raise the offer either way
|
|
// (RaiseWebcamGateChanged) so an existing identity whose session was
|
|
// down un-greys the row the moment the lock is real.
|
|
if (_webcam == null)
|
|
{
|
|
_webcam = new Webcam { DeviceId = device.Id, Name = device.DisplayName };
|
|
OnPropertyChanged(nameof(CanChangeWebcam));
|
|
}
|
|
RaiseWebcamGateChanged();
|
|
WebcamLockAlert = null;
|
|
return;
|
|
}
|
|
|
|
var reason = WebcamError ?? "the camera could not be locked.";
|
|
WebcamLockAlert = $"Web Cam unavailable: {reason}";
|
|
}
|
|
|
|
// Adds the webcam to the active scene. The webcam is an app-level resource:
|
|
// once a camera is the app default, adding the layer just places it — the
|
|
// picker runs only for the initial selection. Each scene's placement config
|
|
// is independent (webcam.{scene}.config); the camera device is Windows' own
|
|
// singleton and stays where we put it.
|
|
private async Task AddWebcamToStagedSceneAsync()
|
|
{
|
|
var scene = StagedScene;
|
|
if (scene == null || scene.WebcamConfig != null) return;
|
|
|
|
if (_webcam == null)
|
|
{
|
|
var dialog = new CameraPickerDialog(new CameraPickerViewModel(_cameraEnumerator))
|
|
{
|
|
Owner = Application.Current.MainWindow
|
|
};
|
|
if (dialog.ShowDialog() != true || dialog.PickedDevice == null) return;
|
|
var device = dialog.PickedDevice;
|
|
_webcam = new Webcam { DeviceId = device.Id, Name = device.DisplayName };
|
|
OnPropertyChanged(nameof(CanChangeWebcam));
|
|
OnPropertyChanged(nameof(CanAddWebcam));
|
|
OnPropertyChanged(nameof(WebcamAddToolTip));
|
|
}
|
|
|
|
var config = new WebcamSceneConfig
|
|
{
|
|
WebcamId = _webcam.Id,
|
|
Name = _webcam.Name,
|
|
Width = 480,
|
|
Height = 270,
|
|
X = MasterFrameWidth - 480 - 32,
|
|
Y = MasterFrameHeight - 270 - 32,
|
|
};
|
|
|
|
_sceneGraph.AddElement(scene, 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;
|
|
}
|
|
}
|
|
|
|
RaiseWebcamGateChanged();
|
|
}
|
|
|
|
// "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();
|
|
}
|
|
} |