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, }; _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; } } } // "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(); } }