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:
+336
-51
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user