diff --git a/ViewModels/MainViewModel.Scenes.cs b/ViewModels/MainViewModel.Scenes.cs new file mode 100644 index 0000000..171307c --- /dev/null +++ b/ViewModels/MainViewModel.Scenes.cs @@ -0,0 +1,273 @@ +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(); + + public ObservableCollection Scenes { get; } = new(); + + 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); + } + 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(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)); + 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; + } +} \ No newline at end of file diff --git a/ViewModels/MainViewModel.cs b/ViewModels/MainViewModel.cs index fdac296..9c1aad0 100644 --- a/ViewModels/MainViewModel.cs +++ b/ViewModels/MainViewModel.cs @@ -31,8 +31,6 @@ public partial class MainViewModel : ViewModelBase private readonly DispatcherTimer _gameVolumeFlashTimer; private readonly DispatcherTimer _healthPollTimer; - private Scene? _stagedScene; - private Scene? _liveScene; private SceneElement? _selectedElement; private ImageSource? _activeBackgroundImage; private double _audioLevel; @@ -170,9 +168,6 @@ public partial class MainViewModel : ViewModelBase private bool _brandFlashEnabled = true; private bool _brandFlashActive; - // TASK 20 step 2 — hotkey bindings (custom chord per action) - private Dictionary _hotkeyBindings = new(); - private const string SupportEmail = "gramps@llamachile.shop"; // The creator-hub link targets (About overlay) — public so the hub's // integration test can assert they point at real destinations. @@ -190,64 +185,9 @@ public partial class MainViewModel : ViewModelBase /// shipped THIRD-PARTY-NOTICES.txt (never opens the OS viewer). public string LicensesText { get; private set; } = string.Empty; - public ObservableCollection Scenes { get; } = new(); public ObservableCollection ChatMessages { get; } = new(); public ObservableCollection Displays { get; } = new(); - 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); - } - 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(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; - public ImageSource? ActiveBackgroundImage { get => _activeBackgroundImage; @@ -320,114 +260,6 @@ public partial class MainViewModel : ViewModelBase => StagedScene != null && !Scenes.Any(s => s.Elements.OfType().Any(e => e.Type == SourceType.ChatBox)); - // ─── 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; - } - } - /// /// Right-click-on-preview → "Show Webcam": offered when the active scene has no /// webcam config (add one) or hides it (unhide — keeps the config row). @@ -1008,26 +840,7 @@ public partial class MainViewModel : ViewModelBase public bool ShowLogInButton => !IsConnected && IsOffline && !IsRecording; public bool ShowChatInactiveMessage => !IsLive; - public bool ShowEmptySceneHint => StagedScene != null && StagedScene.Elements.Count == 0; public bool ShowPreviewPlaceholder => !ShowEmptySceneHint && ActiveBackgroundImage == null && BackgroundImage == null; - 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); // Paid unlock flips this off (see ai.md "Monetization"). When disabled the // cadence timer is stopped and any active flash is hidden immediately. @@ -2193,60 +2006,6 @@ public partial class MainViewModel : ViewModelBase } } - // ─── 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)); - ScheduleSave(); - } - private void ScheduleSave() { if (_isLoading || _saveDebounce == null) return; @@ -2254,23 +2013,6 @@ public partial class MainViewModel : ViewModelBase _saveDebounce.Start(); } - // 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; - } - // ─── Background model (TASK 25): exactly ONE Background per screen, index 0, // never removable or re-addable. Static default art on Starting/BRB/Chat/Ending; // on Live it's the capture-flavored row (Show Desktop / monitor switch / diff --git a/ViewModels/index.md b/ViewModels/index.md index 08a6ebb..4a4d2d8 100644 --- a/ViewModels/index.md +++ b/ViewModels/index.md @@ -31,11 +31,18 @@ Progress (Commit N/12 · tag): (Polar license state: `LicenseKey`/`LicenseMessage`/`IsLicenseValidating`/ `IsLicenseEntryOpen`/`IsHubVisible`/`IsPremium`, `OfflineGracePeriod`, `ValidateLicenseAtStartupAsync`, `ActivateLicenseAsync`). +- **Commit 3/12 · `refactor-commit-3`: Scenes landed** → `MainViewModel.Scenes.cs` + (`Scenes` + `StagedScene`/`LiveScene`/`IsEditMode`, transitions + `SelectedTransitionType`/`TransitionDurationMs`/`TransitionToScene`/ + `ApplyTransitionToLive`, global hotkeys `HandleHotkey`/`OpenHotkeyConfig`/ + `RefreshHotkeyLabels`, scene-gating hints, `MissingScenes`/`ShowAddScene`/ + `CanChangeBackground`, scene wiring `OnScenesChanged`/`WireScene`/`UnwireScene`, + `AddScene`). - Core file keeps: fields, ctor, `Shutdown`/`SaveLayoutNow`/`ScheduleSave`/`LoadLayout`, `SetPanel`/`ShowOverlay`/`ShowLicenses`, `CreateBrush`, `OpenUrl`/`ComposeEmail`, `SubmitBug`/`SubmitFeature`, window-geometry pass-through. -- Partials: `Account` ✔ (Commit 1), `License` ✔ (Commit 2), `Scenes` (scene catalog + hotkeys + transitions), `Background` (background model + screen +- Partials: `Account` ✔ (Commit 1), `License` ✔ (Commit 2), `Scenes` ✔ (Commit 3), `Background` (background model + screen capture + display picker), `Webcam` (webcam identity + per-scene configs + captures), `Audio` (mic + game bars + TRAX), `Trax` (music player), `Socials` (social bar + dialog), `Streaming` (go-live/record + health + quality tiers + media source pipeline),