Screen backdrop (schema v5/v6) + five-scene catalog + webcam polish: live desktop/game capture as a permanent non-deletable bottom layer (Source.IsBackdrop), auto full-screen game detection (Win32FullScreenDetector) else primary display, GraphicsCapturePicker re-designation, refcounted shared ScreenCaptureManager, Live-only backdrop by policy (HasBackdrop + v5-v6 backfill + EnforceBackdropPolicy, checkbox gone), SceneCatalog (Starting/Live/BRB/Chat/Ending) with + button re-adding missing scenes, webcam mid-session transparent-container fix (GetPreviewBitmap propagation), Chat half-screen-area cap, 'Add Webcam' always opens the picker (SwapWebcamIdentityAsync, no silent resurrect), WindowsRuntimeMarshal frame-read + 5s-throttled errors, docs updated, tests (65 passing)

This commit is contained in:
2026-08-07 14:15:50 -07:00
parent e037ba027b
commit ad9b3e1e48
27 changed files with 2232 additions and 116 deletions
+336 -51
View File
@@ -5,6 +5,7 @@ using System.Diagnostics;
using System.IO;
using System.Windows;
using System.Windows.Input;
using System.Windows.Interop;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Threading;
@@ -64,6 +65,17 @@ public class MainViewModel : ViewModelBase
private readonly CameraManager _cameraManager;
private Webcam? _webcam;
// Screen backdrop: a permanent live capture (desktop/game) that every scene
// shows at the bottom layer. One shared capture session per key — the
// ScreenCaptureManager refcounts by key, mirroring CameraManager.
private readonly IFullScreenDetector _fullScreenDetector;
private readonly ScreenCaptureManager _screenCaptureManager;
private readonly ScreenCaptureSourceFactory _screenCaptureFactory;
private int? _lastForegroundFullScreenMonitor;
private CancellationTokenSource? _deactivateCts;
private ImageSource? _backdropImage;
private HashSet<string> _liveCaptureKeys = new();
// Branding flash (monetization): a full-frame "made with ytLlive!" shown
// for ~1s every 300s on the live output, ~25% opacity. Paid unlock sets
// BrandFlashEnabled = false. See ai.md "Monetization".
@@ -78,6 +90,7 @@ public class MainViewModel : ViewModelBase
public ObservableCollection<Scene> Scenes { get; } = new();
public ObservableCollection<ChatMessage> ChatMessages { get; } = new();
public ObservableCollection<DisplayInfo> Displays { get; } = new();
public string[] Visibilities { get; } = { "Public", "Unlisted", "Private" };
public Scene? ActiveScene
@@ -93,9 +106,11 @@ public class MainViewModel : ViewModelBase
OnPropertyChanged(nameof(ShowEmptySceneHint));
OnPropertyChanged(nameof(ShowPreviewPlaceholder));
OnPropertyChanged(nameof(ShowSourcesEmptyHint));
OnPropertyChanged(nameof(CanChangeBackdrop));
OnPropertyChanged(nameof(CanAddWebcamToActiveScene));
OnPropertyChanged(nameof(CanShowWebcamInActiveScene));
UpdateActiveBackground();
UpdateBackdropImage();
}
}
}
@@ -106,6 +121,18 @@ public class MainViewModel : ViewModelBase
private set => SetProperty(ref _activeBackgroundImage, value);
}
/// <summary>The active scene's live-capture backdrop frame (rendered below the
/// static background). Set from the shared capture bitmap as it arrives.</summary>
public ImageSource? BackdropImage
{
get => _backdropImage;
private set
{
if (SetProperty(ref _backdropImage, value))
OnPropertyChanged(nameof(ShowPreviewPlaceholder));
}
}
public SceneElement? SelectedElement
{
get => _selectedElement;
@@ -166,9 +193,20 @@ public class MainViewModel : ViewModelBase
public bool ShowStartStream => IsOffline;
public bool ShowChatInactiveMessage => !IsLive;
public bool ShowEmptySceneHint => ActiveScene != null && ActiveScene.Elements.Count == 0;
public bool ShowPreviewPlaceholder => !ShowEmptySceneHint && ActiveBackgroundImage == null;
public bool ShowPreviewPlaceholder => !ShowEmptySceneHint && ActiveBackgroundImage == null && BackdropImage == null;
public bool ShowSourcesEmptyHint => ActiveScene == null || ActiveScene.Elements.Count == 0;
/// <summary>The canonical scenes (SceneCatalog) not present right now — what the
/// "+" button may re-add. Work with less, never more.</summary>
public IEnumerable<string> MissingScenes
=> SceneCatalog.All.Where(canonical => Scenes.All(s => !SceneCatalog.Is(s.Name, canonical)));
/// <summary>Only show the "+" button when one of the five canonical scenes is missing.</summary>
public bool ShowAddScene => MissingScenes.Any();
/// <summary>"Change Capture…"/"Refresh Capture" apply to the active scene's backdrop.</summary>
public bool CanChangeBackdrop => ActiveScene?.HasBackdrop == true;
// Paid unlock flips this off (see ai.md "Monetization"). When disabled the
// cadence timer is stopped and any active flash is hidden immediately.
public bool BrandFlashEnabled
@@ -205,6 +243,12 @@ public class MainViewModel : ViewModelBase
OnPropertyChanged(nameof(ShowPreviewPlaceholder));
}
private void UpdateBackdropImage()
{
var backdrop = ActiveScene?.Elements.OfType<Source>().FirstOrDefault(s => s.IsBackdrop);
BackdropImage = backdrop?.DisplaySource;
}
public StreamHealth CurrentHealth
{
get => _currentHealth;
@@ -330,15 +374,28 @@ public class MainViewModel : ViewModelBase
// 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.
// (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;
internal static void ClampWebcamToBounds(WebcamSceneConfig config)
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 scale = Math.Min(WebcamMaxWidth / config.Width, WebcamMaxHeight / config.Height);
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);
@@ -472,6 +529,9 @@ public class MainViewModel : ViewModelBase
public ICommand RemoveSourceCommand { get; }
public ICommand ChangeWebcamCommand { get; }
public ICommand ShowWebcamCommand { get; }
public ICommand ChangeCaptureCommand { get; }
public ICommand RefreshCaptureCommand { get; }
public ICommand SetBackdropDisplayCommand { get; }
public ICommand StartStreamCommand { get; }
public ICommand EndStreamCommand { get; }
public ICommand OpenSettingsCommand { get; }
@@ -513,7 +573,7 @@ public class MainViewModel : ViewModelBase
Scenes.CollectionChanged += OnScenesChanged;
AddSceneCommand = new RelayCommand(_ => AddScene());
AddSceneCommand = new RelayCommand(name => AddScene(name as string ?? string.Empty));
EditSceneCommand = new RelayCommand(scene => BeginEditScene(scene as Scene));
RemoveSceneCommand = new RelayCommand(scene => RemoveScene(scene as Scene));
ToggleSceneVisibilityCommand = new RelayCommand(scene => ToggleSceneVisibility(scene as Scene));
@@ -522,6 +582,9 @@ public class MainViewModel : ViewModelBase
RemoveSourceCommand = new RelayCommand(element => RemoveElement(element as SceneElement));
ChangeWebcamCommand = new RelayCommand(_ => _ = ChangeWebcamAsync(), _ => CanChangeWebcam);
ShowWebcamCommand = new RelayCommand(_ => ShowWebcamInActiveScene(), _ => CanShowWebcamInActiveScene);
ChangeCaptureCommand = new RelayCommand(_ => _ = ChangeBackdropCaptureAsync());
RefreshCaptureCommand = new RelayCommand(_ => RefreshBackdropAutoCapture());
SetBackdropDisplayCommand = new RelayCommand(display => SetBackdropCapture(display as DisplayInfo));
OpenSettingsCommand = new RelayCommand(_ => ShowOverlay(nameof(IsSettingsOpen), "App Settings"));
OpenBugCommand = new RelayCommand(_ => ShowOverlay(nameof(IsBugOpen), "Report Bug"));
OpenFeatureCommand = new RelayCommand(_ => ShowOverlay(nameof(IsFeatureOpen), "Feature Request"));
@@ -546,6 +609,18 @@ public class MainViewModel : ViewModelBase
System.Windows.Application.Current?.Dispatcher);
_cameraManager.PreviewBitmapChanged += OnCameraPreviewBitmapChanged;
_fullScreenDetector = new Win32FullScreenDetector();
foreach (var display in _fullScreenDetector.GetDisplays())
Displays.Add(display);
_screenCaptureFactory = new ScreenCaptureSourceFactory(
() => new WindowInteropHelper(System.Windows.Application.Current?.MainWindow).Handle);
_screenCaptureManager = new ScreenCaptureManager(
_screenCaptureFactory.Resolve,
System.Windows.Application.Current?.Dispatcher);
_screenCaptureManager.PreviewBitmapChanged += OnScreenPreviewBitmapChanged;
_screenCaptureManager.CaptureFailed += (key, message) =>
AppLog.Write($"ScreenCaptureManager: capture '{key}' failed: {message}");
LoadLayout();
_ = LoadSavedSessionAsync();
AppLog.Write("MainViewModel ctor end");
@@ -603,17 +678,18 @@ public class MainViewModel : ViewModelBase
Scenes.Add(scene);
if (Scenes.Count == 0)
{
AddScene("Starting");
AddScene("Live");
AddScene("BRB");
AddScene("Chat", isChatScene: true);
AddScene("Ending");
}
foreach (var name in SceneCatalog.All)
AddScene(name);
// The live backdrop belongs to Live only; a pre-policy DB may have
// backdrops lingering in other scenes — drop them, then
// ReacquireScreenCaptures heals Live's.
EnforceBackdropPolicy(Scenes);
foreach (var scene in Scenes)
foreach (var config in scene.Elements.OfType<WebcamSceneConfig>())
{
ClampWebcamToBounds(config);
ClampWebcamToBounds(config, scene.Name);
HealLegacySquareRect(config);
}
AppLog.Write($"LoadLayout: {Scenes.Count} scenes loaded");
@@ -624,7 +700,9 @@ public class MainViewModel : ViewModelBase
}
ActiveScene = Scenes.FirstOrDefault();
UpdateActiveBackground();
UpdateBackdropImage();
ReacquireWebcam();
ReacquireScreenCaptures();
ScheduleSave();
AppLog.Write("LoadLayout end");
}
@@ -645,9 +723,16 @@ public class MainViewModel : ViewModelBase
OnPropertyChanged(nameof(CanAddWebcamToActiveScene));
if (_webcam == null || string.IsNullOrWhiteSpace(newDevice)) return;
if (previousDevice == newDevice) return;
foreach (var unused in Scenes.SelectMany(s => s.Elements.OfType<WebcamSceneConfig>()))
_ = _cameraManager.AcquireAsync(newDevice);
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
@@ -660,11 +745,174 @@ public class MainViewModel : ViewModelBase
config.VideoImageSource = bitmap;
}
// ─── Screen backdrop capture (live desktop/game) ───
private const string MonitorKeyPrefix = "monitor:";
/// <summary>Every backdrop-enabled scene has exactly one backdrop, kept at index 0 (bottom layer).
/// Returns null for a scene with <see cref="Scene.HasBackdrop"/> = false.</summary>
internal static Source? EnsureBackdrop(Scene scene)
{
if (!scene.HasBackdrop) return null;
var backdrop = scene.Elements.OfType<Source>().FirstOrDefault(s => s.IsBackdrop);
if (backdrop != null) return backdrop;
backdrop = new Source
{
Name = "Backdrop",
Type = SourceType.DisplayCapture,
IsBackdrop = true,
IsEnabled = true,
X = 0,
Y = 0,
Width = MasterFrameWidth,
Height = MasterFrameHeight,
};
scene.Elements.Insert(0, backdrop);
return backdrop;
}
// The backdrop belongs to the Live scene alone. Runs after every layout
// load: the flag is normalized by scene name and any backdrop lingering in a
// non-Live scene (from a pre-policy DB) is removed. ReacquireScreenCaptures
// re-heals Live's backdrop right after.
internal static void EnforceBackdropPolicy(IEnumerable<Scene> scenes)
{
foreach (var scene in scenes)
{
scene.HasBackdrop = SceneCatalog.HasBackdrop(scene.Name);
if (scene.HasBackdrop) continue;
var backdrop = scene.Elements.OfType<Source>().FirstOrDefault(s => s.IsBackdrop);
if (backdrop != null)
scene.Elements.Remove(backdrop);
}
}
private IEnumerable<Source> AllBackdrops()
=> Scenes.SelectMany(s => s.Elements.OfType<Source>().Where(x => x.IsBackdrop));
// Full-screen game on its monitor, else the primary display. Called at launch
// (from ReacquireScreenCaptures, before the window steals focus) and on
// focus regain (RefreshBackdropAutoCapture).
private string ResolveAutoCaptureKey()
=> $"{MonitorKeyPrefix}{_fullScreenDetector.GetForegroundFullScreenMonitorIndex() ?? _fullScreenDetector.PrimaryMonitorIndex()}";
// After a layout load / file open: make sure every backdrop-enabled scene
// has a backdrop, give any backdrop without a persisted key the auto-detected
// one, then acquire one capture per unique backdrop key. ScreenCaptureManager
// refcounts by key, so every scene pointing at the same monitor shares one
// session. Captures no longer referenced by any scene are released.
private void ReacquireScreenCaptures()
{
foreach (var scene in Scenes)
EnsureBackdrop(scene);
var auto = ResolveAutoCaptureKey();
foreach (var backdrop in AllBackdrops())
if (string.IsNullOrWhiteSpace(backdrop.CaptureKey))
backdrop.CaptureKey = auto;
var keys = AllBackdrops()
.Select(b => b.CaptureKey!)
.Where(k => !string.IsNullOrWhiteSpace(k))
.ToHashSet();
// A layout reload replaces the scenes; stop captures that are no longer
// referenced (mirrors ReacquireWebcam's device swap).
foreach (var stale in _liveCaptureKeys.Except(keys))
_ = _screenCaptureManager.ReleaseAllAsync(stale);
_liveCaptureKeys = keys;
foreach (var key in keys)
_ = _screenCaptureManager.AcquireAsync(key);
}
// ScreenCaptureManager creates the shared WriteableBitmap on the UI thread;
// every backdrop pointing at that key picks it up.
private void OnScreenPreviewBitmapChanged(string key, WriteableBitmap bitmap)
{
var activeBackdrop = ActiveScene?.Elements.OfType<Source>().FirstOrDefault(s => s.IsBackdrop);
if (activeBackdrop?.CaptureKey == key)
BackdropImage = bitmap;
foreach (var backdrop in AllBackdrops().Where(b => b.CaptureKey == key))
backdrop.VideoImageSource = bitmap;
}
// One-shot foreground snapshot ~250ms after we lose focus, so the next
// Activated re-detect can see the full-screen game the user switched to.
// A single sample per deactivation — not a session listener or a poller.
public void NoteBackgroundWindow()
{
_deactivateCts?.Cancel();
_deactivateCts = new CancellationTokenSource();
var token = _deactivateCts.Token;
_ = Task.Run(async () =>
{
try
{
await Task.Delay(250, token);
var monitor = _fullScreenDetector.GetForegroundFullScreenMonitorIndex();
if (!token.IsCancellationRequested)
_lastForegroundFullScreenMonitor = monitor;
}
catch (OperationCanceledException) { }
});
}
// Re-runs full-screen detection at launch and when the app regains focus.
// A null detection (no full-screen foreground window — our app, the desktop,
// a normal window) leaves the current capture alone.
public void RefreshBackdropAutoCapture()
{
var detected = _lastForegroundFullScreenMonitor ?? _fullScreenDetector.GetForegroundFullScreenMonitorIndex();
_lastForegroundFullScreenMonitor = null;
if (detected == null) return;
_ = RedesignateBackdropAsync($"{MonitorKeyPrefix}{detected}");
}
private async Task RedesignateBackdropAsync(string newKey)
{
var oldKeys = AllBackdrops()
.Select(b => b.CaptureKey!)
.Where(k => !string.IsNullOrWhiteSpace(k))
.ToHashSet();
foreach (var backdrop in AllBackdrops())
backdrop.CaptureKey = newKey;
foreach (var oldKey in oldKeys.Where(k => k != newKey))
await _screenCaptureManager.ReleaseAllAsync(oldKey);
if (!oldKeys.Contains(newKey))
await _screenCaptureManager.AcquireAsync(newKey);
_liveCaptureKeys = new HashSet<string> { newKey };
UpdateBackdropImage();
ScheduleSave();
}
// "Change Capture…": the OS GraphicsCapturePicker designates the target.
// Picks are transient (see ScreenCaptureSourceFactory) — a reload falls
// back to auto-detection.
public async Task ChangeBackdropCaptureAsync()
{
var key = await _screenCaptureFactory.PickAsync();
if (key == null) return;
await RedesignateBackdropAsync(key);
}
// In-app display picker: point every backdrop at a specific monitor.
private void SetBackdropCapture(DisplayInfo? display)
{
if (display == null) return;
_ = RedesignateBackdropAsync($"{MonitorKeyPrefix}{display.Index}");
}
public void Shutdown()
{
_saveDebounce?.Stop();
SaveLayoutNow();
_cameraManager.Dispose();
_screenCaptureManager.Dispose();
_layoutStore.Dispose();
}
@@ -722,6 +970,8 @@ public class MainViewModel : ViewModelBase
if (e.OldItems != null)
foreach (Scene scene in e.OldItems)
UnwireScene(scene);
OnPropertyChanged(nameof(MissingScenes));
OnPropertyChanged(nameof(ShowAddScene));
ScheduleSave();
}
@@ -738,7 +988,12 @@ public class MainViewModel : ViewModelBase
}
private void OnScenePropertyChanged(object? sender, PropertyChangedEventArgs e)
=> ScheduleSave();
{
// A rename can drop a canonical scene in/out of MissingScenes.
OnPropertyChanged(nameof(MissingScenes));
OnPropertyChanged(nameof(ShowAddScene));
ScheduleSave();
}
private void OnElementsChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
@@ -766,9 +1021,19 @@ public class MainViewModel : ViewModelBase
_saveDebounce.Start();
}
private void AddScene(string? name = null, bool isChatScene = false)
// Adds a canonical scene by name — only when it's actually missing. The
// backdrop flag follows the policy (Live yes, everyone else no).
private void AddScene(string name)
{
var scene = new Scene { Name = name ?? $"New Scene {Scenes.Count + 1}", IsChatScene = isChatScene };
if (!SceneCatalog.IsCanonical(name)) return;
if (Scenes.Any(s => SceneCatalog.Is(s.Name, name))) return;
var scene = new Scene
{
Name = name.Trim(),
IsChatScene = SceneCatalog.IsChat(name),
HasBackdrop = SceneCatalog.HasBackdrop(name),
};
EnsureBackdrop(scene);
Scenes.Add(scene);
ActiveScene = scene;
}
@@ -855,25 +1120,33 @@ public class MainViewModel : ViewModelBase
UpdateActiveBackground();
}
// Adds the webcam to the active scene. The camera is picked once app-wide
// (first add); afterwards "Add Webcam" just places the existing webcam here
// at the default spot — each scene's config is independent (webcam.{scene}.config).
// 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 AddWebcamToActiveSceneAsync()
{
var scene = ActiveScene;
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)
{
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));
}
else if (_webcam.DeviceId != device.Id)
{
await SwapWebcamIdentityAsync(device);
}
var config = new WebcamSceneConfig
{
@@ -891,6 +1164,12 @@ public class MainViewModel : ViewModelBase
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)
{
@@ -902,7 +1181,32 @@ public class MainViewModel : ViewModelBase
// 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.
// 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)
{
MessageBox.Show(
"Couldn't start that camera. It may be in use by another app, or Windows camera access may be turned off.",
"ytLlive", MessageBoxButton.OK, MessageBoxImage.Warning);
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;
@@ -912,29 +1216,9 @@ public class MainViewModel : ViewModelBase
Owner = Application.Current.MainWindow
};
if (dialog.ShowDialog() != true || dialog.PickedDevice == null) return;
if (dialog.PickedDevice.Id == _webcam.DeviceId) return;
var oldDevice = _webcam.DeviceId;
var newDevice = dialog.PickedDevice.Id;
if (oldDevice == newDevice) return;
_webcam.DeviceId = newDevice;
_webcam.Name = dialog.PickedDevice.DisplayName;
ScheduleSave();
if (!string.IsNullOrWhiteSpace(oldDevice))
await _cameraManager.ReleaseAllAsync(oldDevice);
foreach (var unused in Scenes.SelectMany(s => s.Elements.OfType<WebcamSceneConfig>()))
{
var started = await _cameraManager.AcquireAsync(newDevice);
if (!started)
{
MessageBox.Show(
"Couldn't start that camera. It may be in use by another app, or Windows camera access may be turned off.",
"ytLlive", MessageBoxButton.OK, MessageBoxImage.Warning);
break;
}
}
await SwapWebcamIdentityAsync(dialog.PickedDevice);
}
// "Show Webcam" from a right-click on the empty preview. Unhides this scene's
@@ -1081,6 +1365,7 @@ public class MainViewModel : ViewModelBase
{
var scene = ActiveScene;
if (scene == null || element == null) return;
if (element is Source { IsBackdrop: true }) return;
if (element is WebcamSceneConfig && _webcam != null)
{
if (SelectedElement == element)