Files
LlamaCasty/ViewModels/MainViewModel.Scenes.cs
T
gramps 5c4acf141b feat(text): composite shared text rasters in output
Raster static text on UI-thread changes and reuse the same transparent BGRA frame in preview and all compositor paths. Cache text frame identity in the frame-pump signature and invalidate static bakes on edits. Follow OBS text-source conventions: https://obsproject.com/kb/text-sources
2026-11-04 15:01:47 -08:00

386 lines
16 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;
// 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));
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);
}
}
/// <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);
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);
EnsureZoomLayer(scene);
Scenes.Add(scene);
StagedScene = scene;
}
}