Current-OAuth-scope data, no re-consent: subscriber bars for both tiers (500 fan-funding /
1,000 ad-revenue; the 2027-02-01 doubling is versioned date-aware data in YppThresholds),
3-uploads/90d, live standing from channels.list auditDetails, self-reported 2FA/AdSense
checkboxes + deep links (Google 2-Step / youtube.com/earn), and the "what counts" education.
Every refresh appends a YppSnapshot (SQLite v10) so the analytics slice has history from day one.
- ChannelStatsService (channels.list statistics,auditDetails,contentDetails + playlistItems.list,
virtual CaptureCurrentAsync = test seam); YppTrackerViewModel mirrors LiveBroadcastFormViewModel.
- MainViewModel.Ypp.cs: Ypp property, ChannelStatsFactoryOverride seam, one-open-at-a-time drawer
exclusivity (both directions); account sign-in/out/restore hooks in Account.cs.
- PreviewPane.xaml: TextPullOutHost is now a drawer bank [broadcast][ypp][stacked white tabs];
outside-click collapse covers both drawers. MainWindow.xaml.cs updated.
- Wait - watchers: the whole feature already reviewed against YouTube's own docs (YPP Earn tab +
API availability) before building — see TASKS/task-39-ypp-journey-tracker.md.
- ONE integration test: YppPullOutTests (snapshot capture/persist + checklist round-trip +
drawer exclusivity). 313/313 pass, 0 warnings.
- Memory in same commit: TASKS.md row, ai.md Journey-tracker bullet (roadmap→reality), HANDOFF.md.
Slice 2 (Analytics yt-analytics.readonly re-consent → watch-hours/Shorts + velocity/ETA +
sparkline) is queued behind this, not merged.
Click-to-pin an open window as the full-bleed Live backdrop, via the existing
window:<hwnd> capture path (same render layer as game/desktop). Session-scoped:
window:/picker: keys heal to auto on reload (HWND-recycle hazard); pin wins
while the window enumerates, then auto-fallbacks (game → desktop → static); a
dead window's capture session heals to auto, no dead ends.
Fix: ScreenCaptureSourceFactory.Resolve hex-parsed "window:0x<Hwnd>" WITHOUT
stripping the 0x prefix — NumberStyles.HexNumber rejects it, so every pin
resolved null ("No capture target for this key") and the heal silently rolled
back. Same flaw in IsAliveWindowPin (pin-wins guard was dead). Both fixed.
Commit also carries an out-of-scope prerequisite: PillRadioTests.cs:105 had a
committed stray token ("...PrimaryStartButtonLabel soil;", CS1003) blocking the
whole test-project compile — token removed.
Plus the previous session's uncommitted pricing docs (one-time $29/$49) that
share TASKS.md/ai.md/HANDOFF.md.
The record-OR-live ruling (2026-09-01) was documented in ai.md but the
'pills = radios' UI constraint never landed — both pills could be armed and
StartSession/BuildEncoderOptions would dual-encode to the YouTube ingest AND
a local file at once, streaming a concurrent disk write off the same ingested
Kbps (cripples the stream on mid-range chassis; 'the VOD is already the copy').
The pill setters now clear one another (silent mutual clear); the dual-block
ffmpeg machinery stays generic and unreachable from the UI by design.
Reorder: OnSceneElementsReordered now calls SaveLayoutNow() (was debounced
ScheduleSave) so a drag-drop persists instantly — one write stores the new
layer list (Source.SortOrder) and each layer's preview geometry (X/Y/W/H) in
the same rows. Reference: WPF ListBox drag-reorder requires an explicit persist
at drop; a debounce window lets a crash lose the drop.
Recording save dialog (RenameRecordingDialog.xaml): height 276→304 so the
Cancel/Save button row is no longer obscured; Save button named
SaveRecordingButton so the sizing test can assert it sits inside the client area.
Tests: deterministic seam test now asserts the dragged layer's geometry (preview
layout) is persisted with the new order; sizing test asserts 304 + textbox and
button-row bottoms within client area. 308/308 green.
RenameRecordingDialog was 230px tall for ~225px of stacked content —
the file-name textbox clipped (old client area ~193px). Height is now 276
(+20%). Integration test shows the real dialog in the app host and asserts
the textbox bottom edge stays inside the client area; the dialog's Icon moved
to the explicit /ytLive;component/ URLC form MainWindow already uses so tests
can construct it (root-relative /Assets/... only resolves in production via
Application.ResourceAssembly).
Dependency fix: MainViewModel.Shutdown() now calls
_fullScreenDetector.StopWatching(). The global EVENT_SYSTEM_FOREGROUND hook
was never unhooked; after VM collection the next foreground event invoked a
garbage-collected WinEventProc delegate, crashing the whole test run. Harmless
in production (exits the process) but fatal in the multi-window test host.
Reference: WPF WinEvent hook lifecycle guidance — SetWinEventHook callbacks
must be unhooked before the owning object is collected (obs-projector
fullscreen-detection pattern).
List_PreviewMouseMove reorders StagedScene.Elements via RemoveAt/Insert,
bypassing SceneGraph's mutation surface, but never scheduled a save — the
new z-order was lost on restart. The drop now sets _dragReordered and
EndListDrag funnels it through MainViewModel.OnSceneElementsReordered()
(InvalidateBake + ScheduleSave, same background-save path as every other
mutation). Integration test (RealApp + temp DB) reproduces the exact
code-behind mutation and asserts the debounced save lands the new Source
SortOrder. Derivation reference: standard WPF ItemsControl drag-reorder
pattern (OBS layering semantics: bottom-most layer = index 0).
Creator refinement: 'offered iff there's not one already configured & attainable'.
CanAddWebcam now requires IsWebcamAttainable = identity present AND a RUNNING
session (CameraManager.IsRunning) — an identity whose camera was unplugged or
whose lock keeps failing leaves the row greyed with reason 'No webcam is
currently available…', and it un-greys the moment a session is live. Gate
re-raised at every attainability flip: staging, removal, startup lock success,
first frame, camera failure, identity swap.
Root cause the old test surfaced: the startup pass skipped acquiring when the
loaded identity's configs already held the session, so there was no independent
app base ref — removing the last placement dropped RefCount to 0 and killed the
session. The single-camera branch now ALWAYS acquires (a running session just
bumps), laying the app-wide base hold so the default outlives the scenes.
Good Dog: WebcamMenuGateTests second fact — identity loaded, session can't start
→ row NOT offered + 'No webcam is currently available…' tooltip. Positive fact
waits for WebcamStartupValidationTask to make the IsRunning read deterministic.
Docs same commit (ai.md gate + base-lock, TASKS.md, HANDOFF.md incl. proven
pre-existing audio flake). 305 tests (304 pass + known flake), 0 warnings.
Chat's camera no longer greys Web Cam in Live (per-scene max, not app-wide);
TASK 26 superseded by creator directive (app-level resource model). Add Webcam
now places the existing app default without the picker; the picker runs only
for the initial selection. Removing the last placement keeps the identity
(_webcam never nulled) so Add stays offered. Startup pass adopts a solo camera
as the app default, so a clean layout offers the layer in Live/Chat at once.
Dynamic WebcamAddToolTip names the why (incl. the 'graduated to OBS' line).
Good Dog: WebcamMenuGateTests rewritten (real app + temp DB + camera seams) —
identity in Chat does not gray Live; Add in Live places same wc-1 no picker;
scene-with-placement stays gray; identity survives both removals (DB row 1).
WebcamStartupResourceTests single-lock fact asserts CanAddWebcam after adoption.
Docs same commit (ai.md supersession, TASKS.md, HANDOFF.md). 304/304, 0 warnings.
The creator couldn't add a webcam to Live (grayed app-wide) and nothing in the
app explained why. Ground truth from the live DB: one Webcam identity AND one
WebcamSceneConfig in the Chat scene — so the gray was the single-identity rule
working, but the reason was unobservable. This slice makes the webcam a
resource the app validates and locks, mirroring how OBS reserves its devices.
At startup we enumerate the OS once (ValidateWebcamResourceStartupAsync, fired
after LoadLayout, stored as WebcamStartupValidationTask for tests to await):
- 0 webcams -> app runs on, layer inactive, no alarm
- exactly 1 -> attempt CameraManager.AcquireAsync as an app-wide lock; on
failure show a persistent red alert at the bottom of the Layers panel
(WebcamLockAlert + Retry) that re-polls every 5s and clears itself the
moment the camera locks, or on any first real frame
- >=2 -> deliberately no auto-lock; camera selection belongs to the App
Settings dialog (gear) — next slice
CameraManager.IsRunning(deviceId) tells the pass a session already exists
(started OR still starting) so loaded identity configs count as the lock and
the pass never double-acquires. Test seams mirror LayoutPathOverride:
CameraEnumeratorOverride / CameraFrameSourceFactoryOverride so the startup
probe never touches real hardware under test.
Good Dog test: WebcamStartupResourceTests x3 — single-cam locked + app runs on,
zero-cams no-alarm/no-lock, lock-fails -> red alert -> Retry -> clears. Full
suite 304/304, build 0 warnings, scope-check passed.
Docs in-commit: TASKS Open items + ai.md Webcam section + HANDOFF rewrite.
Also corrects the record: 'NVIDIA Broadcast opens the webcam exclusively' was a
suspect-list claim (CameraConflictProbe reads process names only, no handles)
— not restated as fact.
The ~30Hz CapturePreviewAsync PNG poll capped real cadence at ~20Hz
(35–165ms full-HD encode+decode), so a 60fps widget still juddered at ~1/6
speed. Replaced polling with frame-driven capture of the composition
controller's root visual — the mechanism WebView2CompositionControl and
Flutter's webview_windows use (graphics_context.cc captures the root
surface_ visual via CreateGraphicsCaptureItemFromVisual; reference:
github.com/microsoft/Windows.UI.Composition.WinUI / flutter-internal
webview_windows). Frames now arrive at the renderer's own pace; capture
memory is epoch'd ring reuse + one crop-sized shared WriteableBitmap.
New Services/WebCaptureFrameSource.cs owns GraphicsCaptureItem + free-
threaded Direct3D11CaptureFramePool + session (Straight alpha readback,
per-frame FindContentBounds → CropBounds). WebView2Manager reworked around
per-session composition controllers + one UI-thread Compositor created via
the CoreMessaging CreateDispatcherQueueController P/Invoke (the 19041
projection lacks CreateOnCurrentThread); internal seam ctor
(Dispatcher, Func<string,IScreenCaptureSource>?) for hermetic tests.
CaptureScheduler.cs deleted; the three SetCaptureInterval cadence hooks
removed; InitWebView2() moved from MainWindow ctor to Loaded (a parent
HWND must exist for the composition controller); the hidden WebViewHostPanel
overlay deleted. TransparentBackgroundScript unchanged.
Tests: WebView2ManagerTests reworked — 4 control-size + scheduler tests
dropped, FindContentBounds tests moved to WebCaptureFrameSource, ONE
integration test (Frames_PublishCroppedPreview_And_CoalesceToLatest_CarryingCropBounds)
drives the seam with a FakeWebSource + background-STA DispatcherPump.
Suite 293/293, 0 warnings.
NOTE: composition path NOT yet verified on a device — the take is the
next step. Web work committed locally only (no push per standing rule).
Docs same-commit: ai.md Slice 14 + supersede marker on Slice 11, HANDOFF,
MyMistakes (CoreMessaging DQ + namespace-landmine recipe), TASK 17,
Controls/ViewModels/Services indexes.
Positive offsets still delay the whole mix via the delay line (lip-sync fix);
negative offsets now ARM once at StartLive and drop |N| ms off the pipe's write
head so audio events land earlier when audio runs BEHIND video. Slider relabeled
AUDIO SYNC, Min −500, locked while live/recording (IsEditMode). LayoutStore and
VM clamp to −500..500.
OBS reference for eat-the-head negative sync: https://obsproject.com/kb/obs-studio/buffering-time (negative sync values pull audio earlier by discarding buffered player audio).
Test: StartLive_NegativeOffset_AdvancesAudio_ByDroppingTheStreamHead (6x0.9 head
must be eaten before 0.2 bed reaches the wire).
The recording is 60fps but WebView2 capture was a blind 100ms DispatcherTimer = 10Hz;
each captured web frame repeated ~6x into the file caps web animation at the capture
rate, not the page's (user: 'the animation appears to be too slow').
- New CaptureScheduler (Services/CaptureScheduler.cs): per-session dispatcher timer
that DROPS a tick while a capture is in flight (latest-wins, never queues) — the
guard that makes a higher cadence safe: concurrent full-HD PNG CapturePreviewAsync
calls (~10-30ms each, slow per WebView2Feedback#20) would stack CPU and publish
stale-after-fresh. Effective cadence = max(interval, capture duration).
- Cadence: SetCaptureInterval(33) on record/stream start, (200) idle — applied via
MainViewModel.Streaming.Operations.cs.
- De-throttle the hidden page: shared CoreWebView2Environment created BEFORE
EnsureCoreWebView2Async with --disable-backgrounding-occluded-windows
--disable-renderer-backgrounding --disable-features=CalculateNativeWinOcclusion.
Off-screen WebView2 is a hidden page when the host window is unfocused/covered and
Chromium then parks rAF and clamps timers to ~1s (WebView2Feedback#1172/#3070,
Chrome-88 timer-throttling blog).
- Telemetry: first 30 captures per session log elapsed ms (PNG encode + decode) to
startup.log — that decides whether ~30Hz stays or drops to ~20Hz; the FramePump
drops frames (never time-lapses, slice 10) if UI-thread GC churn starves it.
- ONE test: CaptureScheduler_Drops_Ticks_While_Capture_InFlight_And_Resumes
(deterministic TCS-driven, no WebView2 runtime). Suite 290/291 — sole failure the
pre-existing compositor pixel test.
- Docs same-commit: ai.md slice 11, MyMistakes.md, HANDOFF.
References: https://github.com/MicrosoftEdge/WebView2Feedback/issues/1172https://github.com/MicrosoftEdge/WebView2Feedback/issues/3070https://github.com/MicrosoftEdge/WebView2Feedback/issues/20https://developer.chrome.com/blog/timer-throttling-in-chrome-88
Take two (2026-09-01) three confirmed defects, all from creator feedback + the
new resolver logging:
1. NO WEBCAM IN OUTPUT: WebcamSceneConfig.WebcamId carries the identity GUID;
CameraManager keys sessions by DEVICE id — GetLatestFrame(guid) returned null
forever, the compositor silently dropped the layer while the preview (bitmap
path) looked fine. The resolver logging added in 8dcaee0 caught it red-handed.
DeviceKeyForWebcam maps identity->device through the _webcam singleton (identity
mismatch / unknown ids pass through). Test: WebcamOutputKeyTests.
2. START BUTTON VANISHED AFTER STOP: my pills-clear ruling met the old
ShowPrimaryStartButton gate (needed IsConnected or a lit REC pill) — signed-out,
pills-off = blank top bar, no session reachable. Start is now ALWAYS the idle
face; unarmed Start records (local needs no account — no dead-end no-op); the
Sign In button folds into Start's context menu ('Sign in to YouTube', shown while
disconnected) with a dedicated SignInCommand. SessionTeardownTests extended:
stopped session must leave a reachable Start.
3. TOP BAR ORDER (creator spec): [sign light] REC [pill] [sign light] ON-AIR [pill]
— each reality lamp now sits in front of its own intent switch (was: two pills,
then two orphaned dots). Sign labels keep the shared StatusSignText style.
Tests: WebcamOutputKey, SessionTeardown, FramePump, WebcamMenuGate, GlobalHotkey,
BroadcastPullOut — 14/14 across the six classes, clean build 0 warnings.
First real recording (2026-09-01): ffmpeg died at ~0.9s mid-session with ZERO log
lines while the pump fed a corpse for 9 more seconds. Three blind spots closed:
- OnStderrLine discarded everything non-progress: ffmpeg's actual words (startup
config, warnings, the reason it quit) now go to AppLog prefixed 'ffmpeg:' (400-char
cap so a \r-choked blob can't flood).
- Unexpected exit only fired ProcessFailed when code != 0 — a CLEAN exit vanished
silently. Any exit we didn't request is now logged and fired, any code.
- ExitCode read raced StopAsync's Dispose (tonight's 'No process is associated'
line) — guarded.
- VM output resolver logs once/5s when a webcam element resolves to a null frame
(preview showed the cam, recording didn't — the layer skip was invisible).
FfmpegEncoderTests + FramePumpTests 21/21 against the new code, 0 warnings.
Creator report 2026-09-01: with the desktop slider dragged to 20%, the game bar's
meter stayed pegged yellow/red. Root cause: the mic meter's formula (level x volume)
was cloned to the game bar WITHOUT the x-volume factor, and the clone inherited a
false premise — that WASAPI loopback capture tracks the endpoint volume. Tonight's
observation disproves it (the tap is pre-endpoint-volume), so both the meter (display)
and the mix (AudioGainProvider.LoopbackGain, fixed in 5ead064) must scale it themselves.
ai.md's two 'loopback scales with the slider' sentences corrected in the same pass.
Test seam: VolumePushOverride (internal static, mirrors LayoutPathOverride pattern) so
the test never hijacks the machine's real volume. GameMeterHonestyTests (RealApp +
temp DB): unity -> hot/red, 20% -> ~1/5 width/green, push observed.
Creator ruling 2026-09-01 (stuck REC pill after the failed first recording):
- StopStream clears RecordPillOn + OnAirPillOn — intent resets with reality.
- OnFramePumpFailed: dispatcher-marshalled FULL rollback via StopStream (was:
toast + Error-status only when live — record-only sessions zombied with
IsRecording=true over a dead encoder; the 20:34 attempt proved it). Toast copy
says 'Recording stopped' vs 'stream pipeline stopped'.
- Same zombie class in the three go-live prep failure branches: StreamStatus.Error
limbo replaced by StopStream() (audio loop + recording + pills unwind; a created
broadcast still gets the close-out).
- AudioMixer.StopLive made explicitly idempotent (Stop() twice, rollback paths that
never reached StartLive).
- Seams: OnFramePumpFailed + IsRecording setter internal (InternalsVisibleTo; test
pattern mirrors LayoutPathOverride).
ONE integration test: SessionTeardownTests (real window + temp DB: pump death with
lit pill -> no zombie, no End button, Offline). AudioPipelineTests confirmed to hang
STANDALONE (pre-existing, the declared-known audio class) — ai.md test-count
paragraph corrected to stop claiming a suite total that cannot currently be measured.
The specced 'End stream -> transition(complete)' call never existed: stopping relied
entirely on enableAutoStop (viewers sat on a frozen stream-offline for ~a minute, VOD
finalized late). Found during the 2026-09-01 recording-verification pass while the
creator asked 'if there's proper close-out info yt needs, we'll provide it?'
EndBroadcastAsync POSTs liveBroadcasts/transition?broadcastStatus=complete&id=..&part=status,
called after the pump stops (RTMP EOF first) and only when a live session had a broadcast —
record-only stops stay offline. invalidTransition/410 (autoStop already ended it) is logged
and returned as an error string, never thrown: a stop must never fail over close-out.
ONE integration test (URL shape + never-throws on 403). ai.md/TASKS.md design lines marked
SHIPPED with the map-lie note.
Ref: https://developers.google.com/youtube/v3/live/docs/liveBroadcasts/transition
User launch (2026-09-01 19:07) NRE'd in MainViewModel ctor:
1. SceneGraph (TASK 31) was 'null!'-declared, assigned mid-ctor, but Scenes is touched
~120 lines earlier — field-initialized now.
2. LeftPanel extraction (c9fd1bd) moved StaticResource users (EyeButton/EyeIconStyle)
into a UserControl while the styles stayed window-scope — invisible at parse time;
moved to Themes/Controls.xaml (app scope, the existing rule). Full audit: these were
the only two offenders (grep of Controls/*.xaml StaticResource keys vs app dictionary).
3. RoundClipInteractionTests — the second 'known failure' the map never explained: it
was two stale-test layers (window.FindName across the new UserControl namescope +
VisualTreeHelper.HitTest, which returned the IsHitTestVisible=False WebViewHostPanel
overlay for EVERY point; UIElement.InputHitTest — the real input pipeline — shows the
corner IS grabbable in both Traditional and Round). Test fixed, no product bug.
Verified: clean rebuild 0 warnings; app boots (log shows full MainWindow loaded; user
clicked + closed, zero new exceptions; real DB Webcam row = the genuine C920, untouched);
RoundClip + 6 RealApp classes pass natively per-class.
Docs: ai.md known-failure note → 246/247 (audio only); TASK 31 verification paragraph
corrected ('cannot run headless' overstated — per-class Windows-host vstest runs them);
MyMistakes: InputHitTest-vs-VTH recipe + namescope/app-style + shared-log facts.
Spin-guard citations: WPF Visual Tree Overview (InputHitTest vs VisualTreeHelper hit
semantics) + XAML namescope docs, learn.microsoft.com.
- IMediaFrameSource gains bool Looping.
- MediaVideoSource ctor takes Func<IDecodeProcess> processFactory instead of a
single IDecodeProcess: a System.Diagnostics.Process can't be re-Start()ed, so
each loop pass creates a fresh decoder. Decode wrapped in do-while(Looping):
restart on natural EOF instead of raising Completed.
- Production wiring (MainViewModel media factory): passes the process factory
AND FfmpegFrameRateProbe -- closes the slice-2b gap where production had no
probe and therefore no pacing.
- Tests: loop test (single frame re-emits across passes, Completed only when
loop cleared); fakes updated for the new interface member. Media tests 12/12,
build 0 warnings.
Wiring Source.MediaIsLooping into the flag needs a manager-level per-path loop
provider -> lands with the UI-picker (acquisition) slice.
Derivative reference: looping media by restarting decode on EOF, standard in
playback/overlay tooling (OBS media source repeat).
Wire the MediaVideoSourceManager into the live pipeline (new partial
ViewModels/MainViewModel.Media.cs mirrors Webcam/Background partials):
readonly _mediaManager assigned in the core ctor (decode at 1920x1080 master,
dispatcher-coalesced preview), OnMediaPreviewBitmapChanged adopts the shared
bitmap onto every IsMediaSource with a matching MediaPath, a
Source { IsMediaSource, MediaPath } case in ResolveOutputFrame feeds
GetLatestFrame, and the manager is disposed on shutdown.
Source.DisplaySource now routes VideoImageSource for MediaSource (Source.cs),
so media previews/canvas show decoded video.
Compositor needs no change: BlitContent UniformToFill-scales any frame to the
element rect. Decoder/manager/seam derived-work mirrors ScreenCaptureManager
(citation in commit for slice-step-3).
Test (one unit for this change, mirroring the live-capture case):
Source_DisplaySource_IsVideoImageSource_WhenMediaSource. 14/14 media +
DisplaySource tests pass, build 0 warnings. Docs (TASKS/HANDOFF/ai.md) updated.
- Add ElementKind (Static/Dynamic) to SceneElement base; Source/WebcamSceneConfig classify
- Services/SceneGraph.cs: owns the Scenes collection (ViewModel's Scenes delegates to it),
the element mutation surface (Add/Insert/Remove/Move, each invalidating the bake), and the
queries that were scattered LINQ (GetBackground/GetWebcam/GetChatBoxes/GetSplitPoint/IsStatic)
- SceneCompositor: split-aware BakeStaticBase + CompositeLayers + Render(.., staticBase, split);
builds/caches the static base below the split point in source-rect space
- FramePump: optional SceneGraph -> optimized bake+composite path; falls back to full render
- MainViewModel: routes element mutations through the graph; invalidates the bake on static
layout/opacity/visibility/useDefaultBackground changes and after background heal/Ensure
- Integration test SceneGraphTests.BakedStaticBase_WithDynamicLayer_CompositesCorrectly
Derivative survey (mandated): tried before writing — OBS does per-source opacity/visibility
caching and static-scene baking; this mirrors OBS's 'cached static source' optimization.
3 documented defensive deviations from the spec (ChatOverlayLayer stays decoupled;
background helpers stay VM-static for direct testability; full facade peel deferred post-1.0)
in TASKS.md + ai.md. Scope check passed; clean build 0 warnings.
The first true decomposition of the 4105-line god-object, not another partial
shuffle. MainViewModel.Chat.cs 194 -> 44 lines of thin delegation; all chat
behavior now lives in a self-contained Services/ChatOverlayLayer.cs (199):
- Owns the message buffer (Messages), ChatBoxRenderer, fade + mock-preview
timers, per-source preview renders, and the live-output RenderFrame path.
- The VM keeps only the binding surface: ChatMessages delegates to
_chatLayer.Messages (so XAML ItemsSource + LeftPanel CollectionChanged hold),
CanAddYouTubeChat / ShowChatInactiveMessage stay computed on the VM
(OnPropertyChanged raised from VM setters + XAML-bound), and thin forwards.
- Scenes handed in as args (no Func seam), so the layer owns no scene graph.
An AI reading ChatOverlayLayer.cs now sees the entire chat feature in one
self-contained unit. Zero behavior change; build 0 warnings; 246 pass, only the
2 known failures. Docs: ViewModels/index.md tracker updated in same commit.
The original premise (extract FFmpegEncoder / StreamHealthMonitor / FramePump
classes) was a no-op — those already exist as Services/Encoder/FfmpegEncoder.cs
and Services/Encoder/FramePump.cs. The honest functional seam was the local
recording-output concern, which was interleaved with live-stream orchestration:
- New MainViewModel.Recording.cs (121): StartRecordFile, FinalizeRecordingAsync,
UniquePath, ChooseRecordFolder, ResetRecordFolder, DefaultRecordFolder + the
recording fields (_recordFolder, RecordFolderDisplay, _activeRecordPath,
_recordStartTime, _recordLength).
- MainViewModel.Streaming.Operations.cs 475 -> 377 (keeps live session lifecycle,
health, visuals).
- MainViewModel.Streaming.cs 328 -> 323 (keeps pills/state/properties intact).
Same partial class — all MVVM bound-property glue untouched, no behavior change.
Build 0 warnings; 246 pass, only the 2 known failures.
Docs: ViewModels/index.md tracker updated in same commit.
LayoutStore.cs 1402 -> 47-line shell + 5 partials, grouped by functionality
(line count is a consequence, not the goal):
- LayoutStore.Migrations.cs: all DB schema migration (EnsureSchema, GetUserVersion,
MigrateSourceTable/WebcamConfigTable/ToV3/SceneTable/SceneSocialBarColumn/
SocialsTable/SocialEntryTable)
- LayoutStore.Load.cs: Load() scene/source read-back
- LayoutStore.Save.cs: Save() full scene tree write
- LayoutStore.Settings.cs: key/value settings + mic/record-folder/reusable-stream
+ license/transition/broadcast/hotkey/window-state GetSetting/UpsertSetting
- LayoutStore.Assets.cs: GetAssetBytes/UpsertAsset blob IO
- LayoutStore.cs: shell — Instance, fields, ctor, Dispose
Converted to public partial class LayoutStore : IDisposable. Pruned per-file
dead usings. Phase 3 DOWN: no production .cs exceeds 500 lines.
Zero behavior change; build 0 warnings; 246 pass, only the 2 known failures.
Docs: ViewModels/index.md Phase 3 tracker updated in same commit.
SocialsDialogViewModel.cs 515 -> 334. Extracted the DialogEntry record + the
row-level SocialSlotViewModel (service/logo/lock/edit/validate state, computed
Show* flags) into a new SocialSlotViewModel.cs partial. The dialog VM keeps the
sign-in gate, validation orchestration, and commit. DialogEntry stays public in
the same namespace.
Zero behavior change; build 0 warnings; 246 pass, only the 2 known failures.
Docs: ViewModels/index.md Phase 3 tracker updated in same commit.
Background.cs 567 -> 418. Moved the static scene-model helpers
(LoadBackgroundImage, CreatePlaceholderSnapshot, EnsureBackground,
CreateBackground, NormalizeBackgrounds, AllBackgrounds, ResolveAutoCaptureKey,
MonitorKeyPrefix) into a new MainViewModel.Background.Model.cs partial.
Background.cs keeps capture/live state + operations.
Zero behavior change; build 0 warnings; 246 pass, only the 2 known failures.
Docs: ViewModels/index.md Phase 3 tracker updated in same commit.
Streaming.cs 789 -> 330. Moved the go-live/record/health operation bodies into a
new MainViewModel.Streaming.Operations.cs partial (459 lines: StartSession,
BeginRecordOnly, BeginGoLive, StartRecordFile, PrepareAndStartLiveAsync,
PollHealthAsync/OnHealthPollTick/ApplyHealthIssue, StopStream,
FinalizeRecordingAsync, UniquePath, Choose/ResetRecordFolder, DefaultRecordFolder,
OnFramePumpFailed/HealthUpdated, ApplyHealth, ResetHealth, BuildEncoderOptions).
Streaming.cs keeps state, quality/resolution tiers, session timer, command
declarations. Pruned dead usings in both files.
Zero behavior change; build 0 warnings; 246 pass, only the 2 known failures.
Docs: ViewModels/index.md Phase 3 tracker updated in same commit.
Core MainViewModel.cs 758 -> 495 lines. Moved the source add/remove/image
block (AddSource, NextSourceName, AddImage, BuildImageCandidates,
PickImageBytes, AddAsset, AddImageSource, AddReusedImage, RemoveElement) into a
new MainViewModel.Sources.cs partial, and WebView2 source hosting
(RegisterLoadedWebSources, InitWebView2, OnWebView2PreviewBitmapChanged) into a
new MainViewModel.Web.cs partial. Pruned now-dead usings from core.
Zero behavior change; build 0 warnings; 246 pass, only the 2 known failures.
Docs: ViewModels/index.md Phase 3 tracker updated in same commit.
OverlayHost.xaml(.cs) takes the window-root overlay dialog grid (settings/
bug/feature/about panels); BackToAbout_Click moved in via inherited DataContext.
WebViewHostPanel + ToastArea stay as thin window-root stubs so the WebView2 ctor
init and NotificationService InArea('ToastArea') name lookups are untouched.
This completes Phase 2: MainWindow.xaml is now a 123-line shell.
Zero behavior change; build 0 warnings; 246 pass, only the 2 known failures.
Docs: ViewModels/index.md Phase 2 tracker updated in same commit.
LeftPanel.xaml(.cs) takes the Row 1 Col 0 panel (layers + properties editor and
the live chat list). All property-editor/chat handlers moved into the control
(SourceNameBox edit, AddSourceButton menu, chat autoscroll self-subscribed on
Loaded, mirror/clip-shape, remove-source, background capture change/refresh,
opacity slider label, list drag-to-reorder, snapshot revert/accept family).
Window keeps only shell + routing: ctor wiring (_leftPanel), geometry, hotkeys,
taskbar overlay, BackToAbout, Window_PreviewMouseLeftButtonDown (uses
_leftPanel.IsClickInside), UpdateSelectionOverlay (calls _leftPanel.OnSelectionChanged).
Zero behavior change; build 0 warnings; 246 pass, only the 2 known failures.
Docs: ViewModels/index.md Phase 2 tracker updated in same commit.
SceneThumbnailStrip.xaml(.cs) takes the Row 0 scene-picker strip (ItemsControl
over Scenes with staged/live border highlight); Thumbnail_MouseLeftButtonDown
moved into the control, transitioning via DataContext.TransitionToScene.
Zero behavior change; build 0 warnings; 246 pass, only the 2 known failures.
Docs: ViewModels/index.md Phase 2 tracker updated in the same commit.
PreviewPane.xaml(.cs) takes the center preview (Row 1 Col 1): the 1920x1080
canvas (OverlayCanvas/SelectionOverlay/BrandFlashLayer/SocialBarElement),
the text pull-out drawer, and the live-controls row (TRAX / game / mic meters,
volume sliders, speaker toggles, socials button) inside the preserved
glow/background Border.
Code-behind moved into the control (drive by DataContext==MainViewModel):
preview drag/select/resize/hit-test (Preview_Mouse{Down,Move,Up},
EndPreviewDrag, HitHandle, HitElement), webcam context-menu handlers
(WebcamMenu_ChangeWebcam/SetBorderAnimation/StopBorderAnimation/
FindBorderShape/CreateBorderAnimation/AddDoubleAnimation/SetBorderColor/
HideInScene/Remove), social-bar glow (SocialBar_MouseLeftButtonDown,
SocialsButton_Mouse{Enter,Leave}, SocialGlowTick), audio controls
(VolumeSlider_*, GameVolumeSlider_*, MicSpeaker_*, GameSpeaker_*,
SetSliderValueFromClick, FindParent), and TraxButton_*.
Window keeps the left source/property/chat panel + thumbnail strip + overlay
host, and routes the selection overlay and preview hit-testing through the
control's public surface (_previewPane.UpdateSelectionOverlay(bool),
IsClickInsidePreview/Drawer, PreviewPane.IsDraggableElement). BackgroundMenu_*
stayed in the window because the left-panel source context menu uses them
(the preview's own desktop/refresh menu binds RefreshCaptureCommand instead).
Preserved the outer preview Border (background/glow/margin) that was initially
dropped in the cut — caught and restored before commit. Pruned now-unused usings
(System.Net.Http, Media.Effects, Media.Animation, Media.Imaging, Shapes,
Threading) from MainWindow.xaml.cs.
Zero behavior change; build 0 warnings; 246 pass, only the 2 known failures.
Docs: ViewModels/index.md Phase 2 tracker updated in the same commit.
TopBar.xaml(.cs) takes the Row 0 top controls: logo/About button, REC/ON-AIR
pills + status signs, avatar, Sign In/Start/End buttons. The avatar fetch
(AvatarHttp/_loadedAvatarUrl/LoadAvatarImage/ShowAvatarFallback) moved into the
control with async semantics preserved; the window grabs the control via
FindName in its ctor and calls _topBar.RefreshAvatar() when IsConnected or
AccountAvatarUrl changes (previously OnViewModelPropertyChanged -> LoadAvatarImage).
AccountLight is pure binding (never touched by code-behind) so it moved with the XAML.
Zero behavior change; build 0 warnings; 246 pass, only the 2 known failures.
Also dropped the now-unused System.IO using from MainWindow.xaml.cs.
Docs: ViewModels/index.md Phase 2 tracker updated in the same commit.
HealthBanner.xaml takes the Row 1 YouTube-health banner (report-by-exception).
Converters: promoted the 7 value<->visibility converters (BoolToVis, Inverse,
NotNull, Null, Hidden, EnumToBool, AllTrueToVis) from MainWindow.Window.Resources
into Themes/Controls.xaml so extracted UserControls can resolve them. AGENTS rule:
controls cannot reach into a window's own named resources; they resolve from the
app-level merged theme instead. Promoted all of them (not just NotNullToVis) so
every pending extraction keeps working without window-level references.
Zero behavior change; build 0 warnings; 246 pass, only the 2 known failures.
Docs: ViewModels/index.md Phase 2 tracker updated in the same commit.
New Controls/ folder + namespace ytLive.Controls. BottomBar takes the
Grid.Row=3 footer: gear menu button (with its GearButton_Click handler moved to
the control's code-behind), centered stream-stats (bitrate/FPS/dropped/
duration/health), and the quality dropdown. All bindings inherit the window
DataContext; app-level styles resolve from Themes/Controls.xaml as before.
Zero behavior change; build 0 warnings; 246 pass, only the 2 known failures.
Docs: ViewModels/index.md tracker gains a Phase 2 section in the same commit.
MainViewModel.Overlays.cs (194 lines) takes the settings/bug/feature/about
panels + creator-hub links: panel state (IsSettingsOpen/IsBugOpen/IsFeatureOpen/
IsAboutOpen via SetPanel, IsLicensesOpen, IsAnyOverlayOpen, OverlayTitle),
ShowOverlay/ShowLicenses, bug/feature report fields + SubmitBug/SubmitFeature/
ComposeEmail/OpenUrl, the About-hub consts (ChannelUrl/MastodonUrl/CoffeeUrl/
PremiumUrl/IsPremiumAvailable), SupportEmail/AppVersionLabel, LicensesText, and
the overlay command declarations (OpenSettingsCommand..OpenLicensesCommand).
Core drops 940 -> 758 lines. Ctor command assignments and all other wiring stay
in core; SetPanel/ShowOverlay keep their cross-partial calls.
This completes Phase 1: MainViewModel.cs has been split into 10 functional
partials (1,890 -> 758 lines in core).
Zero behavior change; build 0 warnings; 246 pass, only the 2 known failures.
Docs: index.md Overlays row added, About-hub row re-pointed, tracker marked
Phase 1 complete in the same commit.
MainViewModel.Streaming.cs (787 lines) takes the full go-live/record flow
(StartSession/BeginRecordOnly/BeginGoLive/PrepareAndStartLiveAsync/StopStream,
record-file naming + rename-on-stop), health pipeline (FramePump.Handle +
poll, report-by-exception banner), session timer, REC/ON-AIR start/end UI,
the resolution quality dropdown over the 1920x1080 master, live window
visual tokens (UpdateLiveVisuals), record-folder commands, broadcast
pull-out (CurrentBroadcastId/BroadcastForm) and the stream-state setters
(StreamStatus/StreamVisibility) + their fields. Core drops 1890 -> 1119
lines. Cross-partial references (IsConnected, IsEditMode, BrandFlash*,
_audioMixer, ctor wiring) are safe on one partial class.
Zero behavior change; build 0 warnings; 246 pass, only the 2 known
pre-existing failures (audio-mix gain + round-clip grab). Docs: index.md
Streaming row added + core row trimmed in the same commit.
MainViewModel.Socials.cs: social-bar state (_socials/_socialBarFrame/
_socialValidator), bar props (HasSocials/SocialBarVisible/SocialBarDotBrush/
SocialBarGlowBrush/Socials/SocialBarTop), OpenSocialDialog + NotifySocialsChanged
+ RenderSocialBarFrame (SocialBarRenderer), fediverse heal
(HealFediverseSoftwareAsync/HealFediverseSoftwareInBackground),
SetSocialBarPosition/ToggleSocialBarPosition, OpenSocialDialogCommand.
BarOnBrush/BarOffBrush/CreateBrush stay in core (shared tokens used by
Audio/Trax partials); core keeps ctor command assignment, LoadLayout,
SaveLayoutNow, and the compositor socialBar seam.
Docs: index.md restructured — each landed partial (Account..Trax) now owns its
own table row, the core row shrinks to only what remains in MainViewModel.cs,
and the split tracker is a compact code<->commit map.
Verify: 0 warnings, 246 passed, 2 known failures only.
Pure member movement into MainViewModel.License.cs (zero behavior change):
Polar license state (LicenseKey/LicenseMessage/IsLicenseValidating/
IsLicenseEntryOpen/IsHubVisible/IsPremium + OfflineGracePeriod),
ValidateLicenseAtStartupAsync, ActivateLicenseAsync. docs in same commit:
ViewModels/index.md split table (License ✔).
Pure member movement into MainViewModel.Account.cs (zero behavior change):
session restore (LoadSavedSessionAsync), sign-in/out/change (SignInAsync,
SignOutYouTubeAsync, ChangeAccountAsync), IsConnected + account avatar/name +
AccountStatusLightToolTip. Partial carries its own usings (ytLive.Services for
the INotificationService default interface methods). docs in same commit:
ViewModels/index.md split table + tagging convention.