Commit Graph
76 Commits
Author SHA1 Message Date
gramps e6abc13461 feat(scoreboard): a DEBUG-only "Simulate" switch, so the strip is provable in the preview
Creator (2026-10-30): pre-1.0, use DEBUG — add a toggle that simulates the ticker so the strip can
be SEEN and its compositing verified before a real stream.

The sample travels the REAL ScoreboardRecorder a stream uses (one new member, a gift of three, a
milestone, a $12.34 Super Chat), so what the preview shows is exactly what live events produce — a
fake renderer would prove nothing. Toggling off clears to an empty session, so the empty state is
visible too.

Debug-gated at COMPILE time: `DevelopmentBuild` is a const true in DEBUG / false in Release, and the
checkbox binds its Visibility to it. A shipped Release build therefore has no dev control at all,
rather than one hidden by a runtime flag.

Guard: ScoreboardSimulationTests — the sample lands all four tallies through the recorder, off clears,
and the panel + const pair exist in the Dual (DEBUG/Release) form.
2026-10-30 14:58:56 -07:00
gramps 38c2ad6555 feat(scoreboard): a master on/off switch that also stops the polling
Creator: "even if it's not visible, we want to avoid polling."

ScoreboardEnabled is the layer's master switch in the properties panel. Off means two things:
- the strip frame is null in every scene (per-scene eye toggles stay independent), and
- the cadence policy returns None at every phase — a scoreboard nobody can see must not spend quota.

The switch is persisted (Scoreboard.Enabled), because one that resets would quietly resume polling.

The polling gate is ScoreboardPollsEnabled: master switch AND at least one scene with a visible
scoreboard — so hiding it in all five scenes also stops the polls.

Guards: ScoreboardCadenceTests adds the switch off across live/test/recording; ScoreboardProperties
pins the checkbox and the VM surface.
2026-10-30 14:54:05 -07:00
gramps 85a4a8a7d3 feat(scoreboard): the layer is available to ALL scenes (YT_ACTIVITY_TICKER)
Creator ruling: the Scoreboard layer exists in every scene rather than being added five times.
ScoreboardHealer.Ensure keeps exactly one per scene — healed on load like the Background and Brand
logo — so a scene can never end up with two, and 'off' is the ordinary per-scene eye toggle rather
than a delete.

It is appended as the TOPMOST layer on purpose: a full-width strip draws over the scene, and
inserting it above the background would break the Brand logo's documented invariant (directly above
the background). That choice is also why the layer-reorder test still passes untouched.

Also: a layer-list icon for the type, so the row is not blank.

Guard: ScoreboardHealerTests — one per scene, topmost, exact strip geometry, idempotent, and a
duplicate healed back to one.
2026-10-30 14:50:20 -07:00
gramps 96d528b3ac fix(scoreboard): clear the two nullable warnings
Both were mine, from the scoreboard units:
- ScoreboardLayout.FitLine null-forgave tileWidths on one expression and then iterated the field
  anyway; it now binds a non-null local once and uses that throughout.
- the output resolver dereferenced _scoreboardPresenter, which is assigned in InitScoreboard — it
  is nullable by construction, so the resolver reads '?.' exactly like the other optional services.

Clean build, 0 warnings, scoreboard tests green.
2026-10-30 14:45:14 -07:00
gramps b205ce9ba9 feat(scoreboard): unit 10 — the polling policy (quiet by default)
ScoreboardCadence.Due(phase, lastAnalytics, lastSubscribers, now) is the creator's rule as a pure
function: watch hours ONLY during a live stream, every 30 minutes (Analytics answers in days and
test/record sessions have no public audience, so querying then is pure waste); subscribers whenever
the app is working, every 60 s; and nothing at all when idle.

Deliberately returns 'no poll' rather than 'analytics only' — a poll kind the poller cannot perform
is a bug waiting for a caller.

Remaining for the scoreboard: the HTTP providers behind this policy (watch hours via Analytics
reports.query, subscribers via channels.list) and the timer that calls Due(). Recorded in HANDOFF.
2026-10-30 14:43:08 -07:00
gramps deb74529a6 feat(scoreboard): unit 6 — it is a LAYER (source type, strip geometry, resolver, session hook)
Adds SourceType.Scoreboard with the same per-scene eye toggle, drag and presence as every other
element — 'prominent on BRB, dialled back on Live' is geometry, not a second mechanism. Default
geometry is the strip: 1920 wide, 56 tall, bottom edge.

The output resolver returns the presenter's cached frame exactly like the chat box and alert box do,
and every session start clears the tallies. Preview publication writes one shared WriteableBitmap on
the UI thread (the pump thread must never touch one), refreshed only when a displayed value moves.

Guard: ScoreboardLayerTests pins the enum member, the geometry constants, the resolver case and the
session hook, so the layer cannot quietly become a special widget.
2026-10-30 14:40:31 -07:00
gramps 41b0cd6e98 feat(scoreboard): unit 5 — tile selection and the cached strip presenter
ScoreboardTiles decides which of the eight facts appear and shapes their text (money uses the
session's dominant currency and flags a second one rather than converting; watch hours carry
'as of {date}' because Analytics is a daily aggregate). Tiles are OPT-IN, so 'None' really means the
layer draws nothing, and 'Default' is the six session facts.

ScoreboardPresenter owns the raster cache: ScoreboardRecorder.Changed fires only on a visible change,
so the strip rebuilds only then — a per-tick raster would be the compositor's most expensive layer
for identical pixels.

Guard: ScoreboardPresenterTests asserts the cache identity (same instance when nothing moved, a new
frame when a value moved, reuse when a recipient-side gift event arrives) and that an empty selection
returns no frame at all.
2026-10-30 14:38:59 -07:00
gramps 359e58a4c2 feat(scoreboard): unit 3 — edge coupling and line fitting, both pure
ResolveEdges: the strip and the social bar are a coupled pair that own opposite edges whenever both
are visible; the social bar keeps its choice and the scoreboard is pushed to the other edge. Hide
either and the survivor keeps its own preference — so hiding the scoreboard frees the social bar,
which is what 'resolved at render time' has to mean.

