Files
LlamaCasty/ViewModels/index.md
T
gramps 6be062c45f vv webcam Unit 1: the vertical (9:16) tier keeps its OWN camera placement per scene
The vertical tier is a centred band crop of the SAME 1920x1080 master (band x
656.25, w 607.5, h 1080), so the 16:9 camera placement (x~1408) falls outside the
cropped window and the creator is simply not in the vertical stream. Each scene
now keeps its own vertical position, size and shape; switching back to 16:9
restores the desktop layout exactly (the creator's locked table, 2026-10-17).

Geometry only - NO render-path change. The preview and the compositor both read
the element's own X/Y/W/H + ClipShape, so the tier switch swaps the LIVE geometry:
- Models/VerticalCameraLayout.cs (new): the pure math - the band, its two 607x540
  areas, the six spots (top/bottom x left/centre/right), the 32px inset and the
  270x152 default tile (480x270 in the 1080x1920 output) in the bottom-right spot,
  the corner the 16:9 default webcam uses.
- Models/WebcamSceneConfig.cs: the vertical placement lives in seven additive
  columns (VerticalX/Y/Width/Height/ClipShape/RectWidth/RectHeight; NULL = "this
  scene has no vertical placement yet"). The 16:9 layout stays in the old columns
  and is parked in the transient Landscape* fields while the vertical tier is
  live; Begin/EndVerticalLayout + CommitLiveLayoutToVertical do the swap. The
  X/Y/W/H overrides clamp every write into the band - outside it the camera is
  invisible in the vertical output.
- Models/SceneElement.cs: X/Y/Width/Height became virtual so the derived element
  constrains every writer (drag, resize handle, typed fields) in ONE choke point
  instead of three writers each re-implementing the rule.
- ViewModels/MainViewModel.Webcam.cs + .Streaming.cs: IsVerticalTier/VerticalBand,
  ResolveVerticalCameraLayout(s) (the tier swap), MoveWebcamInVerticalTier /
  SnapWebcamInVerticalTier (the six-spot snap, by the tile's centre),
  EnsureVerticalCameraLayout; ApplyStreamQuality is the tier-change hook. The
  Live scene gets its small tile automatically on the first vertical switch (the
  vertical stream would otherwise have no camera at all); the other scenes keep
  their 16:9 placement until the creator drags them into the band.
- Controls/PreviewPane.xaml.cs: the drag/resize route through the VM snap under
  the vertical tier instead of free-floating over the master.
  Controls/LeftPanel.xaml: the webcam properties gained a Geometry section
  (X/Y/W/H) for the manual entry the creator asked for, bound to the LIVE
  placement, gated on the VM's IsWebcamSelected (NOT SelectedElement.IsWebcam -
  the TASK 51 test finds that panel by binding text and would match twice).
- The store: seven guarded ADD COLUMNs, the INSERT/SELECT extended, and the
  X/Y/W/H/ClipShape columns stay the 16:9 layout - while the vertical tier is
  live the save writes the parked values there and the live ones into Vertical*.

Test: ytLive.Tests/VerticalWebcamLayoutTests.cs - the ONE Good Dog test. The
vertical tier brings the Live camera INTO the band as a small tile; dragging it to
the top-right lands it top-right inside the band; back to 16:9 the desktop layout
is restored exactly (1408/778/480x270); both placements survive a save + reload.
Full suite 429/429, 0 warnings. LayerReorderPersistenceTests.RealMouseDrag fails
ALONE on a clean tree in this environment - pre-existing flake, proven by stashing
this work, not a regression.

Derivative work: OBS has no built-in per-aspect-ratio layout storage - the
maintainers' answer is "create a second scene and add the scene you want to send
as source" (obsproject.com/forum/threads/161307) - and Streamyard's dual-aspect
guide says the same ("no built-in way to store separate layouts per aspect ratio",
so you rely on manual workarounds and plugins):
https://streamyard.com/blog/dual-aspect-ratio-streaming-tool
Our per-scene, per-tier stored placement, resolved on the tier change, is the
step past both.

Memory in the same commit: ai.md (new section), TASKS.md + TASKS/task-57-*.md
(Unit 1 shipped, Unit 2 = the Chat scene still open), HANDOFF.md, MyMistakes.md
(the per-tier geometry-swap recipe, the binding-text panel trap, the store's
webcam-identity rule) and the Models/Services/ViewModels/Controls index rows.
2026-10-21 08:58:24 -07:00

26 KiB
Raw Blame History

ViewModels — index

MVVM layer. See 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 Core. public partial class MainViewModel : ViewModelBase. 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. Lifecycle/ctor wiring: fields + ctor wiring (all command assignments, mixer/camera/chat/hotkey/subscription hookups), Shutdown/SaveLayoutNow/ScheduleSave/LoadLayout, CreateBrush + shared BarOnBrush/BarOffBrush (referenced by Audio/Trax/Socials partials), window-geometry pass-through, SelectedElement/OnSelectedElementPropertyChanged, ShowPreviewPlaceholder, BrandFlash*, WebView2 hosting (InitWebView2/RegisterLoadedWebSources/OnWebView2PreviewBitmapChanged), frame-pump wiring (construction + _rtmpUrlProvider seam + Dispose, plus ResolveOutputFrame → RenderChatBox/RenderAlertBox for chat + alert layers) — the pump's stream-state consumers live in the Streaming partial.
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.Zoom.cs Zoom box (TASK 52, creator-corrected 2026-10-08) ActivateZoom/ToggleZoom/DismissZoom/StartZoomMove/UpdateZoomMove/EndZoomMove + RefreshZoomUi/RebuildDimRectsForZoom. Right-clicking the live preview anchors a FIXED capture window (the box/2 region centred on the click) and parks a movable 2x magnifier. The box shows a live always-on 2x view of the anchored capture; dragging moves only the box — CaptureX/Y are deliberately untouched in UpdateZoomMove (creator ruling). Each shape is centred on the click and clamped independently (box to the master, capture to box/2 windows): Circle = square, VerticalRectangle = 9:16, HorizontalRectangle = 4:3 (w=h·4/3 with h=0.7·1080; NOT 16:9 — first cut read as a square). The transient ZoomSceneElement mirrors box + capture; the output compositor draws it LAST (layer n+1) from the already-composed frame (SceneCompositor.BlitZoom→ZoomMagnifier) so it captures EVERY layer. No per-frame builder/cache — the resolver case that built ZoomFrameBuilder frames was removed. BuildCompositorOptions (internal = test seam) always returns the tier crop — the box is never the crop source.
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. TASK 57 (the "vv" feature): the vertical (9:16) tier's camera placement — IsVerticalTier/VerticalBand + ResolveVerticalCameraLayouts/ResolveVerticalCameraLayout (the tier swap: the vertical tier brings each scene's own vertical placement in, the Live scene gets its small tile on first use, 16:9 puts the desktop layout back exactly) + MoveWebcamInVerticalTier/SnapWebcamInVerticalTier (the six-spot snap) + EnsureVerticalCameraLayout.
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).
MainViewModel.Streaming.cs Go-live/record + health + quality tiers + session UI. Stream state: StreamStatus/StreamVisibility setters + _streamStatus field, IsOffline/IsLive/IsLivePrivate. Go-live flow: StartSession (routes by pill state) → BeginRecordOnly (signed-out local recording) or BeginGoLive (GoLive dialog → captures title/description/visibility + BroadcastForm.CaptureGoLive → PrepareAndStartLiveAsync: reusable stream → CreateBroadcast (stores _currentBroadcastId) → empty-metadata warn → chat poll start (liveChatId) → _framePump.StartAsync() + health poll; failures → StreamStatus.Error, never a crash); StartRecordFile (auto-named ty-…-0000.mp4) + FinalizeRecordingAsync (rename modal, UniquePath); StopStream (posts Offline, stops chat/audio pipe/pump, clears _currentBroadcastId; session/token deliberately NOT cleared — sign-out stays explicit). Health: CurrentHealth/ResetHealth/ApplyHealth/OnFramePumpHealthUpdated (marshal FramePump.HealthUpdated off the stderr thread), PollHealthAsync/OnHealthPollTick/ApplyHealthIssue (report-by-exception banner HealthIssueBanner/HealthIssueBackground via StreamHealthReporter). Session timer: _liveTimer (ctor-wired) UpdateSessionTimer/OnLiveTimerTick/LiveElapsedText/_recDotPulse. Start/End UI: mode switch RecordPillOn/OnAirPillOn, top-bar worlds (ShowIdleWorld/ShowRecordAction/ShowStreamActions/ShowSignInButton/ShowAvatarButton, reality line RunningVisible/RunningText/RunningDotBrush, Test gate ShowTestButton/CanStartTest — RaiseTopBarModes()), ShowPrimaryStartButton/CanStartSession/ShowEndStreamButton/EndButtonText, REC sign RecDotBrush/RecTextBrush/RecDotOpacity/OnAirBrush, live window tokens WindowTitle/TopBarBackground/PreviewGlow* via UpdateLiveVisuals; ChooseRecordFolder/ResetRecordFolder/_recordFolder/RecordFolderDisplay/DefaultRecordFolder. Resolution dropdown: QualityOptions/SelectedQuality/ResolutionHelp/ApplyStreamQuality + OutputRect*/DimRects/IsOutputCropped/ResolutionBadgeText over the 1920×1080 master; TASK 57: the tier change also resolves every scene's camera placement (the vertical tier brings the vertical placements in, 16:9 restores the desktop layout — see MainViewModel.Webcam.cs). Pipeline bits: _framePump/_liveTimer/_healthPollTimer fields, BuildEncoderOptions. Commands StartStreamCommand/EndStreamCommand/ChooseRecordFolderCommand/ResetRecordFolderCommand. Broadcast pull-out: CurrentBroadcastId/BroadcastForm.
MainViewModel.Overlays.cs Settings/bug/feature/about panels + creator-hub links: panel state IsSettingsOpen/IsBugOpen/IsFeatureOpen/IsAboutOpen (via SetPanel → IsAnyOverlayOpen), IsLicensesOpen (flips IsHubVisible), OverlayTitle, ShowOverlay (one-panel exclusivity) + ShowLicenses (loads shipped THIRD-PARTY-NOTICES.txt into LicensesText on first open, never the OS viewer); bug/feature report BugReportText/BugReportEmail/FeatureRequestText/FeatureRequestEmail + SubmitBug/SubmitFeature/ComposeEmail/OpenUrl; About hub consts ChannelUrl/MastodonUrl/CoffeeUrl (public, integration-test live) + PremiumUrl (tabled billing seam — TASK 12 → greyed "Unlock Premium" lights up when filled) + IsPremiumAvailable + SupportEmail + AppVersionLabel; commands OpenSettingsCommand/OpenBugCommand/OpenFeatureCommand/OpenAboutCommand/CloseOverlayCommand/SubmitBugCommand/SubmitFeatureCommand/OpenChannelCommand/OpenMastodonCommand/OpenCoffeeCommand/OpenPremiumCommand/OpenLicensesCommand. Ctor command assignments stay in core.
MainViewModel.Chat.cs Chat overlay + preview pipeline: ChatMessages (500-message cap; clear-on-fade), OnChatMessageReceived (marshal _youtubeChat.MessageReceived onto the dispatcher, switch previews mock→real, then feed both component layers), fade _chatFadeTimer/ResetChatFadeTimer (shortest ChatFadeSeconds across chat layers); live render RenderChatBox (called from ResolveOutputFrame, stays in core) + preview render UpdateChatBoxPreview (bitmap onto chatBox.VideoImageSource); mock preview MockChatMessages/StartMockChatPreview/StopMockChatPreview/RunMockChatPreviewAsync/_mockChatCts; UpdateActiveChatBoxPreviews (wires Chat* property-change subscriptions + first render); CanAddYouTubeChat (one-per-layout gate), ShowChatInactiveMessage (!IsLive). Alerts (TASK 43): same facade for the native alert box — _alertLayer (AlertOverlayLayer), RenderAlertBox/UpdateAlertBoxPreview, UpdateActiveAlertBoxPreviews, CanAddAlerts (one alert zone per layout). Ctor wiring (MessageReceived hookup, new ChatBoxRenderer(), new AlertRenderer(), LoadLayout preview start, _youtubeChat.Dispose + _alertLayer.Dispose) stays in core.
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.Overlays.cs) 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
CameraPickerViewModel.cs Add Webcam dialog: async camera list (loading / has / none states), UseRequested(CameraDeviceInfo)/CancelRequested
MicPickerViewModel.cs Select Microphone dialog (voice source): async mic list (loading / has / none states), UseRequested(MicrophoneDeviceInfo)/CancelRequested
SocialsDialogViewModel.cs Social Media Site Promotion dialog (six fixed slots, SocialSlotViewModel): row 0 = YouTube sign-in gate (sign-in provider fills the channel, delete = sign-out); rows 1-5 validated entries / empty / freemium locks 2-5 (isPremium seam); DetectService → ISocialValidator on confirm (input snaps to text + real logo on success, red do-not + stays editable on failure); CanSave blocked while any slot edits/validates/errors; BarEnabled switch; commit via CommittedEntries + BuildSocialsConfig() (YouTube first) + SaveRequested/CancelRequested. WPF-free — the window owns the dialogs (delete confirm, sign-in gate) and calls StartEdit/ConfirmEdit/DeleteSlotAsync.

