diff --git a/ViewModels/MainViewModel.Socials.cs b/ViewModels/MainViewModel.Socials.cs new file mode 100644 index 0000000..3e216a2 --- /dev/null +++ b/ViewModels/MainViewModel.Socials.cs @@ -0,0 +1,161 @@ +using System.Windows; +using System.Windows.Input; +using System.Windows.Media; +using ytLive; +using ytLive.Helpers; +using ytLive.Models; +using ytLive.Services; +using ytLive.Services.Compositor; + +namespace ytLive.ViewModels; + +public partial class MainViewModel +{ + private SocialsConfig? _socials; + private VideoFrame? _socialBarFrame; + private readonly ISocialValidator _socialValidator = new HttpSocialValidator(); + + public ICommand OpenSocialDialogCommand { get; } + + // ─── Social bar (global resource, shown on every scene) ─── + + /// True when the global socials config has at least one validated entry. + public bool HasSocials => _socials != null && _socials.Entries.Count > 0; + + /// The bar is on the stream when it's enabled and has entries. + public bool SocialBarVisible => (_socials?.BarEnabled ?? false) && HasSocials; + + /// Footer indicator dot: green when the bar is on the stream, red otherwise. + public SolidColorBrush SocialBarDotBrush => SocialBarVisible ? BarOnBrush : BarOffBrush; + + /// Green glow on the bar while it's on the stream. + public SolidColorBrush SocialBarGlowBrush => BarOnBrush; + + /// The global socials config (entries + bar settings), or null. + public SocialsConfig? Socials => _socials; + + /// Canvas.Top for the social bar: 0 = top, 1040 = bottom (40px from the 1080 edge). + public double SocialBarTop => _socials?.BarPosition == SocialBarPosition.Top ? 0 : 1040; + + + /// + /// Resolves a missing nodeinfo software name (mastodon, peertube, ...) for every + /// fediverse entry that doesn't have one, so the bar shows the instance's real + /// logo instead of the generic fediverse glyph. Best-effort: a failed resolution + /// leaves the glyph untouched. Returns handle → software for what was resolved — + /// the caller applies + persists (the app hops to the dispatcher; a test applies + /// directly). + /// + public static async Task> HealFediverseSoftwareAsync( + SocialsConfig socials, + ISocialValidator validator, + Action? log = null) + { + var resolved = new Dictionary(); + if (socials == null || validator == null) return resolved; + foreach (var entry in socials.Entries) + { + if (entry.Service != SocialService.Fediverse + || !string.IsNullOrWhiteSpace(entry.FediverseSoftware)) + continue; + if (!SocialServiceIcons.TryParseFediverse(entry.Handle, out _, out var domain)) + continue; + var software = await validator.ResolveFediverseSoftwareAsync( + domain, System.Threading.CancellationToken.None); + if (string.IsNullOrWhiteSpace(software)) continue; + resolved[entry.Handle] = software; + log?.Invoke($"Socials heal: {entry.Handle} → {software}"); + } + return resolved; + } + + /// Runs the heal off the UI thread and applies + saves any result. + private void HealFediverseSoftwareInBackground() + { + var socials = _socials; + if (socials == null) return; + _ = Task.Run(async () => + { + try + { + var resolved = await HealFediverseSoftwareAsync( + socials, _socialValidator, m => AppLog.Write(m)); + if (resolved.Count == 0) return; + await Application.Current.Dispatcher.InvokeAsync(() => + { + var current = _socials; + if (current == null) return; + var applied = false; + foreach (var entry in current.Entries) + { + if (!resolved.TryGetValue(entry.Handle, out var software)) continue; + if (entry.FediverseSoftware == software) continue; + entry.FediverseSoftware = software; + applied = true; + } + if (applied) NotifySocialsChanged(); // re-renders the bar + saves + }); + } + catch (Exception ex) + { + AppLog.Write($"Socials heal failed: {ex.Message}"); + } + }); + } + + /// + /// Re-rasterizes the social bar strip the output compositor overlays. UI thread + /// only (WPF rendering); the resulting frame is immutable, so the frame pump may + /// read it from its own thread. Null when the bar is off or empty. + /// + private void RenderSocialBarFrame() + { + _socialBarFrame = _socials != null && _socials.BarEnabled + ? SocialBarRenderer.Render(_socials.Entries) + : null; + } + + /// + /// Opens the Social Media Site Promotion dialog (6 slots, sign-in gate, + /// validation). On save the working copy replaces . + /// + private void OpenSocialDialog() + { + var dialog = new SocialsDialogViewModel( + _socialValidator, + SignInAsync, + SignOutYouTubeAsync, + _youtubeAuth.CurrentChannel, + _socials); + var window = new ytLive.SocialsDialog(dialog) { Owner = Application.Current.MainWindow }; + if (window.ShowDialog() == true) + { + _socials = dialog.BuildSocialsConfig(); + NotifySocialsChanged(); + } + } + + private void NotifySocialsChanged() + { + RenderSocialBarFrame(); + OnPropertyChanged(nameof(HasSocials)); + OnPropertyChanged(nameof(SocialBarVisible)); + OnPropertyChanged(nameof(SocialBarDotBrush)); + ScheduleSave(); + } + + /// Sets the bar's edge; called by the preview click-toggle. + public void SetSocialBarPosition(SocialBarPosition position) + { + if (_socials == null || _socials.BarPosition == position) return; + _socials.BarPosition = position; + OnPropertyChanged(nameof(SocialBarTop)); + ScheduleSave(); + } + + /// Preview click flips the bar top ⇄ bottom (KISS — no drag math). + public void ToggleSocialBarPosition() + => SetSocialBarPosition(_socials?.BarPosition == SocialBarPosition.Top + ? SocialBarPosition.Bottom + : SocialBarPosition.Top); +} diff --git a/ViewModels/MainViewModel.cs b/ViewModels/MainViewModel.cs index 34ffa95..5629916 100644 --- a/ViewModels/MainViewModel.cs +++ b/ViewModels/MainViewModel.cs @@ -83,9 +83,6 @@ public partial class MainViewModel : ViewModelBase // news up the real service; the VM is built from XAML so no DI seam exists. private readonly INotificationService _notifications = new NotificationService(); - private SocialsConfig? _socials; - private VideoFrame? _socialBarFrame; - // Live frame pipeline (TASK 4 ship step 5): composites the active scene at the // tier's FPS and paces frames into the encoder. The RTMP URL seam stays null // until the live-stream create flow (TASK 5) supplies the reusable stream URL. @@ -169,33 +166,11 @@ public partial class MainViewModel : ViewModelBase => StagedScene != null && !Scenes.Any(s => s.Elements.OfType().Any(e => e.Type == SourceType.ChatBox)); - // ─── Social bar (global resource, shown on every scene) ─── - private static readonly SolidColorBrush BarOnBrush = CreateBrush("#2ecc71"); private static readonly SolidColorBrush BarOffBrush = CreateBrush("#e94560"); private static SolidColorBrush CreateBrush(string hex) => (SolidColorBrush)new BrushConverter().ConvertFromString(hex)!; - /// True when the global socials config has at least one validated entry. - public bool HasSocials => _socials != null && _socials.Entries.Count > 0; - - /// The bar is on the stream when it's enabled and has entries. - public bool SocialBarVisible => (_socials?.BarEnabled ?? false) && HasSocials; - - /// Footer indicator dot: green when the bar is on the stream, red otherwise. - public SolidColorBrush SocialBarDotBrush => SocialBarVisible ? BarOnBrush : BarOffBrush; - - /// Green glow on the bar while it's on the stream. - public SolidColorBrush SocialBarGlowBrush => BarOnBrush; - - /// The global socials config (entries + bar settings), or null. - public SocialsConfig? Socials => _socials; - - /// Canvas.Top for the social bar: 0 = top, 1040 = bottom (40px from the 1080 edge). - public double SocialBarTop => _socials?.BarPosition == SocialBarPosition.Top ? 0 : 1040; - - private readonly ISocialValidator _socialValidator = new HttpSocialValidator(); - /// True when the Elements panel should show the "No configurable elements" hint. public bool IsElementsPanelEmpty => SelectedElement is { } el && !el.IsDraggable && !el.IsWebcam @@ -586,7 +561,6 @@ public partial class MainViewModel : ViewModelBase public ICommand RemoveSourceCommand { get; } public ICommand EditElementCommand { get; } public ICommand ToggleElementVisibilityCommand { get; } - public ICommand OpenSocialDialogCommand { get; } public ICommand StartStreamCommand { get; } public ICommand EndStreamCommand { get; } public ICommand ChooseRecordFolderCommand { get; } @@ -1913,127 +1887,4 @@ public partial class MainViewModel : ViewModelBase OnPropertyChanged(nameof(RecDotOpacity)); } - // ─── Social bar ─── - - /// - /// Resolves a missing nodeinfo software name (mastodon, peertube, ...) for every - /// fediverse entry that doesn't have one, so the bar shows the instance's real - /// logo instead of the generic fediverse glyph. Best-effort: a failed resolution - /// leaves the glyph untouched. Returns handle → software for what was resolved — - /// the caller applies + persists (the app hops to the dispatcher; a test applies - /// directly). - /// - public static async Task> HealFediverseSoftwareAsync( - SocialsConfig socials, - ISocialValidator validator, - Action? log = null) - { - var resolved = new Dictionary(); - if (socials == null || validator == null) return resolved; - foreach (var entry in socials.Entries) - { - if (entry.Service != SocialService.Fediverse - || !string.IsNullOrWhiteSpace(entry.FediverseSoftware)) - continue; - if (!SocialServiceIcons.TryParseFediverse(entry.Handle, out _, out var domain)) - continue; - var software = await validator.ResolveFediverseSoftwareAsync( - domain, System.Threading.CancellationToken.None); - if (string.IsNullOrWhiteSpace(software)) continue; - resolved[entry.Handle] = software; - log?.Invoke($"Socials heal: {entry.Handle} → {software}"); - } - return resolved; - } - - /// Runs the heal off the UI thread and applies + saves any result. - private void HealFediverseSoftwareInBackground() - { - var socials = _socials; - if (socials == null) return; - _ = Task.Run(async () => - { - try - { - var resolved = await HealFediverseSoftwareAsync( - socials, _socialValidator, m => AppLog.Write(m)); - if (resolved.Count == 0) return; - await Application.Current.Dispatcher.InvokeAsync(() => - { - var current = _socials; - if (current == null) return; - var applied = false; - foreach (var entry in current.Entries) - { - if (!resolved.TryGetValue(entry.Handle, out var software)) continue; - if (entry.FediverseSoftware == software) continue; - entry.FediverseSoftware = software; - applied = true; - } - if (applied) NotifySocialsChanged(); // re-renders the bar + saves - }); - } - catch (Exception ex) - { - AppLog.Write($"Socials heal failed: {ex.Message}"); - } - }); - } - - /// - /// Re-rasterizes the social bar strip the output compositor overlays. UI thread - /// only (WPF rendering); the resulting frame is immutable, so the frame pump may - /// read it from its own thread. Null when the bar is off or empty. - /// - private void RenderSocialBarFrame() - { - _socialBarFrame = _socials != null && _socials.BarEnabled - ? SocialBarRenderer.Render(_socials.Entries) - : null; - } - - /// - /// Opens the Social Media Site Promotion dialog (6 slots, sign-in gate, - /// validation). On save the working copy replaces . - /// - private void OpenSocialDialog() - { - var dialog = new SocialsDialogViewModel( - _socialValidator, - SignInAsync, - SignOutYouTubeAsync, - _youtubeAuth.CurrentChannel, - _socials); - var window = new ytLive.SocialsDialog(dialog) { Owner = Application.Current.MainWindow }; - if (window.ShowDialog() == true) - { - _socials = dialog.BuildSocialsConfig(); - NotifySocialsChanged(); - } - } - - private void NotifySocialsChanged() - { - RenderSocialBarFrame(); - OnPropertyChanged(nameof(HasSocials)); - OnPropertyChanged(nameof(SocialBarVisible)); - OnPropertyChanged(nameof(SocialBarDotBrush)); - ScheduleSave(); - } - - /// Sets the bar's edge; called by the preview click-toggle. - public void SetSocialBarPosition(SocialBarPosition position) - { - if (_socials == null || _socials.BarPosition == position) return; - _socials.BarPosition = position; - OnPropertyChanged(nameof(SocialBarTop)); - ScheduleSave(); - } - - /// Preview click flips the bar top ⇄ bottom (KISS — no drag math). - public void ToggleSocialBarPosition() - => SetSocialBarPosition(_socials?.BarPosition == SocialBarPosition.Top - ? SocialBarPosition.Bottom - : SocialBarPosition.Top); - } diff --git a/ViewModels/index.md b/ViewModels/index.md index ca393e8..a197645 100644 --- a/ViewModels/index.md +++ b/ViewModels/index.md @@ -2,9 +2,21 @@ MVVM layer. See [`schema.md`](../schema.md) for the memory-map conventions. +Each landed `MainViewModel` partial owns a row here describing its area; the +`MainViewModel.cs` row describes only what still lives in core. Docking most +areas' docs in their own rows keeps this index small to read. + | File | Purpose | |------|---------| -| `MainViewModel.cs` | `public partial class MainViewModel : ViewModelBase`. The app brain: scenes/elements collections + commands, stream state (`IsLive`/`IsOffline`/`IsConnected`), chat feed, overlays, layout save/open, **resolution dropdown** (`QualityOptions`, `SelectedQuality`, `ResolutionHelp`, `ApplyStreamQuality`) that computes the output rect over the 1920×1080 master (`OutputRectX/Y/W/H`, `DimRects`, `IsOutputCropped`, `ResolutionBadgeText`). Auth: loads the saved DPAPI session at startup (`LoadSavedSessionAsync`), `SignInAsync` feeds the GoLive dialog; **go-live (ship step 7): `BeginGoLive` accepts the dialog → sets `StreamVisibility` (always "Private") → fires `CreateBroadcastAsync` (stores `_currentBroadcastId` for TASK 9's bind/transition; failure → `StreamStatus.Error`) + `_framePump.StartAsync()`**; **End Livestream signs out** (`StopStream` clears session + token + `_currentBroadcastId` — crash-safe). **Connected account:** top-bar avatar/name (`AccountAvatarUrl`/`AccountDisplayName` via `SyncConnectedAccount`, cleared on End). **Element rows:** scenes are pure selection rows (no per-row icons); source rows carry edit/visibility/trash (`EditElementCommand` sets `element.IsEditing`, `ToggleElementVisibilityCommand` flips `IsVisible`, `RemoveSourceCommand`; the Backdrop's trash slot stays **reserved** via `HiddenBoolToVisibilityConverter` so the edit/eye icons keep their fixed columns — TASK 13); duplicate resource names get a no-space incrementing suffix via shared `NextSourceName` (Image, Image2, Image3… — next free number derived from actual names, so deletions never collide; used by `AddSource` + `AddReusedImage`). **Audio (KISS — the mic is the creator's only audio control; capture SHIPPED, runs for the app's lifetime so the meters preview live):** the sound meter is a **READ-ONLY realtime level display**: fill = `Math.Min(1, AudioLevelMeter.ToDisplay((float)AudioLevel) * MicVolume)` (`MeterFillWidth`/`MeterBrush`, green→yellow→red, zone markers at 60%/80% — `ToDisplay` maps the raw linear RMS onto a −60..0 dBFS scale with +10 dB amplification so real speech/game levels occupy the bar and peaks hit red at maxed volume), where `AudioLevel` (live input, 0 with no input) is fed by the audio mixer once capture lands and `MicVolume` acts as a gain on ambient noise; while the slider is dragged the bar previews the slider position (`SetVolumeAdjusting`), returning to the live level on release (0 with no input — clicking the meter does nothing); `MicVolume` (default 0.8) + read-only `MicMuted`/`MicMuteText`/`ToggleMicMuteCommand` — **MicVolume drives MicMuted** (muted ⇔ volume 0): sliding to 0 flips the speaker to muted, sliding up from 0 clears it; muting stores the prior volume, unmuting restores it (default 0.8 if unknown) and flashes the meter to the restored position ~300ms (`BeginVolumeFlash`/`EndVolumeFlash`); `MicSourceName` = picked voice source, shown left-justified inside the meter bar (the fill runs at 75% opacity so the text + ruler markings show through); `OpenMicPickerCommand`/`PickMicrophone()` open the `MicPickerDialog` (a picked device takes effect immediately — `PickMicrophone` swaps the live source via `_audioMixer.RestartMic()`, loopback keeps running); the **MIC label is a button** (`OpenMicPickerCommand`) with a **status dot** (`MicStatus`, `Models/MicStatus`: green = `MicConnected` via the source's `Started` event, yellow = `MicFailed` — in use/unplugged, red = no mic device at startup; `MicStatusBrush`/`MicStatusToolTip`); capture starts once at startup (`StartMicCaptureAsync`) — NOT go-live (`BeginGoLive`/`StopStream` no longer touch the mixer) — zero devices = red dot + the mixer never starts. **Game audio bar** (desktop/game, **overlaid at the bottom of the preview window** — bottom-center chip, a mirror of the mic bar; **always visible** — the TASK 4 show/hide gating (`IGameAudioDetector`/`GameAudioDetector`/`GameAudioHysteresis` + the 250ms poll timer) was **removed 2026-08-15** because the bar kept hiding the desktop meter whenever no full-screen game with sound was up): `GameAudioLevel` (loopback meter via `LoopbackLevelChanged`, scaled by volume), `GameMuted`/`GameMuteText`/`ToggleGameMuteCommand`, `GameAudioVolume` (0..1 volume slider), `Begin/End/CancelGameVolumeFlash` (mirrors the mic bar's volume flash); the game speaker + slider share the mic bar's `PreviewMouseLeftButtonDown/Up` + `LostMouseCapture` code-behind pattern. **Webcam:** owns `CameraManager` (MediaCapture), `AddWebcamToActiveSceneAsync` (picker → default 480×270 bottom-right placement → acquire; the Windows camera picker ALWAYS opens so the creator chooses — never silently reuses the previous camera; picking a different camera swaps the app-wide identity via `SwapWebcamIdentityAsync`, same path as `ChangeWebcamAsync`; propagates the running shared bitmap via `CameraManager.GetPreviewBitmap` first, so a webcam added mid-session never renders a transparent container; greys out when the active scene already has a config via `CanAddWebcamToActiveScene`), `ChangeWebcamAsync` (device swap — `ReleaseAllAsync` old + re-acquire), `ShowWebcamInActiveScene` (reveal hidden config / empty-canvas right-click), `ReacquireWebcam` after layout load (re-propagates the shared bitmap to every config), `OnCameraPreviewBitmapChanged` forwards the shared bitmap into every `WebcamSceneConfig.VideoImageSource`; `SelectedElement` drives the preview overlay + `internal ClampWebcamToBounds(config, sceneName)` (per-scene size cap seam: 50%-per-dimension everywhere, half-screen-AREA in Chat via `MaxWebcamWidthFor`/`MaxWebcamHeightFor`). **Scenes (five-scene catalog):** empty DB seeds Starting/Live/BRB/Chat/Ending (`SceneCatalog.All`); `AddScene(name)` accepts only canonical, missing names; `MissingScenes`/`ShowAddScene` drive the "+" button (hidden once all five exist; its menu lists only the missing scenes via `AddSceneCommand`). **Screen backdrop (Live-only by policy):** owns `ScreenCaptureManager` + `ScreenCaptureSourceFactory` (WinRT GraphicsCapture), `internal static EnsureBackdrop` heals one per backdrop-enabled scene (gated on `Scene.HasBackdrop`), `ReacquireScreenCaptures`/`RefreshBackdropAutoCapture`/`NoteBackgroundWindow` key by full-screen game monitor or the **primary display** via `Win32FullScreenDetector` (`GetDisplays()`/`PrimaryMonitorIndex()`), `ChangeBackdropCaptureAsync` (OS picker) + `SetBackdropCapture(DisplayInfo)` (in-app "Capture Display"), `RedesignateBackdropAsync` re-targets all backdrops and releases orphaned sessions, `internal static EnforceBackdropPolicy` normalizes `Scene.HasBackdrop` by name on every load + strips lingering non-Live backdrops, `CanChangeBackdrop` gates the capture menu to Live (the only scene with a backdrop), `BackdropImage` hides the preview watermark (`ShowPreviewPlaceholder`). **REC sign (2026-08-13):** top-center indicator always visible — `RecDotBrush` (offline `#555555`, live `#e94560`, live-private `#8f1f1f`), `RecTextBrush` (dim offline, white live), `RecDotOpacity` (0.55 offline, pulsing 1.0/0.35 live via `_recDotPulse` flipped on the live tick), `IsLivePrivate` (`IsLive && StreamVisibility == "Private"`) — notified from the `StreamStatus` + `StreamVisibility` setters; `LiveIndicatorVisible`/`LivePulseOpacity` removed. **Mic mute icon:** a second 16px clickable glyph (mic, red + slash when muted) between the meter and the speaker on the mic bar — same `ToggleMicMuteCommand`. **TASK 11 (2026-08-15):** the gain seams (`AudioGainProvider` → `micGain`/`loopbackGain`) reach the mixer at construction so volume sliders + mutes are stream-honest; the game bar also scales TRAX locally (`MusicPlayer.LocalGain`); `MicSourceName` persists via `LayoutStore`'s `Settings` table + restores before first `Start`; TRAX moved into the game audio bar (left of "Desktop Audio") in the preview overlay. **Health stats (TASK 4 ship step 6, 2026-08-13):** `OnFramePumpHealthUpdated` marshals `FramePump.HealthUpdated` (encoder's parsed bitrate/FPS/dropped/duration — raised on the stderr thread) onto the UI thread into `CurrentHealth` (bottom bar bindings); `ResetHealth(status)` zeroes dropped/duration on go-live/End so stats never linger | +| `MainViewModel.cs` | Core. `public partial class MainViewModel : ViewModelBase`. **Broadcast/stream state:** go-live (`BeginGoLive` accepts the GoLive dialog → `StreamVisibility` always "Private" → `CreateBroadcastAsync` storing `_currentBroadcastId` for the bind/transition; failure → `StreamStatus.Error` + `_framePump.StartAsync()`), End (`StopStream` — signs out, clears session/token/`_currentBroadcastId`, crash-safe), `StreamStatus`/`StreamVisibility` setters, `IsOffline`/`IsLive`/`IsLivePrivate`. **Health stats:** `CurrentHealth`/`ResetHealth`/`OnFramePumpHealthUpdated` marshal `FramePump.HealthUpdated` (encoder bitrate/FPS/dropped/duration) off the stderr thread onto the UI thread. **Start/End UI:** REC/ON-AIR pills (`RecordPillOn`/`OnAirPillOn`, `CanStartSession`, `ShowPrimaryStartButton`/`ShowEndStreamButton`/`EndButtonText`), REC sign (`RecDotBrush`/`RecTextBrush`/`RecDotOpacity`, pulsing live via `_recDotPulse`), `ShowLogInButton`, `ChooseRecordFolder`/`ResetRecordFolder`/`_recordFolder`. **Resolution dropdown:** `QualityOptions`/`SelectedQuality`/`ResolutionHelp`/`ApplyStreamQuality` computes the output rect over the 1920×1080 master (`OutputRectX/Y/W/H`, `DimRects`, `IsOutputCropped`, `ResolutionBadgeText`). **Source commands + element rows:** scenes are pure selection rows; sources carry edit/visibility/trash (`EditElementCommand` sets `element.IsEditing`, `ToggleElementVisibilityCommand` flips `IsVisible`, `RemoveSourceCommand`; Backdrop trash slot reserved via `HiddenBoolToVisibilityConverter` — TASK 13); shared `NextSourceName` (Image, Image2, Image3… no-space increment from actual names — used by `AddSource` + `AddReusedImage`), `AddImageCommand`. **Overlays/lifecycle:** fields + ctor wiring (all command assignments, mixer/camera/chat/hotkey/subscription hookups), `Shutdown`/`SaveLayoutNow`/`ScheduleSave`/`LoadLayout`, `SetPanel`/`ShowOverlay`/`ShowLicenses`, `CreateBrush` + shared `BarOnBrush`/`BarOffBrush` (referenced by Audio/Trax partials), `OpenUrl`/`ComposeEmail`/`SubmitBug`/`SubmitFeature`, window-geometry pass-through, `SelectedElement`/`OnSelectedElementPropertyChanged`, `ShowPreviewPlaceholder`/`ShowChatInactiveMessage`, `BrandFlash*`, WebView2 hosting (`InitWebView2`/`RegisterLoadedWebSources`/`OnWebView2PreviewBitmapChanged`). | +| `MainViewModel.Account.cs` | Session + connected account: `LoadSavedSessionAsync` (DPAPI restore at startup), `SignInAsync`/`ChangeAccountAsync`/`SignOutYouTubeAsync`, `IsConnected`/`AccountAvatarUrl`/`AccountDisplayName`/`AccountStatusLightToolTip` via `SyncConnectedAccount` (cleared on End). | +| `MainViewModel.License.cs` | Polar license (freemium): `LicenseKey`/`LicenseMessage`/`IsLicenseValidating`/`IsLicenseEntryOpen`/`IsHubVisible`/`IsPremium`, `OfflineGracePeriod`, `ValidateLicenseAtStartupAsync`/`ActivateLicenseAsync`. | +| `MainViewModel.Scenes.cs` | Five-scene catalog: `Scenes` + `StagedScene`/`LiveScene`/`IsEditMode`, `AddScene` (canonical names only), `MissingScenes`/`ShowAddScene`/`AddSceneCommand`; scene wiring `OnScenesChanged`/`WireScene`/`UnwireScene`; transitions `SelectedTransitionType`/`TransitionDurationMs`/`TransitionToScene`/`ApplyTransitionToLive`; global hotkeys `HandleHotkey`/`OpenHotkeyConfig`/`RefreshHotkeyLabels`; scene-gating hints (`CanChangeBackground`). | +| `MainViewModel.Background.cs` | Screen backdrop (Live-only by policy): `ScreenCaptureManager` + `ScreenCaptureSourceFactory` (WinRT GraphicsCapture); background model `EnsureBackground`/`CreateBackground`/`NormalizeBackgrounds`/`HealBackgrounds`/`StampDefaultBackgroundAsset`; screen capture `ReacquireScreenCaptures`/`ResolveAutoCaptureKey`/`RedesignateBackgroundAsync`/`ClearBackgroundCaptureAsync`/`SetBackgroundCapture`/`OnScreenPreviewBitmapChanged`/`OnFullscreenMonitorChanged`/`RefreshBackgroundAutoCapture`/`BackgroundShowDesktop` (keys on full-screen-game monitor or primary display via `Win32FullScreenDetector`); display picker `Displays`/`SetBackgroundDisplayCommand`; preview fns `ActiveBackgroundImage`/`BackgroundImage`/`UpdateActiveBackground`/`UpdateBackgroundImage`/`RefreshSnapshotsAsync`/`LoadBackgroundImage`/`CreatePlaceholderSnapshot`; `ChangeCaptureCommand`/`RefreshCaptureCommand`/`ToggleShowDesktopCommand`/`BrowseBackgroundCommand`. `ShowPreviewPlaceholder` stays in core. | +| `MainViewModel.Webcam.cs` | Webcam identity: `_webcam`/`ReacquireWebcam`/`SwapWebcamIdentityAsync`; per-scene configs `AddWebcamToStagedSceneAsync`/`ShowWebcamInStagedScene`/`ChangeWebcamAsync` (Windows picker always opens); capture `OnCameraPreviewBitmapChanged`/`OnCameraFailed`; gating `CanAddWebcam`/`CanChangeWebcam`/`CanShowWebcamInStagedScene`/`IsWebcamSelected`/`WebcamError`; size safeguards `WebcamMax*`/`ClampWebcamToBounds`/`HealLegacySquareRect`; `AddWebcamCommand`/`ChangeWebcamCommand`/`ShowWebcamCommand`. | +| `MainViewModel.Audio.cs` | **Mic bar:** read-only realtime meter `AudioLevel`/`MeterFillWidth`/`MeterBrush` (fill = `Math.Min(1, AudioLevelMeter.ToDisplay((float)AudioLevel) * MicVolume)`, green→yellow→red at 60%/80% zones), `MicVolume` (0.8 default; drives `MicMuted` ⇔ volume 0; unmute flash `BeginVolumeFlash`/`EndVolumeFlash`/`CancelVolumeFlash`), `MicSourceName` (left-justified inside the bar, persists via `LayoutStore.Settings`), `ToggleMicMute`/`PickMicrophone` (`MicPickerDialog`; picked device swaps the live source via `_audioMixer.RestartMic()`, loopback keeps running), status dot `MicStatus`/`MicStatusBrush`/`MicStatusToolTip` (`Models.MicStatus`: green Connected / yellow Problem / red no-device), capture starts once at startup (`StartMicCaptureAsync`) — NOT go-live. **Game audio bar:** overlaid bottom-center, always visible (show/hide gating removed 2026-08-15); `GameAudioLevel` (loopback via `OnLoopbackLevelChanged`), `GameAudioVolume`/`GameMuted`/`GameMuteText`/`ToggleGameMute`/`PushSystemVolume`, mirror volume flash `SetGameVolumeAdjusting`/`BeginGameVolumeFlash`/`EndGameVolumeFlash`/`CancelGameVolumeFlash`. Mixer handlers `OnMicLevelChanged`/`OnLoopbackLevelChanged`/`OnMicConnected`/`OnMicFailed`. | +| `MainViewModel.Trax.cs` | Background music (TASK 9, rides the loopback into the stream): `_musicPlayer` (`MusicPlayer`), `IsMusicPlaying`/`TraxError`/`TraxDotBrush`/`TraxToolTip`, interaction `TraxLeftClick`/`TraxRightClick`/`PickTraxTrack`, playback `LoadTrax`/`PlayTrax`/`PauseTrax`/`OnTraxPlaybackEnded`/`RefreshTraxUi`, `UpdateTraxLocalGain` (mirrors the game bar's mute onto `MusicPlayer.LocalGain` so the track ducks/restores with desktop audio). | +| `MainViewModel.Socials.cs` | Social bar (global resource, overlaid on every scene): `_socials`/`_socialBarFrame`/`_socialValidator` (`ISocialValidator`/`HttpSocialValidator`), `HasSocials`/`SocialBarVisible`/`SocialBarDotBrush`/`SocialBarGlowBrush`/`Socials`/`SocialBarTop`, `OpenSocialDialogCommand`/`OpenSocialDialog` (spawns `SocialsDialogViewModel` + window; saved copy replaces `_socials`), `NotifySocialsChanged`/`RenderSocialBarFrame` (`SocialBarRenderer` → immutable `_socialBarFrame` the frame pump reads), fediverse heal `HealFediverseSoftwareAsync` (static, test-facing) + `HealFediverseSoftwareInBackground`, bar edge toggle `SetSocialBarPosition`/`ToggleSocialBarPosition`. `BarOnBrush`/`BarOffBrush`/`CreateBrush` stay in core (shared tokens). | | `GoLiveViewModel.cs` | Start Stream dialog: **account row** (saved channel shown with Change Account, or Sign in to YouTube; Start gated on `IsSignedIn`/`IsBusy`) + title/description + **locked Visibility (always "Private" — ship step 7, no dropdown)**, start/cancel requests | | **About hub (TASK 11, in `MainViewModel`)** | the About overlay is the in-app creator hub: `ChannelUrl`/`MastodonUrl`/`CoffeeUrl` (public consts, live) + `PremiumUrl` (tabled billing seam — TASK 12 → the greyed "Unlock Premium" button lights up when filled); `OpenLicensesCommand` → `ShowLicenses()` loads the shipped `THIRD-PARTY-NOTICES.txt` into `LicensesText` and sets `IsLicensesOpen` (in-app scrolling panel, never the OS viewer); `BackToAbout_Click` in `MainWindow.xaml.cs` flips it off. | | `ReuseImageViewModel.cs` | Add Image dialog: candidate list (`ReuseImageCandidate`), reuse/new/cancel | @@ -21,69 +33,21 @@ for what the ViewModels drive; base class in [`Helpers/ViewModelBase.cs`](../Hel change, one commit per partial (each commit a roll-back point). **Commit naming:** titles carry the series id (`Commit N/12`), tags are `refactor-commit-N` — e.g. `git show refactor-commit-1`. Every partial lands with a `ViewModels/index.md` -update in the SAME commit. +update in the SAME commit. Each partial's docs live in its table row above; this +tracker is just the code↔commit map. Progress (Commit N/12 · tag): -- **Commit 1/12 · `refactor-commit-1`: Account landed** → `MainViewModel.Account.cs` - (session restore `LoadSavedSessionAsync`, sign-in/out/change account, - `IsConnected`/`AccountAvatarUrl`/`AccountDisplayName`/`AccountStatusLightToolTip`). -- **Commit 2/12 · `refactor-commit-2`: License landed** → `MainViewModel.License.cs` - (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`). -- **Commit 4/12 · `refactor-commit-4`: Background landed** → `MainViewModel.Background.cs` - (background model `EnsureBackground`/`CreateBackground`/`NormalizeBackgrounds`/ - `HealBackgrounds`/`StampDefaultBackgroundAsset`, display picker `Displays` + - `SetBackgroundCapture`, screen capture `ReacquireScreenCaptures`/ - `ResolveAutoCaptureKey`/`RedesignateBackgroundAsync`/`ClearBackgroundCaptureAsync`/ - `OnScreenPreviewBitmapChanged`/`OnFullscreenMonitorChanged`/ - `RefreshBackgroundAutoCapture`/`BackgroundShowDesktop`, preview fns - `ActiveBackgroundImage`/`BackgroundImage`/`UpdateActiveBackground`/ - `UpdateBackgroundImage`/`RefreshSnapshotsAsync`/`LoadBackgroundImage`, - `ChangeCaptureCommand`/`RefreshCaptureCommand`/`SetBackgroundDisplayCommand`/ - `ToggleShowDesktopCommand`/`BrowseBackgroundCommand`). `ShowPreviewPlaceholder` - stays in core. -- **Commit 5/12 · `refactor-commit-5`: Webcam landed** → `MainViewModel.Webcam.cs` - (webcam identity `_webcam`/`ReacquireWebcam`/`SwapWebcamIdentityAsync`, per-scene - configs `AddWebcamToStagedSceneAsync`/`ShowWebcamInStagedScene`, capture lifetime - `OnCameraPreviewBitmapChanged`/`OnCameraFailed`, gating `CanAddWebcam`/ - `CanChangeWebcam`/`CanShowWebcamInStagedScene`/`IsWebcamSelected`/`WebcamError`, - size safeguard `WebcamMax*`/`ClampWebcamToBounds`/`HealLegacySquareRect`, - `AddWebcamCommand`/`ChangeWebcamCommand`/`ShowWebcamCommand`). -- **Commit 6/12 · `refactor-commit-6`: Audio landed** → `MainViewModel.Audio.cs` - (mic bar `AudioLevel`/`MeterFillWidth`/`MeterBrush`/`MicVolume`/`MicMuted`/ - `MicMuteText`/`MicSourceName`/`ToggleMicMute`/`PickMicrophone`, volume flash - `SetVolumeAdjusting`/`BeginVolumeFlash`/`EndVolumeFlash`/`CancelVolumeFlash`, - mic status dot `MicStatus`/`MicStatusBrush`/`MicStatusToolTip`/`MicProblemBrush`, - game bar `GameAudioLevel`/`GameMeterLevel`/`GameMeterFillWidth`/`GameMeterBrush`/ - `SetGameVolumeAdjusting`/`GameAudioVolume`/`GameMuted`/`GameMuteText`/ - `ToggleGameMute`, `PushSystemVolume`, mixer handlers `OnMicLevelChanged`/ - `OnLoopbackLevelChanged`/`OnMicConnected`/`OnMicFailed`, `StartMicCaptureAsync`, - audio fields + `ToggleMicMuteCommand`/`ToggleGameMuteCommand`/`OpenMicPickerCommand`). - TRAX stays in core (next commit). -- **Commit 7/12 · `refactor-commit-7`: Trax landed** → `MainViewModel.Trax.cs` - (`_musicPlayer`/`_music`/`_musicPlaying` + `TraxIdleBrush`, `IsMusicPlaying`/ - `TraxError`/`TraxDotBrush`/`TraxToolTip`, `TraxLeftClick`/`TraxRightClick`/ - `PickTraxTrack`/`LoadTrax`/`PlayTrax`/`PauseTrax`/`OnTraxPlaybackEnded`/ - `RefreshTraxUi`/`UpdateTraxLocalGain`). Core keeps the ctor hookup - (`_musicPlayer.PlaybackEnded += ...`), the `LoadLayout`/`Shutdown`/ - `SaveLayoutNow` call sites, and the Scenes partial's hotkey dispatch into `TraxLeftClick`. - -- 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` ✔ (Commit 3), `Background` ✔ (Commit 4), - `Webcam` ✔ (Commit 5), `Audio` ✔ (Commit 6), `Trax` ✔ (Commit 7), - `Socials` (social bar + dialog), `Streaming` (go-live/record + health + quality tiers + media source pipeline), - `Chat` (YouTube chat + mock preview + chatbox render), `Overlays` (settings/bug/feature/about - panels + license entry). +- [x] **Commit 1/12 · `refactor-commit-1`** — **Account** → `MainViewModel.Account.cs` +- [x] **Commit 2/12 · `refactor-commit-2`** — **License** → `MainViewModel.License.cs` +- [x] **Commit 3/12 · `refactor-commit-3`** — **Scenes** → `MainViewModel.Scenes.cs` +- [x] **Commit 4/12 · `refactor-commit-4`** — **Background** → `MainViewModel.Background.cs` +- [x] **Commit 5/12 · `refactor-commit-5`** — **Webcam** → `MainViewModel.Webcam.cs` +- [x] **Commit 6/12 · `refactor-commit-6`** — **Audio** → `MainViewModel.Audio.cs` +- [x] **Commit 7/12 · `refactor-commit-7`** — **Trax** → `MainViewModel.Trax.cs` +- [x] **Commit 8/12 · `refactor-commit-8`** — **Socials** → `MainViewModel.Socials.cs` +- [ ] **Commit 9/12** — **Streaming** (go-live/record + health + quality tiers + media source pipeline) +- [ ] **Commit 10/12** — **Chat** (YouTube chat + mock preview + chatbox render) +- [ ] **Commit 11/12** — **Overlays** (settings/bug/feature/about panels + license entry) The partial `.cs` files land next to `MainViewModel.cs` in this folder as the split -proceeds; each partial carries its own `using`s and re-declares nothing from core. +proceeds; each partial carries its own `using`s and re-declares nothing from core. \ No newline at end of file