FitLine: takes MEASURED tile widths (the renderer measures with FormattedText; a guessed character
width would make the fit lie for a long handle), keeps full size when it fits, shrinks above the
14px legibility floor, and only then wraps — never scrolls, because a reader cannot wait for the
fact to come round.
2026-10-30 14:36:56 -07:00
gramps 6b6ccd7b0a feat(scoreboard): unit 2 — session lifecycle (start clears, End freezes)
ScoreboardRecorder owns the one scope the numbers promise. StartSession resets and marks running;
Record ignores everything outside a session (a stale tally from the last stream is exactly the
'today' lie this design avoids); EndSession freezes the numbers but stops them moving, so an End
screen can still show them. Changed fires only when a displayed value actually moves, which is what
keeps the strip's raster cache honest.
2026-10-30 14:36:13 -07:00
gramps 3ed8386303 feat(scoreboard): unit 1 — session tallies, with the gift double-count guard
ScoreboardState folds the six live-chat event kinds into the numbers a creator puts on screen:
last member, last Super Chat, last gift, members gained, milestones, and session money per
currency. Scope is the running session (creator ruling): reset at start, dead at End, no
persistence.

Two rules are load-bearing:
- money is keyed by currency and never summed across them; the tile shows the dominant currency by
  VALUE, so a single EUR tip cannot flip the display away from the larger USD total
- a gifted membership is counted ONCE, from membershipGiftingEvent (which carries the count);
  giftMembershipReceivedEvent is the recipient's view of the same purchase and changes nothing.
  The test fires five recipient events after one purchase of five and the total must not move.
2026-10-30 14:35:42 -07:00
gramps a7d1afc70b fix(dock): spans the preview column and loses 20px
- The dock now sits in the preview column (Grid.Column=1), so its left edge is the layers panel's
  right edge instead of starting under the brand column. It is a preview-column control, not a
  full-width footer.
- Expanded height 168 → 148 (112 bays + 36 master strip): the mockup's number read as wasted space
  beneath the 154px scene strip.

Guard: AudioDockLayoutTests pins 148 as the design number, so the constant cannot drift silently.
2026-10-30 12:39:36 -07:00
gramps 1a9e8b8d36 feat(audio dock): the shell — 168px, auto-collapsing, replacing the empty bottom bar
Unit 4 of the sound-bar refactor. `Controls/AudioDock` takes the bottom row that held the empty
`BottomBar` spacer (deleted): four bay containers (MIC / GAME / TRAX / LIP-SYNC) above a 36px
master strip, all token-styled.

Height comes from `Models/AudioDockLayout`, not from a setting: 168px normally, 36px below a 900px
window height. At the 1366×768 minimum a full dock leaves ~398px for the preview, so the collapse
is mandatory, not cosmetic. The dock listens to its window's SizeChanged and re-applies.

Nothing here pretends to be a meter yet — the bays and the master readouts fill in over the next
units, each with its own test.

Guard: AudioDockLayoutTests pins both sides of the threshold (768 collapsed, 1040 expanded), the
boundary itself, and the two heights as the design's numbers.
2026-10-30 12:37:17 -07:00
gramps 0f8647790d fix(top bar): a signed-out ON-AIR press starts sign-in instead of disabling the button
Reported: EDIT on + ON-AIR on, signed out → the button reads "Test Stream" and cannot be pressed.
Cause: the primary button bound straight to StartTestCommand, whose CanExecute needs a connection.
StartSession already routed signed-out ON-AIR to the sign-in flow; the test path bypassed it.

The route is now explicit and pure: RouteFor(kind, connected) → SignIn | Record | Live | Test. The
primary command calls SignInAsync for the SignIn route, BeginTestStream for Test, StartSession
otherwise — so the label, the action and the sign-in fallback cannot drift apart. Pinned in
SessionStartDecisionTests for both live and test, connected and not.
2026-10-30 11:17:11 -07:00
gramps fbeb04385b feat(top bar): EDIT decides live vs test on the primary button — the TEST button is gone
Creator ruling (2026-10-30):
- EDIT off + ON-AIR armed → the primary button reads "Go Live" and runs the livestream.
- EDIT on + ON-AIR armed → the same button reads "Test Stream" and runs the test broadcast.
- REC armed (or nothing armed) → "Start Recording", whatever EDIT says; there is no test
  recording, and an unarmed start records locally rather than doing nothing.

So the fifth button was redundant: `ShowTestButton` no longer feeds any control. The decision is a
pure function (`Models/SessionStartDecision`) so the whole matrix is testable without a window;
`PrimaryStartCommand` follows the same kind, so label and action cannot disagree.

Guard: SessionStartDecisionTests pins the four cases, including the no-dead-ends fallback that my
first version got wrong (unarmed returned Live instead of Record — the test caught it).

Note for the v1 gate: the "hide TEST at v1" item is now satisfied by construction — the test path
is a mode of the primary button, not a separate control.
2026-10-30 11:14:33 -07:00
gramps c82636e1a9 feat(socials): bar appearance — text colour, background plate, opacity, text shadow
In the Socials drawer, under the show/hide switch: text colour, an optional background plate
with its own colour and opacity, and a text drop shadow. The bar had been hard-wired
white-on-transparent since it shipped.

- `SocialsConfig` gains five additive properties (BarTextColor, BarBackgroundEnabled,
  BarBackgroundColor, BarBackgroundOpacity, BarTextShadow).
- Persistence: five new Socials columns, added by the per-column idempotent migration pattern
  (a half-migrated DB heals instead of failing), written in Save and read back in Load with
  defaults for pre-existing rows.
- `SocialBarRenderer.Render(entries, SocialBarStyle?)` — the look is a separate value type so the
  renderer stays a pure "entries + look" function and the preview can never read a half-applied
  config mid-edit. A malformed hex falls back to white/black instead of throwing inside a render
  tick.
- The plate hugs the entries (rounded 20px, padding) so it reads as a caption bar, not a band
  across the frame. Off by default, because transparent is what creators have today.
- The colour pickers reuse `BorderColorPalette` — one catalogue of the app's colours, per the
  TASK 60/63 rule.

