diff --git a/HANDOFF.md b/HANDOFF.md index c30bd42..8a74f77 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -2,46 +2,54 @@ ## Branch / Commit State -**`main`**, working tree is **CLEAN**. `origin/main` == `1f4624c` (pushed). +**`main`**, working tree is **CLEAN**. `origin/main` == `85893ea` (pushed). -**Pushed** (reached `origin/main` == `1f4624c`, incl. tags): Phase 1 commit `0` -+ refactor commits `1..11`, **and Phase 2 commits `12..18`** (they were pushed -at the Phase-2 milestone; the old "Phase 2 local-only" claim below was written -before that push and is stale). +**Pushed** (reached `origin/main` == `85893ea`, incl. tags): Phase 1 commit `0` + +refactor commits `1..11` + **Phase 2 commits `12..18`** + the `Controls/index.md` +docs commit `cb54637` + **Phase 3 splits A–E** (tags `refactor-commit-A..E`) + +**Commit F (Recording, `refactor-commit-F`)** + **Commit G (ChatOverlayLayer, +`refactor-commit-G`)**. Everything is pushed; nothing local-only. -**LOCAL ONLY (Phase 3 — A, B, C, D, E) + the Controls doc commit** — policy -2026-08-30: no per-commit push; `git push` only at a user-agreed milestone / -"push it". **Push pending on explicit user approval:** +**Commit log (all pushed):** | Commit | Hash | Tag | Payload | |--------|------|-----|---------| -| docs | `cb54637` | — | `Controls/index.md` (missing per-directory map) | +| docs | `cb54637` | — | `Controls/index.md` | | A | `6944db8` | `refactor-commit-A` | split `MainViewModel.cs` core → **495** | | B | `31362d1` | `refactor-commit-B` | split `MainViewModel.Streaming.cs` → **330** | | C | `fab2e09` | `refactor-commit-C` | split `MainViewModel.Background.cs` → **418** | | D | `d3271a0` | `refactor-commit-D` | trim `SocialsDialogViewModel.cs` → **334** | -| E | `dcb3637` | `refactor-commit-E` | split `LayoutStore.cs` → 6 partials, **≤495** | +| E | `dcb3637` | `refactor-commit-E` | split `LayoutStore.cs` → 6 partials | +| F | `3107f92` | `refactor-commit-F` | extract recording concern → `MainViewModel.Recording.cs` | +| G | `85893ea` | `refactor-commit-G` | **first true decomposition** → `Services/ChatOverlayLayer.cs` | ## What's In Flight -Nothing code-in-flight — working tree clean, **Phase 3 complete**. +Nothing code-in-flight — working tree clean. -**Phase 3 result:** every production `.cs` is now **≤ 500 lines** (checked via -`git ls-files '*.cs'` minus tests; nothing exceeds 500). The 500-line count is a -**hard ceiling, not the goal** — each file was split **grouped by functionality** -so an AI can process one concern per file. Splits were **concern-driven**: -- `MainViewModel` → `.Sources.cs`/`.Web.cs`, `Streaming.cs` + `Streaming.Operations.cs`, - `Background.cs` + `Background.Model.cs`; `SocialSlotViewModel.cs` (row VM). -- `LayoutStore` (was 1402) → `Migrations.cs`/`Load.cs`/`Save.cs`/`Settings.cs`/ - `Assets.cs` + a 47-line shell; `public partial class LayoutStore : IDisposable`. +**The directive (2026-08-31, user):** rewrite the project, breaking files into +**functional components to compliment AI retrieval/processing** — NOT line-count +chasing. Line count is a guideline for context management, not a design goal. -Every change: 0-warning build, `scripts/verify.sh` (246 pass + 2 known only), -explicit-file `scripts/scope-check.sh`, `ViewModels/index.md` tracker in the -SAME commit, tagged `refactor-commit-N`, no per-commit push. +**What was done:** +- **Phase 3 (A–E):** every production `.cs` ≤ 500, grouped by concern. These are + *partial shuffles* — same class, same shared state. Marginal for retrieval. +- **Commit F:** recording-output concern → `MainViewModel.Recording.cs` (partial). +- **Commit G — the real win:** `Services/ChatOverlayLayer.cs` is a genuine + owner-state component (buffer + renderer + timers + preview + live `RenderFrame`); + `MainViewModel.Chat.cs` 194 → 44 (thin binding facade). First true decomposition. -**Next:** -- If user says "push": `git push origin main && git push --tags` (6 commits: - `cb54637` + A–E, and tags `refactor-commit-A..E`). +**Expert diagnostic (recorded in `ai.md` → Key patterns):** the remaining partials +(Audio, Webcam, Background, Scenes, Socials…) are **binding glue over already- +extracted services** (`AudioMixer`, `CameraManager`, `ScreenCaptureManager`, +`ChatBoxRenderer`, `SocialValidator`). They have no cohesive owner-state blob to +peel — forcing extraction adds coupling. **Chat was the one clean peel.** + +**Next (recommended):** the one remaining *genuine* big decomposition is the +**scene-graph segment** (the `Scenes`/`StagedScene`/element-inventory backbone +every feature hangs off). This is a real architectural redesign — a `SceneGraph` +owner-object with narrow seams — that touches the binding surface and needs +design review, NOT an unsupervised autonomous peel. Do not attempt it blind pre-1.0. **Pending bus:** `ViewModels/index.md` Phase 3 tracker has rows A–E (closed); Phase 3 marked COMPLETE. Watch-list (under limit, no action): `Controls/PreviewPane.xaml.cs` @@ -75,7 +83,9 @@ Phase 3 marked COMPLETE. Watch-list (under limit, no action): `Controls/PreviewP - MainViewModel refactor: **Phase 1 complete (11/11 partials)**, pushed. - MainWindow.xaml refactor: **Phase 2 complete (6/6 controls)**, pushed at `1f4624c`. -- 500-line compliance: **Phase 3 complete (all 5 offenders ≤ 500)**, local-only A–E. +- 500-line compliance: **Phase 3 complete (A–E, all ≤500)**, pushed. +- **True decomposition: Commit G — `Services/ChatOverlayLayer.cs` shipped + pushed** (first owner-state component; pattern recorded in `ai.md`). +- **Next (design-needed, do NOT blind-peel):** the scene-graph segment (`SceneGraph` owner-object) — the one remaining genuine big decomposition; touches binding surface, needs review. - TASK 3: 27/30 (preview compositor 16, text source 17, alerts 20 still open). - TASK 4: ✅ shipped. TASK 9: items 1–3 shipped; 4–7 open. TASK 10: steps 1–7; Velopack pending. - TASK 18: shipped, creator verification pending. TASK 19/23, 20, 21-A: shipped. @@ -83,20 +93,21 @@ Phase 3 marked COMPLETE. Watch-list (under limit, no action): `Controls/PreviewP ## Session summary (2026-08-31) -- Ran the **500-line Phase 3 split to completion** under the standing "continue - without stopping" directive — no interruption prompts, no `question` tool. -- Commits A–E landed locally, each: cut → 0-warning build → verify (246 pass + 2 - known) → scope-check → index.md in-commit → `git tag refactor-commit-{A..E}`. +- Ran the **500-line Phase 3 split to completion** (A–E), then the continuation. - **Re-structuring principle (user correction, 2026-08-31):** split by **functionality**, so an AI can process one concern per file; the 500-line - count is a **ceiling**, NOT the target. Do not pad or reshape code to a - number — group by concern and only split enough to stay under the ceiling. -- **Commit E (LayoutStore)** the biggest: 1402 → 6 concern-based partials - (Migrations/Load/Save/Settings/Assets/shell), default partial split. + count is a **ceiling**, NOT the target. Do not pad or reshape code to a number. +- **Correction of a false premise:** my plan to "extract FFmpegEncoder / + StreamHealthMonitor / FramePump" was a no-op — those already exist as + `Services/Encoder/*`. The honest seams were (F) the recording concern and + (G) the chat overlay. +- **Commit G = first TRUE decomposition:** `ChatOverlayLayer` owns chat state + + behavior; `MainViewModel.Chat.cs` 194 → 44 thin facade. This is the pattern + that actually compliments AI retrieval (one self-contained unit per feature), + unlike the partial shuffles of A–E, which only marginalize context because + every partial still shares the god-object's state. - **Accident caught & recovered (Commit D):** a `> ViewModels/SocialsDialogViewModel.cs` - write that read from the file it was simultaneously truncating left the file at - 1 line. Fixed IMMEDIATELY via `git checkout --` and re-ran the split. **Lesson** - (goes in `MyMistakes.md`): never `awk … > SRC` where `awk` also reads `SRC`; - stage every multi-block cut into temp files first, then assemble. All later - LayoutStore cuts did exactly that (read original once into temp files). -- Push checkpoint reached; awaiting explicit "push" for `cb54637` + A–E + tags. + write that truncated the file it was reading left it at 1 line; restored via + `git checkout --`, re-ran from temp files. **Lesson** in `MyMistakes.md`: never + `awk … > SRC` while awking SRC; stage cuts into temp files, then assemble. +- Everything through G is **pushed to `origin/main` (`85893ea`)** with tags A–G. diff --git a/ai.md b/ai.md index a1703c0..1a08d9c 100644 --- a/ai.md +++ b/ai.md @@ -154,7 +154,7 @@ C# / WPF (.NET 8) following MVVM: | Path | Role | |------|------| | `Models/` | Plain data types — Scene, Source (incl. `ClipShape`, `IsMirrored`, `VideoImageSource`), QualityOption, StreamConfig, StreamHealth, YouTubeChannel, ChatMessage, **Socials (`SocialService` enum + `SocialEntry`/`SocialsConfig` + `SocialServiceIcons`) — the social bar** | -| `ViewModels/` | MainViewModel — `public partial class`, one file per functional area (Scenes, Background, Webcam, Audio, Trax, Socials, Streaming, Chat, Overlays, Account, License — split in progress, see `ViewModels/index.md`); GoLiveViewModel, ReuseImageViewModel, CameraPickerViewModel, **SocialsDialogViewModel** | +| `ViewModels/` | MainViewModel — `public partial class`, one file per functional area (Scenes, Background, Webcam, Audio, Trax, Socials, Streaming, Chat, Overlays, Account, License, Recording — split complete, see `ViewModels/index.md`); **Chat.cs is a thin delegating facade over `Services/ChatOverlayLayer.cs` (Commit G, first true decomposition)**; GoLiveViewModel, ReuseImageViewModel, CameraPickerViewModel, **SocialsDialogViewModel** | | `Services/` | YouTube OAuth2, stream/broadcast management, live chat polling, LayoutStore (SQLite), **SocialValidator (`ISocialValidator` seam + `HttpSocialValidator` default)**, **webcam: `VideoFrame` seam + `CameraDeviceInfo`/`ICameraEnumerator`/`ICameraFrameSource` interfaces + `MediaCaptureCameraEnumerator`/`MediaCaptureFrameSource` (WinRT) + `CameraManager`**, **screen capture: `IFullScreenDetector`/`Win32FullScreenDetector` + `IScreenCaptureSource`/`ScreenCaptureFrameSource` (WinRT GraphicsCapture) + `ScreenCaptureManager` + `ScreenCaptureSourceFactory` + `Direct3D11Helper`/`CaptureInterop` (COM bridges)**, **compositor: `SceneCompositor` + `CompositorOptions` + pure `StretchMath` + `StaticPixelCache` (see "Scene compositor")**, **audio: `IAudioSource` seam + `WasapiLoopbackAudioSource`/`WasapiMicAudioSource` (NAudio WASAPI) + `AudioMixer` + pure `AudioLevelMeter`/`WaveToFloat`/`VoiceFilterChain`/`LowShelfFilter`/`HighShelfFilter`/`NoiseGate`/`Compressor`/`AutoDucker`/`AudioRingBuffer`/`TinyResampler` + `MusicPlayer` + `IAudioPipeWriter`/`NamedPipeAudioWriter` (see "Live audio capture")**, **encoder: `IFfmpegEncoder`/`FfmpegEncoder` + `IEncoderProcess`/`FfmpegEncoderProcess` + `IFfmpegLocator`/`FfmpegLocator` + pure `FfmpegArgs`/`FfmpegProgressParser`/`FfmpegEncoderPicker` + the `FramePump` frame producer (see "Live encoder" + "Live frame pipeline")**, **notifications: `INotificationService` seam (`AppNotificationSeverity` Info/Success/Warning/Error) + `NotificationService` (Notification.Wpf toasts, see "Toast notifications")** | | `Helpers/` | ViewModelBase (INotifyPropertyChanged), RelayCommand, ImageCache, AppLog (file logger), FocusPreservingListBox, OAuthCredentials, **TokenStore (DPAPI session persistence)**, visibility converters | | `Themes/` | `Controls.xaml` — the single dark-theme source, merged once in `App.xaml` (see `Themes/index.md`) | @@ -166,6 +166,7 @@ C# / WPF (.NET 8) following MVVM: - `RelayCommand` for all button actions; commands gate on state (e.g. Start only when Offline). **Typed `CommandParameter`s — no stringly-typed command tokens:** menu items that pick a source type pass the enum value itself (`CommandParameter="{x:Static models:SourceType.DisplayCapture}"`), so a typo breaks the build instead of silently adding an Image; `AddSource` still falls back to `Enum.TryParse(..., true)` for safety. The webcam item is its own `AddWebcamCommand` (it greys out via `CanAddWebcamToActiveScene` and isn't a `SourceType` — webcams are `WebcamSceneConfig`, not `Source` rows) - **Audio is KISS by rule** — the whole of audio is *one knob*: **desktop/game audio is automatic** (WASAPI loopback from the default output at unity, zero UI — "it just is"); the **mic is the creator's only audio control** — sound meter + mute button + volume slider (`MicVolume`, defaults to 0.8) all sit together on the second line of the preview bottom row, BELOW the Socials+TRAX row, CENTERED beneath the preview panel. The TRAX button + Desktop Audio meter sit on the first line of that same row. Meter: 288px, muted slate track (`#3a3b52`) with ruler graduations and muted yellow/red zone tints at 60%/80%; fill = green → yellow → red via `MeterFillWidth`/`MeterBrush`; the meter is a **READ-ONLY realtime level display** — it shows the live input level scaled by the volume (raising the volume moves ambient noise up the bar), NOT the volume setting: the fill is `Math.Min(1, AudioLevelMeter.ToDisplay(AudioLevel) * MicVolume)` — `ToDisplay` maps the raw linear RMS onto a −60..0 dBFS display scale, because real speech sits around −40..−20 dBFS (0.01..0.1 linear) which would leave a flat scale dead (`AudioLevel` is fed by the audio mixer once capture lands, 0 with no input) and 0 while muted. While the volume slider is being dragged the bar previews the slider position (`SetVolumeAdjusting`, from `PreviewMouseLeftButtonDown/Up` + `LostMouseCapture` handlers) so the creator sees where they're setting it; on release it returns to the live level — with no input it bounces back to 0, exactly as it does today. Clicking the meter does nothing; **clicking the MIC label opens the mic picker** (`OpenMicPickerCommand`), and the picked voice source name (`MicSourceName`) is shown left-justified INSIDE the meter bar (FontSize 10, ellipsized to the bar) — the fill runs at 75% opacity so the text and the ruler markings stay visible through it. Mute (`ToggleMicMuteCommand`/`MicMuted`) is a plain clickable speaker icon (`MicSpeaker_MouseLeftButtonUp` code-behind handler — not a Button, `Stretch="Uniform"` so the glyph is never clipped) that swaps to a red do-not-symbol (slashed speaker) when muted. **The slider and the speaker can never disagree:** `MicMuted` is read-only, derived from `MicVolume == 0` — sliding the volume off flips the speaker to muted (storing the prior level in `_volumeBeforeMute`), sliding it up from 0 clears the mute indicator (and the stored level); the speaker button just runs the volume to 0 or restores it (default 0.8 if unknown). Muting zeroes the meter; **unmuting flashes the meter to the restored position for ~300ms** (`BeginVolumeFlash`/`EndVolumeFlash` on a DispatcherTimer, cancelled if the slider is grabbed) before it returns to the live level. Line 2 of the footer holds everything else: stream stats (bitrate/fps/dropped/duration/health) on the left, quality dropdown + gear on the right. The slider is a slim dimensional style in `Themes/Controls.xaml` (gradient track, beveled green fill on a 5px pill, gloss-sphere thumb with drop shadow — deliberately NOT flat). No device pickers (never show device names — no "install a device you didn't know existed"), no filter stacks, no monitoring, no routing — OBS's confusion (dynamic mixer, unintuitive names, four required filters) is deliberately absent. A production-ready mic chain (high-pass → noise gate → compressor) will be applied invisibly in the mixer, unconfigurable. Capture runs only while live (privacy indicator stays off otherwise). Capture pipeline = `IAudioSource` seam + NAudio `WasapiCapture`/`WasapiLoopbackCapture` + `AudioMixer` (pending — the UI is in place now). **Mic mute icon (2026-08-13):** a second 16px clickable glyph — a microphone, red + slash when muted — sits **between the meter and the speaker** (both mutes adjacent, spacing between the icons) and reuses the same `MicSpeaker_MouseLeftButtonUp` → `ToggleMicMuteCommand` handler. **REC / ON-AIR pills + signs (TASK 18, 2026-08-29):** the top-center area is now two **sliding pill toggles** (intent) next to two **status signs** (reality). REC pill (`RecordPillOn`) is the local-recording intent — works signed-out; ON-AIR pill (`OnAirPillOn`) is the streaming intent — greyed/disabled until `IsConnected`. The **REC sign** (`RecDotBrush`/`RecTextBrush`/`RecDotOpacity`) is dark-gray + dim offline and **green-tinged, pulsing only while actually recording** (`IsRecording`), not merely pill-on. The **ON-AIR sign** (`OnAirBrush` = `#555` offline, `#22c55e` live) is green while actually streaming (`IsLive`); the `PRIVATE` badge (`IsLivePrivate`) still shows when the broadcast is private. Both sign labels share the `StatusSignText` style (`Themes/Controls.xaml`) so REC and ON-AIR can't drift apart — status color lives on the dot only, never the label. The elapsed timer shows while `IsLive || IsRecording`. The connected account's avatar/name shows in the top bar next to the primary button (`AccountAvatarUrl`/`AccountDisplayName` via `SyncConnectedAccount`), so the creator always sees WHICH account will go live. - ViewModels are constructed in XAML (`` as DataContext) +- **True decomposition beats partial-shuffling (2026-08-31, Commit G):** `MainViewModel` is a ~4100-line god-object split into 17 partials — but partials are a *myth of decomposition*: every partial shares the same class, same `SetProperty` state, same collaborators via `Scenes`/`StagedScene`/`IsLive`/`Source.VideoImageSource`, so an AI fetching one partial still reconstructs the whole class. The only thing that actually improves retrieval is a boundary where a feature **owns its own state + collaborators**. `Services/ChatOverlayLayer.cs` is the model (extracted from `MainViewModel.Chat.cs` 194→44): the layer owns the message buffer, `ChatBoxRenderer`, fade/mock timers, preview renders, and the live `RenderFrame`; the VM keeps only the binding surface (`ChatMessages` delegates to `_chatLayer.Messages` so XAML + LeftPanel `CollectionChanged` hold; the two computed gate props stay on the VM because their `OnPropertyChanged` is raised from VM setters). **Diagnostic — glue vs. component:** before extracting, count `OnPropertyChanged`/`SetProperty` touches (glue) and bound-property reads per partial. Zero/low-glue + a cohesive state blob (renderer/timers/buffer) = extract (chat). High glue over an already-extracted service (Audio, Webcam, Background, Scenes — `AudioMixer`/`CameraManager`/`ScreenCaptureManager`/`ChatBoxRenderer` already exist) = leave as VM glue; forcing it adds coupling, doesn't remove it. Don't manufacture seams to hit a line count — the line count is a guideline for context, not a design goal. - Services are currently instantiated in MainViewModel's constructor — no DI container yet - Layout persists to SQLite (`Microsoft.Data.Sqlite`); scenes/sources/asset bytes stored in the DB, asset identity is a SHA-256 content hash (1:M reuse, no file paths — assets are always available). Loaded sources always derive `IsBackground` from `Type` (OR'd with the persisted column, so legacy DisplayCapture backdrops keep their flag) — pre-derivation rows with `IsBackground=0` heal on load - **Five-scene catalog (`Models/SceneCatalog.cs`):** the product is exactly Starting/Live/BRB/Chat/Ending — work with less, never more (the escape hatch for "more" is OBS). Scenes are matched **by name** (`SceneCatalog.Is`, case-insensitive trim). Empty DBs seed all five; the scenes-header "+" (`ShowAddScene`/`MissingScenes` on `MainViewModel`) only appears while ≥1 canonical scene is missing and its menu lists only the missing ones, re-adding them by name (`AddSceneCommand`). Renaming a canonical scene makes it missing again; `AddScene` rejects non-canonical names.