Files
LlamaCasty/ViewModels/MainViewModel.Scenes.cs
T
gramps 28657918f9 feat(65): F10 saves a clip; the gear drawer owns the three settings
Slice 4. 607 green, 0 warnings. Clips remain **off by default** and, while off, the ring is never
constructed — so no reader thread and no encoder output exist until someone asks for the feature.

- **F10** (`HotkeyId.SaveClip`, default F10, remappable in the hotkey dialog) snapshots the ring, writes
  the file, prunes to the retention count and toasts the name. Empty ring → "nothing to clip yet"
  instead of a zero-byte file; clips off → says so and points at Settings.
- **Settings**: Enable, length (10 or 15, default 10), keep (3/4/5, default 3). Persisted in the layout
  store and **coerced on load** — a bad stored value falls back to a legal one, so the ring can never be
  sized for a length the UI cannot show.
- The ring is sized from length × tier bitrate × 1.25 and rebuilt if either changes; it is created at
  session start and dropped at stop, so memory is only spent while a show is running.
- The drawer states the boundary in the creator's terms: "A capture, not an editor: for trimming,
  captions or a library, use a video editor."

Still to come: the replay layer itself (play once then hide in Live, loop the kept clips elsewhere).
2026-11-10 12:41:39 -08:00

433 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.SaveClip: SaveReplayClip(); 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;
}
}