Guard: SocialBarAppearanceTests paints the real strip and inspects pixels — default is white with
no plate, a red text colour reaches the glyphs, a 55% blue plate yields half-transparent blue
pixels, and the shadow adds dark pixels that were not there before.
2026-10-30 08:47:50 -07:00
gramps 841c905c06 Brand logo: one locked image layer per scene above the background, image from App Settings
- Source.IsBrandLogo persisted (additive migration); layer healed on load, removal blocked
- Image stored as an asset; settings keep the asset id and file name
- App Settings: Brand Logo file field with Browse
- Default placement top-left; per-scene eye uses the existing element visibility
Test: BrandLogoTests; layout/background tests pass (34). Not pushed.
2026-10-27 09:25:49 -07:00
gramps 3239808a73 Layers panel and scene strip: scene layers header with icons, per-type layer icons, PROGRAM badge and selected/live orange borders, scene name and hotkey overlays, Starting as default scene
- LeftPanel: SCENE LAYERS header with layer count; per-type blue icons; properties header gear; layers list min/max height; properties placeholder; list and properties indent
- SceneThumbnailStrip: names/hotkeys over the tile; PROGRAM badge with blinking red dot on the selected tile; orange border on selected and live tiles; fixed the element border override that hid the style border; reflag tiles on load
- LiveScene is now set on go-live, record start, and live transitions (it was never assigned, so IsLive never fired)
- Startup stages the scene named Starting
- DESIGN.md: color roles (red = YouTube/on-air/record, orange #ff8c3a = attention, amber = game audio/Super Chat)
- Staged live tile uses background art instead of the green LIVE placeholder

Not pushed.
2026-10-26 14:00:23 -07:00
gramps 987fddac24 TASK 63: the border-colour picker IS the app's own drop-down
The creator (2026-10-21), on Live -> Layers -> webcam -> Properties:

  "why does the drop down under the web cam properties not show the same style for
   the border color drop down list box?"

  "one of the first things we did was define branding. We established look-and-feel
   standards. And yet you keep ignoring these and I'm wondering why"

WHY IT WAS MISSED - the answer the creator is owed.
The TASK 58 sweep had already walked the whole app for un-themed controls and the
border picker STILL was not themed, because the sweep looked for control TYPES
(ComboBox / CheckBox / TextBox) carrying no theme style - and the border picker was
NONE of those. It was a "..." Button whose Click handler built a ContextMenu of
swatches in code. It was never in the sweep's scope, and PropertiesStyleTests.cs
(a type-based guard) could not see it either. A picker that LOOKS like a drop-down
is invisible to a type-based audit; the guard has to ask "is this the app's widget?".

WHAT CHANGED
- The picker is now the app's YtComboBox - the widget the Display and Font pickers
  wear - bound to the palette (SelectedElement.BorderColorOptions) with its selection
  bound by VALUE (SelectedItem -> BorderColorEntry), because WPF's SelectedValue
  matching is ordinal and the palette mixes #FFFFFF and #e94560.
- The closed box shows the swatch + the colour's NAME; the rows show swatch + hex +
  name (the rows the creator approved in TASK 60). A ComboBox cannot do that with one
  ItemTemplate - WPF copies it into the read-only SelectionBoxItemTemplate - so
  Helpers/ClosedBoxTemplateSelector.cs picks by the ContentPresenter's templated
  parent: the ComboBox for the closed box, the ComboBoxItem for a row. Verified
  against WPF with a throwaway probe, not assumed.
- The swatch is painted by the THEME (Helpers/HexToBrushConverter.cs, registered as
  HexToBrush) - the hand-painted SolidColorBrush(Color.FromRgb(...)) in the old
  click handler was the exact anti-pattern the theme forbids. The swatch also carries
  a 1px #2a3a5e border now, so black and dark swatches are visible on the dark menu
  (the follow-up TASK 60 left open).
- The hex field stays (60px, editable): a colour outside the palette can still be
  typed, and the drop-down then carries that hex as a NAMELESS entry ("no name" is the
  honest answer). The palette's name moved out of TASK 62's TextBlock into the
  drop-down's closed box.
- The hand-built menu, its ContextMenu/MenuItem/SolidColorBrush code and
  WebcamBorderColorPicker_Click are gone.

MODEL: SceneElement.BorderColorName -> BorderColorEntry (the palette entry for the
current hex, with its name; setting it writes the hex back) + BorderColorOptions (the
palette plus the custom entry), both raised when BorderColor changes.
BorderColorPalette gains EntryFor(hex) and OptionsFor(hex); NameOf delegates.

Guards: ytLive.Tests/BorderColorPickerTests.cs (the ONE test for this change - the
model, the panel, and the REAL widget: with the app's theme style and the panel's own
templates the closed box names the colour and the rows show the hex AND the name).
The TASK 60 guard (BorderColorPaletteTests) was updated because the mechanism it
asserted - a code-built menu - no longer exists; it now guards the new wiring (the
drop-down is bound to the palette and neither the panel nor the code-behind carries a
second list of colours).

Full suite: 435/435 passed, 0 warnings.
2026-10-21 23:09:11 -07:00
gramps ef52508b1e TASK 61 + 62: the geometry fields share one width, and the border field names its colour
The creator (2026-10-21), on Live -> Layers -> webcam -> Properties:

  "the Y and H boxes under webcam properties should be the same width as the X and W
   boxes and those widths should be fixed to accomodate the max number of possible
   digits providing the geometry values"

  "also the border color drop drown box content should also include the name of the
   color next to the hex value as they in the drop-down list box"

TASK 61 - one fixed width for the four geometry fields.
The row is a DockPanel, and WPF sizes an element to its CONTENT unless it is told
otherwise, while the LAST child of a DockPanel fills the remaining space. So X/W (not
last) were as wide as their text and Y/H (last) stretched across the row - two widths
for the same kind of field. Now all four carry Width="44" and the rows set
LastChildFill="False" (without it the last field ignores its own Width and fills anyway).
44px is the measured width of "-1920" in the app's font at 11px (the largest geometry
value - the master frame is 1920x1080 - plus a sign) plus the field's padding and
border. Docs: https://learn.microsoft.com/en-us/dotnet/api/system.windows.controls.dockpanel.lastchildfill
The guard MEASURES the widest value with WPF's FormattedText instead of asserting a
magic number, so a font change cannot silently make the field too narrow:
https://learn.microsoft.com/en-us/dotnet/api/system.windows.media.formattedtext

TASK 62 - the border field names its colour.
SceneElement.BorderColorName carries the palette's name for the current colour
("Crimson") and is raised when the colour changes; it is EMPTY for a custom hex the
creator typed, because "no name" is the honest answer. BorderColorPalette.NameOf
changed to match (it used to fall back to the hex itself; the TASK 60 test assertion
was updated with it). The hex field keeps a fixed width (60) so the name sits right
next to the hex instead of drifting to the row's far edge - the same trap as TASK 61.

Guards: ytLive.Tests/GeometryFieldWidthTests.cs, ytLive.Tests/BorderColorNameTests.cs
Full suite: 435/435 passed, 0 warnings.
2026-10-21 22:19:13 -07:00
gramps 6271bf34c9 TASK 60: the border-colour drop-down names every colour
The creator: "under Live->Layers->webcam->Properties->border color: in the drop
down box, the #hex values are good, but add a corresponding color name/label next
to the value."

