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 _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 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(); } } } /// The scene the encoder outputs. Set on Go Live (StagedScene → LiveScene) /// or on transition completion. FramePump reads this every tick. public Scene? LiveScene { get => _liveScene; set { if (SetProperty(ref _liveScene, value)) { foreach (var s in Scenes) s.IsLive = s == value; } } } /// True when offline — edit mode is available. When live/recording, /// all edit operations are locked out (gun safety). 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; /// /// Called when a scene thumbnail is clicked. If live, triggers a transition from /// LiveScene to the selected StagedScene. Always sets StagedScene first. /// public void TransitionToScene(Scene scene) { if (scene.IsHidden || scene == StagedScene) return; StagedScene = scene; if (IsLive) _transition.Start(SelectedTransitionType, TransitionDurationMs, null); } /// /// Apply the current transition type to the next StagedScene change while live. /// Called from XAML when user picks a transition from the dropdown. /// public void ApplyTransitionToLive() { if (IsLive) _transition.Start(SelectedTransitionType, TransitionDurationMs, null); } // ─── Global hotkeys (TASK 20) — dispatch a registered hotkey to its action ─── /// /// Callback set by MainWindow so the VM can trigger hotkey re-registration /// after the user saves custom bindings from the config dialog. /// public Action>? HotkeyBindingsChanged { get; set; } /// The current hotkey bindings (loaded from DB, updated on dialog save). public Dictionary 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")); /// The canonical scenes (SceneCatalog) not present right now — what the /// "+" button may re-add. Work with less, never more. public IEnumerable MissingScenes => SceneCatalog.All.Where(canonical => Scenes.All(s => !SceneCatalog.Is(s.Name, canonical))); /// Only show the "+" button when one of the five canonical scenes is missing. public bool ShowAddScene => MissingScenes.Any(); /// "Change Capture…"/"Refresh Capture" apply to the active scene's background. // 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; } }