Files
LlamaCasty/ViewModels/MainViewModel.Webcam.cs
T

269 lines
11 KiB
C#

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();
}
}