Each row now reads "#e94560  Crimson" - the hex stays exactly as it was, the
colour's name goes next to it (dimmed #a0a0b0 so the hex reads first), and the
tooltip becomes "Crimson (#e94560)".

The 14 colours were a hard-coded string[] inside the click handler - a list of
bare strings cannot carry a name, and a list hidden in UI code cannot be asserted
on. They now live in Models/BorderColorPalette.cs: NamedColor(Hex, Name) +
BorderColorPalette.All (the single source of truth) + NameOf(hex), which answers
case-insensitively and falls back to the hex for a colour outside the palette.
The picker builds its menu from the catalogue and no longer knows any colour by
heart.

The names are chosen not to collide - the palette has two greens, two blues and
two warm colours: #00FF00 is Lime and #22c55e Emerald, #0000FF is Blue and
#3b82f6 Azure, #FF8800 is Orange and #f59e0b Amber.

Guard: ytLive.Tests/BorderColorPaletteTests.cs - every entry valid + named, no
hex or name repeated, the 14 legacy colours all still offered, NameOf answers,
and the old list is gone from the handler. 433/433 green, 0 warnings.
2026-10-21 20:03:19 -07:00
gramps 89f6aa3a67 TASK 59: the properties panel is a hierarchy, and the shape is a choice
The creator's ruling on the webcam's properties: a "Shape" title above the
Mirror/Rect row; the drag hint indented under that row "to show parentage";
the same for Geometry, Border Color, Border Thickness; Remove and Blur indented
under Background; Strength indented under Blur.

One rule covers it: a title owns its content and the content is indented one
level (10px) under it - two levels where a title is itself nested (Strength
under Blur under Background). The titles are SemiBold 11px #a0a0b0 like the
ones already there.

The shape also stops being a toggle button that shows the NEXT action ("Round"
when the camera was a rectangle) and becomes a CHOICE: two segment options,
Rect | Circle, with Rect selected by default - which is what the creator asked
for ("Rect should be selected as a default if none exists"). The old
ShapeButtonText is gone.

The mutual exclusion lives in the MODEL: IsRectShape/IsRoundShape on
SceneElement, each setter flipping the shape through ToggleClipShape() (so the
pre-round rect is still stored/restored) and always re-raising, so a refused
write - the vertical Chat layout is rect-only (TASK 57) - makes the UI snap
back instead of sticking. Deliberately NOT a WPF RadioButton group: a group
unchecks its siblings by setting IsChecked in code, which makes WPF drop the
binding (stackoverflow 7376373; the same trap measured in 50980687/57275046).

The Circle option stays hidden where CanToggleClipShape is false, so the circle
is still not offered in the vertical Chat layout.

Guard: ytLive.Tests/PropertiesHierarchyTests.cs - the model half (Rect by
default, pick circle, pick rect, the locked Chat layout refuses the circle) and
the panel half (the Shape title owns both options, the circle is the gated one,
the hint is indented and follows the row, every titled block's content is at
10px, Strength at 20px). 432/432 green, 0 warnings.
2026-10-21 19:58:28 -07:00
gramps 3a77662a95 vv webcam Unit 2: the Chat scene in the vertical tier - rect only, tile or one whole 607x540 area
The creator's locked table (2026-10-17) gives the Chat scene its own rules in
the 9:16 tier: RECT ONLY - the circle option is not offered - and a size that
is either the normal small tile or ENLARGED to fill one whole 607x540 area
(top or bottom). Unit 1 shipped the per-scene placement; this unit is the Chat
scene's own variant of it.

The rule lives on the MODEL, not on the button:
- Models/SceneElement.cs: ClipShape and ToggleClipShape() are now virtual.
- Models/WebcamSceneConfig.cs: VerticalShapeLocked (set per scene by the VM)
  OVERRIDES the ClipShape property, so no writer can slip a circle in - the
  toggle, the Shape button and the placement swap all go through it. The lock
  is cleared BEFORE the 16:9 layout comes back, so the desktop shape returns.
- ViewModels/MainViewModel.Webcam.cs: CanToggleClipShape (the circle is NOT
  offered in the vertical Chat layout - the Shape button disappears, it is not
  merely greyed out), IsChatSceneCamera, ResizeWebcamInVerticalTier.
- Models/VerticalCameraLayout.cs: FillsArea (the "enlarged" state, derived -
  no new column) and SizeFor - the Chat camera's TWO sizes, the tile or the
  whole area, whichever is nearer the requested width, so the resize handle
  has two detents instead of any size in between.
- An enlarged camera FILLS the area its centre is in (top or bottom), so
  dragging it up/down swaps the areas.
- The Chat camera is auto-tiled like the Live one when the vertical tier is
  picked - otherwise the vertical chat stream has no camera at all.
- Controls/PreviewPane.xaml.cs: the resize routes through the VM; the drag
  assigns, lets the model clamp into the band, then snaps (so the enlarged
  camera can be dragged from the top area to the bottom one).

Test: ytLive.Tests/VerticalChatCameraLayoutTests.cs - the ONE Good Dog test for
the unit: the vertical tier is picked -> the Chat camera comes into the band as
a small RECT tile -> the circle option is not offered and cannot be set even on
the model -> dragged up and enlarged it fills the TOP area exactly -> back to
16:9 the desktop layout is restored exactly and the circle option is offered
again. 430/430 tests pass, 0 warnings.