Related: Models/index.md for the data; Services/index.md for what the ViewModels drive; base class in Helpers/ViewModelBase.cs.

MainViewModel partial split (in progress)

MainViewModel.cs is being split into one file per functional area, zero behavior 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. 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 → MainViewModel.Account.cs
  • Commit 2/12 · refactor-commit-2 — License → MainViewModel.License.cs
  • Commit 3/12 · refactor-commit-3 — Scenes → MainViewModel.Scenes.cs
  • Commit 4/12 · refactor-commit-4 — Background → MainViewModel.Background.cs
  • Commit 5/12 · refactor-commit-5 — Webcam → MainViewModel.Webcam.cs
  • Commit 6/12 · refactor-commit-6 — Audio → MainViewModel.Audio.cs
  • Commit 7/12 · refactor-commit-7 — Trax → MainViewModel.Trax.cs
  • Commit 8/12 · refactor-commit-8 — Socials → MainViewModel.Socials.cs
  • Commit 9/12 · refactor-commit-9 — Streaming → MainViewModel.Streaming.cs
  • Commit 10/12 · refactor-commit-10 — Chat → MainViewModel.Chat.cs
  • Commit 11/12 · refactor-commit-11 — Overlays → MainViewModel.Overlays.cs
  • Phase 1 complete: core MainViewModel.cs split into 10 functional partials (1,890 → 758 lines).

