Files
LlamaCasty/ViewModels/MainViewModel.Scenes.cs
gramps 9d00955004 feat(webcam): app-default gate slice — per-scene offer, Add places default directly, identity survives removal
Chat's camera no longer greys Web Cam in Live (per-scene max, not app-wide);
TASK 26 superseded by creator directive (app-level resource model). Add Webcam
now places the existing app default without the picker; the picker runs only
for the initial selection. Removing the last placement keeps the identity
(_webcam never nulled) so Add stays offered. Startup pass adopts a solo camera
as the app default, so a clean layout offers the layer in Live/Chat at once.
Dynamic WebcamAddToolTip names the why (incl. the 'graduated to OBS' line).

Good Dog: WebcamMenuGateTests rewritten (real app + temp DB + camera seams) —
identity in Chat does not gray Live; Add in Live places same wc-1 no picker;
scene-with-placement stays gray; identity survives both removals (DB row 1).
WebcamStartupResourceTests single-lock fact asserts CanAddWebcam after adoption.
Docs same commit (ai.md supersession, TASKS.md, HANDOFF.md). 304/304, 0 warnings.
2026-09-17 08:56:04 -07:00

294 lines
12 KiB
C#

using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.ComponentModel;
using System.Windows;
using ytLive;
using ytLive.Models;
using ytLive.Services;
namespace ytLive.ViewModels;
public partial class MainViewModel
{
private Scene? _stagedScene;
private Scene? _liveScene;
// TASK 20 step 2 — hotkey bindings (custom chord per action)
private Dictionary<HotkeyId, HotkeyChord> _hotkeyBindings = new();
// TASK 31: the scene collection is owned by the SceneGraph (single owner of the
// element inventory + mutation surface). The ViewModel exposes it as-is so the
// XAML bindings and MainViewModel call sites keep working unchanged.
public ObservableCollection<Scene> Scenes => _sceneGraph.Scenes;
public Scene? StagedScene
{
get => _stagedScene;
set
{
if (value != null && value.IsHidden) return;
if (SetProperty(ref _stagedScene, value))
{
// Update IsStaged flags for thumbnail glow
foreach (var s in Scenes)
s.IsStaged = s == value;
SelectedElement = null;
if (value != null)
{
var background = EnsureBackground(value);
if (background != null)
StampDefaultBackgroundAsset(background, value.Name);
_sceneGraph.InvalidateBake(value);
}
OnPropertyChanged(nameof(ShowChatInactiveMessage));
OnPropertyChanged(nameof(ShowEmptySceneHint));
OnPropertyChanged(nameof(ShowPreviewPlaceholder));
OnPropertyChanged(nameof(ShowSourcesEmptyHint));
OnPropertyChanged(nameof(CanChangeBackground));
OnPropertyChanged(nameof(CanAddWebcam));
OnPropertyChanged(nameof(CanAddYouTubeChat));
OnPropertyChanged(nameof(CanShowWebcamInStagedScene));
OnPropertyChanged(nameof(WebcamAddToolTip));
OnPropertyChanged(nameof(IsCountdownAvailable));
UpdateActiveBackground();
UpdateBackgroundImage();
RefreshSnapshotsAsync();
}
}
}
/// <summary>The scene the encoder outputs. Set on Go Live (StagedScene → LiveScene)
/// or on transition completion. FramePump reads this every tick.</summary>
public Scene? LiveScene
{
get => _liveScene;
set
{
if (SetProperty(ref _liveScene, value))
{
foreach (var s in Scenes)
s.IsLive = s == value;
}
}
}
/// <summary>True when offline — edit mode is available. When live/recording,
/// all edit operations are locked out (gun safety).</summary>
public bool IsEditMode => StreamStatus == StreamStatus.Offline && !IsRecording;
// ─── Transitions (TASK 19/23) ───
public TransitionType SelectedTransitionType
{
get => _selectedTransitionType;
set => SetProperty(ref _selectedTransitionType, value);
}
private TransitionType _selectedTransitionType = TransitionType.Fade;
public double TransitionDurationMs
{
get => _transitionDurationMs;
set => SetProperty(ref _transitionDurationMs, value);
}
private double _transitionDurationMs = 300;
/// <summary>
/// Called when a scene thumbnail is clicked. If live, triggers a transition from
/// LiveScene to the selected StagedScene. Always sets StagedScene first.
/// </summary>
public void TransitionToScene(Scene scene)
{
if (scene.IsHidden || scene == StagedScene) return;
StagedScene = scene;
if (IsLive)
_transition.Start(SelectedTransitionType, TransitionDurationMs, null);
}
/// <summary>
/// Apply the current transition type to the next StagedScene change while live.
/// Called from XAML when user picks a transition from the dropdown.
/// </summary>
public void ApplyTransitionToLive()
{
if (IsLive)
_transition.Start(SelectedTransitionType, TransitionDurationMs, null);
}
// ─── Global hotkeys (TASK 20) — dispatch a registered hotkey to its action ───
/// <summary>
/// Callback set by MainWindow so the VM can trigger hotkey re-registration
/// after the user saves custom bindings from the config dialog.
/// </summary>
public Action<Dictionary<HotkeyId, HotkeyChord>>? HotkeyBindingsChanged { get; set; }
/// <summary>The current hotkey bindings (loaded from DB, updated on dialog save).</summary>
public Dictionary<HotkeyId, HotkeyChord> CurrentHotkeyBindings => _hotkeyBindings;
public void HandleHotkey(Services.HotkeyId id)
{
switch (id)
{
case Services.HotkeyId.SceneStarting: TransitionToCanonical(SceneCatalog.Starting); break;
case Services.HotkeyId.SceneLive: TransitionToCanonical(SceneCatalog.Live); break;
case Services.HotkeyId.SceneBrb: TransitionToCanonical(SceneCatalog.Brb); break;
case Services.HotkeyId.SceneChat: TransitionToCanonical(SceneCatalog.Chat); break;
case Services.HotkeyId.SceneEnding: TransitionToCanonical(SceneCatalog.Ending); break;
case Services.HotkeyId.StartStopStream:
if (IsLive || IsRecording)
{
if (EndStreamCommand.CanExecute(null)) EndStreamCommand.Execute(null);
}
else if (StartStreamCommand.CanExecute(null))
{
StartStreamCommand.Execute(null);
}
break;
case Services.HotkeyId.ToggleMicMute: ToggleMicMuteCommand.Execute(null); break;
case Services.HotkeyId.ToggleGameMute: ToggleGameMuteCommand.Execute(null); break;
case Services.HotkeyId.TraxPlayPause: TraxLeftClick(); break;
}
}
private void TransitionToCanonical(string name)
{
var scene = Scenes.FirstOrDefault(s => SceneCatalog.Is(s.Name, name));
if (scene != null) TransitionToScene(scene);
}
private void OpenHotkeyConfig()
{
var vm = new HotkeyConfigViewModel(_layoutStore);
var dialog = new HotkeyConfigDialog(vm) { Owner = Application.Current.MainWindow };
if (dialog.ShowDialog() == true)
{
// Reload bindings from DB (the dialog already saved them).
_hotkeyBindings = _layoutStore.LoadHotkeyBindings();
HotkeyBindingsChanged?.Invoke(_hotkeyBindings);
RefreshHotkeyLabels();
}
}
private void RefreshHotkeyLabels()
{
foreach (var scene in Scenes)
{
if (SceneCatalog.SceneHotkeys.TryGetValue(scene.Name, out var hid)
&& _hotkeyBindings.TryGetValue(hid, out var chord))
scene.HotkeyLabel = chord.ToDisplayString();
else if (SceneCatalog.SceneHotkeys.TryGetValue(scene.Name, out var defId)
&& GlobalHotkeyManager.Defaults.TryGetValue(defId, out var defChord))
scene.HotkeyLabel = defChord.ToDisplayString();
else
scene.HotkeyLabel = string.Empty;
}
}
public bool ShowEmptySceneHint => StagedScene != null && StagedScene.Elements.Count == 0;
public bool ShowSourcesEmptyHint => StagedScene == null || StagedScene.Elements.Count == 0;
public bool IsCountdownAvailable => StagedScene != null
&& (SceneCatalog.Is(StagedScene.Name, "Starting") || SceneCatalog.Is(StagedScene.Name, "BRB"));
/// <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 background.</summary>
// Capture controls (Show Desktop / monitor pick / refresh) exist only on
// the Live screen's Background. Static-art screens never expose them.
public bool CanChangeBackground =>
StagedScene != null && SceneCatalog.Is(StagedScene.Name, SceneCatalog.Live);
// ─── Auto-save wiring ───
private void OnScenesChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
if (e.NewItems != null)
foreach (Scene scene in e.NewItems)
WireScene(scene);
if (e.OldItems != null)
foreach (Scene scene in e.OldItems)
UnwireScene(scene);
OnPropertyChanged(nameof(MissingScenes));
OnPropertyChanged(nameof(ShowAddScene));
ScheduleSave();
}
private void WireScene(Scene scene)
{
scene.PropertyChanged += OnScenePropertyChanged;
scene.Elements.CollectionChanged += OnElementsChanged;
}
private void UnwireScene(Scene scene)
{
scene.PropertyChanged -= OnScenePropertyChanged;
scene.Elements.CollectionChanged -= OnElementsChanged;
}
private void OnScenePropertyChanged(object? sender, PropertyChangedEventArgs e)
{
// 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)
{
if (e.NewItems != null)
foreach (SceneElement element in e.NewItems)
element.PropertyChanged += OnElementPropertyChanged;
if (e.OldItems != null)
foreach (SceneElement element in e.OldItems)
element.PropertyChanged -= OnElementPropertyChanged;
OnPropertyChanged(nameof(CanAddWebcam));
OnPropertyChanged(nameof(CanAddYouTubeChat));
ScheduleSave();
}
private void OnElementPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (sender is WebcamSceneConfig && e.PropertyName == nameof(SceneElement.IsVisible))
OnPropertyChanged(nameof(CanShowWebcamInStagedScene));
// A Static element's baked pixels depend on its layout, opacity and
// visibility — any of those changing invalidates the cached base.
if (sender is SceneElement { Kind: ElementKind.Static } el)
{
var bakedSurface = e.PropertyName is nameof(SceneElement.X) or nameof(SceneElement.Y)
or nameof(SceneElement.Width) or nameof(SceneElement.Height)
or nameof(SceneElement.Opacity) or nameof(SceneElement.IsVisible)
or nameof(SceneElement.ClipShape) or nameof(SceneElement.IsMirrored);
if (bakedSurface)
{
var containingScene = Scenes.FirstOrDefault(s => s.Elements.Contains(el));
if (containingScene != null)
_sceneGraph.InvalidateBake(containingScene);
}
}
ScheduleSave();
}
// Adds a canonical scene by name — only when it's actually missing. The
// background flag follows the policy (Live yes, everyone else no).
private void AddScene(string name)
{
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),
HasBackground = SceneCatalog.HasBackground(name),
};
EnsureBackground(scene);
Scenes.Add(scene);
StagedScene = scene;
}
}