Research (the creator's table is the established practice, not our invention):
- Streamlabs' vertical guide: "splitting the screen in half horizontally,
  placing the camera feed in the top half and the main content on the bottom
  (or vice versa) may be the bet", and a just-chatting vertical wants the
  camera "to take up most of the screen"
  (streamlabs.com/content-hub/post/overlays-vertical-and-horizontal-streaming)
- Bloopbot's vertical overlay docs describe the same per-layer model we use at
  scene granularity: "move or resize a layer on Vertical and it stops being
  Auto-placed. It keeps the place you gave it, and the horizontal layout is not
  touched" (bloopbot.com/docs/vertical-overlays-twitch-dual-format) - our
  swap-at-tier-change, stored per scene.
- Landmine recorded: "about the bottom 560 px of the vertical video can be
  covered by Twitch's chat and player controls on phones" (Bloopbot) - the Chat
  camera's enlarged BOTTOM area may be half-hidden by the platform chrome;
  TASK 53's safe-zone work decides that.

Memory in the same commit: ai.md (the Chat rules, the shape lock, the tests),
TASKS.md + TASKS/task-57-vertical-camera-layout.md (TASK 57 complete), HANDOFF.md,
MyMistakes.md (3 recipes: lock the model property not the UI button; two detents
for a discrete size set; the edit tool cannot match an oldString with an em
dash), Models/ViewModels/Controls index rows.
2026-10-21 19:23:29 -07:00
gramps 6be062c45f vv webcam Unit 1: the vertical (9:16) tier keeps its OWN camera placement per scene
The vertical tier is a centred band crop of the SAME 1920x1080 master (band x
656.25, w 607.5, h 1080), so the 16:9 camera placement (x~1408) falls outside the
cropped window and the creator is simply not in the vertical stream. Each scene
now keeps its own vertical position, size and shape; switching back to 16:9
restores the desktop layout exactly (the creator's locked table, 2026-10-17).

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

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

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

Memory in the same commit: ai.md (new section), TASKS.md + TASKS/task-57-*.md
(Unit 1 shipped, Unit 2 = the Chat scene still open), HANDOFF.md, MyMistakes.md
(the per-tier geometry-swap recipe, the binding-text panel trap, the store's
webcam-identity rule) and the Models/Services/ViewModels/Controls index rows.
2026-10-21 08:58:24 -07:00
gramps 931ab46783 Pinned chat message + brand wordmark assets. Chat can pin one message to a banner above the feed: ChatMessage.IsPinned, MainViewModel.PinnedChatMessage + Pin/ClearPinnedChatMessageCommand (Helpers/RelayCommandGeneric.cs), LeftPanel pinned-message border bound via NotNullToVis with the feed nudged down. 2026-10-10 14:54:08 -07:00
gramps 8e1d6f44db Scene strip tile artwork + on-air indicator (creator ruling 2026-10-10). Top-bar tiles now carry their own art: each canonical scene's tile is a per-scene CRT-styled image stored in the internal DB (Scene.ThumbnailAssetId - additive migration, seeded at heal from the creator's five Downloads JPGs via SceneThumbnailSeedPaths), and the preview pane keeps rendering the scene's ORIGINAL background - never the tiles. One shared on-air image (Gemini 4zajyl...jpg) replaces the staged scene's tile and follows the scene swap ('the on-air screen jumps around the top bar as the user swaps scenes'): stored as an Asset BLOB tracked by the OnAirImageAssetId Setting (EnsureOnAirImage, same contract as the alert clip) with MainViewModel.OnAirSeedPath override; RefreshSnapshotsAsync shows it for the staged scene, otherwise thumbnail-or-background art, and a staged Live with no on-air art still falls back to the green placeholder (minis never render live captures). Removed the 6px #22c55e rectangle in PreviewPane's backdrop ('green outline around the preview screen' that surfaced when a scene had no background) - the outer PreviewGlow red border is the real live signal. Doubled the scene-tile labels (FontSize 10 -> 20). Fixed a real bug this surfaced: Save()'s Asset pruner only kept Source/alert-clip rows, so the first save would have DELETED the seeded tile + on-air assets - the keep-set now includes Scene.ThumbnailAssetId and the OnAirImageAssetId Setting. New Settings/Load/Save migrations + SceneThumbnailTests (4 tests: seeded tile in strip, preview keeps original bg, Live-staged tile, no-seed fallback, on-air swap + persistence). 427 pass, 0 build warnings. (Unit: strip tiles + on-air) 2026-10-10 13:27:26 -07:00
gramps 352f117cb2 TASK 51 slice 5: webcam Background section in the Layers properties — Remove/Blur PillToggles (radio-exclusive) + Strength slider; strength changes never blank the webcam (new variant inherits the same-effect frame + bitmap). TASK 51 built (Unit: bg-effects UI) 2026-10-09 10:31:11 -07:00
gramps 55210b9a61 TASK 51 slice 3: per-scene webcam BackgroundEffect + BackgroundStrength on WebcamSceneConfig, persisted via additive guarded columns; radio-pill bools RemoveBackground/BlurBackground (Unit: bg-effect config) 2026-10-09 10:19:48 -07:00
gramps 9f3c154d84 TASK 51 slice 1: background-effects engine — bundled MediaPipe Selfie Segmenter (ONNX) + Remove (Unit: bg-effects engine)
- Model: Google's official MediaPipe Selfie Segmenter (landscape, Apache-2.0 per model card),
  converted to ONNX by tools/selfie-seg/convert_tflite_to_onnx.py (custom op
  Convolution2DTransposeBias -> ConvTranspose) and verified against MediaPipe's own runtime
  (verify_against_mediapipe.py: max |diff| 5e-5, 100% binary agreement). Embedded resource, 430 KB.
- Services/BackgroundEffects: IPersonSegmenter seam, OnnxPersonSegmenter (onnxruntime 1.31.0 CPU,
  never throws), BackgroundEffectProcessor (None passthrough, Remove = alpha mask). Not wired yet.
- Tests: Remove integration test (fake segmenter) + bundled-model contract test vs MediaPipe
  reference mask. Full suite 407/408 — the one failure is the documented interactive-desktop flake
  RealMouseDrag_OnTheLayerList_PersistsTheReorder (passed in the run before; code path untouched).
- Docs: ai.md reversal + section + licensing guard, THIRD-PARTY-NOTICES 11-12, TASK 51, indexes,
  MyMistakes recipe, HANDOFF.

References: obs-backgroundremoval (approach only — ONNX Runtime on CPU, 2 threads; GPL-3.0, no code
used) https://github.com/royshil/obs-backgroundremoval ; MediaPipe image segmenter
https://ai.google.dev/edge/mediapipe/solutions/vision/image_segmenter
2026-10-09 09:24:13 -07:00
gramps fe80e8c994 Docs: record the zoom box as TASK 52 and fix the task-numbering drift (Unit: zoom docs)
The zoom feature shipped without a task record while its index rows carried the
"TASK 51" label — which belongs to the webcam background effects (Remove + Blur).
For an honest catalogue and handoff:

- TASKS/task-52-zoom-box.md: feature provenance (creator-requested), the three
  2026-10-08 corrections (loupe -> anchored capture over the full composite drawn
  last -> never show the dim shading), shapes, implementation map, tests, DONE.
- TASKS.md: row 52 added (shipped); row 51 reworded — unblocked now that the
  zoom build shipped.
- TASK 51 -> TASK 52 in Models/index.md, ViewModels/index.md, and the two code
  comments that pointed at the zoom as "TASK 51".
- HANDOFF.md: pre-push readiness notes (5 commits ahead, push-safe).
2026-10-08 08:52:36 -07:00
gramps 6bff21b234 Zoom: anchor the capture, magnify the full composite, draw on top (Unit: zoom rework per creator correction)
Creator corrected the "loupe" zoom on 2026-10-08: the click anchors a fixed
capture region (box/2 centred on the click) and the box is a live 2x view of
that region — dragging the box never changes its content; it magnifies the
fully-composited frame (every layer, incl. webcam) and draws LAST (n+1).
Rectangle shape is 4:3 ("more of a 640x480 aspect").

- MainViewModel.Zoom.cs rewritten: ActivateZoom anchors CaptureX/CaptureY
  (independently clamped); UpdateZoomMove relocates the box only; the per-frame
  BuildZoomFrame/ZoomFrameBuilder path is gone.
- SceneCompositor: skips the active ZoomSceneElement in every layer loop and
  BlitZooms it last via the new pure ZoomMagnifier (2x bilinear, temp snapshot
  via ArrayPool, round clip).
- Preview: ZoomBox hoisted above CanvasGrid as a sibling; its brush samples the
  whole composed preview at the fixed capture (2x).
- Tests: ZoomMagnifierTests (box-over-blue shows green capture at 2x incl. an
  extra red "layer" — the loupe/backdrop-only regressions both fail it) +
  round-clip; ZoomBoxInteractionTests rewritten (anchored capture, move-doesn't-
  change-capture, 4:3/9:16/square). 404 pass, 2 known environment flakes.
2026-10-08 08:26:29 -07:00
gramps 4b3d82ff0f Zoom: compose the box as a real 2x layer above the background (Unit: zoom layer + magnification proof)
Right-click now creates a transient ZoomSceneElement at z-index 1 whose pixels are a
genuine 2x magnified sample of the backdrop (live capture under art), composited into
stream/recording like any layer while the output crop stays the tier. All shapes are
centered on the click and clamped to frame; healed on load/scene-create, never persisted,
hidden from the layers list + element template (IsZoomLayer). BuildCompositorOptions is
now internal = always tier crop (test seam). ZoomFrameBuilder is a pure static sampler
(box/2 window centered on the box center, single-slot cache) proven by a banded-backdrop
test; LayerReorderPersistenceTests drag resolves rows by item since the collapsed zoom
row shifts container indices. Also books TASK 51 (webcam background Remove + Blur) and
syncs memory docs (HANDOFF, MyMistakes, indexes). Full suite: 405 passed, 0 warnings.
2026-10-07 23:39:54 -07:00
gramps 966e718f1e Zoom: fix preview zoom box + shape-aspect anchoring (Unit: zoom box visuals)
Right-click in the live preview now procs a box that is actually visible and
populated with the zoomed region, and each Zoom View Shape sculpts the box per
spec. OBS-style derivative: magnifier box shows the composited region and dims
the surroundings (same pattern as OBS "zoom" / preview region highlights).

Root causes of the invisible box (two WPF traps, recorded in MyMistakes.md):
- Empty <VisualBrush Stretch="None"/> used as an OpacityMask paints nothing,
  so the mask alpha is 0 and the WHOLE box (border included) went transparent.
- ZoomBox lived inside CanvasGrid while its VisualBrush sampled CanvasGrid — a
  circular reference WPF renders as nothing. Hoisted ZoomBox to a sibling of
  CanvasGrid inside a new PreviewCanvas wrapper (same 1920x1080 space).

Shape semantics (MainViewModel.ActivateZoom):
- Circle: square, click point = center.
- VerticalRectangle (phone): tall 9:16, click point = top-left corner.
- HorizontalRectangle (tablet): wide 16:9, click point = top-left corner.
- Boxes stay fully inside 1920x1080 via Math.Clamp.

UI: ZoomRectOutline/ZoomCircleOutline shapes replace the square Border;
circle clips the box to an EllipseGeometry; brush Visual released when inactive.
Zoom View Shape dropdown now uses the standard YtComboBox style.

Test (Good Dog rule, one integration test): ZoomBoxInteractionTests verifies
aspect/anchoring/clamp per shape and the matching outline+clip in the real
MainWindow. Full suite 404 passed, 0 warnings.

Memory: HANDOFF.md zoom state updated; WPF VisualBrush traps appended to
MyMistakes.md.
2026-10-07 20:46:45 -07:00
gramps a8e3954f01 Fix: add IsAlertBox virtual on SceneElement and override on Source
The Stream Alerts properties panel was incorrectly showing for the webcam
layer (a WebcamSceneConfig is not a Source) because IsAlertBox only existed
on Source, causing WPF binding to be treated as false-but-invisible context
or the trigger to bind against the wrong type. Adding a virtual IsAlertBox
to the base SceneElement (default false) and overriding on Source prevents
the UI block from rendering under webcam selection. Scope: Models/SceneElement.cs, Models/Source.cs, ytLive.Tests/AlertBoxPreviewVisibilityTests.cs. Build/test: app + 4 alert-box tests pass, other failures unrelated (float precision).
2026-10-05 13:26:12 -07:00
gramps 7f14ffb11e TASK 47 take four: alert ticker visible in the preview + 3 display methods
The creator confirmed the clip fix ("the video plays now"), then asked where the
scrolling text was — it was invisible, for a structural reason. AlertTickerFrame
existed only as a frame-pump callback blitted into the OUTPUT; PreviewPane.xaml had
no element for it, because the strip is master-width and global, not a Source, so it
cannot ride a per-element Image. Nothing was wrong in the renderer: there was no
consumer in the preview. Same class of defect as the missing IsAlertBox trigger, one
layer up (MyMistakes RULE 5/6).

- tickerPreviewSink on AlertOverlayLayer, published from RefreshAlertPreviews() so it
  is always the UI thread; MainViewModel.AlertTicker writes it into one reused
  WriteableBitmap bound to a new global AlertTickerElement, mirroring SocialBarElement.
- Source.AlertDisplayMethod + panel "Display" selector: TickerScroll / Flash / Solid.
  Flash pulses 0.5s on / 0.5s off for the whole alert; Solid is centred and still.
- The marquee was also unreadable: a fixed 140px/s took ~17s per pass, so a 10s alert
  showed the text once, entering from the right and never crossing. Paced in reads per
  alert instead (TickerReadsPerAlert = 3 inside the alert's own length, speed derived
  from it) — never px/s. Research (websearch: how do OBS/Streamlabs/StreamElements
  alert boxes present announcement timing?) settled the unit: Streamlabs exposes "Alert
  Duration: choose how long your alert stays on your stream" and "Text Delay", never a
  scroll-speed slider (https://support.streamlabs.com/hc/en-us/articles/52499995174299-Setting-up-Your-Streamlabs-Alerts).
  Run is phase-started half a frame in so the first frame isn't blank.
- Persistence: AlertDisplayMethod INTEGER NOT NULL DEFAULT 0 via the idempotent
  table_info migration, appended LAST in the SELECT because the Source reader is
  positional (GetInt32(32..34)) — a mid-list insert would silently shift a neighbour.

Tests: 15 new facts (suite 339/339). RealApp STA host: the pane draws the strip and
collapses at alert end; the layer publishes a real 1920x48 frame for all three methods
and nothing when the ticker is off; three passes counted in 10s by the pill's leading
edge resetting (a seamless marquee never blanks, so an empty frame cannot count a pass);
Solid byte-identical at every moment; Flash on for half of each second; the panel shows
and writes back the choice; the DB round-trips all three alert fields together.

Incidental finding: a bound ItemsSource ComboBox in LeftPanel.xaml broke
LayerReorderPersistenceTests.RealMouseDrag (that test injects PHYSICAL mouse input, so a
load-time re-measure moves the rows out from under the cursor). Rewritten as inline
ComboBoxItems, the shape the chat Font selector already uses in that panel. Recorded as
MyMistakes RULE (8).
2026-09-26 15:10:26 -07:00
gramps a11b15e444 feat(alerts): TASK 47 — alert box plays a video (built-in/custom clip) + read-time fade + message ticker
TASK 43's alert box grows a real video celebration. Per-alert IAlertClipDecoder
(ffmpeg bgra + f32le pipes, real-time paced, disposed at drain) plays the shipped
Assets/alert-default.mp4 (stamped into the Asset table at startup) unless the
creator picks their own file — path reference only, never stored in the DB; the
six AlertRenderer animations stay the fallback. ~0.3s fade rides the alpha
envelope on straight-source copies (EOF freeze-frames then fades out); audio
forwards to a new AudioMixer alert ring (8s, 48k stereo) drained at unity — no
duck, creator ruling — scaled by volume × fade. An auto-composed marquee ticker
('Funder — Super Chat · $10.00', 140px/s) scrolls top-of-frame via a
FramePump._alertTicker seam through Render/CompositeLayers, mixed into the cache
signature (dynamic overlay, never baked). New Stream Alerts section in LeftPanel.

Derivative-work references (how OBS/Streamlabs alert boxes do per-alert video):
- https://support.streamlabs.com/hc/en-us/articles/217741147-Setting-Up-Your-Streamlabs-Alerts (custom image/video per alert type + variations)
- https://obsproject.com/kb/stream-tutorial-2-alerts (alert overlay as an on-screen zone)
- https://streamlabs.com/content-hub/widgets/alert-box (per-event alert playback)

Good Dog: AlertLayerVideoTests drives a fake IAlertClipDecoder through the whole
lifecycle in one pass (custom path wins, decoder spawns/disposes, fade envelope
0→127→255, audio volume×fade, ticker scrolls, EOF fade-drain to idle). It caught
the clip branch of Advance not clearing _current before AdvanceToNext — the layer
stayed IsPlaying after drain (MyMistakes post-mortem).

Full vstest 319/319; clean build 0 warnings; scope check green.
2026-09-26 11:15:17 -07:00
gramps d4aa588da0 feat(alerts): TASK 43 — native events & alerts (six unique animations) + chat parity
Replace the external StreamElements feed with NATIVE YouTube events per the creator's
2026-09-23 question.

Chat parity half:
- YouTubeChatService decodes ALL SIX liveChat/messages event types into
  ChatMessage.Kind (superChat/superSticker/newSponsor/membershipGifting/
  giftMembershipReceived/memberMilestoneChat) — the four formerly-empty overlay
  rows are real now — and re-arms its one-shot poll on the server's
  pollingIntervalMillis (streamList connection semantics; clamp 1000-6000ms,
  maxResults=2000). ParsePage internal static seam + ChatPage record for
  deterministic tests; optional HttpClient ctor seam kept; InjectSimulatedMessage
  (TASK 41) preserved.

Alerts half (creator rulings: one celebration zone, six UNIQUE animations, no
menus/polls, sub mention = chat row only, NO viewer count):
- New SourceType.AlertBox 'Stream Alerts' (one per layout, CanAddAlerts gate
  mirroring chat; idle = transparent).
- AlertRenderer: six distinct branded animations (SuperChat slide-up/shine/
  count-up, SuperSticker scale-pop, NewMember drop-in/flash, MemberGift slide-left/
  chip-fan, GiftReceived confetti, MemberMilestone rise/growth-bar), every card
  drawing the 'made with LlamaCasty!' brand line.
- AlertOverlayLayer: true component (Commit-G pattern) — queue (cap 10, drop tail
  never stall), 33ms UI ticker, cache-first RenderFrame + UpdatePreview,
  Advance(double) as the deterministic test clock; ChatEventKind.None rows never
  enqueue.
- Wired: resolver RenderAlertBox, _alertLayer ctor + dispose, LoadLayout previews,
  Add menu item (Controls/LeftPanel.xaml), TestSessionViewModel sims tagged
  (member->NewMember, superchat->SuperChat).

Good Dog ONE integration test: AlertLayerTests (RealApp STA, real WPF raster) —
ParsePage classifies all six kinds + cadence fields; None rows enqueue nothing;
six events play pairwise-distinct moving frames then drain to null.

Gate: clean build 0 warnings; full suite 316/317, the one failure
(LayerReorderPersistenceTests.RealMouseDrag) repros on the clean tree — the
known environmental class (real-mouse-drag no-ops with a game/fullscreen window
focused).

References (OBS/overlay ecosystem):
- streamList semantics: https://developers.google.com/youtube/v3/live/docs/liveChatMessages/streamList
- OBS alert-box pattern (designated celebration zone, idle transparent): creator-chosen model
2026-09-24 08:27:00 -07:00
gramps bb5dcb4ba2 feat(stream): TASK 41 Test Stream mode — private test broadcast + TEST drawer
Test button next to Start runs the real private-only go-live pipeline as a
session variant (IsTestStream): BeginTestStream -> extracted
StartStreamingSession(alsoRecord:false) shared with the dialog path — no Go
Live dialog, no 'last live' stamp, no recording. Gold top bar + TEST badge +
'End Test' button face; TEST drawer (third right-rail pull-out, three-way
exclusivity) auto-opens once the liveChatId resolves.

Chat tooling: Mock Chat Input sends a REAL liveChat/messages.insert
(YouTubeStreamService.InsertChatMessageAsync) that round-trips the real ~2s
poll and renders through the live overlay; simulated Subscriber/New
Member/Super Chat events inject through the poller's MessageReceived seam
(YouTubeChatService.InjectSimulatedMessage, ChatMessage.IsSimulated) — the
insert API only creates textMessageEvent, so non-text events are local-only
by design (ref: developers.google.com/youtube/v3/live/docs/liveChatMessages/insert).
Scopes youtube + youtube.force-ssl already cover insert; no re-consent.

Good Dog: ytLive.Tests/TestStreamTests.cs — coordinator protocol with fake
HTTP + real-window drawer exclusivity/gate. 0 warnings; 314/314 pass (clean
run; intermittent host abort is the pre-existing WinRT-webcam flake).

Docs ride in the same change: TASKS.md row 41, TASKS/task-41-test-stream-mode.md,
ai.md (YouTube Live API constraints), HANDOFF rewrite.
2026-09-22 09:31:00 -07:00
gramps a9d5f29084 feat(ypp): YPP journey tracker slice 1 — the YPP pull-out below Stream Settings (TASK 39, Good Dog ONE test)
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.
2026-09-22 08:07:54 -07:00
gramps 4e0915a36e feat(backdrop): Capture Window… in-app window pin (TASK 38, Good Dog ONE test)
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.
2026-09-22 07:00:47 -07:00
gramps 071ec8be0c TASK 21 slice 1 step 4: wire media into resolver + preview routing
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.
2026-08-31 19:32:41 -07:00
gramps 670fe3a10a TASK 31: SceneGraph component + baked-crust compositor optimization
- 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.
2026-08-31 17:53:34 -07:00
gramps eba6af5b9b TASK 21 Increment A: media-source model + persistence (schema migration + round-trip test)
SourceType.MediaSource added; MediaSourceType (Video/Audio) and MediaPlaybackState
(Stopped/Playing/Paused) enums. Source gains MediaPath, MediaIsLooping, MediaVolume
(0-1 clamped), MediaPlaybackState. LayoutStore: MigrateSourceTable adds 4 guarded
ALTERs (MediaPath, MediaIsLooping, MediaVolume, MediaPlaybackState); SELECT appends
indices 26-29, INSERT adds 4 columns + params. Round-trip test
MediaSource_Config_Survives_Save_And_Reload passes. Build 0 warnings, 245/247
tests (2 known pre-existing). Gated on creator run for later integration (ffmpeg).
2026-08-29 09:43:29 -07:00
gramps fd6e02b687 Web source: off-screen WebView2 capture + layout persistence + transparent bg
Bundle exception (2026-08-27): this lands prior-session work plus today's
transparency fix as one commit per creator instruction.

WebView2Manager (new): off-screen WebView2 control parked at -5000/-5000 in
the window's visual tree, captures via CapturePreviewAsync every 100 ms into
a BGRA8 VideoFrame. Source Width/Height sync to the control via
PropertyChanged. JS transparent-background injection runs on every
NavigationStarting/NavigationCompleted so the page's own CSS does not paint
white over the capture.

Transparency fix: DefaultBackgroundColor = System.Drawing.Color.Transparent
set on the WPF control before EnsureCoreWebView2Async. The WPF SDK doc says
the value is forwarded to the controller on init; the IDL (ICoreWebView2-
Controller2) confirms alpha=0 makes the capture alpha-preserving. The
prior-session 'CoreWebView2.BackgroundColor' path was the wrong property
name and has been replaced.

LayoutStore: WebUri column added (idempotent ALTER TABLE) and round-tripped
on save/load (schema v11).

MainViewModel: WebView2Manager constructed on Loaded; loaded web sources
re-registered; Source.WebUri PropertyChanged forwards to manager.

MainWindow: off-screen host Canvas added to visual tree.

Tests: 3 scale-sync + WebView2_DefaultBackgroundColor_Is_Transparent +
LayoutStorePersistenceTests.WebSource_WebUri_Persists. 234/236 pass
(2 pre-existing failures unchanged).

Scale bug (DPI/physical-pixel mismatch) still open.
2026-08-27 08:52:39 -07:00
gramps d4aa5e1003 Thumbnail strip: show hotkey label next to each scene name 2026-08-26 08:28:48 -07:00
gramps 89ab83be2e Update Broadcast button: diff before push, offline=local only, live=API; Go Live empty-field prompt 2026-08-25 17:25:25 -07:00
gramps b988e6d72e Broadcast Text pull-out (Live screen) + launch geometry 1920x1040/1366x768 with restore 2026-08-24 11:04:41 -07:00
gramps d2114c7e1c the ai is an overthinking fucking moron who needs it's plug pulled until such time it can repeat a similar action with its head and ass 2026-08-23 06:25:38 -07:00
gramps b74f15ff14 Fix: IsBackground derived from SourceType + per-scene background property tests
- Source.Type setter auto-sets IsBackground = true when Type is Background
- SceneElement.IsBackground: expression-bodied → virtual auto-property
- Source.IsBackground: new → override (WPF binding fix)
- Three new tests: IsBackground per-scene, derived from type, UseDefaultBackground default
2026-08-22 18:16:11 -07:00
gramps 4b9a8e515f Control Surface UX: pill toggle, background rename, per-scene properties
- Sliding pill toggle (green ON / red OFF) replaces checkbox
- Geometry mismatch error for custom backgrounds
- Backdrop -> Background: C# rename, SQL column rename with migration
- Source.IsBackground: new -> override (fixes WPF binding)
- Legacy 'Backdrop' display name migrated to 'Background'
- Per-scene UseDefaultBackground + CustomBackgroundPath
- Asset files renamed backdrop -> background
- StagedScene/LiveScene split, thumbnail strip, edit gating
- TransitionService (Cut/Fade/Move)
- Context menu: Capture Desktop, Refresh Desktop
- Resolution badge removed, ON-AIR status light added
2026-08-22 18:10:59 -07:00
gramps 2f135687cd fix: countdown timer panel showing under webcam properties
Same root cause as IsChatBox — SourceType binding fails on WebcamSceneConfig,
WPF defaults Visibility to Visible without calling the converter.
Added virtual bool IsCountdown on SceneElement, override in Source.
Swapped binding to BoolToVis. Removed dead SourceTypeToVisibility converters.
2026-08-20 18:13:31 -07:00