Phase 2 — MainWindow.xaml split into UserControls (new Controls/ folder, namespace ytLive.Controls):

  • Commit 12 · refactor-commit-12 — BottomBar → Controls/BottomBar.xaml(.cs) (gear menu + stream stats + quality dropdown; GearButton_Click moved to the control's code-behind).
  • Commit 13 · refactor-commit-13 — HealthBanner → Controls/HealthBanner.xaml (Row 1). Also promoted the 7 value↔visibility converters out of MainWindow.Window.Resources into Themes/Controls.xaml so extracted controls can resolve them (app-level, not window-level).
  • Commit 14 · refactor-commit-14 — TopBar → Controls/TopBar.xaml(.cs) (Row 0: logo, REC/ON-AIR pills + signs, avatar, Sign In/Start/End buttons). Avatar fetch moved (async-preserved) into the control; window exposes _topBar.RefreshAvatar() invoked on IsConnected/AccountAvatarUrl changes.
  • Commit 15 · refactor-commit-15 — PreviewPane → Controls/PreviewPane.xaml(.cs) (Row 1 Col 1 center preview pane). Moved the 1920×1080 canvas (OverlayCanvas/SelectionOverlay/BrandFlashLayer/SocialBarElement), text pull-out, and the live-controls row (TRAX/game/mic meters + sliders + speakers + socials button). Code-behind moved: preview drag/select/resize/hit-test (Preview_Mouse*, HitHandle, HitElement), webcam-context-menu handlers (WebcamMenu_), social-bar glow (SocialBar_, SocialsButton_, SocialGlowTick), audio sliders/speakers (VolumeSlider_, GameVolumeSlider_, MicSpeaker_, GameSpeaker_, SetSliderValueFromClick, FindParent), TraxButton_. Window keeps the source-list left panel + thumbnail strip + edit UI, and routes selection-overlay via _previewPane.UpdateSelectionOverlay(bool) plus _previewPane.IsClickInsidePreview/Drawer. BackgroundMenu_* stayed in the window (used by the left-panel source context menu).
  • Commit 16 · refactor-commit-16 — SceneThumbnailStrip → Controls/SceneThumbnailStrip.xaml(.cs) (Row 0 scene-picker strip). Thumbnail_MouseLeftButtonDown moved into the control (transitions via DataContext.TransitionToScene).
  • Commit 17 · refactor-commit-17 — LeftPanel → Controls/LeftPanel.xaml(.cs) (Row 1 Col 0 left panel: layers/properties edit UI + live chat list). Moved ALL property-editor and chat handlers: SourceNameBox_*/CommitSourceEdit, AddSourceButton_Click, OnChatMessagesChanged (now self-subscribed on Loaded), MirrorButton_Click, ShapeButton_Click, SourceMenu_Remove, BackgroundMenu_*, OpacitySlider_ValueChanged, list drag-to-reorder (_dragStartPoint+List_Preview*+EndListDrag+FindItemContainerAt), and the snapshot family (TakeSnapshot, WebcamBorder*, Countdown*, Web*). Window exposes via _leftPanel.IsClickInside(original) (deselect guard) and _leftPanel.OnSelectionChanged(vm) (opacity label + snapshot). Pending: overlay host region.
  • Commit 18 · refactor-commit-18 — OverlayHost → Controls/OverlayHost.xaml(.cs) (settings/bug/feature/about dialogs). BackToAbout_Click moved with it (uses inherited DataContext). Window keeps the thin WebViewHostPanel + ToastArea root stubs so name lookups are untouched (WebView2 ctor init, NotificationService.InArea("ToastArea")) — toast/WebView2 behavior unchanged. Phase 2 COMPLETE — MainWindow.xaml carved down to a 123-line shell over 6 controls (TopBar, HealthBanner, BottomBar, SceneThumbnailStrip, LeftPanel, PreviewPane, OverlayHost). Pending: session wrap-up (HANDOFF.md) + push on approval. (The WebViewHostPanel root stub is since GONE — the 2026-09-14 composition-capture redesign moved InitWebView2() to MainWindow_Loaded and deleted the panel; see Controls/index.md.)

Phase 3 — 500-line compliance (production .cs only). Bring all production source files under 500 lines. One roll-back commit per file; same drill (0-warning build, verify.sh 246+2, explicit-file scope-check, tracker in same commit, tag refactor-commit-N, no per-commit push).

  • Commit A · refactor-commit-A — core → MainViewModel.cs 758 → 495. Moved source add/remove/image block to new MainViewModel.Sources.cs (234 lines: AddSource, NextSourceName, AddImage, BuildImageCandidates, PickImageBytes, AddAsset, AddImageSource, AddReusedImage, RemoveElement) and WebView2 hosting (RegisterLoadedWebSources, InitWebView2, OnWebView2PreviewBitmapChanged) to new MainViewModel.Web.cs. Pruned now-dead usings (Microsoft.Win32, NAudio.CoreAudioApi, System.Windows.Media.Imaging, Collections.ObjectModel/Specialized). Pending: Streaming, Background, SocialsDialogViewModel, LayoutStore.
  • Commit B · refactor-commit-B — Streaming → MainViewModel.Streaming.cs 789 → 330 + new MainViewModel.Streaming.Operations.cs (459 lines). Moved the go-live/record/health operation bodies (StartSession, BeginRecordOnly, BeginGoLive, StartRecordFile, PrepareAndStartLiveAsync, PollHealthAsync/OnHealthPollTick/ApplyHealthIssue, StopStream, FinalizeRecordingAsync, UniquePath, ChooseRecordFolder, ResetRecordFolder, DefaultRecordFolder, OnFramePumpFailed, OnFramePumpHealthUpdated, ApplyHealth, ResetHealth, BuildEncoderOptions) into the Operations partial; Streaming.cs keeps state + quality/resolution tiers + session timer + command declarations. Pruned dead usings both files. Pending: Background, SocialsDialogViewModel, LayoutStore.
  • Commit C · refactor-commit-C — Background → MainViewModel.Background.cs 567 → 418 + new MainViewModel.Background.Model.cs (163 lines). Moved the static scene-model helpers (LoadBackgroundImage, CreatePlaceholderSnapshot, EnsureBackground, CreateBackground, NormalizeBackgrounds, AllBackgrounds, ResolveAutoCaptureKey, MonitorKeyPrefix) into the Model partial; Background.cs keeps capture/live state + operations (UpdateActiveBackground, UpdateBackgroundImage, RefreshSnapshotsAsync, ReacquireScreenCaptures, OnScreenPreviewBitmapChanged, OnFullscreenMonitorChanged, RefreshBackgroundAutoCapture, BrowseBackground, ClearBackgroundCaptureAsync, RedesignateBackgroundAsync, ChangeBackgroundCaptureAsync, SetBackgroundCapture, DefaultBackgroundAsset, HealBackgrounds, StampDefaultBackgroundAsset). Pending: SocialsDialogViewModel, LayoutStore.
  • Commit D · refactor-commit-D — SocialsDialog → SocialsDialogViewModel.cs 515 → 334 + new SocialSlotViewModel.cs (183 lines). Moved the DialogEntry record + the row-level SocialSlotViewModel (six-fixed-slot row VM: service/logo/lock/edit/validate state + computed Show* flags) into its own file; SocialsDialogViewModel.cs keeps the dialog VM (sign-in gate, validation orchestration, commit). DialogEntry stays public in the same namespace so both files see it. Pending: LayoutStore.
  • Commit E · refactor-commit-E — LayoutStore → LayoutStore.cs 1402 split into 6 concern-based partials: LayoutStore.cs (47: shell — Instance, fields, ctor + EnsureSchema, GetUserVersion, Dispose), LayoutStore.Migrations.cs (495: all Migrate* + EnsureSchema), LayoutStore.Load.cs (221: Load), LayoutStore.Save.cs (339: Save), LayoutStore.Settings.cs (292: mic/record-folder/reusable-stream + license/transition/broadcast/hotkey/window GetSetting/UpsertSetting), LayoutStore.Assets.cs (41: GetAssetBytes/UpsertAsset). PHASE 3 COMPLETE — no production .cs over 500.
  • Commit F · refactor-commit-F — Recording (context/concern) → new MainViewModel.Recording.cs (121 lines). The encoder (Services/Encoder/FfmpegEncoder.cs) and frame pump (Services/Encoder/FramePump.cs) were ALREADY extracted services — the planned "extract FFmpegEncoder/StreamHealthMonitor/FramePump" was a no-op premise. The honest functional seam was the local recording-output concern: StartRecordFile, FinalizeRecordingAsync, UniquePath, ChooseRecordFolder, ResetRecordFolder, DefaultRecordFolder + fields _recordFolder/RecordFolderDisplay/_activeRecordPath/_recordStartTime/_recordLength moved out of the live-stream orchestration. MainViewModel.Streaming.Operations.cs 475 → 377; MainViewModel.Streaming.cs 328 → 323. Same partial class, MVVM glue untouched.
  • Commit G · refactor-commit-G — ChatOverlayLayer (true component) → new Services/ChatOverlayLayer.cs (199 lines). The first REAL decomposition of the 4,105-line god-object: MainViewModel.Chat.cs 194 → 44 (thin delegation). The layer owns all chat behavior — Messages buffer, ChatBoxRenderer, fade timer, mock-preview timer, per-source preview renders, live-output RenderFrame. The VM keeps only the binding surface: ChatMessages (delegates to _chatLayer.Messages, so XAML bind + LeftPanel CollectionChanged hold), CanAddYouTubeChat/ShowChatInactiveMessage (computed + OnPropertyChanged from VM setters), and thin forwards. Scenes are handed in as args (no Func seam) so the layer stays scene-graph-free. Zero behavior change; 246 pass / 2 known. The partial .cs files land next to MainViewModel.cs in this folder as the split proceeds; each partial carries its own usings and re-declares nothing from core.