Files
LlamaCasty/ViewModels/MainViewModel.Scenes.cs
T
gramps c257de1516 feat(35): scenes remember the room — BRB mutes the mic on entry
591 green, 0 warnings.

- `Scene.SceneMutesMic` / `SceneMutesDesktop` are **nullable booleans**: null = leave the mix alone, so
  a scene is passive until the creator says otherwise. Schema columns added, NULL for every existing
  layout except one deliberate default.
- **Applied once when the scene takes the PROGRAM output** (`TransitionToScene`, only when live or
  recording). Staging a scene offline is auditioning, not a stream edit — the test asserts that.
- **The creator is boss:** a manual mute survives any transition into a scene with no opinion; a scene
  that does have one wins on its next entry. `SetMicMuted`/`SetGameMuted` are idempotent so a repeated
  "mute" cannot clobber the level that unmute would restore.
- **BRB ships muted** (creator spec) for new layouts *and* via a one-time migration for existing rows
  where the creator has never expressed an opinion.
- UI: two three-state checkboxes under the layers list ("When this scene goes live"), styled with
  `PropertyPillCheck` so the theme test holds.

`SceneAudioTests` covers the whole rule in one integration test: default state, offline staging does
nothing, live entry mutes, manual unmute survives a passive scene, an opinionated scene wins, desktop
untouched, and all three states roundtrip through save/reload.

Needs the creator's eye: the checkboxes are new chrome in the left panel (indeterminate = passive).
2026-11-10 11:51:21 -08:00

