Creator: the About box was "shitty" — an unreadable logo, a version number nobody asked for, copy that insulted the creator, and buttons that looked like text. Rebuilt around one job: convert. - **820×860 modal** (was 650×700) with a scrollable hub, and the logo at **520 px wide** instead of 125 — the asset is 2816×1536, so it was being drawn as a thumbnail, not an icon problem. - **Pre-Release pill** replaces "Version 1.0.0" (the assembly version stays in log lines). The pill is a button: it opens llamacasty.com until a Steam store page exists, so the link changes in one place. - Tagline: "1-2-3, and go live to the world on YouTube!" The old "simple enough that even the most right-brained person can intuit it" is gone, and a test asserts it stays gone. - The offer in three short blocks (free 30 fps · paid 60 fps · one-time, lifetime, no account / subscription / tracking), then the launch price and **one primary CTA**: "Unlock 60 fps — $79". - Price: $79 until **April 1, 2027**, then $99 locked for life. The numbers live once, in `MainViewModel.Overlays`, and are recorded in `docs/ai/commercial.md`. - **A paying customer is never sold to**: ownership collapses the price/CTA block and shows "Licence: owned via Steam — thank you." - Secondary destinations (channel, Mastodon, coffee, licences) are one quiet row; footer is `© Llama Chile Shop`; the "Made by gramps" line is removed. - `YtButtonSecondary` now carries a higher surface, a hairline border and a keyboard focus ring — they were the same tone as the panel behind them, which is why they read as plain text. Test: `AboutHubTests.About_ShowsTheOfferToFreeUsers_AndNothingToSellToOwners` — tagline, pill, price, CTA text, copyright, the removed lines stay removed, and the CTA block collapses when owned. FAST gate: 540 passed, 0 failed, 0 warnings.
255 lines
43 KiB
Markdown
255 lines
43 KiB
Markdown
## About box (2026-11-05)
|
||
|
||
A conversion surface, not a credits page: **820×860 modal**, ~520 px logo, a `Pre-Release` pill that is
|
||
itself a button (links to llamacasty.com until a Steam store page exists), the tagline
|
||
"1-2-3, and go live to the world on YouTube!", the offer as three short blocks (free 30 fps · paid
|
||
60 fps · one-time/lifetime/no-tracking), then the launch price and **one primary CTA**
|
||
("Unlock 60 fps — $79"). Secondary destinations are one quiet row; the footer is `© Llama Chile Shop`.
|
||
|
||
**A paying customer is never sold to:** with ownership verified the price/CTA block collapses and is
|
||
replaced by `Licence: owned via Steam — thank you.` The old tagline ("simple enough that even the most
|
||
right-brained person can intuit it") and the "Made by gramps" line are gone for good.
|
||
`YtButtonSecondary` carries a higher surface, a hairline border and a keyboard focus ring — before this
|
||
the secondary buttons were the same tone as the panel behind them and read as plain text.
|
||
|
||
## Left panel alert stack (2026-11-05)
|
||
|
||
A stack of red rows at the top of the left panel, **above both mode grids** (so it shows while editing
|
||
and while live) and pushing the panel's content down. Only red rows belong there — it is the surface for
|
||
"something needs you", never chatter.
|
||
|
||
- **Rows are text only, two lines at most** — no heading line above the message (creator, 2026-11-05:
|
||
the heading was redundant and pushed the stack past its budget).
|
||
- **Row 1 is always the Steam licence indicator** and nothing displaces it. It reads as status, not a
|
||
warning: `Steam ID Limit: 30fps` / `Click for More Info` (60fps once owned), both numbers derived from
|
||
`QualityOptions` and `HasProOutput`. It shows a **pin** (standing indicator, no close control) while higher tiers are
|
||
unavailable, and a **close control once ownership is verified** — that close is persisted
|
||
(`Steam.LicenceIndicatorDismissed`) and permanent *while ownership holds*; losing ownership restores
|
||
the row as pinned, because that is a change of state rather than a preference. Click → About.
|
||
- **Event rows stack beneath it, newest first**, one row per episode (a reconnect advancing its state
|
||
updates its own row rather than adding another). Each is dismissible. Click → the Logs tab opens with
|
||
that event's sequence range painted red (`HighlightedLogFrom/To` + `LogInRangeConverter`; two numbers
|
||
rather than a collection because the log list is virtualised and realised rows do not re-evaluate on
|
||
membership changes).
|
||
- The old red panel under the quality picker is **gone** — this stack replaced it.
|
||
|
||
## Preview pane — frame-source layers are a WHITELIST
|
||
|
||
`PreviewPane.xaml`'s per-layer `Image` is **Collapsed by default** and switched on by one DataTrigger
|
||
per layer type that renders a frame (`IsImageSource`, `IsChatBox`, `IsAlertBox`, `IsTicker`,
|
||
`IsScoreboard`, `IsTextOverlay`, `IsWebSource`, the webcam variants, and — since 2026-11-05 —
|
||
`IsMediaSource` + `IsRectShape`). **A new frame-source layer type that forgets its trigger is
|
||
invisible in the preview while its decode logs healthy.** That has now bitten the alert box
|
||
(2026-09-26), the scoreboard, and the video layer. Add the trigger in the same commit as the layer
|
||
type, and pin it with a test that asserts the bound `Image` is `Visible` — see
|
||
`ytLive.Tests/MediaPreviewDisplayTests.cs`.
|
||
|
||
# UI surface: control room, patterns & transitions
|
||
|
||
The control-surface layout, scene strip, preview rail, hotkeys, broadcast drawer, toasts,
|
||
transitions, brand logo, launch geometry, and the theme/properties-panel conventions.
|
||
Read before changing anything the creator looks at.
|
||
|
||
**Frame-source layers are drawn by a WHITELIST (2026-11-05).** `PreviewPane.xaml`'s per-layer `Image`
|
||
is `Collapsed` by default and switched on by one DataTrigger per layer type that renders a frame
|
||
(`IsImageSource`, `IsChatBox`, `IsAlertBox`, `IsTicker`, `IsScoreboard`, `IsTextOverlay`,
|
||
`IsWebSource`, webcam variants, and — since 2026-11-05 — `IsMediaSource` with `IsRectShape`).
|
||
**A new frame-source layer type that forgets its trigger is invisible in the preview while the
|
||
decode logs healthy.** This has now bitten the alert box (2026-09-26), the scoreboard, and the video
|
||
layer, so add the trigger in the same commit as the layer type, and pin it with a test that asserts
|
||
the bound `Image` is `Visible` (see `MediaPreviewDisplayTests`).
|
||
|
||
## Launch POSITION is derived, never restored (2026-10-04)
|
||
|
||
Creator ruling: the window must open in the **upper-left of its screen with ~75px padding**, because
|
||
the restore path was putting it below the fold. `MainWindow.RestoreWindowGeometry` now ignores the
|
||
saved position entirely and calls the pure `TopLeftPlacement(workArea, w, h, padding)`, which takes
|
||
the work-area origin plus `LaunchPadding = 75` and clamps so the window is never pushed off-screen:
|
||
a window that fits stays wholly visible, one too big to fit falls back to the work-area origin.
|
||
`LaunchPadding` is in **device-independent units**, so it scales with DPI instead of being a fixed
|
||
physical pixel count.
|
||
|
||
**Size and maximized state are still persisted and restored** — only position was dropped. The
|
||
clamp still applies to size: saved values are `Math.Clamp`ed to `[MinWidth, work.Width]`, which is why
|
||
a saved 800×500 comes back as 1366×768 (the XAML `MinWidth`/`MinHeight`). Reverting this is
|
||
reverting the one block in `MainWindow.xaml.cs`; the saved values are still written, so nothing is lost.
|
||
|
||
**Why the old clamp was wrong (do not "fix" it back):** it tolerated `Left` as low as
|
||
`work.Left - width + 120` and `Top` up to `work.Bottom - 80` — ranges wide enough to leave the window
|
||
almost entirely off-screen while still looking like a successful clamp. A clamp that permits the
|
||
window to be ~unreachable is worse than no clamp, because it reads as a guarantee it does not keep.
|
||
|
||
`BroadcastPullOutTests.Metadata_Persists_Window_Launches_TopLeft_And_UpdatePatchesRemote` (renamed
|
||
from `..._WindowRestores_Clamped_...`) is the integration guard — it asserts the constructor applies
|
||
the derived placement for both an on-screen and a wildly off-screen (`-99999`) saved position.
|
||
Placement math itself is covered by `WindowPlacementTests` (pure function, no HWND needed).
|
||
|
||
## Key patterns
|
||
|
||
- `ViewModelBase.SetProperty<T>()` for property change notifications
|
||
- `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<SourceType>(..., 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 TRAX row, CENTERED beneath the preview panel. The TRAX button + Desktop Audio meter sit on the first line of that same row; Socials is on the preview rail. 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. **Dock layout (2026-11-01):** four bays — MIC | GAME/PC | TRAX | LIP-SYNC — where the three sound bays share equal width so their meters stay the same relative size, and lip-sync is narrow (0.45*) because its function is one slider and three presets. TRAX is its own bay with its own meter (`TraxMeterFraction`), which it did not have while it lived inside the game bay. The master strip is 44px tall, its meter is the largest on screen (420×14, 32 segments) and is centred under the preview with a 12px MASTER label — bigger than the 9px bay labels. The kbps / fps / dropped counters were removed from that line: the same readouts already sit above the preview, and duplication on a telemetry strip is noise. 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 (`<vm:MainViewModel/>` 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). **`Services/AlertOverlayLayer.cs` (TASK 43, 2026-09-24) is a second true component built fresh on the same contract** — it owns the event queue, the 33ms playback ticker, the per-source preview renders and the live `RenderFrame`, with `Advance(double)` exposed as the deterministic test clock; the VM again keeps only the facade (`RenderAlertBox`, `UpdateAlertBoxPreview`, `CanAddAlerts`). Two COMPONENT layers, one facade partial. **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.
|
||
- **SceneGraph component + baked-crust compositor (TASK 31):** `Services/SceneGraph.cs` owns the scene collection (`Scenes` — the ViewModel's `Scenes` property delegates to it) and the element mutation surface (`AddElement`/`InsertElement`/`RemoveElement`/`MoveElement`, each invalidating the bake cache) plus queries that were scattered LINQ (`GetBackground`/`GetWebcam`/`GetChatBoxes`/`GetSplitPoint`/`IsStatic`). Elements expose `ElementKind Kind` (`Static` = images/background art, `Dynamic` = webcam/live capture/chat/web). The **split point** is the index of the first dynamic element; `SceneGraph.GetBakedBase` bakes/caches all static layers below it (keyed by scene id + static element identities), and `SceneCompositor.BakeStaticBase`/`CompositeLayers`/the `Render(.., staticBase, split)` overload composite the dynamic/above-split layers per frame. `FramePump.RenderScene` uses the optimized path when a `SceneGraph` is wired in (falls back to full render without one). Invariant: dynamic-only pixel changes never invalidate; static layout/opacity/visibility/asset changes do (via `InvalidateBake` from the VM's element-property and background-heal paths). **Defensive deviation from the TASK 31 spec:** `ChatOverlayLayer` keeps taking `IEnumerable<Scene>` instead of depending on `GetChatBoxes()` — it is deliberately decoupled from the graph (its doc comment says "without owning the scene graph"); and the static background helpers (`EnsureBackground`/`NormalizeBackgrounds`) stay on the ViewModel because `BackgroundTests.cs` unit-tests `MainViewModel.EnsureBackground` directly. Queries + invalidation moved; helpers stayed.
|
||
- 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
|
||
- **Element z-order is a unified space, not per-table (2026-09-20):** `Source` and `WebcamSceneConfig` live in different tables, and a webcam can be interleaved between sources in a scene. Both tables' `SortOrder` is stamped from the SAME counter — the element's index within `scene.Elements` — and `Load` merges the two tables' rows per scene by that shared z (tie-break sources-first, so legacy per-table rows read back stacked the way they were saved). Never re-introduce per-type sort counters + "append all Sources then all configs", or a webcam slotted mid-stack reverts on reload (creator-reproduced bug).
|
||
- **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.
|
||
- Theming: all custom styles live in `Themes/Controls.xaml`, merged in `App.xaml` — never duplicate styles per-window (dialog duplicates were consolidated into this dictionary). **Every control in the app carries a theme style (TASK 58, 2026-10-21):** an unstyled `ComboBox`/`CheckBox` falls back to the **light WPF system template** (grey border, system arrow, white popup list) and sticks out of the dark panels — the properties panel's drop-downs had been hand-painted with a `Background`/`Foreground` instead of a style (the chat Font drop-down was a copy of that), and every check box was default. The rule now: `ComboBox` → `YtComboBox`, property-panel switches → `PropertyPillCheck` (plain check boxes may use `YtCheckBox`), the dense properties fields → `YtTextBoxCompact` (`BasedOn YtTextBox`: 11px, padding 4,2) — **no hand-painted `Background`/`BorderBrush` on a themed control**. `ytLive.Tests/PropertiesStyleTests.cs` is the guard: it fails if an unstyled drop-down, check box or properties field comes back — **but that guard sees only those three control types**, so a picker built as something else (a `Button` + a code-built `ContextMenu`) slipped through it and the creator had to ask twice (TASK 63)
|
||
- **The properties panel is a HIERARCHY (TASK 59, 2026-10-21):** a **title owns its content, and the content is indented one level (10px) under it** — the creator's ruling: *"the 'drag to move' help text needs to be indented under 'Mirror Rect' to show parentage … same with Geometry, Border Color, Border Thickness … Remove should be indented under Background as should Blur … Strength should be indented under Blur."* So the webcam's block reads `Shape` (the mirror switch + the shape choice) → `Geometry` → `Border Color` → `Border Thickness` → `Background` (Remove, Blur, and Strength one level deeper under Blur), each title SemiBold at 11px `#a0a0b0` like the existing ones. **The shape is a CHOICE, not a toggle button:** `IsRectShape`/`IsRoundShape` on `SceneElement` are two settable bools and the panel shows them as two `SegmentToggle` options (Rect | Circle) with **Rect selected by default** (an element that never picked a shape is a rect) — the old single button showed the *next* action ("Round"), which is why the creator could not tell what the camera was. The mutual exclusion lives in the MODEL (each setter flips through `ToggleClipShape()` and both props are re-raised), **not** in a WPF `RadioButton` group — a RadioButton group unchecks its siblings by setting `IsChecked` in code, which silently kills the binding (stackoverflow 7376373). Each setter always re-raises, so a refused write (the vertical Chat layout is rect-only) makes the UI snap back. Guard: `ytLive.Tests/PropertiesHierarchyTests.cs`
|
||
- **One list for the offered colours (TASK 60):** the border-colour drop-down builds its menu from
|
||
`Models/BorderColorPalette.cs` (`NamedColor(Hex, Name)` + `NameOf(hex)` — **empty** for a colour
|
||
outside the palette, "no name" being the honest answer), NOT from a list inside
|
||
the click handler — the creator asked for a colour NAME next to each hex (`#e94560 Crimson`), and a
|
||
hard-coded `string[]` cannot carry one. A palette the creator may want to extend belongs in a
|
||
testable catalogue, with the UI as a consumer. **TASK 62:** the field that shows the *current* colour
|
||
shows the name beside it too (`SceneElement.BorderColorName`), and the hex field keeps a fixed width
|
||
so the name stays next to the hex instead of drifting to the row's far edge
|
||
- **A picker IS the app's drop-down (TASK 63, 2026-10-21):** the border colour is now a
|
||
**`YtComboBox`** — the same widget the Display and Font pickers wear — not a `...` button that
|
||
built a `ContextMenu` in code. The creator's complaint (*"one of the first things we did was define
|
||
branding. We established look-and-feel standards. And yet you keep ignoring these"*) had a precise
|
||
cause: the TASK 58 sweep looked for `ComboBox`/`CheckBox`/`TextBox` **types**, and the border picker
|
||
was neither — it was a hand-rolled `Button` + code-built menu, so it was never in scope, and the
|
||
guard could not see it. **Lesson: a style sweep must ask "is this the app's widget?", not "is this
|
||
control type themed?"** — anything hand-rolled that *looks* like a picker (a button that opens a
|
||
menu) is invisible to a type-based guard. The hex field stays (a custom colour can still be typed);
|
||
the drop-down carries the swatch and the colour's NAME, the rows carry swatch + hex + name.
|
||
Guard: `ytLive.Tests/BorderColorPickerTests.cs`
|
||
- **A ComboBox's CLOSED box and its ROWS can differ — only through a template selector (TASK 63):**
|
||
WPF copies `ItemTemplate` into `SelectionBoxItemTemplate`, and that property is **read-only in
|
||
C#** ("Property or indexer 'ComboBox.SelectionBoxItemTemplate' cannot be assigned"), so a plain
|
||
`ItemTemplate` renders the SAME content in the closed box and in every row. The one mechanism that
|
||
tells the two apart is an `ItemTemplateSelector`: WPF calls it with the `ContentPresenter`'s
|
||
**templated parent** — the **`ComboBox`** when the closed box renders, the **`ComboBoxItem`** when a
|
||
row renders. `Helpers/ClosedBoxTemplateSelector.cs` does exactly that (`ClosedTemplate` for the
|
||
ComboBox, `RowTemplate` for the ComboBoxItem). A `ComboBoxItem.ContentTemplate` set through an
|
||
`ItemContainerStyle` does **not** work: WPF overwrites the container's `ContentTemplate` with the
|
||
ComboBox's `ItemTemplate` (null) — so use the selector.
|
||
- **The theme's drop-down popup sizes to its CONTENT, not to the ComboBox (measured 2026-10-21):**
|
||
the `YtComboBox` popup carries no width binding, so the pop-up is as wide as its widest row
|
||
(measured 142.7px for the border-colour rows against a 100px ComboBox) — a row is never clipped,
|
||
and the popup may be wider than the closed box. Measure (`RenderTargetBitmap`/`ActualWidth` in a
|
||
throwaway test), don't guess. **And a `Popup`'s visual root (`PopupRoot`) has a `null` visual
|
||
parent (`VisualTreeHelper.GetParent(popupRoot) == null`)** even though `PopupRoot` IS a `Visual`:
|
||
its only link back to the `Popup` and the owning `ComboBox` is `LogicalTreeHelper.GetParent`. Any
|
||
ancestor walk used by a window-level click-outside guard (`LeftPanel.IsDescendantOf`) MUST fall
|
||
back to `LogicalTreeHelper.GetParent(child)` when `VisualTreeHelper.GetParent(child)` returns
|
||
`null`, or clicking a `ComboBoxItem` inside `PART_Popup` looks like an outside click and
|
||
deselects `SelectedElement` before the item click registers (fixed 2026-10-22).
|
||
- **A field in a dense panel carries a FIXED width (TASK 61):** a WPF element sizes itself to its
|
||
content, and in a `DockPanel` the **last child fills the row** — so a row of same-kind fields looks
|
||
broken unless every field has an explicit `Width` AND the row sets `LastChildFill="False"`. The
|
||
geometry fields are 44px: the measured width of `-1920` in the app's font at 11px (via WPF's
|
||
`FormattedText` — **measure, don't guess**) plus the field's padding and border. Guard:
|
||
`ytLive.Tests/GeometryFieldWidthTests.cs`
|
||
- Resolution tiers: 1080p60@8 (default) → 1080p30@8 → 720p60@6 → 720p30@6 → **Vertical 1080p30@8 and 1080p60@8 (9:16, 1080×1920)**. The composition master frame is **always 1920×1080** — a tier is an output rect + target resolution over that master, so source geometry is never rewritten (no rounding drift). 16:9 tiers use the full frame; vertical tiers use a centered **607×1080** window and the preview dims the cropped side strips at 55% black with an accent outline (semi-crop — the cut area stays visible). A resolution badge in the preview corner shows the active tier. The resolution picker and live readouts (bitrate/FPS/dropped/duration) are in the top-left cell above the layers panel; the bottom bar is an empty height-preserving spacer. A **tooltip** explains finding upload bandwidth — an in-app speed test was deliberately dropped (unreliable). The future encoder crops the master to the rect and scales to the tier's Width×Height
|
||
- **Locked output tiers stay visible and clickable.** They are dimmed for non-owners. Choosing one keeps the free tier selected and opens About in-app; only the explicit llamacasty.com link opens a browser.
|
||
- Crash diagnosis: `AppLog` writes startup checkpoints to `%APPDATA%\ytLlive\startup.log`; `App.xaml.cs` logs `DispatcherUnhandledException`/`AppDomain.UnhandledException`. When WPF won't run from WSL, this log is how you find the failure (it caught the `MenuItemRole.Separator` XAML crash and the ComboBox SelectionBoxItem bug). **The log is SIZE-CAPPED (TASK 55, 2026-10-22):** at 5 MB `startup.log` rolls to `startup.1.log` (one rolled copy kept) — the policy lives in `Helpers/LogRotation.cs` as a pure function over a path, so `AppLog`'s 156-call-site surface never changes and the rule is testable on its own. Guard: `ytLive.Tests/AppLogRotationTests.cs`
|
||
|
||
## ⚠️ Known issue: screens/layers settings audit — RETIRED INTO THE GOLD PASS (2026-08-22, dispositioned 2026-09-01)
|
||
|
||
The AI hallucinated through multiple commits that night on background/scene property placement, repeatedly misreading the spec and introducing bugs. Specifically: moved properties to the wrong superclass level, used `new` instead of `override` breaking WPF bindings, removed working code, and added UI elements that weren't spec'd. The audit was never run since. The creator ruled (2026-09-01): it is a **going-gold requirement**, not session debt — the fine-tooth-comb pass (every screen's Background layer properties, pill toggle persistence, context menu visibility, "Add Layer"/"(+)" menu items) is a named checklist item under **TASK 36 (gold pass)** and this landmine is closed here.
|
||
|
||
## Control Surface UX (TASK 19/23 foundation; surface redesign started 2026-10-26)
|
||
|
||
The app is a **director's control surface**, not an editor. This replaces the OBS-style scene list with a TV-control-room metaphor.
|
||
|
||
**Current layout (2026-10-26 redesign commits):** `MainWindow` has a top bar and health banner, then a two-column work area with a fixed 360px left column and preview column. Its top row holds stream readouts/resolution on the left and the five-scene strip above the preview; the lower row holds the left panel and central preview. The bottom bar is now only a height-preserving spacer.
|
||
|
||
- **Left panel:** `EditingActive` is an explicit `EDIT` toggle, not a mode inferred from `StreamStatus`. On = scene layers + properties; off = a two-tab panel with **Live Chat** first/default and **Logs** second. Logs is a read-only view of up to 500 `AppLog` entries captured in this process; it does not load older `startup.log` contents or change file logging/rotation. Entries wrap and use the theme's type colors. Explicit Class 1/2 severity is independent of message type/source: Class 1 sorts above Class 2 in the pinned group; acknowledging unpins the entry but retains it in the chronological list. The staged scene is the preview/edit target; `LiveScene` is the program output.
|
||
- **Settings is a right-edge drawer, not a modal (2026-11-01):** the gear rail tab opens `Controls/SettingsDrawer` in the same animated-width stack as Stream Settings / YPP / Socials / TEST (`SettingsDrawerOpen`, exclusive with the other four). Categories are section headings with their controls indented under them — Zoom holds *Zoom view shape* and *Zoom box follows*. The app menu (Report Bug / Feature Request / About) hangs off a **right-click on the LlamaCasty wordmark**; left-click still opens About. The modal shell keeps only Bug / Feature / About, which is why it no longer needs a scroll viewer.
|
||
- **Top bar:** the `EDIT` toggle sits beside the REC/ON-AIR session controls; the brand and account surfaces remain at the sides. Its background uses the idle surface color when stopped. Normal ON-AIR stays `Brush.Primary` red while connecting and turns `Brush.Positive` green only after YouTube reports the broadcast `live`; stopping restores the idle color. TEST stays gold for the entire test session. Its **right edge aligns with the layers panel below** (`TopBar` column 0 is a fixed **352** = work-area margin 8 + 360px column − the bar's own 16px padding), so the button and the panel share an edge. The **settings gear moved to the preview rail** (2026-11-01): first tab, orange (`Brush.Attention`), gear icon only — left-click opens the app menu (Settings / Bug / Feature / About), right-click goes straight to App Settings.
|
||
- **Preview gestures (2026-11-01):** *edit mode* — left selects/drags/resizes, right opens the context menu, **Ctrl+right toggles the zoom box**. *Preview mode* — **left toggles the zoom box**, right opens the context menu; **Ctrl+right does nothing at all** (no chord while live or recording — one hand, always). ⚠️ **Both the Down and the Up event must be handled for a Ctrl+right-click**: WPF's `PopupControlService` opens a context menu on right-button-**up**, so suppressing only the down event leaves the menu appearing under the gesture (found 2026-11-01 in edit mode). Menus exist on the webcam layer and the backdrop; every other layer right-clicks through to the backdrop menu. Left-dragging with the zoom box open moves it, in both modes. **Ctrl-down closes it**: pressing Ctrl while the box is open dismisses it, so the same hand that opened it can put it away (`MainWindow_PreviewKeyDown`; the box cannot cancel itself on creation because Ctrl is already down before it exists). **Follow mode** is a Settings pill, default **off = Anchor**: with Follow on, dragging re-anchors the capture to the box centre (`ZoomFollowsBox`, persisted as the `Zoom.FollowBox` setting). **The gesture activates the box at the cursor**, not merely dismisses it: `ActivateZoom` no longer refuses when idle (it used to return unless `IsLive || IsRecording`, and `ToggleZoom` only ever dismissed — so the gesture did nothing at all outside a session until 2026-11-01). The social bar consumes its own click (toggle top ⇄ bottom), and the preview's tunnelling handler steps aside for it — otherwise the bar is unclickable in preview mode and drags the layer beneath it in edit mode.
|
||
- **Preview undo/redo (2026-11-01):** `Ctrl+Z` / `Ctrl+Shift+Z`, window-level bindings, live only while `EditingActive` — undo with the preview showing program output would rewrite a scene nobody is looking at. `Models/PreviewHistory` snapshots element **references** plus geometry, visibility, order and membership, so move / resize / reorder / add / remove / hide all undo in one step per gesture (`PushPreviewUndo` is called once at drag or resize start, never inside the move loop). Property edits (colour, opacity, border, font) are **not** captured yet — stated in the type's doc comment so nobody assumes otherwise. The stack clears on layout load. Guard: `ytLive.Tests/PreviewHistoryTests.cs`.
|
||
- **Developer affordances are compiled out of release, not hidden.** Any control that must never ship (today: the all-resolutions override) lives in its own `UserControl` whose `Page` and `Compile` items are removed by `ytLive.csproj` when `$(Configuration) != 'Debug'`, and is added to its host at runtime inside `#if DEBUG` (`OverlayHost`). A collapsed row still exists in the visual tree of every shipped copy; "not compiled" is the only claim worth making. Label such rows with `Brush.Attention` (orange) while they are visible in Debug. Guard: `ytLive.Tests/OutputProfileTests.cs` asserts the build rule, and `Helpers/DevBuild.cs` is the single source for "is this a dev build".
|
||
- **Scene strip:** five fixed-size cards above the preview show the scene name and hotkey; the staged scene carries a `PROGRAM` badge, and staged/live borders use orange. Click stages a scene while idle; while streaming or recording it changes program output and starts the selected transition.
|
||
- **Preview rail:** stream info, YPP, Socials, and TEST are icon/color tabs on the preview's right edge. Socials opens an animated in-preview drawer; it is no longer a standalone modal window. The bottom live-controls row retains TRAX and the game/mic meters and controls.
|
||
- **Central monitor:** shows the staged scene while editing and the program scene when editing is off. Preview transitions snapshot and animate the outgoing picture in every mode; output transitions run while streaming or recording. Go Live itself remains a Cut.
|
||
- **Edit safety:** edit availability follows `EditingActive`; it is no longer a hard lock derived from "offline and not recording." Keep the explicit toggle and output/edit behavior distinct.
|
||
- **Selected-layer preview access:** a draggable layer selected from the list temporarily gets the highest preview `Panel.ZIndex` and first priority in the preview's custom hit test, so it can be grabbed even underneath a higher scene layer. Clearing/replacing selection restores the preview stack; `Scene.Elements` order is untouched, so output and saved z-order do not change. Guard: `ytLive.Tests/SelectedLayerPreviewHitTestingTests.cs`.
|
||
|
||
**Scene thumbnails:** `Scene.Snapshot` is a 320×180 compositor render. The strip uses per-scene `ThumbnailAssetId`, then background art, then a placeholder; existing saved `OnAirImageAssetId` can still replace the staged tile. The old hardcoded CRT-thumbnail and on-air seed paths are empty, so a fresh layout no longer imports that artwork. Minis never render live captures. `DESIGN.md` is the visual standard (tokens — `Themes/Tokens.xaml` implements them); it is not a layout spec.
|
||
|
||
## Global hotkeys (TASK 20 — step 1 shipped 2026-08-24)
|
||
|
||
- `Services/GlobalHotkeys.cs`: `HotkeyId` enum (9 actions), `IHotkeyRegistrar` seam, `Win32HotkeyRegistrar` (`RegisterHotKey`, no modifiers), `GlobalHotkeyManager`. Fixed defaults: F1-F5 canonical scenes, F6 start/end stream, F7 mic mute, F8 desktop-audio mute, F9 TRAX play/pause.
|
||
- `MainWindow.OnSourceInitialized` creates the manager and forwards `Activated` → `MainViewModel.HandleHotkey`; window `Closed` detaches (unregister all).
|
||
- `MainViewModel.HandleHotkey` maps ids to `TransitionToScene`/start-end commands (honoring `CanExecute`)/mute toggles/`TraxLeftClick`.
|
||
- Tests: `RegistrarOverride` static seam mirrors `LayoutPathOverride`. **Landmine:** forcing a WPF window's handle with `WindowInteropHelper.Handle` does NOT raise `SourceInitialized` and returns Zero pre-show — use `EnsureHandle()` in tests; it both creates the HWND and fires the event.
|
||
|
||
## Broadcast metadata pull-out + launch geometry (2026-08-24)
|
||
|
||
**The "Text" tab** (always visible, 2026-08-24 revision): a white 30px tab with rotated YouTube-red "Text" sits on the right edge of the preview, vertically centered — **shown regardless of login/stream state** (creator decision; original IsLive gating removed same day). Click **toggles** the drawer (`ToggleDrawerCommand`); any left-click outside the form also collapses it (`Window_PreviewMouseLeftButtonDown` checks `TextPullOutHost` ancestry), as does the ✕. The drawer is a 320px dark panel over the preview's right edge (200ms CubicEase on `Width`, `BroadcastForm.IsDrawerOpen` drives it). The Update button still greys until `CurrentBroadcastId` exists.
|
||
|
||
**Fields — the always-editable snippet/status set** (⚠️ creator premise inverted: contentDetails fields like latency/DVR/embed are editable ONLY in created/ready, NOT while live; they are deliberately absent):
|
||
- Title / Description / Tags (comma-separated string → `snippet.tags[]`) / Visibility ComboBox (private/unlisted/public) / Made-for-Kids CheckBox — all live-editable pre- and post-launch
|
||
- Scheduled Start is read-only ("set at Go Live")
|
||
|
||
**Plumbing:** `Models/BroadcastMetadata` POCO; `Services/LiveBroadcastFormViewModel` (owns field state, persists every edit to Settings keys `Broadcast.*` immediately via `Func<LayoutStore>` seam — survives layout re-pointing; owns drawer state; `UpdateBroadcastCommand` gated by `CurrentBroadcastId != null && !IsUpdating`). `YouTubeStreamService.UpdateBroadcast` PUTs `liveBroadcasts?part=snippet,status` and **must echo scheduledStartTime — update replaces the whole snippet part**, omitting it clears the schedule. Go Live prefills the dialog from the form (`BroadcastForm.Title/Description`) and calls `CaptureGoLive(title, description, now)` on confirm. `MainViewModel.CurrentBroadcastId` exposes the private `_currentBroadcastId`; notified on create/end.
|
||
|
||
**The old Default Stream Title/Description** (un-persisted, App Settings overlay) are DELETED — the form owns those fields end-to-end; Go Live prefill routes through it.
|
||
|
||
**Launch geometry:** default **1920×1040**, MinWidth **1366**, MinHeight **768**, `WindowStartupLocation=Manual`. Window size/position persist on close via `RestoreBounds` (+ WindowState Normal/Maximized; Minimized treated as Normal) into Settings keys `Window.*`; restored in the MainWindow ctor BEFORE show, clamped to the minimums and the primary `SystemParameters.WorkArea` (a position saved on a since-disconnected secondary monitor falls back on-screen). Multi-monitor restore is not supported yet.
|
||
|
||
Test seam note: `YouTubeAuthService.SetSession()` is public — tests inject a channel with future `TokenExpiry` so `EnsureToken()` passes offline; pair with an `HttpClient(new StubHandler())`.
|
||
|
||
## Toast notifications (TASK 24 — shipped 2026-08-23, plan in TASKS.md)
|
||
|
||
Non-blocking user-facing messages replace blocking MessageBoxes. **Seam:** `INotificationService.Show(title,
|
||
message, AppNotificationSeverity)` (`Services/INotificationService.cs`) + `Info/Success/Warning/Error`
|
||
extension methods; `MainViewModel` news up the real `NotificationService` at its field initializer
|
||
(no DI container — the VM is built from XAML). **Library:** `Notification.Wpf` 11.0.0 (Platonenkov fork,
|
||
MIT — notice #10 in `THIRD-PARTY-NOTICES.txt`). Facts that cost a hunt:
|
||
- The `NotificationArea` XAML control registers itself with the manager on `Loaded`; routing matches
|
||
the area's `Name` against the request's `AreaName` — our host is `x:Name="ToastArea"` in MainWindow's
|
||
root grid (last child = topmost z-order, `Grid.RowSpan=4`, bottom-right above the footer via
|
||
`Margin="0,0,12,96"`, outside the preview Viewbox). An unknown AreaName silently drops the toast.
|
||
- `NeverExpires()` sets `ExpirationTime = TimeSpan.MaxValue` (NOT null); `NotificationColor.ToHex()`
|
||
returns `#AARRGGBB` (alpha prefix).
|
||
- Lifetimes: Info/Success ~4s, Warning ~8s auto-dismiss, Error sticky until dismissed (the pure mapping
|
||
lives in `NotificationService.BuildRequest` + the `Cards` tint table: slate info `#3a3b52`, green
|
||
success `#1d5c38`, amber warning `#b8860b`, dark-red error `#8f1f1f` — matching the health-banner language).
|
||
- Calls marshal to the captured Dispatcher (`_dispatcher.Invoke`) — encoder/pump events fire off-thread.
|
||
|
||
Migrated MessageBoxes: both webcam-acquire warnings, image-read warning, sign-in unsuccessful/failed.
|
||
Promoted from log-only (each keeps its AppLog line): go-live prep failures ×3 (reusable stream,
|
||
broadcast insert, prep exception), frame-pump death while live, mic-missing at startup, premium lapse,
|
||
saved-session refresh failure (sign-out notice). Deliberately left log-only: offline license re-validation
|
||
skip (would spam every launch). Still modal by design: SocialsDialog's slot-delete YesNo confirm.
|
||
|
||
## Transitions (2026-10-27)
|
||
|
||
- **Fade starts from the outgoing picture.** `TransitionService.Start` with no "from" frame sets
|
||
`CaptureRequested`; the frame pump copies its last output at the top of its next tick and calls
|
||
`SupplyFromFrame`. Before that fix the blend had no starting frame and a scene swap was instant.
|
||
Guard: `ytLive.Tests/TransitionFadeTests.cs`.
|
||
- **Type and duration** are selected in App Settings and persisted with the layout. Cut, Fade, Move,
|
||
and Wipe are offered; Wipe is a CPU left-to-right reveal. Guard: `ytLive.Tests/TransitionPickerTests.cs`
|
||
and `ytLive.Tests/WipeTransitionTests.cs`.
|
||
- **A scene change animates the outgoing preview in every mode.** Fade and Move fade it out; Wipe
|
||
shrinks it away; Cut is immediate. Program output transitions when streaming or recording, while
|
||
offline scene changes affect only the preview. Guard: `ytLive.Tests/PreviewTransitionTests.cs`.
|
||
- **Still planned (see TASKS.md):** persistent layers for Move and a capability-gated GPU Cube.
|
||
|
||
## Brand logo (2026-10-27)
|
||
|
||
- One locked image layer per scene, `Source.IsBrandLogo` (persisted column, additive migration), named
|
||
"Brand logo", directly above the background. Healed on load by `EnsureBrandLogos`, which also copies the
|
||
App Settings image into every scene's layer. Removal is blocked like the background.
|
||
- The image lives in the asset table; the settings table keeps its asset id and file name
|
||
(`BrandLogoAssetId` / `BrandLogoFileName`). Eye visibility is the layer's ordinary per-scene `IsVisible`.
|
||
- Default placement: top-left (32,32), 240x120 master px.
|
||
- **Properties panel typography (2026-11-01):** one hierarchy for every section, defined in
|
||
`Themes/Controls.xaml` — `PropertyHeader` (category: 11px semibold, `Brush.OnSurface`, the
|
||
brightest text), `PropertySubHeader` (a field label inside it: 10px semibold, indented 10,
|
||
`OnSurfaceVariant`), `PropertyFieldLabel` (a label sharing a row with its control: same
|
||
typography, vertically centred, no margin), `PropertyHint` (the explanatory sentence: 10px,
|
||
wrapped, dim). Colour carries the grouping — bright for the thing being edited, dim for its
|
||
labels and help — and no new palette entries were needed. ⛔ No literal hex in the panel: the
|
||
four `#888`/`#666`/`#555` values that had crept in are now `OnSurfaceVariant` /
|
||
`OnSurfaceDisabled`. ⛔ **Every checkbox in the properties panel is a pill switch**
|
||
(`PropertyPillCheck`, 32×16 track with an animated thumb) — a 14px box reads as a form, a switch
|
||
reads as a setting; `IsChecked` semantics are untouched so all bindings still work.
|