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; /// /// 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. /// public bool CanAddWebcam => StagedScene is { WebcamConfig: null } && IsWebcamAttainable; private bool IsWebcamAttainable => _webcam != null && _cameraManager.IsRunning(_webcam.DeviceId); /// /// 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. /// 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)); } /// /// 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); } // 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; } /// /// 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. /// 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())) _ = _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; WebcamLockAlert = null; // the camera is provably alive — alert resolved. RaiseWebcamGateChanged(); 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}"; RaiseWebcamGateChanged(); } /// /// 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. /// internal async Task ValidateWebcamResourceStartupAsync() { IReadOnlyList 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())) { 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(); } }