432 lines
18 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;
/// <summary>TASK 35: the staged scene's audio state, bound by the left panel's three-state
/// checkboxes (indeterminate = leave the mix alone).</summary>
public bool? SceneMutesMic
{
get => StagedScene?.SceneMutesMic;
set
{
if (StagedScene is not { } scene || scene.SceneMutesMic == value) return;
scene.SceneMutesMic = value;
ScheduleSave();
OnPropertyChanged(nameof(SceneMutesMic));
}
}
public bool? SceneMutesDesktop
{
get => StagedScene?.SceneMutesDesktop;
set
{
if (StagedScene is not { } scene || scene.SceneMutesDesktop == value) return;
scene.SceneMutesDesktop = value;
ScheduleSave();
OnPropertyChanged(nameof(SceneMutesDesktop));
}
}
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;
// Scene-scoped menu affordances: the (+) menu's Blur entry is Live-only, and a
// binding that is never re-notified stays collapsed for the rest of the session
// (build 1082: the option vanished after the first layout load).
OnPropertyChanged(nameof(CanAddBlur));
OnPropertyChanged(nameof(StagedBrandLogo));
OnPropertyChanged(nameof(SceneMutesMic));
OnPropertyChanged(nameof(SceneMutesDesktop));
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(LayerCount));
OnPropertyChanged(nameof(CanChangeBackground));
OnPropertyChanged(nameof(CanAddWebcam));
OnPropertyChanged(nameof(CanAddYouTubeChat));
OnPropertyChanged(nameof(CanAddTicker));
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>
private bool _editingActive = true;
/// <summary>The edit toggle (TopBar). On by default; going live on YouTube turns it off
/// and shows the live chat; stopping turns it back on.</summary>
public bool EditingActive
{
get => _editingActive;
set
{
if (!SetProperty(ref _editingActive, value)) return;
OnPropertyChanged(nameof(IsEditMode));
// Undo only exists while the preview is the edit surface.
OnPropertyChanged(nameof(CanUndoPreview));
OnPropertyChanged(nameof(CanRedoPreview));
// EDIT decides whether the ON-AIR start is live or a test (creator ruling 2026-10-30).
OnPropertyChanged(nameof(PrimaryStartKind));
OnPropertyChanged(nameof(PrimaryStartButtonLabel));
OnPropertyChanged(nameof(PrimaryStartCommand));
}
}
public bool IsEditMode => EditingActive;
// ─── Transitions (TASK 19/23) ───
public TransitionType SelectedTransitionType
{
get => _selectedTransitionType;
set { if (SetProperty(ref _selectedTransitionType, value)) ScheduleSave(); }
}
private TransitionType _selectedTransitionType = TransitionType.Fade;
public double TransitionDurationMs
{
get => _transitionDurationMs;
set { if (SetProperty(ref _transitionDurationMs, value)) ScheduleSave(); }
}
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;
// The preview animates the outgoing picture before the switch, in every mode.
PreviewTransition?.Invoke(SelectedTransitionType, TransitionDurationMs);
StagedScene = scene;
// The program output transitions whenever it is running: recording or streaming.
if (IsLive || IsRecording)
{
LiveScene = scene;
_transition.Start(SelectedTransitionType, TransitionDurationMs, null);
// TASK 35: the scene carries the room. Applied ONCE at entry, and only when the program
// output actually changes — staging a scene offline must never edit the stream's state.
ApplySceneAudio(scene);
}
}
/// <summary>TASK 35: a scene's audio state, applied once when it takes the program output.
/// <c>null</c> means "leave the mix alone" — the creator's manual mute survives until a scene that
/// actually has an opinion takes over.</summary>
private void ApplySceneAudio(Scene scene)
{
if (scene.SceneMutesMic is { } muteMic)
SetMicMuted(muteMic);
if (scene.SceneMutesDesktop is { } muteDesktop)
SetGameMuted(muteDesktop);
}
/// <summary>Raised before a scene switch so the preview can animate the old picture away.</summary>
public event Action<TransitionType, double>? PreviewTransition;
/// <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 int LayerCount => StagedScene?.Elements.Count(e => !e.IsZoomLayer) ?? 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>
/// The five canonical scenes render in F-key order (Starting, Live, BRB, Chat,
/// Ending = F1-F5), whichever order the persisted SortOrder rows ended up in —
/// an older session can leave Chat before BRB (seen on the creator's DB).
/// Renamed (non-canonical) scenes keep their loaded order AFTER the canonical
/// five. Stable <see cref="Enumerable.OrderBy"/>, so one pass; the next
/// auto-save re-stamps each SortOrder to its corrected index.
/// </summary>
private void ReorderScenesToCanonical()
{
var target = Scenes.OrderBy(s => CanonicalIndex(s.Name)).ToList();
for (var i = 0; i < target.Count; i++)
{
var current = Scenes.IndexOf(target[i]);
if (current != i) Scenes.Move(current, i);
}
}
private static int CanonicalIndex(string? name)
{
if (name == null) return SceneCatalog.All.Length;
for (var i = 0; i < SceneCatalog.All.Length; i++)
if (SceneCatalog.Is(name, SceneCatalog.All[i])) return i;
return SceneCatalog.All.Length;
}
/// <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));
RetainTextOverlaySubscriptions();
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.Action != NotifyCollectionChangedAction.Move)
{
if (e.NewItems != null)
foreach (SceneElement element in e.NewItems)
{
if (element is Source { Type: SourceType.TextOverlay } text)
{
WireTextOverlaySource(text);
UpdateTextOverlayPreview(text);
}
else
element.PropertyChanged += OnElementPropertyChanged;
}
if (e.OldItems != null)
foreach (SceneElement element in e.OldItems)
{
if (element is Source { Type: SourceType.TextOverlay } text)
RemoveTextOverlayPreview(text);
else
element.PropertyChanged -= OnElementPropertyChanged;
}
}
RetainTextOverlaySubscriptions();
OnPropertyChanged(nameof(CanAddWebcam));
OnPropertyChanged(nameof(CanAddYouTubeChat));
OnPropertyChanged(nameof(CanAddTicker));
ScheduleSave();
}
private void OnElementPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (sender is Source { Type: SourceType.TextOverlay } text)
OnTextOverlayPropertyChanged(text, e.PropertyName);
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)
or nameof(Source.TextContent) or nameof(Source.TextFontFamily)
or nameof(Source.TextFontSize) or nameof(Source.TextColor) or nameof(Source.TextBold)
or nameof(Source.TextItalic) or nameof(Source.TextUnderline) or nameof(Source.TextAlignment)
or nameof(Source.TextVerticalAlignment);
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),
// TASK 35: BRB ships with the mic muted; every other scene is passive until told.
SceneMutesMic = SceneCatalog.Is(name, SceneCatalog.Brb) ? true : null,
};
EnsureBackground(scene);
EnsureZoomLayer(scene);
Scenes.Add(scene);
StagedScene = scene;
}
}