The blur is universal, so it would swallow the logo along with everything else. The compositor now defers every visible brand logo: skipped in both element loops, blitted immediately AFTER the blur group, before the flash/social/ticker overlays. One logo per scene, so it is cheap. Preview mirrors it: the staged scene's brand logo is drawn above the blur host (StagedBrandLogo), so what you see in the editor is what the encoder gets. Test: a blur covering the logo's area leaves the logo's pixels exactly white while the coloured background beneath it blends.
359 lines
15 KiB
C#
359 lines
15 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(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));
|
|
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);
|
|
EnsureZoomLayer(scene);
|
|
Scenes.Add(scene);
|
|
StagedScene = scene;
|
|
}
|
|
} |