Compare commits

..
309 Commits
Author SHA1 Message Date
gramps 69f08a478e feat(topbar): three fixed slots — every state changes inside its own slot
Creator correction (2026-11-13): "OpenCode literally inserted Live and Private as two extra flat siblings
inside the outer pill bar, shoving Record to the right and making it look like five ungrouped toolbar
items". Correct — completion states were being added to the BAR instead of to a SLOT.

Now the bar is exactly three slots, and the bar's shape never changes:

  [ Build Scenes ]   [ YouTube ]   [ Record ]

  YouTube:  ▶ YouTube -> [Live | Private | ✕] -> [● Start Live | ✕] (amber: next click goes on air)
            -> [■ Stop Live]
  Record:   ◉ Record  -> [● Start Recording | ✕] -> ● REC hh:mm:ss [■ Stop]
  Build:    enabled only while BOTH action slots are Idle; otherwise disabled at 40% with the tooltip
            "Scene editing is locked while streaming or recording"

State lives in YouTubeSlotState / RecordSlotState, with one RaiseSlotStates() announcing every flag so no
caller can forget one. Esc and the ✕ cancel the slot you are in and nothing else.

This retires the BarWorld model from earlier today, along with TopBarActivityTests (superseded by
TopBarSlotTests, which pins all three flows, the lock, and cancel-to-Idle).

679 pass, 0 failed (the host-hang fallback in f4caea0 counted the per-test lines).
Scope justification: TopBarActivityTests.cs is deleted as part of this unit — it tested the model being
replaced; TopBarSlotTests is its successor.
2026-11-13 12:20:11 -08:00
gramps ba60f9ab3d feat(topbar): three activities, always visible — build, stream, record
Creator ruling 2026-11-13: "the app does three things. builds. records. live-streams." So the bar holds
three buttons that never disappear; the active one is lit and runs its own flow in place, and the other
two dim but stay clickable, so there is no state where the creator cannot see where they are or how to
step back.

  Build    [Build Scenes]  -> editing on, nothing to start
  YouTube  [Live][Private] -> [Start] -> [Stop]     (Private = the private-visibility test stream)
  Record   [Start Recording] -> [● REC hh:mm:ss][Stop Recording]

Arming and starting are separate presses on purpose (creator): Start is the last chance to tell someone
"check you are ready". Collapsing them would save one click and lose that prompt.

This replaces the two-world model (BarWorld) and its output stage outright, including the chips added
earlier today. The old pills remain the underlying state (OnAirPillOn/RecordPillOn) so the start, stop and
step-down paths are untouched; the activity only decides what the bar shows.

Closing the app no longer waits 6 s for a session that cannot stop: it waits ~2 s, then clears the state
and closes anyway. A window that refuses to close is worse than a stale flag — and that refusal is what
hung the suite in ZoomBoxInteractionTests, which sets IsRecording and closes a real window.

Test: TopBarActivityTests pins all three flows, the arm-then-start split, and that stepping back to Build
forgets the YouTube choice instead of carrying it. TopBarWorldTests is retired with the model it tested.
2026-11-13 11:30:57 -08:00
gramps f4caea09ad ci(verify): judge the suite on its results, not on a summary the host may never print
Three gate runs in two days died the same way: every test passed, then the test host failed to exit, so
vstest never printed its "Passed!" line and the gate waited until the harness gave up. One of those hours
was a real product bug hiding inside the noise (a close handler that could refuse to close), which is
exactly why the noise has to go.

- The suite runs with a 300 s bound and per-test console output.
- Output goes to a FILE, not command substitution: `timeout` kills the host with SIGTERM and a substituted
  buffer dies with it, which left the previous attempt with no summary at all.
- If the summary is missing, the per-test lines decide: zero "Failed" among real results is a pass, and the
  counts are printed so the verdict is never taken on faith.
- The orphaned testhost is swept afterwards.
- `pipefail` no longer kills the gate when the summary grep finds nothing (the fallback already decided).

Scope justification: Assets/starting-background.jpg is outside this unit — the creator's replacement art,
checked and committed earlier today.
2026-11-13 11:30:56 -08:00
gramps 70b5b01920 assets(starting): the creator's replacement Starting-screen art
Checked visually by the creator on 2026-11-13 (#3 of the pending checks).
2026-11-13 09:42:45 -08:00
gramps bd61e8df2a fix(lifecycle): no dialogs while the app is closing
1ce6b9c made closing stop the session first, which was right and incomplete: the stop path opens TWO
modals, and WPF forbids that during a close. The creator's #2 test produced it immediately —

  System.InvalidOperationException: Cannot set Visibility to Visible or call Show, ShowDialog, Close,
  or WindowInteropHelper.EnsureHandle while a Window is closing.

Two prompts are suppressed by a `_shuttingDown` flag, each taking its safe default rather than a prompt
nobody can answer:
- The rename dialog: the recording is saved under its automatic name. Never discard footage the creator
  did not choose to discard.
- The private-broadcast keep/delete prompt: the broadcast is KEPT (stays private on YouTube) instead of
  being deleted by an answer that was never given.

The general rule this establishes: a teardown path may not ask questions. Any future prompt reached from
stop must check IsShuttingDown.

Not covered by a unit test — the trigger is a real WPF close, not reachable from the view model. Verified
against the exception in %APPDATA%\ytLlive\startup.log (09:40:27) and it needs the creator's retest.
2026-11-13 09:42:44 -08:00
gramps 5bd750eaf5 fix(topbar): announce the output stage when recording starts or stops
Starting a local recording left the idle 'Start Recording' chip on screen and never revealed 'Stop
Recording' — IsRecording raised its own notifications but not the output stage's, so the visibility
bindings were never re-evaluated. The visible chip's command then correctly refused to run (recording was
already in progress), which is the worst shape of bug: a dead button that looks live.

IsRecording and StreamStatus now call RaiseOutputStage(). The regression test asserts the NOTIFICATIONS,
not the property values — reading ShowEndRecordingAction directly passes either way, which is exactly how
this slipped through.

682 pass, 0 warnings.
2026-11-13 07:47:36 -08:00
gramps 1ce6b9c1b8 fix(lifecycle): end the session before disposing it on close
Closing with a recording active called Shutdown() straight away, which disposed the frame pump and the
audio mixer underneath a running loop: ffmpeg never flushed, the file was never finalized or renamed,
and the pump was torn down mid-iteration. That is the 'app goes autistic on close' report.

MainWindow_Closing now holds the window open, runs StopBeforeShutdownAsync (the ordinary stop, bounded at
~6 s so a stuck encoder cannot make the app unclosable), then closes for real. StopStream already honours
the audio-before-video ordering, so the close path inherits it instead of inventing its own teardown.

Test: with nothing running, closing must not hold the window at all (the fast path stays fast).

Scope justification: Assets/starting-background.jpg is outside this unit — the creator's replacement art,
still awaiting their visual check.
2026-11-13 07:47:35 -08:00
gramps 70fcf89f9e feat(topbar): the recording lives in the pill — green pulse, REC clock, Stop Recording
Build 730 (creator):

- Left pill while recording: solid #2ECC71 dot pulsing 1.0 <-> 0.2 on the existing one-second timer, and
  "REC: hh:mm:ss" in Consolas so the digits cannot jitter the pill's width. Idle face unchanged.
- Right pill while recording: a stop square and "Stop Recording"; clicking it runs the normal stop, which
  halts and resets the clock and returns the pair to "Local MP4" / "Start Recording".
- The separate reality line (dot + REC/LIVE + elapsed + End) is now LIVE-ONLY: a recording would otherwise
  print the same three facts twice. RunningVisible is IsLive, and ShowOutputStage no longer hides itself
  for a recording (it must be on screen to carry the clock and the stop).

Two rules collided and are now separated rather than merged: a creator's Stop on a recording restores the
Local MP4 pair (build 730), while a pipeline FAILURE still clears both pills (the 2026-09-01 zombie rule).
StopStream(afterFailure:) carries the difference, and the pump-failure path passes it.

RealAppHost.Run was rethrowing with `throw failure;`, which destroyed the original stack — a failing
assertion inside the action reported the wrapper's line instead of its own. ExceptionDispatchInfo now
preserves it; that is how the two failures above were located in one pass instead of five.

680 pass (one audio-pipe instance flake under full-suite load, passes alone), 0 warnings.
Scope justification: Assets/starting-background.jpg is outside this unit — the creator's replacement art,
still awaiting their visual check.
2026-11-12 16:52:36 -08:00
gramps caf5d6130d feat(topbar): the output stage's three states, and keyboard entry into the bar
Build 729 (creator):

- "press <esc> to return" loses the word "press"; the label is "Record / Go-Live".
- The bar takes focus on "Build scenes" and Tab cycles between the two halves only
  (KeyboardNavigation.TabNavigation="Cycle"), never off into the window.
- Entering Record / Go-Live starts with NOTHING chosen — the output switches are a fresh decision,
  not a remembered one.
- Three states: nothing chosen [YouTube][Local MP4]; YouTube chosen [YouTube(red)][Live Stream][Private
  Stream]; Local MP4 chosen [Local MP4(red)][Start Recording] → [End Recording] while recording. Only the
  chosen output is red; the action chips never highlight. Choosing Local MP4 takes the left slot and the
  YouTube chip disappears with it, which is what "slides to the left, replacing it" describes.

"Private Stream" is the private-visibility stream — the same thing this app has always called the test
stream (creator's clarification, so the wording is deliberate, not a new broadcast type).

Output chips are one style with a Tag-driven trigger (OutputChip + OutputChipIcon), so a chip's red face
and its glyph colour can't drift apart; the one-alpha hit-area colour is a token, because
DesignTokensTests rightly refuses literals in theme files.

680 pass, 0 warnings. Scope justification: Assets/starting-background.jpg is outside this unit — the
creator's replacement art, still awaiting their visual check.
2026-11-12 16:35:16 -08:00
gramps 6b4aa99863 feat(topbar): the segmented mode switcher, to the mock
Implements the creator's mock (2026-11-12) for the world segment: pill track (#1E222B, radius 12,
padding 4) holding two equal halves; the active half sits on a lighter pill (#2E3440, radius 8) with
#E2E8F0 bold text and a coral (#F26D6D) icon; the inactive half is #8892A4 on a one-alpha black face so
the whole area hit-tests; hover tints #252A34 on the inactive half only, so the active one never looks
clickable-but-dim. Labels are "Build scenes" / "Record / go live".

Two deliberate deviations from the pasted recipe, both to honour this repo:
- State is the existing BarWorld binding plus a command, not a code-behind SetActiveMode — one source of
  truth, and the change still raises the same property-changed notification a mode event would.
- Styles live in Themes/Controls.xaml (SegmentHalf, SegmentIcon) with colours as tokens in Tokens.xaml,
  rather than inline properties on the elements: AGENTS requires shared styles, and one DataTrigger on
  each element's own Tag serves both halves.

DesignTokensTests caught the one raw literal (#01000000) and it is now Brush.SegmentHitArea, which is the
rule working as intended.

680 pass, 0 warnings. Scope justification: Assets/starting-background.jpg is outside this unit — the
creator's replacement art, still awaiting their visual check.
2026-11-12 16:20:25 -08:00
gramps b92569a8c7 feat(topbar): Output:, no chevron, and Esc returns
Creator ruling 2026-11-12, the parts that were unambiguous:

- The back chevron is gone from the left of the output line.
- "Output" gains a colon and takes the same 11 px muted style as "One output at a time".
- "press <esc> to return" sits centred under the switch pair.
- Esc actually returns from the Output world (MainWindow_PreviewKeyDown -> EnterBuildWorldCommand).
  Without this the bar would have lost its only route back, since the chevron was that route.

UiShellTests guards all four: the colon, the hint, the absence of the chevron tooltip, and the muted
style class of both labels.

680 pass, 0 warnings. The switches' post-click behaviour is still awaiting the creator's mock
description, so this commit deliberately leaves the pill pair alone.

Scope justification: Assets/starting-background.jpg is outside this unit — the creator's replacement art,
still awaiting their visual check.
2026-11-12 16:14:48 -08:00
gramps 838ba17eaf fix(topbar): the bar carries no action button
Creator ruling 2026-11-12: the action is not part of the output row, and it is not to be placed at all
until they say where. So Start Recording / Private Test Stream / Review & Go Live are out of the bar,
along with the divider that had been separating them.

What the middle of the bar is now: the world segment in Build, and the output line
(Output [YouTube] [Local MP4] One output at a time) in the Record / Go Live world. The view-model
commands (StartRecordingCommand, TestStreamCommand, GoLiveCommand) stay, unused by XAML, because they are
wherever the action lands next.

UiShellTests now asserts the bar contains none of the three action labels, so re-adding one by accident
fails the gate.

Scope justification: Assets/starting-background.jpg is outside this unit — the creator's replacement art,
still awaiting their visual check.
2026-11-12 16:00:50 -08:00
gramps 16abb1ddc1 fix(topbar): the output line REPLACES the segment, it does not sit beside it
Creator correction: choosing Record / Go Live must take the [ Build Scenes ][ Record / Go Live ] dual
button away and put the Output line in its place. It was rendering both, segment first, which read as one
long toolbar instead of two worlds.

The segment is now Build-world only. That also retires the padlock glyph added in 6c5cd2f — with the
segment gone in the Output world there is no state left for it to describe, so the branch is removed
rather than left unreachable.

Because the segment was the only route back to the layers, the Output world gains a left chevron
("Back to Build Scenes"), present for the whole world including while a session runs — otherwise
editing during a show (unit 1) would have had no entry point and the bar would have dead-ended.

680 pass, 0 warnings. Scope justification: Assets/starting-background.jpg is outside this unit — the
creator's replacement art, still awaiting their visual check.
2026-11-12 15:48:34 -08:00
gramps 4bc41c488c feat(topbar): the action follows the armed output; Test is an explicit ask again
Unit C (creator ruling 2026-11-12).

- Local MP4: one button, "Start Recording".
- YouTube: "Private Test Stream" beside "Review & Go Live", the latter with the primary face because it
  is the default ask.
- Running: "End Live Stream" (or "End Test" for a test run, "Stop Recording" for a local one).

The behavioural change underneath: SessionStartDecision.For no longer takes `editing`. Since 2026-10-30
EDIT silently chose test-vs-real, which meant the same button did two different things depending on a
toggle three inches away. With Test as its own button that inference has no job, so For() now reads only
the armed output and ON-AIR always means the real livestream. That supersedes the 2026-10-30 ruling;
SessionStartDecisionTests is rewritten to pin the new matrix rather than the old one.

A signed-out press still starts the sign-in flow instead of sitting dead (no-dead-ends), unchanged from
before — now stated in one place, the two new commands, instead of inside a route table.

680 pass, 0 warnings. Scope justification: Assets/starting-background.jpg is outside this unit — the
creator's replacement art, still awaiting their visual check.
2026-11-12 15:44:15 -08:00
gramps a5a10fb0b0 feat(topbar): the output stage — Output [YouTube] [Local MP4] One output at a time
Unit B (creator ruling 2026-11-12). Pressing Record / Go Live now changes the bar: the REC/ON-AIR pill
pair becomes two labelled switches with glyphs (the YouTube mark, the record dot), under an "Output"
label and beside "One output at a time". Build world shows nothing but its own segment.

Two things this exposed that were not cosmetic:

- **Local MP4 was not actually the default.** Both pills started unarmed, so the stage opened with no
  output selected; the old screen got away with it because the primary button inferred "record" from
  "nothing armed". RecordPillOn now starts true, which is what "pos = ON (default)" means in code.
- **The stage gates the whole idle cluster, not just the switch.** Gating only the switch left the start
  button visible in the Build world — a Start with no output chosen. ShowOutputStage (output world, idle,
  not recording) wraps the cluster, so a running session hides the stage too: its output cannot change.

Exclusivity stays in the view model (arming one drops the other); the XAML merely reflects it.
TopBarWorldTests pins the stage's visibility and the radio pair starting at Local MP4.

680 pass, 0 warnings. Next: unit C (output-dependent primary action, End labels).
Scope justification: Assets/starting-background.jpg is outside this unit — the creator's replacement art,
still awaiting their visual check.
2026-11-12 15:38:35 -08:00
gramps 6c5cd2f66c feat(topbar): glyphs on the world segment — layout, record dot, and a padlock
Creator ruling 2026-11-12: an icon left of each label, and Build Scenes' glyph becomes a padlock while
Record / Go Live is the active world.

- Build Scenes: a four-pane layout glyph while it is the active world, a padlock while the output world
  is — the door is still there, it is just behind the session. Clicking it swaps the glyph back and
  takes the creator into the layers, which is the only route back mid-show now that EDIT is gone.
- Record / Go Live: the ring-and-dot record glyph, the same "armed" language the REC sign speaks, so
  the halves are read apart at a glance.

Both glyphs fill from the button's own Foreground, so the selected (red) and unselected states keep
legible ink without a second colour pair. UiShellTests guards both paths and the padlock's world flag.

679 pass, 0 warnings. Scope justification: Assets/starting-background.jpg is outside this unit — the
creator's replacement art, still awaiting their visual check.
2026-11-12 15:32:12 -08:00
gramps a5dc2f494c feat(topbar): one decision — [ Build Scenes ][ Record / Go Live ]
Unit A of the top-bar redesign (creator ruling 2026-11-12). The standalone EDIT toggle is gone;
Build Scenes IS edit mode, so "which world am I in?" and "can I edit?" cannot disagree any more.

BarWorld (ViewModels/MainViewModel.TopBar.cs) has two values, Build and Output. Build ships
highlighted at launch and sets EditingActive on; choosing Record / Go Live turns it off. Clicking the
already-highlighted half is deliberately not a no-op: it re-enters that world, which is the only way
back to the layers during a session now that the EDIT button is gone (unit 1 made that legal).

WorldSegmentButton (Themes/Controls.xaml) is a Button, not a ToggleButton, and each half passes its
own flag through Tag to one shared DataTrigger — a ToggleButton would fight the view model and, with a
one-way IsChecked, clear its own binding on first click.

UiShellTests updated where it guarded the old EDIT markup (it now guards the segment). TopBarWorldTests
pins launch state, both transitions, and the re-enter case.

679 pass, 0 warnings. Next: unit B (output switch), unit C (output-dependent action + End labels).
Scope justification: Assets/starting-background.jpg is outside this unit — the creator's replacement
art, still awaiting their visual check.
2026-11-12 15:05:29 -08:00
gramps d46e84562f feat(settings): editing during a session is the creator's call (default: allowed)
Creator ruling 2026-11-12: "we don't care if a creator wants to edit their scene: move stuff around,
shrink/grow a webcam view, etc. Why not?" Correct — the app was only ever *forcing* EDIT off at
go-live, never enforcing it, so the gun-safety was a habit rather than a rule.

App.LockEditWhileLive ships off: a show no longer takes EDIT away. Turning it on restores the old
behaviour exactly — go-live flips EDIT off and stop flips it back, and only because the lock took it,
so a creator who had already turned EDIT off does not get it handed back on stop.

MainViewModel.ApplyEditLockForSession holds the rule so it is testable without starting a broadcast;
EditWhileLiveTests covers both settings, the round trip, and the "don't hand it back" case. Settings →
Editing carries the checkbox.

This is the first slice of TASK 68 (editing while live).

678 pass, 0 warnings. Scope justification: Assets/starting-background.jpg is outside this unit — the
creator's replacement art, still awaiting their visual check.
2026-11-12 14:45:58 -08:00
gramps aae6104666 docs(ui): record the open question about layer visibility while a scene is locked
Creator report (2026-11-12): "the creator can still use the layer icon — visibility — if it was
already enabled for that layer". Not reproduced from the obvious paths — with EDIT off the left panel
swaps the layer list for chat, the eye button carries no lock guard, and the preview routes left click
to the zoom box and swallows Ctrl-click — so the note names the two possible answers (a deliberate
exception, or a surface that should be closed) and where to look first. Parked, not acted on.

Scope justification: Assets/starting-background.jpg is outside this unit — the creator's replacement
art, still awaiting their visual check.
2026-11-12 14:24:51 -08:00
gramps cc75212997 fix(audio): a sync offset is saved the moment it changes
Both offsets rode the debounced layout save, so a value set seconds before the app closed was lost
without a word. That is what happened to the -130 ms offset on 2026-11-12: two clips were measured
against a setting that was not in force, the numbers disagreed by ~65 ms, and the disagreement was
blamed on the audio path.

Setters now write the row immediately (two tiny rows), keeping ScheduleSave for the rest of the
layout. AudioSyncSettingsTests gains the regression: set both offsets on a view model and read them
back through a fresh store with no debounce flush.

MyMistakes records the general shape — "a setting that didn't stick" is almost always ScheduleSave —
with the check list and the two related rules: offsets never ship with a non-zero default, and
scripts/avsync.py only resolves ~50 ms, so below that the creator's judgement wins.

677 pass, 0 warnings. Scope justification: Assets/starting-background.jpg is outside this unit — the
creator's replacement art, still awaiting their visual check.
2026-11-12 14:21:48 -08:00
gramps 1eb04597d1 fix(audio): both sync offsets ship at zero
Shipping 175 ms as the built-in mic delay was wrong on principle, not just in value: it shifts
every creator's voice to correct one camera on one desk, and it hides whether a desync is theirs
or ours. A correction has to be something the creator turned on.

So DefaultMicSyncOffsetMs is 0. The control stays (slider 0..500, off/175/350 presets, applied value
logged) for anyone whose camera needs it; it is simply no longer pre-applied. The global offset was
already 0.

AudioSyncSettingsTests pins the rule: a fresh profile reads 0 for both, the mic delay refuses a
negative (advancing is the global offset's job) and clamps above 500, and set values round-trip.

676 pass, 0 warnings. Scope justification: Assets/starting-background.jpg is outside this unit — the
creator's replacement art, still awaiting their visual check.
2026-11-12 14:16:20 -08:00
gramps c1b3868913 docs(audio): record the measured lip-sync answer and the floor
Mic delay off, global offset -130 ms. With the delay off the audio path runs ~100 ms behind the
picture, which is why delaying the mic made it worse rather than better — the 240 ms this session
briefly recommended would have put the stream 340 ms late.

Residual after -130 is inside one 30 fps frame, and at that point the two measurement methods
(clap contact, mouth/speech correlation) disagree by more than the correction, so the reading is
noise. Recorded so the next session stops at the floor instead of re-deriving it with more clips.

Scope justification: Assets/starting-background.jpg is outside this unit — the creator's replacement
art, still awaiting their visual check.
2026-11-12 14:11:30 -08:00
gramps af6210082c fix(tools): a negative lip-sync offset shortens the audio track by design
The length check called -74 ms of audio a clock problem on a clip recorded with the global
offset at -100. Trimming the stream head to advance the mix removes exactly that much track,
so the mismatch is the feature working. The verdict now says so instead of sending the next
hour after a clock that is fine.

Same clip: correlation +30 ms (two windows above 0.55), clap contact +34 ms — the two methods
agree, so the offset needs -130 rather than -100.
2026-11-12 14:09:55 -08:00
gramps 1abc86358a feat(tools): avsync pairs clap transients when the mouth correlation is weak
Mouth-motion against speech is a statistical proxy and it fails on ordinary talking clips: the
per-window estimates swing by 700 ms and the honest answer is "unmeasurable". A clap is not a
proxy — it has one unmistakable audio spike and one unmistakable visual spike, a fixed distance
apart, with no periodicity to lock onto wrongly.

So when the correlation is weak, the tool now detects sharp audio transients (99th percentile,
one event per 0.6 s), finds the biggest visual change within ±400 ms of each, and reports the
median of the cluster within 60 ms of it — at least three pairs or it still says unmeasurable.
The cluster matters: an arm entering the frame moves more than the clap itself and lands early
(two pairs on the validation clip came out at +248 and +283 ms against -285).

Validated on ty-20261112-1405-621-0000.mp4: the correlation path said +98 ms and the clap path
+116 ms (three tight pairs at +117, +115, +107), so the applied 350 ms should come down to about
240. Two methods, same answer, no listening required.
2026-11-12 14:07:09 -08:00
gramps 514fb8480d fix(tools): avsync says "unmeasurable" instead of trusting a 0.08 correlation
On ty-20261112-1401-952-0000-2.mp4 the per-window estimates ranged from +400 to -370 ms and
only one window cleared 0.35, yet the tool printed "-370 ms, adjust by +370" from an overall
correlation of 0.08. A number that wrong is worse than no number: it invites another build.

Now: windows above 0.35 must also agree within 150 ms, and anything weaker reports
"unmeasurable" with the remedy — three claps with the hands in the webcam frame, two seconds
apart, which gives a sharp visual and audio transient instead of a mouth-motion proxy.

Also reverted an onset-based envelope tried on the same clip: it dropped the best window's
correlation from 0.47 to 0.08, so the plain level envelope stays.

What the run did establish: lengths match (15.555 s video against 15.601 s audio, 0.3%), and
the log confirms "mic delay 350 ms" was applied — the drift fix and the delay wiring both hold.
2026-11-12 14:03:45 -08:00
gramps bb3bc12784 docs(handoff): bitrate closed, audio drift closed, one sync number left
Scope justification: Assets/starting-background.jpg is outside this unit — the creator's
replacement art, still awaiting their visual check (recorded in the landmines).
2026-11-12 13:58:20 -08:00
gramps b216cbc56f tools: scripts/avsync.py measures A/V sync from the file, not from an ear
Three numbers, from the recording itself: each stream's length, the drift implied by a length
mismatch (a clock problem), and the residual offset between mouth motion and speech (a constant
problem). Per-3s window table with correlations, so a weak reading announces itself instead of
reporting a confident wrong number, and a median over only the strongly-correlated windows — a
whole-clip search on periodic speech locks onto the wrong beat and returns nonsense (it said
-400 ms at the search limit on the clip where the windows said -185).

Validated on ty-20261112-1228-457-0000.mp4: lengths 13.271/13.305 s (match, so no drift) and
-185 ms (sound early), which agrees with the independent visual read of the same clip.

The creator is deaf, so "does it sound right" cannot be the loop. This is.
Needs numpy; uses the bundled ffmpeg.exe.
2026-11-12 13:57:29 -08:00
gramps e654a16b60 fix(history): file each session under the real encoder, not "unknown"
FfmpegEncoder resolved the encoder name into its OWN StreamHealth instance, while the view
model read CurrentHealth — a different object. So every recorded session was filed as
"unknown", and the rate history's encoder key (evidence about NVENC says nothing about QSV)
had nothing to key on. The name now travels on the encoder interface and the pump forwards
it: IFfmpegEncoder.VideoEncoderName -> FramePump.EncoderName.

Caught while adding it: reading _framePump.EncoderName from RefreshRateProposal throws,
because that call runs at MainViewModel ctor line 354 and the pump is built at line 468.
Eight tests failed with the same NullReferenceException, which took a while to see because
the harness's test output is buffered — the first honest look at per-test logging named it
in one line. Guarded; the history falls back to the last session's encoder for that tier,
which is exactly the pre-first-session case the fallback already existed for.

Tests: FramePumpTests gains the encoder-name assertion (null before start, h264_nvenc
after), and the three fake encoders implement the new member.

675 pass, 0 warnings.
2026-11-12 13:57:03 -08:00
gramps 5ae52808bb feat(audio): the mic delay gets a slider, and the log states the applied value
Two gaps in what 7f89534 shipped, both caught by the creator's report that sound still
leads the lips:

- The mic delay had presets only (off / 150 / 200), so the value a slower camera needs —
  roughly 175 ms of residual on top of the 175 ms already applied — could not be set at
  all. Now a 0..500 ms slider with off / 175 / 350 presets.
- Nothing recorded which number was in force. The setting lives in the layout DB, so a
  preset click left no trace in the log; "Audio live: started" now names it.

Confirmed meanwhile from the profile: Audio.MicSyncOffsetMs is 175, so the delay is being
applied — the 150 ms of digital silence at the head of the 12:22 track is the delay line
priming, which is what an applied delay looks like. The residual offset is the camera's
pipeline being slower than 175 ms, not a missing delay.

674 pass, 0 warnings.
2026-11-12 12:37:03 -08:00
gramps 751d9b199c fix(audio): the track starts with the session, not with the first write
Third measurement, and it names the start-up defect the creator heard as "started off
wrong, then corrected" (ty-20261112-1222-748-0000-2.mp4):

  wall clock (audio start -> audio stop)  12.614 s
  video: 754 frames                       12.588 s   correct
  audio: 585 packets                      12.477 s   137 ms short
  pipe connected                          160 ms after the session opened
  first 150 ms of the audio track         digital silence (the mic delay line's head)

The rate is fixed — 12.5 s of show, no accumulating drift — but starting the clock at
connect made the audio stream begin 160 ms after the video stream, so every word sat 160 ms
late and the track came out that much shorter. The silence at the head is the 175 ms mic
delay doing its job, not a fault.

Now the clock runs from session open. Time that passes before ffmpeg connects stays owed,
and it is paid the moment the pipe connects — out of the capture ring, so it is the audio
that was missed rather than padding. Debt larger than one 200 ms chunk continues to be paid
over the following ticks.

674 pass, 0 warnings.
2026-11-12 12:27:34 -08:00
gramps 102cb96b28 fix(audio): only count audio that actually reached the encoder
Second measurement of the same recording family (ty-20261112-1133-701-0000-2.mp4):

  wall clock (audio start -> audio stop)  6.477 s
  video: 386 frames                       6.455 s   correct
  audio: 294 packets                      6.252 s   226 ms SHORT

Same magnitude as the original bug, opposite sign — the clock fixed the tick-rate error and
exposed the next one underneath. Audio started 49 ms before the pump, ffmpeg connected ~80 ms
after, and every chunk written in that window was dropped (dropped writes 27). The loop still
counted those frames as produced, so the track lost exactly that much time.

Now: the clock does not start until the pipe is connected, and AudioClock.Advance counts a
frame only when its write reached the pipe. A dropped write stays owed and is paid off on the
next tick, so a start-up gap costs content (silence, which there was anyway) instead of
length.

Two measurements, two different signs, one lesson: the audio track's LENGTH has to be the
clock's, and anything that removes samples without removing time — a fast tick, a dropped
write — shows up as drift no static offset can fix.

674 tests (one notification-timing flake under full-suite load; passes alone), 0 warnings.
2026-11-12 11:39:20 -08:00
gramps 47f409f0e4 fix(audio): drive the mix from the clock — the audio track was 2.1% longer than the show
Measured on the creator's recording (ty-20261112-1118-423-0000-2.mp4), which drifts
out of sync over ten seconds:

  wall clock (pump start -> audio stop)   9.875 s
  video: 594 frames, uniform 1/60         9.888 s   correct
  audio: 485,376 samples @ 48 kHz        10.112 s   +2.1%, 224 ms long

So it was not VFR and not wall-clock PTS: the video is CFR and matches the clock (the
only PTS anomaly in the file is one 38 ms gap from two lost frames). The audio track is
what was too long, and a static offset cannot fix a rate error — which is why the opening
looked aligned (the 175 ms mic delay cancels the camera's constant pipeline latency) and
the end did not.

Cause: LiveLoopAsync wrote a fixed 480 frames per iteration and trusted that each
iteration was 10 ms. Under a raised timer resolution they land near 9.8 ms — 102 ticks
per second where 100 are expected, i.e. 102% of an audio second per wall second. FFmpeg
stamps audio by sample count, so every second of show pushed the sound ~2% later against
a correct picture. Video never had the bug because the pump paces off a stopwatch.

Fix: Services/Audio/AudioClock asks the clock how many frames are owed
(expected - produced, clamped to one 200 ms chunk) and the loop produces exactly that,
padding on underrun. A stalled loop pays its debt over the following ticks instead of
dumping one huge write. FillAndMix takes a frame count, and the master limiter/meter take
spans, so a partial chunk is processed without touching stale buffer tail.

674 tests (one log-rotation flake under full-suite load while the app itself is running
and writing startup.log; passes alone), 0 warnings. Ear check pending.
2026-11-12 11:28:28 -08:00
gramps 7f89534309 feat(audio): microphone-only sync delay, on by default at 175 ms
The webcam's frames reach the compositor later than the mic's samples reach the audio
pipe, so the voice runs ahead of the lips. The existing lip-sync offset could not fix
that: it delays the WHOLE mix, which drags desktop and alert audio — neither of which
has a camera to follow — out of sync in the process.

So the delay line learned channels (mono for a source, stereo for the mix, offset always
in milliseconds so the frame count is what stays constant), and FillAndMix runs a second
line over micChunk before the mix. Slider-side: presets off / 150 / 200 in the LIP-SYNC
bay under the whole-mix control, persisted as Audio.MicSyncOffsetMs.

Default 175 ms is the creator's own number for a C920 pipeline (they asked for
+150..+200). It is a setting, not a constant, because the right figure belongs to the
camera and the machine — a faster camera wants less, no camera wants none.

Both delay lines are reconfigured every tick, so the 2026-09-12 trap still applies and is
documented at both sites: a Configure that reallocates discards the buffered voice every
10 ms.

673 pass, 0 warnings. Ear check pending: this is an audio change.
2026-11-12 11:15:50 -08:00
gramps f6fdab3bf2 docs(task-40): App Settings is the SettingsDrawer, not the OverlayHost panel
The Unit A plan still points at `OverlayHost.xaml:39`, which stopped being the settings
surface on 2026-11-01 (creator ruling: a right-edge drawer). Recorded before anyone
starts the unit and hangs it off the wrong host.

Scope justification: Assets/starting-background.jpg is outside this unit — the creator's
replacement art awaiting their visual check.
2026-11-12 08:30:15 -08:00
gramps ada8d2457d docs(tasks): mark the 8-Mbps ladder and flat bitrate floor superseded
TASK 4's ladder and TASK 33's worst-case table both quote numbers the code no longer
produces. Replaced with a dated note pointing at the current truth rather than
rewriting the decision records: the ladder is 12/10/6 with CBR, and the step-down floor
is the tier's YouTube minimum, not a flat 3.

Scope justification: Assets/starting-background.jpg is outside this unit — it is the
creator's replacement art, still awaiting their visual check.
2026-11-12 08:29:57 -08:00
gramps 7acf044e4a docs(handoff): bitrate correction landed — four units, next is the creator's live check
Scope justification: the only file outside this commit's declared scope is
Assets/starting-background.jpg, the creator's replacement art still awaiting their
visual check (recorded in the landmines).
2026-11-12 08:26:52 -08:00
gramps b0f8263414 feat(quality): learn the starting rate from real sessions, on the creator's permission
Each finished broadcast is filed locally — tier, probed encoder, rated, settled, steps
spent — and Models/RateHistory turns that into at most a suggestion. The app never
applies one: it asks before the next stream, "Use it" sets the rate, "Keep mine"
remembers that exact number so the same question does not return until the evidence
moves. Nothing leaves the machine.

Two rules keep it from nagging. The high-water mark is the highest rate that
*finished* a session with zero steps, not the highest seen, so one easy session (a
menu screen, a quiet game) cannot set a target the machine cannot hold. And it is
asymmetric: raising needs 10% above the current rate across five clean sessions of the
same tier and encoder, lowering needs three step-downs in five — fixing damage should
not wait for a percentage, and risking it should.

The encoder key is real, not decorative: StreamHealth.VideoEncoder is now filled from
the probe (FfmpegEncoder), so history recorded against NVENC is not applied to a
machine that later runs QSV. At startup, before anything has streamed, the last
session on that tier is the honest guess.

672 pass, 0 warnings.
2026-11-12 08:24:12 -08:00
gramps f2cab5ca0f feat(quality): the creator sets the rate — per tier, persisted, clamped only at YouTube's floor
The tier table cannot know a creator's connection, so it should not pretend to. The
Mbps box under the picker is theirs: the tier chooses geometry and frame rate, the
box chooses bits. Stored per tier (`Video.Rate.<tier>`), so tuning 1080p60 does not
touch 720p30, and none means "the shipped default" rather than zero.

Clamped at the tier's YouTube minimum (`RateLadder`), because a target under it
cannot hold the resolution; no ceiling otherwise, because a wrong guess is exactly
what live step-down exists to catch, and a creator who types 25 on a fibre line
should be allowed to find out. The tooltip carries YouTube's number for the tier.

`MainViewModel.EffectiveQuality` is the single substitution point, and the encoder
options, the step-down policy and the clip buffer all read it — so a rated tier can
never stream at the shipped default because one call site was missed.

One ordering trap found by the test: the constructor resolves the default tier
before the layout store is open, so a read at that point must not cache, or the
shipped default shadows the creator's stored rate for the whole run.

670 pass, 0 warnings.
2026-11-12 08:11:13 -08:00
gramps 93719a286d feat(step-down): the bitrate floor is the tier's YouTube minimum
Step-down used a flat 3 Mbps (4 for vertical) as its floor — a number that had
nothing to do with the tier being streamed. With CBR holding the target, that
floor is a real picture-quality floor, so it has to follow YouTube's published
H.264 table (support.google.com/youtube/answer/2853702): 6 at 1080p60, 5 at
1080p30, 3 at 720p.

Models/RateLadder is that table as pure policy. Vertical is not a separate row:
1080x1920 and 1920x1080 are the same pixel count, so the frame rate alone picks
the band. StepDownPolicy takes the floor as an optional constructor argument and
refuses one above the starting rate, so an unusual start cannot begin under its
own floor. Omitted, the historical 3/4 floor still applies, which keeps the
existing policy tests meaningful and lets a caller opt in.

One consequence worth knowing: the three-step budget can stop above the floor
(12 → 9.6 → 7.7 → 6.2 from a 20% loss), so the floor is a limit, not a target.

669 pass, 0 warnings.
2026-11-12 08:05:55 -08:00
gramps 1708667602 feat(quality): retarget the ladder — 1080p60@12, 1080p30@10, 720p@6
Every 1080 tier carried 8 Mbps, inherited from TASK 4's "mainstream ceiling,
upload headroom stays comfortable". It was never derived from anything: the same
number served 30 fps and 60 fps, landscape and vertical, which no per-tier
derivation would produce. With CBR now actually holding the rate (700c7a8) that
number becomes the picture quality, so it has to be chosen rather than inherited.

New defaults sit between YouTube's H.264 recommendation (17 at 1080p60, 14 at
1080p30, 8 at 720p — support.google.com/youtube/answer/2853702) and the rate
practitioner OBS guides converge on for YouTube (9–12 Mbps at 1080p60). Vertical
mirrors its landscape pair: 1080x1920 is the same pixel count as 1920x1080.

The free-profile fallback in OutputProfile.Resolve moves with it (8.0 -> 10.0) so
a build with no shipped tiers still emits the 1080p30 rate rather than a stale
one.

668 tests (1 audio-pipe flake under full-suite load, passes alone), 0 warnings.
2026-11-12 08:01:01 -08:00
gramps 700c7a8c23 fix(encoder): ask for CBR — the capped-VBR triad was never a constant rate
The stream was configured as "@8 Mbps" and averaged 5.1. The cause is the argument
set, not the machine and not YouTube: `-b:v K -maxrate K -bufsize 2K` is capped
VBR. Measured on the bundled BtbN build with a static 640x480 frame at -b:v 2000k:

  default rate control   9,858 bytes / 8 s  =     9 kbps
  -rc cbr            2,001,989 bytes / 8 s  = 1,951 kbps
  -rc vbr                9,858 bytes / 8 s  =     9 kbps

On high-motion testsrc2 the same target produced 2006 kbps (CBR) against 1741
(VBR): VBR undershoots by however easy the picture is, and a composited scene
with a still background is very easy.

Spelling is per encoder, verified rather than assumed:
- h264_nvenc, h264_amf: `-rc cbr`.
- h264_qsv: nothing. It has no `-rc`, and libavcodec/qsvenc.c (select_rc_mode,
  FFmpeg d263fce2b2) selects MFX_RATECONTROL_CBR when bitrate == maxrate — which
  the triad already sets. Passing -rc here would be an unknown option.
- libopenh264: nothing. `-rc_mode bitrate` changed no measurable output on this
  build (3,832 vs 3,830 bytes), so it stays out rather than adding a line whose
  effect nobody can demonstrate.

Reference: YouTube's encoder spec requires CBR
(support.google.com/youtube/answer/2853702).

668 tests pass, build 0 warnings.
2026-11-12 07:57:36 -08:00
gramps 37ef74eb8d docs(handoff): bitrate diagnosis, four-unit plan, unit 1 landed
Records the 5.1-vs-8 finding as an args defect (no -rc, so NVENC ran VBR) rather
than a machine or ingest limit, the four units that follow, and two new landmines.

Assets/starting-background.jpg stays out of this commit: it is the creator's
replacement art, still awaiting the visual check recorded in the landmines.
2026-11-12 07:51:30 -08:00
gramps 1e3a4f7fe0 feat(entitlement): pre-release switch so Release builds can test 60 fps
The frame-rate boundary is correct as a product rule, but it only bites in a
Release build, where DevBuild.IsDevelopment is false and DeveloperMode cannot
unlock anything — exactly the build whose performance numbers are worth reading
(see docs/ai/environment.md: Debug pixel paths are several times slower). 60 fps
could not be tested at all.

OutputProfile.EntitlementEnforced is false: every shipped tier is selectable by
any build, and HasProOutput — the one property the tier setter, the dropdown's
strikethrough and the locked-item style all read — answers yes regardless of
ownership. static readonly rather than const, so the consumed `||` does not fold
into unreachable operand code (same reason as DevBuild.IsDevelopment).

The licence indicator row is suppressed while the switch is off: it exists to
explain what the boundary costs, and claiming "Steam ID Limit: 60fps" to an
unowned build would be a false statement. Docs corrected in place — the enforced
rule is unchanged, only its state is recorded.

Tests: one integration case covers both switch states (unowned build reaches
1080p60 with the switch off, 1080p30 with it on), so flipping at launch leaves no
red test behind. The two suites that anchor on the pinned licence row early-return
while it is absent; EntitlementTests covers that state meanwhile.

667 pass, build 0 warnings.
2026-11-12 07:50:59 -08:00
gramps 9c0c40c688 feat(settings): make slide-out alerts persist until dismissed
Add a persisted global App Settings switch. Off keeps the existing per-severity
lifetimes (errors remain sticky); on makes every alert non-expiring and closes
it when its body is clicked. The NotificationService supplies the current
setting to each toast request.

StickyNotificationIntegrationTests covers persistence and both request modes.
618 tests green, clean build with 0 warnings.

Reference: Notification.Wpf exposes a CloseOnClick request option:
https://github.com/G-Stas/Notification.Wpf
2026-11-11 10:19:20 -08:00
gramps ff5c005bd9 feat(settings): position slide-out notifications
App Settings now chooses one of six ToastArea positions (left/right ×
top/center/bottom), defaulting to the existing bottom-right. The setting is
persisted per profile and the toast area moves immediately; edge gutters keep
bottom notifications above the footer.

ToastPositionIntegrationTests covers live repositioning and restart persistence.
617 tests green, clean build with 0 warnings.

Reference scan: OBS Notifier supports user-configured notification placement:
https://github.com/DmitriySalnikov/OBSNotifier
2026-11-11 10:10:21 -08:00
gramps 2f5d0c1759 feat(settings): position slide-out notifications
App Settings now chooses one of six ToastArea positions (left/right ×
top/center/bottom), defaulting to the existing bottom-right. The setting is
persisted per profile and the toast area moves immediately; edge gutters keep
bottom notifications above the footer.

ToastPositionIntegrationTests covers live repositioning and restart persistence.
617 tests green, clean build with 0 warnings.

Reference scan: OBS Notifier supports user-configured notification placement:
https://github.com/DmitriySalnikov/OBSNotifier
2026-11-11 10:06:54 -08:00
gramps 9621db29c9 fix(stream): keep recovery notifications and stop on the dispatcher
The FFmpeg ProcessFailed event arrives off-thread. Start recovery on the UI
dispatcher, publish retry status there, and route StopStream back to the
dispatcher before changing bound state or alert collections. This prevents a
recovery exception from becoming an unhandled WPF CollectionView crash.

AttentionAlertStackTests covers worker-thread recovery reporting and stop.
616 tests green, clean build with 0 warnings.
2026-11-11 09:57:26 -08:00
gramps 24077e58cf fix(stepdown): restart the audio pipe around encoder changes
A quality step-down now closes the named audio pipe before stopping/flushing the
pump, then opens a fresh server before restarting the pump. This prevents ffmpeg
from waiting on the old audio input and avoids reconnecting to a broken pipe.

EncoderRestartTests pins the audio-stop → video-stop → audio-start → video-start
order. 616 tests green, clean build with 0 warnings.
2026-11-11 09:52:27 -08:00
gramps 2c5a3d8c11 docs(70): queue the Live Window App overlay layer
Record the creator's Live-only, single-layer scope and distinguish it from the
existing session-scoped Capture Window backdrop pin. Include HWND lifecycle,
protected-window, visual-only, and duplicate-underlay boundaries.

Reference scan: Microsoft HWND capture item API and OBS Window Capture:
https://learn.microsoft.com/en-us/windows/win32/api/windows.graphics.capture.interop/nf-windows-graphics-capture-interop-igraphicscaptureiteminterop-createforwindow
https://obsproject.com/kb/window-capture-sources
2026-11-11 07:42:37 -08:00
gramps 4e5b4e3999 docs(69): queue the chat sample preview toggle 2026-11-11 07:37:38 -08:00
gramps b265d42fb4 docs(handoff): record pushed main state 2026-11-11 07:31:19 -08:00
gramps 1d35a7400f docs(37): close defaults/current split and advance the queue 2026-11-10 18:10:35 -08:00
gramps e6c6913994 feat(37): expose the staged-scene factory reset
App Settings can now restore only the staged canonical scene. The VM saves
pending edits first, resets that scene, reloads immediately, and keeps the same
scene staged. Other scene edits survive; the action is disabled while live or
recording. 615 tests green, clean build with 0 warnings.

Reference: OBS Scene Collections keep scene/source sets distinct:
https://obsproject.com/kb/scene-collections
2026-11-10 18:09:16 -08:00
gramps 41064bdd13 feat(37): expose the all-scenes factory reset
App Settings now offers a clearly described all-scenes reset. It is disabled
while live or recording and reloads the restored layout immediately. The
integration test drives the real VM command and checks the Settings binding.
614 tests green, clean build with 0 warnings.

Reference: OBS Scene Collections are a distinct saved set of scenes and sources:
https://obsproject.com/kb/scene-collections
2026-11-10 18:05:35 -08:00
gramps b8eb2b4451 feat(37): restore all scenes to factory defaults 2026-11-10 17:59:28 -08:00
gramps ff1c1965e0 fix(37): preserve camera choice when restoring default layouts
Build-mismatch restoration now keeps the currently selected webcam identity,
restores default scene placements against that camera, and leaves cameras absent
when none is selected. The integration regression test pins the creator's camera
choice and restored default geometry.
612 tests green, clean build with 0 warnings.
2026-11-10 17:56:24 -08:00
gramps c4da06b56b feat(37): reset one scene to its factory snapshot
The per-scene reset restores that scene's settings, layers, webcam placement,
and background-path settings while leaving other scenes' current edits alone.
A focused integration test covers the restore/isolation behavior.
611 tests green, clean build with 0 warnings.

Reference: OBS scene collections store scenes and sources as a saved set:
https://obsproject.com/kb/scene-collections
2026-11-10 17:52:20 -08:00
gramps 1c6fc3223d feat(37): restore the default layout when the build ID changes
A mismatched or missing current-build stamp restores the immutable default
snapshot atomically before the VM loads scenes. The new integration case edits
current on build A, opens on build B, and verifies the factory layout returns.
610 tests green, clean build with 0 warnings.

Reference: OBS scene collections keep scenes/sources as a separate saved set:
https://obsproject.com/kb/scene-collections
2026-11-10 17:48:43 -08:00
gramps 37791086ae feat(37): keep a default layout snapshot beside the current layout
The matching-build persistence slice is in place: existing layouts are captured
once as defaults, first-run factory scenes are saved and captured before the
creator can edit, and same-build startup continues to load current rows. Default
asset references survive current-layout saves.

One integration test covers the current-layout roundtrip and immutable default.
609 tests green, clean build with 0 warnings.

Reference scan: OBS stores scene collections separately from output profiles;
this split keeps layout state independent in the same SQLite store:
https://obsproject.com/kb/scene-collections
2026-11-10 17:45:39 -08:00
gramps b093609ad3 docs(14): close mic gain slice after creator test 2026-11-10 17:40:44 -08:00
gramps de787e0dad docs(65): replay clips complete; queue moves to 66 2026-11-10 12:55:06 -08:00
gramps 5fcfa545e5 feat(65): the replay layer plays — once in Live, looping everywhere else
608 green, 0 warnings. Completes the layer half of TASK 65.

- **(+) → Replay Clip** adds an ordinary media layer with an app-owned path, one per scene, silent by
  default (`MediaMuted`) and looping only outside Live — the compositor needs no special case.
- **F10 saves → the Live layer plays it once, then hides** (`SessionCompleted` → invisible, guard
  cleared). Every other scene with a replay layer keeps looping the kept set, advancing on each clip's
  completion, wrapping at the end.
- **The guard the creator asked for:** while that Live replay is playing, **F10 is inert** with a plain
  explanation. That is what replaces the impossible "exclude the layer from its own clip" — with one
  encoder and two muxes the clip *is* the program, so the fix is to not clip a clip.

`ReplayLayerTests` drives the real VM: one layer per scene, muted by default, once-through in Live,
looping in BRB, and a stray F10 with clips off writes nothing.
2026-11-10 12:54:54 -08:00
gramps a5c2061b55 feat(65): the replay layer exists in the model; the exclusion problem is solved by the guard
Slice 5a, scaffolding only. 607 green.

- `Source.IsReplayLayer` + schema column + roundtrip: a media layer that shows saved clips instead of a
  chosen file. The compositor needs to know nothing — it is a media layer whose path the VM owns.
- **Two spec corrections from the creator (2026-11-10), recorded in the task:**
  1. The immediate replay of a fresh clip happens **only in the Live scene**. While it plays, **F10 is
     inert** ("replay in progress").
  2. That guard replaces the "layer excluded from its own clip" idea, which is **impossible** with one
     encoder and two muxes: the clip is a second mux of the same frames, so it necessarily contains what
     viewers saw. No second encode, no fiction about a clean feed.

Remaining for 65: the add-layer menu entry, `ClipSaved` → play (Live: once then hide; elsewhere: loop the
kept set), `SessionCompleted` → advance or hide, and silent-by-default audio.
2026-11-10 12:48:41 -08:00
gramps 28657918f9 feat(65): F10 saves a clip; the gear drawer owns the three settings
Slice 4. 607 green, 0 warnings. Clips remain **off by default** and, while off, the ring is never
constructed — so no reader thread and no encoder output exist until someone asks for the feature.

- **F10** (`HotkeyId.SaveClip`, default F10, remappable in the hotkey dialog) snapshots the ring, writes
  the file, prunes to the retention count and toasts the name. Empty ring → "nothing to clip yet"
  instead of a zero-byte file; clips off → says so and points at Settings.
- **Settings**: Enable, length (10 or 15, default 10), keep (3/4/5, default 3). Persisted in the layout
  store and **coerced on load** — a bad stored value falls back to a legal one, so the ring can never be
  sized for a length the UI cannot show.
- The ring is sized from length × tier bitrate × 1.25 and rebuilt if either changes; it is created at
  session start and dropped at stop, so memory is only spent while a show is running.
- The drawer states the boundary in the creator's terms: "A capture, not an editor: for trimming,
  captions or a library, use a video editor."

Still to come: the replay layer itself (play once then hide in Live, loop the kept clips elsewhere).
2026-11-10 12:41:39 -08:00
gramps f7d3d1301e feat(65): clips on disk — write one, keep 3/4/5, delete the oldest
Slice 3 of TASK 65. 606 green, 0 warnings.

`ClipLibrary` is the whole of clip "management": a file lands in `clips\` beside the recordings, the
oldest is deleted past the retention count, and `Recent()` returns newest-first because that is also the
replay layer's playlist order. No gallery, no prompt, no naming.

Two guards the tests pin: an **empty snapshot never becomes a zero-byte file** (a save that fails loudly
is better than a file that won't open), and **pruning only touches our own prefix** — another file in the
folder survives.

`RingBytesFor(seconds, bitrate)` is the memory cap the ring is sized with: exact wire size × 1.25
headroom, because bitrate moves and the ring trims whole fragments.

Still to come: the replay layer, the F10 hotkey, and the Settings switch.
2026-11-10 12:35:38 -08:00
gramps 5066d81aab feat(65): the clip mux — a third output on the same ffmpeg, drained off the frame path
Slice 2 of TASK 65. 602 green, 0 warnings. **Clips are still off**: no `ClipBuffer` is passed, so the
ffmpeg command line is byte-for-byte what it was before this commit, and a test asserts exactly that
(the original args are a strict prefix of the new ones).

- `FfmpegArgs` appends a fragmented-MP4 output to `pipe:1` only when `ClipBuffer` is set. One-second
  fragments (`-frag_duration 1000000`) rather than the 4 s GOP, so a clip can start inside the window
  instead of at the last keyframe. Stats already own stderr, so stdout was free.
- `Fmp4FragmentSplitter` turns that byte stream into whole fragments: `ftyp`+`moov` is the init
  segment, each `moof`+`mdat` is a fragment. It parses box headers itself, survives a box being cut
  anywhere across reads, and **resyncs instead of throwing** — a clip fault must never take the stream
  down.
- `FfmpegEncoder` reads it on its own task, never blocking the frame path. Any exception logs and
  disables clips for the session; the broadcast is untouched.
- `IEncoderProcess` gained `StandardOutputBytes` (the seam already had the text reader for probing).

Next slices: the replay layer + retention, then the Settings switch with disabled-but-remembered controls.
2026-11-10 12:25:19 -08:00
gramps b93744ec7f feat(65): the replay ring — encoded fragments in RAM, capped by bytes
Slice 1 of TASK 65, pure memory, no encoder change yet.

A 1080p BGRA frame is 8.3 MB, so fifteen seconds of raw composite is ~7.4 GB — the 15 MB figure in the
spec is the *compressed* stream. So the ring holds fragmented-MP4 bytes that ffmpeg will write to a
pipe, and a save is init-segment + surviving fragments in order.

Two rules the tests pin: the cap is by **bytes** (bitrate moves; seconds do not), and the newest
fragments always win, because a clip that ends at the keypress is the entire point. The init segment does
not rotate and cannot be reused across encoder starts, so `Clear` drops it too — an MP4 without its moov
is noise.

Next slice: ffmpeg writes the buffer output to stdout while `-stats` stays on stderr, and the pump feeds
this ring. Not done here on purpose — an encoder change is the riskiest edit in the app and deserves its
own commit and test.
2026-11-10 11:59:02 -08:00
gramps 1a6a549e17 feat(55): stop leaving disk debts — temp clips and a low-disk guard
595 green, 0 warnings. Completes TASK 55 (slice 1, log rotation, shipped 2026-10-22).

- **`AlertClipCleanup`**: the default alert clip is materialised into `%TEMP%` for ffmpeg to read, and
  nothing ever deleted it — one orphan per profile that ever played an alert. Now: delete this session's
  file in `Shutdown`, and sweep `ytLive-alert-*.mp4` at startup for anything a crash left behind. Only
  the prefix we own is touched; a directory is never deleted.
- **`DiskSpaceGuard`**: a recording that dies of a full disk is a trust breach, so the numbers are a
  pure policy — refuse to start under **1 GB** (toast; the stream still starts, recording does not),
  warn under **5 GB** at start ("about eighty minutes" at 8 Mbps ≈ 3.6 GB/h), and one mid-show warning
  under **2 GB** — the last useful moment before the encoder fails for a reason it won't explain.
  Unreadable drives (a USB stick that just left) return null and never block a running recording.

Tests are policy-level on purpose: filling a real drive is not a test. `DiskHygieneTests` proves the
sweep only removes our own files and that the thresholds behave at both ends.
2026-11-10 11:55:42 -08:00
gramps c257de1516 feat(35): scenes remember the room — BRB mutes the mic on entry
591 green, 0 warnings.

- `Scene.SceneMutesMic` / `SceneMutesDesktop` are **nullable booleans**: null = leave the mix alone, so
  a scene is passive until the creator says otherwise. Schema columns added, NULL for every existing
  layout except one deliberate default.
- **Applied once when the scene takes the PROGRAM output** (`TransitionToScene`, only when live or
  recording). Staging a scene offline is auditioning, not a stream edit — the test asserts that.
- **The creator is boss:** a manual mute survives any transition into a scene with no opinion; a scene
  that does have one wins on its next entry. `SetMicMuted`/`SetGameMuted` are idempotent so a repeated
  "mute" cannot clobber the level that unmute would restore.
- **BRB ships muted** (creator spec) for new layouts *and* via a one-time migration for existing rows
  where the creator has never expressed an opinion.
- UI: two three-state checkboxes under the layers list ("When this scene goes live"), styled with
  `PropertyPillCheck` so the theme test holds.

`SceneAudioTests` covers the whole rule in one integration test: default state, offline staging does
nothing, live entry mutes, manual unmute survives a passive scene, an opinionated scene wins, desktop
untouched, and all three states roundtrip through save/reload.

Needs the creator's eye: the checkboxes are new chrome in the left panel (indeterminate = passive).
2026-11-10 11:51:21 -08:00
gramps 80ccdc9cb8 docs(readme): new hero screenshot (1400×862, 289 KB)
Creator-supplied capture of the main window: five scenes across the top, the Starting scene with its
three callouts in the preview, the layer panel, and the audio dock.

Re-encoded with the bundled ffmpeg instead of the WPF resize tool (scale 1400 + q4) — one command, the
same tool the rest of the assets use — and that shortcut is recorded in MyMistakes next to the original
recipe so it is not re-derived a third time.
2026-11-09 14:32:02 -08:00
gramps ec6a5ca466 docs(queue): six veggies, then the new work; scheduling and publishing retired
Creator ruling 2026-11-09: finish the queue before the new features, gold pass always last.

**Retired (platform's surface, not ours):**
- 34 scheduled streams — YouTube owns scheduling *and* the reminder that makes it worth anything.
- 50 publish recording — Studio owns upload (resumable, quota-aware, explains rejections), and an
  in-app uploader would widen the OAuth consent to whole-channel video management for one convenience.
  The app adds "Show in folder" instead.

**New tasks:** 65 replay clips (the creator-side buffer YouTube does not give you), 66 timed ticker
messages, 67 per-event alert videos, 68 edit-while-live + first-run On-Air.

**TASK 65 spec** is the interesting one: 15 s is a clip (10/15/20, default 15); the ring holds encrypted
*encoded* fragments in RAM — raw 1080p frames would be ~7.4 GB for 15 s — so there is no steady disk
write and no crash debris; F10 saves; the replay layer plays once then hides in Live and loops the last
1–3 elsewhere; silent by default; three kept; the layer is excluded from its own clip (hall of mirrors).

**TASK 14 gains Branch 3:** mic input gain ahead of the gate (table boom mics fall under it), webcam
blank hotkey, opacity/geometry parity with a collapsed Geometry disclosure, Show-in-folder.

**Decisions recorded:** the replay-buffer reversal (it was retired in 2022-era reasoning as "different
product"; the case for it won), the platform-surface rule, and voice triggering deferred — always-
listening in a streaming app hears the game, so the content is its own false-positive source.

Queue refactored to 56 lines by archiving completed rows and the superseded surge plan; HANDOFF at 40.
2026-11-09 14:27:07 -08:00
gramps 42228c7aab docs(handoff): 40 lines 2026-11-09 13:21:14 -08:00
gramps 4b3d3f2645 docs(handoff): fit the 40-line budget 2026-11-09 13:05:59 -08:00
gramps 4855d5cf8d docs(handoff): TASK 33 complete; queue starts at 34 2026-11-09 13:05:53 -08:00
gramps 0fa3006da7 feat(33): shed our own capture cost first; the task is done
Last slice. 590 green, 0 warnings.

- **Shed before you charge the viewer.** On machine evidence the policy's first act is
  `ShedCaptureCadence`: `ScreenCaptureFrameSource` widens its conversion interval 10 ms → 33 ms (≈100
  → 30 conversions/s) through a new `ScreenCaptureManager.ShedLoad`. The output tier, the declared fps
  and the viewer's picture are untouched — we stop paying for readbacks we were throwing away. It
  costs no step budget; if the starvation persists, the fps ladder runs on the next clean window.
- The shed is reset on every stop path, so a cheap session cannot leave a later one capped.
- **The session line is a Logs-tab line, not a session-report line.** The session report is the money
  summary (Super Chats, per currency); quality chatter there would bury the numbers it exists for. The
  cause-tagged line already lands in the log at the moment of the step.

TASK 33 is complete: two ladders, budgets, floors, ambiguity, one-way with recovery behind a flag, the
relaunch, the copy, and the cost shed. 18 tests pin the ladders, the copy and the restart.
2026-11-09 13:05:44 -08:00
gramps 134103c08b feat(33): the step-down acts — one encoder relaunch, one toast, same URL
Slice 3. 589 green, 0 warnings.

- **`FramePump.ChangeEncoderAsync`** is a full pump stop/start, not an encoder swap. A swap leaves an
  in-flight submit pointing at a disposed process, and that faults the loop — which ends the
  broadcast, the one thing a quality step-down must never do. `StopAsync` already awaits the loop and
  flushes, so this reuses the tested path and costs one short freeze, which the ladder accepts.
- **`MainViewModel.StepDown`**: one planner per broadcast, subscribed to `StatsReported`, unsubscribed on
  every stop path. The pump thread only decides; the restart is posted to the dispatcher, because the
  act stops the loop that raised the event.
- **The creator's choice is not rewritten**: `_encoderTierOverride` changes what the encoder emits while
  the dropdown keeps showing what they picked, and the badge adds `· lowered` so the two never disagree
  silently.
- Copy comes from `StepDownPlanner.Describe` (machine cause owns it, upload cause blames the upload);
  the log gets the cause-tagged line for support.

Test: `EncoderRestartTests` runs a real pump with a fake encoder, changes the tier, and asserts a NEW
encoder started at the new fps/bitrate and produced a frame — the regression that would otherwise ship
as "it lowered the badge and the stream died".

Remaining for 33: shed our own capture cost before the fps step, and the session-report line.
2026-11-09 12:50:53 -08:00
gramps b84802e657 docs(handoff): trim to budget; 588 tests 2026-11-09 11:54:15 -08:00
gramps b39439abdc docs: the vertical take is clean — 29.8 distinct webcam images/s
Creator's Release take `ty-20261109-1142-036-0005.mp4` (1080×1920 @ 30, 300 s) measured by cropping the
webcam tile and diffing frames: 29.8 distinct images/s overall, median 30 s window 30.0/s, worst 24/s,
one 4-frame repeat run in 9026 frames. The app's own lines agree (`webcam 28.4–30.0/s` against camera
`arrivals 29.9–30.1/s`).

- The 25.6/s failure recorded on the 2026-10-05 16:9 take does **not** reproduce in the vertical tier,
  so the vertical take no longer blocks the cadence units.
- Recorded the measurement trap in `frame-pipeline.md`: a threshold of 1.0 on a downscaled tile
  reports long "0.0/s" stretches during low motion — duplicates sit below 0.02, a still face at
  0.3–0.7. Read the histogram before believing a count.
- Vertical-tier camera check reported 1–6 pass; both creator-hands items in TASKS.md are closed.

(The mic being off for the first 2:42 does not affect video cadence.)
2026-11-09 11:53:58 -08:00
gramps 555ff5c0ac feat(33): structured pump stats + the session planner and its plain-words copy
Slice 2. 588 green, 0 warnings.

- `FramePumpStats` and `FramePump.StatsReported`: the 5 s window the log line already describes, as
  data. `Dropped` is the ENCODER's counter (frames submitted, not accepted); `Stalls`/`AvgRenderMs`
  are the pump's. `FramesRendered` is deliberately not used for classification — a static scene
  reuses the paste cache and renders few frames while completely healthy, so it would read as
  starvation.
- `StepDownPlanner`: one per broadcast, owns the stateful policy, converts stats → sample, exposes the
  landed tier for the restart. It never acts — the VM owns the effects, which is what keeps the ladder
  testable without an encoder.
- `Describe` / `ReportLine`: the sentences a creator sees. Machine cause says LlamaCasty couldn't keep
  up and the connection was not the problem; upload cause blames the upload and promises no further
  thrash. A test asserts the machine copy never leaks "dropped" — mechanism strings stay in the log.

Remaining for the task: VM subscription, the encoder restart at the landed tier, the toast, and
shedding our own capture cost before stepping fps.
2026-11-09 10:06:36 -08:00
gramps bc62bd81ec feat(33): StepDownPolicy — two ladders, one budget, recovery behind a flag
Pure policy, no wiring yet. 12 unit tests, 584 green overall, 0 warnings.

- **Machine starvation** buys less work: 60 → 30 fps first, then 1080 → 720. The vertical tier has no
  resolution step — its floor *is* its only geometry.
- **Upload congestion** buys fewer bits: `current − max(0.5, current × lossFraction)`, floored at
  3.0 Mbps (16:9) / 4.0 (vertical). Resolution is deliberately not a bandwidth lever: 1080p30 and
  720p60 cost the same, so dropping pixels at an unchanged bitrate relieves nothing.
- **Budgets** as ruled: three steps for a 1920-wide start, one for 720 and vertical. 15 s of evidence,
  30 s cooldown, ambiguous evidence does nothing at all.
- **Recovery** is `allowClimb`, default off (the one-way ruling): the machine carries its ceiling from
  construction, climbs one step after 180 s of clean windows, and a climb followed by a relapse inside
  120 s freezes the session at the low value — one flap pair is proof enough.

Inputs are the poster's own counters (`PumpStalls`, `PumpDrops`, `AvgRenderMs`, `EncoderDrops`), not a
throughput reading: `StreamHealth.CurrentBitrate` is ffmpeg's *encoded output* rate and sits at target
even while the socket is gagging, so it cannot size a step.
2026-11-09 09:30:28 -08:00
gramps 92d875c343 tools(stats): mine sessions from the logs, split by sink
`scripts/session-stats.py` turns every `startup*.log` into one row per FramePump session: tier,
scene, measured fps, drops, stalls, average/worst render, capture rate, and the build — with the
sink marked, because that is what makes the numbers quotable:

- **RECORD** — machine only, no socket. The cleanest minimum-config evidence.
- **TEST** — machine + socket, no audience; recognised by `Test session dock open` /
  `Test chat simulation`, which the creator pointed out are recorded too.
- **LIVE** — network and audience included.
- **UNKNOWN** — no marker; not evidence.

Verdicts use the average render against the frame budget, not the worst: the worst is dominated by
the 100 ms first-frame bake (a session that "holds" can still show worst 35 ms). `holds` = no drops,
no stalls, average under half the budget.

First run over the existing logs already separates the two halves of TASK 33: static Starting scenes
render at ~1.5 ms, the same pump with display capture running at 35–46 ms — the capture path, not the
overlays, is where a budget streamer loses the frame budget.
2026-11-09 09:04:04 -08:00
gramps ed32151136 docs(33): cause-classified step-down, and a toast that owns the mistake
Design settled with the creator. Two findings drove it:

- **Resolution is not a bandwidth lever.** 1080p30 and 720p60 are both 6 Mbps in the shipped table,
  so "step down to 720p" relieves nothing against congestion; and 60→30 at the same bitrate relieves
  nothing either. Congestion therefore steps **bitrate only** (8.0 → 6.4 → 5.1 → 4.1, ~20%), which is
  what OBS Dynamic Bitrate does — cited, adopted, with its climb-back discarded for v1.
- **The other half has no OBS equivalent**: machine starvation (the game and the app on one PC) steps
  fps, then resolution, and sheds LlamaCasty's own capture cost *first*. That is the app taking
  responsibility for a stream it is helping to starve.

Budgets as ruled: 3 steps for a 1080 start, 1 for 720 and vertical, hard floors at 720p30 /
Vertical 1080p30, hold-and-warn at the floor, never disconnect. Trigger ≥5% drops over 15 s twice,
30 s cooldown, ambiguous evidence does nothing.

New rule in `ui-surface.md`: a toast names the cause, the culprit and the remedy. Mechanism strings
("All pipe instances are busy", HTTP codes, exit codes) are log findings, never user-facing copy.
2026-11-09 08:51:31 -08:00
gramps e53f9974a5 docs(tasks): TASK 32 is complete — Back on air shipped; the queue starts at 33 2026-11-08 12:22:35 -08:00
gramps 4a43b3e229 docs(videos): fill the gaps — chroma, video layers, live edit, hotkeys, resilience
Audited the shipped feature surface against the list. Seven subjects were missing, three of them
distinguishers:

- **Green/blue screen removal** — auto-detect + opt-in suggestion, and it keys alert clips too.
- **Video file layers** — clip decodes, audio joins the mixer, per-layer mute/level.
- **Edit the broadcast on air** — title/description/tags/visibility, same fields prefill Go Live.
- Global hotkeys (F1–F5 / F6 / F7 / F8 / F9, OS-level), layout autosave, record with no sign-in.
- Resilience as one subject: ingest pre-flight, reconnect, Back on air, crash-safe fragmented MP4.

Corrections: the transition row claimed Move, which is still queued — now says so. The after-surge
list gained Move and scene-linked audio (35) and dropped entries that have shipped (media file
picking) or that belong to ready rows (resilience). 32 ready subjects, 8 later.
2026-11-06 15:59:12 -08:00
gramps a8f27db4c2 docs(videos): the running list — capture window/desktop, zoom gesture, How to Blur as its own video 2026-11-06 15:57:10 -08:00
gramps b4e952fda9 fix(go-live): the Start Stream dialog re-reads Stream Info at open
Creator went live on the Debug build while the Release build was recording and got an empty
title/description, with the Stream Info tab filled in on screen. Both processes write to the same
profile: `BroadcastForm` is loaded ONCE at startup and each keystroke persists straight to the store,
so the instance that opened the dialog was holding the form as it was when it launched — before the
other instance saved those fields. The dialog copied from that stale copy.

`CreateGoLiveDialog` (new, extracted so it can be tested without showing a window) calls
`BroadcastForm.Load()` before composing the dialog. That is what the form's own doc comment always
claimed ("startup / Go Live refresh") but the refresh was never wired. It cannot lose an edit: every
keystroke is already persisted.

The dialog also logs `Go Live dialog: title='…' description=N chars`, so the next report of "the
dialog was blank" says immediately whether the values reached the dialog or never left the store.

Test: `BroadcastPullOutTests` writes metadata through a second `LayoutStore` handle (the other
instance) after the VM has loaded and asserts the dialog carries it. 572 green, 0 warnings.
2026-11-06 12:56:05 -08:00
gramps bef818f4a1 art(starting): new Starting background — cleaner chat panels
Creator supplied a 2730x1536 render; resampled to the 1920x1080 master at q3 (392 KB, down from
432 KB). The previous render is archived as `Assets/archive/starting-background-v2-2026-11-06.jpg`
alongside the two before it.

No version bump needed any more: `StampDefaultBackgroundAsset` compares the stored asset's hash with
the bundled bytes, so an existing profile re-seeds on the next launch by itself.

The same image replaces the Starting shot in the site's scenes section (llamacasty.web, deployed
2026-11-06 12:39).

Background tests: 45 passed; the Debug assembly embeds the new bytes (391786) and no longer carries
the previous render.
2026-11-06 12:40:27 -08:00
gramps dbcb69278f fix(audio): a DEBUG build never shares the pipe name with Release
Creator was recording on the Release build and hit "Couldn't start the audio — another copy of
LlamaCasty is already using the audio pipe" when going live on Debug. The guard did its job (no
crash), but the collision should not have been possible: the pipe name only suffixed when
`YTLIVE_INSTANCE` was set, and a Debug launched straight from the IDE has no id — so it asked for
the same `ytllive_audio` the Release build was serving.

DEBUG now always suffixes: the claimed instance id when there is one, otherwise the process id. Any
number of dev builds can run beside the Release app, and each Debug process is distinct from every
other. Release is unchanged — it resolves to the plain name, and the whole mechanism disappears in
`#if DEBUG` as before.

`TryStartAudioPipe` now logs the resolved name (`Audio pipe: …`), so the next report of this kind is
one grep instead of a code hunt.

Tests: AudioPipeInstanceTests pins that an id-less DEBUG process never resolves to the bare name;
572 green, 0 warnings.
2026-11-06 12:30:48 -08:00
gramps 30824c6d6e docs(handoff): byte-compare art seed, not a version stamp 2026-11-06 12:21:35 -08:00
gramps 70e2c48136 fix(art): re-seed a bundled background when the stored bytes differ
The creator's profile still showed the previous Starting art after the replacement, with
`Art.DefaultVersion = 4` recorded — so the version gate had already declared itself done. It
compared a constant against the embedded resource, and those two were compiled at different
moments (version 4 landed with the OLD image embedded; the corrected image reached the assembly
one build later). A version bump can always be raced that way.

The seed now compares BYTES: `LayoutStore.AssetMatches` hashes the bundled resource and checks the
row the background already points at. Same bytes → leave it untouched (so this is idempotent and
never churns the database). Different bytes → re-stamp. The version constant and its setting are
gone, because a heal that only runs once can silently skip the one launch it was needed for.

`SceneThumbnailTests` planted its own art on a background still flagged as default art — in the
real app that state only ever means stale bundled art, because Browse… sets the path and clears
the flag. The fixture now models a creator's own picture.

Tests: 572 green, 0 warnings. Verified the profile's Starting asset hash (167CDD3B…, the
two-category render) differs from the shipped `starting-background.jpg` (5FC6DD22…), so the next
launch re-seeds it.
2026-11-06 12:21:24 -08:00
gramps 3db964e5cf docs(handoff): Starting art vintage 4, the AllBackgrounds trap, site scenes shot 2026-11-06 12:06:13 -08:00
gramps a6f15a4bc3 art(starting): the corrected onboarding screen, and a heal that actually heals
The creator's corrected Starting artwork (the in-app onboarding screen: sign in → stream details →
go live) replaces yesterday's version. 2724x1536 resampled to the 1920x1080 master, q3 (432 KB).
The previous Gemini render is archived beside the original art.

Also fixes the two reasons the replacement would not have appeared on an existing profile:

- **The first heal iterated `AllBackgrounds()`**, which returns only the LIVE scene's capture rows, so it
  cleared nothing and then saved the version anyway — permanently skipping the heal. It now walks every
  scene's background. Version bumped to 4 so profiles stamped 2 or 3 heal.
- **The pack URI was unqualified**, so it resolved against the ENTRY assembly: LlamaCasty.exe in
  production, the test host under `dotnet test`, where the seed silently did nothing. It is now
  `/LlamaCasty;component/Assets/…`, which works in both.

Test: `BackgroundDefaultArtTests` seeds a profile, plants an "old art" asset on the Starting background,
marks the layout as an older art vintage, relaunches and asserts the shipped art is re-stamped. 45
background tests pass.
2026-11-06 12:04:06 -08:00
gramps f7e1b76cdd art(starting): new Starting background; the old one is archived, not lost
Creator supplied a new 2724x1536 image; resampled to the 1920x1080 master and encoded at q3
(2.9 MB → 424 KB, so the shipped asset also got smaller).

- `Assets/starting-background.jpg` — the new art, same filename, so nothing else changes
- `Assets/archive/starting-background-pre-2026-11-06.jpg` — the previous image, kept in the repo. The
  csproj lists assets explicitly, so an unreferenced file never ships.
- **Existing layouts would have kept the old art forever**: default backgrounds are hash-keyed into the
  layout DB on first seed, and the seed returns early once an AssetId exists. `HealDefaultArtVersion`
  bumps `Art.DefaultVersion` to 2 and clears the AssetId of backgrounds still using the DEFAULT (a
  creator's custom background is skipped), so the next load re-stamps the new image.

Background tests: 44 passed.
2026-11-06 11:48:13 -08:00
gramps 0f08fd7ade fix(start): a second instance can no longer kill the app on Go Live
Reported 2026-11-06: a Release instance crashed when clicking Go Live in test mode, while a Debug
instance was recording its window. The Windows event log gave the cause:

    System.IO.IOException: All pipe instances are busy.
    at System.IO.Pipes.NamedPipeServerStream.Create(...)
    at ytLive.Services.Audio.NamedPipeAudioWriter.Start(...)
    at ytLive.Services.Audio.AudioMixer.StartLive(...)
    at MainViewModel.StartStreamingSession(...)
    at MainViewModel.BeginTestStream(...)
    at MainViewModel.StartFromPrimaryButton(...)
    at System.Windows.Controls.Button.OnClick()

Two defects, both fixed:

1. **A button click could end the process.** `StartLive` throws when the pipe name is taken, and nothing
   on the go-live path caught it. `TryStartAudioPipe` now catches, logs, toasts ("Another copy of
   LlamaCasty is already using the audio pipe") and rolls the session back. Recording-only takes the same
   path.
2. **The pipe name was a shared constant**, so a second instance could never go live on the same machine.
   `InstanceProfile.AudioPipeName` appends the dev instance id (DEBUG only); Release keeps the plain name
   so the normal single-instance case is unchanged. The encoder reads the same derived name, so mixer and
   ffmpeg cannot disagree.

Tests: `AudioPipeInstanceTests` — a second server on one name throws (the collision is real), dev names
derive per instance, and a writer on a taken name throws rather than silently reusing the pipe.
Debug and Release both build clean.
2026-11-06 08:41:16 -08:00
gramps 3261293d57 fix(ui): the gear and socials drawers retract on an outside click
Reported 2026-11-05: those two tabs stayed open when clicking elsewhere, unlike Stream Settings, YPP
and TEST. All five drawers share one host (TextPullOutHost), but the window's click-outside guard only
listed the other three, so the gear and socials panels had no way to dismiss except their own tab.

The guard now closes them too, and a test pins both directions: an outside click retracts the drawer,
and a click inside it does not.
2026-11-05 17:14:40 -08:00
gramps a1d861608f feat(ypp): the three eligibility bars, with dated thresholds (TASK 39·2)
YouTube moves the goalposts, so the app stops modelling them as its own truth and shows progress against
a threshold table that carries the date it was read.

- `Models/YppProgress.cs` — pure: three metrics, per-criterion met, and `MeetsPublishedRule`, which
  encodes the real shape of the rule (subscribers AND either view route). The drawer says "x of 3 criteria
  met — YouTube reviews eligibility" and spells out the either/or, so "2 of 3" can't be misread.
- `Services/YouTubeAnalyticsService.cs` — the two numbers only Analytics publishes: long-form watch hours
  (trailing 12 months, `creatorContentType==video`) and Shorts-feed views (90 days, `==shorts`). No rows
  means NO DATA, not zero.
- **Thresholds are data with a publication date** (`YppThresholds.AsOfUtc`, shown in the drawer) and a
  test fails once that reading is more than 180 days old. The 2027-02-01 doubling (4,000h → 8,000h,
  10M → 20M Shorts views) resolves from the date, not from an edit.
- **No Analytics scope → no invented numbers:** the drawer explains the missing consent instead of drawing
  two empty bars as zero. Money stays behind 3·20's "Show monetary metrics" toggle.
- Fan-funding tier (500 subs) demoted to a single muted line, since the three bars are the ad-revenue path.

Tests: `YppProgressTests` — either/or eligibility, subscribers alone insufficient, Shorts as a qualifying
route, bars clamp at 100%, the doubling date, the staleness guard, Analytics parsing (no rows = no data),
and compact value formatting. FAST gate: 567 passed, 0 failed, 0 warnings.
2026-11-05 16:41:09 -08:00
gramps 0fade9702b feat(ypp): session report derived from YouTube — no ledger, money off by default (TASK 3·20)
The task asked for a `RewardEvents` table plus a backfill. That would have contradicted the published
promise that no viewer, member or donor information is retained after the session ends, and it would
have meant owning a ledger that YouTube already keeps.

So the report is DERIVED on demand instead:

- `Services/SuperChatEventService.cs` — `superChatEvents.list`, parsed for both event types (a sticker
  must not be dropped), authorised by the existing `auth/youtube` scope, so no re-consent.
- `Models/SessionRewardSummary.cs` — rolls events up for the session window with **per-currency totals
  that are never summed together** (a $5 and a €5 are not $10). Nothing is written down.
- The YPP drawer gains a **THIS SESSION** section: counts are neutral and always shown when there were
  events; money appears only when the creator asks for it. `Show monetary metrics` is a drawer toggle,
  persisted, **off by default** — a creator who never monetized is never shown a $0.00.

Also: money is labelled as estimates (removed/refunded events are excluded and totals can be restated
for weeks, not days).

Tests: `SessionRewardReportTests` — both reward types parse, the window makes it a session rather than
a channel report, currencies stay separate, an empty session shows nothing, a sticker-only session
still counts. `BackOnAirTests` moves into the serialised `MainViewModelLayoutPath` collection: it builds
a MainViewModel directly and was flaking against parallel tests that swap the static layout path.

FAST gate: 557 passed, 0 failed, 0 warnings.
2026-11-05 15:38:32 -08:00
gramps 27f655c8de copy(price): the launch price ends on an event we control, not on Valve's queue
The promo read 'until April 1, 2027', which is a public promise tied to a review queue outside our
control: identity verification and the tax interview are in Valve's hands (they quote up to 30 business
days), and the store page and build reviews follow. If any of that slips, the date becomes untrue.

It now reads '30 days after the store page goes live — after that $99, locked for life', in the About
box and in commercial.md, so the claim cannot be wrong however long Valve takes.

Also recorded: the $100 Steam Direct fee is PAID (creator, 2026-11-05); a store page can be configured
once identity clears. The launch-price deadline no longer depends on Valve's calendar.
2026-11-05 15:13:20 -08:00
gramps 9611cee199 docs(steam): record the real onboarding state — a wait on Valve, not a task
The docs described Steamworks onboarding as pending work. It is pending VALVE: the partner account
exists, identity verification is under review, and the tax interview is in their queue with a quoted
turnaround of up to 30 business days. No app IDs are assigned, no store page exists, and Release
entitlement therefore still fails closed — by design.

Distribution.md §2 becomes a status table plus the critical path computed from Valve's own published
windows (30 business days verification → fee/app IDs → up to 12 business days store review → page
public ≥2 weeks before release → up to 12 business days build review). Earliest realistic launch:
early-to-mid February 2027, leaving ~7 weeks before the 2027-04-01 launch price ends.

Nothing in this blocks the code; commercial.md, TASKS 54 and HANDOFF now say so instead of implying
Steam is an outstanding coding task.
2026-11-05 15:08:53 -08:00
gramps dd51f7842a feat(stream): one-click Back on air — a new broadcast, honestly labelled (TASK 32·3)
The last resilience slice. When a drop ends the broadcast, YouTube cannot resume it, so the only way
back is a new one — and that is what the app now offers, in one click.

- `Services/BroadcastTitle.cs` — the resume marker is applied to the title the show was using, and any
  existing marker is REPLACED rather than stacked ("Show (resumed)" → "Show (resumed 2)"), with the base
  truncated so the whole title fits YouTube's 100-character limit. Otherwise a channel grows two
  identically named videos and the creator has to explain it.
- `MainViewModel.BackOnAirCommand` — reuses the ordinary go-live path with the saved details and the
  cached stream: new broadcast, new VOD, no dialog. It appears only after a drop ended the session and
  the ordinary start button steps aside, so there is exactly one way forward.
- The running line goes red OFF AIR (not amber) in that state; the button reads "● Back on air".

Tests: `BackOnAirTests` — first re-air marks the title, a second drop counts instead of stacking, the
100-character limit holds, an empty title still works, and the post-drop state exposes exactly one
start affordance. FAST gate: 552 passed, 0 failed, 0 warnings.

TASK 32 is now complete: fragmented MP4, blip retry, poll-driven RECONNECTING, ingest pre-flight, Back
on air.
2026-11-05 14:59:00 -08:00
gramps ec1091a170 feat(stream): probe the ingest before going live — no more ghost broadcasts (TASK 32·5)
"I forgot the VPN" used to create a broadcast on YouTube that nothing ever pushed to: the app looked
live, YouTube held an empty broadcast, and the creator had to work out what happened. Prevention beats
recovery here, so go-live now asks one question first.

- `Services/IngestProbe.cs` — `IIngestProbe` + `TcpIngestProbe`: parse the RTMP URL, connect to the host
  (rtmp → 1935, rtmps → 443) with a 2 s timeout. Any failure answers "not reachable"; the point is a
  fast unambiguous answer, not a diagnosis.
- `MainViewModel.IngestPreflightAsync` runs **before the first API call** whenever a cached ingest URL
  exists; on a first run it probes once the stream is resolved, still before any broadcast is created.
  A false answer logs, tells the creator ("Nothing was created on YouTube…"), and rolls the session
  back — nothing to clean up on YouTube.
- The probe is a test seam (`IngestProbeOverride`) like the camera and chroma ones.

Tests: `IngestPreflightTests` — unreachable aborts, reachable continues, URL parsing (rtmp/rtmps,
garbage), and the probe against **real sockets**: a listening port answers true, the same port closed
answers false and does so fast. FAST gate: 547 passed, 0 failed, 0 warnings.
2026-11-05 14:56:20 -08:00
gramps d8c1bdba24 feat(stream): RECONNECTING driven by the status poll — no invented countdown (TASK 32·2)
Creator challenge (2026-11-05): "I really don't think that's going to be a constant — it's going to
depend on so many variables, some that are dark to us." Correct, so the measured-grace constant and
the countdown are dropped from the spec. YouTube's cutoff is server-side and depends on ingest health,
prior stream quality, region and load; a pinned number would be a guess wearing a timer, and it would
be wrong exactly when the creator is staring at it.

What ships instead:

- **Amber RECONNECTING** in the top bar with measured facts only: elapsed seconds and the attempt
  number ("0:12 · attempt 2 of 4"). The seconds come from a stopwatch started at the drop.
- **The status poll speeds up while recovering** (30 s → 4 s) and drives the sign; it restores 30 s
  when recovery ends.
- **`chatEndedEvent`** — already in the chat feed we poll and previously skipped — now raises a signal
  that prompts an immediate status check. It never decides anything: `liveBroadcasts.list`
  `lifeCycleStatus` stays authoritative, with `liveStreams.list` health as the push-received signal.
- At `complete`: red OFF AIR (the existing stop path) rather than a pretend-live sign.

Tests: `ChatEndedSignalTests` (the marker is not a chat row, the service raises the signal, and a
complete broadcast ends recovery with the honest message). FAST gate: 543 passed, 0 warnings.
2026-11-05 14:53:06 -08:00
gramps f2c43909c8 fix(about): copyright carries the year — © 2026 Llama Chile Shop
Derived from the clock rather than a literal, so a build shipped next year does not display a stale
date. The About test asserts the rendered string, year included.
2026-11-05 14:44:46 -08:00
gramps 0de953ef66 feat(about): rebuild the About box as a conversion surface
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.
2026-11-05 13:00:57 -08:00
gramps 3fde7fcfe6 refactor(ui): alert rows are two lines of text — the heading line was redundant
Creator: system messages are limited to two lines; the label above the message ('STEAM ID NOT
PRESENT', 'STREAM') was repeating what the row already said and pushing the stack past its budget.

AttentionAlert loses Label; rows render Text only. The licence row is now exactly:
    Steam ID Limit: 30fps
    Click for More Info
(60fps with a close control once owned). Rows are identified by Id internally, so nothing in the UI
depends on the removed text. Tests updated to assert position/text rather than the heading.
2026-11-05 12:13:45 -08:00
gramps 72cb3ad55a feat(stream): retry a dropped push instead of ending the session (TASK 32·1)
A dead encoder used to run the whole stop path, so a one-second blip ended the broadcast. Now a dropped
push is relaunched against the same ingest while the broadcast is still alive.

- `Services/StreamRetryPolicy.cs` — immediate, +1 s, +2 s, +3 s. Minimum waits on purpose (creator
  ruling): YouTube's grace is short, so every second not pushing is grace spent.
- `Services/StreamRecovery.cs` — the loop, with the audio pipe recreated before every attempt (its
  server is single-instance and unusable after the old client dies, so a naive relaunch fails on a
  missing input). After a push comes up it asks the API whether the broadcast survived: "complete"
  ends recovery as `BroadcastComplete` instead of showing a live sign over a dead stream; an API
  hiccup counts as recovered rather than ending a healthy session.
- `FramePump.EncoderFailed` — a dead push is recoverable; a compositor fault still goes through
  `Failed` and ends the session, because retrying it would loop.
- `MainViewModel.Recovery.cs` — wires the steps, reports each episode into the left panel alert stack
  (one row per episode, updated in place, log range painted), and cancels on Stop.
- Alert stack polish: a rule and a gap now separate the stack from the scene layers (they read as one
  list before), and the licence indicator uses the creator's wording — `STEAM ID NOT PRESENT` /
  `Steam ID Limit: 30fps` / `Click for More Info`, becoming `STEAM ID PRESENT` / `60fps` with a close
  control once owned.

Tests: `StreamRecoveryTests` (schedule, recover on attempt 2 with the pipe restarted each time,
complete-broadcast honesty, unknown liveness, give-up, cancel, audio-restart fault),
`FramePumpTests` now pin that process death raises `EncoderFailed` and not `Failed`.
FAST gate: 538 passed (one documented pre-existing audio failure), 0 warnings.
2026-11-05 12:09:38 -08:00
gramps a80108dd96 feat(ui): left-panel alert stack — licence indicator pinned on top, events LIFO below
Creator design (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, pushing the panel's content down.

- Row 1 is the **Steam licence indicator** and nothing displaces it. It reads as status, not a
  warning: "Entitlement 30 fps · Maximum 60 fps", both numbers derived from QualityOptions and
  HasProOutput. While higher tiers are unavailable it shows a **pin** (standing indicator, no close
  control); once ownership is verified the pin becomes a **close control** and the dismissal is
  persisted (`Steam.LicenceIndicatorDismissed`), permanent while ownership holds. Losing ownership
  restores it as pinned — a change of state, not a preference. Click → About.
- **Event rows stack beneath, newest first**, one row per episode (an episode 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).
- Replaces the old red panel under the quality picker, which is removed.
- The Steam diagnostic text loses "Contact the creator or support" for a neutral
  "Steam licensing is not configured in this build."

Tests: `AttentionAlertStackTests` — pinned first with the status text; owned → closable and the close
survives a reload but ownership loss restores it pinned; pinned cannot be dismissed; event rows LIFO
with in-place episode updates; clicking an event opens Logs and sets its range. AboutHub + Entitlement
tests updated for the removed panel and the new wording. FAST gate: 532 passed, 0 failed, 0 warnings.
2026-11-05 11:41:59 -08:00
gramps adf9883ae9 docs(tests): document FAST and PHYSICAL_TESTS in the gate header
Also corrects what the 17s actually is: the RealApp collection's one-time fixture (WPF App + XAML
resources + audio/WebView2 startup) charged to whichever UI test runs first — ZoomBoxInteractionTests
measures 1.1s beside other UI tests and 17.1s when it happens to be first. Nothing to trim in the
test; the lever would be App startup cost.

Full suite on the current tree: 575 passed, 0 failed, 1m47s, clean build.
2026-11-05 11:03:59 -08:00
gramps 77eebdb2ce chore(tests): FAST=1 gate (62s) alongside the full 2m19s run
Measured the suite with a TRX logger instead of guessing: 575 tests, 2m19s wall, 165s of test
time. The 20-minute runs were NOT the suite — they were a malformed XAML trigger throwing on window
construction, crashing the test host and hanging the retry. That is fixed (17d8353).

The cost is concentrated: 113 of 155 classes finish under 1s (17s total), while a dozen classes
wait on real timers — ZoomBoxInteractionTests alone is 17s for one test. FAST=1 excludes those
twelve (527 tests, 49s of test time, 62s wall including the clean build) for runs while the machine
is in use; the full set stays the milestone gate.
2026-11-05 10:55:32 -08:00
gramps 17d835353d fix(preview): repair the round-clip trigger placement — it broke XAML load
The previous commit inserted the round-media MultiDataTrigger INSIDE a <Setter> instead of into the
Style.Triggers collection, so every window that loads PreviewPane threw
"System.Windows.MultiDataTrigger is not of type System.Windows.SetterBase" at parse time. A Debug
build would have failed to open the main window at all.

Caught by `AlertBoxPreviewVisibilityTests` and `AboutHubTests` in the suite, which is the reason
those UI tests exist. Both green again, as is `MediaPreviewDisplayTests`.
2026-11-05 10:51:44 -08:00
gramps 3a56807ee3 fix(media): pace clip audio to a deadline too, and prove the keyed layer in the OUTPUT
Two follow-ups from the creator's lip-sync observation.

1. Audio still slept a fixed 50 ms per chunk — the same "sleep on top of the work" fault fixed for
   video an hour ago, left in place. Every chunk's read cost was added, so a clip's audio fell
   progressively behind its picture: small per chunk, unbounded over a long clip. It now uses the
   same deadline pacer, so audio and video advance on the same wall clock.

2. The preview pane is NOT the compositor. "Preview looks great" proves the decode and the preview
   bitmap; the recording and stream are blitted by SceneCompositor. `MediaCompositeOutputTests`
   renders a keyed video layer over a background through the real compositor and asserts the two
   things that matter: keyed (alpha 0) pixels let the background through, and the subject half
   covers it. So "green screen removed" is now proven in the output path, not just the pane.

Also: a round-clipped video layer had no preview trigger (only webcam+Round existed), so it would
render in the output and be invisible in the pane. Same unit-circle path now covers it.

Tests: 16 scoped (pacer, real-clip rate + keying, composite output, decode pacing, alert pacing)
green. FULL SUITE NOT RUN — the machine is in use: two runs under load aborted in the WPF test host
(HWND subclass crash) and one hit the 15-minute cap. Re-run scripts/verify.sh in a quiet window.
2026-11-05 10:40:25 -08:00
gramps 5577aaaf91 fix(media): play at the clip's real rate — the pipe was copying a frame per read
Reported as "the default video and the green screen video are playing at a hideously slow
rate… I thought the playback timing issues were resolved?!" They were not: two independent
faults, both in the shared clip pipe.

1. QUADRATIC COPY IN THE FRAME READER. `RawVideoFrameReader` re-concatenated the whole pending
   buffer on every `Feed`, and the pipe feeds it 64 KB at a time. A 1080p BGRA frame is 8 MB, so
   one frame cost ~127 copies of a growing buffer — roughly half a gigabyte of memcpy per frame.
   Measured on a 30 fps clip: 6 frames in 3 seconds (2 fps). The reader now fills one preallocated
   frame buffer and hands that array to the VideoFrame, so a frame is copied exactly once. Pipe
   reads are 256 KB. Same clip: 29 fps.

2. PACING SLEPT ON TOP OF THE WORK. A fixed 1/fps sleep after each frame adds the frame's own
   cost, so 20 ms of work on a 30 fps clip yields 1/(33+20) ≈ 19 fps — measured 20.9. Pacing is
   now deadline-based (`Helpers/FramePacer.cs`): wait only what is left of the frame's interval,
   resync when a frame overruns instead of bursting, never sleep longer than one interval.

The alert clip shares this pipe, so it gained both fixes.

Tests: `FramePacerTests` (subtract work, no drift, late → resync, zero duration, reset);
`ChromaKeyRealClipTests.RealClip_PlaysAtItsNativeRate` (throughput floor of 15 fps on the real
30 fps clip — the old code sat at 2); the two pacing assertions now check "at most one interval"
rather than an exact sleep, which is what deadline pacing means. Full suite: 574 passed,
0 failed, clean build.
2026-11-05 09:57:07 -08:00
gramps 69e595da25 test(chroma): commit the real green-screen clip as a development fixture
The creator's test_greenscreen_moving_subject.mp4 (1920x1080, 5s, subject moving over a
green field) is what exposed the detector's brightness ceiling and proved the key colour must
come from the measured screen.

Development-only by construction, not by convention: ytLive.csproj lists Assets as explicit
<Resource Include> entries with no Assets/** glob, so a file that is not on that list is never
compiled in, copied to output, or published. The test project links this one path into its own
Fixtures output, so the repo holds ONE copy (23 KB).
2026-11-05 09:42:36 -08:00
gramps 0f4a035376 fix(preview): draw video layers, unlink Play from Loop, geometry last
Three reports from the creator, plus two bugs the real clip exposed.

1. A video layer was INVISIBLE in the preview. The pane's per-layer Image is Collapsed by
   default and switched on by a whitelist of DataTriggers; MediaSource matched none, so the clip
   decoded into a bitmap that nothing displayed. Same defect the alert box hit on 2026-09-26 and
   the scoreboard after it — now recorded at the top of docs/ai/ui-surface.md with a test that
   asserts the bound Image is Visible (`MediaPreviewDisplayTests`).

2. Play is no longer tied to Loop: the button is always enabled (creator ruling — Loop must not
   toggle Play). Loop still restarts a finished clip, which is the behaviour that was broken.

3. Geometry is now the LAST section of the properties list for every draggable element, with
   opacity at the top; the web source's duplicate geometry block is gone (it now uses the shared
   one, which had been sitting above the media panel).

4. The physical mouse-drag test is deleted. It injected real cursor movement, hijacked whatever
   machine ran it, and failed whenever the desktop was busy. The arithmetic it covered now lives
   in `Helpers/DragReorderMath.cs` — pure, and covered by `DragReorderMathTests` (threshold,
   up/down moves, no-op and out-of-range). The two persistence tests were already synthetic.

5. Real-clip check (creator's `test_greenscreen_moving_subject.mp4`, 1920×1080): the detector
   REJECTED it — a fully saturated green sits at value 1.0 and the detector's 0.98 brightness
   ceiling threw it away. White highlights are excluded by the saturation floor, which is the
   property that actually separates them, so the ceiling is gone. With it fixed, the clip is
   detected as green (high confidence), and keyed through our own pipe 97.8% of pixels go
   transparent. Measured: keying with the canonical #00B140 keys 0.0% of this clip — which is why
   the key colour comes from the measured screen.

Tests: 567 passed, 0 failed, clean build 0 warnings. The gate's scope check flags
`Assets/Videos/test_greenscreen_moving_subject.mp4` — the creator's working copy, deliberately not
committed; the fixture lives under ytLive.Tests/Fixtures.
2026-11-05 09:38:18 -08:00
gramps 242a13ca30 fix(media): a played-out clip can play again; play button beside the Video File label
Reported 2026-11-05: "when I click Loop, the video doesn't play in the preview pane". Two
causes, both real:

- `VideoClipPipe.StartAsync` bailed out once it had run once (`_run != null`), so a clip that
  reached EOF could never be started again — it held its last frame forever.
- Loop is only consulted at end-of-file by a LIVE decoder, so flipping it on a finished clip
  did nothing at all.

Now: the pipe reports running state and is restartable after a natural end; the manager marks a
session finished (keeping its last frame) and exposes `IsRunning`/`RestartAsync`; the layer shows
`IsMediaRunning`/`IsMediaFinished`; flipping Loop on a finished clip restarts it.

UI: `▶ Play` sits to the right of the **Video File** label and is enabled only while Loop is off
(creator ruling: with Loop on the clip already runs continuously, so there is nothing to start).
The panel says "Clip finished — holding the last frame." so a frozen picture explains itself.

Also: verify.sh no longer runs physical-input tests by default. That test moves the real cursor
(SetCursorPos + mouse_event); running it mid-gate hijacks the machine someone is using, which is
exactly how it felt. Opt in with PHYSICAL_TESTS=1 when the desktop is idle; the header documents
the scoped-run command for working hours.

Test: `MediaPlaybackRestartTests` (pipe restart, finished state, Play again, Loop-on-finished).
8 scoped tests green. FULL SUITE NOT RUN — the machine was in use; run scripts/verify.sh in a
quiet window before the next milestone.
2026-11-05 09:15:30 -08:00
gramps e37215d9c4 feat(record): fragment the MP4 so a crash cannot destroy the whole take
TASK 32·4. A plain MP4 keeps its index (moov) at the end, so a power cut or a killed
process left the entire recording unreadable even though every video byte was on disk.
The record block now carries
`-movflags +frag_keyframe+empty_moov+default_base_moof`: index first, a playable
fragment flushed at every keyframe, so an interrupted take plays to the last fragment.

Record block only — the FLV stream output has no index and never gets the flags.
Rename-on-stop is untouched.

Test: `Args_RecordBlock_IsFragmentedMp4_SoACrashKeepsTheFile` asserts the flags on the
record block and their absence from the streaming block.

Not yet proven on a real interrupted take (kill ffmpeg mid-recording, play the result);
the args test is by construction.

Docs: task-32 slice 4 ticked with that caveat, TASKS row, output.md record note, HANDOFF.
verify.sh: 550 passed (mouse-injection test excluded, desktop in use), 0 warnings.
2026-11-05 08:58:48 -08:00
gramps a814bcd502 fix(chat): stop painting what YouTube removed, and retire the local word filter
TASK 49 is retired (creator ruling): YouTube owns chat moderation. Its blocked-words filter and
hold-for-review run upstream of `liveChatMessages.list` — held messages are never returned
(https://stackoverflow.com/questions/74511524/) — so a second local list would duplicate a switch
the creator already owns, with a second set of false positives and a second support surface.

What we actually owed the platform, and had wrong:

- The parser added every item to the overlay. A deleted message arrives as a `tombstone` carrying
  no text at all ("it has since been deleted … only liveChatId, type and publishedAt are present" —
  https://developers.google.com/youtube/data/live/reference/rest/v3/liveChatMessages), so every
  deletion rendered as a BLANK chat row. `hasDisplayContent:false` messages and chat-mode events
  did the same.
- Deletion events (`messageDeletedEvent`/`messageRetractedEvent`/`userBannedEvent`) are documented
  but have not been delivered to clients for years (https://stackoverflow.com/questions/44346893/),
  so nothing is built on them; they are skipped if they ever arrive.
- Found while testing: Super Chat read `displayString`, but YouTube's field is `amountDisplayString`
  — every amount silently fell back to a recomposed string, which mangles non-dollar locales. The
  model's own comment said this was the reason the field exists.

Test: `ChatModerationParseTests` — tombstone, no-display-content, chat-mode events all produce no
row; a real Super Chat still parses its pre-rendered amount (constraint 4: reward fields are never
reinterpreted).

Docs: TASK 49 marked retired with the citations, the rule recorded in decisions.md, HANDOFF updated.
verify.sh: 550 passed, 0 failed, 0 warnings.
2026-11-05 08:39:46 -08:00
gramps 7bb1207fb2 feat(chroma): opt-in auto key, and alert clips key like any other clip
TASK 64 slice F — the two things left after the feature worked by hand.

- App Settings → **Video clips → Auto Green/Blue Screen** (off by default). When on, a
  high-confidence detected screen is keyed as soon as the clip is added; the panel says
  "Removed automatically" so an automatic change is never silent. Medium confidence still
  asks, low says nothing — a wrong automatic key eats the picture.
- **A clip is a clip:** the alert box's own replacement clip keys through the same settings
  block (one alert box per layout, so its settings are the ones the next alert uses), and
  choosing an alert clip runs the same detector. The Green/Blue Screen panel now appears for
  a video layer *or* an alert box with a custom clip (`Source.ShowsChromaKey`).
- Spill ships as the creator-facing **Fringe** switch; nothing further to tune behind it.

Test: `ChromaKeyApplyTests` gains the auto-apply case (no click, still explicit in the panel)
and an alert-clip case (its pipe asks ffmpeg for colorkey+despill with the blue screen type).

Docs: TASK 64 complete in TASKS.md, known limits recorded in the task file, HANDOFF rewritten.
verify.sh: 546 passed, 0 failed, 0 warnings.
2026-11-05 08:20:33 -08:00
gramps ea24405ba2 feat(chroma): offer to remove a green or blue screen, and key it in the decode
TASK 64 slices D and E: the feature is usable end to end.

- Add a video file → one frame is probed (new `IChromaProbe`/`FfmpegChromaProbe`, one ffmpeg
  pass at 160x90, off the UI thread) and the layer gets an offer: "Looks like a green screen —
  Remove it", with the measured colour as a swatch.
- Accepting it turns the offer into settings (Amount / Softness / Fringe) that reach ffmpeg as
  `colorkey` + `despill` + `format=bgra`, so the key is computed in the pipe and both consumers
  (the shared preview bitmap and the compositor's frame) carry the same alpha.
- Media sessions are now keyed by **path + look** (`MainViewModel.MediaSessionKey`), so two
  layers with the same settings still share one decoder, and changing the look releases the old
  session and starts the new one. Removal, load reconciliation, loop and audio settings all move
  to the same key.
- Settings persist on the `Source` row (six ALTER TABLE guards + SELECT/INSERT), so a keyed layer
  comes back keyed.
- Panel section is plain creator words: Green/Blue Screen, Amount, Softness, Fringe.

Test: `ChromaKeyApplyTests` — the pipe asks ffmpeg to key (and does not when off); a green-screen
clip is offered, accepting it starts a keyed session, the layer's resolver then reads that session
rather than the old one; the setting survives a save.

Docs: TASK 64 D/E ticked with slice F (auto-apply setting + alert clips) left open; frame-pipeline
records the decode-option design; HANDOFF rewritten. verify.sh: 544 passed, 0 failed, 0 warnings.
2026-11-05 08:15:32 -08:00
gramps 21db8a17b8 feat(chroma): detect a green or blue screen and build the key filter
TASK 64 units B and C — the two pure halves, no wiring yet.

- `Services/ChromaKeyDetector.cs`: samples the frame's outer ring (the screen is at the
  edges, the subject in the middle), reports Green/Blue/None plus the colour actually shot
  and a confidence. Confidence is coverage × hue tightness, which is what keeps a lawn or
  a football pitch from being offered as a keyable screen. Letterbox bars are excluded from
  the denominator rather than counted against the screen. Never decides anything: an offer
  is the creator's to accept.
- `Services/VideoFilterChain.cs`: options → `-vf` (scale → colorkey → despill → format=bgra)
  plus a `Signature` so two layers keyed identically can share one decoder. Numbers clamp and
  an unparseable colour falls back to the app default, so ffmpeg is never handed a filter it
  rejects at spawn.
- Keying is done by the bundled ffmpeg, not by us: verified against the shipped binary that
  `colorkey=…,format=bgra` emits straight-alpha BGRA (alpha 0 on keyed pixels), which is what
  the compositor already blends. No per-pixel loop lands in the 16.67 ms render budget.

Test: `ChromaKeyDetectorTests` — green, blue, letterboxed, lawn (must say None), mixed scene,
dark frame, plus the chain strings and signature sharing.

Docs: TASK 64 queued in TASKS.md with its slice table; the decisions.md chroma-key reversal
recorded as a reversal (it was buried for webcam background removal, not for supplied clips).
2026-11-05 08:05:18 -08:00
gramps 2670ac022b feat(media): play the clip's soundtrack and share one decode pipe
A video file was picture-only: the decode pipe passed `-an`, so a starting-soon
or BRB clip with music played silent everywhere. Adds sound to every clip and
collapses the two decoders into one.

- `Services/VideoClipPipe.cs`: the shared pipe behind `MediaVideoSource` (TASK 21)
  and `AlertClipDecoder` (TASK 47) — video (`rawvideo bgra`, scaled) + audio
  (`f32le -ac 2 -ar 48000`, 50 ms chunks, byte-carry across reads), both paced to
  real time, loop restarts both so picture and sound stay together. Alert
  behaviour is unchanged.
- `MediaVideoSourceManager` applies the layer's Level/Mute per session and raises
  `AudioAvailable`; `MainViewModel` feeds it to the new
  `AudioMixer.EnqueueDesktopMediaAudio`, which joins the DESKTOP/GAME ring so the
  desktop fader and the mic auto-ducker apply exactly as they do for game audio
  and TRAX (creator ruling 2026-11-05: audio joins the group).
- Per-layer `MediaMuted` + existing `MediaVolume` surfaced as Mute/Level in the
  properties panel; `MediaMuted` persisted (ALTER TABLE guard + SELECT/INSERT).
- Level/mute are per SESSION because one decoder is shared per file path — the
  last layer touched wins; one decoder per layer would play the file twice.

Test: `MediaSourceAudioTests` — the pipe emits 50 ms chunks at 48k stereo, the
manager scales by level and drops when muted, and a media layer's sound moves the
desktop meter. Alert/decode suites unchanged and green.

Docs: task-21 closed on item 10, the "silent soundtrack" facts corrected in
frame-pipeline/output/overlays, HANDOFF rewritten. verify.sh green (529 passed;
the two documented pre-existing failures only).
2026-11-05 07:54:06 -08:00
gramps 776f84fb88 docs(media): correct the audio claim — a video file's sound is silent, not loopback-carried 2026-11-05 07:22:15 -08:00
gramps db563c8644 docs(overlays): record the alert-clip/media decode overlap 2026-11-05 07:20:09 -08:00
gramps 77d7208bfa feat(media): add a video file layer that decodes into the scene
TASK 21's picker slice: the decoder, manager, pacing and loop mechanism were
already shipped but nothing ever acquired a session, so a media Source rendered
nothing. Adds "Video File" to the (+) menu (video-filtered OpenFileDialog),
one decode session per distinct path with a HasSession guard, release when the
last scene reference goes, and a post-load reconcile. Loop is a manager-level
per-path answer ("any live reference loops") applied before StartAsync and
updated live via SetLooping. Properties panel shows the file with Change… and
Loop. Volume stays unwired: media audio rides the desktop loopback, TRAX-style.

Reference: OBS's media source (file → decode → composite into the scene) is the
established answer for this feature; the lifecycle shape mirrors this repo's
WebView2/ScreenCapture registration rather than inventing a new one.

Test: ytLive.Tests/MediaSourceAcquisitionTests.cs — add starts one decoder and
frames reach the layer, loop flip reaches the decoder, remove stops it.

Docs: task-21 closed, TASKS row + Day-4 row + stale 3·17 row corrected,
frame-pipeline acquisition note, HANDOFF rewritten. verify.sh green.
2026-11-05 07:17:43 -08:00
gramps 785b5ff8f3 chore(assets): delete four unreferenced brand exports
The four llamacasty-* files were source exports, not assets: shipped art is
embedded via <Resource> in ytLive.csproj and read through pack: URIs, and the
Store tiles derive from Assets/llamacasty-logo.jpg. None of the four appears in
the csproj, any XAML Source/Icon, or a script, so they were dead weight that
kept resurfacing as untracked noise.

MyMistakes.md gains the recipe (csproj list + grep = 30-second verdict) so a
loose file in Assets/ is never re-litigated; HANDOFF drops the stale note.
2026-11-04 17:50:50 -08:00
gramps d195c29f79 fix(text): group controls and filter font families
Put size and color on one row, place emphasis labels before their toggles, and split the remaining font families into handwriting, symbols, and international groups while omitting legacy dingbat fonts. Text-layer properties follow the existing hierarchical layout and palette conventions.
2026-11-04 17:17:57 -08:00
gramps 5f7bcc2425 fix(text): show selected formatting and style controls
Use category-filtered font and selected-item dropdowns, separate horizontal and vertical alignment, and reuse the border-color palette templates for text color. Persist vertical alignment additively. Follow the established static text-source pattern: https://obsproject.com/kb/text-sources
2026-11-04 17:05:06 -08:00
gramps 5c4acf141b feat(text): composite shared text rasters in output
Raster static text on UI-thread changes and reuse the same transparent BGRA frame in preview and all compositor paths. Cache text frame identity in the frame-pump signature and invalidate static bakes on edits. Follow OBS text-source conventions: https://obsproject.com/kb/text-sources
2026-11-04 15:01:47 -08:00
gramps 58eb640c4e feat(text): add static text editor and preview
TextOverlay layers now have a multiline editor, Windows font and size controls, color/emphasis/alignment settings, and a visible draggable preview. Text sources follow OBS's static text-source conventions: https://obsproject.com/kb/text-sources
2026-11-04 14:28:47 -08:00
gramps 60c4a0032f feat(text): persist independent text layer settings
Add additive defaults and round-trip tests for per-layer text content and formatting. Model the static text source after OBS text-source conventions: https://obsproject.com/kb/text-sources
2026-11-04 14:22:32 -08:00
gramps 106896c408 docs: close Task 53 and advance queue 2026-11-04 13:49:39 -08:00
gramps 7216c95449 feat(entitlement): gate 60 fps output through Steam 2026-11-04 12:14:46 -08:00
gramps 1993e7cc8b docs(task-53): accept vertical live-player check 2026-11-04 11:37:10 -08:00
gramps ede6ecac8e docs(task-53): retire platform safe-zone requirement 2026-11-04 11:35:29 -08:00
gramps bde2a4cf4b docs(task-53): accept six output samples and advance queue 2026-11-04 11:32:44 -08:00
gramps 4b7e798757 feat(ui): show YouTube-confirmed live state in top bar
Normal ON-AIR stays red until YouTube reports lifeCycleStatus=live, then turns green; TEST stays gold and stop restores the idle color.

Status-color reference: https://www.carbondesignsystem.com/building-blocks/core/patterns/status-indicators
2026-11-04 11:19:40 -08:00
gramps f921c0174b docs: reconcile Day 2 release policy and output tasks 2026-11-04 11:18:16 -08:00
gramps 6310f50c27 perf(video): move tier scaling to ffmpeg
The frame pump now submits crop-sized BGRA frames; FFmpeg scales them to final tier dimensions. Bundled scaler benchmark: 300 608x1080 to 1080x1920 frames in 0.435s (scaler-only).

Reference: https://ffmpeg.org/ffmpeg-filters.html#scale-1
2026-11-04 10:52:35 -08:00
gramps 76e8c22815 fix(ui): own private broadcast close-out dialog 2026-11-04 10:11:39 -08:00
gramps a6aea5bf26 feat(ui): color and acknowledge pinned log errors
Add independent AppLog message types and explicit Class 1/2 severity. Wrap and color log rows, pin Class 1 above Class 2, and let acknowledgement return entries to the current-launch timeline without changing file format or rotation.

Ref: https://logtape.org/manual/levels
2026-11-02 18:17:18 -08:00
gramps 01a3fcaaf7 docs: replace system-message strip with Logs tab 2026-11-02 15:55:04 -08:00
gramps 264a90dae8 test(ui): align top bar mode guard with live decision 2026-11-02 11:43:57 -08:00
gramps c856d11fb5 test(ui): configure EDIT-off live start in pill guard 2026-11-02 11:38:17 -08:00
gramps c9bd7e6093 test(audio): match mic fader unity ceiling 2026-11-02 11:34:48 -08:00
gramps e6a7147dc3 test(ui): follow the relocated settings gear 2026-11-02 11:31:49 -08:00
gramps 0e1f948189 test(scoreboard): check shared dev-build gate 2026-11-02 11:28:11 -08:00
gramps caf91e6564 test(ui): read notification colors from app theme 2026-11-02 11:24:50 -08:00
gramps 82609a5046 test(ui): follow shared geometry panel gate 2026-11-02 11:20:54 -08:00
gramps 91e308e8e2 test(ui): inspect themed property hierarchy 2026-11-02 11:18:10 -08:00
gramps 221b81628a test(scoreboard): assert the themed pill switch 2026-11-02 11:14:15 -08:00
gramps 3985a413a7 test(ypp): keep upload window fixture current 2026-11-02 11:10:21 -08:00
gramps 9fa06c67c2 feat(ui): add current-launch logs tab
Add Live Chat and Logs tabs to the non-edit left panel. Expose a bounded process-local AppLog feed so the newest messages appear first without importing earlier startup.log contents or changing file logging.

Ref: https://obsproject.com/forum/resources/understanding-obs-studio-log.473
2026-11-02 11:06:43 -08:00
gramps 20f059422b feat(alerts): add scene-local recent ticker
Model the AlertBox and standalone ticker with shared settings; scene tickers render the in-memory active and queued events without saving history. Keep AlertBox content active-event-only.

Ref: https://obsproject.com/kb/obs-studio-overview
2026-11-02 10:47:48 -08:00
gramps 6208cb9409 feat(ui): route locked tiers through About
Keep non-owner resolution choices visible but muted. Selecting one preserves the free tier and opens About; external navigation is only the explicit llamacasty.com link. Ref: https://streamlabs.com/desktop
2026-11-02 09:41:38 -08:00
gramps ff5e8d94ae feat(entitlement): pivot to paid app ownership
Check Steam ownership of the paid app with BIsSubscribedApp; keep Debug fake ownership out of Release. Ref: https://partner.steamgames.com/doc/api/ISteamApps#BIsSubscribedApp
2026-11-02 09:18:03 -08:00
gramps 54902cb838 ui(properties): no raw typography left in the panel, and one heading rank
The earlier pass checked wiring only; this is the typography sweep. Raw Foreground/FontSize
labels are gone from the elements panel:

- Opacity, Timer (minutes) and URI are headings above their control (PropertyHeader), with the
  control row on the panel's 10px indent — the same shape as Border Thickness/Opacity.
- Scroll speed, scoreboard Opacity/Text size and their value readouts are PropertyFieldLabel.
- The drag hint and the scoreboard sign-in note are PropertyHint.
- Simulate (dev) keeps its orange, gains the field-label typography.
- Edge, Text colour and Background were PropertySubHeader/SubHeader: ONE heading rank now, so
  PropertySubHeader and the legacy SubHeader are deleted from Themes/Controls.xaml.
- DeveloperPanel — the last panel with raw typography and an unstyled CheckBox — now uses the
  same styles, and PropertiesStyleTests accepts PropertyPillCheck (the guard went stale when
  every check box became a pill switch). 18 tests pass; Release 0 warnings.
2026-11-01 16:26:20 -08:00
gramps 790cff45c2 ui(alerts): Video, Sound byte and Ticker are headings, not sub-headings
Same PropertyHeader style as Geometry and Stream Alerts — one heading rank in the properties
panel, not two.
2026-11-01 16:19:07 -08:00
gramps b3d7cf30b2 ui(properties): sub-headings sit flush left, level with the category header
Their rank is carried by size and colour, not by an indent — so Video, Sound byte and Ticker all
line up on the left edge exactly like each other, and only the controls under them tab in.
2026-11-01 16:17:38 -08:00
gramps 31b655cecb ui(alerts): a Ticker sub-heading, with Message ticker / Display / Volume tabbed under it 2026-11-01 16:15:41 -08:00
gramps 0fdd9d5faf fix(brand): one logo, not two — and hit-testing follows what you see
The brand exemption for the universal blur put a BrandLogoOverlay above the blur host while the
canvas ItemsControl kept drawing the same element, so two logos sat on top of each other.

- The canvas presenter for a brand logo is collapsed: BrandLogoOverlay is the only render, and it
  now carries the element's Opacity so the canvas copy is not missed.
- Hit-testing follows the eye: a click inside the logo's bounds selects the logo, because it is the
  topmost layer in the compositor too. Without that you saw the logo on top and grabbed the webcam
  underneath it.
- SelectedLayerPreviewHitTestingTests updated to the new contract (collapsed presenter, logo wins
  its bounds, list selection still works). 3 tests pass; Release 0 warnings.
2026-11-01 16:12:38 -08:00
gramps 41b3933b08 fix(background): refresh BOTH preview layers, not just the thumbnails
The preview draws two background images — BackgroundImage and, above it, ActiveBackgroundImage
(the static art) — while the mini-pane reads the scene snapshot. The default-restore path only
called UpdateBackgroundImage, so the thumbnails updated and the preview did not.

It now calls UpdateActiveBackground as well.

Also adds the generic cure behind the Background bug: OnSelectedElementPropertyChanged invalidates
the scene bake for ANY property change. A static layer's pixels are cached by element identity, so
a property edit without invalidation repaints nothing and the control looks dead — the failure was
never specific to UseDefaultBackground, only first noticed there.

Background + thumbnail tests pass (21); Release 0 warnings.
2026-11-01 16:01:41 -08:00
gramps d6088f8e35 fix(background): Use default background actually restores the shipped art
Browse… writes the chosen picture into BOTH CustomBackgroundPath and AssetId, and the default
stamp bails when AssetId is already set. So flipping "Use default background" back on changed a
bool while the preview kept showing the custom image — it looked like the switch did nothing.

Turning the default on now clears the custom pointer and the asset, re-runs
StampDefaultBackgroundAsset for the scene's shipped art, and only then invalidates the bake,
updates the preview and refreshes thumbnails.

BackgroundTests pass (17); Release 0 warnings.
2026-11-01 15:59:10 -08:00
gramps c297b3fb0f ui(properties): answer the visual review — quiet actions, real hint tone, dividers, legible disabled
Review findings from a screenshot of the Stream Alerts panel, each addressed:

- "Reset to built-in" and "Browse…" were YtButton-derived (white, semi-bold, 13px), so a reset
  action outranked the section header. Property-panel actions now use PropertyButton: bordered,
  11px, normal weight, OnSurface text.
- Explanation text and field labels were the same muted blue-gray. PropertyHint is now
  OnSurfaceDisabled, one clear step below a label.
- Groups inside a category ran together. PropertyDivider (1px OutlineVariant) separates Video,
  Sound byte and Message ticker.
- The disabled loop switch was near-invisible. Disabled now keeps the label at OnSurfaceVariant
  and dims the whole control to 0.55 — readable, obviously inactive.
- Switch "on" was full-strength accent, shouting about static state. The track now sits at 0.6
  opacity when on, which also stops it competing with the slider's accent fill.

No new palette entries: every change uses an existing token, so DESIGN.md stays the one colour
source. Release 0 warnings; token and typography tests pass.
2026-11-01 15:53:40 -08:00
gramps 8221bd6478 ui(properties): one typography hierarchy, pill switches, and tokens instead of hex
Creator directive 2026-11-01: the properties panels had 14 hand-rolled heading styles, 37
hand-rolled labels, four literal grey hex values, and 14 square checkboxes.

- PropertyHeader / PropertySubHeader / PropertyFieldLabel / PropertyHint in Themes/Controls.xaml
  now carry the whole hierarchy: category bright and 11px, labels and hints dim, size and weight
  doing the level. Colour groups the panel — bright for what you edit, dim for its labels and
  help — and no new palette entry was needed; DESIGN.md stays the only colour source.
- Every checkbox in the panel is a pill switch (PropertyPillCheck, 32x16 animated thumb).
  IsChecked semantics unchanged, so no binding moved.
- Literal #888/#666/#555 became OnSurfaceVariant / OnSurfaceDisabled.
- 13 headings, 2 sub-headers and 31 in-row labels now reference a style instead of repeating
  colour + size + alignment inline.

Controls audited for backing: every Button/Slider/ComboBox/CheckBox/ToggleButton/TextBox in the
panel has a Command or Click, a Value binding, an ItemsSource or Selected*, IsChecked, or Text —
no dead controls. Release 0 warnings; typography and token tests pass.
2026-11-01 15:51:33 -08:00
gramps 82960d0ca3 docs(video): the training-video plan — formats, why Shorts are a funnel, and what to film
New `youTubeTrainingVideos.md`: the feature list worth filming, split into "ready now" and
"after the surge lands" so nothing gets filmed before it exists, plus the format decisions
settled on 2026-11-01.

Format: 16:9 long-form is the library (search intent, walkthroughs, conversion); vertical Shorts
are trailers cut from the same pass. A 16:9 video cannot be a Short, so it is a per-video choice.
TikTok is a mirror of the verticals, never a strategy.

The numbers behind it: Shorts need 1,000 subs + 10M views in 90 days for YPP versus 4,000 watch
hours in a year; Shorts RPM is $0.02-0.08 per 1,000 against $3-8 for tutorials; and one $79 sale
nets ~$55.30, which is what 700k-2M Shorts views pay. Shorts are an install engine, not revenue.

Film first: privacy blur, auto-ducking, webcam blur.
2026-11-01 15:43:48 -08:00
gramps 5d836aecbb docs(commercial): one price — $79 Steam DLC rising to $99, subscription retired
Creator ruling 2026-11-01. One SKU, one price, Steam does the selling.

- Pro is a one-time Steam DLC on the free app: $79 introductory, $99 on a stated date, announced
  where the price is shown and actually raised.
- The subscription is retired. The reasoning is in decisions.md: no service behind a monthly fee
  (no servers, no cloud, no content), ~1-3 subscribers per 1,000 installs against 5-8% churn, and
  it carried the most expensive code in the product.
- Polar goes dormant. Its customer-portal design is recorded, not built. The "freemium on Steam,
  convert on Polar, Valve gets nothing" proposal is rejected: in-app purchases must use the Steam
  Wallet, so it risks the listing to save ~$24 a sale.
- Entitlement collapses to `steamDlcOwned`. No lease, no key, no portal, no device limit; the
  greyed tier opens the Steam store overlay, so every purchase stays inside Valve's own surface.
- The site publishes the model and links the store page. It takes no payment and hosts no binaries.
- The $400 lifetime died with the subscription it anchored. Founding 200 died with the coupons and
  passes it needed.

Costs: $115 fixed year one; $79 nets $55.30 after Steam, so three sales cover the year.
2026-11-01 15:40:44 -08:00
gramps 1c2eedcaba ui(audio): the volume faders stop at 0 dB — no unreachable track
Mic and desktop gains run -60 to 0 dB and that is the ceiling: 0 dB is full input level, and
nothing past it is offered or drawn. The unity tick is gone with the zone it explained.
2026-11-01 15:16:03 -08:00
gramps 1074a46e9c ui(audio): a 0 dB unity tick on both volume faders
The faders run -60 to +6 dB but the ladder tops out at 0 dBFS, so the last 6 dB looked like
dead track. The tick marks unity at 60/66 of the track: what is left of it is boost, and now it
reads that way. Both mic and desktop faders; the lip-sync slider is milliseconds and gets none.
2026-11-01 15:13:21 -08:00
gramps b00bf48b9d feat(alerts): sound byte under Built-in video, and alert audio that reaches the device
Creator request: a sound-byte browse + label under Built-in video, with an option to loop the
sound while the clip plays. The video itself is always one pass to completion.

- Source gains AlertAudioPath and AlertAudioLoop, persisted (column + migration + INSERT +
  SELECT + reader, the whole chain — that chain is where the blur crashed once).
- The sound byte plays through MediaPlayer on the default output device; MediaEnded re-seeks to
  zero while looping, and StopClip kills it so a tail cannot bleed into the next alert.
- The clip's OWN soundtrack moved off the mixer and onto the device as well
  (Services/Audio/AlertAudioOutput: one WaveOutEvent + buffered float provider).

Why the move: the creator asked that alert audio — the clip's track or the added sound byte —
appear in the desktop sound bar. Only audio that reaches the default device is picked up by the
WASAPI loopback that feeds both the meter and the stream. The old EnqueueAlertAudio route put the
sound in the stream but left the meter flat and the room silent.

Tests: the sound byte and its loop flag round-trip through SQLite, and a fresh alert layer starts
with no sound byte and no loop. Release 0 warnings.
2026-11-01 14:13:57 -08:00
gramps 46d12e5873 fix(alerts): Test alert actually plays — it was queuing into nothing
TestAlert called Enqueue, which is the pure test seam: it fills the queue and returns. The
alert layer needs three more things to show anything — its scene list, the playback ticker
running, and the preview refreshed — and all three live in OnMessageReceived, the same entry
the chat feed uses (MainViewModel.Chat.cs).

Now it calls OnMessageReceived(message, Scenes), so the clip advances on the ticker and writes
frames to the box's VideoImageSource, which is what the preview pane displays.
2026-11-01 14:07:14 -08:00
gramps 005b459fe0 feat(alerts): a Test alert button in the alert properties
Sits to the right of the Built-in video pill, labelled "Test alert". It enqueues one synthetic
Super Chat through the real AlertOverlayLayer, so what plays is exactly what a viewer triggers —
the custom clip when one is set, the built-in clip otherwise — and it goes through the same
output path (preview and encoder) as a live alert. Local only: nothing reaches YouTube and no
chat row is invented.
2026-11-01 14:04:20 -08:00
gramps 754377c5db ui(audio): every header label in the sound area uses one colour
MIC's device name was the only header label at full-strength OnSurface (it was brightened
because it shows a choice), and the lip-sync reading was Tertiary — three different values
across four bays in the same row. All four header labels are now Brush.OnSurfaceVariant, the
standard secondary-text token. No other colours touched.
2026-11-01 13:59:57 -08:00
gramps b75c2b3001 ui(audio): one row grid across the dock — ladders on a line, volume bars on a line
Every bay is now header / ladder / controls. Lip-sync has no ladder, so it holds an empty 20px
row (10px meter + the two margins) that keeps its slider exactly level with the other three,
and MIC's ladder gained the missing 4px bottom margin that had its slider sitting 4px high.

Presets stay under the slider in the narrow lip-sync bay.
2026-11-01 13:57:36 -08:00
gramps 2fedc7cb64 ui(audio): the lip-sync slider moves up one line — onto the header
Not under it: the offset slider now rides the header line beside the LIP-SYNC badge and the
reading, and the preset row is what stays below. The box is narrow, so a slider on its own line
was the only thing making it two-and-a-bit rows tall.
2026-11-01 13:56:53 -08:00
gramps 789272142d fix(build): the wordmark number counts commits again, not compiles
GenerateBuildStamp read BuildCounter.txt, added 1 and wrote it back on every CoreCompile, so
the number tracked BUILDS: a day of iteration reached #1122 against 563 commits. It now uses
git rev-list count of HEAD, which is what the comment always claimed (and why the file existed
in .gitignore). No counter file, no drift; a missing git still falls back to the start.

Also moves the MIC and lip-sync volume bars up one line: both were pinned to the floor of their
bay by a star row plus VerticalAlignment=Bottom. They now sit directly under their headers,
with the auto rows sized to content.
2026-11-01 13:56:17 -08:00
gramps 35c7809c28 fix(audio): bay ladders fill their bay again — the 320px cap was a mistake
Capping the three bay meters at 320px and left-aligning them made each ladder look cut off
mid-bay, which is what the creator saw as deleted bars. The caps are gone; the master ladder
keeps its own 420-900px range so it stays the longest without shortening anything else.
2026-11-01 13:52:31 -08:00
gramps 4c4f22e6bb ui(audio): the master ladder is always the longest one
The master meter was a fixed 420px, but each sound bay's ladder fills its column — on a wide
window a bay could be longer than the master, which is the opposite of what was asked.

Master now stretches between 420 and 760 px, and the three bay ladders cap at 320 with the same
cap for each, so they stay the same relative size to one another and stay shorter than the
master at every window width.
2026-11-01 13:49:05 -08:00
gramps f8e4f2cf29 ui(audio): master meter centred and biggest; TRAX gets its own bay and meter
Creator rulings, 2026-11-01.

- Master strip is 44px (from 36) and its meter is now the largest on screen: 420x14 with 32
  segments, centred under the preview, with a 12px MASTER label — bigger than the 9px bay
  labels. L/R imbalance is a flag on the left, MONO and the session clock on the right.
- The kbps / fps / dropped counters are gone from that line: the live readouts already sit
  above the preview, and the duplicate was noise rather than telemetry.
- TRAX is its own bay (MIC | GAME | TRAX | LIP-SYNC) with its own meter bound to
  TraxMeterFraction. It lived inside the game bay with no ladder, which meant whether the bed
  was actually playing could not be answered from the dock — and the player already measures
  its own output.
- Lip-sync is narrow (0.45 star) since it is one slider and three presets; the three sound bays
  are equal width, so their meters keep the same relative size.

AudioDockLayout moves with it (156 = 112 + 44; collapsed 44) and the layout test is updated.
TraxBayTests now asserts the opposite of the old ruling: the bay HAS a meter, and the game bay
no longer carries it.
2026-11-01 13:48:28 -08:00
gramps 9b337ad91b feat(blur): the brand logo cannot be blurred
The blur is universal, so it would swallow the logo along with everything else. The compositor
now defers every visible brand logo: skipped in both element loops, blitted immediately AFTER
the blur group, before the flash/social/ticker overlays. One logo per scene, so it is cheap.

Preview mirrors it: the staged scene's brand logo is drawn above the blur host (StagedBrandLogo),
so what you see in the editor is what the encoder gets.

Test: a blur covering the logo's area leaves the logo's pixels exactly white while the coloured
background beneath it blends.
2026-11-01 13:32:23 -08:00
gramps bb51164f2f feat(blur): universal — the blur covers every layer, webcam included
The creator found the use case that reframes the feature: a moment of privacy while the webcam is
on. A blur that can only cover the desktop cannot do that.

- SceneCompositor applies the blur group AFTER every element is composited (still before the
  flash / social bar / ticker / zoom overlays), so it covers webcam, chat, alerts — anything. Its
  position in the layer list no longer affects the output, so it is added at the top and the
  reorder clamp is gone.
- Supersedes the earlier "blur sits below the brand" rule: a universal blur does obscure the logo
  when it covers it. Called out in frame-pipeline.md so it is not re-litigated.
- Preview mirrors it exactly: the blur host moved OUT of CanvasGrid to a sibling above it, so the
  VisualBrush samples every layer (and cannot sample itself). It used to sample only the backdrop,
  which is why a circle over the webcam would have shown sharp pixels in the editor and blurred
  ones in the stream.
2026-11-01 13:29:15 -08:00
gramps bb80ec8370 ui(properties): geometry for every placeable layer, and no fake opacity on a blur
Two holes in the blur's property panel, both of which also affected other layers:

- X/Y/W/H were gated on IsWebcamSelected, so a blur (or an image, chat box or alert zone)
  had no numeric position or size at all — only dragging. The block is generic: it always
  edited SelectedElement's own values, so the gate was the only thing excluding them.
- Opacity showed for a blur, but the compositor's blur reads BlurAmount — the slider would
  have moved a number that changes nothing in the output. Hidden for blur layers.

Blur panel is now: Box | Circle, strength, and the shared geometry fields.
2026-11-01 13:20:13 -08:00
gramps da508f0a14 fix(blur): the (+) menu entry re-evaluates — CanAddBlur was never re-notified
The Blur item's Visibility is bound to CanAddBlur, which depends on the staged scene. The scene
setter never raised it, so the binding evaluated once (collapsed) and stayed collapsed for the
session — build 1082: the option simply wasn't there.

StagedScene now notifies CanAddBlur, with a test that pins both halves: Live true, Chat false,
and a notification on every staged-scene change.
2026-11-01 13:17:40 -08:00
gramps 9b44a32054 fix(blur): the layer is draggable, so adding one drops a rectangle with a resize handle
Source.IsDraggable listed Image/Web/Chat/Alert only, so a blur had no selection box, no corner
handle, and no hit-test — the rectangle existed in the render but could not be grabbed. Adding
Blur to that list gives it the same selection chrome and resize path every placeable layer has
(drag to move, corner dot to resize, one undo snapshot per gesture).

It is selected on add (AddSource already does that), so the rectangle appears already outlined
and ready to size.
2026-11-01 13:14:51 -08:00
gramps 1416548f24 feat(blur): the editor shows the blur — whatever is beneath it is blurred, in the preview
The preview canvas is WPF visuals; a blur layer contributes no visual of its own, so until now
the creator saw nothing where the blur was and could not tell what it was hiding.

Each visible blur now draws a rectangle (or ellipse) filled with a VisualBrush of the whole
BackdropLayer — the desktop capture plus the background art — sampled at the blur's own rect and
carrying a BlurEffect at the same strength the compositor uses. The host canvas sits after the
backdrop and before the element layers, which is the model's own z-rule, so the preview shows
exactly what the output will: desktop blurred, brand and everything above it untouched.

Rebuilt on scene change, on add/remove, and on any blur property change (position, size, shape,
strength) — the brush samples the live backdrop, so the picture keeps moving underneath.
2026-11-01 13:12:27 -08:00
gramps 0ed341fd18 fix(blur): the crash on launch — BlurAmount was in the INSERT and the reader, not the SELECT
LayoutStore.Load threw ArgumentOutOfRangeException on GetOrdinal("BlurAmount") during the
MainViewModel constructor, so the app died at startup rather than when a blur was used. The
migration adds the column and Save writes it; the reading query still listed its columns
explicitly and BlurAmount was missing from that list.

Adds LayoutBlurPersistenceTests: a blur saved and reloaded, asserting the amount and geometry
survive. That is the test that should have existed with the feature — the startup path it
covers has no other guard.
2026-11-01 13:08:03 -08:00
gramps 71d0e87cac feat(blur): a privacy blur layer for the Live scene — boxes and circles over the desktop
Creator request 2026-11-01: a layer that hides part of the desktop from viewers. Box or circle,
drag/resize like any layer, one strength slider, several allowed.

**Z-order is policy, not preference.** `AddSource` inserts a blur directly above the background
and therefore below the brand logo, and `OnSceneElementsReordered` pushes any blur that ends up
above the brand back down. Nothing else in the app can move it into the brand's way.

**Overlap = the strongest single blur, never a stack.** `SceneCompositor.ApplyBlurLayers` collects
every visible blur the first time one is reached in the z-order, sorts them strongest-first, and
shares one coverage map: a pixel already covered by a stronger blur is skipped. Blurring
back-to-front would have produced something stronger than any slider setting allows, which is
exactly what was ruled out.

**One blur curve, shared with the webcam.** `RegionBlur` carries the constants the webcam blur
already used — downscale 4, radius 1–6 downscaled px, `RadiusFor(strength)` — and
`BackgroundEffectProcessor` now delegates to it instead of holding its own copy. The layer's
slider is the same 0–1 scale with the same 0.5 default, so "half strength" means the same blur in
both features. Cost is proportional to the blur's own rect, not the frame.

**Live scene only**, enforced in `AddSource` as well as the (+) menu (`CanAddBlur`), so a stale
menu item or a scripted call cannot plant one on BRB. `ElementKind.Dynamic`, so it is never baked
into the static base and every render path gets it — pump, preview, thumbnails, scene dump.

Persistence: `Source.BlurAmount` column + migration; shape rides the existing `ClipShape`.
Properties panel: Box | Circle plus the strength slider, gated on `IsBlurSelected`.

4 tests: outside the mask is byte-identical, inside loses its hard edges, a circle leaves its
box corners alone, and two identical stacked blurs equal one (the coverage map). Release builds
at 0 warnings.
2026-11-01 09:55:04 -08:00
gramps 2e12c5651e ui(drawers): 20px of content padding on the right of every pull-out
The five drawers each sized their content Grid to Width=320 inside a panel that itself
animates to 320, with 14px margins — so the content box was 348 wide in a 320 window and its
right edge was clipped. That is why every drawer's content jammed into the right margin
regardless of the margin value.

The Grid now fills the panel (no fixed width) with Margin 14,10,20,12: 14 on the left, 20
between the content and the right edge of the div, which is the requested gutter.
2026-11-01 09:35:37 -08:00
gramps a543d3629b ui(settings): 20px right gutter on the drawer, rail tabs centred on the preview
Two build-1047 notes. The settings content gets a 20px right margin (plus 4 left) inside the
scroll viewer, so text and controls stop running into the panel edge and the scrollbar has
somewhere to live. The rail tab stack is VerticalAlignment=Center instead of Stretch, so the
whole group of tabs sits centred against the preview rather than hanging from the top.
2026-11-01 09:22:02 -08:00
gramps a35ec0c5f8 refactor(settings): a pull-out drawer, with Zoom as a category — and the menu moves to the wordmark
Creator directive, 2026-11-01. Settings stopped being a centred modal.

- **Drawer** (Controls/SettingsDrawer): hosted in PreviewPane's pull-out stack, 320px, the same
  animated-width pattern as Stream Settings / YPP / Socials / TEST, opened by the gear rail tab
  on a single left click. SettingsDrawerOpen is exclusive with the other four, both directions.
  This also removes the modal's MaxHeight-700 scroll problem for good: the drawer is the height
  of the preview.
- **Categories**: headings with controls indented beneath them. Zoom now owns *Zoom view shape*
  (Circle / Phone / Tablet) and *Zoom box follows*; Recordings, Brand logo and Scene transition
  are their own categories.
- **The app menu moved to the LlamaCasty wordmark** (right-click → Report Bug / Feature Request /
  About). Left-click still opens About. The gear tab no longer carries a menu at all, so clicking
  it does exactly one thing.

OverlayHost keeps only the Bug / Feature / About panels; its brand-logo handlers and the dev-panel
injection moved with the settings block. BrandLogoPreviewTests follows the control (same assertions,
new path). Release 0 warnings; 15 tests pass across brand logo, zoom, entitlement and preview
history.
2026-11-01 09:16:27 -08:00
gramps c418288215 fix(settings): the App Settings panel scrolls — the lower options were clipped off-screen
The shell is MaxHeight 700 and the settings panel had grown past it with no ScrollViewer,
so everything below roughly the transition section was unreachable. That is why 'Tablet
(Horizontal Rectangle)' existed in the XAML and in the model but never appeared in the app:
the shape control itself was cut off, not the option.

Panel row is now * with an Auto ScrollViewer, so every section is reachable and the next
one added cannot silently fall off the bottom.
2026-11-01 09:08:08 -08:00
gramps 517e60d593 fix(zoom): the magnifier freezes the layers — no picking up objects while it is open
While zoom is active a left drag moves the box and never selects or moves a layer beneath
it (creator ruling 2026-11-01). MouseMove already owned the zoom drag; the missing guard was
in MouseLeftButtonDown, which happily hit-tested a layer first and left a stray selection
plus a half-started drag behind the moving box.
2026-11-01 09:06:05 -08:00
gramps c3bbf1fdda fix(zoom): ignore Ctrl auto-repeat — the box was closing itself as it was created
Creation holds Ctrl (Ctrl+right-click), and Windows repeats a held modifier. The repeat
tick landed after the box existed, so the dismiss handler saw an open box and closed it:
appear, then vanish ~500ms later. Guarded with e.IsRepeat, which only ever skips the
repeat events — a genuine second press still closes the box.
2026-11-01 09:01:02 -08:00
gramps 15734a5477 feat(zoom): Ctrl closes the box, and a Follow pill that re-anchors the capture
Two creator directives, 2026-11-01.

**Ctrl-down dismisses an open zoom box.** Window-level PreviewKeyDown, so the same hand that
opened it can put it away. It cannot cancel itself at creation: Ctrl is already held before
the right-click that makes the box, so the keydown fires while nothing is open.

**Follow mode, default Anchor.** A PillToggle in App Settings ("Follow the box"), persisted
as Zoom.FollowBox. Off — the default — keeps the 2026-10-08 model: the capture stays on the
clicked point and dragging only moves the box over it. On, UpdateZoomMove re-anchors the
capture to the box centre (half the box's size, clamped per axis), so the magnifier shows
whatever it hovers. Flipping to Follow while the box is open snaps it immediately.

Three tests: anchor drags leave CaptureX alone, follow moves it to the expected quarter-offset
centre, and the setting round-trips through LayoutStore (tested against the store, not via two
view models — two live VMs fight over the temp DB, which is a fixture problem, not a bug).

Both new test classes share a collection tag: MainViewModel.LayoutPathOverride is static, and
xUnit runs classes in parallel, so without it the two view models collided (one failure in a
combined run, clean when run apart).

Release build 0 warnings; 14 tests pass across zoom, entitlement and preview history. Debug
build is blocked by the running app (PID 47388) — close it and the exe updates.
2026-11-01 08:51:28 -08:00
gramps 1d2d58f1d7 fix(zoom): the gesture now creates the box instead of only closing it
Creator report: Ctrl+right in edit mode and left-click in preview did nothing when
neither streaming nor recording. The bindings were correct; the action was not.

Two causes, both in the model rather than the handler:
- ActivateZoom opened with `if (!(IsLive || IsRecording)) return;` — the box could not be
  created outside a session at all.
- ToggleZoom only ever called DismissZoom, so "toggle" could turn it off and never on.

Now ToggleZoom(x, y) activates at the cursor or dismisses if already open, and the live-only
guard is gone — the magnifier is an editing aid as much as a live one. Both call sites pass
the canvas point, so the box still anchors on the click rather than the frame centre.

Debug 0 warnings; ZoomBox + ZoomMagnifier tests pass (3).
2026-11-01 08:43:17 -08:00
gramps 4b91760f92 fix(preview): Ctrl+right no longer opens the menu, and the social bar keeps its click
Two event-pairing bugs found by validating every handler instead of trusting the table.

1. Ctrl+right-click opened the context menu in edit mode. Cause: WPF's PopupControlService
   opens menus on right-button-UP, and only the DOWN event was being handled — so the zoom
   toggled and the menu appeared under it. Both events are now handled when Ctrl is down.
   (The creator saw this in edit mode; preview mode had the same latent bug.)

2. The social bar's left-click was dead in preview mode and dragged the layer beneath it in
   edit mode. Cause: PreviewGrid handles the TUNNELLING PreviewMouseLeftButtonDown, which
   fires before the bar's bubbling handler — and since preview-mode left-click now toggles
   zoom, that handler swallowed the bar's click too. The preview handler now steps aside when
   the point is inside the bar's bounds (SocialBarElement.RenderSize, translated to canvas).

Audited and left alone because they are correct: left-down → resize handle / element drag /
deselect; move → zoom drag before edit drag (by design); left-up → EndPreviewDrag; right-up
→ zoom-drag release then the Ctrl swallow.

Build Debug 0 warnings, 22 tests pass across ZoomBox, PreviewHistory and SocialBar.
2026-11-01 08:19:17 -08:00
gramps 6d1c982e26 feat(entitlement): IEntitlement seam — Steam DLC answers, everything fails closed
The first half of surge day 1. One question, "may this session emit Pro output?", with
one method on the interface so nothing downstream knows which rail proved it.

SteamEntitlement reads the lifetime SKU the way Valve intends: SteamAPI.Init() then
SteamApps.BIsDlcInstalled(dlcAppId). Four different failure paths all resolve to the
free tier rather than to Pro or to a crash — no DLC id configured, no Steam client,
steam_api64.dll missing (a throw on first touch), and simply not owned. A copied folder
run without Steam is the common case, not an exceptional one.

NotConfigured() is the shipping default until Steamworks creates the DLC app; it answers
false without ever touching the native library, so today's builds behave exactly as they
did before this commit.

HasProOutput is now IsPremium || DeveloperMode || rail, with an internal EntitlementOverride
test seam (internal, because a public setter here is a licence bypass). Four tests: no id
reads false, no client reads false without throwing, the fake reports what a test sets, and
the view model takes its answer from the rail.

Steamworks.NET 2024.8.0 added; the native DLL still comes from scripts/steam-sdk.sh, so the
csproj change is three lines and no formatting churn.
2026-11-01 08:13:25 -08:00
gramps 279eb08dea feat(preview): right-click is the menu, zoom is the single-handed gesture
Creator ruling 2026-11-01, and the reason is live-stream ergonomics: no two-handed
chords while a broadcast or recording is running.

- Edit mode: left selects / drags / resizes, right opens the context menu, Ctrl+right
  toggles the zoom box. Chords are acceptable here because editing is a stopped activity.
- Preview mode: left toggles the zoom box, right opens the context menu, and Ctrl+right
  is swallowed outright — handled rather than left to fall through, so Ctrl+right cannot
  open a menu by accident while one hand is on the mouse.

This also removes the old live behaviour where right-click activated the magnifier at the
cursor point (and dismissed it). Zoom in preview is now a plain toggle; if point-zoom is
wanted back it needs a one-button gesture, not a chord.

Build: Release 0 warnings. Debug is currently blocked by the running app holding the exe
(PID 47252) — close it and the Debug build copies normally.
2026-11-01 08:10:04 -08:00
gramps fdb27f0758 feat(ui): gear to the rail, EDIT on the panel edge, preview undo/redo
Three creator directives (2026-11-01), done ahead of the surge.

**Gear moved to the preview rail** — first tab, orange (Brush.Attention), gear icon
only. Left-click opens the same app menu the top bar gear owned (Settings / Bug /
Feature / About), right-click goes straight to App Settings; both behaviours moved
with it, so nothing lost an entry point. TopBar keeps the wordmark and EDIT.

**EDIT right edge aligned to the layers panel.** The work area is margin 8 + a 360px
column, the top bar is padding 16, so the shared edge is 352 inside the bar. TopBar
column 0 is now a fixed 352 grid with EDIT right-aligned in it — alignment by
arithmetic instead of by eye.

**Preview undo/redo: Ctrl+Z / Ctrl+Shift+Z.** Models/PreviewHistory snapshots element
REFERENCES plus geometry, visibility, order and membership — a deep clone would mirror
every property of a dozen element subclasses and quietly miss the next one added.
One snapshot per gesture (pushed at drag/resize start, never in the move loop), bounded
at 50, redo survives multiple undos, cleared on layout load. Gated on EditingActive:
undo while the preview shows program output would rewrite a scene nobody is looking at.

Honest scope, in the type's doc comment: layout edits only. Colour/opacity/border/font
edits are not captured yet, so Ctrl+Z after one of those restores the last layout change.

6 tests in PreviewHistoryTests; 19 pass across it and the neighbouring UI classes; Debug
and Release both build at 0 warnings.
2026-11-01 07:46:10 -08:00
gramps bd669f2e73 docs(handoff): the six-day surge is the next action 2026-11-01 07:35:49 -08:00
gramps 600088c794 docs(tasks): six days, all remaining code — no week 2, no after-launch list
Correcting my own plan: the surge is six calendar days (Mon 2 - Sat 7), Saturday included,
and it finishes the coding side. Every open row is assigned to a day; the week-2 and
after-launch sections are gone because there are no such sections.

Days: licensing (entitlement, licence UI, greyed tiers, system messages), release blockers
(visibility picker, output contract, webcam identity, Move transitions), resilience (TASK 32,
bandwidth step-down), content and media (text layer, media wiring, profanity filter,
scheduled streams), data and settings (reward persistence, YPP slice 2, defaults split,
scene audio, settings round, disk hygiene), finish and prove (publish recording, undo, gold
pass, full sweep).

Target 6-8 units a day against a measured baseline of 3-5 — that gap is the surge. If a day
overruns, the smallest item moves; a launch blocker never does. Day 6 is the only polish day.
2026-11-01 07:35:38 -08:00
gramps 68fdacc12d docs(handoff): trim to the 40-line cap 2026-11-01 07:33:00 -08:00
gramps 724f940f5e docs(handoff): 40 lines 2026-11-01 07:32:44 -08:00
gramps 832955bbb8 docs(handoff): fit the 40-line cap 2026-11-01 07:32:30 -08:00
gramps 7dab7ded36 docs(handoff): HANDOFF under the 40-line cap for the surge plan 2026-11-01 07:32:13 -08:00
gramps b2e8b26b6e docs(tasks): the surge week, coding only — five days sized at 3-5 units each
Non-coding is parked on Valve (identity verification, store page, depot); the week is code,
tests, debugging, polish. Ordered by what actually blocks a public build: entitlement seam
and licence UI (days 1-2), greyed tiers (2), visibility picker (3), stream resilience (3-4),
output contract (4), gold pass (5).

Sized honestly against the measured rate: ~50 units remain, 15-25 fit in five days, so the
plan names week-2 candidates and the five items that should not block launch at all
(scheduled streams, scene audio, defaults split, YPP slice 2, feedback batch).

Every unit carries the same definition of done — clean build, per-class tests, verify.sh on
the declared scope, and the falsified doc corrected in the same commit.
2026-11-01 07:31:52 -08:00
gramps f189c5e328 build(steam): the SDK zip IS the only download — ContentBuilder included, links recorded
The partner dashboard offers exactly one download because there is only one: the SDK zip
carries the runtime (steam_api64.dll) and the release tooling (sdk/tools/ContentBuilder
with steamcmd.exe for Windows and steamcmd.sh for Linux/macOS, plus the app_build and
depot_build VDF templates). Verified against the zip rather than assumed.

scripts/steam-sdk.sh gains --contentbuilder, opt-in because it is 27 MB of binaries that
only the uploading machine needs.

Distribution.md §2 now opens with that fact — there is no separate ContentBuilder
download to hunt for — plus a reference table of the pages that actually get used:
SteamPipe upload, DLC setup, BIsDlcInstalled, store artwork sizes, Coming Soon window,
the Wallet rule, recurring subs, partner support and Steamworks.NET.
2026-11-01 07:26:51 -08:00
gramps 1fe84be64a build(steam): stage steam_api64.dll beside the exe — one file, not the whole SDK
The SDK zip is 24 MB of headers, examples and tools we never build. The runtime
dependency is one native library, so scripts/steam-sdk.sh extracts exactly
sdk/redistributable_bin/win64/steam_api64.dll into steam/ (gitignored, like ffmpeg/ —
the script is the audit trail) and writes steam_appid.txt = 480, Valve's Spacewar app,
so SteamAPI.Init() resolves an app id locally before LlamaCasty has one of its own.

Two csproj details that would have cost a debugging session:
- Link="steam_api64.dll" puts it BESIDE the exe; without Link, None items keep their
  relative path and it lands in steam/, where the loader does not look.
- ExcludeFromSingleFile, for the same reason ffmpeg needed it: a native DLL otherwise
  gets swallowed into the single-file bundle and cannot be loaded at runtime.

steam_appid.txt is Debug-only — Valve's guidance is not to ship it — and the publish
output confirms it: dll present at the root, no appid file, exe still 127 MB (proof the
DLL was not bundled).

Also queued, as asked: preview undo (Ctrl+Z / Ctrl+Shift+Z) in edit mode, noted as the
partner to the defaults-vs-current split — undo covers the slip, defaults cover the mess.
2026-11-01 07:23:29 -08:00
gramps 27fc6fb507 feat(profile): the free budget is pixels and fps, enforced once — with a dev-only release hatch
The paid delta is output quality, so the rule needs to be measurable rather than a word:
921,600 pixels and 30 fps, fail-closed. Models/OutputProfile.cs is that rule as pure
policy — a permitted tier passes through untouched (Pro, or the free tier's own 720p30),
a disallowed one resolves to the best tier the budget allows and carries ITS bitrate, so
a capped user is never offered an 8 Mbps budget for a 720p frame.

Applied once, in BuildEncoderOptions. That is genuinely one place: the stream and the
local recording are two output blocks of ONE ffmpeg process fed by one EncoderOptions,
so there is no second encoder to forget. (commercial.md claimed "both encoders — never
two checks"; the code is stronger than the doc, and the doc is corrected.)

Developer override, as asked: a "Developer mode — all resolutions" checkbox in App
Settings, bound to MainViewModel.DeveloperMode, which feeds HasProOutput. It is not
hidden in a release build — Controls/DeveloperPanel's Page and Compile items are removed
when Configuration != Debug, and OverlayHost adds it at runtime inside #if DEBUG, so a
shipped exe has no BAML for it at all. The setter also refuses the value when
DevBuild.IsDevelopment is false, which is now the single source for that question (the
scoreboard partial had its own duplicate const).

Two traps worth their lines: a `const bool` dev flag makes `if (!Flag) return;` an
unreachable-code warning in one configuration or the other (CS0162), so DevBuild uses
static readonly; and a Condition on an ItemGroup is invisible to a test reading
item.Attribute("Condition") — read the parent.

Tests: 14 pass across OutputProfileTests, AssemblyIdentityTests and StorePackagingTests.
The invariant test is the one that matters: iterate every shipped tier under a free
entitlement and assert none emits more than the budget, so a future 4K tier fails here
instead of shipping to everyone. App builds Debug and Release at 0 warnings.
2026-11-01 07:11:49 -08:00
gramps abfab25685 build: the exe is LlamaCasty.exe, and the publish script proves the artifact
Renames AssemblyName to LlamaCasty before the first Steam depot — Steamworks is explicit
that renaming a live exe is painful, so this had to land first, not later.

Four assembly-qualified pack URIs carried the old name, not three: MainWindow (window
icon), OverlayHost (logo), RenameRecordingDialog and KeepBroadcastDialog. Pack URIs
resolve by assembly name at runtime, so a stale one compiles, passes every test and then
silently renders nothing the first time that view opens. AssemblyIdentityTests guards
both halves — the assembly name, and no source XAML still naming the legacy assembly.
Packaging/Package.appxmanifest and package-msix.sh carry the exe name too.

scripts/publish.sh replaces the by-hand publish: single-file win-x64, ReadyToRun,
ffmpeg beside the exe, and it refuses to finish if ffmpeg, LICENSE, PRIVACY.md or
THIRD-PARTY-NOTICES.txt are missing, or if a test assembly leaked in.

That last guard exists because the first publish was wrong in a way no test could see:
PublishSingleFile + IncludeNativeLibrariesForSelfExtract bundled the libav*.dll family
into the exe — a 9-file output with no ffmpeg folder and a 182 MB binary, so the locator
would have found nothing at runtime. ExcludeFromSingleFile="true" on the content keeps
the folder a folder. Diffing against a control publish without the single-file flags
found it; guessing at item globs wasted two attempts first (recorded in MyMistakes.md).

Measured: 127 MB exe + 129 MB ffmpeg = 257 MB depot. Tests: AssemblyIdentity,
StorePackaging, BuildStamp, BundledFonts all pass; app builds with 0 warnings.
2026-11-01 07:01:50 -08:00
gramps 8f79b20a18 docs(distribution): Steam is the only channel — the zip, winget and the Store are dead
The site said "Coming soon to Steam" while the app docs still said direct download plus
winget primary with Steam parked. One of them had to move, and the creator's ruling is
Steam exclusive: a free app on Steam with the paid edition as a Steam DLC, and the
$10/month plan sold off-Steam by Polar.

Records the constraint that shapes the code, not just the prose:
- Steamworks requires in-app purchases to use the Steam Wallet (Microtransactions doc,
  quoted in commercial.md), so the monthly is *stated*, never *sold*, inside a Steam
  build; the lifetime is plain DLC bought on the store page and read with BIsDlcInstalled.
- Entitlement is `steamDlcOwned || polarKeyValid`, both resolving to one OutputProfile.
  steam_api64.dll ships beside the exe and is a functional dependency, not the rejected
  "IP protection" native DLL — without it DLC ownership cannot be read. Launched without
  Steam, the app falls back to the free profile rather than refusing to start.
- Signing is moot: Steam installs never meet SmartScreen. Artifact Signing stays on the
  shelf for the day a non-Steam download exists. Year-one fixed cost is ~$115.
- The exe rename must land before the first depot upload — Steamworks warns about
  renaming a live exe.

Distribution.md is rewritten to SteamPipe mechanics (publish output, depot, steamcmd,
DLC wiring, clean-account purchase/refund test). product.md, decisions.md (a new
"Steam exclusive" entry with every dead vector), TASKS.md (Steamworks entitlement row,
Steam packaging row), ai.md, index.md and the TASK 48 banner follow. MyMistakes.md gains
the Steam rules that cost real time today: installs skip SmartScreen, updates are free,
the Wallet rule, and the recurring-billing caveat.
2026-10-31 11:05:53 -07:00
gramps 57ef9e7a5e docs(commercial): the claim page was never needed, signing waits for revenue, and support is a filter
Four things derived this session superseded what the plan said twenty-four hours earlier,
and the docs are corrected where each one died:

**Licensing needs no server.** Polar's customer-portal endpoints authenticate on the key
itself plus the public organization_id; the org access token is only needed to create
products. Channel binding rides in `conditions: {channel_id}`, re-checked on every
validate — so the "claim page" (a hosted OAuth flow that wrote the UC id onto the key)
and the key database it implied are deleted. Device management, key rotation and
subscription auto-revocation are Polar's; four states, one lease, no callback endpoint.

**Signing is deferred until two subscribers.** It never removes the day-one SmartScreen
warning (EV's instant-bypass died in 2024), so buying it before revenue buys only the
publisher line. Renamed to Azure Artifact Signing, $9.99/mo Basic, and it requires a
paid Azure subscription. Year-one fixed cost drops from ~$135 to ~$15.

**The Store account is free.** Microsoft waived the $19 individual fee and removed the
$99 company fee in May 2026, which makes the Store the only zero-cost route to a
Microsoft-signed install — the free answer to SmartScreen, noted beside the paid one.

**Support is a filter, not a promise.** Email, best effort, no SLA, refunds on request
without argument; no training service, no helpdesk. A repeated question becomes a
training video on @llamacasty, which is content and monetization instead of a ticket.
GitHub issues on the releases repo are the deliberate friction.

Also settled: binaries live in a second Gitea repo (`llamacasty-releases`) push-mirrored
to a public GitHub repo — issues and assets public, code private, one publish direction
(a git mirror carries refs, not GitHub Release assets); `@llamacasty` is
standard-verified with intermediate features, so live streaming, >15 min uploads and
custom thumbnails work today and no SIM or ID is needed; and OAuth comes out of Testing
mode by publishing the app (100-user lifetime cap, no 7-day token death).

TASKS.md now opens with the four-day surge slice — artifact shape, output gate,
entitlement seam, real-machine day — because those are what the first public release
waits on. HANDOFF.md rewritten. MyMistakes.md gains the Polar recipe and the
"verify means three different things" entry. MARCOM.md gains the support→content loop
and the surface roles.
2026-10-31 10:08:05 -07:00
gramps 0b41b7db67 docs(mistakes): the www → apex recipe — CNAME, the silently self-signed cert, and the heredoc that ate $request_uri
www.llamacasty.com now resolves and 301s to the apex. Four traps worth keeping:
yunohost domain add --install-letsencrypt-cert exits SUCCESS with only a self-signed
cert (it skips LE when the parent has no wildcard), plain cert install then insists on
a diagnosis and needs --no-checks, a domain with no app 302s to the YunoHost SSO rather
than 404ing, and writing the nginx snippet over ssh with an unquoted heredoc silently
expands $request_uri to nothing — every path then redirects to the homepage. Also
recorded: both resolvers near this job lie about the name (workstation NXDOMAIN cache,
droplet dnsmasq answering ::1).
2026-10-31 09:05:16 -07:00
gramps 7827d239e2 docs(commercial): one source of truth for the money — prices, entitlement, channels
The commercial plan was spread over five documents written in five different weeks,
and they contradicted each other: product.md sold the Store, decisions.md called the
Store dead then re-opened, Distribution.md (736 lines) still described Polar as a
download host, MARCOM.md quoted "no subscription" next to a paid tier, and README.md
promised "free forever". An outsider could not tell what ships.

docs/ai/commercial.md is now the only place money is decided: free = whole app at a
921,600 px / 30 fps output profile covering the recording as well as the stream; Pro =
$10/mo or $400 lifetime through Polar, key bound to the buyer's YouTube channel ID via
a claim page, 2 activations, 7–14 day lease with offline grace, fail closed, never
downgrade a running stream; one portable zip + winget primary, Steam and the Store
parked; Azure Trusted Signing (~$9.99/mo), no EV; Founding 200 promo; ~$135/yr costs;
anti-piracy posture and what is permanently rejected.

Everything else now points at it instead of restating it:
- product.md — money sections replaced with pointers; packaging rewritten to
  winget-first, Store parked; signing updated to Trusted Signing.
- decisions.md — graveyard with cause of death for free-forever, the Store ordering,
  the updater, and the 2026-10-30 reversal of the quality-ceiling rejection.
- Distribution.md — rewritten to release mechanics only (artifact, GitHub Releases,
  winget manifest, signing, checklist, takedown): 736 → 120 lines.
- TASKS.md — TASK 54 rewritten, TASK 48's route settled, TASK 10 superseded, five new
  rows (entitlement seam, output-profile gate, licence UI, system messages, packaging).
- README.md — "free forever" replaced with the real split.
- capture.md — obs-backgroundremoval is GPL-3.0 and so licence-INCOMPATIBLE since the
  proprietary EULA; the old "compatible" line was falsified by that change.
- TASKS/task-10, task-48, task-51, task-54 — superseded-banner at the top of each, so
  none of them reads as current policy.
- HANDOFF.md — rewritten: plan settled, next code unit is the entitlement seam.

MARCOM.md and MONETIZATION.md (gitignored) were corrected in place: policy pointer at
the top of each, the flash removed from every live claim, the Polar fee break-even
corrected to ~$900/mo, and the projections flagged as written against a dead model.
2026-10-31 08:32:25 -07:00
gramps 90d5140906 docs(mistakes): the llamacasty.com deploy recipe, recorded
Deploying the site cost a session because the only documentation was a destination
('/var/www/linkstack/'), not a transport, and the failure modes looked like credential
problems rather than the two things they were: sshd offers publickey only on 2214, and
it appended a keyboard-interactive 2FA step that a non-interactive tool call cannot
answer (hang, then 'Permission denied (publickey)').

Records the whole shape: web root and owner, port 2214 key-only, password = sudo not
SSH, the 2026-10-30 removal of AuthenticationMethods/PAM 2FA, and the traps — the md
files must never be published (build-this.md now carries INTERNAL pricing), drvfs 777
needs the chmod pass, linkstack__2 is a different app, and 22/1319 are closed.
Recipe lives in llamacasty.web/deploy.sh.
2026-10-30 16:49:33 -07:00
gramps c72b61b61e docs(decisions): the site publishes the model, not the terms
Records the split so neither side drifts: the app repo holds the real numbers (720p30 free, $10/mo
or $400 lifetime, Store individual account) and the site holds only the promise (free forever, every
feature unlocked, a paid edition at launch).
2026-10-30 16:16:31 -07:00
gramps 54de9c3dfb docs(decisions): Store route re-opened via an Individual developer account
Creator ruling 2026-10-30: the Microsoft Store is the distribution target again, through an
Individual (not Company) developer account — which is what made the 2026-10-22 withdrawal necessary
in the first place, and why no legal entity is now required. The publisher line shows the verified
individual name; distribution work stays parked until the account exists, and the site's 'coming
soon to the Microsoft Store' copy is accurate again.
2026-10-30 16:15:28 -07:00
gramps 0498f7e130 legal: proprietary EULA + privacy policy; GPL terms retired
Licensing posture changes to closed-source commercial software (creator ruling 2026-10-30): free
tier caps output at 720p30, a paid licence unlocks 1080p ($10/month or $400 lifetime).

- LICENSE is now an End User License Agreement: grant, what you may do with your own content,
  no redistribution/reverse engineering, third-party components prevail, updates at publisher
  discretion, warranty and liability limits, termination, governing law.
- ADDITIONAL-TERMS.md deleted — those were GPL §7 terms and have no meaning under the EULA.
- THIRD-PARTY-NOTICES.txt keeps every third-party obligation and drops the GPL header and §6
  source offer; it states plainly that LlamaCasty ships no source.
- PRIVACY.md is new: exactly what is written locally (layout DB, DPAPI token, capped log,
  recordings you choose), what leaves the machine (Google's APIs only, with your account), and the
  standing promises (chat rendered never stored, no telemetry, no ad SDKs).
- README, docs/ai/product.md, docs/ai/decisions.md and TASKS.md record the decision and the
  obligations that still bind (ffmpeg LGPL as a separate process, OFL fonts, CC0 icons, model
  provenance, notices in the About screen).

Deliberately NOT decided here: key issuance/verification and the resolution gate itself. Earlier
GPL builds remain GPL for the versions that shipped under it.
2026-10-30 16:10:38 -07:00
gramps 7829610cad docs(mistakes): record the record-struct zero-init trap
Two controls looked dead because new ScoreboardStyle() zero-initialised instead of applying the
declaration's defaults. Recorded with the rule (explicit parameterless ctor) and the testing lesson
(a defaults test must construct with new(), not with explicit arguments).
2026-10-30 15:24:33 -07:00
gramps c03acd0aa0 fix(scoreboard): the defaults were never applied — a record struct zero-initialises
Creator's defaults: white text, Background ON (black, 20%), speed capped at 200 px/s, text size shown
in real pixels, and "plate" dropped from the label.

The important find is underneath those: `ScoreboardStyle` is a record STRUCT, and `new
ScoreboardStyle()` zero-initialises — it does NOT run the constructor's parameter defaults. So the
shipped strip had null text colour, NO background and zero opacity, whatever the source said. That is
why the Background checkbox and the opacity slider looked like they had no code behind them: the
renderer was faithfully drawing the zeros it was handed.

An explicit parameterless constructor now carries the real defaults, and the renderer test asserts
them (white / on / black / 0.20 / max speed 200) so this class of silent-zero cannot return.

Readouts fixed too: the opacity and text-size setters never raised their own property, so the numbers
never moved while the render did. Text size now reads "24 px" instead of a bare slider value, and
snaps to whole pixels.
2026-10-30 15:24:14 -07:00
gramps 28525c90aa feat(scoreboard): scroll speed is a property (20-400 px/s, persisted)
The marquee now has the dial it needs. `ScoreboardPresenter.PixelsPerSecond` is the single owner of
the range (20-400, default 120), the layer's properties expose it with a live px/s readout, and the
value is persisted as Scoreboard.ScrollSpeed because it is a READABILITY choice — re-dialling it
every launch would be a tax on the creator who needs the strip to be readable.

Advance() still takes an optional override so tests can drive exact distances.

Guard: the presenter test proves speed is real (same elapsed time, twice the speed, different
window) — not a value that is stored and ignored — and the panel test pins the control, its range
and the persistence key.
2026-10-30 15:19:28 -07:00
gramps 460b36204d feat(scoreboard): it ticks — the marquee is the design, not an option
Creator, plainly: "the entire point of a ticker is to tick… so, tick." The static strip was my
reading of an earlier exchange; it is gone.

How it ticks without becoming the compositor's most expensive layer:
- `ScoreboardRenderer.RenderRun` rasterises the run ONCE per value change at its natural width plus
  a gap.
- `ScoreboardPresenter` crops a 1920-wide window at a moving offset each tick — a row-wise byte
  copy, no text layout, no re-raster.
- `TickScoreboard` runs on the existing 30 Hz meter clock and only while the strip is shown, so a
  hidden or disabled scoreboard costs nothing.

The dev simulation uses the same path, so what is previewed is what ships.

Test updated to the real contract: no tick → identical pixels; a tick → different pixels; a value
change → re-rasterised run. (The old test asserted frame-instance identity, which a cropped window
can never satisfy.)
2026-10-30 15:17:55 -07:00
gramps 882b8551d9 fix(scoreboard): the strip and the social bar now really share the frame
Reported: the ticker was painting OVER the socials. The coupling rule existed and was tested
(ScoreboardLayout.ResolveEdges) but nothing ever CALLED it — the strip's Y still came from its own
preference, so both bars landed on the south edge.

Now the resolution runs at publish time: the social bar keeps its side, the scoreboard takes the
other one, and its Y follows from that edge. The pair re-sorts whenever the social bar moves (the
preview click-toggle) or shows/hides, because the rule is "resolve from what is visible", not a
stored pairing.

The layer also gains an Edge preference (Top/Bottom) in its properties, labelled "socials take the
other" — a preference, never a claim on an occupied edge.

Also stabilised the opacity assertion: it samples a plate-only pixel 5px from the left edge instead
of the first semi-transparent pixel it finds (which could be anti-aliased text).
2026-10-30 15:11:35 -07:00
gramps e27e79962c fix(scoreboard): box follows the rendered strip, opacity proven, plate always settable, dev override
Four reports, three causes:

1. "Background plate has no code behind it" — the plate COLOUR and OPACITY controls were disabled
   until the plate switch was on, which reads as dead controls. They are always settable now; the
   switch is what decides whether a plate is drawn.
2. "Opacity doesn't work" — proven in the renderer test: 20% and 80% produce alphas of ~51 and ~204,
   so the slider reaches the pixels. What was actually wrong was the box.
3. The strip's BOX was fixed at 56px while a wrapped strip renders taller — the frame was scaled
   into the box and looked squashed/clipped. The source's width/height now follow the rendered
   frame, and its Y follows from the edge.
4. "Simulate (dev) should override Show the scoreboard" — it does: the preview gate is
   (_scoreboardEnabled || _scoreboardSimulate), so the dev toggle works even with the product
   setting off.

Not changed: the strip is STATIC by design (all enabled facts always visible, wrap rather than
scroll). If scrolling is wanted after all, that is a presentation mode to add, not a fix.
2026-10-30 15:09:19 -07:00
gramps 8caee21b9a fix(scoreboard): the strip finally reaches the preview, and dev labels are orange
"Clicking on everything produces no visible effect" — the cause was upstream of the controls, and it
is the same defect as the invisible alert box (TASK 47):

- `Source.DisplaySource` returned `VideoImageSource` only for ChatBox / WebSource / MediaSource /
  AlertBox. The scoreboard's frame was rendered and published, then never displayed.
- the preview's Image has a per-type Visibility trigger for each of those and none for IsScoreboard,
  so even a correct DisplaySource would have stayed collapsed.

Both are fixed, and the layer test now pins the frame-source list AND the trigger so the pair cannot
drift apart again.

Dev-only affordances are ORANGE from now on (creator ruling): "Simulate (dev)" is a TextBlock in
Brush.Attention, so a development control can never be mistaken for product. The panel test pins the
colour.
2026-10-30 15:05:33 -07:00
gramps 9405c54750 feat(scoreboard): dev mode can configure the board without signing in
Creator (2026-10-30): in dev mode the "Show the scoreboard" option must always be available.

The block's gate becomes CanUseScoreboard = IsConnected || IsDevelopmentBuild. Signed-out Release
still shows the inert block with its "sign in" line; a DEBUG build is fully usable, so the strip can
be toggled and previewed before there is an account. IsConnected raises it, so signing in flips it
live.

Guard: ScoreboardPropertiesTests now asserts the CanUseScoreboard gate (enabled AND hint), and the
VM surface list includes it.
2026-10-30 15:01:32 -07:00
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 ea1017776d feat(scoreboard): the properties are inert until the creator is signed in
The scoreboard is fed by the signed-in channel (chat events + channel stats), so offering its
switches to a signed-out user is a dead control. The whole block now binds IsEnabled to IsConnected
and shows one line — "Sign in to YouTube to use the scoreboard." — instead of looking broken.

Nothing extra is needed to re-enable: IsConnected already raises on sign-in/out, so the block
comes alive the moment the session lands.

Guard: ScoreboardPropertiesTests asserts the IsEnabled binding, the signed-out hint and that the
hint is gated by the inverse converter.
2026-10-30 14:54:52 -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 d4a4b1c80e docs(handoff): scoreboard units 1-10 landed; providers + goal inputs remain 2026-10-30 14:43:26 -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 9a6099af7c feat(scoreboard): unit 9 — the Analytics scope, requested incrementally
Scope becomes youtube + yt-analytics.readonly, with include_granted_scopes=true so an existing
session is asked for the NEW permission only. GrantedScopes records what Google says on the token
response (both the code exchange and the refresh), and HasAnalyticsScope is the flag the scoreboard
uses to offer a one-click re-consent instead of failing anonymously mid-stream.

TokenStore is unchanged on purpose: a launch-time refresh carries the scope list, so old sessions
report 'not granted' until the creator re-consents — the honest state.

The old auth test asserted 'exactly one scope'; it now asserts the new DECISION (two scopes, no
force-ssl, analytics read-only) rather than loosening.
2026-10-30 14:42:40 -07:00
gramps 7048bd8bff fix(scoreboard): the chat feed actually reaches the tallies
Unit 6 created the recorder and the session hook but nothing fed it — the strip would have stayed
empty through a whole stream. The poll's message handler now records each decoded event after the
chat and alert layers have seen it.

Caught by tracing the data path while wiring analytics, not by a test: the guards cover the tally
rules, not the seam into the poller.
2026-10-30 14:41:47 -07:00
gramps 534f56a7c4 feat(scoreboard): units 7+8 — the layer's properties panel (facts, colours, plate, size)
Selecting a Scoreboard layer now shows its settings, using the app's own widgets only:
- eight fact checkboxes (subscribers, watch hours, Super Chats, new members, milestones, last member,
  last Super Chat, last gift)
- text colour and plate colour via YtComboBox over BorderColorPalette (the app's one palette —
  TASK 63's rule: a picker IS the app's drop-down, never a hand-built menu)
- plate on/off, plate opacity, text size 14-24

Every change pushes through the presenter (SetStyle/SetTiles) and repaints the strip, so the preview
and the output follow immediately. One look per layout, stored on the view-model: the scoreboard is a
single billboard, not eight independent widgets.

Guard: ScoreboardPropertiesTests asserts the panel is gated on SelectedElement.IsScoreboard, binds
all eight facts and the four look controls, uses YtComboBox/YtCheckBox only, and that the VM surface
exists for each name (a typo would otherwise bind to nothing in silence).
2026-10-30 14:41:31 -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 fbcccea0f7 feat(scoreboard): unit 4 — the strip renderer (plate, text colour, fit, wrap)
Rasterises enabled tiles into one straight-alpha BGRA frame: measured widths -> FitLine (shrink,
then wrap, never scroll) -> centred lines with dim separators, over an optional rounded plate whose
colour and opacity are the creator's knobs. Uses OutputPalette.Sans (bundled Inter) because on-stream
text wears the app's typefaces.

Static by design: it takes plain strings, so the caller caches the frame and only re-rasterises when
a value changes — a strip that re-rendered every tick would be the compositor's most expensive layer
for no benefit.

Guard: ScoreboardRendererTests paints real frames and inspects pixels — plate colour AND opacity,
text colour reaching glyphs, plate-off leaves the area transparent, and an empty tile set returns no
frame at all.
2026-10-30 14:37:34 -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 e92ce4bb63 fix(dock): the master ladder moves when no session is running
Reported: no activity in the master meter.

Cause: `MasterLevel` measures the buffer the ENCODER receives, and that buffer only exists while the
mix is being pulled — streaming or recording. Idle, nothing fills it, so the master read zero while
the bays below it moved. A master that is dead between sessions is a gap in the design.

Now: while a session runs the master shows the measured mix (unchanged); idle it shows an estimate
computed from the same measured sources and the same gains the mix applies
(`MeterScale.MonitorFraction`, pure + tested). Two ladders can therefore never tell contradictory
stories about whether audio exists.

Guard: MeterScaleTests pins the estimate — silence reads zero, a quiet source at unity is visible,
two sources add, and gain is applied in the LINEAR domain like the mix (scaling the display fraction
would be a different, wrong curve).
2026-10-30 14:01:29 -07:00
gramps ec293e54b0 feat(dock): TRAX becomes a background level, not a channel strip
Creator ruling (2026-10-30): TRAX is background music and is controlled separately from desktop and
mic — so KISS it:

- TRAX loses its own ladder and slider. It is one number plus ±5 dB buttons, disabled at the ends
  (−60 dB floor, +6 dB ceiling).
- It STARTS at −50 dB, taken from the creator's own meter readings, and moves in 5 dB clicks. If that
  proves inaudible on their setup, six clicks lands at −20 — the buttons exist for exactly that, and
  `TraxDefaultDb` is one constant.
- Layout follows: Desktop's volume now sits directly beneath its ladder, and the TRAX row (name,
  play/pause, level) sits beneath that, inside the GAME bay. The bays are MIC | GAME+TRAX | LIP-SYNC.
- The metering tap on the player stays (it is how "is the music producing sound" is answered) but has
  no ladder of its own; the desktop bed already shows the music.

Guards updated: TraxBayTests now asserts the stepper wiring and that the row has NO SegmentMeter;
GameBayTests asserts the desktop fader sits below the desktop ladder.
2026-10-30 13:46:05 -07:00
gramps 93dc6592ac docs(dock): the desktop ladder says it includes TRAX
TRAX is not a separate bus: it plays through the default output device and WASAPI loopback taps
that device, so the music genuinely is in the desktop bed — the Desktop ladder moving with a track
is correct, not a leak. The bay now says so on the label and in a tooltip, because two moving
ladders for one sound invites exactly this question.

No behaviour change: routing stays right (the stream hears the music once, via loopback).
2026-10-30 13:34:27 -07:00
gramps 8d23502220 fix(trax): the fader now actually changes the level — and the meter follows it
Reported: pegging the TRAX fader changes nothing audible, the ladder never reaches red, and the
ladder does not scale with the fader.

Cause (two defects in the same setters):
- UserGain wrote the OUTPUT DEVICE's volume. NAudio's WaveOut ignores that, so the fader was inert.
- LocalGain (the desktop-mute follow) recomputed the sample-chain volume WITHOUT the user gain, so
  every duck/mute silently discarded whatever the creator had set.

Fix: one `EffectiveVolume(user, local)` = MusicVolume × user × local, applied to the sample chain by
both setters. The meter sits AFTER that stage, so the ladder now scales with the fader and can reach
the peak band on a loud track — exactly what pegging should look like. Max user gain is +6 dB, so
"pegged" means the file at unity, not a magic boost.

Guard: MusicLevelTests pins the arithmetic (default half, pegged double, mute multiplies to zero),
that the user gain survives the mute follow, and that nothing may drive the output device's volume
again — the no-op that made this invisible.
2026-10-30 13:32:24 -07:00
gramps 2607ecf210 fix(dock): TRAX ladder gets the mic's ballistics (and a 33 Hz beat)
Reported: the TRAX bar moves, but its sampling looks far slower than the mic meter.

Cause: the TRAX number was a raw instantaneous peak per metering block — a stutter sampled by the UI
timer, with no smoothing. The mic looks fluid because it is smoothed at audio-callback rate.

Fix: the player now smooths its measured level with the SAME curve the mic uses
(MeterScale.NextLevel: fast attack, gentle decay), and the dock's meter clock runs at ~30 Hz instead
of 20. Two ladders that move alike, because they are the same ballistics.

Guard: MeterScaleTests pins the attack/decay behaviour alongside peak-hold.
2026-10-30 13:28:32 -07:00
gramps 839bc991a2 fix(dock): meters beat on their own 20 Hz clock, and TRAX silence is diagnosed
Reported: TRAX plays audibly but no ladder registers anywhere.

Two distinct faults, one of them mine:
1. The meter readouts were refreshed only from the MIC's level callback. With a silent or absent
   mic there is no tick, so a playing TRAX never updated its ladder — the level was measured, the
   UI simply never asked for it. Meters now have a 20 Hz DispatcherTimer of their own.
2. If the player's own meter still reads 0 while playing, the samples are not reaching the device
   (or metering is not attached). That now writes ONE line to startup.log after 2s — so the next
   report says which side is broken instead of another round of guessing.

Note: TRAX rides the desktop loopback, so once the mix captures it the GAME ladder should move too;
if it does not, the loopback endpoint and the music's output device differ.
2026-10-30 13:24:29 -07:00
gramps a54149bd7c feat(dock): TRAX gets its own ladder — measured at the player's output
Creator: "why is there no meter for when trax is playing?"

Routing answer: TRAX rides the desktop loopback, so it already moved the GAME ladder — there was no
separate TRAX signal in the mix. But that ladder cannot answer the question a deaf creator actually
has: "is the music producing sound at all?" The play glyph cannot either — an open file with a dead
decoder still reports Playing.

So the player now measures its own output: \`MusicPlayer\` wraps the volume stage in an NAudio
\`MeteringSampleProvider\` and exposes \`Level\`; \`ITraxPlayer.Level\` keeps the fake honest. The VM maps
it through the same display scale (\`TraxMeterFraction\`), zeroed when not playing, and the TRAX bay
carries a 24-segment ladder above its fader.

This reverses my earlier "no ladder, nothing honest to show" — the honest thing arrived with a real
measurement. TraxBayTests flipped accordingly (it now asserts the ladder AND its source).
2026-10-30 13:22:13 -07:00
gramps 8aa483f933 docs(handoff): audio dock shipped, awaiting an eyes-and-hands pass 2026-10-30 13:20:19 -07:00
gramps 16689c0a80 feat(dock): the dock is now the only audio surface (unit 10, final)
Deletes the preview's live-controls strip — mic meter + speaker + volume, the game-audio chip,
the TRAX button and the lip-sync slider — and the three code-behind handlers that served it.

One sound surface instead of two: the same gains in two places was the original duplication, and
two places to set a gain is how a creator ends up with a meter that disagrees with what they hear.
~16.7 KB of XAML and 60 lines of event code gone.

Verified: the preview still loads and its interactions still work (selected-layer hit testing, the
socials preview, the brand-logo preview), plus the theme's resource guard.
2026-10-30 13:19:58 -07:00
gramps 5889d71db6 feat(dock): master strip + MONO output (the accessibility switch)
Unit 9, the one that matters most for this creator.

MASTER STRIP (top, nearest the preview):
- one ladder fed by the MEASURED mix — `AudioMixer` now measures the buffer the encoder receives
  (`MasterLevel`, `MasterImbalanceDb`), so the master cannot show a source instead of the output
- `-12.4 dB` readout, and ONE L/R flag that appears above a 6 dB gap instead of a second ladder
- bitrate / FPS / dropped / duration from the existing health poll

MONO OUTPUT:
- `AudioFold.ToMono` averages each stereo pair into both channels, applied after the limiter and
  the sync delay, so the stream AND the recording get what was folded
- `MonoOutput` is persisted (`Audio.MonoOutput`) and restored at startup: an accessibility switch
  that resets each session is an accessibility failure
- one click in the strip, remembered forever

Guard: AudioFoldTests (hard-left reaches the right at half amplitude, hard-right — the inaudible
case — reaches the left, centred material keeps its level, mono buffers untouched) and
MasterStripTests (the strip binds the measured mix + the switch, the store round-trips it, and the
fold is the tested helper rather than new arithmetic). 33 audio tests green.
2026-10-30 13:18:23 -07:00
gramps a6f63f56f3 feat(dock): the LIP-SYNC bay — offset, readout, zero detent
Unit 8. Re-houses the shipped TASK 22 offset in the dock:
- slider −200…+200 ms, snapping to 5 ms ticks (the existing property still clamps and persists)
- readable readout: "0 ms (aligned)" when right, signed otherwise ("+50 ms", "−120 ms")
- presets 0 / +50 / +100; 0 IS the detent — a button is easier to hit than a snap, and "the lip-sync
  is right" deserves one click.

Guard: SyncBayTests pins the binding, the readout, the presets and the snapping, and that the
offset stays the persisted property (no second source of truth).
2026-10-30 13:16:19 -07:00
gramps 7339df5fcd feat(dock): the TRAX bay — track, play/pause, level, duck badge
Unit 7. The bay shows the track name (left-click toggles playback, right-click opens the picker —
the label stays the control), a play/pause button, the duck badge, and a dB level.

No ladder here, deliberately: nothing measures the music bed, and a decorative ladder would break
the measured-not-painted rule. A test asserts the bay has no SegmentMeter.

The level is real: `ITraxPlayer.UserGain` is a new creator gain multiplied onto the fixed design
volume, separate from `LocalGain` (which follows the desktop mute). The VM's `TraxVolumeDb` is the
same dB scale as the other faders, so unity means the bed's design level.
2026-10-30 13:15:17 -07:00
gramps a1d8e761ea feat(dock): the GAME bay — dB fader, measured ladder, mute, real duck badge
Unit 6. Mirrors the mic bay for the desktop/game bed, plus the badge that matters to a creator who
cannot hear the duck: it shows while the ducker is actually attenuating, and it prints the gain the
mixer applied (−12.0 DUCK by design).

- `AudioMixer.DuckGain` exposes `AutoDucker.CurrentGain` instead of leaving it internal.
- VM: `GameVolumeDb` / `GameVolumeDbLabel` (fader face), `GameMeterFraction` (measured level),
  `IsGameDucking` + `GameDuckLabel` refreshed on the level tick — the same moment the ducker runs,
  so the badge cannot lag behind the mix.
- The bay: header chip + source, ladder, mute dot (uses the meter band, so mute reads at a glance),
  dB readout, fader.

Guard: GameBayTests — the dock binds all four, `AutoDucker.DuckGain` is −12.04 dB and the label is
produced through the same dB scale as the faders, and the mixer property exists so the badge cannot
become a hardcoded number.
2026-10-30 13:12:10 -07:00
gramps 3524ede480 feat(dock): the MIC bay — dB fader, measured ladder, mute, picker
Unit 5. The dock's first working bay:
- 24-segment ladder fed by `MicMeterFraction` — the MEASURED display level (already scaled by the
  volume and zeroed when muted), never the gain setting.
- Fader in dB via `MicVolumeDb` (−60 … +6, 0 = unity) with a mono-weight readout
  (`MicVolumeDbLabel`), so "−6.0 dB" is what the creator reads instead of "0.50".
- Mute button whose dot uses the existing MicStatusBrush; clicking the device name opens the mic
  picker, so the label stays the control (the KISS rule: one place to change the mic).

The preview's own mic row stays for now — it is removed in the final unit, after the dock bays are
complete, so nothing is lost if a bay misbehaves.

Guard: MicBayTests — the bay binds the dB face and the measured level (not the gain), the fader
range IS the scale's range, the VM surface exists, and mute still zeroes the ladder.
2026-10-30 12:46:57 -07:00
gramps 7d8021ca09 fix(dock): master strip on top, against the preview
During a live session attention sits on the preview, so the master sound bar belongs as close to it
as possible — master row first, the four bays below. Collapsed mode is unchanged: 36px is the
master strip on its own, so it stays exactly where the eye already is.
2026-10-30 12:45:12 -07:00
gramps 5552bf97a1 fix(dock): inside the work area, bottom edge aligned with the layers panel
The dock was a sibling row BELOW the whole work area, so it could not line up with the layers
panel and read as sitting under it. It now lives in the work-area grid (preview column, bottom
row), and the left panel spans content + dock rows — both columns end on the same line, which is
what the creator asked for.

No behaviour change; placement only.
2026-10-30 12:42:49 -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 0f62e55474 feat(audio): the segment meter control (unit 3 of the dock)
`Controls/SegmentMeter` draws a 24-segment ladder in ONE OnRender pass — no per-segment elements
updating on the UI thread, which is the difference between a meter and a UI thread tax. Level and
peak are display fractions (what was MEASURED), never gain settings; vertical for the bay ladders,
horizontal for the master strip.

Bands come from `Helpers/MeterScale` (nominal/caution/peak, DESIGN.md thresholds) and live as
dependencies of the control, so the painting logic is pure and testable: the band a segment paints
is the level it measured. Brushes resolve from the theme tokens via SetResourceReference, so the
ladder cannot drift from DESIGN.md.

Peak-hold is the reason the header exists: a bar that follows the moment hides clipping, and
clipping is the one audio fault that cannot be fixed after the stream.

Guard: MeterScaleTests — band edges (inclusive at −12 and −3, as the standard states the ranges),
the −60…0 display scale, and peak-hold rising instantly then decaying at 0.35/s.
2026-10-30 12:36:05 -07:00
gramps 575caa7bc4 feat(audio): the meter knows which side a signal is on (unit 2 of the dock)
`AudioLevelMeter` measured one number for everything. It now also keeps smoothed per-channel
levels with the same attack/decay, plus `ImbalanceDb`. Mono samples report both sides equal (a mono
source is centred), so the two never disagree about whether audio exists.

The dock shows ONE ladder per source — two ladders per bay was visual noise for a rare failure —
but this creator is profoundly deaf on the right, so the one case that matters is "the sound is
panned away from me". That is now measurable and will drive a flag, not a second ladder.

Semantics that mattered: a silent side is compared at the −60 dBFS floor, so a fully one-sided
signal reads ≈54 dB rather than "nothing to compare". My first version returned 0 for exactly the
case the feature exists for; the test caught it.

Guard: StereoMeterTests — hard left, hard right (the inaudible case), balanced stereo, mono
centred, and silence-is-not-an-imbalance. 31 audio tests green.
2026-10-30 12:33:40 -07:00
gramps afe21682c2 feat(audio): the dock's dB scale, and the mono fold primitive
Unit 1 of the sound-bar refactor. The model stores linear gain; the dock shows decibels, because
"−6 dB" is readable and "0.50" is not. `Helpers/GainDb` owns the mapping:

- 0 dB = unity (the old 1.0), range −60 dB (true silence, exactly 0 gain) … +6 dB.
- Labels for the fader readout: "−∞", "-6.0", "+1.5".
- `MonoFold(L,R)` = (L+R)/2, the accessible downmix: hard-panned content becomes audible on both
  sides at half amplitude instead of absent from one. Creator context: profoundly deaf on the
  right, so single-sided audio is inaudible, not merely annoying.

Guard: GainDbTests pins unity, the −6 dB half, the exact-zero floor, the +6 dB ceiling, a
round-trip across the range, the labels, and the fold arithmetic (a right-only 0.5 arrives on the
left at 0.25).
2026-10-30 12:32:07 -07:00
gramps 17249229c8 feat(top bar): EDIT moves beside the gear; the gear wears the app's orange
- EDIT leaves the centre session cluster and sits immediately right of the gear, in the brand
  column — so mode lives with the controls that configure the app, not with the session buttons.
- The gear is a LlamaCasty control, so its icon is `attention` orange instead of muted grey.

Guard: UiShellTests asserts the gear fill and that EDIT appears before the centre (session)
cluster, so a later reformat cannot quietly put it back.
2026-10-30 11:18:36 -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 9b08c3af4f fix(ui): no space inside the wordmark — adjacent Runs again
The comment I added between the two <Run> elements introduced whitespace, and whitespace between
inline Runs collapses to a rendered space: the wordmark read "LlamaCas ty". The comment moved
above the TextBlock, the Runs are contiguous, and UiShellTests asserts them adjacent so a future
edit cannot re-introduce the gap.
2026-10-30 11:09:16 -07:00
gramps 318871033f feat(ui): the capture context menu is always available (Ctrl+right-click); wordmark "ty" is YouTube red
1. The preview's right-click handler swallowed every right-click for the zoom box, so the
   element/backdrop context menu could not open at all. Sources and capture windows must be
   changeable AT ANY TIME — a creator swaps a window mid-stream without entering edit mode.
   Ctrl + right-click now bypasses the zoom path and lets WPF open the menu under the cursor;
   plain right-click keeps the zoom box. No menu markup changed.

2. The wordmark's "ty" is YouTube red (#ff0000), permanently. The token sweep had recoloured it
   to the interactive blue because it was a bare literal; DESIGN.md now records "never re-colour
   the wordmark" beside the token, and UiShellTests pins it.

Guard: UiShellTests gains the wordmark rule and the Ctrl+right-click chord.
2026-10-30 11:07:51 -07:00
gramps cb4f8ec758 fix(ui): REC / ON-AIR labels are white in every state
RecordPillToggle never set a base Foreground, and its label inherits the toggle's — so unchecked
inherited the Control default (black) on the dark bar, while checked was white on red. The style
now sets on-accent once; the checked trigger only changes the fill.

That is the consistency rule for session controls: white ink always, the fill carries the state
(neutral off, red live). EDIT follows the same rule. UiShellTests asserts the base foreground, so
the missing setter cannot come back silently.
2026-10-30 10:46:51 -07:00
gramps feef763035 fix(ui): EDIT label is white in both states
The armed state was fixed to on-accent last build, but the resting state kept on-surface-variant
(#94a3b8) — so with EDIT off, which is its normal state, the label was grey on the dark bar. The
label is white always now; only the fill changes (neutral → blue). The redundant foreground setter
is gone, and UiShellTests asserts the resting colour rather than the trigger.
2026-10-30 10:45:24 -07:00
gramps d25dbf58dc design: white ink on any accent fill (on-accent token)
The EDIT button's armed face used on-tertiary (#0b1326 navy) on blue — the creator read it as grey
on blue and could not read it. Same problem, latent, on the armed REC/ON-AIR pills, which used the
error ink.

One rule instead of two exceptions: a saturated accent fill carries WHITE ink. New token
on-accent #ffffff, used by the EDIT trigger and RecordPillToggle; on-tertiary stays for large blue
surfaces where dark ink is right. UiShellTests pins the EDIT foreground to it.
2026-10-30 10:34:19 -07:00
gramps 568586c541 fix(xaml): the app refused to launch — RecordPillToggle was defined before SegmentToggle
`BasedOn="{StaticResource SegmentToggle}"` resolves while the dictionary is being built, so a
forward reference fails at load with "Cannot find resource named 'SegmentToggle'". The style now
sits after SegmentToggle and SegmentLabel, where its base already exists.

Guard: ThemeResourceTests gains the ordering rule — within one dictionary, no StaticResource may
point at a key defined later in the same file. The existence check alone passed this, which is
exactly why the app died on launch while the tests were green.

Root cause of the unreliable test results this session: a stale `testhost` held ytLive.dll locked,
so builds silently kept the previous assembly (MSB3027 on copy). Killed it; the suite now rebuilds
and the two new facts actually run.
2026-10-30 10:29:22 -07:00
gramps 4841c27514 feat(ui): the creator's six corrections
1. Stream Info + YPP rails are YouTube surfaces → new `youtube` token #ff0000. It is documented
   as brand-only: live state stays `primary`, so the two reds never mean the same thing.
2. EDIT toggle: the checked trigger set the SAME colour as the resting face, so clicking it
   changed nothing visible. Resting is now the neutral surface with muted text; armed is blue with
   dark text, and the label follows the fill.
3. Active scene outline is orange again (`attention`) — it marks the scene you work on, not a
   selection; blue made it read as "selected". Live stays red.
4. Scene names wear the same pill as the hotkey chips (black, 10px radius, 8×2 padding), so both
   labels stay readable over any thumbnail.
5. REC / ON-AIR pills and the running stop button read red while armed/live — including test
   mode, where the top bar is gold. New `RecordPillToggle` style (its own template, because
   SegmentToggle hard-codes a transparent face); the stop button's grey `#333` literal is gone.
6. The properties list had `MaxHeight="420"` inside an Auto row, so it scrolled while the window
   had space left. The row is `*` now and the cap is gone.

Guard: UiShellTests pins all six to the markup and names the one that regresses.
2026-10-30 10:27:11 -07:00
gramps df0118ac52 docs(handoff): design conversion complete; spacing is a layout pass 2026-10-30 10:02:32 -07:00
gramps e89cc3682a test(socials): a real preview test — and the Cancel bug it immediately found
The two regressions that reached the creator both lived in preview XAML that no test loads: the
appearance controls painted nothing, then the bar vanished when the plate was off. Every existing
test was green through both.

This test drives the real drawer on a real MainWindow (hermetic temp DB, per the layout-pollution
rule) and reads the real preview elements:
- the bar is VISIBLE, whatever the plate switch says
- a drawer text colour reaches the glyph
- the plate carries its colour and ~50% alpha, and its 18,6 padding only when enabled
- the shadow is an actual DropShadowEffect on the glyph, and off by default
- Cancel restores the pre-edit look

It paid for itself on the first run: Cancel did NOT revert. The snapshot held the same config
object the live preview mutates, so "restore" restored the edited object. The snapshot is now the
six values, written back on cancel — entries untouched.

Also fixes the harness details that made the first attempts fail: everything runs on the app's STA
thread (window creation AND close), and the exception is rethrown with its stack so a failure is
diagnosable next time.
2026-10-30 10:02:06 -07:00
gramps 0822552830 design: the theme's 4px corners come from the radius token
Six control corners used a literal 4px while DESIGN.md defines radius-sm for exactly that role.
Component geometry stays literal on purpose (the pill toggle's 9px is half its 18px height — a
token there would be a lie about what the number means).
2026-10-30 09:59:12 -07:00
gramps ec01a7deb8 design: the last VM-side colours come from tokens (ThemeBrush)
The state colours the view-models hand to bindings — top bar, REC/ON-AIR dots, health banner,
meter bands, toast tints — were hex literals in C#. They now resolve from Themes/Tokens.xaml
through `Helpers/ThemeBrush`, so the values live in one place. Without an Application (unit tests)
the helper returns a neutral grey: visibly wrong rather than silently plausible.

Four tokens the code needed and the document lacked, added to DESIGN.md (so the doc-is-the-contract
guard demanded their brushes): info-container #3a3b52, success-container #1d5c38,
warning-container #b8860b, test-container #9c6f1c (the test-stream gold bar).

Also fixed by this: the top bar still painted the RETIRED brand red #e94560 when live — the
standard's #ff5451 now, and `OnAirSignTests` (which pinned the old value) says so. BarOn/BarOff
became properties rather than static fields, because a static field would resolve tokens before
the Application exists.

Verified: DesignTokensTests, GameMeterHonestyTests, NotificationAreaIntegrationTests,
OnAirSignTests, SessionTeardownTests — 38 tests green.
2026-10-30 09:58:52 -07:00
gramps 7adff685c0 design: the on-stream graphics join the standard (OutputPalette)
The broadcast picture is part of the app, so it stops carrying its own colours. Alert cards, the
ticker and the branding flash now paint from OutputPalette, which Mirrors DESIGN.md for the brand
tokens and owns the alert box's six-event accent language (gold/violet/pink — content, not
chrome; the standard has no purple and should not grow one, but they live in ONE file instead of
57 scattered literals).

It is a separate type because the renderers run on the frame-pump thread and must not touch
application resources. The duplication is made safe by OutputPaletteTests: it parses
Themes/Tokens.xaml and fails if any mirrored token drifts, and fails if a renderer reintroduces a
colour literal or "Segoe UI".

Ticker and flash glyphs now use the bundled Inter instead of Segoe UI. Neon red is the standard
live red — one red in the product, including the credit.

Fixed on the way: the palette's font fields were static pack-URI initialisers, which throw
TypeInitializationException in any host without a WPF Application (the test process caught it);
they are lazy now with a name fallback.
2026-10-30 09:30:56 -07:00
gramps c4642f1e99 design: every window inherits Inter (implicit Window style)
Dialogs are separate WPF windows, so the FontFamily set on MainWindow never reached them —
GoLive, Socials and the four pickers still rendered Segoe UI while the main window used Inter.

The theme now carries an implicit Window style (family + Display text mode + layout rounding).
Guard: DialogTypographyTests opens a real Window inside the real app and asserts it resolves the
bundled Inter family — which fails if the style or the font resource goes missing — and asserts
no dialog sets FontFamily itself, i.e. nobody opts out of the standard.
2026-10-30 09:18:50 -07:00
gramps 51d9dd09c7 fix(socials): the bar disappeared from the preview when the plate was off
My own regression from the previous commit: the plate Border WRAPPED the bar's content and its
Visibility was bound to BarBackgroundEnabled — so with the plate off (the default) the preview
collapsed the entire social bar. Nothing was wrong with the data; the element was there and
invisible.

Fix: the wrapper is always laid out. "Plate off" now means a TRANSPARENT background
(`SocialBarBackgroundBrush` returns Transparent when disabled) and zero padding, "on" means the
colour/opacity plate with 18,6 padding — which also matches what the renderer bakes for the
output, so preview and broadcast agree.

Guard (new fact in SocialBarAppearanceTests): the preview must never visibility-gate the bar on
BarBackgroundEnabled, the wrapper must bind SocialBarBackgroundBrush, and the plate's switching
must be Padding.
2026-10-30 09:06:53 -07:00
gramps c1212644b0 fix(socials): live preview of the bar appearance + the crash on dragging a slider
Two bugs from the creator's first look at the new controls.

1. CRASH — '{DependencyProperty.UnsetValue}' is not a valid value for property 'Fill'.
   The token conversion deleted `YtSliderKnobHoverBrush` while two slider triggers still
   referenced it. WPF does not fail that at build time: it substitutes UnsetValue and throws at
   the next layout, inside `Ellipse.OnRender` — so the first slider touched (the new opacity one)
   killed the app. The brush is back, as a glossier version of the rest state.
   Guard: `ThemeResourceTests` scans every app XAML and proves each {StaticResource} key is
   defined (theme/App resources or the same file). Proven by removing the key again — the test
   names it.

2. PREVIEW DID NOTHING — the preview bar is its own XAML rendering (not the rasterized strip the
   compositor overlays), and it was hard-wired white-on-transparent, so the drawer's controls had
   nothing to move. Now:
   - `SocialBarTextBrush` / `SocialBarBackgroundBrush` come from the same `SocialBarStyle` the
     renderer bakes, so preview and output cannot disagree.
   - the preview draws the plate (same colour + opacity) and the drop shadow.
   - the drawer applies changes to the live config as you edit (`ApplyAppearancePreview`), and
     Cancel restores the pre-edit snapshot — so the preview tracks the controls without
     persisting anything until Save.

46 tests green (theme, socials, design tokens, layout store, real-MainWindow meter test).
2026-10-30 08:57:09 -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 9528128ca7 feat(settings): brand-logo thumbnail with a hover enlargement
The creator (2026-10-30): the brand-logo field in App Settings showed only a file NAME, so a
myopic user could not confirm which picture was set. Now a 28x28 thumbnail sits left of the
field showing the actual image, and hovering it opens an enlarged copy (up to 420x320) beside
the panel with the file name underneath. A click collapses it; moving the pointer away closes
it, unless the pointer walked onto the popup itself, where it stays so the creator can look
closely.

Mechanics:
- `MainViewModel.BrandLogoPreviewImage` decodes the stored asset ONCE and freezes it, so the
  thumbnail and the enlargement share one bitmap and no dispatcher hop is needed. A corrupt
  image returns null instead of taking the dialog down.
- The thumbnail is hidden when no image is set (NotNullToVis), so there is nothing to hover and
  no empty frame can appear.
- The popup is a separate window: WPF refuses to open one until its placement target is in a
  live visual tree, which is why the test hosts the control in a real window rather than
  asserting on a detached control.

Guard: BrandLogoPreviewTests — the wiring (image binding, the no-image gate, the four gesture
handlers, the readable enlargement size) and the control's open/close behaviour.
2026-10-30 08:39:42 -07:00
gramps 7a49b38cae docs(handoff): every XAML surface tokenised; VM state colours are the last unit 2026-10-30 08:30:45 -07:00
gramps c885bbd782 design: the code-set state colours now agree with the standard
Last inconsistency from the sweep: several VM-side state colours are still literals, and after
the palette moved they disagreed with everything around them — the top bar's LIVE state would
have painted the retired pink-red while the tile beside it used the standard red.

Values aligned (tokenisation of these sites is its own unit — they are strings bound to
Background/Foreground, so the fix is a VM->Brush refactor, not a find/replace):
- live / on-air / recorder dot: #e94560 -> #ff5451 (the standard red)
- error banner: #8f1f1f -> #93000a (error-container)
- audio meter bands follow the doc's stated thresholds: nominal cyan #38bdf8, caution amber
  #ffb95f, peak #ff5451 — they were green/yellow/red, which contradicted DESIGN.md's own table

GameMeterHonestyTests pinned the old band colours; its intent (the meter scales by volume) is
unchanged, so the two colour expectations now name the new bands.
2026-10-30 08:30:33 -07:00
gramps 8502c1d194 design: app settings + dialogs to tokens (surface 5 of the sweep)
OverlayHost (the App Settings surface) and every dialog — Go Live, Socials, Mic picker, Camera
picker, Hotkey config, Reuse Image, Rename Recording, Keep Broadcast — now resolve from tokens.
That closes the last place the creator sees a dialog: the settings overlay was still wearing the
old palette while the main window had moved on.

Every #e94560 in these files was an ERROR or validation affordance (hotkey conflicts, socials
validation, the remove tooltip, the go-live error line) — six sites all borrowing the retired
brand red to mean "something is wrong". They are `error` now, which is what the standard says
red-adjacent means; live/record keeps `primary`.

Guard list extended to all nine files.
2026-10-30 08:29:28 -07:00
gramps 8415bb5f37 design: preview pane to tokens (surface 4), and one palette list instead of two
110 hardcoded colours gone from the preview — the biggest surface in the app. Nicknames:
- the webcam selection ring is the interactive accent (was the retired brand red)
- the mute chips are `attention` (a muted mic is a caution, not a live state) with a white play
  glyph; the live badge is `primary`
- YPP progress bars/track, TRAX level zones, the error line, the dim strips and the poster veil
  all resolve from tokens (`positive`, `secondary-container`, `error-container`, `scrim-dark`,
  `scrim-dark-strong`)

Plus the duplication this sweep exposed: the webcam's right-click "Border Color" submenu carried
**seven hand-written colours**, a second source of truth beside the properties drop-down that
TASK 63 had already unified onto `Models/BorderColorPalette.cs` — and the two lists disagreed
(7 vs 14). The submenu is now built from the palette when it opens, so every offered colour is
the same colour everywhere, with the palette's name beside it. The click handler is unchanged;
generated rows inherit the menu's SceneElement DataContext.

Guard list extended: Controls/PreviewPane.xaml.
2026-10-30 08:28:21 -07:00
gramps 66b85d3fcc docs(handoff): preview pane next, with the colour-picker trap recorded 2026-10-30 08:03:44 -07:00
gramps 3edb0d10be design: three tokens the sweep needed — positive, scrim-dark, scrim-dark-strong
The palette had no green for status and no black scrim, so two surfaces were about to either
invent literals or misuse an accent. Creator's call (2026-10-30): give green a token rather
than painting "connected" blue, which already means five other things.

- `positive` #22c55e — STATUS ONLY (connected, applied, healthy). Never an accent, never a
  button fill; the doc says so and the guard parses the doc.
- `scrim-dark` #26000000 (15% black) and `scrim-dark-strong` #00000080 (50%) — the preview's dim
  strips and heavy overlays. 18 sites justify a token, and they are structural, not decoration.
- LeftPanel's ✓ confirm now wears `positive` instead of the interactive accent, matching the
  status rule the new token states.

DesignTokensTests still green: the doc is the contract, so the three new rows demanded three
new brushes and got them.
2026-10-30 08:03:18 -07:00
gramps 2dbc35b285 design: left panel to tokens (surface 3 of the sweep) — 101 colours gone
The layers/properties panel is the surface the creator looks at most, and it carried the
largest hardcoded palette in the app: 50 label greys, 15 icon blues, and a scatter of
one-off hexes. All of it resolves from tokens now.

Three sites needed a decision rather than a mapping, because the same hex meant different things:
- the + add icon and the chat author name were the old brand red → interactive accent (blue)
- the webcam lock alert is an ERROR, not decoration → error surface + error border
- the ✕ revert affordance was red, which the standard reserves for live/record → muted. ✓ stays
  a positive confirm, now in the interactive accent rather than a green that exists nowhere else
  in the palette. If green checks are missed, a `positive` token is the way back, not a literal.
- Super Chat gold was #eab308; it is the standard's amber now (game audio + Super Chat only).

Guard list extended: Controls/LeftPanel.xaml can no longer regress to a raw colour.
2026-10-30 08:00:43 -07:00
gramps 8a2cb92d8c docs(handoff): shell converted, left panel is next 2026-10-30 07:56:13 -07:00
gramps 0275e30103 design: shell to tokens — top bar, window, scene strip (surface 2 of the sweep)
- Telemetry readouts (bitrate, FPS, dropped, live duration) now wear JetBrains Mono, so the
  tabular digits stop shifting width as values change — the first place the bundled mono face
  is actually visible.
- Scene tiles: staged = blue (selection), live/program = red. Both were orange, which meant the
  same colour marked two different states and neither was the live colour.
- EDIT toggle: "on" is the interactive accent, not orange. Orange now means warning only.
- Wordmark's two-tone accent, avatar chrome, readout panel, tile borders and the program badge
  all resolve from tokens.

Guard list extended: MainWindow.xaml, Controls/TopBar.xaml, Controls/SceneThumbnailStrip.xaml —
these three can no longer regress to a hardcoded colour.
2026-10-30 07:56:02 -07:00
gramps ffdf3b6fc9 design: wire the sans family into the window; fonts ship as explicit Resource items
The bundled faces were packaged but unused — no style referenced FontFamily.Sans, so the app
still rendered Segoe UI. MainWindow now sets FontFamily from the token, which cascades to every
child (dialogs follow in the sweep).

Font packaging: six explicit <Resource> items instead of the Assets\Fonts\*.ttf glob, so the
guard can name exactly what must ship.

Investigation worth recording: a runtime assertion that the families resolve from
pack://application:,,,/ would have needed an Application instance inside the test host, and
without one WPF resolves the pack authority against the test host — every probe failed,
including the app icon that demonstrably works. The resources ARE embedded (the lowercase
`assets/fonts/inter-regular.ttf` entry is present in ytLive.g.resources); the assertion was
removed rather than faked, and the real confirmation is a launch, which is a visual check.

Guard remains structural: files are real binaries, each is a Resource in the csproj, the theme
uses pack URIs, notices + both OFL texts are present, and no third family sneaks in.
2026-10-30 07:53:37 -07:00
gramps 1defb71568 docs(handoff): design standard landed, token sweep in flight 2026-10-30 07:47:14 -07:00
gramps 4ef84f2a97 design: convert Themes/Controls.xaml to tokens (surface 1 of the sweep)
The theme file that every control inherits is now token-driven: 73 hardcoded colours became
Brush.* references, and the two role changes the standard requires landed here first.

- Red leaves the chrome. The seven #e94560 sites (base button, focus borders, checked box and
  segment backgrounds, the selected-layer accent strip) were all interactive/selection roles,
  so they are blue now. Red stays for live/record only.
- The slider was green: its fill and gloss knob are blue now, with the track on
  SurfaceContainerLowest. The old green fill predated the standard.
- The pill toggle was ON = green, OFF = red — a settings control using the live colour. It is
  now OFF = neutral surface, ON = blue.
- Added scrim-soft/scrim-strong tokens (#22FFFFFF/#26FFFFFF) for the existing white washes, and
  Color.* resources for the two gradients (a GradientStop needs a Color, not a Brush).

Guard: DesignTokensTests gains Converted_surfaces_carry_no_raw_colour — a declared list of
converted files, so each sweep commit is guarded as it lands instead of waiting for one global
grep at the end. Pure white/black highlights are the only allowed literals.

No layout, spacing or behaviour change: this unit is colour only.
2026-10-30 07:47:00 -07:00
gramps 84c335a929 design: bundle Inter + JetBrains Mono (OFL-1.1) as the standard typefaces
The DESIGN.md type standard needs the fonts actually present — the machine's installed faces
are not a design system. Four Inter weights (400/500/600/700) and two JetBrains Mono weights
(500/600) ship as WPF resources from Assets/Fonts; the token families resolve them with the
`/Assets/Fonts/#Family` pack form, so nothing depends on an install or a network fetch.

Licensing (recorded in docs/ai/product.md): OFL-1.1 is a free licence but is NOT GPL-compatible
in the linking sense. These are separate .ttf files loaded through WPF's font stack — an
aggregate, not linked code — so they coexist with the GPL-3.0 app. Obligations carried:
copyright line in THIRD-PARTY-NOTICES.txt (new items 13-14), the OFL text beside the files
(Assets/Fonts/OFL-*.txt), no modification, no reserved-name reuse.

Guard: BundledFontsTests — files exist and are real binaries, the csproj ships them, the theme
uses pack URIs (a bare family name would silently fall back), the notice and both OFL texts are
present, and no third font family sneaks in.

Pending visual confirmation: the first launch should render panel text in Inter and telemetry
in JetBrains Mono.
2026-10-30 07:45:19 -07:00
gramps f2bf577f34 design: Themes/Tokens.xaml — one place a raw value may live, guarded by DESIGN.md
Tokens.xaml carries every colour, type size, spacing step, radius and motion duration from
DESIGN.md; App.xaml merges it BEFORE Controls.xaml so styles can resolve tokens.

Guard (DesignTokensTests, 4 facts): the test PARSES DESIGN.md — the colour tables under
"## Colour" are the contract — and fails if a documented token has no brush, if the hex
disagrees, if the meter bands drift, if Tokens ever merges after Controls, or if the retired
brand red #e94560 comes back as a token. Adding a colour to the standard now requires
implementing it, and changing the theme without the document is caught.

Inter/JetBrains Mono are declared as token families with system fallbacks; the bundled font
assets are the next unit.

No surface consumes tokens yet — this unit is the foundation only.
2026-10-30 07:43:17 -07:00
gramps 565b8dbd6f docs(design): DESIGN.md becomes the visual standard (tokens merged, controls rejected)
Merges the 2026-10-29 Stitch export into the existing 2026-10-26 token sheet and promotes the
file from "redesign exploration" to the normative standard:

- type scale (32→10px), spacing, radii and elevation levels — the three scales the old
  DESIGN.md listed as missing
- meter thresholds: cyan ≤ -12 dB, amber -12..-3, red > -3
- interaction rules: focus ring, hover step, 150ms/90ms easing, no springs

Four export tokens rejected as Material-3 warm-seed artifacts (recorded with reasons):
tertiary #4cd7f6 (kept #38bdf8), outline #ab8986, outline-variant #5b403e,
on-surface-variant #e4beba (kept slate #94a3b8).

Product rulings recorded: #e94560 retired as the app's general accent (blue takes every
interactive role, red is live/record only, wordmark artwork untouched); error gets its own
surface (#93000a) so it cannot be mistaken for on-air red.

The export's dock, four bays, page-nav sidebar, CPU/GPU/NVENC readouts and acrylic blur are
explicitly marked exploratory, not spec — the design principle is a simpler surface, not
hardware cosplay. On-stream output styling stays outside this theme.

Falsified facts fixed in the same commit: product.md brand section now points at DESIGN.md for UI
tokens (MARCOM.md stays the marketing palette), and ui-surface.md no longer calls DESIGN.md an
unreconciled exploration.

Docs-only unit: scope-check run, no build.
2026-10-30 07:41:43 -07:00
gramps 5a102a4a37 docs: decisions log, queue trim, HANDOFF rewrite — the map stops being a treadmill
TASKS.md 467 → 66 lines: one line per live item, detail stays in TASKS/
task-*.md. The old catalogue (63 rows of paragraph status prose plus the
historical narration) is preserved verbatim as TASKS/archive.md — a
2026-10-29 snapshot, not the live queue. The "out of product" list and every
retired decision moved to docs/ai/decisions.md with cause of death.

HANDOFF.md 440 → 40 lines, and it is now OVERWRITTEN, not appended: branch,
what's in flight, next action, live landmines, pending creator checks. History
belongs to git log; the previous handoff is reachable at ee3c592:HANDOFF.md.

docs/ai/decisions.md (new) collects what no longer governs: the paid tier and
its pricing, Polar, Store IAP, the Store route and why it died, the deleted
Velopack updater, signing economics, the retired monetization ideas, and the
permanent out-of-product list.

Facts that lived in the old ai.md "Current limitations" section were re-homed
rather than dropped with the router rewrite: OAuth/DPAPI/session behaviour →
docs/ai/youtube-api.md; paths, persistence and the Google project number →
docs/ai/environment.md.

verify.sh caps now cover all four always-read files (AGENTS 210, ai 100,
HANDOFF 40, TASKS 120).

Docs-only unit: scope-check run, no build.
2026-10-29 11:10:51 -07:00
gramps c9ac348976 docs(ai): ai.md becomes a router; invariants move to AGENTS.md; budgets gated
ai.md 228 → 64 lines. It now carries only: what the product is, the build
commands, the architecture table, a "where the truth lives" table, and a
pointer to HANDOFF. No narrative, no status, no duplicated invariants.

AGENTS.md (195 lines) is now the single always-read file:
- the 20 invariants that break output, wipe user data, or ship a legal/trust
  breach — one line each, pointing at the doc that explains it (they replace
  110 lines of invariant stubs that duplicated docs/ai/*.md verbatim)
- Response style + No-Fluff Mode moved in from ai.md (rules belong in the rules file)
- onboarding is now: HANDOFF → ai.md router → the ONE docs/ai file → dir index;
  TASKS.md is read when the question is "what's next", not to write code
- memory rides with the change but is no longer a unit of its own; HANDOFF is
  ≤40 lines and overwritten, never appended

Anti-rebloat gate: scripts/verify.sh gains step 1/4, a doc-budget check
(AGENTS ≤210, ai ≤100; HANDOFF/TASKS caps land with the queue trim). A commit
that pads a capped file instead of moving detail fails the gate.

schema.md and docs/index.md corrected for the new roles and reading order.
Docs-only unit: scope-check run, no build.
2026-10-29 11:08:07 -07:00
gramps a311c8feb1 docs(ai): partition ai.md into five subsystem files (verbatim moves)
ai.md 1,308 → 228 lines. No prose rewritten: every block was moved as-is so
nothing could be lost in translation, then replaced by a pointer.

New files:
- docs/ai/environment.md — LAN/SSH + Gitea push recipe, Windows dotnet
  build/run, test-suite map, hermetic real-MainWindow test rule
- docs/ai/product.md — brand/naming, AI transparency, GPL licensing
  prohibitions, free-forever monetization, auth/account assumptions,
  packaging & distribution
- docs/ai/youtube-api.md — go-live ordering, the broadcast-binding fix, the
  private-only lock, health parsing, end-stream pre-flight, Test mode,
  chat insert shape
- docs/ai/overlays.md — social bar, native alert box (animations, clip,
  ticker), chat box conventions
- docs/ai/ui-surface.md — control surface, scene strip, preview rail,
  hotkeys, broadcast drawer, toasts, transitions, brand logo, launch
  geometry, theme/properties conventions

ai.md keeps the response/derivation rules, the architecture table, the four
existing invariant stubs, and one pointer per moved subsystem.
docs/ai/index.md routes every file with a "read when" column.

References falsified by the move were fixed in the same commit: AGENTS.md
build note → environment.md → Run, and the "Related" footers in
frame-pipeline.md / output.md / capture.md now point at the owning files.

Docs-only unit: scope-check run, no build (no source touched).
2026-10-29 11:05:53 -07:00
gramps ee3c59210d Settings: group preferences and open from gear
Group App Settings by purpose and let right-click on the gear open settings directly while preserving the left-click menu.

WPF event reference: https://learn.microsoft.com/en-us/dotnet/api/system.windows.uielement.previewmouserightbuttondown?view=windowsdesktop-10.0
2026-10-28 17:57:02 -07:00
gramps 769c616706 Preview: promote selected layer for hit testing
Temporarily raise the selected preview item and prioritize it in the custom hit test without changing scene order. Refresh integration-test expectations for Brand logo layers and explicit thumbnail assets.

WPF references: https://learn.microsoft.com/en-us/dotnet/api/system.windows.controls.panel.zindex?view=windowsdesktop-10.0 https://learn.microsoft.com/en-us/dotnet/desktop/wpf/graphics-multimedia/hit-testing-in-the-visual-layer
2026-10-28 13:09:15 -07:00
gramps 7da287244b Transitions in edit mode and on output: program transitions run while recording or streaming; the preview animates the outgoing picture (fade/move fade, wipe shrinks) in every mode
Test: PreviewTransitionTests (with the transition, wipe, and brand logo tests).
2026-10-27 10:20:53 -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 b86b6bb601 Transitions: Wipe (left-to-right reveal), added to the App Settings picker
Test: WipeTransitionTests. Not pushed.
2026-10-27 09:12:59 -07:00
gramps f67a263ccd App Settings: transition type and length picker
Cut/Fade/Move and a 100-2000 ms length, bound to the view model; changes save with the layout.
Test: TransitionPickerTests. Not pushed.
2026-10-27 09:12:37 -07:00
gramps 90cd039d98 Transitions: fade starts from the outgoing picture (was an instant cut)
The transition started with no from-frame, so BlendFrame returned the new scene at once.
Start now requests a capture and the frame pump supplies its last output on the next tick.
Test: TransitionFadeTests. Docs: ai.md, TASKS.md.

Not pushed.
2026-10-27 08:39:49 -07:00
gramps 7ae30d4cfc Right rail, top bar, bottom bar and chat border: Socials slide-out drawer, tab icons and colors, EDIT toggle, stream readouts moved to the top-left cell, webcam border restored, chat border controls, 30fps vertical tier
- Socials: the dialog is a right-rail drawer (same pattern as Stream Info/YPP/TEST); the live-screen button is removed
- Right rail: Stream Info (was Stream Settings) and YPP show the YouTube logo; Socials and TEST get icons; tab colors and label colors per spec
- Top bar: EDIT toggle beside REC (orange when on, blue when off); going live on YouTube turns it off
- Stream readouts (bitrate, FPS, dropped n (x.y%), Live hh:mm:ss, health) and the resolution dropdown moved to the top-left cell; bottom bar emptied at the same height
- Webcam border drawing restored (it was removed with the CRT bezel change)
- YouTube chat layer: border color, thickness, and opacity controls
- Vertical 1080p30 tier added

Not related to the fps gate yet; no gating is enforced.
2026-10-26 18:24:09 -07:00
gramps a8814b42d0 Top bar and scene strip: EDIT toggle beside REC (orange on, blue off); fixed-size centered mini screens; fixed 360px left column so the empty cell matches the layers panel
- TopBar: EDIT ToggleButton, spaced from the REC/ON-AIR segments
- MainViewModel: explicit EditingActive drives IsEditMode; YouTube go-live turns edit off, stop turns it on
- SceneThumbnailStrip: tiles fixed 196x130 and centered; dark background fills the column so its edge does not move on resize; strip row fixed 154px
- MainWindow: left column fixed at 360px; WIDTHS diagnostic log on resize

Not pushed.
2026-10-26 14:26:23 -07:00
388 changed files with 29350 additions and 5914 deletions
+5 -1
View File
@@ -20,4 +20,8 @@ MARCOM.md
CREDENTIALS.md
.aider*
ffmpeg/
BuildCounter.txt
BuildCounter.txt # unused since 2026-11-01 (the build stamp is the git commit count)
# Steamworks runtime (steam_api64.dll, 312 KB) — extracted from the SDK zip, never committed;
# scripts/steam-sdk.sh documents which file and why. Same rule as ffmpeg/: the pin is the audit trail.
steam/
-54
View File
@@ -1,54 +0,0 @@
# LlamaCasty — copyright, license, and additional terms
Copyright (C) 2026 Gramps — https://llamacasty.com
LlamaCasty is free software: you can redistribute it and/or modify it under the terms of the
**GNU General Public License as published by the Free Software Foundation, either version 3 of
the License, or (at your option) any later version** (GPL-3.0-or-later), together with the
additional terms below, which section 7 of that license permits.
The full license text is in [`LICENSE`](LICENSE).
LlamaCasty is distributed in the hope that it will be useful, but **WITHOUT ANY WARRANTY**;
without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
Third-party components bundled with LlamaCasty keep their own licenses — see
[`THIRD-PARTY-NOTICES.txt`](THIRD-PARTY-NOTICES.txt).
---
## Additional terms (GPL-3.0 section 7)
1. **Attribution — section 7(b).** Every conveyed copy of this program, modified or not, must keep
the copyright notice above and must show, in its About screen (or the equivalent place in its
user interface), the notice:
> Based on LlamaCasty by Gramps — https://llamacasty.com
2. **Modified versions — section 7(c).** A modified version must be clearly marked as modified,
must use a name other than "LlamaCasty", and must not be represented as the original LlamaCasty
or as made or endorsed by Gramps.
3. **Trademarks — section 7(e).** This license grants no right to use the LlamaCasty name, the
llama logo, or any other LlamaCasty brand mark, except for the factual attribution required by
term 1.
## Brand artwork — not licensed under the GPL
The following files are Copyright (C) 2026 Gramps, **all rights reserved**, and are **not**
covered by GPL-3.0:
- `Assets/llamacasty-icon.png`
- `Assets/llamacasty-icon.ico`
- `Assets/llamacasty-logo.jpg`
Unmodified copies of LlamaCasty may be redistributed with these files included. Modified versions
must replace them with their own artwork. No other use — including on merchandise — is permitted
without written permission.
## Placeholder artwork — licensed with the code
The scene background images (`Assets/*-background.jpg`) and the default alert video
(`Assets/alert-default.mp4`) are AI-generated placeholders, there so the app works the moment it
opens. Creators are expected to replace them with their own. They are distributed with the code
under GPL-3.0-or-later and carry none of the restrictions above.
+106 -50
View File
@@ -1,7 +1,8 @@
# AGENTS — how to work in this repo
This file is auto-read at the start of every session. The project keeps its
memory as a linked map of markdown files — read it before doing anything.
Auto-read at the start of every session: the rules, the invariants that break the product, and
the map to everything else. `ai.md` is the router, `docs/ai/*.md` is the subsystem truth,
`HANDOFF.md` is current state.
## ⚠️ CRITICAL: The Good Dog Rule
@@ -16,9 +17,8 @@ When the user asks for changes that would involve multiple integration tests:
> *A good dog learns one trick perfectly before learning the next.*
**No feature branches pre-1.0 (decided 2026-08-24):** solo dev, pre-1.0 — all
work lands directly on `main`, committed and pushed per work unit. No
`taskNN-*` branches, no PRs. The scope discipline above is about test focus,
not git mechanics.
work lands directly on `main`, committed per work unit. No `taskNN-*` branches, no PRs. The scope
discipline above is about test focus, not git mechanics.
**Commit / push policy (2026-08-30, user directive):** commit every work unit —
committing is expected and never needs extra permission. Pushing is different:
@@ -27,48 +27,53 @@ that means commits land locally every partial; `git push` happens at the
session's agreed checkpoint(s), not per-commit. When a milestone warrants a
push the user will say so (or say "push it").
MUST read the architecture guide before planning or modifying the codebase:
[`ai.md`](ai.md) (+ [`schema.md`](schema.md) for the memory-map conventions).
## Onboarding (in order — and no further)
MUST read the coding guide before writing tests or code: the [Working
rules](#working-rules) in this file — this repo has no `CODING.md`;
conventions live here and in `ai.md`.
1. [`HANDOFF.md`](HANDOFF.md) — current state: branch, what's in flight, next action, landmines.
**Trust it**; no branch hunting, no file-scanning to re-derive what it already says.
2. [`ai.md`](ai.md) — the router: what the product is, the architecture table, and where each
subsystem's truth lives.
3. **The one** [`docs/ai/`](docs/ai/index.md) file that matches your task — not all of them.
The routing table at the top of `ai.md` picks it.
4. `<dir>/index.md` — the index for any directory you're about to touch.
## Onboarding (in order)
`TASKS.md` is the queue. Read it when the user asks what's next or status is part of the
question — it is not required reading to write code.
1. [`schema.md`](schema.md) — the memory-map conventions (what lives where, how to keep it true).
2. [`ai.md`](ai.md) — the AI guide: architecture, patterns, decisions, current state.
3. [`TASKS.md`](TASKS.md) — the task catalog (index + queue state); open items and API research facts are in [`TASKS/`](TASKS/).
4. [`HANDOFF.md`](HANDOFF.md) — current operational state: what's in flight, landmines, next step.
5. `<dir>/index.md` — the index for any directory you're about to touch.
**Why it is this short:** the mandatory read used to be ~56k tokens of map, so sessions paid it
and then skimmed — which is exactly how stale facts survive. Read the one subsystem you touch.
**If `HANDOFF.md` exists, trust it as current state** — no `fsck`, no branch
hunting, no file-scanning to re-derive what it already states, unless it points
at a problem.
## Reading budget
Enforced by `scripts/verify.sh`; a commit that pads instead of replacing fails the gate.
| File | Cap | Role |
|---|---|---|
| `AGENTS.md` | 210 lines | rules + invariants, always read |
| `ai.md` | 100 lines | router only — no narrative, no status |
| `HANDOFF.md` | 40 lines | current state, overwritten every time |
| `TASKS.md` | 120 lines | the active queue; done work moves to `TASKS/archive.md` |
If a fact does not fit, it belongs in `docs/ai/<subsystem>.md`, `MyMistakes.md`, or `git log`.
## Working rules
- **Never work without the map.** If the map contradicts the code, the code wins
and the map gets fixed in the same change (stale facts are corrected, not appended).
- **Every feature change ships with its memory update in the SAME commit:**
`ai.md` for architecture/patterns, `TASKS.md` for status, index files when
layout changes. No code commit without its docs — a follow-up "docs backfill"
commit is a broken rule, not a style.
- **Rewrite `HANDOFF.md` at session end, compaction, or any interruption.**
Never end a session with uncommitted work unrecorded — the handoff names the
branch, the dirty files, and why it stopped.
- **The first time a fact costs a hunt (secrets path, DB path, port, recovery
source), record it** in `ai.md`/indexes/`HANDOFF.md` so the next session never
re-hunts it.
- **Derived-solution rule (record once, then grep):** the moment you work out a
reusable solution — a recipe, workaround, or how-to (e.g. how to shrink and
re-encode an image for the README) — write it into `MyMistakes.md` **in the same
session**, before you finish the task. And always **grep `MyMistakes.md` first**
when you hit a "I've done this before but have to figure it out again" wall. A
solution recorded once ends the re-derivation loop; an un-recorded solution is a
guarantee you will re-derive it and make the user sit through it again. This is
the fix for the image-shrink incident (2026-08-29): the recipe was never written
down, so it had to be worked out from scratch a second time.
- **Never work without the map.** If the map contradicts the code, the code wins and the facts
your change **falsifies** are corrected in the same commit (stale facts are corrected, not
appended). Cosmetic drift — a stale task number, a heading — may wait for a sweep; it is not a
reason to stall a unit.
- **Memory rides with the change, not as a unit of its own.** Correct what the change
falsifies in the same commit; a broad restatement can be batched at a milestone.
- **`HANDOFF.md` is ≤40 lines and is overwritten, never appended:** branch, in flight, dirty
files, next action, new landmines. History belongs to `git log`.
- **The first time a fact costs a hunt, record it where it will be found:** paths / ports /
secrets / build commands → `docs/ai/environment.md`; subsystem traps → that subsystem's
`docs/ai` file; one-off recipes → `MyMistakes.md` → Recipes registry.
- **Derived-solution rule (record once, then grep):** when you work out a reusable solution — a
recipe, workaround, or how-to (e.g. how to shrink and re-encode an image for the README) —
write it into `MyMistakes.md` **in the same session**, and **grep `MyMistakes.md` first** on
any "I've done this before but have to figure it out again" wall. An un-recorded solution
guarantees the user hears it derived again (the image-shrink incident, 2026-08-29).
- **Follow existing conventions** — MVVM, `RelayCommand` for actions,
`ViewModelBase.SetProperty<T>()`, all styles in `Themes/Controls.xaml`
(merged once in `App.xaml`; never duplicate per-window).
@@ -83,13 +88,64 @@ at a problem.
- **Spin guard (objectively triggered):** a SECOND failed remedy for the same
symptom = STOP. No third guess over the same code. Research the established
answer externally, cite its URL in the commit message AND in `MyMistakes.md`,
then code it. You do not know everything — repeated failure is a research
trigger, not a persistence trigger.
then code it. Repeated failure is a research trigger, not a persistence trigger.
- **Response style: no default planning template.** Do the work, then report
what changed and what's next. No "Plans & Pitfalls", pros/cons tables, or
step-by-step plans unless the user asks for a plan first (see `ai.md`).
- **No-Fluff Mode is available on request** — unpadded, ruthless review that
argues rather than reassures (see `ai.md`). Optional, never the default.
step-by-step plans unless the user asks for a plan first. A short diff-style summary beats a
proposal document every time.
- **No-Fluff Mode is available on request.** Invoke with "no-fluff mode" (or similar) for
ruthless review instead of reassurance: strip pleasantries, emojis and padding; treat the
user's input as a draft to deconstruct; give unvarnished truth. Occasional and
explicitly-invoked — never the default.
## Invariants that break the product
Violating any of these breaks output, wipes a user's data, or ships a legal/trust breach. Each
line points at the file that explains it.
- **The Background is one locked layer per scene** — index 0, never addable/removable/reorderable;
Live is `DisplayCapture`, every other canonical scene is static art. Never feed a capture row to
the `ActiveBackgroundImage` overlay. → `docs/ai/capture.md`
- **Every global overlay composites on every render path** — pump, preview/thumbnails, scene dump.
A feature wired into one path is silently absent from the output. → `docs/ai/overlays.md`
- **`VideoFrame.Epoch` bumps on every frame from a recycling producer**, or the pump's paste cache
and full-render signature serve stale pixels. → `docs/ai/frame-pipeline.md`
- **Never demote threads inside `Parallel.For`** — `StripPool` owns four `BelowNormal` threads;
strips below 64 rows run inline. → `docs/ai/frame-pipeline.md`
- **Submit never blocks:** the encoder queue is bounded, the newest frame is dropped and counted,
and `StopAsync` flushes the encoder before awaiting the pump. → `docs/ai/frame-pipeline.md`
- **Audio closes before video:** `StopStream` runs `StopLive` (audio EOF) before stopping the pump;
the reverse order stalls on pipe backpressure. → `docs/ai/output.md`
- **rawvideo is paced by arrival, never by a timestamp** — no `-re`, and `nextTick` is never
rebased to wall-now (that turns a hitch into a silent time-lapse). → `docs/ai/output.md`
- **The encoder is probed, never libx264** — NVENC → QSV → AMF → OpenH264; no static or GPL codec
path. → `docs/ai/output.md`
- **REC and ON-AIR are radio-exclusive** — one ffmpeg, one output, in the UI and the VM.
→ `docs/ai/output.md`
- **ffmpeg is bundled and separate** — never downloaded at runtime, never linked into the app; the
pin stays `lgpl-shared`. → `docs/ai/product.md`
- **A modal's falsy result never falls through into its side effect** — Cancel on the recording
save dialog deletes the temp footage. → `docs/ai/output.md`
- **Any test that constructs `MainWindow` must point `LayoutPathOverride` at a temp DB** and clear
it in `finally`, or it overwrites the user's real layout. → `docs/ai/environment.md`
- **The vertical tier is geometry only**, and the Chat camera is rect-locked in the MODEL
(`VerticalShapeLocked`), not just in the UI. → `docs/ai/capture.md`
- **Never override the user's camera choice** — there is deliberately no fallback camera.
→ `docs/ai/capture.md`
- **Chat is rendered, never stored** — no history, no moderation log, no reward payloads; the
Store 11.12 certification answer depends on it. → `docs/ai/product.md`
- **Full trust only** — a packaged build flipped to `appContainer` silently loses recordings.
→ `docs/ai/product.md`
- **A `PopupRoot` has no visual parent** — ancestor walks must fall back to
`LogicalTreeHelper.GetParent`, or a popup row click reads as an outside click.
→ `docs/ai/ui-surface.md`
- **Licensing: no non-CC0 icon art, no dropped `THIRD-PARTY-NOTICES.txt`, no
licence-incompatible dependency, no unvetted ML model.** → `docs/ai/product.md`
- **Force Private only for development/test runs.** The released creator flow offers Private,
Unlisted, or Public, defaults to Private, and allows live visibility changes through
`UpdateBroadcast`. → `docs/ai/youtube-api.md`
- **Read render numbers in Release only** — Debug pixel paths are several× slower.
→ `docs/ai/environment.md`
## Scope Lock
@@ -115,12 +171,12 @@ don't make it. A stable file touched by an unrelated task is a bug, not a featur
### Pre-commit audit
Before every commit, run `scripts/scope-check.sh` with the declared file list.
If any file appears in `git diff` but not in the declared scope, either justify
it or revert the change. The script enforces what the rule demands.
One gate: `./scripts/verify.sh "<file1>" "<file2>" ...` with the declared scope. It runs the doc
budgets, a **clean** build (0 warnings), the test suite, and `scope-check.sh`. Docs-only units run
`scope-check.sh` alone.
```bash
./scripts/scope-check.sh "Models/Scene.cs" "ViewModels/MainViewModel.cs" "MainWindow.xaml"
./scripts/verify.sh "Models/Scene.cs" "ViewModels/MainViewModel.cs" "MainWindow.xaml"
```
## Build
@@ -128,7 +184,7 @@ it or revert the change. The script enforces what the rule demands.
From WSL, ALWAYS use the Windows dotnet host — Linux `dotnet` re-downloads the
`windowsdesktop.app.*` packs over the slow 9p bridge and re-restores twice (WPF
`_wpftmp`), and `--no-restore` right after an interrupted restore produces bogus
`NETSDK1064` errors. See `ai.md` → Run for the exact commands and why.
`NETSDK1064` errors. See `docs/ai/environment.md` → Run for the exact commands and why.
```bash
"/mnt/c/Program Files/dotnet/dotnet.exe" build "C:\Users\gramp\Documents\Code\projects\ytLive\ytLive.csproj"
+16 -13
View File
@@ -1,13 +1,16 @@
<Application x:Class="ytLive.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:ytLive"
StartupUri="MainWindow.xaml">
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="Themes/Controls.xaml"/>
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Application.Resources>
</Application>
<Application x:Class="ytLive.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:ytLive"
StartupUri="MainWindow.xaml">
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<!-- Design tokens first: every style below resolves its colours, sizes and
radii from them (DESIGN.md is the source of truth). -->
<ResourceDictionary Source="Themes/Tokens.xaml"/>
<ResourceDictionary Source="Themes/Controls.xaml"/>
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Application.Resources>
</Application>
+7 -2
View File
@@ -19,12 +19,17 @@ public partial class App : Application
AppDomain.CurrentDomain.UnhandledException += (_, args) =>
AppLog.Write(args.ExceptionObject is Exception ex
? "AppDomain unhandled exception: " + ex
: "AppDomain unhandled exception object: " + args.ExceptionObject);
: "AppDomain unhandled exception object: " + args.ExceptionObject,
AppLogMessageType.Error, AppLogErrorClass.Class1);
DispatcherUnhandledException += (_, args) =>
AppLog.Write(args.Exception, "Dispatcher unhandled exception");
AppLog.Write(args.Exception, "Dispatcher unhandled exception", AppLogErrorClass.Class1);
AppLog.Write("App OnStartup begin");
// TASK 55·2: a crash leaves the same temp clip a clean exit would have deleted.
var sweptClips = ytLive.Services.AlertClipCleanup.Sweep(System.IO.Path.GetTempPath());
if (sweptClips > 0) AppLog.Write($"Temp alert clips removed at startup: {sweptClips}");
// Attribution (2026-09-04): every take must be traceable to the exact binary
// that produced it — the wordmark shows the release counter (BuildStamp.Release);
// the per-build GUID is forensic only and lands here.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+92
View File
@@ -0,0 +1,92 @@
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION AND CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 423 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 422 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 MiB

After

Width:  |  Height:  |  Size: 2.5 MiB

+1
View File
@@ -0,0 +1 @@
1122
+10 -10
View File
@@ -1,10 +1,10 @@
<Window x:Class="ytLive.CameraPickerDialog"
<Window x:Class="ytLive.CameraPickerDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Add Webcam" Height="340" Width="460"
Icon="/Assets/llamacasty-icon.png"
WindowStartupLocation="CenterOwner" ResizeMode="NoResize"
ShowInTaskbar="False" Background="#1a1a2e">
ShowInTaskbar="False" Background="{StaticResource Brush.Background}">
<Window.Resources>
<ResourceDictionary>
@@ -21,10 +21,10 @@
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="#1c2a52"/>
<Setter TargetName="Bd" Property="Background" Value="{StaticResource Brush.SurfaceContainerHigh}"/>
</Trigger>
<Trigger Property="IsSelected" Value="True">
<Setter TargetName="Bd" Property="Background" Value="#2a2450"/>
<Setter TargetName="Bd" Property="Background" Value="{StaticResource Brush.SurfaceContainerHigh}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
@@ -43,10 +43,10 @@
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Text="Add Webcam" FontSize="18" FontWeight="Bold"
Foreground="#e0e0e0" Margin="0,0,0,14"/>
Foreground="{StaticResource Brush.OnSurface}" Margin="0,0,0,14"/>
<TextBlock Grid.Row="1" Text="Pick the camera to add to your stream."
Foreground="#a0a0b0" FontSize="13" TextWrapping="Wrap" Margin="0,0,0,12"/>
Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="13" TextWrapping="Wrap" Margin="0,0,0,12"/>
<Grid Grid.Row="2">
<ListBox Background="Transparent" BorderThickness="0"
@@ -58,7 +58,7 @@
<DataTemplate>
<StackPanel Orientation="Horizontal">
<TextBlock Text="📷" FontSize="15" VerticalAlignment="Center" Margin="0,0,10,0"/>
<TextBlock Text="{Binding Name}" Foreground="#e0e0e0" FontSize="13"
<TextBlock Text="{Binding Name}" Foreground="{StaticResource Brush.OnSurface}" FontSize="13"
VerticalAlignment="Center" TextTrimming="CharacterEllipsis"/>
</StackPanel>
</DataTemplate>
@@ -67,15 +67,15 @@
<StackPanel VerticalAlignment="Center" HorizontalAlignment="Center"
Visibility="{Binding IsLoading, Converter={StaticResource BoolToVis}}">
<TextBlock Text="Searching for cameras…" Foreground="#a0a0b0" FontSize="13"/>
<TextBlock Text="Searching for cameras…" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="13"/>
</StackPanel>
<StackPanel VerticalAlignment="Center"
Visibility="{Binding NoCameras, Converter={StaticResource BoolToVis}}">
<TextBlock Text="No cameras found" Foreground="#e0e0e0" FontSize="15"
<TextBlock Text="No cameras found" Foreground="{StaticResource Brush.OnSurface}" FontSize="15"
FontWeight="SemiBold" HorizontalAlignment="Center"/>
<TextBlock Text="Check that a webcam is connected and that Windows camera access is enabled."
Foreground="#a0a0b0" FontSize="13" TextWrapping="Wrap" MaxWidth="360"
Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="13" TextWrapping="Wrap" MaxWidth="360"
TextAlignment="Center" HorizontalAlignment="Center" Margin="0,8,0,0"/>
</StackPanel>
</Grid>
+303
View File
@@ -0,0 +1,303 @@
<UserControl x:Class="ytLive.Controls.AudioDock"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="clr-namespace:ytLive.Controls">
<!-- The sound bars. Replaces the empty bottom-bar spacer; collapses to the telemetry strip
on short windows (AudioDockLayout decides the height, not this file). -->
<Border x:Name="DockRoot" Background="{StaticResource Brush.SurfaceContainer}"
BorderBrush="{StaticResource Brush.Outline}" BorderThickness="0,1,0,0">
<Grid>
<Grid.RowDefinitions>
<!-- Master FIRST: during a live session attention is on the preview, so the
master strip sits against it and the bays hang below (creator, 2026-10-30). -->
<RowDefinition Height="44"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<!-- Four bays (creator ruling 2026-11-01): MIC | GAME | TRAX | LIP-SYNC. The three
sound bays share equal width so their meters stay the same relative size; lip-sync
is narrow because its function is one slider and three presets. -->
<Grid x:Name="Bays" Grid.Row="1" Margin="8,4,8,6">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="0.45*"/>
</Grid.ColumnDefinitions>
<!-- MIC -->
<Border Grid.Column="0" Background="{StaticResource Brush.SurfaceContainerLow}" CornerRadius="{StaticResource Radius.Sm}" Margin="0,0,4,0" Padding="10,6">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Grid Grid.Row="0" Cursor="Hand" ToolTip="Change microphone" MouseLeftButtonUp="MicLabel_MouseLeftButtonUp">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Border Background="{StaticResource Brush.SurfaceContainerHighest}" CornerRadius="{StaticResource Radius.Sm}" Padding="5,1">
<TextBlock Text="MIC 1" FontSize="9" FontWeight="Bold" Foreground="{StaticResource Brush.OnSurfaceVariant}"/>
</Border>
<TextBlock Grid.Column="1" Margin="6,0,0,0" FontSize="11" VerticalAlignment="Center"
TextTrimming="CharacterEllipsis" Text="{Binding MicSourceName, TargetNullValue='No microphone'}"
Foreground="{StaticResource Brush.OnSurfaceVariant}"/>
</Grid>
<controls:SegmentMeter Grid.Row="1" Height="10" Margin="0,6,0,4" Orientation="Horizontal"
Level="{Binding MicMeterFraction}" Segments="24"/>
<DockPanel Grid.Row="2" Margin="0,6,0,0">
<Button DockPanel.Dock="Left" Width="24" Height="20" Margin="0,0,6,0"
Style="{StaticResource YtButtonSecondary}" Padding="0"
Command="{Binding ToggleMicMuteCommand}" ToolTip="{Binding MicMuteText}">
<Ellipse Width="9" Height="9" Fill="{Binding MicStatusBrush}"/>
</Button>
<TextBlock DockPanel.Dock="Right" Width="56" TextAlignment="Right" FontSize="10"
FontFamily="{StaticResource FontFamily.Mono}" VerticalAlignment="Center"
Foreground="{StaticResource Brush.OnSurfaceVariant}" Text="{Binding MicVolumeDbLabel}"/>
<Grid VerticalAlignment="Center" ToolTip="0 dB is the ceiling — full input level, no boost beyond it">
<Slider Minimum="-60" Maximum="0" SmallChange="0.5" LargeChange="3"
Value="{Binding MicVolumeDb, Mode=TwoWay}" VerticalAlignment="Center"/>
</Grid>
</DockPanel>
</Grid>
</Border>
<!-- GAME / PC, then TRAX beneath it: meter -> desktop volume -> TRAX line -->
<Border Grid.Column="1" Background="{StaticResource Brush.SurfaceContainerLow}" CornerRadius="{StaticResource Radius.Sm}" Margin="4,0" Padding="10,6">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Grid Grid.Row="0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<Border Background="{StaticResource Brush.SurfaceContainerHighest}" CornerRadius="{StaticResource Radius.Sm}" Padding="5,1">
<TextBlock Text="GAME / PC" FontSize="9" FontWeight="Bold" Foreground="{StaticResource Brush.OnSurfaceVariant}"/>
</Border>
<TextBlock Grid.Column="1" Margin="6,0,0,0" FontSize="11" VerticalAlignment="Center"
TextTrimming="CharacterEllipsis" Foreground="{StaticResource Brush.OnSurfaceVariant}"
Text="Desktop loopback · incl. TRAX"
ToolTip="Everything playing on the desktop — including TRAX, which rides the same device."/>
<Border Grid.Column="2" Background="{StaticResource Brush.SurfaceContainerHighest}"
CornerRadius="{StaticResource Radius.Sm}" Padding="5,1"
Visibility="{Binding IsGameDucking, Converter={StaticResource BoolToVis}}">
<TextBlock FontSize="9" FontWeight="Bold" FontFamily="{StaticResource FontFamily.Mono}"
Foreground="{StaticResource Brush.Secondary}" Text="{Binding GameDuckLabel}"/>
</Border>
</Grid>
<controls:SegmentMeter Grid.Row="1" Height="10" Margin="0,6,0,4" Orientation="Horizontal"
Level="{Binding GameMeterFraction}" Segments="24"/>
<!-- Desktop volume, directly beneath its meter. -->
<DockPanel Grid.Row="2" VerticalAlignment="Center">
<Button DockPanel.Dock="Left" Width="24" Height="20" Margin="0,0,6,0"
Style="{StaticResource YtButtonSecondary}" Padding="0"
Command="{Binding ToggleGameMuteCommand}" ToolTip="Mute desktop audio">
<Ellipse Width="9" Height="9" Fill="{Binding GameMeterBrush}"/>
</Button>
<TextBlock DockPanel.Dock="Right" Width="56" TextAlignment="Right" FontSize="10"
FontFamily="{StaticResource FontFamily.Mono}" VerticalAlignment="Center"
Foreground="{StaticResource Brush.OnSurfaceVariant}" Text="{Binding GameVolumeDbLabel}"/>
<Grid VerticalAlignment="Center" ToolTip="0 dB is the ceiling — full input level, no boost beyond it">
<Slider Minimum="-60" Maximum="0" SmallChange="0.5" LargeChange="3"
Value="{Binding GameVolumeDb, Mode=TwoWay}" VerticalAlignment="Center"/>
</Grid>
</DockPanel>
</Grid>
</Border>
<!-- TRAX: its own bay and its own meter now (creator ruling 2026-11-01). It used to
be a strip inside the game bay because the desktop ladder "already carried it";
that hid whether the music was actually playing. -->
<Border Grid.Column="2" Background="{StaticResource Brush.SurfaceContainerLow}" CornerRadius="{StaticResource Radius.Sm}" Margin="4,0" Padding="10,6">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Grid Grid.Row="0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Border Background="{StaticResource Brush.SurfaceContainerHighest}" CornerRadius="{StaticResource Radius.Sm}" Padding="5,1">
<TextBlock Text="TRAX" FontSize="9" FontWeight="Bold" Foreground="{StaticResource Brush.OnSurfaceVariant}"/>
</Border>
<TextBlock Grid.Column="1" Margin="6,0,0,0" FontSize="11" VerticalAlignment="Center"
TextTrimming="CharacterEllipsis" Foreground="{StaticResource Brush.OnSurfaceVariant}"
Text="{Binding TraxTrackLabel}" Cursor="Hand"
MouseLeftButtonUp="TraxLabel_MouseLeftButtonUp"
MouseRightButtonUp="TraxLabel_MouseRightButtonUp"/>
</Grid>
<controls:SegmentMeter Grid.Row="1" Height="10" Margin="0,6,0,4" Orientation="Horizontal"
Level="{Binding TraxMeterFraction}" Segments="24"/>
<DockPanel Grid.Row="2" VerticalAlignment="Center">
<Button DockPanel.Dock="Left" Width="24" Height="20" Margin="0,0,6,0" Padding="0"
Style="{StaticResource YtButtonSecondary}" Click="TraxPlayPause_Click"
ToolTip="{Binding TraxToolTip}">
<TextBlock FontSize="9" Foreground="{StaticResource Brush.OnSurface}">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Text" Value="▶"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsMusicPlaying}" Value="True">
<Setter Property="Text" Value="❚❚"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</Button>
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal" VerticalAlignment="Center">
<Button Content="−" Width="18" Height="16" Padding="0" FontSize="11"
Style="{StaticResource YtButtonSecondary}" Click="TraxStepDown_Click"
IsEnabled="{Binding CanLowerTrax}" ToolTip="5 dB quieter"/>
<TextBlock Width="52" TextAlignment="Center" FontSize="10" VerticalAlignment="Center"
FontFamily="{StaticResource FontFamily.Mono}"
Foreground="{StaticResource Brush.OnSurfaceVariant}"
Text="{Binding TraxVolumeDbLabel}"/>
<Button Content="+" Width="18" Height="16" Padding="0" FontSize="11"
Style="{StaticResource YtButtonSecondary}" Click="TraxStepUp_Click"
IsEnabled="{Binding CanRaiseTrax}" ToolTip="5 dB louder"/>
</StackPanel>
</DockPanel>
</Grid>
</Border>
<!-- LIP-SYNC (narrow: one slider, three presets) -->
<Border Grid.Column="3" Background="{StaticResource Brush.SurfaceContainerLow}" CornerRadius="{StaticResource Radius.Sm}" Margin="4,0,0,0" Padding="10,6">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- Row alignment across the dock (creator ruling 2026-11-01): every bay is
header / meter / controls, so the ladders sit on one horizontal line and the
volume bars on another. Lip-sync has no ladder, so it holds an empty row the
height of one (10px meter + 6/4 margins = 20) to keep its slider level with
the other three. -->
<Grid Grid.Row="0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Border Background="{StaticResource Brush.SurfaceContainerHighest}" CornerRadius="{StaticResource Radius.Sm}" Padding="5,1">
<TextBlock Text="LIP-SYNC" FontSize="9" FontWeight="Bold" Foreground="{StaticResource Brush.OnSurfaceVariant}"/>
</Border>
<TextBlock Grid.Column="1" Margin="6,0,0,0" FontSize="10" VerticalAlignment="Center"
FontFamily="{StaticResource FontFamily.Mono}" TextAlignment="Right"
Foreground="{StaticResource Brush.OnSurfaceVariant}" Text="{Binding SyncOffsetLabel}"/>
</Grid>
<Border Grid.Row="1" Height="20"/>
<StackPanel Grid.Row="2">
<Slider Minimum="-200" Maximum="200" SmallChange="5" LargeChange="25"
TickFrequency="5" IsSnapToTickEnabled="True"
Value="{Binding AudioSyncOffsetMs, Mode=TwoWay}" VerticalAlignment="Center"/>
<StackPanel Orientation="Horizontal" Margin="0,2,0,0" HorizontalAlignment="Right">
<Button Content="0" Width="26" Height="16" FontSize="9" Padding="0" Margin="0,0,4,0"
Style="{StaticResource YtButtonSecondary}" Click="SyncPreset_Click" Tag="0"/>
<Button Content="+50" Width="30" Height="16" FontSize="9" Padding="0" Margin="0,0,4,0"
Style="{StaticResource YtButtonSecondary}" Click="SyncPreset_Click" Tag="50"/>
<Button Content="+100" Width="34" Height="16" FontSize="9" Padding="0"
Style="{StaticResource YtButtonSecondary}" Click="SyncPreset_Click" Tag="100"/>
</StackPanel>
<!-- Microphone-only delay: the voice waits for the camera's picture. The
whole-mix slider above would drag desktop and alert audio with it. -->
<Grid Margin="0,5,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Border Background="{StaticResource Brush.SurfaceContainerHighest}" CornerRadius="{StaticResource Radius.Sm}" Padding="5,1">
<TextBlock Text="MIC" FontSize="9" FontWeight="Bold" Foreground="{StaticResource Brush.OnSurfaceVariant}"/>
</Border>
<TextBlock Grid.Column="1" Margin="6,0,0,0" FontSize="10" VerticalAlignment="Center"
FontFamily="{StaticResource FontFamily.Mono}" TextAlignment="Right"
Foreground="{StaticResource Brush.OnSurfaceVariant}" Text="{Binding MicSyncOffsetLabel}"/>
</Grid>
<Slider Minimum="0" Maximum="500" SmallChange="5" LargeChange="25"
TickFrequency="5" IsSnapToTickEnabled="True"
Value="{Binding MicSyncOffsetMs, Mode=TwoWay}" VerticalAlignment="Center"/>
<StackPanel Orientation="Horizontal" Margin="0,2,0,0" HorizontalAlignment="Right">
<Button Content="off" Width="26" Height="16" FontSize="9" Padding="0" Margin="0,0,4,0"
Style="{StaticResource YtButtonSecondary}" Click="MicSyncPreset_Click" Tag="0"/>
<Button Content="175" Width="30" Height="16" FontSize="9" Padding="0" Margin="0,0,4,0"
Style="{StaticResource YtButtonSecondary}" Click="MicSyncPreset_Click" Tag="175"/>
<Button Content="350" Width="30" Height="16" FontSize="9" Padding="0"
Style="{StaticResource YtButtonSecondary}" Click="MicSyncPreset_Click" Tag="350"/>
</StackPanel>
</StackPanel>
</Grid>
</Border>
</Grid>
<!-- Master strip: always visible, even collapsed. Rebuilt 2026-11-01 — the meter is
CENTRED under the preview and is the biggest one on screen; MASTER's own label is
larger than the bay labels. The kbps / fps / dropped counters that used to end this
line are gone: the live readouts already sit above the preview. -->
<Border x:Name="MasterStrip" Grid.Row="0" Background="{StaticResource Brush.SurfaceContainerLowest}" Padding="12,0">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<!-- Left: the imbalance flag, the one case that silently ruins a mix. -->
<Border Grid.Column="0" Margin="0,0,8,0" Padding="5,1" VerticalAlignment="Center"
CornerRadius="{StaticResource Radius.Sm}" Background="{StaticResource Brush.SurfaceContainerHigh}"
Visibility="{Binding MasterImbalance, Converter={StaticResource BoolToVis}}"
ToolTip="Left and right differ by more than 6 dB — one side may be inaudible.">
<TextBlock Text="L/R" FontSize="9" FontWeight="Bold" FontFamily="{StaticResource FontFamily.Mono}"
Foreground="{StaticResource Brush.Attention}"/>
</Border>
<!-- Centre: MASTER + the long meter + its reading. The ladder stretches with the
window between 420 and 760 px — always longer than the 320-capped bay meters. -->
<Grid Grid.Column="1" VerticalAlignment="Center" HorizontalAlignment="Center">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" Text="MASTER" FontSize="12" FontWeight="Bold" VerticalAlignment="Center"
Foreground="{StaticResource Brush.OnSurface}"/>
<controls:SegmentMeter Grid.Column="1" Height="14" Margin="12,0,0,0" MinWidth="420" MaxWidth="900"
Orientation="Horizontal" Segments="32"
Level="{Binding MasterMeterFraction}" VerticalAlignment="Center"/>
<TextBlock Grid.Column="2" Width="72" Margin="10,0,0,0" FontSize="12"
FontFamily="{StaticResource FontFamily.Mono}" VerticalAlignment="Center"
Foreground="{StaticResource Brush.OnSurface}"
Text="{Binding MasterLevelLabel}"/>
</Grid>
<!-- Right: MONO (accessibility, remembered) + the session clock. -->
<StackPanel Grid.Column="2" Orientation="Horizontal" VerticalAlignment="Center">
<ToggleButton Height="20" Padding="8,0"
Style="{StaticResource SegmentToggle}" IsChecked="{Binding MonoOutput, Mode=TwoWay}"
ToolTip="Fold the output to mono — both ears hear everything">
<TextBlock Text="MONO" FontSize="10" FontWeight="Bold"
Foreground="{Binding Foreground, RelativeSource={RelativeSource AncestorType=ToggleButton}}"/>
</ToggleButton>
<TextBlock FontSize="11" FontFamily="{StaticResource FontFamily.Mono}" Margin="12,0,0,0"
VerticalAlignment="Center"
Foreground="{StaticResource Brush.OnSurfaceVariant}"
Text="{Binding CurrentHealth.StreamDuration, StringFormat={}{0:hh\:mm\:ss}}"/>
</StackPanel>
</Grid>
</Border>
</Grid>
</Border>
</UserControl>
+100
View File
@@ -0,0 +1,100 @@
using System.Windows;
using System.Windows.Controls;
using ytLive.Models;
namespace ytLive.Controls;
/// <summary>The sound bars (creator ruling 2026-10-30). This unit owns the SHELL: the piece of the
/// window it occupies and the collapse behaviour. The bays and the master readouts fill in over
/// the following units, so nothing here pretends to be a working meter.
///
/// <para>Collapse is driven by the window height, not a user setting: at 1366×768 a full dock
/// squeezes the preview, and the creator should not have to manage that.</para></summary>
public partial class AudioDock : UserControl
{
private Window? _window;
public AudioDock()
{
InitializeComponent();
Loaded += OnLoaded;
Unloaded += OnUnloaded;
}
/// <summary>Is the dock showing only the telemetry strip?</summary>
internal bool IsCollapsed { get; private set; }
/// <summary>Test seam: the same code the window's SizeChanged runs, without a window.</summary>
internal void ApplyWindowHeight(double windowHeight)
{
var collapsed = AudioDockLayout.IsCollapsed(windowHeight);
IsCollapsed = collapsed;
Height = AudioDockLayout.HeightFor(windowHeight);
Bays.Visibility = collapsed ? Visibility.Collapsed : Visibility.Visible;
}
private void MicLabel_MouseLeftButtonUp(object sender, System.Windows.Input.MouseButtonEventArgs e)
{
if (DataContext is ViewModels.MainViewModel vm && vm.OpenMicPickerCommand.CanExecute(null))
vm.OpenMicPickerCommand.Execute(null);
}
private void TraxStepDown_Click(object sender, RoutedEventArgs e)
{
if (DataContext is ViewModels.MainViewModel vm) vm.StepTrax(-1);
}
private void TraxStepUp_Click(object sender, RoutedEventArgs e)
{
if (DataContext is ViewModels.MainViewModel vm) vm.StepTrax(+1);
}
private void SyncPreset_Click(object sender, RoutedEventArgs e)
{
if (DataContext is not ViewModels.MainViewModel vm) return;
if (sender is Button { Tag: string tag } && int.TryParse(tag, out var ms))
vm.SetSyncOffset(ms);
}
private void MicSyncPreset_Click(object sender, RoutedEventArgs e)
{
if (DataContext is not ViewModels.MainViewModel vm) return;
if (sender is Button { Tag: string tag } && int.TryParse(tag, out var ms))
vm.MicSyncOffsetMs = ms;
}
private void TraxPlayPause_Click(object sender, RoutedEventArgs e)
{
if (DataContext is ViewModels.MainViewModel vm) vm.TraxLeftClick();
}
private void TraxLabel_MouseLeftButtonUp(object sender, System.Windows.Input.MouseButtonEventArgs e)
{
if (DataContext is ViewModels.MainViewModel vm) vm.TraxLeftClick();
}
private void TraxLabel_MouseRightButtonUp(object sender, System.Windows.Input.MouseButtonEventArgs e)
{
if (DataContext is ViewModels.MainViewModel vm) vm.ChooseTrack();
}
private void OnLoaded(object sender, RoutedEventArgs e)
{
_window = Window.GetWindow(this);
if (_window == null)
{
ApplyWindowHeight(AudioDockLayout.CollapseBelowHeight); // no host: show the dock
return;
}
_window.SizeChanged += OnWindowSizeChanged;
ApplyWindowHeight(_window.ActualHeight > 0 ? _window.ActualHeight : _window.Height);
}
private void OnUnloaded(object sender, RoutedEventArgs e)
{
if (_window != null) _window.SizeChanged -= OnWindowSizeChanged;
_window = null;
}
private void OnWindowSizeChanged(object sender, SizeChangedEventArgs e) => ApplyWindowHeight(e.NewSize.Height);
}
-48
View File
@@ -1,48 +0,0 @@
<UserControl x:Class="ytLive.Controls.BottomBar"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Border Background="#0f3460" Padding="16,4">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<!-- Stream stats: centered under the preview pane -->
<StackPanel Grid.Column="1" Orientation="Horizontal" HorizontalAlignment="Center" VerticalAlignment="Center">
<TextBlock Text="Bitrate:" Style="{StaticResource YtLabel}" Margin="0,0,4,0"/>
<TextBlock Style="{StaticResource YtLabel}"
Foreground="White" Margin="0,0,20,0">
<Run Text="{Binding CurrentHealth.CurrentBitrate, Mode=OneWay}"/>
<Run Text=" Mbps"/>
</TextBlock>
<TextBlock Text="FPS:" Style="{StaticResource YtLabel}" Margin="0,0,4,0"/>
<TextBlock Style="{StaticResource YtLabel}"
Foreground="White" Margin="0,0,24,0">
<Run Text="{Binding CurrentHealth.FPS, Mode=OneWay, StringFormat={}{0:0}}"/>
</TextBlock>
<TextBlock Text="Dropped:" Style="{StaticResource YtLabel}" Margin="0,0,4,0"/>
<TextBlock Text="{Binding CurrentHealth.DroppedFrames}" Style="{StaticResource YtLabel}"
Foreground="White" Margin="0,0,20,0"/>
<TextBlock Text="Duration:" Style="{StaticResource YtLabel}" Margin="0,0,4,0"/>
<TextBlock Text="{Binding CurrentHealth.StreamDuration}" Style="{StaticResource YtLabel}"
Foreground="White"/>
<TextBlock Text="{Binding CurrentHealth.HealthMessage}" Style="{StaticResource YtLabel}"
Foreground="#e94560" Margin="20,0,0,0"/>
</StackPanel>
<!-- Resolution dropdown: far right -->
<ComboBox Grid.Column="2" Style="{StaticResource YtComboBox}" MinWidth="150"
ItemsSource="{Binding QualityOptions}"
SelectedItem="{Binding SelectedQuality}"
DisplayMemberPath="Label"
ToolTip="{Binding ResolutionHelp}"
IsEnabled="{Binding IsOffline}"/>
</Grid>
</Border>
</UserControl>
-11
View File
@@ -1,11 +0,0 @@
using System.Windows.Controls;
namespace ytLive.Controls;
public partial class BottomBar : UserControl
{
public BottomBar()
{
InitializeComponent();
}
}
+21
View File
@@ -0,0 +1,21 @@
<UserControl x:Class="ytLive.Controls.DeveloperPanel"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<!-- ⛔ THIS FILE IS EXCLUDED FROM THE RELEASE COMPILE (see ytLive.csproj: Page Remove +
Compile Remove under '$(Configuration) != Debug'). It is not merely hidden in a
Release build — its BAML never exists, so there is nothing to reveal. Do not add it
to a window or reference it from code that compiles in Release; the only host is
OverlayHost.xaml.cs inside #if DEBUG. -->
<StackPanel>
<TextBlock Text="Developer" Style="{StaticResource PropertyHeader}"
Foreground="{StaticResource Brush.Attention}"/>
<CheckBox Style="{StaticResource PropertyPillCheck}" Margin="10,2,0,0"
IsChecked="{Binding DeveloperMode, Mode=TwoWay}"
Content="Developer mode — all resolutions, output budget ignored"/>
<TextBlock Text="Debug builds only. Overrides the free output profile (921,600 px / 30 fps). Never present in a release build."
Style="{StaticResource PropertyHint}" Foreground="{StaticResource Brush.Attention}"
Margin="10,4,0,8"/>
</StackPanel>
</UserControl>
+21
View File
@@ -0,0 +1,21 @@
using System.Windows.Controls;
namespace ytLive.Controls;
/// <summary>
/// The developer affordances that must never exist in a shipped build: today, the
/// all-resolutions override for the output profile.
///
/// <para><b>This file is excluded from the Release compile</b> (<c>ytLive.csproj</c> removes
/// the Page and this Compile item when <c>Configuration != Debug</c>), so a Release binary
/// contains no BAML for it and cannot reveal it. That is deliberate over the cheaper
/// "collapse the panel" approach: a collapsed panel still exists in the visual tree of every
/// shipped copy, and "not visible" is a much weaker claim than "not compiled".</para>
/// </summary>
public partial class DeveloperPanel : UserControl
{
public DeveloperPanel()
{
InitializeComponent();
}
}
+742 -208
View File
File diff suppressed because it is too large Load Diff
+7 -7
View File
@@ -3,6 +3,7 @@ using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
using ytLive.Helpers;
using ytLive.Models;
using ytLive.ViewModels;
@@ -178,8 +179,9 @@ public partial class LeftPanel : UserControl
var position = e.GetPosition(listBox);
if (!_isDragging)
{
if (Math.Abs(position.X - _dragStartPoint.X) < SystemParameters.MinimumHorizontalDragDistance &&
Math.Abs(position.Y - _dragStartPoint.Y) < SystemParameters.MinimumVerticalDragDistance)
if (!DragReorderMath.ExceededThreshold(
position.X - _dragStartPoint.X, position.Y - _dragStartPoint.Y,
SystemParameters.MinimumHorizontalDragDistance, SystemParameters.MinimumVerticalDragDistance))
return;
_isDragging = true;
listBox.CaptureMouse();
@@ -193,12 +195,10 @@ public partial class LeftPanel : UserControl
return;
var targetIndex = listBox.Items.IndexOf(targetItem);
if (targetIndex < 0) return;
var landed = DragReorderMath.TryMove(items, _dragIndex, targetIndex);
if (landed < 0) return;
var dragged = items[_dragIndex];
items.RemoveAt(_dragIndex);
items.Insert(targetIndex, dragged);
_dragIndex = targetIndex;
_dragIndex = landed;
_lastHoveredItem = targetItem;
_dragReordered = true;
}
+96 -90
View File
@@ -1,11 +1,12 @@
<UserControl x:Class="ytLive.Controls.OverlayHost"
<UserControl x:Class="ytLive.Controls.OverlayHost"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:Helpers="clr-namespace:ytLive.Helpers"
xmlns:Models="clr-namespace:ytLive.Models">
xmlns:Models="clr-namespace:ytLive.Models"
xmlns:services="clr-namespace:ytLive.Services">
<Grid Grid.RowSpan="3"
Visibility="{Binding IsAnyOverlayOpen, Converter={StaticResource BoolToVis}}">
<Button Background="#99000000" BorderThickness="0" Cursor="Hand"
<Button Background="{StaticResource Brush.ScrimDarkStrong}" BorderThickness="0" Cursor="Hand"
Command="{Binding CloseOverlayCommand}">
<Button.Template>
<ControlTemplate TargetType="Button">
@@ -14,51 +15,28 @@
</Button.Template>
</Button>
<Border Background="#0A0C1A" CornerRadius="8" Width="650" MaxHeight="700"
<Border Background="{StaticResource Brush.Background}" CornerRadius="8" Width="820" MaxHeight="860"
VerticalAlignment="Center" HorizontalAlignment="Center"
Padding="28" BorderBrush="#333" BorderThickness="1">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<!-- Header -->
<Grid Grid.Row="0">
<TextBlock Text="{Binding OverlayTitle}" FontSize="18" FontWeight="Bold"
Foreground="#e0e0e0" VerticalAlignment="Center"/>
Foreground="{StaticResource Brush.OnSurface}" VerticalAlignment="Center"/>
<Button HorizontalAlignment="Right" ToolTip="Close" Style="{StaticResource IconButton}"
Command="{Binding CloseOverlayCommand}">
<Path Data="M19,6.41L17.59,5 12,10.59 6.41,5 5,6.41 10.59,12 5,17.59 6.41,19 12,13.41 17.59,19 19,17.59 13.41,12z"
Fill="#d0d0d0" Width="14" Height="14" Stretch="Uniform"/>
Fill="{StaticResource Brush.OnSurface}" Width="14" Height="14" Stretch="Uniform"/>
</Button>
</Grid>
<!-- App Settings: broadcast fields live in the preview's
Text pull-out now — this panel keeps app-level prefs only.
(Currently empty; the overlay shell stays for future use.) -->
<StackPanel Grid.Row="1" Margin="0,16,0,0"
Visibility="{Binding IsSettingsOpen, Converter={StaticResource BoolToVis}}">
<TextBlock Text="Default Location for Recordings" Style="{StaticResource YtLabel}" Margin="0,0,0,4"/>
<TextBox Style="{StaticResource YtTextBox}"
Text="{Binding RecordFolderDisplay, Mode=OneWay}"
IsReadOnly="True" Margin="0,0,0,12" Foreground="#a0a0b0"/>
<StackPanel Orientation="Horizontal" Margin="0,0,0,16">
<Button Content="Browse…" Command="{Binding ChooseRecordFolderCommand}"
Style="{StaticResource YtButtonSecondary}" Margin="0,0,8,0"/>
<Button Content="Use Downloads" Command="{Binding ResetRecordFolderCommand}"
Style="{StaticResource YtButtonSecondary}"/>
</StackPanel>
<TextBlock Text="Zoom View Shape" Style="{StaticResource YtLabel}" Margin="0,0,0,4"/>
<ComboBox Style="{StaticResource YtComboBox}" SelectedValue="{Binding ZoomShape}" SelectedValuePath="Tag">
<ComboBoxItem Content="Circle" Tag="{x:Static Models:ZoomShape.Circle}"/>
<ComboBoxItem Content="Phone (Vertical Rectangle)" Tag="{x:Static Models:ZoomShape.VerticalRectangle}"/>
<ComboBoxItem Content="Tablet (Horizontal Rectangle)" Tag="{x:Static Models:ZoomShape.HorizontalRectangle}"/>
</ComboBox>
</StackPanel>
<!-- Report Bug -->
<StackPanel Grid.Row="1" Margin="0,16,0,0"
<StackPanel Grid.Row="1" Margin="0,16,0,0"
Visibility="{Binding IsBugOpen, Converter={StaticResource BoolToVis}}">
<TextBlock Text="What went wrong?" Style="{StaticResource YtLabel}" Margin="0,0,0,4"/>
<TextBox Style="{StaticResource YtTextBox}" Height="110" AcceptsReturn="True" TextWrapping="Wrap"
@@ -95,56 +73,103 @@
<!-- About -->
<Grid Grid.Row="1" Margin="0,16,0,0"
Background="#171928"
Background="{StaticResource Brush.SurfaceContainerLow}"
Visibility="{Binding IsAboutOpen, Converter={StaticResource BoolToVis}}">
<!-- Hub view -->
<StackPanel Visibility="{Binding IsHubVisible, Converter={StaticResource BoolToVis}}">
<Image Source="/ytLive;component/Assets/llamacasty-logo.jpg" Height="125" Stretch="Uniform"
HorizontalAlignment="Center" Margin="0,0,0,12"/>
<TextBlock Text="{Binding AppVersionLabel}" Foreground="#a0a0b0" FontSize="12"
HorizontalAlignment="Center" Margin="0,4,0,0"/>
<TextBlock Text="Live streaming for YouTube — simple enough that even the most right-brained person can intuit it."
Foreground="#a0a0b0" FontSize="13" TextWrapping="Wrap" MaxWidth="380"
TextAlignment="Center" Margin="0,16,0,0"/>
<TextBlock Text="Free forever — live streams carry a brief &quot;made with LlamaCasty!&quot; flash. A subscription removes it and adds alerts."
Foreground="#888" FontSize="12" TextWrapping="Wrap" MaxWidth="380"
TextAlignment="Center" Margin="0,12,0,0"/>
<TextBlock Text="Made by gramps · llama chile shop" Foreground="#a0a0b0" FontSize="12"
HorizontalAlignment="Center" Margin="0,16,0,8"/>
<!-- Hub view — a conversion surface, not a credits page (creator, 2026-11-05):
large logo, one tagline, the offer in three short blocks, one primary CTA,
everything else demoted. A paying customer never sees the price. -->
<ScrollViewer VerticalScrollBarVisibility="Auto"
Visibility="{Binding IsHubVisible, Converter={StaticResource BoolToVis}}">
<StackPanel>
<Image Source="/LlamaCasty;component/Assets/llamacasty-logo.jpg"
Width="520" Stretch="Uniform" HorizontalAlignment="Center" Margin="0,0,0,10"/>
<!-- Creator hub links -->
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center" Margin="0,0,0,8">
<Button Content="llama chile shop on YouTube" Command="{Binding OpenChannelCommand}"
Style="{StaticResource YtButtonSecondary}" Margin="0,0,8,0"/>
<Button Content="Mastodon" Command="{Binding OpenMastodonCommand}"
Style="{StaticResource YtButtonSecondary}"/>
</StackPanel>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center">
<Button Content="Buy me a coffee" Command="{Binding OpenCoffeeCommand}"
Style="{StaticResource YtButtonSecondary}" Margin="0,0,8,0"/>
<Button Content="Unlock Premium" Command="{Binding OpenPremiumCommand}"
Style="{StaticResource YtButtonSecondary}"
IsEnabled="{Binding IsPremiumAvailable}"
ToolTip="Open the LlamaCasty subscription page"/>
<Button Content="Enter License Key" Command="{Binding OpenLicenseEntryCommand}"
Style="{StaticResource YtButtonSecondary}"
ToolTip="Activate a license key to remove the watermark"/>
</StackPanel>
<Button x:Name="ReleaseBadgeButton" Content="{Binding AppReleaseLabel}"
Style="{StaticResource PillBadgeButton}"
Command="{Binding OpenPremiumCommand}"
HorizontalAlignment="Center" Margin="0,0,0,10"
ToolTip="Pre-release — opens llamacasty.com when clicked"/>
<Button Content="Licenses &amp; legal" Command="{Binding OpenLicensesCommand}"
Style="{StaticResource YtButtonSecondary}" HorizontalAlignment="Center"
Margin="0,16,0,0"/>
</StackPanel>
<TextBlock x:Name="AboutTagline"
Text="1-2-3, and go live to the world on YouTube!"
Foreground="{StaticResource Brush.OnSurface}" FontSize="17" FontWeight="SemiBold"
TextWrapping="Wrap" MaxWidth="640" TextAlignment="Center"
HorizontalAlignment="Center" Margin="0,4,0,18"/>
<Grid MaxWidth="640" HorizontalAlignment="Center" Margin="0,0,0,4">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Grid.Column="0" Text="Free" Margin="0,0,14,4"
Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="13" FontWeight="SemiBold"/>
<TextBlock Grid.Row="0" Grid.Column="1" Text="{Binding FreeOfferText}" Margin="0,0,0,4"
Foreground="{StaticResource Brush.OnSurface}" FontSize="13" TextWrapping="Wrap"/>
<TextBlock Grid.Row="1" Grid.Column="0" Text="Paid" Margin="0,0,14,4"
Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="13" FontWeight="SemiBold"/>
<TextBlock Grid.Row="1" Grid.Column="1" Text="{Binding PaidOfferText}" Margin="0,0,0,4"
Foreground="{StaticResource Brush.OnSurface}" FontSize="13" TextWrapping="Wrap"/>
<TextBlock Grid.Row="2" Grid.ColumnSpan="2" Text="{Binding TrustLine}"
Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="12"
TextWrapping="Wrap" Margin="0,6,0,0"/>
</Grid>
<!-- Free: the offer. Owned: thanks, no price. -->
<StackPanel x:Name="UpgradeBlock"
Visibility="{Binding IsAppOwned, Converter={StaticResource InverseBoolToVis}}"
HorizontalAlignment="Center" Margin="0,18,0,0">
<TextBlock x:Name="AboutPriceLine" Text="{Binding PriceLine}"
Foreground="{StaticResource Brush.OnSurface}" FontSize="16" FontWeight="Bold"
HorizontalAlignment="Center"/>
<TextBlock Text="{Binding PriceNote}" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="12"
TextAlignment="Center" TextWrapping="Wrap" MaxWidth="520" Margin="0,2,0,10"/>
<Button x:Name="UpgradeCtaButton" Content="{Binding UpgradeCtaLabel}"
Command="{Binding OpenPremiumCommand}"
Style="{StaticResource YtButton}"
HorizontalAlignment="Center" Padding="22,10" FontSize="14"
ToolTip="Opens llamacasty.com in your browser when clicked"/>
</StackPanel>
<TextBlock x:Name="OwnedStateText" Text="{Binding LicenceStateLabel}"
Foreground="{StaticResource Brush.Positive}" FontSize="13" FontWeight="SemiBold"
HorizontalAlignment="Center" Margin="0,18,0,0"
Visibility="{Binding IsAppOwned, Converter={StaticResource BoolToVis}}"/>
<!-- Secondary destinations, one row, deliberately quieter than the CTA. -->
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center" Margin="0,22,0,0">
<Button Content="llama chile shop on YouTube" Command="{Binding OpenChannelCommand}"
Style="{StaticResource YtButtonSecondary}" Margin="0,0,8,0" FontSize="11" Padding="10,5"/>
<Button Content="Mastodon" Command="{Binding OpenMastodonCommand}"
Style="{StaticResource YtButtonSecondary}" Margin="0,0,8,0" FontSize="11" Padding="10,5"/>
<Button Content="Buy me a coffee" Command="{Binding OpenCoffeeCommand}"
Style="{StaticResource YtButtonSecondary}" Margin="0,0,8,0" FontSize="11" Padding="10,5"/>
<Button Content="Licenses &amp; legal" Command="{Binding OpenLicensesCommand}"
Style="{StaticResource YtButtonSecondary}" FontSize="11" Padding="10,5"/>
</StackPanel>
<TextBlock x:Name="AboutCopyright" Text="{Binding CopyrightLabel}"
Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11"
HorizontalAlignment="Center" Margin="0,18,0,4"/>
</StackPanel>
</ScrollViewer>
<!-- Licensing/legal panel -->
<StackPanel Visibility="{Binding IsLicensesOpen, Converter={StaticResource BoolToVis}}">
<TextBlock Text="Third-party notices &amp; license texts" FontSize="15" FontWeight="Bold"
Foreground="#e0e0e0" Margin="0,0,0,8"/>
<Border Background="#10101e" CornerRadius="4" Padding="10" MaxHeight="360"
Foreground="{StaticResource Brush.OnSurface}" Margin="0,0,0,8"/>
<Border Background="{StaticResource Brush.SurfaceContainerLowest}" CornerRadius="4" Padding="10" MaxHeight="360"
BorderBrush="#333" BorderThickness="1">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<TextBlock Text="{Binding LicensesText}" Foreground="#b8b8c8" FontSize="11"
<TextBlock Text="{Binding LicensesText}" Foreground="{StaticResource Brush.OnSurface}" FontSize="11"
FontFamily="Consolas" TextWrapping="Wrap"/>
</ScrollViewer>
</Border>
@@ -153,25 +178,6 @@
Click="BackToAbout_Click"/>
</StackPanel>
<!-- License key entry panel -->
<StackPanel Visibility="{Binding IsLicenseEntryOpen, Converter={StaticResource BoolToVis}}">
<TextBlock Text="Activate LlamaCasty Premium" FontSize="15" FontWeight="Bold"
Foreground="#e0e0e0" Margin="0,0,0,8"/>
<TextBlock Text="Enter your license key to remove the branding watermark."
Foreground="#a0a0b0" FontSize="12" TextWrapping="Wrap" Margin="0,0,0,12"/>
<TextBlock Text="License Key" Style="{StaticResource YtLabel}" Margin="0,0,0,4"/>
<TextBox Style="{StaticResource YtTextBox}" Margin="0,0,0,12"
Text="{Binding LicenseKey, UpdateSourceTrigger=PropertyChanged}"
MaxLength="100"/>
<Button Content="Activate" Command="{Binding ActivateLicenseCommand}"
Style="{StaticResource YtButton}" HorizontalAlignment="Right"
Margin="0,0,0,8"/>
<TextBlock Text="{Binding LicenseMessage}" Foreground="#a0a0b0" FontSize="12"
TextWrapping="Wrap" Margin="0,0,0,8"/>
<Button Content="← Back to About" Style="{StaticResource YtButtonSecondary}"
HorizontalAlignment="Left" Margin="0,8,0,0"
Click="BackToAbout_Click"/>
</StackPanel>
</Grid>
</Grid>
</Border>
+428 -265
View File
@@ -1,8 +1,9 @@
<UserControl x:Class="ytLive.Controls.PreviewPane"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:Helpers="clr-namespace:ytLive.Helpers">
<Border Background="#0a0a1a" CornerRadius="6" Margin="8,0"
xmlns:Helpers="clr-namespace:ytLive.Helpers"
xmlns:local="clr-namespace:ytLive" xmlns:controls="clr-namespace:ytLive.Controls">
<Border Background="{StaticResource Brush.SurfaceContainerLowest}" CornerRadius="6" Margin="8,0"
BorderBrush="{Binding PreviewGlowBrush}" BorderThickness="{Binding PreviewGlowThickness}">
<Grid>
<Grid.RowDefinitions>
@@ -19,6 +20,11 @@
PreviewMouseRightButtonUp="Preview_MouseRightButtonUp">
<Viewbox Stretch="Uniform">
<Canvas x:Name="PreviewCanvas" Width="1920" Height="1080" ClipToBounds="True">
<!-- Privacy blur preview (2026-11-01): the blur is UNIVERSAL — it covers
every layer beneath it, so this host sits ABOVE the whole CanvasGrid
rather than inside it. Each child samples CanvasGrid (all layers, no
recursion because this canvas is a sibling) at the blur's rect and
carries a BlurEffect at the output's strength. -->
<Grid x:Name="CanvasGrid" Width="1920" Height="1080" ClipToBounds="True" Background="Transparent">
<Grid.RenderTransform>
<TransformGroup>
@@ -70,8 +76,10 @@
Visibility="{Binding BackgroundVisible, Converter={StaticResource BoolToVis}}"/>
<Image Source="{Binding ActiveBackgroundImage}" Stretch="UniformToFill"/>
</Grid>
<Image x:Name="TransitionOverlay" Width="1920" Height="1080" Stretch="Fill"
IsHitTestVisible="False" Visibility="Collapsed"/>
<Canvas x:Name="OverlayCanvas" Width="1920" Height="1080">
<ItemsControl ItemsSource="{Binding StagedScene.Elements}"
<ItemsControl x:Name="OverlayElements" ItemsSource="{Binding StagedScene.Elements}"
Width="1920" Height="1080">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
@@ -82,6 +90,15 @@
<Style TargetType="ContentPresenter">
<Setter Property="Canvas.Left" Value="{Binding X}"/>
<Setter Property="Canvas.Top" Value="{Binding Y}"/>
<!-- ⛔ The brand logo renders ONCE, in BrandLogoOverlay above
the blur host, so it can never be blurred. Drawing it here
as well stacked two copies on top of each other
(creator report, 2026-11-01). -->
<Style.Triggers>
<DataTrigger Binding="{Binding IsBrandLogo}" Value="True">
<Setter Property="Visibility" Value="Collapsed"/>
</DataTrigger>
</Style.Triggers>
</Style>
</ItemsControl.ItemContainerStyle>
<ItemsControl.ItemTemplate>
@@ -135,10 +152,32 @@
<DataTrigger Binding="{Binding IsAlertBox}" Value="True">
<Setter Property="Visibility" Value="Visible"/>
</DataTrigger>
<DataTrigger Binding="{Binding IsTicker}" Value="True">
<Setter Property="Visibility" Value="Visible"/>
</DataTrigger>
<!-- The scoreboard strip is a frame-source too: without this trigger its
Image stays collapsed and every property change looks inert. -->
<DataTrigger Binding="{Binding IsScoreboard}" Value="True">
<Setter Property="Visibility" Value="Visible"/>
</DataTrigger>
<DataTrigger Binding="{Binding IsTextOverlay}" Value="True">
<Setter Property="Visibility" Value="Visible"/>
</DataTrigger>
<DataTrigger Binding="{Binding IsWebSource}" Value="True">
<Setter Property="Visibility" Value="Visible"/>
<Setter Property="Stretch" Value="Fill"/>
</DataTrigger>
<!-- A video file layer is a frame source too. Without this trigger its Image stayed
Collapsed and the clip was invisible in the preview while the decode ran — the
same defect the alert box hit on 2026-09-26. -->
<MultiDataTrigger>
<MultiDataTrigger.Conditions>
<Condition Binding="{Binding IsMediaSource}" Value="True"/>
<Condition Binding="{Binding IsRectShape}" Value="True"/>
</MultiDataTrigger.Conditions>
<Setter Property="Visibility" Value="Visible"/>
</MultiDataTrigger>
<MultiDataTrigger>
<MultiDataTrigger.Conditions>
<Condition Binding="{Binding IsWebcam}" Value="True"/>
@@ -170,6 +209,15 @@
</MultiDataTrigger.Conditions>
<Setter Property="Visibility" Value="Visible"/>
</MultiDataTrigger>
<!-- A round-clipped video layer uses the same unit-circle path, or it would
render in the output and be invisible in the preview (2026-11-05). -->
<MultiDataTrigger>
<MultiDataTrigger.Conditions>
<Condition Binding="{Binding IsMediaSource}" Value="True"/>
<Condition Binding="{Binding IsRoundShape}" Value="True"/>
</MultiDataTrigger.Conditions>
<Setter Property="Visibility" Value="Visible"/>
</MultiDataTrigger>
</Style.Triggers>
</Style>
</Image.Style>
@@ -181,6 +229,50 @@
</Viewbox>
<!-- OBS-style border: stroked rect (Traditional) / centered circle (Round).
Animations are driven by code-behind in WebcamMenu_SetBorderAnimation
because DataTrigger storyboards in DataTemplates cannot be stopped
(no ExitActions namescope support). -->
<Rectangle IsHitTestVisible="False"
Stroke="{Binding BorderBrush}"
StrokeThickness="{Binding BorderWidth}">
<Rectangle.Style>
<Style TargetType="Rectangle">
<Setter Property="Visibility" Value="Collapsed"/>
<Style.Triggers>
<MultiDataTrigger>
<MultiDataTrigger.Conditions>
<Condition Binding="{Binding HasBorder}" Value="True"/>
<Condition Binding="{Binding ClipShape}" Value="Traditional"/>
</MultiDataTrigger.Conditions>
<Setter Property="Visibility" Value="Visible"/>
</MultiDataTrigger>
</Style.Triggers>
</Style>
</Rectangle.Style>
</Rectangle>
<Ellipse IsHitTestVisible="False"
Stroke="{Binding BorderBrush}"
StrokeThickness="{Binding BorderWidth}"
Width="{Binding RoundBorderSize}"
Height="{Binding RoundBorderSize}"
HorizontalAlignment="Center" VerticalAlignment="Center">
<Ellipse.Style>
<Style TargetType="Ellipse">
<Setter Property="Visibility" Value="Collapsed"/>
<Style.Triggers>
<MultiDataTrigger>
<MultiDataTrigger.Conditions>
<Condition Binding="{Binding HasBorder}" Value="True"/>
<Condition Binding="{Binding ClipShape}" Value="Round"/>
</MultiDataTrigger.Conditions>
<Setter Property="Visibility" Value="Visible"/>
</MultiDataTrigger>
</Style.Triggers>
</Style>
</Ellipse.Style>
</Ellipse>
<Grid.ContextMenu>
<ContextMenu DataContext="{Binding PlacementTarget.DataContext, RelativeSource={RelativeSource Self}}">
<MenuItem Header="Change Webcam…" Click="WebcamMenu_ChangeWebcam"
@@ -188,7 +280,7 @@
<Separator/>
<MenuItem Header="Border Thickness">
<StackPanel Orientation="Horizontal" Margin="8,2">
<TextBlock Text="Thickness" Foreground="#e0e0e0" VerticalAlignment="Center"/>
<TextBlock Text="Thickness" Foreground="{StaticResource Brush.OnSurface}" VerticalAlignment="Center"/>
<Slider Width="120" Minimum="0" Maximum="20" Margin="8,0,0,0"
Value="{Binding BorderWidth, Mode=TwoWay}"
VerticalAlignment="Center"/>
@@ -196,35 +288,17 @@
</MenuItem>
<MenuItem Header="Border Opacity">
<StackPanel Orientation="Horizontal" Margin="8,2">
<TextBlock Text="Opacity" Foreground="#e0e0e0" VerticalAlignment="Center"/>
<TextBlock Text="Opacity" Foreground="{StaticResource Brush.OnSurface}" VerticalAlignment="Center"/>
<Slider Width="120" Minimum="0" Maximum="1" Margin="8,0,0,0"
Value="{Binding BorderOpacity, Mode=TwoWay}"
VerticalAlignment="Center"/>
</StackPanel>
</MenuItem>
<MenuItem Header="Border Color">
<MenuItem Header="None" Tag="" IsCheckable="True"
IsChecked="{Binding BorderColor, Converter={StaticResource EnumToBool}, ConverterParameter=''}"
Click="WebcamMenu_SetBorderColor"/>
<MenuItem Header="#ffffff" Tag="#ffffff" IsCheckable="True"
IsChecked="{Binding BorderColor, Converter={StaticResource EnumToBool}, ConverterParameter=#ffffff}"
Click="WebcamMenu_SetBorderColor"/>
<MenuItem Header="#000000" Tag="#000000" IsCheckable="True"
IsChecked="{Binding BorderColor, Converter={StaticResource EnumToBool}, ConverterParameter=#000000}"
Click="WebcamMenu_SetBorderColor"/>
<MenuItem Header="#e94560" Tag="#e94560" IsCheckable="True"
IsChecked="{Binding BorderColor, Converter={StaticResource EnumToBool}, ConverterParameter=#e94560}"
Click="WebcamMenu_SetBorderColor"/>
<MenuItem Header="#22c55e" Tag="#22c55e" IsCheckable="True"
IsChecked="{Binding BorderColor, Converter={StaticResource EnumToBool}, ConverterParameter=#22c55e}"
Click="WebcamMenu_SetBorderColor"/>
<MenuItem Header="#eab308" Tag="#eab308" IsCheckable="True"
IsChecked="{Binding BorderColor, Converter={StaticResource EnumToBool}, ConverterParameter=#eab308}"
Click="WebcamMenu_SetBorderColor"/>
<MenuItem Header="#38bdf8" Tag="#38bdf8" IsCheckable="True"
IsChecked="{Binding BorderColor, Converter={StaticResource EnumToBool}, ConverterParameter=#38bdf8}"
Click="WebcamMenu_SetBorderColor"/>
</MenuItem>
<!-- Filled from Models/BorderColorPalette on open — the list used to be
seven hand-written rows here, a second source of truth beside the
properties drop-down (TASK 63). -->
<MenuItem x:Name="WebcamBorderColorMenu" Header="Border Color"
SubmenuOpened="WebcamBorderColorMenu_SubmenuOpened"/>
<MenuItem Header="Border Effect">
<MenuItem Header="None" Tag="None" IsCheckable="True"
IsChecked="{Binding BorderAnimation, Converter={StaticResource EnumToBool}, ConverterParameter=None}"
@@ -267,12 +341,12 @@
<Grid x:Name="SelectionOverlay" IsHitTestVisible="False"
Canvas.Left="{Binding SelectedElement.X}" Canvas.Top="{Binding SelectedElement.Y}"
Width="{Binding SelectedElement.Width}" Height="{Binding SelectedElement.Height}">
<Rectangle Stroke="#e94560" StrokeDashArray="4 3" StrokeThickness="3"/>
<Ellipse Width="26" Height="26" Fill="#e94560" Stroke="White" StrokeThickness="2"
<Rectangle Stroke="{StaticResource Brush.Attention}" StrokeDashArray="4 3" StrokeThickness="5"/>
<Ellipse Width="26" Height="26" Fill="{StaticResource Brush.Attention}" Stroke="White" StrokeThickness="2"
HorizontalAlignment="Right" VerticalAlignment="Bottom" Margin="0,0,-13,-13"/>
</Grid>
<Rectangle IsHitTestVisible="False" Stroke="#e94560" StrokeThickness="3"
<Rectangle IsHitTestVisible="False" Stroke="{StaticResource Brush.Tertiary}" StrokeThickness="3"
Canvas.Left="{Binding OutputRectX}" Canvas.Top="{Binding OutputRectY}"
Width="{Binding OutputRectWidth}" Height="{Binding OutputRectHeight}"
Visibility="{Binding IsOutputCropped, Converter={StaticResource BoolToVis}}"/>
@@ -322,8 +396,20 @@
<DropShadowEffect Color="{Binding SocialBarGlowBrush.Color}"
BlurRadius="18" ShadowDepth="0" Opacity="0.9"/>
</Grid.Effect>
<Border HorizontalAlignment="Center" Margin="40,8" CornerRadius="20"
Background="{Binding SocialBarBackgroundBrush}">
<Border.Style>
<Style TargetType="Border">
<Setter Property="Padding" Value="0"/>
<Style.Triggers>
<DataTrigger Binding="{Binding Socials.BarBackgroundEnabled}" Value="True">
<Setter Property="Padding" Value="18,6"/>
</DataTrigger>
</Style.Triggers>
</Style>
</Border.Style>
<ItemsControl ItemsSource="{Binding Socials.Entries}"
HorizontalAlignment="Center" Margin="40,8"
HorizontalAlignment="Center"
IsHitTestVisible="False">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
@@ -333,15 +419,45 @@
<ItemsControl.ItemTemplate>
<DataTemplate>
<StackPanel Orientation="Horizontal" Margin="0,0,20,0">
<Path Data="{Binding LogoData}" Fill="White" Width="24" Height="24"
Stretch="Uniform" VerticalAlignment="Center"/>
<TextBlock Text="{Binding Handle}" Foreground="White"
FontSize="14" VerticalAlignment="Center"
Margin="8,0,0,0"/>
<Path Data="{Binding LogoData}" Width="24" Height="24"
Fill="{Binding DataContext.SocialBarTextBrush, RelativeSource={RelativeSource AncestorType=ItemsControl}}"
Stretch="Uniform" VerticalAlignment="Center">
<Path.Style>
<Style TargetType="Path">
<Style.Triggers>
<DataTrigger Binding="{Binding DataContext.Socials.BarTextShadow, RelativeSource={RelativeSource AncestorType=ItemsControl}}" Value="True">
<Setter Property="Effect">
<Setter.Value>
<DropShadowEffect Color="Black" BlurRadius="4" ShadowDepth="2" Direction="315" Opacity="0.85"/>
</Setter.Value>
</Setter>
</DataTrigger>
</Style.Triggers>
</Style>
</Path.Style>
</Path>
<TextBlock Text="{Binding Handle}" FontSize="14" VerticalAlignment="Center"
Margin="8,0,0,0"
Foreground="{Binding DataContext.SocialBarTextBrush, RelativeSource={RelativeSource AncestorType=ItemsControl}}">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Style.Triggers>
<DataTrigger Binding="{Binding DataContext.Socials.BarTextShadow, RelativeSource={RelativeSource AncestorType=ItemsControl}}" Value="True">
<Setter Property="Effect">
<Setter.Value>
<DropShadowEffect Color="Black" BlurRadius="4" ShadowDepth="2" Direction="315" Opacity="0.85"/>
</Setter.Value>
</Setter>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</Border>
</Grid>
</Canvas>
</Grid>
@@ -383,16 +499,25 @@
Canvas.Left="{Binding ZoomState.X}" Canvas.Top="{Binding ZoomState.Y}"
Width="{Binding ZoomState.Width}" Height="{Binding ZoomState.Height}"
Visibility="{Binding IsZoomActive, Converter={StaticResource BoolToVis}}">
<Rectangle x:Name="ZoomRectOutline" Stroke="#d8a72b" StrokeThickness="3" Fill="Transparent"/>
<Ellipse x:Name="ZoomCircleOutline" Stroke="#d8a72b" StrokeThickness="3" Fill="Transparent" Visibility="Collapsed"/>
<Rectangle x:Name="ZoomRectOutline" Stroke="{StaticResource Brush.Secondary}" StrokeThickness="3" Fill="Transparent"/>
<Ellipse x:Name="ZoomCircleOutline" Stroke="{StaticResource Brush.Secondary}" StrokeThickness="3" Fill="Transparent" Visibility="Collapsed"/>
<Grid.Background>
<VisualBrush x:Name="ZoomVisualBrush" ViewboxUnits="Absolute" Stretch="Fill"/>
</Grid.Background>
</Grid>
<Canvas x:Name="BlurPreviewHost" Width="1920" Height="1080" IsHitTestVisible="False"/>
<!-- The brand logo rides ABOVE the blur host, mirroring the compositor:
a privacy blur can never swallow it. -->
<Image Canvas.Left="{Binding StagedBrandLogo.X}" Canvas.Top="{Binding StagedBrandLogo.Y}"
Width="{Binding StagedBrandLogo.Width}" Height="{Binding StagedBrandLogo.Height}"
Source="{Binding StagedBrandLogo.DisplaySource}" Stretch="Fill"
Opacity="{Binding StagedBrandLogo.Opacity}"
IsHitTestVisible="False"
Visibility="{Binding StagedBrandLogo, Converter={StaticResource NotNullToVis}}"/>
</Canvas>
</Viewbox>
<Border HorizontalAlignment="Left" VerticalAlignment="Top" Margin="8,8,0,0"
Background="#CCe94560" CornerRadius="4" Padding="8,3" IsHitTestVisible="False"
Background="{StaticResource Brush.Primary}" CornerRadius="4" Padding="8,3" IsHitTestVisible="False"
Visibility="{Binding WebcamError, Converter={StaticResource NotNullToVis}}">
<TextBlock Text="{Binding WebcamError}" Foreground="White" FontSize="12"
MaxWidth="520" TextTrimming="CharacterEllipsis"/>
@@ -408,10 +533,10 @@
<TextBlock Text="🖼️" Foreground="White" FontSize="30" Margin="0,0,20,0"/>
<TextBlock Text="✏️" Foreground="White" FontSize="30"/>
</StackPanel>
<TextBlock Text="Make it yours!" Foreground="#e0e0e0" FontSize="18"
<TextBlock Text="Make it yours!" Foreground="{StaticResource Brush.OnSurface}" FontSize="18"
FontWeight="SemiBold" HorizontalAlignment="Center" Margin="0,18,0,0"/>
<TextBlock Text="Add layers — your webcam, your screen, images, and text — to craft a brand and identity that's unmistakably you."
Foreground="#a0a0b0" FontSize="13" Margin="0,8,0,0"
Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="13" Margin="0,8,0,0"
TextWrapping="Wrap" MaxWidth="440" TextAlignment="Center"
HorizontalAlignment="Center"/>
</StackPanel>
@@ -427,7 +552,7 @@
<StackPanel x:Name="TextPullOutHost" Orientation="Horizontal"
HorizontalAlignment="Right" VerticalAlignment="Stretch">
<!-- The broadcast-metadata drawer -->
<Border Background="#16213e" ClipToBounds="True">
<Border Background="{StaticResource Brush.SurfaceContainer}" ClipToBounds="True">
<Border.Style>
<Style TargetType="Border">
<Setter Property="Width" Value="0"/>
@@ -462,7 +587,7 @@
</Style>
</Border.Style>
<!-- Fixed-width inner so text never reflows mid-animation -->
<Grid Width="320" Margin="14,10,14,12">
<Grid Margin="14,10,20,12">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
@@ -480,7 +605,7 @@
<ContentPresenter/>
</ControlTemplate>
</Button.Template>
<TextBlock Text="✕" Foreground="#a0a0b0" FontSize="14" FontWeight="Bold"/>
<TextBlock Text="✕" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="14" FontWeight="Bold"/>
</Button>
</Grid>
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto">
@@ -504,20 +629,77 @@
IsChecked="{Binding BroadcastForm.MadeForKids}"/>
<TextBlock Text="SCHEDULED START" Style="{StaticResource YtLabel}" Margin="0,0,0,3"/>
<TextBlock Text="{Binding BroadcastForm.ScheduledStartText}"
Foreground="#888899" FontSize="12" Margin="0,0,0,4"/>
Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="12" Margin="0,0,0,4"/>
</StackPanel>
</ScrollViewer>
<StackPanel Grid.Row="2" Margin="0,10,0,0">
<Button Style="{StaticResource YtButton}" Content="Update Broadcast"
Command="{Binding BroadcastForm.UpdateBroadcastCommand}"/>
<TextBlock Text="{Binding BroadcastForm.UpdateError}"
Foreground="#ef4444" FontSize="11" TextWrapping="Wrap" Margin="0,6,0,0"
Foreground="{StaticResource Brush.Error}" FontSize="11" TextWrapping="Wrap" Margin="0,6,0,0"
Visibility="{Binding BroadcastForm.UpdateError, Converter={StaticResource NotNullToVis}}"/>
</StackPanel>
</Grid>
</Border>
<!-- The Socials drawer (same pattern as the other pull-outs) -->
<Border Background="{StaticResource Brush.SurfaceContainer}" ClipToBounds="True">
<Border.Style>
<Style TargetType="Border">
<Setter Property="Width" Value="0"/>
<Style.Triggers>
<DataTrigger Binding="{Binding SocialsDrawerOpen}" Value="True">
<DataTrigger.EnterActions>
<BeginStoryboard>
<Storyboard>
<DoubleAnimation Storyboard.TargetProperty="Width"
To="320" Duration="0:0:0.2">
<DoubleAnimation.EasingFunction>
<CubicEase EasingMode="EaseOut"/>
</DoubleAnimation.EasingFunction>
</DoubleAnimation>
</Storyboard>
</BeginStoryboard>
</DataTrigger.EnterActions>
<DataTrigger.ExitActions>
<BeginStoryboard>
<Storyboard>
<DoubleAnimation Storyboard.TargetProperty="Width"
To="0" Duration="0:0:0.2">
<DoubleAnimation.EasingFunction>
<CubicEase EasingMode="EaseOut"/>
</DoubleAnimation.EasingFunction>
</DoubleAnimation>
</Storyboard>
</BeginStoryboard>
</DataTrigger.ExitActions>
</DataTrigger>
</Style.Triggers>
</Style>
</Border.Style>
<Grid Margin="14,10,20,12">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<Grid Grid.Row="0" Margin="0,0,0,8">
<TextBlock Text="SOCIALS" Style="{StaticResource SectionHeader}" HorizontalAlignment="Left"/>
<Button HorizontalAlignment="Right" VerticalAlignment="Top"
Background="Transparent" BorderThickness="0" Cursor="Hand"
Command="{Binding OpenSocialDialogCommand}"
ToolTip="Close">
<Button.Template>
<ControlTemplate TargetType="Button">
<ContentPresenter/>
</ControlTemplate>
</Button.Template>
<TextBlock Text="✕" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="14" FontWeight="Bold"/>
</Button>
</Grid>
<local:SocialsDialog Grid.Row="1" DataContext="{Binding SocialsDrawer}"/>
</Grid>
</Border>
<!-- The YPP-progress drawer (below Stream Settings on the rail) -->
<Border Background="#16213e" ClipToBounds="True">
<Border Background="{StaticResource Brush.SurfaceContainer}" ClipToBounds="True">
<Border.Style>
<Style TargetType="Border">
<Setter Property="Width" Value="0"/>
@@ -551,7 +733,7 @@
</Style.Triggers>
</Style>
</Border.Style>
<Grid Width="320" Margin="14,10,14,12">
<Grid Margin="14,10,20,12">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
@@ -569,48 +751,87 @@
<ContentPresenter/>
</ControlTemplate>
</Button.Template>
<TextBlock Text="✕" Foreground="#a0a0b0" FontSize="14" FontWeight="Bold"/>
<TextBlock Text="✕" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="14" FontWeight="Bold"/>
</Button>
</Grid>
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto">
<StackPanel>
<TextBlock Text="Sign in to YouTube to see your Partner Program progress and eligibility checklist here."
Foreground="#d0d0e0" FontSize="13" TextWrapping="Wrap"
Foreground="{StaticResource Brush.OnSurface}" FontSize="13" TextWrapping="Wrap"
Visibility="{Binding Ypp.IsSignedOut, Converter={StaticResource BoolToVis}}"/>
<TextBlock Text="Refreshing from YouTube…" Foreground="#a0a0b0" FontSize="12"
<TextBlock Text="Refreshing from YouTube…" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="12"
Margin="0,0,0,4"
Visibility="{Binding Ypp.IsLoading, Converter={StaticResource BoolToVis}}"/>
<TextBlock Text="{Binding Ypp.LoadError}" Foreground="#ef4444" FontSize="11"
<TextBlock Text="{Binding Ypp.LoadError}" Foreground="{StaticResource Brush.Error}" FontSize="11"
TextWrapping="Wrap" Margin="0,0,0,4"
Visibility="{Binding Ypp.LoadError, Converter={StaticResource NotNullToVis}}"/>
<StackPanel Visibility="{Binding Ypp.IsSignedOut, Converter={StaticResource InverseBoolToVis}}">
<TextBlock Text="{Binding Ypp.DisplayName}" Foreground="#e0e0e0" FontSize="13"
<TextBlock Text="{Binding Ypp.DisplayName}" Foreground="{StaticResource Brush.OnSurface}" FontSize="13"
FontWeight="SemiBold" Margin="0,0,0,10"/>
<TextBlock Text="SUBSCRIBERS — TIER 1 · FAN FUNDING" Style="{StaticResource YtLabel}" Margin="0,0,0,2"/>
<TextBlock Text="{Binding Ypp.Tier1SubsText}" Foreground="#e0e0e0" FontSize="13"/>
<Border Height="8" Background="#2a2a40" CornerRadius="4" Margin="0,4,0,10">
<Border Height="8" Background="#e94560" CornerRadius="4" HorizontalAlignment="Left"
Width="{Binding Ypp.Tier1SubsBarWidth}"/>
<!-- TASK 39·2 — the three metrics that count, with the threshold
that applies TODAY (the long-form bar doubles 2027-02-01; that is
data, not a code change). YouTube decides eligibility; these are
estimates and the drawer says so. No red/green scoring. -->
<TextBlock Text="SUBSCRIBERS" Style="{StaticResource YtLabel}" Margin="0,0,0,2"/>
<DockPanel Margin="0,0,0,2">
<TextBlock DockPanel.Dock="Right" Text="{Binding Ypp.SubscribersText}"
Foreground="{StaticResource Brush.OnSurface}" FontSize="12"
FontFamily="{StaticResource FontFamily.Mono}"/>
<TextBlock Text="Ad revenue tier" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11"/>
</DockPanel>
<Border Height="8" Background="{StaticResource Brush.SurfaceContainerHigh}" CornerRadius="4" Margin="0,0,0,10">
<Border Height="8" Background="{StaticResource Brush.Tertiary}" CornerRadius="4" HorizontalAlignment="Left"
Width="{Binding Ypp.SubscribersBarWidth}"/>
</Border>
<TextBlock Text="SUBSCRIBERS — TIER 2 · AD REVENUE" Style="{StaticResource YtLabel}" Margin="0,0,0,2"/>
<TextBlock Text="{Binding Ypp.Tier2SubsText}" Foreground="#e0e0e0" FontSize="13"/>
<Border Height="8" Background="#2a2a40" CornerRadius="4" Margin="0,4,0,10">
<Border Height="8" Background="#e94560" CornerRadius="4" HorizontalAlignment="Left"
Width="{Binding Ypp.Tier2SubsBarWidth}"/>
<TextBlock Text="LONG-FORM WATCH HOURS · 12 MONTHS" Style="{StaticResource YtLabel}" Margin="0,0,0,2"/>
<DockPanel Margin="0,0,0,2">
<TextBlock DockPanel.Dock="Right" Text="{Binding Ypp.WatchHoursText}"
Foreground="{StaticResource Brush.OnSurface}" FontSize="12"
FontFamily="{StaticResource FontFamily.Mono}"/>
<TextBlock Text="Public videos" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11"/>
</DockPanel>
<Border Height="8" Background="{StaticResource Brush.SurfaceContainerHigh}" CornerRadius="4" Margin="0,0,0,4">
<Border Height="8" Background="{StaticResource Brush.Tertiary}" CornerRadius="4" HorizontalAlignment="Left"
Width="{Binding Ypp.WatchHoursBarWidth}"/>
</Border>
<TextBlock Text="PUBLIC UPLOADS (90 DAYS)" Style="{StaticResource YtLabel}" Margin="0,0,0,2"/>
<TextBlock Text="{Binding Ypp.UploadsText}" Foreground="#e0e0e0" FontSize="13"/>
<Border Height="8" Background="#2a2a40" CornerRadius="4" Margin="0,4,0,6">
<Border Height="8" Background="#e94560" CornerRadius="4" HorizontalAlignment="Left"
Width="{Binding Ypp.UploadsBarWidth}"/>
<TextBlock Text="Bar rises to 8,000 h on 1 Feb 2027."
Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" Margin="0,0,0,10"/>
<TextBlock Text="SHORTS VIEWS · 90 DAYS" Style="{StaticResource YtLabel}" Margin="0,0,0,2"/>
<DockPanel Margin="0,0,0,2">
<TextBlock DockPanel.Dock="Right" Text="{Binding Ypp.ShortsViewsText}"
Foreground="{StaticResource Brush.OnSurface}" FontSize="12"
FontFamily="{StaticResource FontFamily.Mono}"/>
<TextBlock Text="Shorts feed" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11"/>
</DockPanel>
<Border Height="8" Background="{StaticResource Brush.SurfaceContainerHigh}" CornerRadius="4" Margin="0,0,0,8">
<Border Height="8" Background="{StaticResource Brush.Tertiary}" CornerRadius="4" HorizontalAlignment="Left"
Width="{Binding Ypp.ShortsViewsBarWidth}"/>
</Border>
<TextBlock Text="LONG-FORM WATCH HOURS + SHORTS VIEWS" Style="{StaticResource YtLabel}" Margin="0,10,0,2"/>
<TextBlock Text="These arrive with the extended analytics update — subscriber + upload + standing progress is live now."
Foreground="#666677" FontSize="12" TextWrapping="Wrap"/>
<TextBlock Text="{Binding Ypp.CriteriaMetText}"
Foreground="{StaticResource Brush.OnSurface}" FontSize="12" FontWeight="SemiBold"
TextWrapping="Wrap" Margin="0,0,0,2"/>
<TextBlock Text="Either view route qualifies — subscribers plus watch hours, or subscribers plus Shorts views."
Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11"
TextWrapping="Wrap" Margin="0,0,0,4"/>
<TextBlock Text="{Binding Ypp.ThresholdsAsOfText}"
Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11"
TextWrapping="Wrap" Margin="0,0,0,8"/>
<TextBlock Text="{Binding Ypp.WatchMetricsMissingText}"
Foreground="{StaticResource Brush.Attention}" FontSize="11" TextWrapping="Wrap"
Margin="0,0,0,8"
Visibility="{Binding Ypp.HasWatchMetrics, Converter={StaticResource InverseBoolToVis}}"/>
<TextBlock Text="FAN FUNDING TIER · 500 SUBSCRIBERS" Style="{StaticResource YtLabel}" Margin="0,0,0,2"/>
<TextBlock Text="{Binding Ypp.Tier1SubsText}" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11"
FontFamily="{StaticResource FontFamily.Mono}" Margin="0,0,0,10"/>
<TextBlock Text="COMPLIANCE CHECKLIST" Style="{StaticResource YtLabel}" Margin="0,14,0,4"/>
<TextBlock Text="CHANNEL STANDING" Style="{StaticResource YtLabel}" Margin="0,0,0,2"/>
<TextBlock Text="{Binding Ypp.StandingDetailText}" Foreground="#888899" FontSize="11"
<TextBlock Text="{Binding Ypp.StandingDetailText}" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11"
TextWrapping="Wrap" Margin="0,0,0,8"/>
<CheckBox Content="Two-Step Verification switched on" Style="{StaticResource YtCheckBox}"
FontSize="12" Margin="0,2,0,0"
@@ -622,7 +843,7 @@
<ContentPresenter/>
</ControlTemplate>
</Button.Template>
<TextBlock Text="Set up on Google →" Foreground="#5aa7ff" FontSize="12" Margin="0,0,0,6"/>
<TextBlock Text="Set up on Google →" Foreground="{StaticResource Brush.Tertiary}" FontSize="12" Margin="0,0,0,6"/>
</Button>
<CheckBox Content="AdSense account linked" Style="{StaticResource YtCheckBox}"
FontSize="12" Margin="0,2,0,0"
@@ -634,11 +855,11 @@
<ContentPresenter/>
</ControlTemplate>
</Button.Template>
<TextBlock Text="How to link on YouTube →" Foreground="#5aa7ff" FontSize="12" Margin="0,0,0,6"/>
<TextBlock Text="How to link on YouTube →" Foreground="{StaticResource Brush.Tertiary}" FontSize="12" Margin="0,0,0,6"/>
</Button>
<TextBlock Text="WHAT COUNTS" Style="{StaticResource YtLabel}" Margin="0,10,0,4"/>
<TextBlock Text="{Binding Ypp.WhatCountsText}" Foreground="#a0a0b0" FontSize="12"
<TextBlock Text="{Binding Ypp.WhatCountsText}" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="12"
TextWrapping="Wrap" Margin="0,0,0,6"/>
</StackPanel>
</StackPanel>
@@ -646,12 +867,12 @@
<StackPanel Grid.Row="2" Margin="0,10,0,0">
<Button Style="{StaticResource YtButton}" Content="Refresh from YouTube"
Command="{Binding Ypp.RefreshCommand}"/>
<TextBlock Text="{Binding Ypp.LastUpdatedText}" Foreground="#888899" FontSize="11" Margin="0,6,0,0"/>
<TextBlock Text="{Binding Ypp.LastUpdatedText}" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" Margin="0,6,0,0"/>
</StackPanel>
</Grid>
</Border>
<!-- The Test-stream drawer (below YPP on the rail) -->
<Border Background="#16213e" ClipToBounds="True">
<Border Background="{StaticResource Brush.SurfaceContainer}" ClipToBounds="True">
<Border.Style>
<Style TargetType="Border">
<Setter Property="Width" Value="0"/>
@@ -685,7 +906,7 @@
</Style.Triggers>
</Style>
</Border.Style>
<Grid Width="320" Margin="14,10,14,12">
<Grid Margin="14,10,20,12">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
@@ -703,20 +924,20 @@
<ContentPresenter/>
</ControlTemplate>
</Button.Template>
<TextBlock Text="✕" Foreground="#a0a0b0" FontSize="14" FontWeight="Bold"/>
<TextBlock Text="✕" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="14" FontWeight="Bold"/>
</Button>
</Grid>
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto">
<StackPanel>
<TextBlock Text="{Binding TestSession.SessionCaption}"
Foreground="#d0d0e0" FontSize="13" TextWrapping="Wrap" Margin="0,0,0,8"/>
Foreground="{StaticResource Brush.OnSurface}" FontSize="13" TextWrapping="Wrap" Margin="0,0,0,8"/>
<TextBlock Text="{Binding TestSession.ChatIdLine}"
Foreground="#888899" FontSize="11"
Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11"
Visibility="{Binding TestSession.IsTestSession, Converter={StaticResource BoolToVis}}"/>
<TextBlock Text="MOCK CHAT INPUT" Style="{StaticResource YtLabel}" Margin="0,10,0,3"/>
<TextBlock Text="Sends a real live chat message — it round-trips through the actual chat poll and renders on your chat overlay."
Foreground="#a0a0b0" FontSize="11" TextWrapping="Wrap" Margin="0,0,0,4"/>
Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" TextWrapping="Wrap" Margin="0,0,0,4"/>
<TextBox Style="{StaticResource YtTextBox}" Margin="0,0,0,6"
Text="{Binding TestSession.SendMessageText, UpdateSourceTrigger=PropertyChanged}"/>
<Button Style="{StaticResource YtButton}" Content="Send Message"
@@ -724,7 +945,7 @@
<TextBlock Text="SIMULATED EVENTS" Style="{StaticResource YtLabel}" Margin="0,14,0,3"/>
<TextBlock Text="Fired into your chat overlay locally — YouTube's insert API only creates text, so these never reach YouTube."
Foreground="#a0a0b0" FontSize="11" TextWrapping="Wrap" Margin="0,0,0,4"/>
Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" TextWrapping="Wrap" Margin="0,0,0,4"/>
<StackPanel Orientation="Horizontal" Margin="0,0,0,4">
<Button Style="{StaticResource YtButtonSecondary}" Content="Subscriber" Padding="10,4" FontSize="11"
Margin="0,0,6,0" Command="{Binding TestSession.SimulateSubscriberCommand}"/>
@@ -735,7 +956,7 @@
HorizontalAlignment="Left" Command="{Binding TestSession.SimulateSuperChatCommand}"/>
<TextBlock Text="{Binding TestSession.LastEventText}"
Foreground="#22c55e" FontSize="11" TextWrapping="Wrap" Margin="0,10,0,0"
Foreground="{StaticResource Brush.Positive}" FontSize="11" TextWrapping="Wrap" Margin="0,10,0,0"
Visibility="{Binding TestSession.LastEventText, Converter={StaticResource NotNullToVis}}"/>
</StackPanel>
</ScrollViewer>
@@ -744,230 +965,172 @@
Command="{Binding TestSession.EndTestCommand}"
Visibility="{Binding TestSession.IsTestSession, Converter={StaticResource BoolToVis}}"/>
<TextBlock Text="Test broadcasts are private — nobody is notified, and it runs through the exact same pipeline as a real live."
Foreground="#666677" FontSize="11" TextWrapping="Wrap" Margin="0,6,0,0"/>
Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" TextWrapping="Wrap" Margin="0,6,0,0"/>
</StackPanel>
</Grid>
</Border>
<!-- The Settings drawer: same animated-width pattern as the other
pull-outs, opened by the gear rail tab (creator ruling 2026-11-01).
Settings used to be a centred modal; it is a drawer now. -->
<Border Background="{StaticResource Brush.SurfaceContainer}" ClipToBounds="True">
<Border.Style>
<Style TargetType="Border">
<Setter Property="Width" Value="0"/>
<Style.Triggers>
<DataTrigger Binding="{Binding SettingsDrawerOpen}" Value="True">
<DataTrigger.EnterActions>
<BeginStoryboard>
<Storyboard>
<DoubleAnimation Storyboard.TargetProperty="Width"
To="320" Duration="0:0:0.2">
<DoubleAnimation.EasingFunction>
<CubicEase EasingMode="EaseOut"/>
</DoubleAnimation.EasingFunction>
</DoubleAnimation>
</Storyboard>
</BeginStoryboard>
</DataTrigger.EnterActions>
<DataTrigger.ExitActions>
<BeginStoryboard>
<Storyboard>
<DoubleAnimation Storyboard.TargetProperty="Width"
To="0" Duration="0:0:0.2">
<DoubleAnimation.EasingFunction>
<CubicEase EasingMode="EaseOut"/>
</DoubleAnimation.EasingFunction>
</DoubleAnimation>
</Storyboard>
</BeginStoryboard>
</DataTrigger.ExitActions>
</DataTrigger>
</Style.Triggers>
</Style>
</Border.Style>
<Grid Margin="14,10,20,12">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<Grid Grid.Row="0" Margin="0,0,0,8">
<TextBlock Text="SETTINGS" Style="{StaticResource SectionHeader}" HorizontalAlignment="Left"/>
<Button HorizontalAlignment="Right" VerticalAlignment="Top"
Background="Transparent" BorderThickness="0" Cursor="Hand"
Command="{Binding OpenSettingsCommand}"
ToolTip="Close">
<Button.Template>
<ControlTemplate TargetType="Button">
<ContentPresenter/>
</ControlTemplate>
</Button.Template>
<TextBlock Text="✕" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="14" FontWeight="Bold"/>
</Button>
</Grid>
<controls:SettingsDrawer Grid.Row="1"/>
</Grid>
</Border>
<!-- The stacked white tabs -->
<StackPanel Orientation="Vertical" HorizontalAlignment="Right" VerticalAlignment="Stretch">
<Border Width="30" VerticalAlignment="Center" Background="#ffffff"
<StackPanel Orientation="Vertical" HorizontalAlignment="Right" VerticalAlignment="Center">
<!-- Settings: the first tab, orange, gear only (creator ruling
2026-11-01). A left click opens the Settings DRAWER immediately
— same behaviour as the other rail tabs. The app menu (Report
Bug / Feature Request / About) moved to a right-click on the
LlamaCasty wordmark in the top bar. -->
<Border Width="30" VerticalAlignment="Center" Background="{StaticResource Brush.Attention}"
CornerRadius="4,0,0,4" Padding="0,10" Cursor="Hand" Margin="0,4,0,0"
ToolTip="Settings"
MouseLeftButtonUp="SettingsTab_Click">
<Path Data="M19.43,12.98c0.04,-0.32 0.07,-0.65 0.07,-0.98c0,-0.33 -0.03,-0.66 -0.07,-0.98l2.11,-1.65c0.19,-0.15 0.24,-0.42 0.12,-0.64l-2,-3.46c-0.12,-0.22 -0.38,-0.31 -0.61,-0.22l-2.49,1c-0.52,-0.4 -1.08,-0.73 -1.69,-0.98l-0.38,-2.65C14.46,2.18 14.25,2 14,2h-4c-0.25,0 -0.46,0.18 -0.49,0.42l-0.38,2.65c-0.61,0.25 -1.17,0.59 -1.69,0.98l-2.49,-1c-0.23,-0.09 -0.49,0 -0.61,0.22l-2,3.46c-0.13,0.22 -0.07,0.49 0.12,0.64l2.11,1.65c-0.04,0.32 -0.07,0.65 -0.07,0.98c0,0.33 0.03,0.66 0.07,0.98l-2.11,1.65c-0.19,0.15 -0.24,0.42 -0.12,0.64l2,3.46c0.12,0.22 0.38,0.31 0.61,0.22l2.49,-1c0.52,0.4 1.08,0.73 1.69,0.98l0.38,2.65c0.03,0.24 0.24,0.42 0.49,0.42h4c0.25,0 0.46,-0.18 0.49,-0.42l0.38,-2.65c0.61,-0.25 1.17,-0.59 1.69,-0.98l2.49,1c0.23,0.09 0.49,0 0.61,-0.22l2,-3.46c0.12,-0.22 0.07,-0.49 -0.12,-0.64L19.43,12.98zM12,15.5c-1.93,0 -3.5,-1.57 -3.5,-3.5c0,-1.93 1.57,-3.5 3.5,-3.5c1.93,0 3.5,1.57 3.5,3.5C15.5,13.93 13.93,15.5 12,15.5z"
Fill="{StaticResource Brush.Background}" Width="18" Height="18" Stretch="Uniform"
HorizontalAlignment="Center"/>
</Border>
<Border Width="30" VerticalAlignment="Center" Background="{StaticResource Brush.Youtube}"
CornerRadius="4,0,0,4" Padding="0,10" Cursor="Hand" Margin="0,4,0,0"
ToolTip="Broadcast text fields">
<Border.InputBindings>
<MouseBinding Gesture="LeftClick"
Command="{Binding BroadcastForm.ToggleDrawerCommand}"/>
</Border.InputBindings>
<TextBlock Text="Stream Settings" Foreground="#e94560" FontWeight="Bold" FontSize="15"
<StackPanel HorizontalAlignment="Center">
<Border Width="18" Height="13" CornerRadius="3" Background="White" HorizontalAlignment="Center" Margin="0,0,0,6">
<Border.LayoutTransform><RotateTransform Angle="90"/></Border.LayoutTransform>
<Path Data="M0,0 L6,3.5 L0,7 Z" Fill="{StaticResource Brush.Primary}" Width="6" Height="7"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<TextBlock Text="Stream Info" Foreground="White" FontWeight="Bold" FontSize="15"
HorizontalAlignment="Center">
<TextBlock.LayoutTransform>
<RotateTransform Angle="90"/>
</TextBlock.LayoutTransform>
</TextBlock>
</StackPanel>
</Border>
<Border Width="30" VerticalAlignment="Center" Background="#ffffff"
<Border Width="30" VerticalAlignment="Center" Background="{StaticResource Brush.Youtube}"
CornerRadius="4,0,0,4" Padding="0,10" Cursor="Hand" Margin="0,4,0,0"
ToolTip="YouTube Partner Program progress">
<Border.InputBindings>
<MouseBinding Gesture="LeftClick"
Command="{Binding Ypp.ToggleDrawerCommand}"/>
</Border.InputBindings>
<TextBlock Text="YPP" Foreground="#e94560" FontWeight="Bold" FontSize="15"
<StackPanel HorizontalAlignment="Center">
<Border Width="18" Height="13" CornerRadius="3" Background="White" HorizontalAlignment="Center" Margin="0,0,0,6">
<Border.LayoutTransform><RotateTransform Angle="90"/></Border.LayoutTransform>
<Path Data="M0,0 L6,3.5 L0,7 Z" Fill="{StaticResource Brush.Primary}" Width="6" Height="7"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<TextBlock Text="YPP" Foreground="White" FontWeight="Bold" FontSize="15"
HorizontalAlignment="Center">
<TextBlock.LayoutTransform>
<RotateTransform Angle="90"/>
</TextBlock.LayoutTransform>
</TextBlock>
</StackPanel>
</Border>
<Border Width="30" VerticalAlignment="Center" Background="#ffffff"
<Border Width="30" VerticalAlignment="Center" Background="{StaticResource Brush.Tertiary}"
CornerRadius="4,0,0,4" Padding="0,10" Cursor="Hand" Margin="0,4,0,0"
ToolTip="Add or manage your social handles">
<Border.InputBindings>
<MouseBinding Gesture="LeftClick" Command="{Binding OpenSocialDialogCommand}"/>
</Border.InputBindings>
<StackPanel HorizontalAlignment="Center">
<Path Data="M4,4 m-2,0 a2,2 0 1 0 4,0 a2,2 0 1 0 -4,0 M16,8 m-2,0 a2,2 0 1 0 4,0 a2,2 0 1 0 -4,0 M4,14 m-2,0 a2,2 0 1 0 4,0 a2,2 0 1 0 -4,0 M5.8,5.2 L14.2,7.2 M5.8,12.8 L14.2,8.8"
Stroke="{StaticResource Brush.Background}" StrokeThickness="1.5" Width="18" Height="18" Stretch="Uniform"
HorizontalAlignment="Center" Margin="0,0,0,6">
<Path.LayoutTransform><RotateTransform Angle="90"/></Path.LayoutTransform></Path>
<TextBlock Text="Socials" Foreground="{StaticResource Brush.Background}" FontWeight="Bold" FontSize="15"
HorizontalAlignment="Center">
<TextBlock.LayoutTransform>
<RotateTransform Angle="90"/>
</TextBlock.LayoutTransform>
</TextBlock>
</StackPanel>
</Border>
<Border Width="30" VerticalAlignment="Center" Background="{StaticResource Brush.Attention}"
CornerRadius="4,0,0,4" Padding="0,10" Cursor="Hand" Margin="0,4,0,0"
ToolTip="Test stream — mock chat input and simulated events">
<Border.InputBindings>
<MouseBinding Gesture="LeftClick"
Command="{Binding TestSession.ToggleDrawerCommand}"/>
</Border.InputBindings>
<TextBlock Text="TEST" Foreground="#9c6f1c" FontWeight="Bold" FontSize="15"
<StackPanel HorizontalAlignment="Center">
<Path Data="M8,2 H16 M9,2 V8 L4,18 H20 L15,8 V2 M7,14 H17"
Stroke="{StaticResource Brush.Background}" StrokeThickness="1.5" Width="18" Height="18" Stretch="Uniform"
HorizontalAlignment="Center" Margin="0,0,0,6">
<Path.LayoutTransform><RotateTransform Angle="90"/></Path.LayoutTransform></Path>
<TextBlock Text="TEST" Foreground="{StaticResource Brush.Background}" FontWeight="Bold" FontSize="15"
HorizontalAlignment="Center">
<TextBlock.LayoutTransform>
<RotateTransform Angle="90"/>
</TextBlock.LayoutTransform>
</TextBlock>
</StackPanel>
</Border>
</StackPanel>
</StackPanel>
</Grid>
<!-- ═══ LIVE CONTROLS (below preview, never overlaps) ═══
Line 1: Socials + TRAX meter (desktop/game audio)
Line 2: Mic meter + volume -->
<StackPanel Grid.Row="2" Orientation="Vertical" HorizontalAlignment="Center"
VerticalAlignment="Center" Margin="0,4">
<!-- Line 1: Socials + TRAX -->
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center" Margin="0,0,0,4">
<!-- Socials button: hover glows the social bar in the preview -->
<Button x:Name="SocialsButton"
Command="{Binding OpenSocialDialogCommand}"
Style="{StaticResource YtButtonSecondary}" Padding="12,4"
FontSize="11" VerticalAlignment="Center" Margin="0,0,12,0"
ToolTip="Add or manage your social handles"
MouseEnter="SocialsButton_MouseEnter"
MouseLeave="SocialsButton_MouseLeave">
<StackPanel Orientation="Horizontal">
<Ellipse Width="8" Height="8" Fill="{Binding SocialBarDotBrush}"
Margin="0,0,6,0" VerticalAlignment="Center"/>
<TextBlock Text="Socials"/>
</StackPanel>
</Button>
<!-- TRAX meter -->
<Border Background="#D9000000" CornerRadius="6" Padding="12,8">
<StackPanel Orientation="Horizontal">
<Button Style="{StaticResource YtButtonSecondary}" Padding="8,2"
FontSize="11" VerticalAlignment="Center" Margin="0,0,8,0"
ToolTipService.ToolTip="{Binding TraxToolTip}"
ToolTipService.Placement="Top"
Click="TraxButton_Click"
PreviewMouseRightButtonUp="TraxButton_PreviewMouseRightButtonUp">
<StackPanel Orientation="Horizontal">
<Ellipse Width="8" Height="8" Fill="{Binding TraxDotBrush}"
Margin="0,0,6,0" VerticalAlignment="Center"/>
<TextBlock Text="TRAX"/>
</StackPanel>
</Button>
<TextBlock Text="Desktop Audio" Foreground="#a0a0b0" FontSize="11"
FontWeight="SemiBold" VerticalAlignment="Center" Margin="0,0,8,0"
ToolTip="Desktop/game audio via WASAPI loopback (automatic); TRAX music rides the same channel"/>
<Border Width="288" Height="14" CornerRadius="7" Background="#3a3b52" Margin="0,0,8,0"
ClipToBounds="True" VerticalAlignment="Center">
<Grid>
<Rectangle Width="57" Fill="#4a4520" HorizontalAlignment="Left" Margin="173,0,0,0"
VerticalAlignment="Stretch"/>
<Rectangle Width="58" Fill="#4a2222" HorizontalAlignment="Left" Margin="230,0,0,0"
VerticalAlignment="Stretch"/>
<Border HorizontalAlignment="Left" Width="{Binding GameMeterFillWidth}"
Background="{Binding GameMeterBrush}" Opacity="0.75"/>
<Rectangle Width="1" Height="10" Fill="#26000000" VerticalAlignment="Center" HorizontalAlignment="Left" Margin="36,0,0,0"/>
<Rectangle Width="1" Height="10" Fill="#26000000" VerticalAlignment="Center" HorizontalAlignment="Left" Margin="72,0,0,0"/>
<Rectangle Width="1" Height="10" Fill="#26000000" VerticalAlignment="Center" HorizontalAlignment="Left" Margin="108,0,0,0"/>
<Rectangle Width="1" Height="10" Fill="#26000000" VerticalAlignment="Center" HorizontalAlignment="Left" Margin="144,0,0,0"/>
<Rectangle Width="1" Height="10" Fill="#26000000" VerticalAlignment="Center" HorizontalAlignment="Left" Margin="180,0,0,0"/>
<Rectangle Width="1" Height="10" Fill="#26000000" VerticalAlignment="Center" HorizontalAlignment="Left" Margin="216,0,0,0"/>
<Rectangle Width="1" Height="10" Fill="#26000000" VerticalAlignment="Center" HorizontalAlignment="Left" Margin="252,0,0,0"/>
<Rectangle Width="2" Fill="#00000080" HorizontalAlignment="Left" Margin="173,0,0,0"
VerticalAlignment="Stretch"/>
<Rectangle Width="2" Fill="#00000080" HorizontalAlignment="Left" Margin="230,0,0,0"
VerticalAlignment="Stretch"/>
<!-- TRAX error: a terse message that scrolls across the bar
(marquee) while the TRAX dot is red. -->
<Canvas x:Name="TraxErrorMarquee" ClipToBounds="True"
Visibility="{Binding TraxError, Converter={StaticResource NotNullToVis}}">
<TextBlock x:Name="TraxErrorText" Text="{Binding TraxError}" Foreground="#ff6b81" FontSize="10"
FontWeight="SemiBold" Canvas.Top="0">
<TextBlock.RenderTransform>
<TranslateTransform x:Name="TraxErrorScroll"/>
</TextBlock.RenderTransform>
<TextBlock.Triggers>
<EventTrigger RoutedEvent="FrameworkElement.Loaded">
<BeginStoryboard>
<Storyboard RepeatBehavior="Forever">
<DoubleAnimation Storyboard.TargetName="TraxErrorScroll"
Storyboard.TargetProperty="X"
From="288" To="-220" Duration="0:0:7"/>
</Storyboard>
</BeginStoryboard>
</EventTrigger>
</TextBlock.Triggers>
</TextBlock>
</Canvas>
</Grid>
</Border>
<Grid Width="16" Height="16" Cursor="Hand" Margin="0,0,8,0"
VerticalAlignment="Center" ToolTip="{Binding GameMuteText}"
MouseLeftButtonUp="GameSpeaker_MouseLeftButtonUp">
<Path Fill="#d0d0d0" Stretch="Uniform"
Data="M3,9v6h4l5,5V4L7,9H3zM16.5,12c0,-1.77 -1,-3.29 -2.5,-4.03v8.05c1.5,-0.73 2.5,-2.25 2.5,-4.02zM14,3.23v2.06c2.89,0.86 5,3.54 5,6.71s-2.11,5.85 -5,6.71v2.06c4.01,-0.91 7,-4.49 7,-8.77s-2.99,-7.86 -7,-8.77z"
Visibility="{Binding GameMuted, Converter={StaticResource InverseBoolToVis}}"/>
<Path Fill="#ef4444" Stretch="Uniform"
Data="M3,9v6h4l5,5V4L7,9H3zM16.5,12c0,-1.77 -1,-3.29 -2.5,-4.03v8.05c1.5,-0.73 2.5,-2.25 2.5,-4.02zM14,3.23v2.06c2.89,0.86 5,3.54 5,6.71s-2.11,5.85 -5,6.71v2.06c4.01,-0.91 7,-4.49 7,-8.77s-2.99,-7.86 -7,-8.77z"
Visibility="{Binding GameMuted, Converter={StaticResource BoolToVis}}"/>
<Path Stroke="#ef4444" StrokeThickness="2.5" StrokeEndLineCap="Round" Stretch="Uniform"
Data="M21,3 L3,21"
Visibility="{Binding GameMuted, Converter={StaticResource BoolToVis}}"/>
</Grid>
<Slider Width="130" Minimum="0" Maximum="1"
Value="{Binding GameAudioVolume, Mode=TwoWay}" VerticalAlignment="Center"
PreviewMouseLeftButtonDown="GameVolumeSlider_PreviewMouseLeftButtonDown"
PreviewMouseLeftButtonUp="GameVolumeSlider_PreviewMouseLeftButtonUp"
LostMouseCapture="GameVolumeSlider_LostMouseCapture"/>
</StackPanel>
</Border>
</StackPanel>
<!-- Line 2: Mic meter + volume -->
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center">
<Button Command="{Binding OpenMicPickerCommand}"
Style="{StaticResource YtButtonSecondary}" Padding="12,4"
FontSize="11" VerticalAlignment="Center" Margin="0,0,8,0"
ToolTip="{Binding MicStatusToolTip}">
<StackPanel Orientation="Horizontal">
<Ellipse Width="8" Height="8" Fill="{Binding MicStatusBrush}"
Margin="0,0,6,0" VerticalAlignment="Center"/>
<TextBlock Text="MIC"/>
</StackPanel>
</Button>
<Border Width="288" Height="14" CornerRadius="7" Background="#3a3b52" Margin="0,0,8,0"
ClipToBounds="True" VerticalAlignment="Center">
<Grid>
<Rectangle Width="57" Fill="#4a4520" HorizontalAlignment="Left" Margin="173,0,0,0"
VerticalAlignment="Stretch"/>
<Rectangle Width="58" Fill="#4a2222" HorizontalAlignment="Left" Margin="230,0,0,0"
VerticalAlignment="Stretch"/>
<TextBlock Text="{Binding MicSourceName}" Foreground="#b8b8c8" FontSize="10"
VerticalAlignment="Center" Margin="6,0,0,0" MaxWidth="270"
TextTrimming="CharacterEllipsis"
Visibility="{Binding MicSourceName, Converter={StaticResource NotNullToVis}}"/>
<Border HorizontalAlignment="Left" Width="{Binding MeterFillWidth}"
Background="{Binding MeterBrush}" Opacity="0.75"/>
<Rectangle Width="1" Height="10" Fill="#26000000" VerticalAlignment="Center" HorizontalAlignment="Left" Margin="36,0,0,0"/>
<Rectangle Width="1" Height="10" Fill="#26000000" VerticalAlignment="Center" HorizontalAlignment="Left" Margin="72,0,0,0"/>
<Rectangle Width="1" Height="10" Fill="#26000000" VerticalAlignment="Center" HorizontalAlignment="Left" Margin="108,0,0,0"/>
<Rectangle Width="1" Height="10" Fill="#26000000" VerticalAlignment="Center" HorizontalAlignment="Left" Margin="144,0,0,0"/>
<Rectangle Width="1" Height="10" Fill="#26000000" VerticalAlignment="Center" HorizontalAlignment="Left" Margin="180,0,0,0"/>
<Rectangle Width="1" Height="10" Fill="#26000000" VerticalAlignment="Center" HorizontalAlignment="Left" Margin="216,0,0,0"/>
<Rectangle Width="1" Height="10" Fill="#26000000" VerticalAlignment="Center" HorizontalAlignment="Left" Margin="252,0,0,0"/>
<Rectangle Width="2" Fill="#00000080" HorizontalAlignment="Left" Margin="173,0,0,0"
VerticalAlignment="Stretch"/>
<Rectangle Width="2" Fill="#00000080" HorizontalAlignment="Left" Margin="230,0,0,0"
VerticalAlignment="Stretch"/>
</Grid>
</Border>
<Grid Width="16" Height="16" Cursor="Hand" Margin="0,0,8,0"
VerticalAlignment="Center" ToolTip="{Binding MicMuteText}"
MouseLeftButtonUp="MicSpeaker_MouseLeftButtonUp">
<Path Fill="#d0d0d0" Stretch="Uniform"
Data="M12,14c1.66,0 3,-1.34 3,-3V5c0,-1.66 -1.34,-3 -3,-3S9,3.34 9,5v6C9,12.66 10.34,14 12,14zM17.3,11c0,3 -2.54,5.1 -5.3,5.1S6.7,14 6.7,11H5c0,3.41 2.72,6.23 6,6.72V21h2v-3.28c3.28,-0.49 6,-3.31 6,-6.72H17.3z"
Visibility="{Binding MicMuted, Converter={StaticResource InverseBoolToVis}}"/>
<Path Fill="#ef4444" Stretch="Uniform"
Data="M12,14c1.66,0 3,-1.34 3,-3V5c0,-1.66 -1.34,-3 -3,-3S9,3.34 9,5v6C9,12.66 10.34,14 12,14zM17.3,11c0,3 -2.54,5.1 -5.3,5.1S6.7,14 6.7,11H5c0,3.41 2.72,6.23 6,6.72V21h2v-3.28c3.28,-0.49 6,-3.31 6,-6.72H17.3z"
Visibility="{Binding MicMuted, Converter={StaticResource BoolToVis}}"/>
<Path Stroke="#ef4444" StrokeThickness="2.5" StrokeEndLineCap="Round" Stretch="Uniform"
Data="M21,3 L3,21"
Visibility="{Binding MicMuted, Converter={StaticResource BoolToVis}}"/>
</Grid>
<Slider Width="130" Minimum="0" Maximum="1"
Value="{Binding MicVolume, Mode=TwoWay}" VerticalAlignment="Center"
PreviewMouseLeftButtonDown="VolumeSlider_PreviewMouseLeftButtonDown"
PreviewMouseLeftButtonUp="VolumeSlider_PreviewMouseLeftButtonUp"
LostMouseCapture="VolumeSlider_LostMouseCapture"/>
<StackPanel Orientation="Horizontal" Margin="10,0,0,0" VerticalAlignment="Center"
ToolTip="Audio sync (−500..+500 ms). Positive: delays the audio when it runs ahead of the video. Negative: advances it (drops the first |N| ms of the recording) when it runs behind. Calibrate with a clap: is the clap late in the video, move negative; early, move positive.">
<TextBlock Text="AUDIO SYNC" FontSize="9" Foreground="#9898aa"
VerticalAlignment="Center" Margin="0,0,5,0"/>
<Slider Width="70" Minimum="-500" Maximum="500"
Value="{Binding AudioSyncOffsetMs, Mode=TwoWay}"
IsEnabled="{Binding IsEditMode}"
VerticalAlignment="Center"
ToolTip="{Binding AudioSyncOffsetMs, StringFormat=Audio sync: {0} ms}"/>
</StackPanel>
</StackPanel>
</StackPanel>
<!-- LIVE CONTROLS moved to the audio dock (2026-10-30): the mic row, the game
chip, TRAX and the sync slider all live there now. One audio surface. -->
</Grid>
</Border>
</UserControl>
+278 -41
View File
@@ -31,27 +31,91 @@ public partial class PreviewPane : UserControl
{
InitializeComponent();
DataContextChanged += PreviewPane_DataContextChanged;
OverlayElements.ItemContainerGenerator.StatusChanged += (_, _) => UpdateSelectedLayerZOrder();
}
private void PreviewPane_DataContextChanged(object sender, DependencyPropertyChangedEventArgs e)
{
if (e.OldValue is MainViewModel oldVm)
{
oldVm.PropertyChanged -= ViewModel_PropertyChanged;
oldVm.PreviewTransition -= PlayPreviewTransition;
}
if (e.NewValue is MainViewModel newVm)
{
newVm.PropertyChanged += ViewModel_PropertyChanged;
newVm.PreviewTransition += PlayPreviewTransition;
UpdateZoomBox();
UpdateSelectedLayerZOrder();
HookBlurPreview();
}
}
/// <summary>Animates the outgoing picture away over the new scene: a fade for Fade and Move,
/// a right-to-left shrink for Wipe. Cut plays nothing.</summary>
private void PlayPreviewTransition(ytLive.Services.TransitionType type, double milliseconds)
{
TransitionOverlay.BeginAnimation(System.Windows.UIElement.OpacityProperty, null);
TransitionOverlay.Clip = null;
if (type == ytLive.Services.TransitionType.Cut || milliseconds <= 0)
{
TransitionOverlay.Visibility = Visibility.Collapsed;
return;
}
var snapshot = new System.Windows.Media.Imaging.RenderTargetBitmap(1920, 1080, 96, 96,
System.Windows.Media.PixelFormats.Pbgra32);
snapshot.Render(PreviewCanvas);
TransitionOverlay.Source = snapshot;
TransitionOverlay.Opacity = 1;
TransitionOverlay.Visibility = Visibility.Visible;
var duration = TimeSpan.FromMilliseconds(milliseconds);
if (type == ytLive.Services.TransitionType.Wipe)
{
var clip = new System.Windows.Media.RectangleGeometry(new Rect(0, 0, 1920, 1080));
TransitionOverlay.Clip = clip;
var reveal = new System.Windows.Media.Animation.RectAnimation(new Rect(0, 0, 0, 1080), duration)
{
FillBehavior = System.Windows.Media.Animation.FillBehavior.Stop,
};
reveal.Completed += (_, _) => TransitionOverlay.Visibility = Visibility.Collapsed;
clip.BeginAnimation(System.Windows.Media.RectangleGeometry.RectProperty, reveal);
}
else
{
var fade = new System.Windows.Media.Animation.DoubleAnimation(1, 0, duration)
{
FillBehavior = System.Windows.Media.Animation.FillBehavior.Stop,
};
fade.Completed += (_, _) => TransitionOverlay.Visibility = Visibility.Collapsed;
TransitionOverlay.BeginAnimation(System.Windows.UIElement.OpacityProperty, fade);
}
}
private void ViewModel_PropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(MainViewModel.SelectedElement))
UpdateSelectedLayerZOrder();
if (e.PropertyName == nameof(MainViewModel.IsZoomActive) || e.PropertyName == nameof(MainViewModel.ZoomState) || e.PropertyName == nameof(MainViewModel.ZoomShape))
{
UpdateZoomBox();
}
}
private void UpdateSelectedLayerZOrder()
{
var selected = (DataContext as MainViewModel)?.SelectedElement;
if (selected != null && !IsDraggableElement(selected))
selected = null;
for (var i = 0; i < OverlayElements.Items.Count; i++)
{
if (OverlayElements.ItemContainerGenerator.ContainerFromIndex(i) is UIElement container)
Panel.SetZIndex(container, ReferenceEquals(OverlayElements.Items[i], selected) ? 1 : 0);
}
}
// Static images and the webcam share the move/resize/selection behavior.
@@ -145,13 +209,6 @@ public partial class PreviewPane : UserControl
return null;
}
private void MicSpeaker_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
{
if (sender is FrameworkElement { DataContext: MainViewModel vm })
{
vm.ToggleMicMuteCommand.Execute(null);
}
}
private void GameSpeaker_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
{
@@ -164,18 +221,7 @@ public partial class PreviewPane : UserControl
// TRAX (TASK 9): left-click toggles play/pause (picks a track when none is
// loaded); right-click always opens the picker. The picker is the OS file
// dialog — the "never the OS viewer" rule covers the preview, not pickers.
private void TraxButton_Click(object sender, RoutedEventArgs e)
{
if (DataContext is ViewModels.MainViewModel vm)
vm.TraxLeftClick();
}
private void TraxButton_PreviewMouseRightButtonUp(object sender, MouseButtonEventArgs e)
{
if (DataContext is ViewModels.MainViewModel vm)
vm.TraxRightClick();
e.Handled = true; // the button's right-click context menu must not fire
}
// ─── Webcam / background context menus (right-click on overlay elements) ───
private void WebcamMenu_ChangeWebcam(object sender, RoutedEventArgs e)
@@ -302,6 +348,34 @@ public partial class PreviewPane : UserControl
sb.Children.Add(anim);
}
/// <summary>The border-colour submenu is BUILT from the palette on open: the list of
/// offered colours lives once, in <see cref="BorderColorPalette"/> (TASK 63's rule —
/// a picker never carries its own copy). The generated rows inherit this menu's
/// DataContext (the <see cref="SceneElement"/>), so the click handler is unchanged.</summary>
private void WebcamBorderColorMenu_SubmenuOpened(object sender, RoutedEventArgs e)
{
if (sender is not MenuItem { DataContext: SceneElement element } menu) return;
menu.Items.Clear();
var none = new MenuItem { Header = "None", Tag = string.Empty, IsCheckable = true,
IsChecked = string.IsNullOrEmpty(element.BorderColor) };
none.Click += WebcamMenu_SetBorderColor;
menu.Items.Add(none);
foreach (var colour in BorderColorPalette.All)
{
var row = new MenuItem
{
Header = colour.ToString(),
Tag = colour.Hex,
IsCheckable = true,
IsChecked = string.Equals(element.BorderColor, colour.Hex, StringComparison.OrdinalIgnoreCase),
};
row.Click += WebcamMenu_SetBorderColor;
menu.Items.Add(row);
}
}
private void WebcamMenu_SetBorderColor(object sender, RoutedEventArgs e)
{
if (sender is MenuItem { DataContext: SceneElement element, Tag: string color })
@@ -329,6 +403,18 @@ public partial class PreviewPane : UserControl
e.Handled = true;
}
/// <summary>Is this canvas point inside the social bar? It is a full-width strip at the top or
/// bottom of the frame and it consumes its own click, so the preview's tunnelling handler has
/// to step aside (see <c>Preview_MouseLeftButtonDown</c>).</summary>
private bool IsOverSocialBar(Point canvasPoint)
{
if (DataContext is not MainViewModel { SocialBarVisible: true }) return false;
if (SocialBarElement.ActualHeight <= 0) return false;
var topLeft = SocialBarElement.TranslatePoint(new Point(0, 0), CanvasGrid);
return new Rect(topLeft, SocialBarElement.RenderSize).Contains(canvasPoint);
}
private void SocialsButton_MouseEnter(object sender, MouseEventArgs e)
{
if (SocialBarElement == null) return;
@@ -378,14 +464,35 @@ public partial class PreviewPane : UserControl
private void Preview_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
var vm = DataContext as MainViewModel;
if (vm is null || !vm.IsEditMode) return;
if (vm is null) return;
var grid = (Grid)sender;
var toCanvas = CanvasGrid.TransformToVisual(grid).Inverse;
var p = toCanvas.Transform(e.GetPosition(grid));
// The social bar owns its own left-click (toggle top ⇄ bottom). This handler is the
// TUNNELLING preview event, so it fires before the bar's bubbling handler: without this
// guard the bar would be unclickable in preview mode (zoom would win) and, in edit mode,
// it would drag whatever layer sat underneath it.
if (IsOverSocialBar(p)) return;
// Preview mode (EDIT off): the left button drives the zoom box — the common gesture in
// the mode where nothing is editable. Dragging it afterwards is handled by MouseMove.
if (!vm.IsEditMode)
{
vm.ToggleZoom(p.X, p.Y);
e.Handled = true;
return;
}
// Zoom freezes the layers (creator ruling 2026-11-01): while the magnifier is up, a left
// drag moves the box and NEVER picks up a layer underneath it. MouseMove owns the drag.
if (vm.IsZoomActive) return;
var selected = vm.SelectedElement;
if (selected is { } sel && IsDraggableElement(sel) && HitHandle(e.GetPosition(grid), sel))
{
vm.PushPreviewUndo();
_isResizing = true;
_resizeAspect = sel.ClipShape == ClipShape.Round ? 1 : sel.Width / Math.Max(1, sel.Height);
grid.CaptureMouse();
@@ -393,10 +500,13 @@ public partial class PreviewPane : UserControl
return;
}
var hit = HitElement(vm, p);
var hit = HitElementAt(vm, p);
if (hit != null)
{
vm.SelectedElement = hit;
// One snapshot per gesture: pushing inside the move loop would fill the stack with
// a hundred one-pixel steps.
vm.PushPreviewUndo();
_isDraggingOverlay = true;
_grabOffset = new Point(p.X - hit.X, p.Y - hit.Y);
grid.CaptureMouse();
@@ -499,26 +609,29 @@ public partial class PreviewPane : UserControl
private void Preview_MouseRightButtonDown(object sender, MouseButtonEventArgs e)
{
// Right-click = the context menu, in both modes (creator ruling 2026-11-01).
// Ctrl+right-click = the zoom box, EDIT MODE ONLY: the creator asked for no two-handed
// chords while live or recording, so the preview swallows Ctrl+right-click instead of
// letting it fall through to the menu. Returning without Handled is what lets WPF's
// ContextMenuService open the menu under the cursor.
var vm = DataContext as MainViewModel;
if (vm == null) return;
var grid = (Grid)sender;
var toCanvas = CanvasGrid.TransformToVisual(grid).Inverse;
var p = toCanvas.Transform(e.GetPosition(grid));
if ((vm.IsLive || vm.IsRecording) && !vm.IsEditMode)
if (vm is null) return;
if ((Keyboard.Modifiers & ModifierKeys.Control) == 0) return;
if (!vm.IsEditMode)
{
if (vm.IsZoomActive)
{
vm.DismissZoom();
e.Handled = true;
return;
}
vm.ActivateZoom(p.X, p.Y);
e.Handled = true;
return;
}
vm.ToggleZoom();
var grid = (Grid)sender;
var toCanvas = CanvasGrid.TransformToVisual(grid).Inverse;
var p = toCanvas.Transform(e.GetPosition(grid));
vm.ToggleZoom(p.X, p.Y);
e.Handled = true;
} private void Preview_MouseRightButtonUp(object sender, MouseButtonEventArgs e)
}
private void Preview_MouseRightButtonUp(object sender, MouseButtonEventArgs e)
{
if (_isZoomDragging)
{
@@ -529,6 +642,13 @@ public partial class PreviewPane : UserControl
e.Handled = true;
return;
}
// WPF's PopupControlService opens a context menu on right-button-UP, not down — so
// handling the Down event alone still let a menu appear under a Ctrl+right-click (found
// by the creator in edit mode, 2026-11-01). Swallow the Up event too, in both modes:
// Down decides the action, Up must not undo that decision.
if ((Keyboard.Modifiers & ModifierKeys.Control) != 0)
e.Handled = true;
}
private void EndPreviewDrag(Grid grid)
@@ -547,23 +667,41 @@ public partial class PreviewPane : UserControl
return Math.Abs(mouseScreen.X - corner.X) <= 20 && Math.Abs(mouseScreen.Y - corner.Y) <= 20;
}
private SceneElement? HitElement(MainViewModel vm, Point p)
internal SceneElement? HitElementAt(MainViewModel vm, Point p)
{
var scene = vm.StagedScene;
if (scene == null) return null;
// A layer selected from the list temporarily wins both preview stacking and hit-testing;
// the scene's persistent element order remains unchanged.
if (vm.SelectedElement is { } selected && IsHitTestable(selected) && Contains(selected, p))
return selected;
// The brand logo is drawn ABOVE everything (the compositor blits it after the blur group,
// and the preview renders it in BrandLogoOverlay), so a click inside it must select it —
// otherwise you see the logo on top and grab the webcam underneath it.
var brand = vm.StagedBrandLogo;
if (brand is { IsVisible: true } && Contains(brand, p))
return brand;
for (var i = scene.Elements.Count - 1; i >= 0; i--)
{
var element = scene.Elements[i];
if (!IsDraggableElement(element)) continue;
if (element is Source { IsEnabled: false }) continue;
if (element is WebcamSceneConfig { IsVisible: false }) continue;
if (p.X >= element.X && p.X <= element.X + element.Width &&
p.Y >= element.Y && p.Y <= element.Y + element.Height)
if (IsHitTestable(element) && Contains(element, p))
return element;
}
return null;
}
private static bool IsHitTestable(SceneElement element)
=> IsDraggableElement(element)
&& element is not Source { IsEnabled: false }
&& element is not WebcamSceneConfig { IsVisible: false };
private static bool Contains(SceneElement element, Point point)
=> point.X >= element.X && point.X <= element.X + element.Width
&& point.Y >= element.Y && point.Y <= element.Y + element.Height;
private void UpdateZoomBox()
{
if (DataContext is not MainViewModel vm) return;
@@ -599,4 +737,103 @@ public partial class PreviewPane : UserControl
ZoomVisualBrush.Visual = null;
}
}
}
/// <summary>Rail settings tab (creator ruling 2026-11-01): a left click opens the Settings
/// DRAWER immediately, exactly like the other rail tabs — no menu, no modal.</summary>
private void SettingsTab_Click(object sender, MouseButtonEventArgs e)
{
if (DataContext is MainViewModel vm) vm.ToggleSettingsDrawer();
e.Handled = true;
}
// ─── Privacy blur preview (2026-11-01) ───
// The editor has to show the blur it is about to bake into the output, and the blur is
// UNIVERSAL: it covers every layer, webcam and chat included. Each blur becomes a
// rectangle/ellipse filled with a VisualBrush of the whole CanvasGrid sampled at that rect,
// with a BlurEffect at the same strength the compositor uses. The host canvas is a SIBLING
// above CanvasGrid, which is what stops the brush from sampling itself.
private MainViewModel? _blurVm;
private Scene? _blurScene;
private void HookBlurPreview()
{
if (_blurVm != null) _blurVm.PropertyChanged -= BlurViewModelChanged;
_blurVm = DataContext as MainViewModel;
if (_blurVm != null) _blurVm.PropertyChanged += BlurViewModelChanged;
HookBlurScene();
}
private void BlurViewModelChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
{
if (e.PropertyName is nameof(MainViewModel.StagedScene)) HookBlurScene();
else if (e.PropertyName is nameof(MainViewModel.ActiveBackgroundImage)
or nameof(MainViewModel.BackgroundImage)) RebuildBlurPreview();
}
private void HookBlurScene()
{
if (_blurScene != null)
{
_blurScene.Elements.CollectionChanged -= BlurElementsChanged;
foreach (var element in _blurScene.Elements) element.PropertyChanged -= BlurElementChanged;
}
_blurScene = _blurVm?.StagedScene;
if (_blurScene != null)
{
_blurScene.Elements.CollectionChanged += BlurElementsChanged;
foreach (var element in _blurScene.Elements) element.PropertyChanged += BlurElementChanged;
}
RebuildBlurPreview();
}
private void BlurElementsChanged(object? sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
{
foreach (var element in e.OldItems?.OfType<SceneElement>() ?? System.Linq.Enumerable.Empty<SceneElement>())
element.PropertyChanged -= BlurElementChanged;
foreach (var element in e.NewItems?.OfType<SceneElement>() ?? System.Linq.Enumerable.Empty<SceneElement>())
element.PropertyChanged += BlurElementChanged;
RebuildBlurPreview();
}
private void BlurElementChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
{
if (sender is Source { IsBlur: true }) RebuildBlurPreview();
}
/// <summary>Rebuild one overlay shape per visible blur. Cheap by design: a handful of shapes,
/// and the VisualBrush samples the live backdrop so the preview keeps moving underneath.</summary>
private void RebuildBlurPreview()
{
if (BlurPreviewHost == null) return;
BlurPreviewHost.Children.Clear();
if (_blurScene == null) return;
foreach (var blur in _blurScene.Elements.OfType<Source>()
.Where(s => s.IsBlur && s.IsVisible && s.Width > 0 && s.Height > 0))
{
var shape = new Rectangle
{
Width = blur.Width,
Height = blur.Height,
Fill = new VisualBrush(CanvasGrid)
{
Viewbox = new Rect(blur.X, blur.Y, blur.Width, blur.Height),
ViewboxUnits = BrushMappingMode.Absolute,
Stretch = Stretch.Fill,
},
// 4..24 px at master scale, the same shape of curve the compositor's radius uses.
Effect = new BlurEffect { Radius = 4 + 20 * Math.Clamp(blur.BlurAmount, 0, 1), KernelType = KernelType.Gaussian },
};
if (blur.ClipShape == ClipShape.Round)
shape.Clip = new EllipseGeometry(new Rect(0, 0, blur.Width, blur.Height));
Canvas.SetLeft(shape, blur.X);
Canvas.SetTop(shape, blur.Y);
BlurPreviewHost.Children.Add(shape);
}
}
}
+25 -25
View File
@@ -1,42 +1,45 @@
<UserControl x:Class="ytLive.Controls.SceneThumbnailStrip"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Viewbox Stretch="Uniform">
<Border Width="1000" Height="150" Background="#0d1117" CornerRadius="4" Margin="0,0,0,4" Padding="4">
<ItemsControl ItemsSource="{Binding Scenes}">
<Border Background="{StaticResource Brush.SurfaceContainerLowest}" CornerRadius="4" Padding="4">
<ItemsControl ItemsSource="{Binding Scenes}" HorizontalAlignment="Center" VerticalAlignment="Center">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<UniformGrid Rows="1" Columns="5"/>
<StackPanel Orientation="Horizontal"/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border CornerRadius="4" Margin="2" Padding="2"
<Border Width="196" Height="130" CornerRadius="4" Margin="2" Padding="2"
BorderThickness="2"
Cursor="Hand"
MouseLeftButtonDown="Thumbnail_MouseLeftButtonDown">
<Border.Style>
<Style TargetType="Border">
<Setter Property="BorderBrush" Value="#333344"/>
<Setter Property="BorderBrush" Value="{StaticResource Brush.Outline}"/>
<Style.Triggers>
<!-- Active scene = orange (creator ruling 2026-10-30): it marks
the stage you are working on, not a selection — blue is
the interactive accent and read as "selected". Live
stays red. -->
<DataTrigger Binding="{Binding IsStaged}" Value="True">
<Setter Property="BorderBrush" Value="#ff8c3a"/>
<Setter Property="BorderBrush" Value="{StaticResource Brush.Attention}"/>
</DataTrigger>
<DataTrigger Binding="{Binding IsLive}" Value="True">
<Setter Property="BorderBrush" Value="#ff8c3a"/>
<Setter Property="BorderBrush" Value="{StaticResource Brush.Primary}"/>
</DataTrigger>
</Style.Triggers>
</Style>
</Border.Style>
<Grid>
<Border CornerRadius="3" Background="#0a0a1a" ClipToBounds="True">
<Border CornerRadius="3" Background="{StaticResource Brush.SurfaceContainerLowest}" ClipToBounds="True">
<Image Source="{Binding Snapshot}"
Stretch="UniformToFill"
RenderOptions.BitmapScalingMode="LowQuality"/>
</Border>
<Border HorizontalAlignment="Left" VerticalAlignment="Top" Margin="6" Padding="8,3"
CornerRadius="3" Background="#ff8c3a">
CornerRadius="3" Background="{StaticResource Brush.Primary}">
<Border.Style>
<Style TargetType="Border">
<Setter Property="Visibility" Value="Collapsed"/>
@@ -48,7 +51,7 @@
</Style>
</Border.Style>
<StackPanel Orientation="Horizontal">
<Ellipse Width="8" Height="8" Fill="#ff5451" VerticalAlignment="Center" Margin="0,0,5,0">
<Ellipse Width="8" Height="8" Fill="{StaticResource Brush.Primary}" VerticalAlignment="Center" Margin="0,0,5,0">
<Ellipse.Triggers>
<EventTrigger RoutedEvent="FrameworkElement.Loaded">
<BeginStoryboard>
@@ -63,25 +66,22 @@
</StackPanel>
</Border>
<TextBlock Text="{Binding HotkeyLabel, Mode=OneWay}" Foreground="White" FontSize="16" FontWeight="SemiBold"
HorizontalAlignment="Right" VerticalAlignment="Top" Margin="6">
<TextBlock.Effect>
<DropShadowEffect BlurRadius="4" ShadowDepth="0" Opacity="0.9"/>
</TextBlock.Effect>
</TextBlock>
<Border HorizontalAlignment="Right" VerticalAlignment="Top" Margin="6" Padding="8,2"
CornerRadius="10" Background="#000000">
<TextBlock Text="{Binding HotkeyLabel, Mode=OneWay}" Foreground="White" FontSize="16" FontWeight="SemiBold"/>
</Border>
<TextBlock Text="{Binding Name, Mode=OneWay}" Foreground="White" FontSize="16" FontWeight="SemiBold"
HorizontalAlignment="Left" VerticalAlignment="Bottom" Margin="6"
TextTrimming="CharacterEllipsis">
<TextBlock.Effect>
<DropShadowEffect BlurRadius="4" ShadowDepth="0" Opacity="0.9"/>
</TextBlock.Effect>
</TextBlock>
<!-- The scene name wears the same pill as the hotkey chip, so both
labels stay readable over any thumbnail. -->
<Border HorizontalAlignment="Left" VerticalAlignment="Bottom" Margin="6"
Padding="8,2" CornerRadius="10" Background="#000000">
<TextBlock Text="{Binding Name, Mode=OneWay}" Foreground="White" FontSize="15" FontWeight="SemiBold"
TextTrimming="CharacterEllipsis"/>
</Border>
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</Border>
</Viewbox>
</UserControl>
-3
View File
@@ -8,12 +8,9 @@ namespace ytLive.Controls;
public partial class SceneThumbnailStrip : UserControl
{
private const double DesignAspect = 154.0 / 1000.0;
public SceneThumbnailStrip()
{
InitializeComponent();
SizeChanged += (_, _) => Height = ActualWidth * DesignAspect;
Loaded += (_, _) =>
{
if (DataContext is MainViewModel vm)
+105
View File
@@ -0,0 +1,105 @@
using System;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
using ytLive.Helpers;
namespace ytLive.Controls;
/// <summary>A segmented level ladder for the audio dock.
///
/// <para>Drawn in ONE <see cref="OnRender"/> pass. Twenty-four <c>Rectangle</c>s per meter would
/// mean hundreds of elements updating on the UI thread; a single drawing call does not. The
/// consumer updates <see cref="Level"/> at ~20 Hz — music-level smoothness is not needed and the
/// paint cost should never compete with the compositor.</para>
///
/// <para>Level is a 0…1 display fraction (see <see cref="MeterScale"/>), never a gain setting: the
/// ladder shows what was measured.</para></summary>
public sealed class SegmentMeter : FrameworkElement
{
public static readonly DependencyProperty LevelProperty = DependencyProperty.Register(
nameof(Level), typeof(double), typeof(SegmentMeter),
new FrameworkPropertyMetadata(0d, FrameworkPropertyMetadataOptions.AffectsRender));
/// <summary>Peak-hold mark, 0…1. Falls back slowly so a clip stays visible long enough to see.</summary>
public static readonly DependencyProperty PeakProperty = DependencyProperty.Register(
nameof(Peak), typeof(double), typeof(SegmentMeter),
new FrameworkPropertyMetadata(0d, FrameworkPropertyMetadataOptions.AffectsRender));
public static readonly DependencyProperty SegmentsProperty = DependencyProperty.Register(
nameof(Segments), typeof(int), typeof(SegmentMeter),
new FrameworkPropertyMetadata(24, FrameworkPropertyMetadataOptions.AffectsRender));
public static readonly DependencyProperty OrientationProperty = DependencyProperty.Register(
nameof(Orientation), typeof(Orientation), typeof(SegmentMeter),
new FrameworkPropertyMetadata(Orientation.Vertical, FrameworkPropertyMetadataOptions.AffectsRender));
public double Level { get => (double)GetValue(LevelProperty); set => SetValue(LevelProperty, value); }
public double Peak { get => (double)GetValue(PeakProperty); set => SetValue(PeakProperty, value); }
public int Segments { get => (int)GetValue(SegmentsProperty); set => SetValue(SegmentsProperty, value); }
public Orientation Orientation { get => (Orientation)GetValue(OrientationProperty); set => SetValue(OrientationProperty, value); }
public SegmentMeter()
{
// Token-driven, not literal: the ladder cannot drift from DESIGN.md.
SetResourceReference(NominalBrushProperty, "Brush.Meter.Nominal");
SetResourceReference(CautionBrushProperty, "Brush.Meter.Caution");
SetResourceReference(PeakBrushProperty, "Brush.Meter.Peak");
SetResourceReference(DimBrushProperty, "Brush.SurfaceContainerHighest");
}
private static readonly DependencyProperty NominalBrushProperty = DependencyProperty.Register(
"NominalBrush", typeof(Brush), typeof(SegmentMeter), new FrameworkPropertyMetadata(Brushes.Cyan, FrameworkPropertyMetadataOptions.AffectsRender));
private static readonly DependencyProperty CautionBrushProperty = DependencyProperty.Register(
"CautionBrush", typeof(Brush), typeof(SegmentMeter), new FrameworkPropertyMetadata(Brushes.Orange, FrameworkPropertyMetadataOptions.AffectsRender));
private static readonly DependencyProperty PeakBrushProperty = DependencyProperty.Register(
"PeakBrush", typeof(Brush), typeof(SegmentMeter), new FrameworkPropertyMetadata(Brushes.Red, FrameworkPropertyMetadataOptions.AffectsRender));
private static readonly DependencyProperty DimBrushProperty = DependencyProperty.Register(
"DimBrush", typeof(Brush), typeof(SegmentMeter), new FrameworkPropertyMetadata(Brushes.DimGray, FrameworkPropertyMetadataOptions.AffectsRender));
private Brush NominalBrush => (Brush)GetValue(NominalBrushProperty);
private Brush CautionBrush => (Brush)GetValue(CautionBrushProperty);
private Brush PeakBrush => (Brush)GetValue(PeakBrushProperty);
private Brush DimBrush => (Brush)GetValue(DimBrushProperty);
private const double Gap = 2;
protected override void OnRender(DrawingContext dc)
{
var count = Math.Max(1, Segments);
var vertical = Orientation == Orientation.Vertical;
var extent = vertical ? ActualHeight : ActualWidth;
var thickness = vertical ? ActualWidth : ActualHeight;
if (extent <= 0 || thickness <= 0) return;
var segmentSize = (extent - Gap * (count - 1)) / count;
if (segmentSize <= 0) return;
var lit = Math.Clamp(Level, 0, 1) * count;
for (var i = 0; i < count; i++)
{
// Segment 0 sits at the bottom (vertical) or the left (horizontal).
var fraction = (i + 1) / (double)count;
var brush = fraction >= MeterScale.ToFraction(MeterScale.PeakDb) ? PeakBrush
: fraction >= MeterScale.ToFraction(MeterScale.CautionDb) ? CautionBrush
: NominalBrush;
if (i >= lit) brush = DimBrush;
var offset = i * (segmentSize + Gap);
var rect = vertical
? new Rect(0, extent - offset - segmentSize, thickness, segmentSize)
: new Rect(offset, 0, segmentSize, thickness);
dc.DrawRectangle(brush, null, rect);
}
// Peak-hold: a 2px marker at the held level, drawn above the ladder.
var peak = Math.Clamp(Peak, 0, 1);
if (peak <= 0) return;
var line = vertical
? new Rect(0, extent - peak * extent - 1, thickness, 2)
: new Rect(peak * extent - 1, 0, 2, thickness);
dc.DrawRectangle(PeakBrush, null, line);
}
}
+168
View File
@@ -0,0 +1,168 @@
<UserControl x:Class="ytLive.Controls.SettingsDrawer"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:models="clr-namespace:ytLive.Models"
xmlns:services="clr-namespace:ytLive.Services"
xmlns:notification="clr-namespace:Notification.Core;assembly=Notification.Core">
<!-- App Settings, as a RIGHT-EDGE PULL-OUT (creator ruling 2026-11-01): the gear tab opens
this drawer immediately, in the same animated-width pattern as Stream Settings / YPP /
Socials / TEST. It used to be a centred modal overlay, which then needed its own scroll
viewer once the section list outgrew the shell — a drawer has the height of the preview
and that problem disappears.
Categories are headings; their controls are indented under them (Zoom is one). -->
<ScrollViewer VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled">
<StackPanel Margin="0,0,0,0">
<!-- ═══ RECORDINGS ═══ -->
<TextBlock Text="Recordings" Style="{StaticResource SectionHeader}" Margin="0,0,0,4"/>
<TextBlock Text="Default location" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" Margin="10,0,0,2"/>
<DockPanel Margin="10,0,0,10">
<Button DockPanel.Dock="Right" Content="Browse…" Command="{Binding ChooseRecordFolderCommand}"
Style="{StaticResource YtButtonSecondary}" Padding="8,2" FontSize="11" Margin="6,0,0,0"/>
<Button DockPanel.Dock="Right" Content="Use Downloads" Command="{Binding ResetRecordFolderCommand}"
Style="{StaticResource YtButtonSecondary}" Padding="8,2" FontSize="11" Margin="6,0,0,0"/>
<TextBox Style="{StaticResource YtTextBoxCompact}" Text="{Binding RecordFolderDisplay, Mode=OneWay}"
IsReadOnly="True" Foreground="{StaticResource Brush.OnSurfaceVariant}" VerticalAlignment="Center"/>
</DockPanel>
<TextBlock Text="Slide-out alerts" Style="{StaticResource SectionHeader}" Margin="0,6,0,4"/>
<TextBlock Text="Position" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" Margin="10,0,0,2"/>
<ComboBox Style="{StaticResource YtComboBox}" Margin="10,0,0,8"
SelectedValue="{Binding ToastPosition, Mode=TwoWay}" SelectedValuePath="Tag"
ToolTip="Choose where notifications appear inside the app window.">
<ComboBoxItem Content="Top left" Tag="{x:Static notification:NotificationPosition.TopLeft}"/>
<ComboBoxItem Content="Top right" Tag="{x:Static notification:NotificationPosition.TopRight}"/>
<ComboBoxItem Content="Center left" Tag="{x:Static notification:NotificationPosition.CenterLeft}"/>
<ComboBoxItem Content="Center right" Tag="{x:Static notification:NotificationPosition.CenterRight}"/>
<ComboBoxItem Content="Bottom left" Tag="{x:Static notification:NotificationPosition.BottomLeft}"/>
<ComboBoxItem Content="Bottom right" Tag="{x:Static notification:NotificationPosition.BottomRight}"/>
</ComboBox>
<CheckBox Content="Keep alerts until dismissed" Style="{StaticResource PropertyPillCheck}"
Margin="12,2,0,2" IsChecked="{Binding StickyAlertsUntilDismissed, Mode=TwoWay}"
ToolTip="When on, every slide-out alert stays until you click it or close it."/>
<TextBlock Text="Off: informational alerts use their normal timeouts; errors still stay until closed."
Style="{StaticResource PropertyHint}" Margin="10,0,0,8"/>
<!-- ═══ BRAND ═══ -->
<TextBlock Text="Brand logo" Style="{StaticResource SectionHeader}" Margin="0,6,0,4"/>
<TextBlock Text="Image file (shown on every scene)" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" Margin="10,0,0,2"/>
<DockPanel Margin="10,0,0,10">
<Button DockPanel.Dock="Right" Content="Browse…" Command="{Binding BrowseBrandLogoCommand}"
Style="{StaticResource YtButtonSecondary}" Padding="8,2" FontSize="11" Margin="6,0,0,0"/>
<!-- A file NAME says nothing about the picture. The thumbnail shows what is set;
hovering enlarges it, a click collapses it again. -->
<Border x:Name="BrandLogoThumbFrame" DockPanel.Dock="Left" Width="28" Height="28"
Margin="0,0,6,0" CornerRadius="4" ClipToBounds="True"
Background="{StaticResource Brush.SurfaceContainerLowest}"
BorderBrush="{StaticResource Brush.Outline}" BorderThickness="1"
Cursor="Hand" ToolTip="Hover to enlarge — click to close"
Visibility="{Binding BrandLogoPreviewImage, Converter={StaticResource NotNullToVis}}">
<Image x:Name="BrandLogoThumbImage" Source="{Binding BrandLogoPreviewImage}"
Stretch="Uniform" MouseEnter="BrandLogoThumb_MouseEnter"
MouseLeave="BrandLogoThumb_MouseLeave"
MouseLeftButtonUp="BrandLogoThumb_Click"/>
</Border>
<TextBox Style="{StaticResource YtTextBoxCompact}" Text="{Binding BrandLogoFileDisplay, Mode=OneWay}"
IsReadOnly="True" Foreground="{StaticResource Brush.OnSurfaceVariant}" VerticalAlignment="Center"/>
</DockPanel>
<Popup x:Name="BrandLogoPreviewPopup" PlacementTarget="{Binding ElementName=BrandLogoThumbFrame}"
Placement="Left" HorizontalOffset="-6" StaysOpen="True" AllowsTransparency="True"
IsHitTestVisible="True" MouseLeave="BrandLogoPreviewPopup_MouseLeave">
<Border Padding="6" CornerRadius="6"
Background="{StaticResource Brush.SurfaceContainerHighest}"
BorderBrush="{StaticResource Brush.Outline}" BorderThickness="1">
<StackPanel>
<Image Source="{Binding BrandLogoPreviewImage}" MaxWidth="420" MaxHeight="320"
Stretch="Uniform" RenderOptions.BitmapScalingMode="HighQuality"/>
<TextBlock Text="{Binding BrandLogoFileDisplay}" FontSize="11" Margin="0,6,0,0"
Foreground="{StaticResource Brush.OnSurfaceVariant}"/>
</StackPanel>
</Border>
</Popup>
<!-- ═══ ZOOM ═══ -->
<TextBlock Text="Clips" Style="{StaticResource SectionHeader}" Margin="0,10,0,4"/>
<CheckBox Content="Save replay clips" Style="{StaticResource PropertyPillCheck}" Margin="12,2,0,4"
IsChecked="{Binding ClipsEnabled, Mode=TwoWay}"
ToolTip="F10 saves the last few seconds of the show. Off by default; nothing runs while it is off."/>
<StackPanel Margin="12,0,0,4" IsEnabled="{Binding ClipsEnabled}">
<TextBlock Text="Clip length" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" Margin="0,0,0,2"/>
<ComboBox ItemsSource="{Binding ClipLengthOptions}" SelectedItem="{Binding ClipLengthText, Mode=TwoWay}"
Style="{StaticResource YtComboBox}" Height="28" Margin="0,0,0,6"/>
<TextBlock Text="Keep" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" Margin="0,0,0,2"/>
<ComboBox ItemsSource="{Binding ClipKeepOptions}" SelectedItem="{Binding ClipKeepText, Mode=TwoWay}"
Style="{StaticResource YtComboBox}" Height="28"/>
<TextBlock Text="A capture, not an editor: for trimming, captions or a library, use a video editor."
Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="10" TextWrapping="Wrap" Margin="0,6,0,0"/>
</StackPanel>
<TextBlock Text="Zoom" Style="{StaticResource SectionHeader}" Margin="0,6,0,4"/>
<TextBlock Text="Zoom view shape" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" Margin="10,0,0,2"/>
<ComboBox Style="{StaticResource YtComboBox}" Margin="10,0,0,8" SelectedValue="{Binding ZoomShape}" SelectedValuePath="Tag">
<ComboBoxItem Content="Circle" Tag="{x:Static models:ZoomShape.Circle}"/>
<ComboBoxItem Content="Phone (Vertical Rectangle)" Tag="{x:Static models:ZoomShape.VerticalRectangle}"/>
<ComboBoxItem Content="Tablet (Horizontal Rectangle)" Tag="{x:Static models:ZoomShape.HorizontalRectangle}"/>
</ComboBox>
<DockPanel Margin="10,0,0,10">
<ToggleButton DockPanel.Dock="Right" Style="{StaticResource PillToggle}"
IsChecked="{Binding ZoomFollowsBox, Mode=TwoWay}" VerticalAlignment="Center"
ToolTip="Off: the box magnifies the area you clicked. On: it magnifies whatever it hovers over."/>
<TextBlock Text="Zoom box follows" Foreground="{StaticResource Brush.OnSurface}" FontSize="11" VerticalAlignment="Center"/>
</DockPanel>
<!-- ═══ VIDEO CLIPS (TASK 64) ═══ -->
<TextBlock Text="Video clips" Style="{StaticResource SectionHeader}" Margin="0,6,0,4"/>
<DockPanel Margin="10,0,0,10">
<ToggleButton DockPanel.Dock="Right" Style="{StaticResource PillToggle}"
IsChecked="{Binding AutoChromaScreen, Mode=TwoWay}" VerticalAlignment="Center"
ToolTip="On: a clip shot against a clear green or blue screen is keyed as soon as it is added. Off: the app asks first."/>
<StackPanel>
<TextBlock Text="Auto Green/Blue Screen" Foreground="{StaticResource Brush.OnSurface}" FontSize="11"/>
<TextBlock Text="Removes a detected screen without asking." Style="{StaticResource PropertyHint}"/>
</StackPanel>
</DockPanel>
<!-- ═══ SCENE TRANSITIONS ═══ -->
<TextBlock Text="Scene transition" Style="{StaticResource SectionHeader}" Margin="0,6,0,4"/>
<TextBlock Text="Type" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" Margin="10,0,0,2"/>
<ComboBox Style="{StaticResource YtComboBox}" Margin="10,0,0,8" SelectedValue="{Binding SelectedTransitionType}" SelectedValuePath="Tag">
<ComboBoxItem Content="Cut" Tag="{x:Static services:TransitionType.Cut}"/>
<ComboBoxItem Content="Fade" Tag="{x:Static services:TransitionType.Fade}"/>
<ComboBoxItem Content="Move" Tag="{x:Static services:TransitionType.Move}"/>
<ComboBoxItem Content="Wipe" Tag="{x:Static services:TransitionType.Wipe}"/>
</ComboBox>
<DockPanel Margin="10,0,0,10">
<TextBlock DockPanel.Dock="Right" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" VerticalAlignment="Center" Margin="8,0,0,0" Width="56">
<Run Text="{Binding TransitionDurationMs, StringFormat={}{0:0}}"/><Run Text=" ms"/>
</TextBlock>
<Slider Minimum="100" Maximum="2000" TickFrequency="50" IsSnapToTickEnabled="True"
Value="{Binding TransitionDurationMs, Mode=TwoWay}" VerticalAlignment="Center"/>
</DockPanel>
<!-- ═══ LAYOUT DEFAULTS (TASK 37) ═══ -->
<TextBlock Text="Editing" Style="{StaticResource SectionHeader}" Margin="0,10,0,4"/>
<CheckBox IsChecked="{Binding LockEditWhileLive, Mode=TwoWay}" Margin="10,0,0,2"
Style="{StaticResource PropertyPillCheck}"
Content="Lock editing while recording or live"/>
<TextBlock Text="Off: layers can be moved or resized during a show. On: going live turns EDIT off and stopping turns it back on."
Style="{StaticResource PropertyHint}" Margin="28,0,0,10"/>
<TextBlock Text="Layout defaults" Style="{StaticResource SectionHeader}" Margin="0,10,0,4"/>
<TextBlock Text="Replaces every scene and layer with this build’s factory layout. Your selected webcam stays the same."
Style="{StaticResource PropertyHint}" Margin="10,0,0,6"/>
<Button Content="Restore staged scene" Command="{Binding RestoreStagedSceneDefaultsCommand}"
Style="{StaticResource YtButtonSecondary}" Margin="10,0,0,4" HorizontalAlignment="Left"/>
<TextBlock Text="Resets only the scene currently staged in the preview."
Style="{StaticResource PropertyHint}" Margin="10,0,0,8"/>
<Button Content="Restore all scenes" Command="{Binding RestoreAllLayoutDefaultsCommand}"
Style="{StaticResource YtButtonSecondary}" Margin="10,0,0,10" HorizontalAlignment="Left"/>
<!-- Development panel host. Empty in a Release build: the panel itself is excluded
from the Release compile, and the code-behind only fills this inside #if DEBUG. -->
<StackPanel x:Name="DeveloperPanelHost"/>
</StackPanel>
</ScrollViewer>
</UserControl>
+58
View File
@@ -0,0 +1,58 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
namespace ytLive.Controls;
/// <summary>
/// App Settings as a right-edge pull-out (creator ruling 2026-11-01). Hosted by
/// <see cref="PreviewPane"/>'s drawer stack and opened by the gear rail tab, exactly like Stream
/// Settings / YPP / Socials / TEST.
///
/// <para>Born from the modal overlay: that shell was MaxHeight 700, so once the section list grew
/// the bottom options were clipped and unreachable — including the zoom shape the creator was
/// looking for. A drawer is the height of the preview, so the list can grow without a second
/// scroll decision to get wrong.</para>
/// </summary>
public partial class SettingsDrawer : UserControl
{
public SettingsDrawer()
{
InitializeComponent();
#if DEBUG
// Development affordances are added at runtime, never declared in XAML: the panel is
// excluded from the Release compile (ytLive.csproj), so this line is the only reference.
DeveloperPanelHost.Children.Add(new DeveloperPanel());
#endif
}
/// <summary>Is the enlarged brand-logo preview showing? Test seam — a Popup is a separate
/// window, so nothing else can observe it.</summary>
internal bool IsBrandLogoPreviewOpen => BrandLogoPreviewPopup.IsOpen;
/// <summary>The thumbnail is hidden when no image is set (see the XAML), so there is nothing to
/// open in that case — no empty frame can appear.</summary>
internal void OpenBrandLogoPreview() => BrandLogoPreviewPopup.IsOpen = true;
internal void CloseBrandLogoPreview() => BrandLogoPreviewPopup.IsOpen = false;
/// <summary>Hover opens the enlargement. Nothing to show when no image is set, so the
/// thumbnail simply does nothing in that case.</summary>
private void BrandLogoThumb_MouseEnter(object sender, MouseEventArgs e)
=> BrandLogoPreviewPopup.IsOpen = true;
/// <summary>Leaving the thumbnail closes it — unless the pointer walked onto the popup
/// itself, where it stays put so the creator can look closely.</summary>
private void BrandLogoThumb_MouseLeave(object sender, MouseEventArgs e)
{
if (BrandLogoPreviewPopup.Child?.IsMouseOver == true) return;
BrandLogoPreviewPopup.IsOpen = false;
}
/// <summary>A click collapses the enlargement — the explicit "enough, thanks" gesture.</summary>
private void BrandLogoThumb_Click(object sender, MouseButtonEventArgs e)
=> BrandLogoPreviewPopup.IsOpen = false;
private void BrandLogoPreviewPopup_MouseLeave(object sender, MouseEventArgs e)
=> BrandLogoPreviewPopup.IsOpen = false;
}
+216 -64
View File
@@ -5,48 +5,55 @@
x:Name="TopBarRoot">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<!-- 352 = the layers panel's right edge in the work area below: window margin 8 +
360px column = 368, minus this bar's own 16px padding. EDIT is right-aligned in
this column so the two edges line up exactly (creator ruling 2026-11-01). -->
<ColumnDefinition Width="352"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<!-- LOGO / BRAND — clicking opens the in-app About overlay; the gear sits
a couple characters past the build stamp and opens App Settings. -->
<StackPanel Grid.Column="0" Orientation="Horizontal" VerticalAlignment="Center"
Margin="0,0,24,0">
<Button Background="Transparent" BorderThickness="0" Cursor="Hand"
VerticalAlignment="Center" ToolTip="About LlamaCasty"
Command="{Binding OpenAboutCommand}">
<Button.Template>
<ControlTemplate TargetType="Button">
<ContentPresenter/>
</ControlTemplate>
</Button.Template>
<TextBlock FontSize="22" FontWeight="Bold" VerticalAlignment="Center">
<Run Text="LlamaCas" Foreground="#e0e0e0"/><Run Text="ty" Foreground="#e94560"/><Run
x:Name="BuildIdStamp" Text="{x:Static helpers:BuildStamp.Label}" FontSize="9"
BaselineAlignment="Superscript" Foreground="#8888aa"
ToolTipService.ToolTip="{x:Static helpers:BuildStamp.Label}"/><Run Text=" " FontSize="4"/>
</TextBlock>
</Button>
<!-- Settings gear (user directive): a couple characters beyond the brand,
nudged right a couple more places 2026-09-22 (build 77 feedback).
Moved up from the bottom bar — one menu: App Settings / Bug / Feature / About. -->
<Button Style="{StaticResource IconButton}" Margin="18,0,0,2" VerticalAlignment="Center"
ToolTip="Menu" Click="GearButton_Click">
<!-- BRAND + EDIT. The gear moved to the preview's right rail, first tab (2026-11-01):
app-level actions live with the other pull-outs now. -->
<Grid Grid.Column="0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<!-- LOGO / BRAND — left-click opens the in-app About overlay; right-click opens the app
menu (Report Bug / Feature Request / About). App Settings is no longer in this
menu: the gear rail tab opens the Settings drawer directly (creator ruling
2026-11-01). -->
<Button Grid.Column="0" Background="Transparent" BorderThickness="0" Cursor="Hand"
HorizontalAlignment="Left"
VerticalAlignment="Center" ToolTip="About LlamaCasty — right-click for the menu"
Command="{Binding OpenAboutCommand}"
PreviewMouseRightButtonDown="BrandWordmark_PreviewMouseRightButtonDown">
<Button.ContextMenu>
<ContextMenu DataContext="{Binding PlacementTarget.DataContext, RelativeSource={RelativeSource Self}}">
<MenuItem Header="App Settings" Command="{Binding OpenSettingsCommand}"/>
<Separator/>
<MenuItem Header="Report Bug" Command="{Binding OpenBugCommand}"/>
<MenuItem Header="Feature Request" Command="{Binding OpenFeatureCommand}"/>
<MenuItem Header="About" Command="{Binding OpenAboutCommand}"/>
</ContextMenu>
</Button.ContextMenu>
<Path Data="M19.43,12.98c0.04,-0.32 0.07,-0.65 0.07,-0.98c0,-0.33 -0.03,-0.66 -0.07,-0.98l2.11,-1.65c0.19,-0.15 0.24,-0.42 0.12,-0.64l-2,-3.46c-0.12,-0.22 -0.38,-0.31 -0.61,-0.22l-2.49,1c-0.52,-0.4 -1.08,-0.73 -1.69,-0.98l-0.38,-2.65C14.46,2.18 14.25,2 14,2h-4c-0.25,0 -0.46,0.18 -0.49,0.42l-0.38,2.65c-0.61,0.25 -1.17,0.59 -1.69,0.98l-2.49,-1c-0.23,-0.09 -0.49,0 -0.61,0.22l-2,3.46c-0.13,0.22 -0.07,0.49 0.12,0.64l2.11,1.65c-0.04,0.32 -0.07,0.65 -0.07,0.98c0,0.33 0.03,0.66 0.07,0.98l-2.11,1.65c-0.19,0.15 -0.24,0.42 -0.12,0.64l2,3.46c0.12,0.22 0.38,0.31 0.61,0.22l2.49,-1c0.52,0.4 1.08,0.73 1.69,0.98l0.38,2.65c0.03,0.24 0.24,0.42 0.49,0.42h4c0.25,0 0.46,-0.18 0.49,-0.42l0.38,-2.65c0.61,-0.25 1.17,-0.59 1.69,-0.98l2.49,1c0.23,0.09 0.49,0 0.61,-0.22l2,-3.46c0.12,-0.22 0.07,-0.49 -0.12,-0.64L19.43,12.98zM12,15.5c-1.93,0 -3.5,-1.57 -3.5,-3.5c0,-1.93 1.57,-3.5 3.5,-3.5c1.93,0 3.5,1.57 3.5,3.5C15.5,13.93 13.93,15.5 12,15.5z"
Fill="#a0a0b0" Width="18" Height="18" Stretch="Uniform"/>
<Button.Template>
<ControlTemplate TargetType="Button">
<ContentPresenter/>
</ControlTemplate>
</Button.Template>
<!-- Wordmark: "LlamaCas" + "ty" in YouTube red (creator ruling 2026-10-30 — never
re-colour it). The Run elements MUST stay adjacent: whitespace between inline
Runs renders as a space and splits the name into "LlamaCas ty". -->
<TextBlock FontSize="22" FontWeight="Bold" VerticalAlignment="Center">
<Run Text="LlamaCas" Foreground="{StaticResource Brush.OnSurface}"/><Run Text="ty" Foreground="{StaticResource Brush.Youtube}"/><Run
x:Name="BuildIdStamp" Text="{x:Static helpers:BuildStamp.Label}" FontSize="9"
BaselineAlignment="Superscript" Foreground="{StaticResource Brush.OnSurfaceVariant}"
ToolTipService.ToolTip="{x:Static helpers:BuildStamp.Label}"/><Run Text=" " FontSize="4"/>
</TextBlock>
</Button>
</StackPanel>
</Grid>
<!-- Session surface (top bar redesign): the bar is ONE surface rendered by
the FIRST decision — Record or Stream. Each world is the whole bar; the
@@ -54,36 +61,174 @@
reality line + End so nothing mid-session can be re-armed. -->
<StackPanel Grid.Column="1" Orientation="Horizontal"
HorizontalAlignment="Center" VerticalAlignment="Center">
<!-- IDLE: the mode switch + the action(s) of the armed world. The three
controls share one height (build 77 feedback) so they read as one
family — segments host, Start/Go Live, and Test all sit at 32. -->
<StackPanel Orientation="Horizontal"
Visibility="{Binding ShowIdleWorld, Converter={StaticResource BoolToVis}}">
<Border Background="#0f1622" CornerRadius="4" Padding="2" Height="32" VerticalAlignment="Center">
<StackPanel Orientation="Horizontal">
<ToggleButton Style="{StaticResource SegmentToggle}"
IsChecked="{Binding RecordPillOn}">
<TextBlock Text="REC" Style="{StaticResource SegmentLabel}"/>
</ToggleButton>
<ToggleButton Style="{StaticResource SegmentToggle}"
IsChecked="{Binding OnAirPillOn}">
<TextBlock Text="ON-AIR" Style="{StaticResource SegmentLabel}"/>
</ToggleButton>
</StackPanel>
</Border>
<!-- Go face names the world: "Start Recording" vs "Go Live".
(Signed-out ON-AIR Start routes to the sign-in — StartSession.) -->
<Button Content="{Binding PrimaryStartButtonLabel}" Style="{StaticResource YtButton}"
Height="32" Margin="10,0,0,0" Command="{Binding StartStreamCommand}"
Visibility="{Binding ShowPrimaryStartButton, Converter={StaticResource BoolToVis}}"/>
<!-- Test is a child of Stream — a dry-run of the livestream, so it
exists only inside the Stream world (stream armed + signed in). -->
<Button Content="Test" Style="{StaticResource YtButtonSecondary}" Padding="12,4" FontSize="11"
Height="32" Margin="8,0,0,0" VerticalAlignment="Center"
Command="{Binding StartTestCommand}"
ToolTip="Run a private test broadcast to check your chat overlay."
Visibility="{Binding ShowTestButton, Converter={StaticResource BoolToVis}}"/>
</StackPanel>
<!-- THE decision (creator ruling 2026-11-12): [ Build Scenes ][ Record / Go Live ].
Build Scenes is the new edit mode — there is no separate EDIT button, so the
question "which world am I in?" and "can I edit?" cannot disagree. Clicking the
highlighted half re-enters it, which is how a creator gets back to the layers
during a session. -->
<!-- THREE FIXED SLOTS (creator, 2026-11-13): [Build Scenes] [YouTube] [Record]. The bar's
shape NEVER changes — every transition happens inside one slot, so the neighbouring
buttons cannot move. Each slot is its own little flow. -->
<Border Background="{StaticResource Brush.SegmentTrack}" CornerRadius="12" Padding="4"
VerticalAlignment="Center" Margin="0,0,12,0"
KeyboardNavigation.TabNavigation="Cycle" KeyboardNavigation.DirectionalNavigation="Cycle">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<!-- ══ SLOT 1: BUILD SCENES ══ -->
<Button x:Name="BuildSegmentButton" Grid.Column="0" Style="{StaticResource SegmentHalf}"
Tag="True" Command="{Binding SelectBuildCommand}"
IsEnabled="{Binding IsBuildScenesEnabled}"
ToolTip="{Binding BuildScenesTooltip}" FocusVisualStyle="{x:Null}">
<StackPanel Orientation="Horizontal">
<Path Style="{StaticResource SegmentIcon}" Tag="True" Data="M3,4h8v6H3V4zM13,4h8v6h-8V4zM3,12h8v8H3V12zM13,12h8v8h-8V12z"/>
<TextBlock Text="Build Scenes" FontSize="12" FontWeight="Bold" Margin="6,0,0,0"
VerticalAlignment="Center"
Foreground="{Binding Foreground, RelativeSource={RelativeSource AncestorType=Button}}"/>
</StackPanel>
</Button>
<!-- ══ SLOT 2: YOUTUBE ══ -->
<Grid Grid.Column="1" Margin="4,0">
<!-- State 0: idle -->
<Button Style="{StaticResource SegmentHalf}" Tag="False"
Command="{Binding OpenYouTubeCommand}" FocusVisualStyle="{x:Null}"
Visibility="{Binding IsYouTubeIdle, Converter={StaticResource BoolToVis}}"
ToolTip="Stream to YouTube">
<StackPanel Orientation="Horizontal">
<Path Style="{StaticResource SegmentIcon}" Tag="False" Data="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"/>
<TextBlock Text="YouTube" FontSize="12" Margin="6,0,0,0" VerticalAlignment="Center"
Foreground="{Binding Foreground, RelativeSource={RelativeSource AncestorType=Button}}"/>
</StackPanel>
</Button>
<!-- State 1: choose privacy — grouped inside a bordered sub-pill -->
<Border Background="{StaticResource Brush.SegmentActive}" CornerRadius="8" Padding="3"
Visibility="{Binding IsYouTubeChoosingPrivacy, Converter={StaticResource BoolToVis}}">
<StackPanel Orientation="Horizontal">
<Button Style="{StaticResource OutputChip}" Command="{Binding ChooseLiveCommand}"
FocusVisualStyle="{x:Null}" ToolTip="Public livestream">
<StackPanel Orientation="Horizontal">
<Path Style="{StaticResource OutputChipIcon}" Data="M8,5v14l11,-7z"/>
<TextBlock Text="Live" FontSize="12" Margin="5,0,0,0" VerticalAlignment="Center"
Foreground="{StaticResource Brush.SegmentTextActive}"/>
</StackPanel>
</Button>
<Button Style="{StaticResource OutputChip}" Command="{Binding ChoosePrivateCommand}"
FocusVisualStyle="{x:Null}" ToolTip="Private stream — nothing public is published">
<StackPanel Orientation="Horizontal">
<Path Style="{StaticResource OutputChipIcon}" Data="M7,10V7a5,5 0 0,1 10,0v3h1a1,1 0 0,1 1,1v9a1,1 0 0,1 -1,1H6a1,1 0 0,1 -1,-1v-9a1,1 0 0,1 1,-1h1zM9,10h6V7a3,3 0 0,0 -6,0v3z"/>
<TextBlock Text="Private" FontSize="12" Margin="5,0,0,0" VerticalAlignment="Center"
Foreground="{StaticResource Brush.SegmentTextActive}"/>
</StackPanel>
</Button>
<Button Style="{StaticResource OutputChip}" Command="{Binding CancelYouTubeCommand}"
FocusVisualStyle="{x:Null}" ToolTip="Cancel">
<Path Style="{StaticResource OutputChipIcon}" Data="M18.3,5.71L12,12l6.3,6.29 -1.41,1.42L10.59,13.41 4.29,19.71 2.88,18.29 9.17,12 2.88,5.71 4.29,4.29 10.59,10.59 16.89,4.29z" Width="11" Height="11"/>
</Button>
</StackPanel>
</Border>
<!-- State 2: armed — amber border, because the next click goes on air -->
<Border Background="{StaticResource Brush.SegmentActive}" CornerRadius="8" Padding="3"
BorderBrush="{StaticResource Brush.Attention}" BorderThickness="1"
Visibility="{Binding IsYouTubeArmed, Converter={StaticResource BoolToVis}}">
<StackPanel Orientation="Horizontal">
<Button Style="{StaticResource OutputChip}" Command="{Binding StartYouTubeCommand}"
FocusVisualStyle="{x:Null}" ToolTip="Start broadcasting now">
<StackPanel Orientation="Horizontal">
<Ellipse Width="9" Height="9" VerticalAlignment="Center"
Fill="{StaticResource Brush.Attention}"/>
<TextBlock Text="{Binding YouTubeArmLabel}" FontSize="12" FontWeight="Bold"
Margin="5,0,0,0" VerticalAlignment="Center"
Foreground="{StaticResource Brush.SegmentTextActive}"/>
</StackPanel>
</Button>
<Button Style="{StaticResource OutputChip}" Command="{Binding CancelYouTubeCommand}"
FocusVisualStyle="{x:Null}" ToolTip="Cancel">
<Path Style="{StaticResource OutputChipIcon}" Data="M18.3,5.71L12,12l6.3,6.29 -1.41,1.42L10.59,13.41 4.29,19.71 2.88,18.29 9.17,12 2.88,5.71 4.29,4.29 10.59,10.59 16.89,4.29z" Width="11" Height="11"/>
</Button>
</StackPanel>
</Border>
<!-- State 3: streaming -->
<Button Style="{StaticResource SegmentHalf}" Tag="True"
Command="{Binding StopYouTubeCommand}" FocusVisualStyle="{x:Null}"
Visibility="{Binding IsYouTubeStreaming, Converter={StaticResource BoolToVis}}"
ToolTip="End the broadcast">
<StackPanel Orientation="Horizontal">
<Path Style="{StaticResource SegmentIcon}" Tag="True" Data="M6,6h12v12H6z"/>
<TextBlock Text="{Binding YouTubeStopLabel}" FontSize="12" FontWeight="Bold"
Margin="6,0,0,0" VerticalAlignment="Center"
Foreground="{Binding Foreground, RelativeSource={RelativeSource AncestorType=Button}}"/>
</StackPanel>
</Button>
</Grid>
<!-- ══ SLOT 3: RECORD ══ -->
<Grid Grid.Column="2" Margin="4,0,0,0">
<!-- State 0: idle -->
<Button Style="{StaticResource SegmentHalf}" Tag="False"
Command="{Binding OpenRecordCommand}" FocusVisualStyle="{x:Null}"
Visibility="{Binding IsRecordIdle, Converter={StaticResource BoolToVis}}"
ToolTip="Record to a local MP4">
<StackPanel Orientation="Horizontal">
<Path Style="{StaticResource SegmentIcon}" Tag="False" Data="M12,2a10,10 0 1,0 0,20a10,10 0 1,0 0,-20zM12,4a8,8 0 1,1 0,16a8,8 0 1,1 0,-16zM12,7.5a4.5,4.5 0 1,1 0,9a4.5,4.5 0 1,1 0,-9z"/>
<TextBlock Text="Record" FontSize="12" Margin="6,0,0,0" VerticalAlignment="Center"
Foreground="{Binding Foreground, RelativeSource={RelativeSource AncestorType=Button}}"/>
</StackPanel>
</Button>
<!-- State 1: armed -->
<Border Background="{StaticResource Brush.SegmentActive}" CornerRadius="8" Padding="3"
BorderBrush="{StaticResource Brush.Attention}" BorderThickness="1"
Visibility="{Binding IsRecordArmed, Converter={StaticResource BoolToVis}}">
<StackPanel Orientation="Horizontal">
<Button Style="{StaticResource OutputChip}" Command="{Binding StartRecordingCommand}"
FocusVisualStyle="{x:Null}" ToolTip="Start recording now">
<StackPanel Orientation="Horizontal">
<Ellipse Width="9" Height="9" VerticalAlignment="Center"
Fill="{StaticResource Brush.Attention}"/>
<TextBlock Text="Start Recording" FontSize="12" FontWeight="Bold" Margin="5,0,0,0"
VerticalAlignment="Center"
Foreground="{StaticResource Brush.SegmentTextActive}"/>
</StackPanel>
</Button>
<Button Style="{StaticResource OutputChip}" Command="{Binding CancelRecordCommand}"
FocusVisualStyle="{x:Null}" ToolTip="Cancel">
<Path Style="{StaticResource OutputChipIcon}" Data="M18.3,5.71L12,12l6.3,6.29 -1.41,1.42L10.59,13.41 4.29,19.71 2.88,18.29 9.17,12 2.88,5.71 4.29,4.29 10.59,10.59 16.89,4.29z" Width="11" Height="11"/>
</Button>
</StackPanel>
</Border>
<!-- State 2: recording — green pulse, clock, stop -->
<Border Background="{StaticResource Brush.SegmentActive}" CornerRadius="8" Padding="3"
Visibility="{Binding IsRecordRecording, Converter={StaticResource BoolToVis}}">
<StackPanel Orientation="Horizontal">
<Ellipse Width="9" Height="9" VerticalAlignment="Center"
Fill="{StaticResource Brush.RecordLive}"
Opacity="{Binding RecDotOpacity, Mode=OneWay}"/>
<TextBlock Text="{Binding RecordElapsedLabel}" FontSize="11" FontWeight="Bold"
FontFamily="Consolas" Margin="5,0,8,0" VerticalAlignment="Center"
Foreground="{StaticResource Brush.SegmentTextActive}"/>
<Button Style="{StaticResource OutputChip}" Command="{Binding StopRecordingCommand}"
FocusVisualStyle="{x:Null}" ToolTip="Stop recording and save">
<StackPanel Orientation="Horizontal">
<Path Style="{StaticResource OutputChipIcon}" Data="M6,6h12v12H6z"/>
<TextBlock Text="Stop" FontSize="12" FontWeight="Bold" Margin="5,0,0,0"
VerticalAlignment="Center"
Foreground="{StaticResource Brush.SegmentTextActive}"/>
</StackPanel>
</Button>
</StackPanel>
</Border>
</Grid>
</Grid>
</Border>
<!-- RUNNING: one reality line (dot + word + elapsed) and the stop -->
<StackPanel Orientation="Horizontal"
@@ -94,8 +239,15 @@
Foreground="White" Margin="8,0,0,0" VerticalAlignment="Center"/>
<TextBlock Text="{Binding LiveElapsedText}" Foreground="White" FontSize="14"
FontFamily="Consolas" VerticalAlignment="Center" Margin="8,0,0,0"/>
<!-- TASK 32·2: measured facts only — elapsed seconds and which attempt. No countdown:
YouTube's cutoff is not a constant we can promise. -->
<TextBlock x:Name="ReconnectDetail" Text="{Binding ReconnectDetailText}"
Foreground="{StaticResource Brush.Attention}" FontSize="13"
FontFamily="Consolas" VerticalAlignment="Center" Margin="10,0,0,0"
Visibility="{Binding IsReconnecting, Converter={StaticResource BoolToVis}}"/>
<Button Content="{Binding EndButtonText}" Style="{StaticResource YtButton}"
Background="#333" Margin="14,0,0,0" Command="{Binding EndStreamCommand}"/>
Background="{StaticResource Brush.Primary}" Margin="14,0,0,0"
Command="{Binding EndStreamCommand}"/>
</StackPanel>
</StackPanel>
@@ -118,11 +270,11 @@
<MenuItem Header="Logout" Command="{Binding LogoutCommand}"/>
</ContextMenu>
</Button.ContextMenu>
<Border Width="40" Height="40" CornerRadius="20" Background="#16213e"
ClipToBounds="True" BorderBrush="#3a4a66" BorderThickness="1">
<Border Width="40" Height="40" CornerRadius="20" Background="{StaticResource Brush.SurfaceContainer}"
ClipToBounds="True" BorderBrush="{StaticResource Brush.Outline}" BorderThickness="1">
<Grid>
<Image x:Name="AvatarImage" Stretch="UniformToFill"/>
<TextBlock x:Name="AvatarInitial" Foreground="#e0e0e0" FontSize="20"
<TextBlock x:Name="AvatarInitial" Foreground="{StaticResource Brush.OnSurface}" FontSize="20"
FontWeight="Bold" HorizontalAlignment="Center" VerticalAlignment="Center"
Visibility="Collapsed"/>
</Grid>
+14 -8
View File
@@ -19,25 +19,31 @@ public partial class TopBar : UserControl
InitializeComponent();
AvatarImage.Source = null;
AvatarInitial.Visibility = System.Windows.Visibility.Collapsed;
// Keyboard enters the bar on Build scenes, and Tab cycles between the two halves only
// (creator, build 729) — Tab must not walk off into the rest of the window from here.
Loaded += (_, _) => BuildSegmentButton.Focus();
}
public void RefreshAvatar()
/// <summary>Right-click on the LlamaCasty wordmark opens the app menu (Report Bug / Feature
/// Request / About) — the menu that used to hang off the gear, which now opens the Settings
/// drawer directly (creator ruling 2026-11-01).</summary>
private void BrandWordmark_PreviewMouseRightButtonDown(object sender, System.Windows.Input.MouseButtonEventArgs e)
{
_ = LoadAvatarImageAsync();
}
private void GearButton_Click(object sender, RoutedEventArgs e)
{
// One menu (Settings / Bug / Feature / About), opened on click — moved up
// from the bottom bar so app-level actions live next to the brand.
if (sender is Button { ContextMenu: { } menu } button)
{
menu.PlacementTarget = button;
menu.Placement = System.Windows.Controls.Primitives.PlacementMode.Bottom;
menu.IsOpen = true;
e.Handled = true;
}
}
public void RefreshAvatar()
{
_ = LoadAvatarImageAsync();
}
private async System.Threading.Tasks.Task LoadAvatarImageAsync()
{
var vm = DataContext as MainViewModel;
+13 -6
View File
@@ -7,16 +7,23 @@ See [`schema.md`](../schema.md) for the memory-map conventions.
| File | Purpose |
|------|---------|
| `BottomBar.xaml(.cs)` | Footer (Row 3): stream stats (bitrate/fps/dropped/duration/health), resolution dropdown (gear menu moved up to TopBar 2026-09-22) |
| `BottomBar.xaml(.cs)` | Footer (Row 3): intentionally empty, height-preserving spacer; stream readouts and resolution picker moved to the top-left cell in the 2026-10-26 surface redesign |
| `HealthBanner.xaml(.cs)` | Row 1 status banner + the 7 value→visibility converters live in the app theme, not here |
| `TopBar.xaml(.cs)` | Row 0 session surface (2026-09-22 redesign): brand + gear menu (left), one segmented REC|ON-AIR mode switch + world actions (Record: Start Recording · Stream: Go Live + Test) + reality line while running (dot/word/elapsed) + End (center), account zone — Sign In or avatar w/ Change Account/Logout context menu (right). `RefreshAvatar()`, `GearButton_Click` |
| `PreviewPane.xaml(.cs)` | Row 1 Col 1 center: the 1920×1080 `OverlayCanvas`/`SelectionOverlay`/`BrandFlashLayer`/`SocialBarElement`, text pull-out drawer, and the live-controls row (TRAX / game / mic meters + sliders + speakers + socials). **TASK 57:** dragging a webcam under the vertical tier routes through `MainViewModel.MoveWebcamInVerticalTier` (the six-spot snap inside the cropped band) instead of free-floating over the master, and a resize re-snaps the tile onto its spot (TASK 57 Unit 2: the resize routes through `MainViewModel.ResizeWebcamInVerticalTier` — the Chat camera's two sizes, then the snap) |
| `SceneThumbnailStrip.xaml(.cs)` | Row 0 scene-picker strip (`ItemsControl` over `Scenes`), staged-red / live-green border highlight |
| `LeftPanel.xaml(.cs)` | Row 1 Col 0 left panel: edit mode → layers+properties; live → YouTube chat. Source add/edit/remove, list drag-to-reorder, snapshot revert-accept. **TASK 51:** the webcam properties carry the Background section — `RemoveBackgroundPill` / `BlurBackgroundPill` (`PillToggle`, radio-exclusive via `WebcamSceneConfig.RemoveBackground`/`BlurBackground`) + `BackgroundStrengthSlider` (0..1, 5% snaps, shown only while an effect is on). **TASK 57:** the webcam properties carry a Geometry section (X/Y/W/H) — manual placement of the camera, bound to the **live** placement (the one the current tier shows), gated on `IsWebcamSelected` (not `SelectedElement.IsWebcam` — the TASK 51 test finds that panel by binding text and would match twice); the Shape button's `Visibility` is bound to `CanToggleClipShape`, so the circle option is NOT OFFERED in the vertical Chat layout. **TASK 58:** every drop-down, check box and field in the properties panel carries the theme's style (`YtComboBox`/`YtCheckBox`/`YtTextBoxCompact`) and paints no colour of its own — the guard is `ytLive.Tests/PropertiesStyleTests.cs`. **TASK 59:** the webcam's properties are a HIERARCHY — a `Shape` title owns the mirror switch and the shape choice (`Rect` | `Circle` as two `SegmentToggle` options, Rect selected by default, the circle option hidden where `CanToggleClipShape` is false), and every titled block (Geometry, Border Color, Border Thickness, Background) owns its content one indent deeper (Strength sits under Blur). **TASK 60:** the border-colour drop-down builds its menu from `Models/BorderColorPalette.cs` — each row is the hex plus the colour's NAME (`#e94560 Crimson`, tooltip "Crimson (#e94560)"); the swatch icon is unchanged. **TASK 61:** the four geometry fields (X/Y/W/H) share one fixed width (44px — the largest value, 1920, plus a sign) and their rows set `LastChildFill="False"`, so Y/H no longer stretch to fill the row. **TASK 62:** the border-colour field shows the palette's name beside the hex (`SelectedElement.BorderColorName`, empty for a custom hex) and keeps a fixed width so the name stays next to the hex. **TASK 63:** the border colour is picked with the **app's own drop-down** (`YtComboBox`) — the `...` button and its code-built `ContextMenu` are gone; the closed box shows the swatch + the colour's NAME, the rows show swatch + hex + name (the panel's own `DataTemplate`s + `Helpers/ClosedBoxTemplateSelector.cs`), and the hex field stays for a custom colour; `IsDescendantOf` falls back to `LogicalTreeHelper.GetParent` / `TemplatedParent` when `VisualTreeHelper.GetParent` returns `null` (at `PopupRoot`) so clicking a popup row does not trigger `MainWindow`'s deselection guard |
| `OverlayHost.xaml(.cs)` | Root-grid overlay dialogs: Settings / Report Bug / Feature Request / About (+ licenses + license-key entry). `BackToAbout_Click` lives here |
| `TopBar.xaml(.cs)` | Row 0 session surface: brand + gear menu (left), explicit `EDIT` toggle beside the REC|ON-AIR mode switch and world actions (Record: Start Recording · Stream: Go Live + Test), running-state reality line + End (center), account zone (right). Left-click gear opens the menu; right-click opens App Settings directly. `RefreshAvatar()`, `GearButton_Click` |
| `PreviewPane.xaml(.cs)` | Row 2 Col 1 center: 1920×1080 `OverlayCanvas`/`SelectionOverlay`/`BrandFlashLayer`/`SocialBarElement`, static TextOverlay preview image from the shared cached text raster, text drawer, outgoing-picture transition overlay, right-edge Stream Info/YPP/Socials/TEST tabs and drawers, and live controls (TRAX / game / mic meters + sliders + speakers). The list-selected draggable layer is temporarily raised in preview z-order and custom hit-testing; deselection restores the scene's unchanged order (`SelectedLayerPreviewHitTestingTests`). **TASK 57:** dragging/resizing a webcam in the vertical tier routes through `MainViewModel.MoveWebcamInVerticalTier` / `ResizeWebcamInVerticalTier` for the six-spot snap or Chat camera's two sizes. |
| `SceneThumbnailStrip.xaml(.cs)` | Top row over the preview column: five fixed-size scene cards with scene name, hotkey, staged `PROGRAM` badge, orange staged/live border |
| `LeftPanel.xaml(.cs)` | Row 2 Col 0 left panel: explicit EditingActive toggle on → Scene Layers+properties; off → YouTube chat, independent of stream status. Source add/edit/remove, list drag-to-reorder, snapshot revert-accept; TextOverlay properties edit multiline content, font type/family, size, color via the shared palette, emphasis, and horizontal/vertical alignment with a static-text notice; sub-options are indented under their headings. **TASK 51:** the webcam properties carry the Background section — `RemoveBackgroundPill` / `BlurBackgroundPill` (`PillToggle`, radio-exclusive via `WebcamSceneConfig.RemoveBackground`/`BlurBackground`) + `BackgroundStrengthSlider` (0..1, 5% snaps, shown only while an effect is on). **TASK 57:** the webcam properties carry a Geometry section (X/Y/W/H) — manual placement of the camera, bound to the **live** placement (the one the current tier shows), gated on `IsWebcamSelected` (not `SelectedElement.IsWebcam` — the TASK 51 test finds that panel by binding text and would match twice); the Shape button's `Visibility` is bound to `CanToggleClipShape`, so the circle option is NOT OFFERED in the vertical Chat layout. **TASK 58:** every drop-down, check box and field in the properties panel carries the theme's style (`YtComboBox`/`YtCheckBox`/`YtTextBoxCompact`) and paints no colour of its own — the guard is `ytLive.Tests/PropertiesStyleTests.cs`. **TASK 59:** the webcam's properties are a HIERARCHY — a `Shape` title owns the mirror switch and the shape choice (`Rect` | `Circle` as two `SegmentToggle` options, Rect selected by default, the circle option hidden where `CanToggleClipShape` is false), and every titled block (Geometry, Border Color, Border Thickness, Background) owns its content one indent deeper (Strength sits under Blur). **TASK 60:** the border-colour drop-down builds its menu from `Models/BorderColorPalette.cs` — each row is the hex plus the colour's NAME (`#e94560 Crimson`, tooltip "Crimson (#e94560)"); the swatch icon is unchanged. **TASK 61:** the four geometry fields (X/Y/W/H) share one fixed width (44px — the largest value, 1920, plus a sign) and their rows set `LastChildFill="False"`, so Y/H no longer stretch to fill the row. **TASK 62:** the border-colour field shows the palette's name beside the hex (`SelectedElement.BorderColorName`, empty for a custom hex) and keeps a fixed width so the name stays next to the hex. **TASK 63:** the border colour is picked with the **app's own drop-down** (`YtComboBox`) — the `...` button and its code-built `ContextMenu` are gone; the closed box shows the swatch + the colour's NAME, the rows show swatch + hex + name (the panel's own `DataTemplate`s + `Helpers/ClosedBoxTemplateSelector.cs`), and the hex field stays for a custom colour; `IsDescendantOf` falls back to `LogicalTreeHelper.GetParent` / `TemplatedParent` when `VisualTreeHelper.GetParent` returns `null` (at `PopupRoot`) so clicking a popup row does not trigger `MainWindow`'s deselection guard |
| `OverlayHost.xaml(.cs)` | Root-grid overlays: App Settings / Report Bug / Feature Request / About; About includes the output-quality conversion copy and an explicit llamacasty.com link, while the licensing/legal panel remains in-app. App Settings groups recording, slide-out alert placement/stickiness, brand-logo, preview, transition, and staged/all-scene layout-default controls. `BackToAbout_Click` lives here |
| `SceneThumbnailStrip.xaml.cs` | Code-behind for the strip above |
| `LeftPanel.xaml.cs` | Code-behind for the panel above |
The Add Layer menu exposes one standalone recent-alert Ticker per scene. The
properties panel edits its independent `Ticker` settings; Stream Alerts retains
its separate current-event ticker controls. Outside edit mode, the left panel
offers Live Chat first and Logs second; Logs wraps and colors typed entries,
pins explicit Class 1/2 errors above the timeline until acknowledged, then
returns them to the timeline; it excludes earlier file contents.
## Window ↔ control contract
The shell (`MainWindow.xaml(.cs)`) wires up each control and routes the few
+150 -25
View File
@@ -1,10 +1,16 @@
# DESIGN.md — LlamaCasty visual design tokens (redesign exploration)
# DESIGN.md — LlamaCasty visual design standard
> Source: Google Stitch export, 2026-10-26, from the proposed edit-screen mockup. Colors and type
> only — no layout decisions are captured here. This is a design reference for the UX redesign
> in progress; it is not yet wired into the app's theme (`Themes/Controls.xaml`).
> **Status: the standard** (decided 2026-10-30). Token values here are normative;
> `Themes/Tokens.xaml` is the implementation, and no other file may hardcode a hex once the sweep
> lands. Two Google Stitch exports feed it (2026-10-26 edit screen, 2026-10-29 audio dock).
>
> **The exports' layouts and control drawings are exploratory, not spec.** The dock, the four
> bays, the page-nav sidebar and the skeuomorphic faders are *not* adopted — the product's design
> principle is a surface so obvious that a right-brained person can use it without a manual, and
> hardware cosplay is the opposite of that. Only the colour, type, spacing, radius and elevation
> system is adopted.
## Color
## Colour
### Surfaces (dark navy scale, darkest to lightest)
@@ -18,7 +24,11 @@
| `surface-container-highest` | `#2d3449` | Selected/active row background |
| `surface-bright` | `#31394d` | Lightest surface, rare use (strong hover) |
| `outline` | `#334155` | Borders, dividers |
| `outline-variant` | `#1e293b` | Subtle dividers, inactive VU meter segments |
| `outline-variant` | `#1e293b` | Subtle dividers, inactive meter segments |
| `scrim-soft` | `#22FFFFFF` | Hover/press wash over a surface (13% white) |
| `scrim-strong` | `#26FFFFFF` | Stronger wash — selected icon, dimmed image (15% white) |
| `scrim-dark` | `#26000000` | Dim strip / shading over the preview (15% black) |
| `scrim-dark-strong` | `#00000080` | Heavy overlay — modal veil, poster frame (50% black) |
### Text
@@ -26,42 +36,157 @@
|---|---|---|
| `on-surface` | `#dae2fd` | Primary text |
| `on-surface-variant` | `#94a3b8` | Secondary / label text |
| `on-surface-disabled` | `#5b6478` | Disabled text and icons |
### Accent — one role each, never mixed
| Token | Hex | Role |
|---|---|---|
| `tertiary` (electric blue) | `#38bdf8` | The one interactive accent: selection, focus, sliders, primary buttons, transform handles |
| `primary` (red) | `#ff5451` | **Reserved for on-air/recording state only** — LIVE badge, program border, End/Go Live |
| `tertiary` (electric blue) | `#38bdf8` | **The** interactive accent: selection, focus, sliders, primary buttons, transform handles |
| `on-tertiary` | `#0b1326` | Dark ink, for large blue **surfaces** only |
| `on-accent` | `#ffffff` | Text/icon on any saturated accent fill — the blue EDIT button, the armed REC/ON-AIR pills. One rule: a coloured fill carries white ink |
| `primary` (red) | `#ff5451` | **On-air / recording only** — LIVE badge, program border, End/Go Live |
| `primary-container` | `#b91a24` | Red pressed/hover state |
| `secondary` (amber) | `#ffb95f` | Game audio channel, Super Chat highlight |
| `secondary-container` | `#ee9800` | Amber pressed/hover state |
| `error` | `#ef4444` | Error states only (distinct from the on-air red) |
| `attention` (orange) | `#ff8c3a` | Dropped frames, the live-screen indicator, blinking warnings |
| `positive` | `#22c55e` | **Status only** — connected, applied, healthy. Never an accent, never a button fill |
| `error` | `#ef4444` | Error text and icons |
| `error-container` | `#93000a` | Error surface (health banner, toast) — keeps error visually distinct from on-air red |
| `on-error` | `#ffdad6` | Text on an error surface |
| `info-container` | `#3a3b52` | Neutral notice surface (info toast) |
| `success-container` | `#1d5c38` | Success notice surface (success toast) |
| `warning-container` | `#b8860b` | Warning surface — health banner, warning toast |
| `test-container` | `#9c6f1c` | Test-stream chrome: the gold top bar and glow |
| `youtube` | `#ff0000` | **YouTube-branded surfaces only** — Stream Info / YPP rails, the YouTube account row, and the wordmark's "ty" (creator ruling: never re-colour it) | — the Stream Info and YPP rails, the YouTube account row. Not for state: live stays `primary` |
**Rule carried from the critique sessions:** red must mean "live or recording" and nothing else.
Don't reuse it for a Send button, a delete action, or any other control — that was the defect in
the first Stitch draft of the live screen.
**The rule:** red means "live or recording" and nothing else. Never a Send button, a delete action,
a brand flourish, or a general accent — that was the defect in the first Stitch draft.
**`#e94560` is retired** as the app's general accent (it was in 15 files). Blue takes over every
interactive role; red keeps only live/record. The brand/wordmark *artwork* is unchanged — those
assets are the creator's art, not a theme value.
### Meter thresholds (both meters, all bays)
| Band | Range | Brush |
|---|---|---|
| Nominal | −∞ … −12 dB | `#38bdf8` |
| Caution | −12 … −3 dB | `#ffb95f` |
| Peak / clip | −3 … 0 dB+ | `#ff5451` |
Meters are **measured, never decorative** (see `docs/ai/output.md`): the ladder shows the live
level scaled by the current gain, not the gain setting.
## Typography
| Token | Family | Use |
|---|---|---|
| `sans` | Inter (400/500/600/700) | All UI text — labels, buttons, panel titles |
| `mono` | JetBrains Mono (500/600) | All numeric/telemetry values — bitrate, FPS, dB, coordinates, timers. Keeps digits from shifting width as values change. |
| `mono` | JetBrains Mono (500/600) | Numeric/telemetry values — bitrate, FPS, dB, coordinates, timers. Tabular figures stop digits shifting width as values change |
Icons: Material Symbols Outlined.
Icons: Material Symbols Outlined (bundled, not fetched).
## Color roles (decided 2026-10-26)
### Scale
- **Red `#ff5451`:** YouTube references, on-air/GO LIVE, and record only.
- **Attention orange `#ff8c3a`:** dropped packets, the live screen indicator, and blinking warning lights.
- **Amber `#ffb95f`:** game audio and Super Chat only.
- **`error` `#ef4444`:** unchanged for now; it sits close to the on-air red and may need revisiting.
| Token | Size / line | Weight | Tracking | Use |
|---|---|---|---|---|
| `headline-lg` | 32 / 40 | 700 | −0.02em | Landing/empty-state titles only |
| `headline-md` | 24 / 32 | 600 | −0.015em | Dialog titles |
| `headline-sm` | 18 / 24 | 600 | −0.01em | Panel and section headers |
| `body-lg` | 15 / 22 | 400 | — | Default body, chat |
| `body-md` | 13 / 18 | 400 | — | Panel rows, form text |
| `body-sm` | 12 / 16 | 400 | — | Secondary text, hints |
| `label-lg` | 13 / 16 | 600 | 0.02em | `mono` — primary telemetry values |
| `label-md` | 11 / 14 | 500 | 0.04em | `mono` — labels, hotkey chips |
| `label-sm` | 10 / 12 | 500 | 0.05em | `mono` — dense telemetry only |
## Open questions for the next pass
**Floors and implementation notes**
- These tokens have not been reconciled against the app's current WPF theme
(`Themes/Controls.xaml`). That comparison should happen once the redesign direction is locked,
not before — do not retrofit the current theme to these values yet.
- No elevation/shadow scale, corner-radius scale, or spacing scale has been captured yet. Add them
here as they're decided, rather than letting them live only in Stitch exports.
- **11px is the default label size; 10px is the absolute floor** and only for dense readouts that
sit beside a larger value. Nothing readable at 100% zoom may be smaller than 10px.
- **WPF has no letter-spacing.** Tracking values above are design intent, not a property; implement
as-is (no tracking) rather than faking it with padded strings.
- Mono numerics use `Typography.NumeralAlignment="Tabular"` so widths stay stable.
## Spacing
| Token | px | Use |
|---|---|---|
| `space-xs` | 4 | Inside a chip, icon-to-text |
| `space-sm` | 8 | Between related controls |
| `space-md` | 12 | Default gap inside a panel |
| `space-lg` | 16 | Panel padding, section separation |
| `space-xl` | 24 | Dialog padding, major separation |
| `gutter` | 12 | Between page columns |
| `margin` | 16 | Window edge to content |
| `margin-docked` | 8 | Edge to content when a rail/drawer is docked |
## Shape
| Token | px | Use |
|---|---|---|
| `radius-sm` | 4 | Chips, badges, keycap tags |
| `radius-md` | 8 | Buttons, inputs, cards, scene tiles |
| `radius-lg` | 16 | Preview viewport, drawers, dialogs |
| `radius-full` | pill | Status chips (LIVE, EDIT), toggles |
Recessed controls (fader wells, inputs) use the same outer radius as their housing so the seams
line up — do not mix a 4px well inside an 8px card.
## Elevation
| Level | Use | Implementation |
|---|---|---|
| −1 recessed | Fader wells, input fields, table headers | inset top shadow + `surface-container-lowest` |
| 0 panel | The default chassis | `surface-container*` + **1px `outline` border**, no shadow |
| 1 raised | Scene tiles, floating chips, action buttons | subtle drop shadow, 0 1px 2px |
| 2 floating | Drawers, popups, dialogs | larger spread + 1px top highlight |
**Budget rule:** `DropShadowEffect` is software-rendered in WPF. Shadows on small controls only —
**never** on the preview viewport, the scene strip, or either wing. Depth on large surfaces comes
from the surface scale and the 1px border, not from effects.
## Interaction
- Focus ring: 1px `#38bdf8` outside a 1px gap — visible against every surface level.
- Hover: step one surface level up; active: step two. No colour change on hover except for
destructive actions.
- Motion: 150ms `CubicEase(EaseOut)` for drawers/popovers; 90ms for hover/press. No springs —
WPF has no spring easing and hand-rolled overshoot reads as jank at 60Hz.
- Every action produces a visible outcome. No dead ends, no silent failures.
## Not in this standard
- The audio dock, its four bays, per-bay faders, 24-segment ladders, transport controls, lip-sync
slider and master telemetry strip — **exploratory**. The controls they re-house already exist in
the app; whether any of them gets a dock is a separate product decision.
- The page-nav sidebar (Broadcast Studio / Audio Mixer / Scene Matrix / Telemetry / Hotkeys) — the
app is one surface with an EDIT toggle and drawers, not a page router.
- CPU / GPU / NVENC readouts — no telemetry exists, and none of it changes a live decision.
- Acrylic/backdrop blur — WPF has no sub-region backdrop blur; panels are solid.
- **On-stream output** (chat box, social bar, alerts, brand flash) is styled by its own renderers,
not by this theme. Do not apply UI tokens to the broadcast picture.
## Rejected from the 2026-10-29 export
The export is a Material-3 dump from a warm seed; four tokens came out tinted by that seed and are
rejected in favour of the values above.
| Export value | Token | Why rejected |
|---|---|---|
| `#4cd7f6` | `tertiary` | Neon cyan beside the amber reads "toy"; `#38bdf8` is calmer and already used in-app |
| `#ab8986` | `outline` | Warm pink borders from the seed — wrong for a navy chassis |
| `#5b403e` | `outline-variant` | Same seed artifact |
| `#e4beba` | `on-surface-variant` | Pink secondary text; slate `#94a3b8` is legible and neutral |
## Implementation status
- **`Themes/Tokens.xaml` exists and is merged first in `App.xaml`** (2026-10-30). Guard:
`ytLive.Tests/DesignTokensTests.cs` — it parses this document and fails if a documented token has
no brush, if a value disagrees, or if the retired `#e94560` returns as a token.
- **Still to sweep:** `Themes/Controls.xaml` and the dozens of hardcoded hexes in XAML/C# still use
the legacy palette. Surfaces convert one at a time; the end state is that every colour, radius and
spacing value resolves from `Tokens.xaml` and a guard fails on any raw `#hex` outside it.
- **Fonts ship with the app** (`Assets/Fonts/*.ttf`, OFL-1.1) — no installation, no network, no
dependence on what the machine happens to have. Notice and license text travel with them (`THIRD-PARTY-NOTICES.txt` items 13-14).
+131 -713
View File
@@ -1,736 +1,154 @@
# Distribution & IP Protection Plan
# Distribution & release mechanics
> 360-degree protection strategy for LlamaCasty (.NET 8 / WPF).
How the build becomes an install on someone's PC. **Policy — prices, entitlement, why Steam is the
only channel, support — is [`docs/ai/commercial.md`](docs/ai/commercial.md).** This file is the how.
> ## ⛔ SUPERSEDED IN PART — 2026-09-27
>
> **Distribution is now the Microsoft Store: MSIX package + Store IAP.** The creator's criteria
> were *"zero headaches, minimal maintenance (for me) while still providing accountability and
> a reasonably easy upgrade flow"*, and that ruling deleted this document's central premise.
>
> **What still stands below:** §1 (code architecture / IP protection), §5 (obfuscation posture),
> §6 (legal), §7 (hardening) — those are about protecting the *binary*, and an MSIX package is
> still a binary. **§1.2's build-posture ceiling (HARDENED + MOCK_REWARDS, additive-only)
> remains the agreed ceiling.**
>
> **What is dead:**
> - **Polar as the distribution backbone** — the entire section below. Store IAP handles
> checkout, entitlements, refunds, tax, and the customer portal. So does **file hosting**:
> the Store is the delivery mechanism, so "upload `LlamaCasty.exe` to Polar as a File
> Download benefit" (§2) no longer exists as a step.
> - **Code signing as our problem** — MSIX is signed by **Microsoft**. There is no certificate
> to buy, no HSM, no annual renewal. See §4.3, which was already corrected to say this, and
> `TASKS/research-store-certification.md` §2–3.
> - **Velopack / the update URL / the DO droplet** — Store auto-update.
>
> **The authoritative plan is now [`TASKS/task-48-distribution-msix.md`](TASKS/task-48-distribution-msix.md).**
> Keep this file for the protection sections; do not re-derive a delivery strategy from it.
> Superseded twice on 2026-10-31. The 736-line Polar/MSIX/obfuscation plan is in
> `git log -- Distribution.md`; the zip-plus-winget plan written earlier the same day is in the
> same place. The reasoning that survives lives in `commercial.md` and `docs/ai/decisions.md`.
---
## 1. The artifact
## Polar.sh as the Distribution Backbone — ⛔ OBSOLETE (2026-09-27)
A self-contained win-x64 publish plus the bundled `ffmpeg\` folder, uploaded to a Steam depot. No
installer, no admin rights, no zip for anyone to mirror.
> **Historical record only.** Retained because the fee tables and the reasoning about *why*
> one-time beat subscription were worth working out. **None of the "How We Use It" rows are
> current**, with one exception worth keeping: the Merchant-of-Record point — *someone else
> collects and remits VAT/GST* — is still true under Store IAP, because **Microsoft** is now
> that someone. That fact alone was the strongest argument for the ruling.
Polar handled everything between "customer wants to pay" and "customer has a working license key." We didn't build any of that. What Polar *was* going to give us:
| Capability | How We Use It |
|------------|---------------|
| **License key management** | Polar generates, validates, expires, rotates, and revokes keys. Activation limits (device caps) are built-in — no custom device registry. |
| **File hosting** (up to 10GB) | Upload `LlamaCasty.exe` (self-contained, ~80-120MB) as a "File Download" benefit. Customers get signed, personal download URLs. SHA-256 checksums included. |
| **Checkout** | Polar's hosted checkout page handles payment. No Stripe integration, no PCI scope, no custom checkout UI. |
| **Customer portal** | Customers self-serve: view/copy license keys, see expiry, deactivate devices, rotate compromised keys. We don't build a portal. |
| **One-time orders** | **Chosen model (2026-09-21):** perpetual license via a one-time Polar order — no subscription. Polar also supports recurring billing if ever needed, but we do not use it. |
| **Merchant of Record** | Polar collects and remits VAT/GST/sales tax globally. We never touch tax compliance. |
| **Webhooks** | Polar notifies our backend on purchase, cancellation, key rotation. Used for optional telemetry (section 6.3). |
**Polar pricing (2026):** 5% + $0.50 per transaction (Starter plan). No monthly fee. ⛔ *No
longer paid to anyone — the equivalent cost is now inside the Store revenue share, whose rate
is unverified (see `TASKS/research-store-certification.md` §11).*
**What Polar did NOT handle:** Obfuscation, code signing, anti-tamper, runtime protection, EULA, DMCA. That's all us — sections 1-3, 5-7 below.
---
## 1. Code Architecture for Protection
### 1.1 Assembly Splitting (est. 2–3 days)
Split the monolith into three assemblies with different protection tiers:
| Assembly | Contents | Protection Level |
|----------|----------|-----------------|
| `LlamaCasty.Core.dll` | Models, DTOs, interfaces, enums, constants | Light obfuscation (symbol renaming only) |
| `LlamaCasty.Services.dll` | YouTube auth, stream service, encoder, audio pipeline, layout store | Full obfuscation + control flow + string encryption |
| `LlamaCasty.exe` | WPF UI, ViewModels, XAML | No obfuscation (XAML constraints) |
**Why:** Obfuscators break WPF XAML/BAML bindings when they rename types referenced in `DataContext`, `Binding Path=`, or `x:Name`. Keeping the UI assembly separate lets you protect the business logic aggressively while leaving the presentation layer safe.
**How:**
- `LlamaCasty.Core` → Class library, `net8.0`, no WPF dependency. Pure C#.
- `LlamaCasty.Services` → Class library, `net8.0`, references Core. No XAML.
- `LlamaCasty` → WPF app, references both. All `MainWindow.xaml`, `Themes/`, ViewModels live here.
### 1.2 Obfuscation-Friendly Patterns (est. 1–2 days, ongoing)
**Do:**
- Use `public`/`internal` properties with `{ get; set; }` for data binding (XAML `Binding Path=StringLiteral` resolves at runtime by string match — obfuscators handle this if configured).
- Use `[Obfuscation(Exclude = true)]` on anything bound to XAML via `Binding`, `CommandParameter`, or `x:Name`.
- Prefer `nameof()` for property names passed to `INotifyPropertyChanged` so renaming works.
- Use `RelayCommand` (already in use) — it takes lambdas, not string names.
**Avoid:**
- `FindName("SomeControl")` in code-behind (breaks if XAML name is mangled).
- `x:Name` references accessed from code-behind (use `Binding` instead).
- `Activator.CreateInstance(Type.GetType("SomeString"))` — obfuscator renames the type string.
- `Assembly.Load("LlamaCasty.Core")` by string — use direct references.
- `[Serializable]` DTOs that get binary-serialized (renamed fields break deserialization).
### 1.3 Strategic Attribute Placement (est. 1 day)
```csharp
// Exclude from obfuscation — XAML binding relies on property name
[Obfuscation(Exclude = true)]
public string RecordFolderDisplay { get; }
// Exclude — DTO serialized to SQLite
[Obfuscation(Exclude = true)]
public class StreamHealth { ... }
// Exclude — ViewModels bound by name in XAML
[Obfuscation(Exclude = true)]
public partial class MainViewModel : ViewModelBase { ... }
// Safe to obfuscate — internal service, no XAML binding
public class FfmpegEncoder { ... }
```bash
dotnet publish ytLive.csproj -c Release -r win-x64 --self-contained true \
-p:PublishSingleFile=true \
-p:IncludeNativeLibrariesForSelfExtract=true \
-p:EnableCompressionInSingleFile=true \
-p:PublishReadyToRun=true -o artifacts/steam-stage
```
**Rules:**
- All ViewModels: `[Obfuscation(Exclude = true)]` (XAML `Binding Path=` resolves by name at runtime).
- All Models/DTOs used in SQLite: `[Obfuscation(Exclude = true)]` (column names are strings).
- All public service interfaces: `[Obfuscation(Exclude = true)]` (DI resolution).
- Internal service implementations: safe to obfuscate.
- Helpers/Converters in XAML: `[Obfuscation(Exclude = true)]` on the class.
- **`PublishTrimmed` stays OFF.** Trimming breaks WPF's XAML reflection (`Binding`, `DataTemplate`,
`StaticResource`). Expect ~110–150 MB in the depot; that is the cost of "no .NET install".
- **ffmpeg stays outside the exe** — `ffmpeg\` beside it (the app's locator contract, see
[`output.md`](output.md) → "FFmpeg locator").
- **`steam_api64.dll` ships beside the exe** and is loaded at startup; without it the app cannot
read DLC ownership. Its absence must degrade to the free profile, never to a crash.
- **WebView2 is the one machine dependency.** Valve's Common Redistributables configure the
Evergreen bootstrapper as a prerequisite, so the depot declares it instead of us shipping it.
- **`AssemblyName` is `LlamaCasty`** (renamed 2026-11-01, before the first depot because
Steamworks warns about renaming a live exe) → the artifact is `LlamaCasty.exe` plus its
`ffmpeg\` folder. `scripts/publish.sh` stages both and refuses to finish if either is missing.
- **⛔ `ExcludeFromSingleFile="true"` on the ffmpeg content.** Without it, `PublishSingleFile` +
`IncludeNativeLibrariesForSelfExtract` swallow the libav\*.dll family **into the exe** — a
182 MB binary with no `ffmpeg\` folder beside it, so the locator finds nothing and every stream
fails at runtime. The publish output is ~257 MB: 127 MB exe + 129 MB ffmpeg.
- **Portable mode** (`portable.txt` → `.\data\`) survives as a feature for a copied install; it is
not a delivery vector.
---
## 2. Steamworks setup, once
## 2. Build Pipeline
**The SDK zip is the only download.** It is ~24 MB and contains the runtime
(`sdk/redistributable_bin/win64/steam_api64.dll`) *and* the release tooling
(`sdk/tools/ContentBuilder/` → `steamcmd.exe` for Windows, `steamcmd.sh` for Linux/macOS, plus the
`app_build_*.vdf` / `depot_build_*.vdf` templates). There is no separate "ContentBuilder download"
on the partner dashboard — the dashboard's Downloads page only re-offers the SDK.
### 2.1 Build Configuration (est. 0.5 day)
**Reference links, so nobody hunts again:**
In `ytLive.csproj`:
| What | Where |
|---|---|
| SDK zip (sign in to partner first) | `partner.steamgames.com/downloads/steamworks_sdk.zip` |
| Docs home | `partner.steamgames.com/doc/home` |
| SteamPipe upload (steamcmd + VDF fields) | `partner.steamgames.com/doc/sdk/uploading` |
| Demo configuration | `partner.steamgames.com/doc/store/application/demos` |
| Paid app ownership (`BIsSubscribedApp`) | `partner.steamgames.com/doc/api/ISteamApps#BIsSubscribedApp` |
| Store artwork sizes | `partner.steamgames.com/doc/store/assets/standard` |
| Coming Soon / 2-week public window | `partner.steamgames.com/doc/store/coming_soon` |
| Wallet rule (in-app purchases) | `partner.steamgames.com/doc/features/microtransactions` |
| Recurring subscriptions | `partner.steamgames.com/doc/store/pricing/subscriptions` |
| Partner support | `help.steampowered.com/en/wizard/HelpWithPublishing` |
| Steamworks.NET (managed wrapper) | `nuget.org/packages/Steamworks.NET` |
```xml
<PropertyGroup>
<TargetFramework>net8.0-windows10.0.19041.0</TargetFramework>
<OutputType>WinExe</OutputType>
<PublishSingleFile>true</PublishSingleFile>
<SelfContained>true</SelfContained>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
<EnableCompressionInSingleFile>true</EnableCompressionInSingleFile>
<PublishTrimmed>true</PublishTrimmed>
<TrimMode>partial</TrimMode> <!-- 'full' breaks WPF reflection; 'partial' is safe -->
<AssemblyName>LlamaCasty</AssemblyName>
<Version>0.9.0</Version>
<Company>LlamaCasty</Company>
<Product>LlamaCasty</Product>
</PropertyGroup>
```
### Status (updated 2026-11-05 — this is a WAIT on Valve, not a task of ours)
**Key decisions:**
- `PublishTrimmed` + `TrimMode=partial`: Trims unused BCL code but preserves WPF infrastructure. `full` trimming breaks `Binding`, `DataTemplate`, and XAML reflection.
- `PublishSingleFile`: One `.exe` to distribute. Self-contained = no .NET runtime dependency on user machines.
- `win-x64`: Target the dominant platform. ARM64 is a separate RID if needed later.
| Step | State |
|---|---|
| Partner account created | ✅ done by the creator |
| Identity verification | ⏳ **pending** — Valve reviewing |
| Tax interview | ⏳ submitted; Valve quotes **up to 30 business days** to review |
| $100 Steam Direct fee | ✅ **paid** (creator, 2026-11-05) — a store page can be configured once identity clears |
| App IDs (paid app + free Demo) | **not yet assigned** |
| Store page | not created |
| Build review | not started |
### 2.2 Obfuscator Integration (est. 2–3 days)
**Critical path from 2026-11-05**, using Valve's own published windows:
**Recommended: Eazfuscator.NET** (free for projects under $1M revenue, excellent WPF support, .NET 8 compatible).
1. identity + tax review — up to **30 business days** (~6 calendar weeks) → mid-December
2. fee paid, app IDs assigned, store page built and submitted
3. store page review — **up to 12 business days** (Valve's current backlog; docs still say 3–5)
4. page public **≥2 weeks before release** (Valve's rule)
5. build review — **up to 12 business days**
Alternative commercial: **ArmDot** ($399/yr, better control flow, built-in anti-tamper).
**Earliest realistic launch: early-to-mid February 2027.** The launch-price deadline is no longer a
date: it now ends **30 days after the store page goes live** (2026-11-05), so Valve's queue cannot make
the public claim wrong.
**Integration as post-publish step:**
Nothing below can start until step 1 clears; none of it blocks the app work.
```powershell
# publish.ps1 — called after dotnet publish
param([string]$PublishDir = "publish")
5. **Store page** configured and made public **two weeks before release** — Valve's rule, and it is
the window where wishlists accumulate.
dotnet publish ytLive.csproj -c Release -r win-x64 `
--self-contained true `
-p:PublishSingleFile=true `
-p:PublishTrimmed=true `
-p:TrimMode=partial `
-o $PublishDir
# Obfuscate the Services assembly (core business logic)
eazfuscator.exe "$PublishDir\LlamaCasty.Services.dll" `
--target-framework net8.0 `
--Renaming-mode unprintable `
--Control-flow-obfuscation true `
--String-encryption true `
--Resource-encryption true
# Obfuscate the Core assembly (lighter touch)
eazfuscator.exe "$PublishDir\LlamaCasty.Core.dll" `
--target-framework net8.0 `
--Renaming-mode unprintable
# Do NOT obfuscate LlamaCasty.exe (WPF entry point + XAML)
```
### 2.3 XAML/BAML Handling (est. 1 day research)
**Problem:** WPF compiles XAML into BAML (binary XAML) embedded as resources. Obfuscators that rename types break BAML because it references types by assembly-qualified name.
**Solution:**
- Do NOT obfuscate the main `.exe` assembly (contains all BAML).
- Only obfuscate `Core` and `Services` DLLs (no XAML).
- If an obfuscator must touch the exe: use Eazfuscator.NET's `[Obfuscation(Exclude = true, Feature = "XAML")]` attribute on types referenced in BAML, or configure the obfuscator to skip the `LlamaCasty.g.resources` manifest resource.
**Testing:** After obfuscation, launch the app and verify:
- All screens render correctly
- `Binding` expressions resolve (no blank/missing data)
- `DataContext` inheritance works
- Storyboards/animations play
- `ContextMenu` items bind correctly
- Dialogs open/close properly
### 2.4 CI/CD Pipeline (est. 1 day)
```yaml
# .github/workflows/release.yml
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: 8.0.x
- name: Test
run: dotnet test --no-restore -c Release
- name: Publish
run: dotnet publish ytLive.csproj -c Release -r win-x64 --self-contained -p:PublishSingleFile=true -p:PublishTrimmed=true -p:TrimMode=partial -o publish
- name: Obfuscate
run: |
eazfuscator.exe publish/LlamaCasty.Services.dll --target-framework net8.0 --Renaming-mode unprintable --Control-flow-obfuscation true --String-encryption true
eazfuscator.exe publish/LlamaCasty.Core.dll --target-framework net8.0 --Renaming-mode unprintable
- name: Sign
run: |
$cert = Get-Content -Raw -Path secrets/certificate.pfx | ConvertTo-SecureString -AsPlainText -Force
Set-AuthenticodeSignature -FilePath publish/LlamaCasty.exe -Certificate $cert -TimestampServer http://timestamp.digicert.com -HashAlgorithm SHA256
- name: Upload to Polar
run: |
# Upload the signed .exe to Polar as a file download benefit
# Update the product's file benefit with the new version
curl -X POST "https://api.polar.sh/v1/products/{product_id}/benefits" \
-H "Authorization: Bearer $POLAR_ACCESS_TOKEN" \
-F "type=file_download" \
-F "file=@publish/LlamaCasty.exe"
```
---
## 3. Obfuscation Strategy
### 3.1 Recommended Obfuscator
**Eazfuscator.NET** (primary):
- Free for projects under $1M revenue — fits solo dev.
- Excellent WPF support: knows to skip BAML resources, handles `INotifyPropertyChanged` patterns.
- .NET 8 support confirmed.
- Symbol renaming (unprintable Unicode), control flow obfuscation, string encryption, resource encryption.
- Simple CLI integration.
**ArmDot** (commercial, $399/yr):
- Better control flow (virtualization of critical methods).
- Anti-tamper and anti-debug built in.
- Better at handling complex WPF scenarios.
**What NOT to use:**
- ConfuserEx — abandoned, doesn't support .NET 8.
- Obfuscar — limited WPF support, BAML breakage issues.
- Dotfuscator Community — too basic, no string encryption.
### 3.2 What to Obfuscate
| Technique | Where | Effect |
|-----------|-------|--------|
| Symbol renaming (unprintable) | `Services.dll`, `Core.dll` | All type/method/field names → Unicode garbage. 10x decompilation difficulty. |
| Control flow obfuscation | `Services.dll` | Flattens `if/else/switch` into state machines. Decompiler produces spaghetti. |
| String encryption | `Services.dll` | All string literals encrypted; decrypted at runtime. Hides API URLs, error messages, config values. |
| Resource encryption | `Services.dll` | Embedded resources encrypted. Prevents extraction of embedded data. |
| Anti-tamper | `Services.dll` | CRC check on method bodies at runtime. Detects binary patching. |
### 3.3 What to Exclude
| Item | Reason |
|------|--------|
| `LlamaCasty.exe` (WPF entry point) | BAML resources break if types are renamed. |
| All ViewModels | `Binding Path=` resolves property names at runtime. |
| All Models/DTOs | SQLite column mapping uses property names as strings. |
| `LayoutStore` (public interface) | Called by ViewModels via interface — renaming breaks DI. |
| All `IValueConverter` implementations | XAML references converters by type name in `StaticResource`. |
| All `MarkupExtension` subclasses | XAML parser resolves by type name. |
| `App.xaml.cs` entry point | Must be discoverable. |
### 3.4 Testing Obfuscated Build (est. 1 day per release)
**Automated checks:**
1. App launches without crash (startup log at `%APPDATA%\ytLlive\startup.log`).
2. All 5 scenes load (Starting/Live/BRB/Chat/Ending).
3. Social bar renders with entries.
4. TRAX button click opens file picker.
5. Recording starts/stops correctly.
6. YouTube OAuth flow completes (if signed in).
7. Settings panel opens from Gear menu.
8. All 247 unit tests pass against the unobfuscated assemblies (tests never run against obfuscated code).
**Manual smoke test:**
- Start a recording, verify file appears in configured folder.
- Open social dialog, add/edit/delete a handle.
- Toggle mic mute, verify meter responds.
- Switch scenes via thumbnail strip.
---
## 4. Distribution Artifacts
### 4.1 Files to Ship
## 3. Release
```
LlamaCasty.msix (full-trust x64 package, self-contained, ~159MB)
publish → steam-stage → steamcmd + app_build.vdf → build upload → set live → announcement
```
Built by **`scripts/package-msix.sh`** (TASK 48 item 2, shipped 2026-10-22): it publishes
`Release -r win-x64 --self-contained` into `artifacts/msix-stage`, stages
`Packaging/Package.appxmanifest` (as `AppxManifest.xml`) + `Packaging/Assets/`, and packs with
the Windows SDK's `makeappx` → `artifacts/LlamaCasty.msix` (gitignored). Unsigned by default —
**the Store signs submissions**; set `MSIX_PFX`/`MSIX_PFX_PASSWORD` to sign a package for a
local test install. The manifest is a full-trust `packagedClassicApp` at `mediumIL` with
`runFullTrust` + webcam + microphone (see `TASKS/task-48-distribution-msix.md` item 2).
A single-file `.exe` is **no longer the artifact**: the Store needs a package, and the package
carries the runtime (~80-120MB of it) plus the bundled ffmpeg (~135MB of DLLs). No installer
framework and no admin rights — the Store handles install, update and uninstall.
### 4.2 Distribution via Polar — ⛔ OBSOLETE (2026-09-27)
> **Superseded by Microsoft Store MSIX + Store IAP** (ruling in
> `TASKS/task-48-distribution-msix.md` item 0). The Store re-signs, hosts, updates, and takes
> payment — so file hosting, license keys, checkout, the customer portal, device deactivation and
> key rotation are **all** handled by Microsoft and none of it needs building. Retained below as a
> record of the model that was designed and abandoned. ⚠️ **The `$29`/`$49` one-time prices in this
> section are dead** — the settled pricing is **`$10/mo` or `$400` lifetime**, net of a verified
> 15% Store share.
**Product setup on Polar (as designed, never shipped):**
1. Create a product: "LlamaCasty" — **one-time purchase** (perpetual license; $29 founder / $49 list).
2. Add two benefits:
- **License Key** — brandable prefix `LLAMA-****`, activation limit (e.g., 3 devices), optional expiry.
- **File Download** — upload the signed `LlamaCasty.exe` (~80-120MB). Polar generates signed, personal download URLs. SHA-256 checksum included.
**Customer flow:**
1. Customer visits `buy.polar.sh/polar_cl_...` (the Premium URL already in `MainViewModel.PremiumUrl`).
2. Customer pays via Polar checkout.
3. Polar generates a license key + provides a signed download URL.
4. Customer downloads `LlamaCasty.exe`, runs it, enters license key in Settings.
5. App calls `PolarLicenseService.ValidateAsync(key)` → Polar API validates → `IsValid = true`.
6. Premium features unlock locally (persisted in `LayoutStore.Settings`).
**Activation limits:**
- Polar supports `limit_activations` per license key (e.g., 3 devices).
- Client calls `/v1/customer-portal/license-keys/activate` before first validation.
- Polar tracks activations; customer can deactivate via Polar's customer portal.
- No custom device registry needed — Polar handles it.
**Key rotation:**
- If a key is compromised, customer or merchant can rotate it via Polar.
- Previous key stops validating immediately. Status, usage, limits, expiry preserved.
**What the Store does instead:** the customer installs from the Store listing, pays through Store
checkout, and the app reads the entitlement the OS already holds. There is no key to enter, no
activation call, no portal to visit, and no offline-grace period to maintain — which also retires
the whole "network flaky → app thinks I'm expired" bug class in §4.5.
### 4.3 Code Signing
> ⚠️ **The cert choice below is a live decision, and the old plan's EV recommendation is
> dead.** Microsoft **removed the SmartScreen instant-bypass for EV certificates in March
> 2024** — an EV-signed file now accrues reputation exactly like an OV-signed one, so paying
> $400+/yr buys precisely what paying ~$150 buys. **Do not buy EV.**
>
> **But the more interesting finding is that you may not need a certificate at all.** Three
> costs, current as of 2026-09-27:
>
> | Route | Cert/yr | SmartScreen | Note |
> |---|---|---|---|
> | **Microsoft Store (MSIX)** | **$0** — Microsoft re-signs | **No warning** | Requires MSIX packaging; Store review; public listing |
> | **Microsoft Store (EXE/MSI)** | $120–300 — *your* cert | Warning ramp | Store does **not** re-sign; silent install mandatory. Strictly dominated — cert required anyway |
> | **Direct + OV cert** | $150–300 | Warning ramp | Worldwide availability |
> | **Direct + Azure Artifact Signing** | ~$9.99/mo ≈ $120 | Warning ramp | **Individuals: USA/Canada only.** Identity validation required |
>
> Signing does **not** clear the warning on day one either way: a valid cert stops the
> *malware* warning immediately, but "unrecognized publisher" clears as SmartScreen accrues
> reputation from real download volume. Budget cert validity at **460 days** (CA/B Forum
> CSC-31) — this is a recurring line item, not a one-time purchase — and note that private
> keys must sit on an **HSM or hardware token**.
>
> **The full comparison, the four real combinations, and the policy analysis live in
> [`TASKS/research-store-certification.md`](TASKS/research-store-certification.md) §2–3.
> The executable checklist is [`TASKS/task-48-distribution-msix.md`](TASKS/task-48-distribution-msix.md),
> and the route decision is the creator's.**
**Certificate:** Buy an **OV (Organization Validation)** code-signing certificate from DigiCert
or Sectigo (~$150–300/year), or an **Azure Artifact Signing** profile (~$9.99/month,
USA/Canada individuals only) if the geography allows. ⛔ **Not EV** — no SmartScreen benefit
since March 2024. **Or buy nothing at all** if the route is the Microsoft Store.
**Signing process:**
```powershell
$cert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2(
"path\to\certificate.pfx",
"password",
[System.Security.Cryptography.X509Certificates.X509KeyStorageFlags]::Exportable
)
Set-AuthenticodeSignature -FilePath "LlamaCasty.exe" `
-Certificate $cert `
-TimestampServer "http://timestamp.digicert.com" `
-HashAlgorithm SHA256
```
**Storage:** Certificate `.pfx` file stored in GitHub Secrets (or Azure DevOps secure files). Never committed to the repo.
### 4.4 Update Mechanism — ⛔ OBSOLETE (2026-09-27)
> **Superseded by Store updates.** Microsoft delivers and updates the app from the listing, so
> there is nothing to check and no manifest to host. The Velopack updater that used to sit in
> `App.xaml.cs` **downloaded *and executed***, which Store policy **10.2.2** does not permit — its
> removal was a **compliance action**, not optional cleanup, and it is **done** (TASK 48 slice 3,
> 2026-10-22: the package reference and the bootstrap are gone; `ytLive.Tests/StorePackagingTests.cs`
> guards it). The Polar-native design below is retained as a record of the approach that was
> rejected; it is not an option.
**Polar-native approach (no custom infrastructure):**
```csharp
// In Services.dll (obfuscated)
public class UpdateCheckService
{
// Polar's customer portal API — we check if a newer file is available
// by validating the license key and checking the benefit's file checksum
private const string ValidateUrl = "https://api.polar.sh/v1/customer-portal/license-keys/validate";
public async Task<UpdateInfo?> CheckForUpdateAsync(string currentVersion, string licenseKey)
{
// Validate the key — Polar returns the current benefit state
// If the file benefit's checksum changed, a new version is available
// The download URL is Polar's signed URL — we never hardcode it
// ... implementation in PolarLicenseService ...
}
}
```
**Design:**
- App validates license key on startup (already implemented in `PolarLicenseService`).
- Polar's validation response includes benefit metadata — if the file benefit's SHA-256 changed, a new version exists.
- App prompts user to download from Polar's customer portal (or provides the signed URL).
- No custom update manifest, no self-hosted endpoints, no signing key infrastructure.
- Version obsolescence: Polar can disable old file benefits, making old versions unsupported.
**Alternative (if Polar's API doesn't expose file version metadata):**
- Host a lightweight `version.json` on GitHub Releases (public, no secrets).
- App checks GitHub Releases API for latest version.
- Download link points to Polar's customer portal (not direct download).
- This adds one external dependency (GitHub) but keeps Polar as the source of truth.
### 4.5 Licensing Logic — ⛔ BEING DELETED (2026-09-27)
> ⚠️ **"Already implemented" was true as of 2026-09-21 and is no longer.** `PolarLicenseService`
> and `Helpers/PolarLicense.cs` are being **removed** as part of the TASK 10 teardown (TASK 48 item 0).
> Store IAP replaces the whole validation hop: `IsPremium` is derived from the **OS-supplied Store
> entitlement**, so there is no network call, no key, and — importantly — **no 14-day offline-grace
> machine to protect**, which retires the entire "network flaky → app thinks I'm expired" bug class.
> What survives is only `IsPremium` → `BrandFlashEnabled = !value`. Retained below as the record of
> what shipped and why it no longer applies.
```csharp
// Services/PolarLicenseService.cs — shipped under Polar; being deleted
// Calls Polar's customer-portal validation endpoint (no auth required)
// Survives obfuscation — property names excluded via [Obfuscation(Exclude = true)]
[Obfuscation(Exclude = true)]
public class LicenseValidationResult
{
public bool IsValid { get; set; }
public string? Status { get; set; } // "granted", "revoked", "disabled"
public DateTime? ExpiresAt { get; set; }
public string? Error { get; set; }
}
```
**What shipped under Polar (now being deleted):**
- `PolarLicenseService` validated keys against the Polar API.
- `OrganizationId` was hardcoded.
- License state persisted in `LayoutStore.Settings` (SQLite) with an offline grace period.
- `PremiumUrl` pointed to Polar checkout.
**What survives obfuscation:** The validation logic in `Services.dll` is obfuscated. An attacker who decompiles the exe sees only the UI calling an interface — the actual Polar API call is in the obfuscated DLL.
---
## 5. Runtime Protection
### 5.1 Anti-Debugging (est. 1–2 days)
```csharp
// In Services.dll (obfuscated)
internal static class AntiTamper
{
[System.Runtime.InteropServices.DllImport("kernel32.dll")]
private static extern bool IsDebuggerPresent();
[System.Runtime.InteropServices.DllImport("kernel32.dll")]
private static extern bool CheckRemoteDebuggerPresent(IntPtr hProcess, ref bool isDebuggerPresent);
internal static void Validate()
{
if (IsDebuggerPresent())
Environment.FailFast("Tamper detected.");
bool debuggerPresent = false;
CheckRemoteDebuggerPresent(IntPtr.Zero, ref debuggerPresent);
if (debuggerPresent)
Environment.FailFast("Tamper detected.");
// Timing check: if a method takes >10x expected time, a debugger is stepping
var sw = System.Diagnostics.Stopwatch.StartNew();
SomeFrequentlyCalledMethod();
sw.Stop();
if (sw.ElapsedMilliseconds > 100)
Environment.FailFast("Tamper detected.");
}
}
```
**Trade-offs:**
- `IsDebuggerPresent()` is trivially patched (NOP the call). Useful against casual inspection, not determined attackers.
- Timing checks can false-positive on slow machines. Use conservative thresholds.
- `Environment.FailFast()` terminates the process immediately — no data corruption, no recovery.
### 5.2 Anti-Tampering (est. 1 day)
```csharp
// Method body CRC check — runs at startup
// Obfuscator can inject this automatically (ArmDot)
internal static void VerifyMethodBodies()
{
var assembly = Assembly.GetExecutingAssembly();
var location = assembly.Location;
var bytes = File.ReadAllBytes(location);
// Check known CRC offsets (computed at build time)
foreach (var (offset, expectedCrc) in KnownChecksums)
{
var actualCrc = Crc32.Compute(bytes, offset, MethodBodyLength);
if (actualCrc != expectedCrc)
Environment.FailFast("Binary integrity check failed.");
}
}
```
### 5.3 Environment Validation (est. 0.5 day)
```csharp
// Detect common analysis environments — NOT bulletproof, raises the bar
internal static bool IsAnalysisEnvironment()
{
var suspiciousProcesses = new[] { "dnSpy", "ILSpy", "x64dbg", "x32dbg", "ollydbg", "procmon", "wireshark" };
var running = System.Diagnostics.Process.GetProcesses()
.Select(p => p.ProcessName.ToLowerInvariant());
if (running.Any(p => suspiciousProcesses.Contains(p)))
return true;
var vmIndicators = new[] { "VMware", "VirtualBox", "Hyper-V", "Sandboxie" };
var wmi = new System.Management.ManagementObjectSearcher("SELECT * FROM Win32_ComputerSystem");
foreach (var obj in wmi.Get())
{
var manufacturer = obj["Manufacturer"]?.ToString() ?? "";
if (vmIndicators.Any(v => manufacturer.Contains(v, StringComparison.OrdinalIgnoreCase)))
return true;
}
return false;
}
```
**Privacy note:** Environment checks that query WMI or enumerate processes require the app to have no network exfiltration of this data. Log locally only. Do NOT send "user is in a VM" telemetry — that's a privacy violation and potential legal issue.
### 5.4 Preventing Memory Dumps (est. 0.5 day, limited effectiveness)
```csharp
internal static void ProtectSensitiveMemory()
{
// Encrypt license keys in memory when not in use
// Zero memory after use
// Use SecureString for transient secrets
}
```
**Reality check:** Against a determined attacker with admin rights, memory protection is bypassable. The value is in raising the bar, not creating an impenetrable wall. Focus effort on:
1. String encryption (hides API endpoints, keys, config).
2. Control flow obfuscation (makes logic hard to follow).
3. Polar server-side validation (license checks hit Polar's API, not our code).
### 5.5 Secrets Management
| Secret | Storage | Notes |
|--------|---------|-------|
| YouTube OAuth ClientSecret | Never in binary. Server-side only. | Client uses OAuth device flow; the secret lives on your auth server. |
| Code-signing certificate | CI/CD secrets only. | Never in source. |
| Polar access token (for uploads) | CI/CD secrets only. | Used in pipeline to upload new .exe to Polar. |
| User tokens (DPAPI) | `%APPDATA%\ytLlive\ytLlive.auth` | OS-level encryption. Never sent anywhere. |
**Principle:** The client binary contains zero secrets that matter. Polar handles license validation server-side. The binary is a consumer of Polar's API, not a holder of credentials. The `OrganizationId` in `PolarLicenseService` is not a secret — it's required by Polar's public validation endpoint.
---
## 6. Legal and Operational Measures
### 6.1 EULA (est. 1 day, lawyer review recommended)
Key clauses:
```
1. LICENSE GRANT: Limited, non-exclusive, non-transferable license to use the software.
2. RESTRICTIONS: No reverse engineering, decompilation, disassembly, or circumvention
of technical protection measures. No redistribution, sublicensing, or rental.
3. INTELLECTUAL PROPERTY: All rights reserved. The software is protected by copyright
law and international treaties.
4. TERMINATION: License terminates automatically upon breach. Upon termination,
you must destroy all copies.
5. DISCLAIMER: Software provided "AS IS" without warranty.
6. LIMITATION OF LIABILITY: Not liable for damages arising from use.
7. GOVERNING LAW: [Your jurisdiction].
```
**Enforceability:** EULAs that prohibit reverse engineering have been upheld in the US (DMCA §1201) and EU (Copyright Directive Art. 6). They're less enforceable in some jurisdictions (e.g., Germany has compulsory decompilation rights under §69e UrhG for interoperability). A lawyer should review for your target markets.
### 6.2 DMCA / Takedown
1. **Register copyright** with the US Copyright Office ($65 per work). Required for statutory damages in infringement suits.
2. **Monitor** GitHub, torrent sites, and software sharing sites for pirated copies. Set up Google Alerts for "LlamaCasty download", "LlamaCasty crack".
3. **DMCA takedown** to GitHub/hosting provider when found. Template:
```
Subject: DMCA Takedown Notice — Copyright Infringement
I am the copyright owner of the software "LlamaCasty".
The following repository contains unauthorized copies:
[URL]
I have a good faith belief that the use is not authorized.
I swear under penalty of perjury that this information is accurate.
```
4. **DMCA to Google** to delist pirate download pages.
### 6.3 Telemetry for Unauthorized Use (est. 0.5 day, optional)
```csharp
// Optional: anonymized heartbeat that detects unauthorized distribution
// Only send: installation GUID (random, stored locally), version, license tier
// DO NOT send: file paths, user names, screen content, keystrokes
internal static async Task SendHeartbeatAsync()
{
var id = GetOrCreateInstallationId(); // Random GUID, stored in Settings
var version = Assembly.GetExecutingAssembly().GetName().Version;
// POST to your API — response can include "deactivate" flag
}
```
**Privacy:** Heartbeat must be disclosed in the EULA and privacy policy. Must not collect PII. Must be opt-out-able in EU (GDPR). Consider whether this is worth the complexity for a solo dev — Polar already tracks validation counts per key, which gives you usage data without custom telemetry.
---
## 7. Fallback and Recovery
### 7.1 If Protection Is Bypassed
**Tier 1 — Casual piracy (someone shares the .exe):**
- Monitor download sites, issue DMCA takedowns.
- Polar can disable the file benefit — old download URLs stop working.
- Push a new version to Polar; old versions become obsolete.
- No kill-switch needed — version obsolescence is sufficient.
**Tier 2 — Cracked version distributed (license check bypassed):**
- Revoke the compromised license key via Polar dashboard (or API: `POST /v1/license-keys/{id}/revoke`).
- Rotate the key — previous key stops validating immediately.
- Push emergency update with new validation logic.
- The cracked version is now outdated; Polar's API rejects old keys.
**Tier 3 — Full decompilation and source reconstruction:**
- This means obfuscation failed. Audit your obfuscation settings.
- Switch from Eazfuscator to ArmDot (virtualization is harder to reverse).
- Add server-side validation for critical features (license, updates).
- Accept that a determined attacker with enough time will always win — focus on making it not worth their time.
### 7.2 Emergency Update Push
**Via Polar (no custom infrastructure):**
1. Upload new `.exe` to Polar dashboard as updated file benefit.
2. Disable old file benefit — existing customers lose access to old download URL.
3. Customers with valid license keys see the new version in their Polar purchase page.
4. Optional: Use Polar webhook to notify a lightweight endpoint → push in-app notification.
**Via GitHub Releases (fallback):**
1. Create a new release on GitHub with the updated `.exe`.
2. App checks GitHub Releases API for latest version (if Polar's file version metadata isn't exposed).
3. Download link points to Polar's customer portal (not direct GitHub download).
### 7.3 Kill-Switch Ethics
A kill-switch is legally and ethically complex:
- **Legal:** You can deactivate your own software per your EULA. Users agreed to it.
- **Ethical:** Don't deactivate without warning. Give 30 days notice. Provide data export.
- **Practical:** A kill-switch that bricks the app without notice will generate bad press and potential legal action.
- **Recommendation:** Use Polar's key revocation + version obsolescence instead of kill-switches. Let the version go stale rather than actively disabling it.
---
## Summary: Feasibility Assessment
| Step | Time | Difficulty | Solo Dev? | Polar Handles? |
|------|------|------------|-----------|----------------|
| Assembly split | 2–3 days | Medium | Yes | No |
| Obfuscation-friendly patterns | 1–2 days | Low | Yes | No |
| Attribute placement | 1 day | Low | Yes | No |
| Build pipeline | 1 day | Medium | Yes | No |
| Obfuscator integration | 2–3 days | Medium | Yes | No |
| XAML/BAML testing | 1 day | Medium | Yes | No |
| CI/CD pipeline | 1 day | Medium | Yes | No |
| Code signing setup | 0.5 day | Low | Yes | No |
| License key setup (Polar) | 0.5 day | Low | Yes | **Yes** |
| File hosting (Polar) | 0.5 day | Low | Yes | **Yes** |
| Checkout (Polar) | 0 | None | Yes | **Yes** |
| Customer portal (Polar) | 0 | None | Yes | **Yes** |
| Activation limits (Polar) | 0.5 day | Low | Yes | **Yes** |
| Key rotation/revocation (Polar) | 0 | None | Yes | **Yes** |
| Update mechanism | 1 day | Medium | Yes | Partial |
| Anti-debug/anti-tamper | 1–2 days | Medium | Yes | No |
| Environment validation | 0.5 day | Low | Yes | No |
| EULA (draft + review) | 1–2 days | Low | Lawyer recommended | No |
| DMCA preparation | 0.5 day | Low | Yes | No |
| **Total** | **~12–18 days** | | **Yes** | **~5 days saved** |
**Bottom line:** Polar eliminates ~5 days of custom infrastructure (licensing backend, checkout, customer portal, device management, tax compliance, file hosting). What remains is purely our protection layer (obfuscation, code signing, anti-tamper) and legal measures.
The most cost-effective approach for a solo dev:
1. **Do (core):** Assembly split + Eazfuscator (Services.dll only) + code signing + Polar license keys + Polar file hosting. Stops 95% of casual piracy. (~7 days)
2. **Do (hardening):** Anti-debug checks + Polar activation limits (device caps). Adds another layer. (~3 days)
3. **Skip (for now):** Environment VM detection, memory protection, complex kill-switches. The cost/benefit doesn't work at indie scale. These are for enterprise or high-value targets.
The determined reverse engineer with IDA Pro and unlimited time will always win. The goal is to make the cost of cracking exceed the value of the software — for a solo dev product, that bar is low. Focus on making a great product that people want to pay for.
1. **Upload the depot** with `steamcmd +login <user> +run_app_build ..\scripts\app_build_<appid>.vdf
+quit`. The VDF names the app, depot, content root and branch.
2. **Set the build live** in Steamworks → *Builds* → the new build → **Set Build Live** on the
default branch. That *is* the release; there is no artifact to hash and no manifest to submit.
3. **Announce** with Steam Events (release notes appear in the library and on the store page).
4. **Verify**: install the Demo from a clean Steam account, confirm paid-app ownership is false,
then test with an account that owns the paid app and confirm the higher tiers unlock.
**Updates** are the same upload with a higher build ID. The app keeps **no self-updater and no
version banner** — Steam is the update path, and that policy has not changed.
## 4. Entitlement wiring
- `IEntitlement` reports `IsAppOwned`. Debug uses a fake owned result; Release checks the paid app
ID through `BIsSubscribedApp`. `MainViewModel.IsAppOwned` is the app-wide binary ownership flag.
- The paid app ID is not yet configured. Until it is assigned, the Release path fails closed without
touching the native library.
- **Fail closed on every path:** no id, no client, missing DLL, thrown load, not owned.
- The EULA should expressly allow commercial/monetized streaming use; confirm with counsel or Valve
that this satisfies the Steam Subscriber Agreement's software terms.
- **Never gate a feature.** The only delta is the output profile.
## 5. Release checklist
- Build clean, 0 warnings (`./scripts/verify.sh`), tests green.
- `steam_api64.dll` present; app starts **without** Steam and falls back to the free profile.
- Paid-app ownership false → free profile; true → higher output tiers offered.
- `LlamaCasty.exe` rename landed (never rename a live exe).
- Clean Steam install on a second machine, then an update pushed and observed.
- Store page public for the full two weeks; support contact and announcements configured.
- Portable mode: `portable.txt` present → data lands in `.\data\`.
## 6. Legal and takedown
- **`LICENSE`** (EULA) forbids redistribution, resale, reverse-engineering and circumvention, and
disclaims warranty.
- **`PRIVACY.md`** must name any third party the app contacts — Google/YouTube. Steam itself is
named by Valve's own policy, not ours.
- **`THIRD-PARTY-NOTICES.txt`** stays shipped and shown in About; the ffmpeg pin stays
`lgpl-shared`; no GPL component may be linked (see `product.md` → "Licensing").
- **DMCA**: a takedown needs only the notice — host, link, good-faith statement, signature.
Statutory damages need a registration ($65). Worth doing once there is something to pirate.
- **What actually protects the product** is in `commercial.md` → "Anti-piracy posture": a useful
free tier, Steam's own delivery, and Steam ownership of the paid app. **Not obfuscation, not a
native DLL, not hardware locking.**
## 7. Effort, once
| Step | Effort |
|---|---|
| Steamworks onboarding + $100 fee | 0.5–1 day, mostly waiting on verification |
| Publish profile + `steam_api64.dll` + `IEntitlement` Steam path | 1 day |
| Depot + first `steamcmd` upload | 0.5 day, then ~15 min per release |
| Store page + assets (screenshots, capsule art) | 1–2 days |
| Clean-account Demo install + paid-app ownership test | 0.5 day |
+11 -11
View File
@@ -1,10 +1,10 @@
<Window x:Class="ytLive.GoLiveWindow"
<Window x:Class="ytLive.GoLiveWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Start Stream" Height="480" Width="520"
Icon="/Assets/llamacasty-icon.png"
WindowStartupLocation="CenterOwner" ResizeMode="NoResize"
ShowInTaskbar="False" Background="#1a1a2e">
ShowInTaskbar="False" Background="{StaticResource Brush.Background}">
<Window.Resources>
<ResourceDictionary>
@@ -22,12 +22,12 @@
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Text="Start Stream" FontSize="18" FontWeight="Bold"
Foreground="#e0e0e0" Margin="0,0,0,16"/>
Foreground="{StaticResource Brush.OnSurface}" Margin="0,0,0,16"/>
<!-- ACCOUNT -->
<StackPanel Grid.Row="1" Margin="0,0,0,16">
<TextBlock Text="ACCOUNT" FontSize="12" FontWeight="SemiBold"
Foreground="#a0a0b0" Margin="0,0,0,8"/>
Foreground="{StaticResource Brush.OnSurfaceVariant}" Margin="0,0,0,8"/>
<!-- signed in -->
<StackPanel Orientation="Horizontal">
@@ -41,11 +41,11 @@
</Style.Triggers>
</Style>
</StackPanel.Style>
<Border Width="34" Height="34" CornerRadius="17" Background="#16213e" ClipToBounds="True"
<Border Width="34" Height="34" CornerRadius="17" Background="{StaticResource Brush.SurfaceContainer}" ClipToBounds="True"
VerticalAlignment="Center" Margin="0,0,10,0">
<Image Source="{Binding AccountAvatarUrl}" Stretch="UniformToFill"/>
</Border>
<TextBlock Text="{Binding AccountDisplayName}" Foreground="#e0e0e0" FontSize="14"
<TextBlock Text="{Binding AccountDisplayName}" Foreground="{StaticResource Brush.OnSurface}" FontSize="14"
VerticalAlignment="Center" Margin="0,0,12,0"/>
<Button Content="Change Account" Command="{Binding SignInCommand}"
Style="{StaticResource YtButtonSecondary}" VerticalAlignment="Center"/>
@@ -65,12 +65,12 @@
</StackPanel.Style>
<Button Content="Sign in to YouTube" Command="{Binding SignInCommand}"
Style="{StaticResource YtButtonSecondary}" Margin="0,0,10,0"/>
<TextBlock Text="Going live requires a YouTube account." Foreground="#a0a0b0" FontSize="12"
<TextBlock Text="Going live requires a YouTube account." Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="12"
VerticalAlignment="Center"/>
</StackPanel>
<TextBlock Text="Signing in… open the browser to authorize, then come back here."
Foreground="#e94560" FontSize="12" Margin="0,8,0,0">
Foreground="{StaticResource Brush.Error}" FontSize="12" Margin="0,8,0,0">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Visibility" Value="Collapsed"/>
@@ -87,7 +87,7 @@
<!-- STREAM METADATA -->
<StackPanel Grid.Row="2">
<TextBlock Text="STREAM DETAILS" FontSize="12" FontWeight="SemiBold"
Foreground="#a0a0b0" Margin="0,0,0,8"/>
Foreground="{StaticResource Brush.OnSurfaceVariant}" Margin="0,0,0,8"/>
<TextBlock Text="Title" Style="{StaticResource YtLabel}"/>
<TextBox Style="{StaticResource YtTextBox}" Margin="0,0,0,12"
@@ -100,10 +100,10 @@
<TextBlock Text="Visibility" Style="{StaticResource YtLabel}"/>
<StackPanel Orientation="Horizontal" Margin="0,0,0,12">
<TextBlock Text="Private" Foreground="#e0e0e0" FontSize="14"
<TextBlock Text="Private" Foreground="{StaticResource Brush.OnSurface}" FontSize="14"
VerticalAlignment="Center"/>
<TextBlock Text="Streams always start Private — change it on YouTube after going live."
Foreground="#a0a0b0" FontSize="12" TextWrapping="Wrap"
Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="12" TextWrapping="Wrap"
Margin="10,0,0,0" VerticalAlignment="Center"/>
</StackPanel>
</StackPanel>
+34 -372
View File
@@ -1,378 +1,40 @@
# HANDOFF — 2026-10-26 (ai.md memory split, 3 units landed; see below — everything after this block is the prior 2026-10-22 state, still accurate)
# HANDOFF — 2026-11-12 (bitrate + audio-clock fixes; sync number pending)
## At a glance (2026-10-26 docs session)
- **`ai.md` is being split into `docs/ai/*.md`** (user-approved: verbatim move, staged units, "ai.md
only"). Three units landed as separate commits, **docs only, not pushed**:
`804b1d2` (frame pipeline: cadence/timing/budget/compositor/pump),
`096bf6d` (capture: backdrop/webcam/background effects/vertical tier),
`a4dfca6` (output: ffmpeg/encoder/recording/audio). Each move is byte-identical in the new file;
`ai.md` keeps a short stub with the load-bearing invariants + a link. `docs/ai/index.md` routes.
**`ai.md` is now 1,289 lines (~32k tokens), down from 2,702 (~64k)** — more than half the
mandatory-read cost is gone.
- **Also landed (docs only, not pushed):** `ef60f1f` — distribution & packaging deferred until near
v1 (creator ruling 2026-10-26); see "Next step" below for the still-open route contradiction.
- **Remaining split candidates in `ai.md`** (not yet moved — smaller, more interleaved with law that
should stay): Architecture's "Social bar" (46) + "Toast notifications" (24) + "Licensing — do not
violate" (60); "YouTube Live API — design constraints" incl. "Native events & alerts" (349 total,
99 of it alerts); "Monetization" (136); "Windows packaging & distribution" (135, frozen per the
deferral above). None of these is urgent — pick back up only if the user asks to continue the split.
- **Scope-check passed on all three commits**; no source touched this session.
## State
---
- `main`; `b265d42` is the latest pushed commit. Everything after is local.
- **Bitrate saga closed.** An @8 stream averaged 5.1 Mbps because the args were capped VBR, not CBR
(measured: 9 kbps vs 1951 kbps at a 2000k target on a static frame). `700c7a8` adds `-rc cbr` for
NVENC/AMF, nothing for QSV (infers CBR from bitrate == maxrate) or OpenH264 (no measurable gain).
- Ladder `1708667` (12/10/6); floor `93719a2` (tier YouTube minimum); rate box `f2cab5c`; rate history +
consent proposal `b0f8263`; pre-release 60 fps switch `1e3a4f7`.
- **Audio drift closed** (`47f409f`, `102cb96`, `751d9b1`): the mix is driven by the monotonic clock,
counts only audio whose write reached the pipe, and starts its clock at session open. Measured
across three recordings: +2.1% long → 226 ms short → 137 ms short → **lengths now match (34 ms in
13 s)**.
- **Sync answer (measured, settled).** Mic delay ships at **0** and should stay off; with it off the audio
path runs ~100 ms behind, so the **global** LIP-SYNC offset is **−130 ms**. Both offsets default to 0 —
a correction belongs to one camera, never to the product (`AudioSyncSettingsTests` pins it).
- **675 tests green, 0 warnings.** `scripts/avsync.py` measures any recording in seconds.
## Prior state (2026-10-22) — the creator's properties-panel asks shipped, incl. TASK 63, and pushed; the vv webcam Units 1+2 state below
## Next action
## At a glance
- **Branch** main, tracking `origin/main`. **The six-commit properties-panel batch is PUSHED**
(2026-10-22, as a sub-milestone): TASK 58 the theme sweep, TASK 59 the properties hierarchy,
TASK 60 the colour names, a docs commit, TASK 61+62 the geometry field widths and the colour
name beside the hex, and TASK 63 the border-colour picker as the app's own drop-down — the
creator's properties-panel asks, done in one batch. `origin/main` is now `987fdda`.
- The vv feature (Units 1+2) **was** pushed at its close (2026-10-21 — the creator approved
the Unit 1 push after testing it, and the completed feature is a milestone).
- **The push path changed:** `origin` is now
`https://llgit.llamachile.tube/gramps/LlamaCasty.git` (HTTPS + the credential helper
`/home/gramps/bin/gitea-credential`, which reads the password from the gitignored
`CREDENTIALS.md`; plain `git push` works). The old SSH remote was broken twice over: the repo
was **renamed `gramps/ytLlive` → `gramps/LlamaCasty`**, and `llgit:2214` refuses the publickey
of this machine's key even though it IS registered (account key id 6 "Corsair VSC"). Ask the
Gitea REST API, don't do key archaeology — recipe in `MyMistakes.md` → 2026-10-21.
- **This session shipped vv webcam Units 1+2** (the vertical 9:16 tier keeps its OWN camera placement
per scene; 16:9 restores the desktop layout exactly; the Chat scene in vv is rect-only and can be
enlarged to fill a whole 607×540 area). Code + memory in ONE commit each; the ONE Good Dog tests
are `ytLive.Tests/VerticalWebcamLayoutTests.cs` (Unit 1) and
`ytLive.Tests/VerticalChatCameraLayoutTests.cs` (Unit 2). **Only the creator's hands-on check is
left** — see the vv section.
- **TASK 58 — the properties panel now wears the theme** (shipped 2026-10-21). The creator's
"why is the drop-down not in the same style as the others?" was a real defect: those drop-downs
(and every check box, and every properties field) had been hand-painted with a `Background`/
`Foreground` instead of a style, so WPF drew the **light system template** on top of the dark
panel. Now: `ComboBox` → `YtComboBox`, `CheckBox` → `YtCheckBox` (new), properties fields →
`YtTextBoxCompact` (new, BasedOn `YtTextBox`). Guard:
`ytLive.Tests/PropertiesStyleTests.cs`.
- **TASK 59 — the properties panel is a hierarchy** (shipped 2026-10-21). A title owns its
content and the content is indented one level (10px): `Shape` (new title) over the
Mirror switch and the shape choice, the drag hint indented under that row, Geometry /
Border Color / Border Thickness as titles with their fields indented, Background's
Remove + Blur indented, and Strength one level deeper under Blur. **The shape became a
CHOICE**: `Rect` | `Circle` as two segment options with **Rect selected by default**
(the old single button showed the *next* action, "Round"). The mutual exclusion lives
in the model (`IsRectShape`/`IsRoundShape` on `SceneElement`) — a WPF `RadioButton`
group would kill the bindings. Guard: `ytLive.Tests/PropertiesHierarchyTests.cs`.
- **TASK 60 — the border-colour drop-down names every colour** (shipped 2026-10-21). The creator:
*"the #hex values are good, but add a corresponding color name/label next to the value"* — each row
now reads `#e94560 Crimson` (the name dimmed, the tooltip "Crimson (#e94560)"). The 14 colours
moved out of the click handler into `Models/BorderColorPalette.cs` (`NamedColor(Hex, Name)` +
`NameOf(hex)`), so the list is testable and extensible. Guard:
`ytLive.Tests/BorderColorPaletteTests.cs`.
- **TASK 61 + 62 — the geometry fields and the border-colour field** (shipped 2026-10-21).
- **61:** the X/Y/W/H fields share ONE fixed width (44px — the largest geometry value, 1920,
plus a sign) and their rows set `LastChildFill="False"`, so Y/H no longer stretch to fill
the row while X/W sized themselves to their text. Guard: `ytLive.Tests/GeometryFieldWidthTests.cs`
(it MEASURES the widest value with WPF's `FormattedText` — measure, don't guess).
- **62:** the border-colour field shows the palette's **name beside the hex**
(`SceneElement.BorderColorName`, empty for a custom hex), and the hex field keeps a fixed
width so the name stays next to the hex. The field stays editable (a custom hex can
still be typed). Guard: `ytLive.Tests/BorderColorNameTests.cs`.
- **TASK 63 - the border-colour picker IS the app's drop-down** (shipped 2026-10-21; **popup click
deselection fixed 2026-10-22**). The creator:
*"why does the drop down under the web cam properties not show the same style for the border
color drop down list box?"* and *"we established look-and-feel standards. And yet you keep
ignoring these and I'm wondering why."* **Root cause of the miss:** the TASK 58 sweep looked for
control **types** (`ComboBox`/`CheckBox`/`TextBox`) without a theme style - and the border picker
was NONE of those: it was a `...` `Button` whose `Click` handler built a `ContextMenu` in code.
It was never in the sweep's scope, and `PropertiesStyleTests.cs` (a type-based guard) could not
see it either. Now: the picker is a **`YtComboBox`** bound to the palette
(`SelectedElement.BorderColorOptions` / `BorderColorEntry`), the **closed box shows the swatch +
the colour's NAME**, the **rows show swatch + hex + name**, the hex field stays for a custom
colour, the swatch is painted by the theme's `HexToBrush` converter, and the hand-built menu +
`WebcamBorderColorPicker_Click` are **gone**. The closed box != the rows only through
`Helpers/ClosedBoxTemplateSelector.cs` (WPF copies `ItemTemplate` into the read-only
`SelectionBoxItemTemplate`; the selector is told them apart by the `ContentPresenter`'s
templated parent - `ComboBox` = closed box, `ComboBoxItem` = a row). **2026-10-22 creator bug fix:**
clicking a colour in the open `ComboBox` popup registered as an exit from the properties listing
because `LeftPanel.IsDescendantOf` used `child is Visual ? VisualTreeHelper.GetParent(child) :
LogicalTreeHelper.GetParent(child)` — at `PopupRoot` (which IS a `Visual`),
`VisualTreeHelper.GetParent` returns `null`, so `IsDescendantOf` stopped without crossing
`PopupRoot`'s logical parent (`PART_Popup` → `ComboBox` → `ElementsScrollViewer`), and
`MainWindow.Window_PreviewMouseLeftButtonDown` cleared `SelectedElement = null`. Fixed by falling
back to `LogicalTreeHelper.GetParent` / `TemplatedParent` when `VisualTreeHelper.GetParent`
returns `null`. Guard: `ytLive.Tests/BorderColorPickerTests.cs` (model + panel + the **real
widget**, including `LeftPanel.IsClickInside` on an open popup row and clicking a row to change
`BorderColor`). The TASK 60
guard (`BorderColorPaletteTests`) was updated to guard the new wiring (the drop-down is bound to
the palette; no second list of colours anywhere).
- **TASK 55 slice 1 — the log is capped (shipped 2026-10-22).** `startup.log` was uncapped (23 MB
and growing every few seconds — ffmpeg logs every stderr line). Now it rolls to `startup.1.log`
at 5 MB (one rolled copy kept), so the growth is bounded. The policy is
`Helpers/LogRotation.cs` (a pure function over a path — `AppLog`'s 156-call-site surface is
unchanged; the seams are two `internal` properties). Guard:
`ytLive.Tests/AppLogRotationTests.cs`. Slices 2 (temp alert-clip cleanup) and 3 (low-disk
guard before recording) are still open.
- **TASK 48 slice 2 — the app PACKS as a Store MSIX (shipped 2026-10-22).** `Packaging/Package.appxmanifest`
(full trust: `Windows.FullTrustApplication` + `uap10:TrustLevel=mediumIL` +
`uap10:RuntimeBehavior=packagedClassicApp` + `rescap runFullTrust`, plus webcam/microphone
`DeviceCapability`s, x64, en-US only) + `Packaging/Assets/` (four logo tiles generated from the
app's own artwork by `scripts/make-store-logos.py`) + `scripts/package-msix.sh` (publish
`Release -r win-x64 --self-contained` → stage → `makeappx pack`, optional `MSIX_PFX` signing).
**First real package built: `artifacts/LlamaCasty.msix`, 159 MB, 509 files, `Package creation
succeeded`** (`artifacts/` is gitignored). Guard:
`StorePackagingTests.The_package_manifest_declares_full_trust_camera_and_microphone`.
⚠️ Before submission the manifest's `Identity/@Publisher` and `PublisherDisplayName` must be
set to the exact values Partner Center shows — a mismatch is a rejection.
- **TASK 48 slice 3 — the self-updater is gone (shipped 2026-10-22).** The Store route (creator
ruling 2026-09-27) means the Store updates the app, so the Velopack package + bootstrap were
**deleted** (`ytLive.csproj`, `App.xaml.cs`) — a compliance action, not cleanup: the updater
downloaded *and executed*, which Store policy 10.2.2 does not permit. The DO update-URL droplet
dies with it. Guard: `ytLive.Tests/StorePackagingTests.cs` (no updater package, no updater API
in any app source file). The app was smoke-tested after the removal: `MainWindow loaded`, no
exceptions. **Still open on TASK 48:** slice 2 (the `Package.appxmanifest` + packaging project),
slice 6 (the full-trust recording invariant comment), slice 7 (disqualifier re-check), slice 8
(the pre-submission gates) — and slices 4/5 are the creator's (Certification Kit, Partner
Center listing, the demo account).
- **The previous session (HA lights) produced NO ytLive code** — it was spent on the creator's Home
Assistant light alert system (off-repo, `192.168.50.145`). See `MyMistakes.md` → 2026-10-21: the plan
was one tweak to a verified-working script; the script was rewritten twice, the alert path broke, and
the day was burned. **The HA work landed OK in the end** (recipes + measurements now in
`MyMistakes.md`). Records below so the next session does not re-hunt.
- Creator: set the mic delay to 350, record ~15 s, run `./scripts/avsync.py "<clip>" --crop
764x764+1032+159`. Target: `offset` inside ±30 ms and `lengths match`.
- Then resume the queue: **40·A/C/D → Move → 9·5 → 14·16/17/18 → 66 → 67 → 68 → 69 → 70 → 36**.
- Do not push app commits absent a checkpoint request.
## HA light alert system — current, verified state (2026-10-21)
Five Tapo bulbs: `light.br1`, `light.br2`, `light.officeone`, `light.officetwo`,
`light.death_star_light`. Purpose: the creator is **deaf** — lights are the alert channel; the phone
(`notify.mobile_app_mis_lx9`) is secondary. **The iPhone is gone — never notify it.**
## Landmines
| State | br1 / officeone | br2 / officetwo | Death Star |
|---|---|---|---|
| Idle | off | off | off |
| Pump running | off | **off** | **blue only** (creator directive) |
| Leak | warm-white 100% | purple | purple |
| Alarm | warm-white 100% | red | red |
| Doorbell | green, 8s | green, 8s | green, 8s |
| Doorbell during an alert | — | suppressed — | — |
| Clear | off | off | off |
**Architecture:** `script.alerts_arm` / `alerts_clear` write only `input_text.light_alert`
(`none|leak|alarm|motion`, restart-surviving). **`script.alerts_render_lights` is the single writer of
the bulbs** — flat top-level `if`/`else` blocks (nested `if` and `choose:` both silently lit nothing;
do not "improve" the shape). Priority: an alert always beats pump status.
**⛔ THE ENTITY-ID TRAP (cost this session hours):** the YAML key is `alerts_render` but the entity id is
**`script.alerts_render_lights`**. `script.turn_on` on a non-existent entity returns **HTTP 200 and does
nothing** — no error, no log. All callers now use the `_lights` id. Check this first on any "script does
nothing" symptom.
**Pump status** is a *status*, not an alert (auto-clearing), and is keyed off
`sensor.water_pump_current_consumption` — **never** `switch.water_pump_2`, which was observed latched
`on` for hours at 0.0A draw and painted the bedroom blue at 5am.
**HA files touched** (`/config`, root-owned → writes need `sudo`): `scripts.yaml`, `automations.yaml`,
`configuration.yaml` (added `input_text.light_alert` / `_prev`). Backups: `scripts.yaml.bak-alertbuild`,
`automations.yaml.bak-alertbuild`, `automations.yaml.bak-notify-20261020`, `configuration.yaml.bak-tplinkfix`.
Zombie entities deleted from the registry: `script.leak_strobe`, `script.doorbell_flash` (old one),
`automation.leak_strobe_notify_clear_restore`, `script.alerts_render_lights_2`.
**Leak sensor is healthy** (`binary_sensor.pump_leak_moisture` = off, battery 100, signal 3) — an earlier
"unavailable" claim was a bad read during a reload; `alert_leak_on` now also triggers on the latched
`sensor.pump_leak_last_water_leak_alert`.
## vv webcam feature — locked rules (creator, 2026-10-17) — **Units 1+2 shipped 2026-10-21**
| mode | shape | size | position |
|---|---|---|---|
| 16:9 | as today | as today | as today |
| **vv · Live** | **circle OR rect, creator's choice** | small tile | one of six spots: top/bottom × L/C/R |
| **vv · Chat** | **rect only — no circle option** | normal, or enlarged to fill one area (607×540) | enlarged → top or bottom |
| Starting / BRB / Ending | unchanged | unchanged | unchanged |
- **Placement is set by tap/click-drag with snap, exactly like the socials today**, plus manual entry in
properties. **vv keeps its own camera position, size and shape per scene** — switching back to 16:9
restores the desktop layout exactly.
- **No explicit borderless/no-background mode is needed** (round + border/bg options already allow it).
- Creator's "square" means the rectangular view (the camera's own aspect), not a literal 1:1 box.
### What the code already gives us (do not re-hunt)
- **vv = the active tier's centred band crop.** 1080p60-vertical: master band **x 656.25–1263.75
(607.5 wide), full height**, upscaled to 1080×1920 (`MainViewModel.Streaming.cs` `ApplyStreamQuality`
→ `OutputRect*`). The band splits into **two 607×540 areas** (top/bottom).
- **The Live webcam sits at x≈1425 → entirely outside the band** — in vv it is invisible. That is the
whole motivation.
- **Geometry only — no render-path change.** Preview (`Controls/PreviewPane.xaml` 144–230) and compositor
(`Services/Compositor/SceneCompositor.cs` 389–397) both read the element's own `X/Y/W/H` + `ClipShape`.
- **Round / border / background are independent:** `ClipShape.Round`; `HasBorder` = width > 0 AND valid
6-hex colour; `BackgroundEffect` = None/Remove/Blur.
- **Per-scene camera size policy exists**: `MaxWebcamWidthFor/HeightFor` (Chat 1358×764, others 960×540)
+ `ClampWebcamToBounds`. **A 607×540 tile sits inside both → no clamp change.**
- **Storage:** `WebcamSceneConfig` table; new columns via `MigrateWebcamConfigTable()` as idempotent
`ALTER TABLE … ADD COLUMN` guarded on `PRAGMA table_info` + `LayoutStore.Save.cs` INSERT and
`LayoutStore.Load.cs` SELECT.
- **Precedent for remembered geometry:** `RectWidth/RectHeight` + `ToggleClipShape`
(`Models/SceneElement.cs` 124–152).
### What shipped in Unit 1 (2026-10-21) — do not re-hunt
- **Geometry only — NO render-path change.** The tier switch swaps the element's LIVE geometry; the
preview and the compositor read `X/Y/W/H` + `ClipShape` exactly as before.
- `Models/VerticalCameraLayout.cs` (new): the pure math — band x 656.25, w 607.5, h 1080; two
607×540 areas; six spots; default tile **270×152 master px** (= 480×270 in the 1080×1920 output),
**bottom-right** spot; inset 32 master px.
- `Models/WebcamSceneConfig.cs`: the vertical placement is persisted in **seven additive columns**
(`VerticalX/Y/Width/Height/VerticalClipShape/VerticalRectWidth/VerticalRectHeight`, 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. `BeginVerticalLayout` /
`EndVerticalLayout` / `CommitLiveLayoutToVertical` do the swap.
- `Models/SceneElement.cs`: `X/Y/Width/Height` are now **`virtual`** so the webcam clamps EVERY write
(drag, resize, typed fields) into the band — outside the band = invisible in vv.
- `ViewModels/MainViewModel.Webcam.cs`: `IsVerticalTier`/`VerticalBand` + `ResolveVerticalCameraLayouts`
(the tier swap; the **Live scene gets its tile automatically** on the first vertical switch) +
`MoveWebcamInVerticalTier`/`SnapWebcamInVerticalTier` (the six-spot snap, by the tile's CENTRE).
`MainViewModel.Streaming.cs` `ApplyStreamQuality` is the tier-change hook.
- `Controls/LeftPanel.xaml`: the webcam properties gained a **Geometry** section (X/Y/W/H) 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).
- `Controls/PreviewPane.xaml.cs`: dragging/resizing a webcam under the vertical tier routes through the
VM's snap instead of free-floating over the master.
- Test seam: the drag lives in a **VM method**, so the test drives it without synthesising mouse input.
### What shipped in Unit 2 (the Chat scene, 2026-10-21) — do not re-hunt
- **Rect only, and the lock is on the MODEL, not the UI.** `WebcamSceneConfig.VerticalShapeLocked`
(set per scene by the VM) **overrides the `ClipShape` property** → a locked camera cannot become
Round by any writer (the toggle, the Shape button, the placement swap). `SceneElement.ClipShape`
and `SceneElement.ToggleClipShape()` are `virtual` for this. The lock is cleared **before** the
16:9 layout comes back, so the desktop shape is restored.
- **The circle is not OFFERED:** `MainViewModel.CanToggleClipShape` is false for a Chat camera in the
vertical tier → the Shape button in the webcam's properties **disappears** (not just disabled).
`MainViewModel.cs` raises it on the selection change; `MainViewModel.Streaming.cs` on the tier change.
- **Two sizes, snapped by the nearer one:** `VerticalCameraLayout.SizeFor(band, requestedWidth)` →
the small tile (270×152) or one whole area (607.5×540). The resize handle therefore has two
detents instead of any size in between.
- **An enlarged camera fills the area its centre is in** — top or bottom (`VerticalCameraLayout.AreaFor`).
- **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). `ResolveVerticalCameraLayout` now auto-tiles Live **and**
Chat; Starting/BRB/Ending keep their 16:9 placement.
- The preview's resize routes through `MainViewModel.ResizeWebcamInVerticalTier` (the Chat camera's
discrete size + the snap); the drag routes through `MoveWebcamInVerticalTier` (assign → clamp →
snap, so the enlarged camera can be dragged from the top area to the bottom one).
- *Test:* `ytLive.Tests/VerticalChatCameraLayoutTests.cs` — **PASSES** (in Chat under the vertical
tier, enlarging fills the full top area and the circle option is not offered).
### Units (each: ONE Good Dog integration test, own commit, memory in the same commit)
1. ✅ **vv camera layout (Unit 1)** — shipped 2026-10-21. Stored separately per scene, resolved when the
tier changes. Live: small tile (circle or rect) snapped to one of six spots; drag like the socials +
properties entry.
*Test:* `ytLive.Tests/VerticalWebcamLayoutTests.cs` — with the vertical tier active, dragging the Live
camera to top-right places it top-right inside the band; switching back to 16:9 restores the original
layout exactly; both placements survive a save + reload. **PASSES.**
2. ✅ **vv · Chat (Unit 2)** — shipped 2026-10-21. Rect enforced with the circle option unavailable, plus
enlarge-to-one-area (607×540) filling the top or the bottom area by where the camera is.
*Test:* `ytLive.Tests/VerticalChatCameraLayoutTests.cs` — in Chat under the vertical tier, enlarge sets
the full top area and the circle option is not offered. **PASSES.**
## Next step
- **✅ TASK 63 + popup-click fix verified by creator (2026-10-22)** — clicking a colour in the
Border Color `YtComboBox` dropdown now stays inside `LeftPanel` (`IsDescendantOf` crosses
`PopupRoot` → `PART_Popup` via `LogicalTreeHelper.GetParent`), updates the webcam border colour,
and does not exit the properties listing.
- **Creator check (needs the creator's hands)** — the last piece of the vv feature: switch the
resolution dropdown to "Vertical 1080p60", confirm the Live camera tile lands inside the band and
the **Circle** option (TASK 59 turned the Shape button into a Rect | Circle choice) turns the tile
into a circle there, open the **Chat** scene and confirm the **Circle** option is **gone** (rect only)
and that dragging the tile up and enlarging it fills a whole 607×540 area, then
switch back and confirm the desktop layout is untouched. The unit tests cover the geometry, not the
eyeball.
- **⏸ Distribution & packaging is DEFERRED (creator ruling 2026-10-26) — do not pick it up until
we are close to v1.** The creator's reasons: the remaining programming tasks are far from
release, and a **complete UX redesign is about to start** — so packaging/signing/updater/route
decisions would chase scope that is about to move. Nothing distribution-related is urgent now.
Frozen: TASK 48 items 6-8, the signing decision, the updater choice, and the **route contradiction**
(`ai.md` says Store route DEAD → direct + Steam; `TASKS.md`/`TASKS/task-48-…`/this file still say
Store MSIX + Store IAP). That contradiction is to be settled in ONE distribution sweep at the
revisit, not patched piecemeal in the meantime. Detail: `TASKS.md` → Open items → the ⏸ TASK 48
bullet.
- Nothing else is queued for llamacasty — the creator queues work.
## Open (operational)
- **Follow-up (not fixed — out of scope for TASK 61):** the **web source** block's geometry rows in
`Controls/LeftPanel.xaml` (the `SelectedElement.IsWebSource` panel, ~lines 387-402) have the
identical shape and the identical defect as the webcam's did — the Y/H fields stretch to fill the
row. Same one-line-per-field fix as TASK 61, when the creator wants it.
- **✅ FIXED in TASK 63:** the border-colour swatches now carry a 1px `BorderBrush="#2a3a5e"`, so
the black (`#000000`) and dark swatches are visible against the dark menu (`#16213e`).
- **Follow-up spotted while doing TASK 63 (not fixed — out of scope for that change):** the theme has
**no implicit `Button` style**, so every plain `<Button>` in the app still renders with the light
WPF system template (`Controls/LeftPanel.xaml` ×7, `Controls/CameraPickerDialog.xaml` ×2). The
border picker's `...` button was one of them and is gone; the rest are still there. A theme-level
implicit `Button` style would fix them all at once — that is a creator-queued task, not a silent edit.
- **GOG Galaxy launcher shows "offline" (2026-10-21) — GOG's own overnight outage, NOT Windows.** GOG
put `www.gog.com` behind a maintenance page (503) and their Fastly services for `auth.gog.com` /
`api.gog.com` / `remote-config.gog.com` return `500 "Fastly error: unknown domain"`; the page itself
says *"Galaxy Launcher will also be down."* **Window: 00:00–08:11 local on 2026-10-21** — the Wayback
snapshot of `www.gog.com/en/` at 06:56 UTC is HTTP 200 with the full live storefront. `status.gog.com`
is NXDOMAIN globally. Local exoneration chain: genuine `GOG sp. z o.o → DigiCert` cert (no MITM),
local DNS == Google's authoritative answers, real Fastly POP headers, and GOG's own non-Fastly servers
still answering. **Nothing to fix locally** — wait for GOG. Galaxy's logs (incl. the rotated ones) show
no API-status failures between Oct 10 and Oct 20. Diagnosis recipe + the `webfetch`-is-not-an-
independent-egress trap: `MyMistakes.md` → 2026-10-21 GOG Galaxy "offline". Galaxy 2.0 logs live in
`C:\ProgramData\GOG.com\Galaxy\logs\` (NOT `%LOCALAPPDATA%`). ⚠️ that folder also holds a **2.7 GB
`cef.log`** (since Jul 13) — disk hygiene, see TASK 55.
- **verify.sh's known-failure whitelist is too loose** (confirmed twice 2026-10-16): require EVERY failure
line to be whitelisted, and add `MasterLimiter_CapsTheLiveMix_OnThePipe` +
`RealMouseDrag_OnTheLayerList_PersistsTheReorder` to the list.
- **A pre-existing stash is still sitting there:** `stash@{0}` = "WIP on main: 0464593 Compositor:
fast-path opaque 1:1 when source fills canvas" (NOT this session's; do not drop blindly).
- The judder reproduction take in the FAILING geometry (vertical 1080p60 first) is still outstanding — no
fix until a take names the stage.
- ⛔ Sudo password rotation pending (user; then update both `CREDENTIALS.md` password lines).
- ✅ **TASK 56 secret scan — DONE 2026-10-22, CLEAN.** 432 reachable commits (all branches, 35
tags, reflog, stash) + 63 dangling commits + the worktree: **0 real secrets**. 4 rule hits, all
false positives (`README.md:79` = the `your-client-secret` doc placeholder;
`TokenStoreTests.cs:22` = a 9-char test dummy). `CREDENTIALS.md`/`MARCOM.md`/
`Helpers/OAuthCredentials.cs` were never tracked. ⚠️ the repo was **already public** (anonymous
GET → 200, API `private = false`) when the scan ran — the gate came late, but nothing leaked.
Method + verdict: `TASKS/task-56-secret-scan.md`.
- TASK 55 disk hygiene: slice 1 (log rotation) **shipped 2026-10-22** — see the At-a-glance bullet.
Slices 2 (temp alert-clip cleanup) + 3 (low-disk guard before recording) are open. (The GOG
Galaxy `cef.log` at 2.7 GB is NOT ours.)
## Tests
- Last full run: **438/438 passed, 0 warnings** (2026-10-22 — after TASK 48 slice 2's
manifest guard; the run before was 437/437 after slice 3's `StorePackagingTests`, then
436/436 after TASK 55 slice 1's `AppLogRotationTests` and 435/435 after TASK 63). The one
failure seen earlier in the
session was `LayerReorderPersistenceTests.RealMouseDrag_OnTheLayerList_PersistsTheReorder`,
which **passes in isolation** (re-run: 3/3) — the known real-mouse flake, not a regression.
- Known flaky-by-environment (all pass in isolation): `AudioPipelineTests.Mix_HonorsProviderGains…`,
`MasterLimiter_CapsTheLiveMix_OnThePipe`, `LayerReorderPersistenceTests.RealMouseDrag…`,
`Round_Clip_Corner…`.
- ⚠️ **`LayerReorderPersistenceTests.RealMouseDrag_OnTheLayerList_PersistsTheReorder` is NOT
"passes in isolation" any more.** During this session it FAILED ALONE on a **clean tree** at
`58b9a7d` (the vv work stashed) with the same "Expected: ImgC / Actual: ImgA" — i.e. it is an
environment/real-mouse flake, **not** a regression from the vv work — and it PASSES in the
full-suite run (429/429). Treat it as environment-dependent, and don't chase it as a regression
without stashing first.
## Secrets / paths (do not re-hunt)
- Layout DB: `%APPDATA%\ytLlive\ytLlive.db` (SQLite, full-rewrite per save). Auth:
`%APPDATA%\ytLlive\ytLlive.auth` (DPAPI). Startup log: `%APPDATA%\ytLlive\startup.log`
(23MB — now CAPPED at 5 MB with one rolled copy, TASK 55).
- Recordings land in `C:\Users\gramp\Downloads\recordings\` (`ty-<date>-<time>-<ms>-<seq>.mp4`; a
duplicate name gets a `-2` suffix).
- **Conversation history:** `~/.local/share/opencode/opencode.db`, table **`session_message`** — creator
turns are `type='user'` with text in `data.text`; my turns are `type='assistant'` with reasoning AND the
rendered reply concatenated into `content[0].text`. The **reply marker's verbatim string is recorded in
`MyMistakes.md`**.
- LAN credentials: `CREDENTIALS.md` (gitignored) — **⚠️ stale for HA: it says `192.168.50.144`, the real
Home Assistant is `192.168.50.145`** (`homeassistant.local`, HAOS 18.2, Core 2026.10.1, SSH
`gramps`/`einstein`, `/config` root-owned). `gordito` is `.144`. Fix this file when next editing it.
- Google Cloud OAuth project number `198374416003`, owned by `llamachile.shop@gmail.com` — verify at
`https://console.cloud.google.com/apis/credentials?project=198374416003`.
## Open (operational) — officerFriendly / PS5 "DNS errors" (2026-10-17, off-repo)
- **Root cause: a ~43 h-wrong system clock on officerFriendly × `dnssec = true`.** Pi 400, **no RTC
hardware at all**, healthy oscillator (−7.39 ppm). Journal shows +54 min, +7 h, +42.9 h steps.
`pihole-FTL.db`: **1,688,396 `dnssec=3` rows on 10-11, 839,276 on 10-12 vs ≤23 other days**, hitting
PSN endpoints = the PS5's "DNS errors". KSK-rollover theory **retracted**.
- **Pending creator decision (do not apply unilaterally):** tighten
`/etc/systemd/timesyncd.conf.d/lan.conf` to `PollIntervalMinSec=16` / `PollIntervalMaxSec=64` /
`ConnectionRetrySec=8` / `SaveIntervalSec=30`, and/or add a DS3231 RTC (`i2c-tools` already installed).
- **LAN DNS routing vs creator intent:** DHCP still hands out `.26` + `.1` (clients query Pi-hole
directly) whereas the creator wants everything → `.1` with BBR forwarding. Also `dnssec = true` on
Pi-hole duplicates dnscrypt-proxy's `require_dnssec = true`. Both offered-for-veto, not done.
- Router is the DHCP server (ASUS ROG GT-BE98 Pro `.1`) — **Pi-hole `.26` is DNS only**, its
`dhcp.leases` is 0 bytes.
- `Assets/starting-background.jpg` is modified in the working tree, **awaiting the creator's visual
check** — do not commit it with another unit.
- **Never read a field the constructor assigns later** (`RefreshRateProposal()` at ctor 354 reads
`_framePump`, built at 468 — 8 failing tests, `e654a16`).
- **Never pipe a long job through `tail`** — it buffers, so a live gate looks dead, and verify's phase-3
build writes to `/dev/null`. Use `vstest --logger:"console;verbosity=normal"`; it names the failure.
- A killed test run leaves `testhost.exe` holding `LlamaCasty.dll`; every later build then fails on the
copy. `taskkill.exe /F /IM testhost.exe` before rebuilding.
- `ChromaKeyRealClipTests`, `AudioPipelineTests.Mix_*`, `AppLogRotationTests` and
`NotificationAreaIntegrationTests` flake under load; each passes alone.
- Every rate change is a full encoder relaunch; rates go through `MainViewModel.EffectiveQuality`.
- `MainWindow` tests need a temporary `LayoutPathOverride` cleared in `finally`; read render numbers in Release.
+70 -2
View File
@@ -2,6 +2,24 @@ using System.IO;
namespace ytLive.Helpers;
/// <summary>Presentation category; independent from the entry's attention class.</summary>
public enum AppLogMessageType
{
Information,
Success,
Warning,
Error,
}
/// <summary>Explicit attention priority, assigned by the writer rather than inferred from text or source.</summary>
public enum AppLogErrorClass
{
/// <summary>Fatal or unhandled failure; highest priority.</summary>
Class1 = 1,
/// <summary>Recoverable failure that still requires attention.</summary>
Class2 = 2,
}
/// <summary>
/// Minimal file logger used to diagnose startup/shutdown crashes on Windows,
/// where the WPF app has no visible console. Writes to
@@ -12,6 +30,7 @@ public static class AppLog
{
/// <summary>The cap for the live log file. Past this, it rolls to <c>startup.1.log</c>.</summary>
private const long DefaultMaxBytes = 5 * 1024 * 1024;
private const int CurrentLaunchEntryLimit = 500;
/// <summary>How many rolled copies to keep (startup.1.log, startup.2.log, …).</summary>
private const int RetainedFiles = 1;
@@ -20,6 +39,8 @@ public static class AppLog
InstanceProfile.DataRoot, "startup.log");
private static readonly object Gate = new();
private static readonly List<AppLogEntry> CurrentLaunchEntries = new();
private static long _nextSequence;
/// <summary>Test seam: point the log somewhere else. Null = the real profile's log.</summary>
internal static string? PathOverride { get; set; }
@@ -29,16 +50,40 @@ public static class AppLog
private static string LogPath => PathOverride ?? DefaultLogPath;
internal static IReadOnlyList<AppLogEntry> SnapshotCurrentLaunch()
{
lock (Gate)
return CurrentLaunchEntries.ToArray();
}
public static void Write(string message)
=> Write(message, AppLogMessageType.Information);
public static void WriteWarning(string message)
=> Write(message, AppLogMessageType.Warning);
public static void WriteSuccess(string message)
=> Write(message, AppLogMessageType.Success);
public static void Write(string message, AppLogMessageType type, AppLogErrorClass? errorClass = null)
{
try
{
lock (Gate)
{
var entry = new AppLogEntry(
++_nextSequence,
$"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff} {message}",
type,
errorClass);
CurrentLaunchEntries.Add(entry);
if (CurrentLaunchEntries.Count > CurrentLaunchEntryLimit)
CurrentLaunchEntries.RemoveAt(0);
var path = LogPath;
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
LogRotation.RollIfNeeded(path, MaxBytes, RetainedFiles);
File.AppendAllText(path, $"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff} {message}{Environment.NewLine}");
File.AppendAllText(path, $"{entry.Text}{Environment.NewLine}");
}
}
catch
@@ -49,6 +94,29 @@ public static class AppLog
public static void Write(Exception exception, string message)
{
Write($"{message}: {exception}");
Write($"{message}: {exception}", AppLogMessageType.Error);
}
public static void Write(Exception exception, string message, AppLogErrorClass errorClass)
=> Write($"{message}: {exception}", AppLogMessageType.Error, errorClass);
}
public sealed record AppLogEntry(long Sequence, string Text, AppLogMessageType Type,
AppLogErrorClass? ErrorClass)
{
public string? ErrorClassLabel => ErrorClass switch
{
AppLogErrorClass.Class1 => "CLASS 1",
AppLogErrorClass.Class2 => "CLASS 2",
_ => null,
};
public string TypeLabel => ErrorClassLabel ?? Type switch
{
AppLogMessageType.Information => "INFO",
AppLogMessageType.Success => "SUCCESS",
AppLogMessageType.Warning => "WARNING",
AppLogMessageType.Error => "ERROR",
_ => Type.ToString().ToUpperInvariant(),
};
}
+27
View File
@@ -0,0 +1,27 @@
using System;
namespace ytLive.Helpers;
/// <summary>The accessible mono fold: average each stereo pair into both channels.
///
/// <para>Creator context (2026-10-30): profoundly deaf on the right. Hard-panned content is not
/// quieter to them, it is ABSENT, so a single-sided game or clip is unusable. Folding to mono puts
/// everything on both sides — the loudness cost is honest (a one-sided signal arrives at half
/// amplitude) and speech is unaffected because the mic is already centred.</para></summary>
public static class AudioFold
{
/// <summary>Folds an interleaved buffer in place. Mono buffers are left untouched: there is
/// nothing to combine, and rewriting them would be a silent no-op that hides a caller's bug.</summary>
public static void ToMono(Span<float> interleaved, int channels)
{
if (channels < 2) return;
var frames = interleaved.Length / channels;
for (var frame = 0; frame < frames; frame++)
{
var offset = frame * channels;
var mono = (interleaved[offset] + interleaved[offset + 1]) * 0.5f;
interleaved[offset] = mono;
interleaved[offset + 1] = mono;
}
}
}
+29
View File
@@ -0,0 +1,29 @@
namespace ytLive.Helpers;
/// <summary>
/// Whether this binary is a **development build**.
///
/// <para><b>Why <c>static readonly</c> and not <c>const</c>:</b> a const makes
/// <c>if (!DevBuild.IsDevelopment) return;</c> a compile-time branch, so whichever arm is dead
/// raises CS0162 — in Debug the guard is dead, in Release the body is. A readonly field is set
/// once at type init, so both arms compile clean and the JIT folds the branch away.</para>
///
/// <para><b>This is not the only defence.</b> The developer panel's XAML and code-behind are
/// removed from the Release compile entirely (<c>ytLive.csproj</c>, Page/Compile Remove), so a
/// shipped binary contains no BAML to reveal. This flag is the second lock: it refuses the
/// value even if something else calls the setter.</para>
///
/// <para>Single source for the question. It gates the multi-instance profile
/// (<see cref="InstanceProfile"/>), the scoreboard's debug seeding, and the all-resolutions
/// override (<c>MainViewModel.DeveloperMode</c>).</para>
/// </summary>
public static class DevBuild
{
/// <summary>True for a Debug compile; false for Release and anything else.</summary>
public static readonly bool IsDevelopment =
#if DEBUG
true;
#else
false;
#endif
}
+36
View File
@@ -0,0 +1,36 @@
using System;
using System.Collections;
namespace ytLive.Helpers;
/// <summary>
/// The arithmetic behind dragging a row in the layer list, kept out of the window's code-behind so it
/// can be tested without a mouse. It used to live inline in the preview-mouse handlers, and the only
/// test that covered it injected REAL cursor movement (<c>SetCursorPos</c> + <c>mouse_event</c>) — which
/// took over whatever machine ran the tests and failed whenever the desktop was busy. 2026-11-05: the
/// arithmetic moved here (pure, tested), the injected-input test was deleted.
/// </summary>
public static class DragReorderMath
{
/// <summary>True once the pointer has travelled far enough to mean "drag", not "click". WPF's
/// own thresholds are the caller's (<c>SystemParameters.MinimumHorizontal/VerticalDragDistance</c>).</summary>
public static bool ExceededThreshold(double dx, double dy, double horizontal, double vertical)
=> Math.Abs(dx) >= horizontal || Math.Abs(dy) >= vertical;
/// <summary>Moves one item to the position currently occupied by another. Returns the index the
/// item actually landed on (the caller keeps dragging from there), or -1 when the move is a no-op
/// or the indices are out of range.
/// <para>Order matters: the item is removed first, so a downward move inserts at the target's old
/// index and lands exactly under the pointer — this is the behaviour the mouse test used to pin.</para>
/// </summary>
public static int TryMove(IList items, int from, int to)
{
if (items == null || from < 0 || to < 0 || from >= items.Count || to >= items.Count) return -1;
if (from == to) return -1;
var moved = items[from]!;
items.RemoveAt(from);
items.Insert(to, moved);
return to;
}
}
+23
View File
@@ -0,0 +1,23 @@
using System.Globalization;
using System.Windows.Data;
using System.Windows.Media;
namespace ytLive.Helpers;
public class DroppedFramesToBrushConverter : IValueConverter
{
private static readonly SolidColorBrush Attention = new(Color.FromRgb(0xff, 0x8c, 0x3a));
private static readonly SolidColorBrush Clean = new(Colors.White);
static DroppedFramesToBrushConverter()
{
Attention.Freeze();
Clean.Freeze();
}
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
=> value is int dropped && dropped > 0 ? Attention : Clean;
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
=> throw new NotSupportedException();
}
+20
View File
@@ -0,0 +1,20 @@
using System.Globalization;
using System.Windows.Data;
namespace ytLive.Helpers;
public class DroppedPercentConverter : IMultiValueConverter
{
public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture)
{
var dropped = values.Length > 0 && values[0] is int d ? d : 0;
var fps = values.Length > 1 && values[1] is double f ? f : 0;
var duration = values.Length > 2 && values[2] is TimeSpan t ? t : TimeSpan.Zero;
var total = fps * duration.TotalSeconds;
var percent = total > 0 ? dropped / total * 100 : 0;
return $"{dropped} ({percent.ToString("0.0", CultureInfo.InvariantCulture)}%)";
}
public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture)
=> throw new NotSupportedException();
}
+47
View File
@@ -0,0 +1,47 @@
using System;
namespace ytLive.Helpers;
/// <summary>
/// Paces a frame stream to wall-clock deadlines instead of sleeping a fixed interval after each
/// frame. The difference matters: a fixed sleep ADDS the frame's own decode/copy cost, so a 30 fps
/// clip with 20 ms of work per frame plays at 1/(33+20) ≈ 19 fps — which is what a 1080p video layer
/// did (measured 20.9 fps on a 30 fps clip, 2026-11-05). Here the target is <c>start + n × frame</c>,
/// so work is absorbed by the interval when it fits, and a stream that has fallen behind resyncs
/// instead of bursting to catch up.
/// </summary>
public sealed class FramePacer
{
private long _nextTicks = long.MinValue;
/// <summary>How long to wait after emitting the frame at <paramref name="now"/>. Returns zero
/// when the frame is already due (or late).</summary>
public TimeSpan DelayFor(TimeSpan frameDuration, TimeSpan now)
{
if (frameDuration <= TimeSpan.Zero) return TimeSpan.Zero;
if (_nextTicks == long.MinValue)
{
_nextTicks = now.Ticks + frameDuration.Ticks;
return frameDuration;
}
var remaining = TimeSpan.FromTicks(_nextTicks - now.Ticks);
_nextTicks += frameDuration.Ticks;
if (remaining < TimeSpan.Zero)
{
// Late (the frame's work exceeded its interval): resync to now rather than emitting a
// burst of frames back-to-back, which the viewer would see as a speed-up.
_nextTicks = now.Ticks + frameDuration.Ticks;
return TimeSpan.Zero;
}
// Never wait longer than one interval, even if the caller's clock stood still (a fake delay
// in a test, or a suspended machine): a longer sleep would be a pause, not pacing.
return remaining > frameDuration ? frameDuration : remaining;
}
/// <summary>Restarts the schedule (a new playback pass begins at the top of the file).</summary>
public void Reset() => _nextTicks = long.MinValue;
}
+44
View File
@@ -0,0 +1,44 @@
using System;
namespace ytLive.Helpers;
/// <summary>The fader scale for the audio dock: the model stores LINEAR gain (0..1), the dock
/// shows and edits DECIBELS, because "−6 dB" means something to a person and "0.50" does not.
///
/// <para>Range: −∞ (silence) to +6 dB of headroom above unity. 0 dB is unity gain, which is what
/// the old 1.0 meant — so a persisted 0.8 comes back as ≈ −1.9 dB and nothing about the stored
/// data changes.</para>
///
/// <para>Creator context (2026-10-30): nearly deaf on the right, so the fader number is read, not
/// heard. The scale therefore has to be the true one, not a cosmetic curve.</para></summary>
public static class GainDb
{
/// <summary>Silence floor. Anything at or below this reads as −∞ and writes as 0 gain.</summary>
public const double FloorDb = -60;
public const double MaxDb = 6;
/// <summary>Linear gain → dB, clamped to the fader's range. Zero becomes <see cref="FloorDb"/>.</summary>
public static double ToDb(double gain)
{
if (gain <= 0) return FloorDb;
var db = 20 * Math.Log10(gain);
return Math.Clamp(db, FloorDb, MaxDb);
}
/// <summary>dB → linear gain. At the floor the result is exactly 0 (true silence), so the
/// bottom of the fader cannot leave a residue of noise.</summary>
public static double FromDb(double db)
{
if (db <= FloorDb) return 0;
return Math.Pow(10, Math.Clamp(db, FloorDb, MaxDb) / 20);
}
/// <summary>"−∞", "−6.0", "+1.5" — the label beside a fader.</summary>
public static string Label(double db) =>
db <= FloorDb ? "−∞" : (db > 0 ? "+" : string.Empty) + db.ToString("0.0");
/// <summary>Mono fold: the accessible downmix. Averages the pair so hard-panned content is
/// audible on both sides at half amplitude instead of absent from one.</summary>
public static float MonoFold(float left, float right) => (left + right) * 0.5f;
}
+12
View File
@@ -69,6 +69,18 @@ public static class InstanceProfile
public static string? WebViewDataFolder => null;
#endif
/// <summary>The audio pipe name for this process. The pipe server allows exactly ONE instance, so a
/// shared name means the second app on the machine can never go live — it throws "All pipe instances
/// are busy" (2026-11-06: Release recording, Debug trying to go live). Release keeps the plain name;
/// a DEBUG build ALWAYS appends a suffix — its claimed id, or the process id when it has none — so a
/// debug run launched straight from the IDE can never collide with the Release build beside it.</summary>
public static string AudioPipeName(string baseName)
#if DEBUG
=> Id is { } id ? $"{baseName}_{id}" : $"{baseName}_pid{Environment.ProcessId}";
#else
=> baseName;
#endif
/// <summary>The one real user profile: <c>%APPDATA%\ytLlive</c>. Note the historical
/// double-L — do not "fix" it, users already have their layouts there.</summary>
public static string DefaultRoot => Path.Combine(
+25
View File
@@ -0,0 +1,25 @@
using System;
using System.Globalization;
using System.Windows.Data;
namespace ytLive.Helpers;
/// <summary>
/// True when a log entry's sequence falls inside the range a clicked alert points at. Two numbers
/// rather than a membership collection: the log list is virtualised, and rows that are already
/// realised do not re-evaluate when a collection changes — but they do when a bound number changes.
/// </summary>
public sealed class LogInRangeConverter : IMultiValueConverter
{
public object Convert(object[] values, Type targetType, object? parameter, CultureInfo culture)
{
if (values.Length < 3) return false;
if (values[0] is not long sequence) return false;
if (values[1] is not long from || values[2] is not long to) return false;
if (from == 0 && to == 0) return false;
return sequence >= from && sequence <= to;
}
public object[] ConvertBack(object value, Type[] targetTypes, object? parameter, CultureInfo culture)
=> throw new NotSupportedException();
}
+68
View File
@@ -0,0 +1,68 @@
using System;
namespace ytLive.Helpers;
/// <summary>Which colour band a level falls in (DESIGN.md meter thresholds).</summary>
public enum MeterBand
{
Nominal,
Caution,
Peak,
}
/// <summary>The mathematics of the dock's segment meter, kept out of the control so it can be
/// tested: a segment ladder is only honest if the band it paints is the level it measured.
///
/// <para>Bands follow DESIGN.md: nominal to −12 dB, caution to −3 dB, peak above. The display
/// fraction spreads −60…0 dBFS across 0…1 — the same scale <c>AudioLevelMeter.ToDisplay</c> uses,
/// so the ladder and the old bars cannot disagree.</para></summary>
public static class MeterScale
{
public const float FloorDb = -60f;
public const float CautionDb = -12f;
public const float PeakDb = -3f;
/// <summary>dBFS → 0…1 across the whole ladder.</summary>
public static float ToFraction(float dbfs)
=> Math.Clamp(1f + Math.Clamp(dbfs, FloorDb, 0f) / 60f, 0f, 1f);
/// <summary>The band boundaries are INCLUSIVE at the bottom edge (a meter reading exactly
/// −12.0 dB is cautious, exactly −3.0 is peaking), matching the ranges as DESIGN.md states
/// them: "−12 … −3" and "−3 … 0+".</summary>
public static MeterBand BandFor(float dbfs)
=> dbfs >= PeakDb ? MeterBand.Peak
: dbfs >= CautionDb ? MeterBand.Caution
: MeterBand.Nominal;
/// <summary>The master ladder when no session is running.
///
/// <para>`AudioMixer.MasterLevel` measures the buffer the encoder receives, and that buffer only
/// exists while the mix is being pulled (streaming or recording). Idle, the master would read
/// zero while the source ladders move — so the monitor level is computed from the same measured
/// sources and the same gains the mix applies. It is an estimate of the mix, not a substitute
/// for it: once a session starts, the real measured value takes over.</para></summary>
public static float MonitorFraction(float micLevel, double micGain, float gameLevel, double gameGain)
{
var linear = Math.Max(0f, (float)(micLevel * micGain) + (float)(gameLevel * gameGain));
if (linear <= 0.001f) return 0f;
return ToFraction(20f * MathF.Log10(linear));
}
/// <summary>Ballistics for a level: reach a new peak quickly, fall back gently. Raw per-block
/// peaks sampled by a UI timer look like a stutter; this is what makes the mic feel fluid, and
/// TRAX gets the same curve so the two ladders move alike.</summary>
public static float NextLevel(float current, float incoming, float attack = 0.6f, float decay = 0.12f)
{
var alpha = incoming > current ? attack : decay;
return current + (incoming - current) * alpha;
}
/// <summary>Peak hold: jumps up instantly to a new maximum and falls back at
/// <paramref name="decayPerSecond"/> — a moment-to-moment bar hides clipping, which is the one
/// audio fault that cannot be fixed afterwards.</summary>
public static float NextPeak(float held, float incoming, float seconds, float decayPerSecond = 0.35f)
{
if (incoming >= held) return incoming;
return Math.Max(0f, held - decayPerSecond * Math.Max(0f, seconds));
}
}
+40
View File
@@ -0,0 +1,40 @@
using System.Windows;
using System.Windows.Media;
namespace ytLive.Helpers;
/// <summary>Resolves a design token for code that cannot bind — view-model state colours (the
/// top bar, the health banner, the meters, toast tints). The values live once, in
/// <c>Themes/Tokens.xaml</c>; this reads them from the application resources.
///
/// <para>Why not a Brush field per colour: a second list in C# would drift from the theme. Why not
/// OutputPalette: that one exists for the frame-pump thread, which must not touch resources; these
/// properties are read by bindings on the UI thread.</para>
///
/// <para>Without an Application (unit tests, design-time) a neutral grey comes back: visibly wrong
/// rather than silently plausible, and never a crash.</para></summary>
internal static class ThemeBrush
{
private static readonly SolidColorBrush Missing = Frozen(Color.FromRgb(0x80, 0x80, 0x80));
/// <summary>The brush for a token key, e.g. <c>ThemeBrush.Get("Brush.Primary")</c>.</summary>
public static SolidColorBrush Get(string key)
=> Application.Current?.TryFindResource(key) as SolidColorBrush ?? Missing;
/// <summary>The token's colour as a browser-style hex string, for APIs that take one
/// (Notification.Wpf's <c>NotificationColor.FromHex</c>, for instance).</summary>
public static string Hex(string key)
{
var c = Get(key).Color;
return c.A == 255
? $"#{c.R:x2}{c.G:x2}{c.B:x2}"
: $"#{c.A:x2}{c.R:x2}{c.G:x2}{c.B:x2}";
}
private static SolidColorBrush Frozen(Color colour)
{
var brush = new SolidColorBrush(colour);
brush.Freeze();
return brush;
}
}
+1 -1
View File
@@ -6,7 +6,7 @@ Cross-cutting utilities. See [`schema.md`](../schema.md) for the memory-map conv
|------|---------|
| `ViewModelBase.cs` | `INotifyPropertyChanged` + `SetProperty<T>()` — base for all ViewModels |
| `RelayCommand.cs` | `ICommand` implementation for all button actions |
| `AppLog.cs` | File logger to `%APPDATA%\ytLlive\startup.log`; startup checkpoints + unhandled-exception capture (the crash this map is named for). Size-capped: rolls to `startup.1.log` at 5 MB (TASK 55) |
| `AppLog.cs` | File logger to `%APPDATA%\ytLlive\startup.log`; startup checkpoints + unhandled-exception capture. Size-capped: rolls to `startup.1.log` at 5 MB (TASK 55). Also keeps the newest 500 formatted entries in a process-local buffer for the Logs tab, with independent message type and explicit Class 1/2 severity metadata; it never loads prior-launch file contents |
| `LogRotation.cs` | The size-capped-rotation policy as a pure function over a path: roll `startup.log` → `startup.1.log` when it passes the cap, keeping N rolled copies. Separate from `AppLog` so the logger's 156-call-site surface never changes and the rule is testable alone (TASK 55) |
| `FocusPreservingListBox.cs` | `ListBox` subclass that keeps selection/focus coherent when the selected item is deleted; `IsSelectable` predicate skips hidden scenes; drag-reorder guard |
| `OAuthCredentials.cs` | Baked-in Google OAuth client ID/secret (desktop app; loopback callback) |
+10 -10
View File
@@ -1,10 +1,10 @@
<Window x:Class="ytLive.HotkeyConfigDialog"
<Window x:Class="ytLive.HotkeyConfigDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Global Hotkey Bindings" Height="520" Width="480"
Icon="/Assets/llamacasty-icon.png"
WindowStartupLocation="CenterOwner" ResizeMode="NoResize"
ShowInTaskbar="False" Background="#1a1a2e">
ShowInTaskbar="False" Background="{StaticResource Brush.Background}">
<Window.Resources>
<BooleanToVisibilityConverter x:Key="BoolToVis"/>
@@ -20,7 +20,7 @@
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Text="Global Hotkey Bindings" FontSize="18" FontWeight="Bold"
Foreground="#e0e0e0" Margin="0,0,0,12"/>
Foreground="{StaticResource Brush.OnSurface}" Margin="0,0,0,12"/>
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto">
<ItemsControl ItemsSource="{Binding Rows}">
@@ -34,7 +34,7 @@
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" Text="{Binding ActionName}"
Foreground="#e0e0e0" FontSize="13"
Foreground="{StaticResource Brush.OnSurface}" FontSize="13"
VerticalAlignment="Center" Margin="0,0,12,0"/>
<Button Grid.Column="1" Click="ChordButton_Click"
@@ -42,8 +42,8 @@
HorizontalAlignment="Left" MinWidth="140"
Padding="10,5" FontSize="13"
VerticalAlignment="Center">
<Border Background="#16213e" CornerRadius="4" Padding="10,5">
<TextBlock Text="{Binding DisplayText}" Foreground="#e0e0e0"
<Border Background="{StaticResource Brush.SurfaceContainer}" CornerRadius="4" Padding="10,5">
<TextBlock Text="{Binding DisplayText}" Foreground="{StaticResource Brush.OnSurface}"
FontSize="13"/>
</Border>
</Button>
@@ -51,7 +51,7 @@
<Button Grid.Column="2" Content="✕" Click="UnbindButton_Click"
Tag="{Binding}" Cursor="Hand"
ToolTip="Remove binding"
FontSize="12" Foreground="#8a93a8"
FontSize="12" Foreground="{StaticResource Brush.OnSurfaceVariant}"
Background="Transparent" BorderThickness="0"
Padding="6,4" VerticalAlignment="Center">
<Button.Style>
@@ -71,8 +71,8 @@
</ScrollViewer>
<!-- Conflict warning: hidden when no message -->
<Border Grid.Row="2" Background="#3d1a1a" CornerRadius="4" Padding="10,8"
Margin="0,8,0,0" BorderBrush="#e94560" BorderThickness="1">
<Border Grid.Row="2" Background="{StaticResource Brush.ErrorContainer}" CornerRadius="4" Padding="10,8"
Margin="0,8,0,0" BorderBrush="{StaticResource Brush.Error}" BorderThickness="1">
<Border.Style>
<Style TargetType="Border">
<Style.Triggers>
@@ -82,7 +82,7 @@
</Style.Triggers>
</Style>
</Border.Style>
<TextBlock Text="{Binding ConflictMessage}" Foreground="#e94560"
<TextBlock Text="{Binding ConflictMessage}" Foreground="{StaticResource Brush.Error}"
FontSize="12" TextWrapping="Wrap"/>
</Border>
+5 -5
View File
@@ -1,10 +1,10 @@
<Window x:Class="ytLive.KeepBroadcastDialog"
<Window x:Class="ytLive.KeepBroadcastDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Keep this broadcast?" Height="240" Width="460"
Icon="/ytLive;component/Assets/llamacasty-icon.png"
Icon="/LlamaCasty;component/Assets/llamacasty-icon.png"
WindowStartupLocation="CenterOwner" ResizeMode="NoResize"
ShowInTaskbar="False" Background="#1a1a2e">
ShowInTaskbar="False" Background="{StaticResource Brush.Background}">
<Grid Margin="24">
<Grid.RowDefinitions>
@@ -14,9 +14,9 @@
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Text="Keep this broadcast?" FontSize="18" FontWeight="Bold"
Foreground="#e0e0e0" Margin="0,0,0,12"/>
Foreground="{StaticResource Brush.OnSurface}" Margin="0,0,0,12"/>
<TextBlock Grid.Row="1" x:Name="MessageText" TextWrapping="Wrap" Foreground="#a0a0b0" FontSize="13"
<TextBlock Grid.Row="1" x:Name="MessageText" TextWrapping="Wrap" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="13"
Margin="0,0,0,16"/>
<StackPanel Grid.Row="2" Orientation="Horizontal" HorizontalAlignment="Right">
+2
View File
@@ -11,6 +11,8 @@ public partial class KeepBroadcastDialog : Window
public KeepBroadcastDialog(string title)
{
InitializeComponent();
if (Application.Current?.MainWindow is { } owner && !ReferenceEquals(owner, this))
Owner = owner;
MessageText.Text = string.IsNullOrWhiteSpace(title)
? "Your private broadcast is on YouTube as a private video. Keep it, or delete it?"
: $"\u201c{title}\u201d is on YouTube as a private video. Keep it, or delete it?";
+68 -674
View File
@@ -1,674 +1,68 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.
LlamaCasty — Proprietary Software License (EULA)
Copyright (C) 2026 Gramps. All rights reserved.
This is a commercial licence for the LlamaCasty desktop application ("the
Software"). It replaces the GNU General Public License that earlier development
builds carried; those earlier builds remain under the licence they shipped
with, and nothing here revokes rights already granted for them.
1. LICENCE GRANT
Subject to payment of the applicable fee and to this agreement, you are
granted a personal, non-exclusive, non-transferable licence to install and
use the Software on machines you own or control, for your own streaming
and recording. A lifetime licence is granted for the life of the product;
a subscription licence lasts while the subscription is paid.
2. WHAT YOU MAY DO
- Use the Software to produce and monetise your own streamed or recorded
content, including commercially, without any further payment or royalty.
- Install it on more than one of your own machines.
- Make copies for backup.
3. WHAT YOU MAY NOT DO
- Redistribute, sublicense, sell, rent, lease or lend the Software, or
make it available to others as a download, on a shared machine, or
bundled with your own product, without written permission.
- Reverse engineer, decompile or disassemble the Software, or remove,
bypass or alter any licence check, except to the extent that applicable
law expressly permits notwithstanding this restriction.
- Remove or obscure copyright, trademark or attribution notices.
- Use the LlamaCasty name or logo to endorse or promote a derived product,
or in a way that suggests affiliation you do not have.
4. THIRD-PARTY COMPONENTS
The Software includes third-party components licensed to you under their
own terms, listed in THIRD-PARTY-NOTICES.txt, which ships with the
Software and is shown in the application's About screen. Those terms
prevail over this licence for those components.
5. UPDATES, SUPPORT AND CHANGES
Updates are provided at the publisher's discretion. Support is provided on
a best-effort basis by email and issue tracker; no service level is
promised. The publisher may change or discontinue features, and may revise
this licence for future versions; the version you installed remains
governed by the licence it shipped with.
6. NO WARRANTY
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT.
7. LIMITATION OF LIABILITY
TO THE MAXIMUM EXTENT PERMITTED BY LAW, THE PUBLISHER IS NOT LIABLE FOR ANY
INDIRECT, INCIDENTAL, SPECIAL OR CONSEQUENTIAL DAMAGES, OR FOR LOST CONTENT,
LOST REVENUE OR LOST PROFITS, ARISING FROM USE OF THE SOFTWARE. THE
PUBLISHER'S TOTAL LIABILITY UNDER THIS AGREEMENT IS LIMITED TO THE AMOUNT
YOU PAID FOR THE LICENCE.
8. TERMINATION
This licence terminates if you breach it. On termination you must stop
using the Software and delete your copies, without refund except where the
law requires one.
9. GOVERNING LAW
This agreement is governed by the laws of the publisher's place of
residence, without regard to conflict-of-law rules. Nothing here removes
rights you have under mandatory consumer law in your own country.
Contact: https://llamacasty.com · llamachile.shop@gmail.com
+125 -12
View File
@@ -11,16 +11,26 @@
mc:Ignorable="d"
Title="{Binding WindowTitle}" Height="1040" Width="1920"
MinWidth="1366" MinHeight="768"
Icon="/ytLive;component/Assets/llamacasty-icon.png"
Background="#1a1a2e"
Icon="/LlamaCasty;component/Assets/llamacasty-icon.png"
Background="{StaticResource Brush.Background}"
FontFamily="{StaticResource FontFamily.Sans}"
WindowStartupLocation="Manual"
Closing="MainWindow_Closing"
Loaded="MainWindow_Loaded"
Activated="MainWindow_Activated"
Deactivated="MainWindow_Deactivated"
PreviewKeyDown="MainWindow_PreviewKeyDown"
PreviewMouseLeftButtonDown="Window_PreviewMouseLeftButtonDown"
ContextMenuOpening="Window_ContextMenuOpening">
<!-- Preview undo/redo (creator directive 2026-11-01). Window-level so the keys work wherever
focus sits; the commands themselves refuse unless EditingActive, because undo with the
preview showing program output would rewrite a scene nobody is looking at. -->
<Window.InputBindings>
<KeyBinding Gesture="Ctrl+Z" Command="{Binding UndoPreviewCommand}"/>
<KeyBinding Gesture="Ctrl+Shift+Z" Command="{Binding RedoPreviewCommand}"/>
</Window.InputBindings>
<Window.DataContext>
<vm:MainViewModel/>
</Window.DataContext>
@@ -31,12 +41,14 @@
<Window.Resources>
<ResourceDictionary>
<Helpers:DroppedFramesToBrushConverter x:Key="DroppedToBrush"/>
<Helpers:DroppedPercentConverter x:Key="DroppedPercent"/>
<!-- Red dot shown in the taskbar icon while live -->
<DrawingImage x:Key="LiveOverlay">
<DrawingImage.Drawing>
<DrawingGroup>
<GeometryDrawing Brush="#e94560">
<GeometryDrawing Brush="{StaticResource Brush.Tertiary}">
<GeometryDrawing.Geometry>
<EllipseGeometry Center="8,8" RadiusX="8" RadiusY="8"/>
</GeometryDrawing.Geometry>
@@ -72,34 +84,135 @@
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<!-- the dock lives INSIDE the work area so its bottom edge lines up with the
layers panel (creator, 2026-10-30) -->
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="1*" MinWidth="220" MaxWidth="360"/>
<ColumnDefinition Width="360"/>
<ColumnDefinition Width="3*"/>
</Grid.ColumnDefinitions>
<!-- STREAM READOUTS (top-left cell, above the layers) + resolution -->
<Border Grid.Row="0" Grid.Column="0" Background="{StaticResource Brush.SurfaceContainerLowest}" CornerRadius="4" Padding="10,8" Margin="0,0,8,0">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Row="0" Grid.Column="0" Text="Bitrate" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="12" Margin="0,0,12,2"/>
<TextBlock Grid.Row="0" Grid.Column="1" Foreground="{StaticResource Brush.OnSurface}" FontSize="13" FontFamily="{StaticResource FontFamily.Mono}" HorizontalAlignment="Right" Margin="0,0,0,2">
<Run Text="{Binding CurrentHealth.CurrentBitrate, Mode=OneWay, StringFormat={}{0:0.0}}"/><Run Text=" Mbps"/>
</TextBlock>
<TextBlock Grid.Row="1" Grid.Column="0" Text="FPS" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="12" Margin="0,0,12,2"/>
<TextBlock Grid.Row="1" Grid.Column="1" Text="{Binding CurrentHealth.FPS, Mode=OneWay, StringFormat={}{0:0}}"
Foreground="{StaticResource Brush.OnSurface}" FontSize="13" FontFamily="{StaticResource FontFamily.Mono}" HorizontalAlignment="Right" Margin="0,0,0,2"/>
<TextBlock Grid.Row="2" Grid.Column="0" Text="Dropped" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="12" Margin="0,0,12,2"/>
<TextBlock Grid.Row="2" Grid.Column="1" FontSize="13" FontFamily="{StaticResource FontFamily.Mono}" HorizontalAlignment="Right" Margin="0,0,0,2"
Foreground="{Binding CurrentHealth.DroppedFrames, Converter={StaticResource DroppedToBrush}}">
<TextBlock.Text>
<MultiBinding Converter="{StaticResource DroppedPercent}">
<Binding Path="CurrentHealth.DroppedFrames" Mode="OneWay"/>
<Binding Path="CurrentHealth.FPS" Mode="OneWay"/>
<Binding Path="CurrentHealth.StreamDuration" Mode="OneWay"/>
</MultiBinding>
</TextBlock.Text>
</TextBlock>
<TextBlock Grid.Row="3" Grid.Column="0" Text="Live:" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="12" Margin="0,0,12,2"/>
<TextBlock Grid.Row="3" Grid.Column="1" Text="{Binding CurrentHealth.StreamDuration, Mode=OneWay, StringFormat={}{0:hh\:mm\:ss}}"
Foreground="{StaticResource Brush.Attention}" FontSize="13" FontFamily="{StaticResource FontFamily.Mono}" FontWeight="SemiBold" HorizontalAlignment="Right" Margin="0,0,0,2"/>
<TextBlock Grid.Row="4" Grid.ColumnSpan="2" Text="{Binding CurrentHealth.HealthMessage}"
Foreground="{StaticResource Brush.Attention}" FontSize="12" TextWrapping="Wrap" Margin="0,0,0,6"/>
<StackPanel Grid.Row="5" Grid.ColumnSpan="2" VerticalAlignment="Bottom">
<ComboBox x:Name="QualityPicker" Style="{StaticResource YtComboBox}"
ItemsSource="{Binding QualityOptions}"
SelectedItem="{Binding SelectedQuality}"
ItemTemplate="{StaticResource YtQualityComboBoxItemTemplate}"
ItemContainerStyle="{StaticResource YtQualityComboBoxItem}"
ToolTip="{Binding ResolutionHelp}"
IsEnabled="{Binding CanChangeQuality}"/>
<!-- The creator's own rate: the tier sets the geometry, this sets the bits.
Guidance lives in the tooltip (YouTube's numbers, from RateLadder). -->
<Grid Margin="0,6,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBox x:Name="RatedMbpsBox" Grid.Column="0" Style="{StaticResource YtTextBoxCompact}"
Text="{Binding RatedMbps, UpdateSourceTrigger=LostFocus, StringFormat={}{0:0.#}}"
ToolTip="{Binding RateHint}"
IsEnabled="{Binding CanChangeQuality}"/>
<TextBlock Grid.Column="1" Text="Mbps" Margin="6,0,0,0" VerticalAlignment="Center"
Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="12"/>
</Grid>
<!-- Between sessions only, never mid-show, never applied without a click: what
the last few sessions actually held (Models/RateHistory). -->
<Border Margin="0,6,0,0" Padding="8,6" CornerRadius="4"
Background="{StaticResource Brush.SurfaceContainerHigh}">
<Border.Style>
<Style TargetType="Border">
<Setter Property="Visibility" Value="Collapsed"/>
<Style.Triggers>
<DataTrigger Binding="{Binding HasRateProposal}" Value="True">
<Setter Property="Visibility" Value="Visible"/>
</DataTrigger>
</Style.Triggers>
</Style>
</Border.Style>
<StackPanel>
<TextBlock Text="{Binding RateProposalText}" TextWrapping="Wrap" FontSize="11"
Foreground="{StaticResource Brush.OnSurface}"/>
<StackPanel Orientation="Horizontal" Margin="0,6,0,0">
<Button Content="Use it" Command="{Binding AcceptRateProposalCommand}"
Style="{StaticResource YtButton}"/>
<Button Content="Keep mine" Command="{Binding DismissRateProposalCommand}"
Style="{StaticResource YtButtonSecondary}" Margin="6,0,0,0"/>
</StackPanel>
</StackPanel>
</Border>
</StackPanel>
</Grid>
</Border>
<!-- ═══ THUMBNAIL STRIP (over the preview column; left column stays empty) ═══ -->
<controls:SceneThumbnailStrip Grid.Row="0" Grid.Column="1" Margin="8,0"/>
<controls:SceneThumbnailStrip x:Name="SceneStrip" Grid.Row="0" Grid.Column="1" Margin="8,0" Height="154"/>
<!-- LEFT PANEL: one panel, two states.
Offline (edit mode): LAYERS + PROPERTIES
Live/Recording: YouTube Live Chat -->
<controls:LeftPanel x:Name="LeftPanel" Grid.Row="1" Grid.Column="0"/>
<controls:LeftPanel x:Name="LeftPanel" Grid.Row="1" Grid.Column="0" Grid.RowSpan="2"/>
<!-- CENTER: Preview Area -->
<controls:PreviewPane x:Name="PreviewPane" Grid.Row="1" Grid.Column="1"/>
<!-- The dock starts where the layers panel ends and shares its bottom edge. -->
<controls:AudioDock x:Name="AudioDock" Grid.Row="2" Grid.Column="1"/>
</Grid>
<controls:BottomBar Grid.Row="3"/>
<!-- ═══ OVERLAY: Settings / Bug / Feature / About ═══ -->
<controls:OverlayHost Grid.RowSpan="3"/>
<!-- ═══ TOASTS: non-blocking notifications (bottom-right, above footer) ═══ -->
<!-- ═══ OVERLAY: Settings / Bug / Feature / About ═══ -->
<controls:OverlayHost x:Name="AboutOverlay" Grid.RowSpan="3"/>
<!-- ═══ TOASTS: non-blocking notifications (user-positioned, bottom-right by default) ═══ -->
<notifications:NotificationArea x:Name="ToastArea"
Grid.RowSpan="4"
Position="BottomRight" MaxItems="4"
Margin="0,0,12,96"/>
Position="{Binding ToastPosition}" MaxItems="4"
Margin="{Binding ToastAreaMargin}"/>
</Grid>
</Window>
+66 -1
View File
@@ -20,6 +20,8 @@ public partial class MainWindow : Window
{
AppLog.Write("MainWindow ctor: before InitializeComponent");
InitializeComponent();
SizeChanged += (_, e) => Helpers.AppLog.Write($"WIDTHS window={e.NewSize.Width:F0} "
+ $"layers={LeftPanel.ActualWidth:F0} strip={SceneStrip.ActualWidth:F0} preview={PreviewPane.ActualWidth:F0}");
AppLog.Write("MainWindow ctor: after InitializeComponent");
_viewModel = (MainViewModel)DataContext;
_viewModel.PropertyChanged += OnViewModelPropertyChanged;
@@ -145,8 +147,28 @@ public partial class MainWindow : Window
_topBar.RefreshAvatar();
}
private void MainWindow_Closing(object? sender, CancelEventArgs e)
private bool _shutdownComplete;
private async void MainWindow_Closing(object? sender, CancelEventArgs e)
{
if (_shutdownComplete)
{
SaveWindowGeometry();
_viewModel.Shutdown();
return;
}
// A session must END before anything is disposed (2026-11-12: closing mid-recording used to
// pull the pump out from under itself). Hold the window open, stop properly, then close again.
if (_viewModel.IsLive || _viewModel.IsRecording)
{
e.Cancel = true;
await _viewModel.StopBeforeShutdownAsync();
_shutdownComplete = true;
Close();
return;
}
SaveWindowGeometry();
_viewModel.Shutdown();
}
@@ -160,6 +182,45 @@ public partial class MainWindow : Window
{
}
/// <summary>
/// Ctrl released/late-pressed while the magnifier is open → the box closes (creator ruling
/// 2026-11-01). This is the "let go and it's gone" half of the zoom gesture; it also means one
/// hand can dismiss without reaching for the mouse. Creating the box still takes Ctrl+right
/// (edit) or a left click (preview), so holding Ctrl to *make* it never cancels it: the key is
/// already down before the box exists.
/// </summary>
private void MainWindow_PreviewKeyDown(object sender, System.Windows.Input.KeyEventArgs e)
{
// ⛔ e.IsRepeat matters: Windows auto-repeats a held modifier. Creation holds Ctrl (the
// chord is Ctrl+right-click), so without this guard the repeat tick ~500 ms later saw an
// open box and closed it — the box appeared and vanished as it was created.
if (e.IsRepeat) return;
if (e.Key is System.Windows.Input.Key.LeftCtrl or System.Windows.Input.Key.RightCtrl
&& _viewModel.IsZoomActive)
{
_viewModel.DismissZoomIfActive();
}
// Esc leaves a sub-menu (creator ruling 2026-11-12): the Output world is a sub-menu, and its
// back chevron is gone in favour of this. The bar says so under the switches.
// Esc backs out of whichever slot is mid-flow (creator, 2026-11-13): the slots cancel in place
// rather than moving the bar around.
if (e.Key == System.Windows.Input.Key.Escape)
{
if (_viewModel.IsYouTubeChoosingPrivacy || _viewModel.IsYouTubeArmed)
{
_viewModel.CancelYouTubeCommand.Execute(null);
e.Handled = true;
}
else if (_viewModel.IsRecordArmed)
{
_viewModel.CancelRecordCommand.Execute(null);
e.Handled = true;
}
}
}
private void UpdateTaskbarOverlay()
{
TaskbarInfo.Overlay = _viewModel.IsLive
@@ -178,6 +239,10 @@ public partial class MainWindow : Window
if (form.IsDrawerOpen) form.CloseDrawerCommand.Execute(null);
if (ypp.IsDrawerOpen) ypp.CloseDrawerCommand.Execute(null);
if (test.IsDrawerOpen) test.CloseDrawerCommand.Execute(null);
// The Settings (gear) and Socials drawers live in the same host but were left out of this
// guard, so clicking anywhere else left them open (reported 2026-11-05).
_viewModel.CloseSettingsDrawerIfOpen();
_viewModel.CloseSocialsDrawer();
}
if (_previewPane.IsClickInsidePreview(original)) return;
if (_leftPanel.IsClickInside(original)) return;
+10 -10
View File
@@ -1,10 +1,10 @@
<Window x:Class="ytLive.MicPickerDialog"
<Window x:Class="ytLive.MicPickerDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Select Microphone" Height="340" Width="460"
Icon="/Assets/llamacasty-icon.png"
WindowStartupLocation="CenterOwner" ResizeMode="NoResize"
ShowInTaskbar="False" Background="#1a1a2e">
ShowInTaskbar="False" Background="{StaticResource Brush.Background}">
<Window.Resources>
<ResourceDictionary>
@@ -21,10 +21,10 @@
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="#1c2a52"/>
<Setter TargetName="Bd" Property="Background" Value="{StaticResource Brush.SurfaceContainerHigh}"/>
</Trigger>
<Trigger Property="IsSelected" Value="True">
<Setter TargetName="Bd" Property="Background" Value="#2a2450"/>
<Setter TargetName="Bd" Property="Background" Value="{StaticResource Brush.SurfaceContainerHigh}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
@@ -43,10 +43,10 @@
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Text="Select Microphone" FontSize="18" FontWeight="Bold"
Foreground="#e0e0e0" Margin="0,0,0,14"/>
Foreground="{StaticResource Brush.OnSurface}" Margin="0,0,0,14"/>
<TextBlock Grid.Row="1" Text="Pick the microphone to use as your voice source."
Foreground="#a0a0b0" FontSize="13" TextWrapping="Wrap" Margin="0,0,0,12"/>
Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="13" TextWrapping="Wrap" Margin="0,0,0,12"/>
<Grid Grid.Row="2">
<ListBox Background="Transparent" BorderThickness="0"
@@ -58,7 +58,7 @@
<DataTemplate>
<StackPanel Orientation="Horizontal">
<TextBlock Text="🎙️" FontSize="15" VerticalAlignment="Center" Margin="0,0,10,0"/>
<TextBlock Text="{Binding Name}" Foreground="#e0e0e0" FontSize="13"
<TextBlock Text="{Binding Name}" Foreground="{StaticResource Brush.OnSurface}" FontSize="13"
VerticalAlignment="Center" TextTrimming="CharacterEllipsis"/>
</StackPanel>
</DataTemplate>
@@ -67,15 +67,15 @@
<StackPanel VerticalAlignment="Center" HorizontalAlignment="Center"
Visibility="{Binding IsLoading, Converter={StaticResource BoolToVis}}">
<TextBlock Text="Searching for microphones…" Foreground="#a0a0b0" FontSize="13"/>
<TextBlock Text="Searching for microphones…" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="13"/>
</StackPanel>
<StackPanel VerticalAlignment="Center"
Visibility="{Binding NoMicrophones, Converter={StaticResource BoolToVis}}">
<TextBlock Text="No microphones found" Foreground="#e0e0e0" FontSize="15"
<TextBlock Text="No microphones found" Foreground="{StaticResource Brush.OnSurface}" FontSize="15"
FontWeight="SemiBold" HorizontalAlignment="Center"/>
<TextBlock Text="Check that a microphone is connected and that Windows microphone access is enabled."
Foreground="#a0a0b0" FontSize="13" TextWrapping="Wrap" MaxWidth="360"
Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="13" TextWrapping="Wrap" MaxWidth="360"
TextAlignment="Center" HorizontalAlignment="Center" Margin="0,8,0,0"/>
</StackPanel>
</Grid>
+36
View File
@@ -0,0 +1,36 @@
namespace ytLive.Models;
/// <summary>
/// One row in the left panel's alert stack (creator design, 2026-11-05). The stack sits above the
/// panel's content and pushes it down, so an alert is seen without hunting for a tab.
/// <para><b>Pinned</b> rows are standing state, not errors to clear: they show a pin instead of a
/// close control and are only removed when the state itself changes (the Steam licence indicator
/// becomes dismissible — and its dismissal permanent — once ownership is verified).</para>
/// <para>Clicking a row runs its action: the licence indicator opens About; an event alert opens the
/// Logs tab with that event's lines highlighted.</para>
/// <para>Rows are text only — a heading line above the message was redundant and pushed the stack past
/// its two-line budget (creator, 2026-11-05).</para>
/// </summary>
public sealed record AttentionAlert(
string Id,
string Text,
bool IsPinned,
AttentionAlertAction Action,
long LogFrom = 0,
long LogTo = 0)
{
/// <summary>True while a close control should be shown (a dismissible row).</summary>
public bool CanDismiss => !IsPinned;
/// <summary>True while the row should show the pin glyph instead of a close control.</summary>
public bool ShowsPin => IsPinned;
}
public enum AttentionAlertAction
{
/// <summary>Open the About panel (the licence indicator).</summary>
About,
/// <summary>Open the Logs tab and highlight this event's lines.</summary>
Logs,
}
+28
View File
@@ -0,0 +1,28 @@
namespace ytLive.Models;
/// <summary>How tall the audio dock should be for a given window height.
///
/// <para>Creator ruling (2026-10-30): the dock is 148px on a normal window and collapses to a 36px
/// telemetry strip when the window is short, because at 1366×768 a full dock leaves ~398px for the
/// preview and the composition starts letterboxing into a postage stamp.</para>
///
/// <para>2026-11-01: the master strip grew from 36 to 44 so its meter could be the biggest one on
/// screen (centred under the preview), so both constants moved with it — 156 = 112 bays + 44.</para>
///
/// <para>Pure, so the switch is testable without a window.</para></summary>
public static class AudioDockLayout
{
/// <summary>Below this window height the dock collapses. The app's minimum is 768; the
/// default window is 1040, so a normal launch shows the full dock.</summary>
public const double CollapseBelowHeight = 900;
/// <summary>148 = 112 of bays + the 36px master strip. The mockup wanted 168; the creator
/// read that as wasted space under a 154px scene strip, so the bays lost 20px of air.</summary>
public const double ExpandedHeight = 156;
public const double CollapsedHeight = 44;
public static bool IsCollapsed(double windowHeight) => windowHeight < CollapseBelowHeight;
public static double HeightFor(double windowHeight)
=> IsCollapsed(windowHeight) ? CollapsedHeight : ExpandedHeight;
}
+69
View File
@@ -0,0 +1,69 @@
namespace ytLive.Models;
/// <summary>
/// The one entitlement rule, as pure policy: what a build is allowed to emit.
///
/// <para><b>Frame rate is the paid boundary.</b> Every supported resolution at 30 fps is free;
/// 60 fps requires paid ownership, regardless of frame dimensions.</para>
///
/// <para><b>Fail closed.</b> Callers pass <c>pro: false</c> when entitlement is unknown,
/// unreadable or unresolved; the free profile is the default state, never the fallback
/// after an error.</para>
///
/// <para><b>This is not a feature gate.</b> Every feature, scene, overlay and the recording
/// itself stay available; only the emitted frame gets smaller. Never call it for anything
/// else — a cracked build buys almost nothing, which is the entire anti-piracy posture.</para>
/// </summary>
public static class OutputProfile
{
/// <summary>Free ceiling: 30 frames per second.</summary>
public const int FreeMaxFps = 30;
/// <summary>
/// Pre-release switch. <c>false</c> means the frame-rate boundary is not enforced yet, so every
/// shipped tier is selectable by any build — the app is still being tested at 60 fps in
/// <b>Release</b>, where <c>DevBuild.IsDevelopment</c> is false and the developer override cannot
/// help. Flip to <c>true</c> when the paid edition ships; the rule itself already exists and its
/// tests do not change.
///
/// <para><b>Why <c>static readonly</c>:</b> a <c>const</c> folds the consuming <c>||</c> at compile
/// time and leaves an unreachable operand, which is a warning. The JIT folds a readonly field
/// just as well, and the build stays at zero warnings.</para>
/// </summary>
public static readonly bool EntitlementEnforced = false;
/// <summary>The default free tier and fallback when no shipped tier is available.</summary>
public const string FreeFallbackName = "1080p30";
/// <summary>Is this tier within the free budget? Pro may use anything shipped.</summary>
public static bool IsAllowed(QualityOption tier, bool pro)
=> pro
|| tier.Fps <= FreeMaxFps;
/// <summary>
/// The tier a session will actually encode. A permitted selection passes through
/// untouched (so Pro and the free tier's own 720p30 keep their exact bitrate); a
/// disallowed one resolves to the best tier the budget allows — most pixels first, then
/// most frames, then the cheapest bitrate — because silently encoding something the user
/// did not pick is only acceptable if it is the closest legal thing to it.
/// </summary>
/// <param name="requested">What the dropdown says.</param>
/// <param name="shipped">Every tier the build offers.</param>
/// <param name="pro">Entitlement, already resolved. Unknown must be passed as false.</param>
public static QualityOption Resolve(
QualityOption requested, IEnumerable<QualityOption> shipped, bool pro)
{
if (IsAllowed(requested, pro)) return requested;
var candidate = shipped
.Where(tier => IsAllowed(tier, pro))
.OrderByDescending(tier => tier.Width * tier.Height)
.ThenByDescending(tier => tier.Fps)
.ThenBy(tier => tier.Bitrate)
.FirstOrDefault();
// No shipped tier fits the budget: build the floor explicitly rather than trusting
// the table. A build whose tiers were all redefined must still emit a legal frame.
return candidate ?? new QualityOption(FreeFallbackName, FreeMaxFps, 10.0, 1920, 1080);
}
}
+115
View File
@@ -0,0 +1,115 @@
namespace ytLive.Models;
/// <summary>
/// Undo/redo for what the creator does in the preview: move, resize, reorder, add, remove, hide.
///
/// <para><b>Snapshots hold element REFERENCES, not copies.</b> The model has a dozen subclasses
/// with dozens of properties each; a deep clone would be a large, fragile mirror of every one of
/// them, and it would silently miss the next field someone adds. Holding the live instances and
/// recording the values the preview can change (geometry, visibility, order, and which elements a
/// scene contains) means a new model property cannot break undo — it simply is not part of this
/// stack's contract.</para>
///
/// <para><b>Scope, stated plainly:</b> this covers layout edits. Property edits (colour, opacity,
/// border, font) are not captured yet — pressing Ctrl+Z after one of those restores the last
/// layout change instead. That is a limitation, not a bug to discover later.</para>
///
/// <para><b>Bounded:</b> <see cref="MaxDepth"/> entries; the oldest falls off. Snapshots are
/// references plus a handful of doubles, so the stack costs kilobytes, not megabytes.</para>
/// </summary>
public sealed class PreviewHistory
{
public const int MaxDepth = 50;
private readonly List<LayoutState> _undo = new();
private readonly List<LayoutState> _redo = new();
public bool CanUndo => _undo.Count > 0;
public bool CanRedo => _redo.Count > 0;
public int UndoDepth => _undo.Count;
public int RedoDepth => _redo.Count;
/// <summary>Record the layout as it is now, before an edit. Clears the redo branch — a new
/// edit after an undo is a new future.</summary>
public void Capture(IEnumerable<Scene> scenes)
{
_undo.Add(LayoutState.From(scenes));
_redo.Clear();
if (_undo.Count > MaxDepth) _undo.RemoveAt(0);
}
/// <summary>Restore the previous layout. The current layout is pushed onto the redo stack, so
/// redo is exact even after several undos.</summary>
public bool Undo(IEnumerable<Scene> scenes)
{
if (_undo.Count == 0) return false;
var sceneList = scenes as IList<Scene> ?? scenes.ToList();
_redo.Add(LayoutState.From(sceneList));
var previous = _undo[^1];
_undo.RemoveAt(_undo.Count - 1);
previous.RestoreTo(sceneList);
return true;
}
public bool Redo(IEnumerable<Scene> scenes)
{
if (_redo.Count == 0) return false;
var sceneList = scenes as IList<Scene> ?? scenes.ToList();
_undo.Add(LayoutState.From(sceneList));
var next = _redo[^1];
_redo.RemoveAt(_redo.Count - 1);
next.RestoreTo(sceneList);
return true;
}
/// <summary>Dropped when the layout is reloaded from disk — undoing across a reload would
/// resurrect a layout the creator never had.</summary>
public void Clear()
{
_undo.Clear();
_redo.Clear();
}
/// <summary>One scene's contents and geometry at a point in time.</summary>
private sealed record SceneState(Scene Scene, SceneElement[] Order, ElementState[] Elements);
private sealed record ElementState(SceneElement Element, double X, double Y, double Width, double Height, bool IsVisible);
private sealed record LayoutState(SceneState[] Scenes)
{
public static LayoutState From(IEnumerable<Scene> scenes) => new(
scenes.Select(scene => new SceneState(
scene,
scene.Elements.ToArray(),
scene.Elements.Select(element => new ElementState(
element, element.X, element.Y, element.Width, element.Height, element.IsVisible)).ToArray()))
.ToArray());
public void RestoreTo(IList<Scene> scenes)
{
foreach (var state in Scenes)
{
// A scene removed from the layout since the snapshot is not restored — the
// layout, not the history, owns which scenes exist.
if (!scenes.Contains(state.Scene)) continue;
foreach (var element in state.Elements)
{
element.Element.X = element.X;
element.Element.Y = element.Y;
element.Element.Width = element.Width;
element.Element.Height = element.Height;
element.Element.IsVisible = element.IsVisible;
}
var elements = state.Scene.Elements;
// Membership AND order: this is what makes add / remove / reorder undo-able.
if (elements.SequenceEqual(state.Order)) continue;
elements.Clear();
foreach (var element in state.Order) elements.Add(element);
}
}
}
}
+2
View File
@@ -9,4 +9,6 @@ public sealed record QualityOption(string Display, int Fps, double Bitrate, int
{
/// <summary>Self-documenting label, e.g. "1080p60 (8Mbps)".</summary>
public string Label => $"{Display} ({Bitrate:0.#}Mbps)";
public bool RequiresPro => !OutputProfile.IsAllowed(this, pro: false);
}
+92
View File
@@ -0,0 +1,92 @@
namespace ytLive.Models;
/// <summary>One finished broadcast, as evidence about a tier's rate.</summary>
/// <param name="Tier">The quality tier's display name, e.g. "1080p60".</param>
/// <param name="Encoder">The probed encoder, e.g. "h264_nvenc" — history against one encoder is not
/// evidence about another, so a GPU or driver change cannot import stale conclusions.</param>
/// <param name="RatedMbps">The rate the session started at (the creator's or the tier default).</param>
/// <param name="SettledMbps">The rate the session ended at after any step-down.</param>
/// <param name="Steps">How many quality steps the session spent. Zero means the rate held.</param>
public readonly record struct SessionRateResult(
string Tier, string Encoder, double RatedMbps, double SettledMbps, int Steps)
{
public bool Stepped => Steps > 0;
}
/// <summary>
/// What the app has learned about a connection, as pure policy — the only thing allowed to suggest a
/// different starting rate, and it never applies one itself.
///
/// <para><b>High-water mark</b> is the highest rate that <i>finished</i> a session with zero steps, not
/// the highest number ever seen: one easy session (a menu screen, a quiet game) must not set a target
/// the machine cannot hold, which would make every later session look like a regression.</para>
///
/// <para><b>Asymmetric on purpose.</b> Raising needs a 10% improvement across five clean sessions, so
/// fixing nothing is slow; lowering needs only repeated step-downs, because fixing damage should not
/// wait. A creator may always override either way — see <c>MainViewModel.RatedMbps</c>.</para>
/// </summary>
public static class RateHistory
{
/// <summary>Sessions considered. Below this there is no evidence, only noise.</summary>
public const int Window = 5;
/// <summary>How far above the current rate an upward proposal must sit to be worth asking.</summary>
public const double RaiseThreshold = 0.10;
/// <summary>Step-downs in the window that make a downward proposal worth asking.</summary>
public const int LowerAfterSteps = 3;
/// <summary>A suggested starting rate, and which way it moves.</summary>
public readonly record struct Proposal(double Mbps, bool Raise);
/// <summary>
/// The suggestion for one tier, or null when the evidence does not warrant one. <paramref name="encoder"/>
/// null considers every encoder; callers pass the current one so a hardware change starts clean.
/// </summary>
public static Proposal? Suggest(
IEnumerable<SessionRateResult> history, string tier, string? encoder, double currentMbps)
{
var window = history
.Where(entry => entry.Tier == tier && (encoder is null || entry.Encoder == encoder))
.TakeLast(Window)
.ToList();
if (window.Count < Window) return null;
if (window.Count(entry => entry.Stepped) >= LowerAfterSteps)
return new Proposal(Math.Round(Median(window.Select(entry => entry.SettledMbps)), 1), Raise: false);
var highWater = window.Max(entry => entry.Stepped ? 0 : entry.RatedMbps);
return highWater >= currentMbps * (1 + RaiseThreshold)
? new Proposal(Math.Round(highWater, 0), Raise: true)
: null;
}
private static double Median(IEnumerable<double> values)
{
var sorted = values.OrderBy(value => value).ToArray();
if (sorted.Length == 0) return 0;
var middle = sorted.Length / 2;
return sorted.Length % 2 == 1
? sorted[middle]
: (sorted[middle - 1] + sorted[middle]) / 2;
}
// ─── Storage form: one line per session, unit-separated fields ───
private const char Field = '\u001F';
public static string Format(SessionRateResult entry)
=> string.Join(Field, entry.Tier, entry.Encoder,
entry.RatedMbps.ToString("0.#"), entry.SettledMbps.ToString("0.#"), entry.Steps);
public static SessionRateResult? Parse(string? line)
{
if (string.IsNullOrWhiteSpace(line)) return null;
var parts = line.Split(Field);
if (parts.Length != 5 || !double.TryParse(parts[2], out var rated)
|| !double.TryParse(parts[3], out var settled) || !int.TryParse(parts[4], out var steps))
return null;
return new SessionRateResult(parts[0], parts[1], rated, settled, steps);
}
}
+31
View File
@@ -0,0 +1,31 @@
namespace ytLive.Models;
/// <summary>
/// YouTube's published H.264 ingestion numbers for a tier, in Mbps — the floor a step-down may walk
/// to and the recommended rate a creator aims at.
///
/// <para><b>Source:</b> support.google.com/youtube/answer/2853702 (checked 2026-11-12): 1080p@60
/// 6 min / 17 recommended, 1080p@30 5/14, 720p@30 and @60 3/8. The same page specifies CBR, which is
/// why these numbers are a target the encoder actually holds (<c>FfmpegArgs.AddRateControl</c>).</para>
///
/// <para><b>Vertical is not a separate row.</b> 1080×1920 has the same pixel count as 1920×1080, so a
/// portrait tier takes the band of its landscape pair — the frame rate alone decides which row.</para>
/// </summary>
public static class RateLadder
{
/// <summary>Minimum and recommended rates for a tier.</summary>
public readonly record struct Band(double Minimum, double Recommended);
public static Band For(int width, int height, int fps)
{
var is1080 = Math.Max(width, height) >= 1920;
return (is1080, fps > 30) switch
{
(true, true) => new Band(6.0, 17.0),
(true, false) => new Band(5.0, 14.0),
(_, _) => new Band(3.0, 8.0),
};
}
public static Band For(QualityOption tier) => For(tier.Width, tier.Height, tier.Fps);
}
+22
View File
@@ -72,6 +72,28 @@ public class Scene : INotifyPropertyChanged
public bool IsChatScene { get; init; }
private bool? _sceneMutesMic;
/// <summary>TASK 35: what this scene does to the mix when it takes the program output.
/// <c>null</c> = leave it alone (the default; scenes are passive until told); true mutes the mic on
/// entry. Applied once at entry, never while staging offline — auditioning a scene must not edit
/// the stream's state.</summary>
public bool? SceneMutesMic
{
get => _sceneMutesMic;
set => Set(ref _sceneMutesMic, value);
}
private bool? _sceneMutesDesktop;
/// <summary>TASK 35: as <see cref="SceneMutesMic"/>, for desktop/game audio.</summary>
public bool? SceneMutesDesktop
{
get => _sceneMutesDesktop;
set => Set(ref _sceneMutesDesktop, value);
}
/// <summary>
/// Asset-table id of this scene's top-strip tile artwork. The tile is a
/// separate image from the scene's background: the preview pane keeps
+3
View File
@@ -85,6 +85,9 @@ public abstract class SceneElement : INotifyPropertyChanged
/// <summary>True only for AlertBox-type sources. Gates stream alerts properties panel.</summary>
public virtual bool IsAlertBox => false;
public virtual bool IsTextOverlay => false;
/// <summary>The scoreboard strip: a per-scene, full-width bar.</summary>
public virtual bool IsScoreboard => false;
public virtual bool IsMediaSource => false;
public virtual bool IsLiveCapture => false;
public virtual bool IsBackground { get; set; }
+52
View File
@@ -0,0 +1,52 @@
using System;
namespace ytLive.Models;
/// <summary>What kind of session the app is in, for polling decisions.</summary>
public enum SessionPhase
{
Idle,
Live,
Test,
Recording,
}
/// <summary>Which channel figures a poll should fetch.</summary>
public enum ScoreboardPoll
{
None,
SubscribersOnly,
Both,
}
/// <summary>Polling policy for the scoreboard's two channel figures (creator ruling 2026-10-30).
///
/// <para>Analytics answers in DAYS, and test/record sessions have no public audience — so querying
/// watch hours then is pure waste. Subscriber count is cheap (one quota unit) and moves during any
/// session, so it polls whenever the app is working. Idle means silent.</para></summary>
public static class ScoreboardCadence
{
public static readonly TimeSpan AnalyticsInterval = TimeSpan.FromMinutes(30);
public static readonly TimeSpan SubscriberInterval = TimeSpan.FromSeconds(60);
/// <summary><paramref name="enabled"/> is the layer's master switch. Off means NOTHING is
/// polled, whatever the session is doing — a hidden scoreboard has no reason to spend quota
/// (creator ruling 2026-10-30).</summary>
public static ScoreboardPoll Due(SessionPhase phase, DateTime lastAnalyticsUtc, DateTime lastSubscriberUtc,
DateTime nowUtc, bool enabled = true)
{
if (!enabled) return ScoreboardPoll.None;
if (phase == SessionPhase.Idle) return ScoreboardPoll.None;
var analyticsDue = phase == SessionPhase.Live && nowUtc - lastAnalyticsUtc >= AnalyticsInterval;
var subscribersDue = nowUtc - lastSubscriberUtc >= SubscriberInterval;
return (analyticsDue, subscribersDue) switch
{
(true, true) => ScoreboardPoll.Both,
(true, false) => ScoreboardPoll.None, // analytics alone is not a poll kind
(false, true) => ScoreboardPoll.SubscribersOnly,
_ => ScoreboardPoll.None,
};
}
}
+56
View File
@@ -0,0 +1,56 @@
using System.Collections.Generic;
using System.Linq;
namespace ytLive.Models;
/// <summary>Keeps exactly one Scoreboard layer in EVERY scene.
///
/// <para>Creator ruling (2026-10-30): "YT_ACTIVITY_TICKER … is available to all scenes." The
/// scoreboard is not something to add five times — it exists once per scene like the Background and
/// the Brand logo, and a creator turns it off per scene with the ordinary eye toggle. Removing it is
/// therefore not a delete: it heals on the next load.</para>
///
/// <para>Pure over a scene collection, so the rule is testable without a view-model.</para></summary>
public static class ScoreboardHealer
{
public const string LayerName = "Scoreboard";
/// <summary>The strip's default box: full width, a strip's height, and its Y for the bottom
/// edge. Geometry only — the EDGE itself is resolved against the social bar at render time.</summary>
public const double StripHeight = 56;
public const double BottomY = 1080 - StripHeight;
/// <summary>Ensures one scoreboard per scene, appended as the TOPMOST layer: it is a full-width
/// strip drawn over the scene, and inserting it above the background would break the Brand
/// logo's documented invariant ("directly above the background"). Returns true when anything
/// changed, so the caller can save.</summary>
public static bool Ensure(IEnumerable<Scene> scenes)
{
var changed = false;
foreach (var scene in scenes)
{
var existing = scene.Elements.OfType<Source>().Where(s => s.IsScoreboard).ToList();
// More than one: keep the first, drop the rest.
foreach (var extra in existing.Skip(1))
{
scene.Elements.Remove(extra);
changed = true;
}
if (existing.Count > 0) continue;
scene.Elements.Add(new Source
{
Name = LayerName,
Type = SourceType.Scoreboard,
IsEnabled = true,
X = 0,
Y = BottomY,
Width = 1920,
Height = StripHeight,
});
changed = true;
}
return changed;
}
}
+90
View File
@@ -0,0 +1,90 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace ytLive.Models;
/// <summary>Which edge a full-width bar occupies.</summary>
public enum BarEdge
{
Top,
Bottom,
}
/// <summary>The scoreboard's placement and line-fitting arithmetic, kept pure so both are testable
/// without a renderer.
///
/// <para>Two rules from the creator (2026-10-30):
/// the strip and the social bar are a COUPLED pair that own opposite edges, resolved at render time
/// from what is actually visible; and a strip that does not fit SHRINKS to a floor and then wraps to
/// a second line — it never scrolls, because a creator reading the screen cannot wait for the fact
/// to come round.</para></summary>
public static class ScoreboardLayout
{
public const double FloorFontSize = 14;
public const double MaxFontSize = 24;
/// <summary>Result of fitting the enabled tiles into one strip.</summary>
public readonly record struct Fit(double FontSize, int LineCount, bool Shrunk)
{
public bool Wrapped => LineCount > 1;
}
/// <summary>Resolves the edge pair. When both bars are visible they take opposite edges; the
/// social bar keeps its own choice and the scoreboard is pushed to the other one. When either is
/// hidden the survivor keeps its preferred edge — so hiding the scoreboard frees the social bar
/// to sit anywhere, exactly as the creator asked.</summary>
public static (BarEdge Social, BarEdge Scoreboard) ResolveEdges(
bool socialVisible, BarEdge socialPreferred,
bool scoreboardVisible, BarEdge scoreboardPreferred)
{
if (socialVisible && scoreboardVisible)
return (socialPreferred, Opposite(socialPreferred));
return (socialPreferred, scoreboardPreferred);
}
public static BarEdge Opposite(BarEdge edge) => edge == BarEdge.Top ? BarEdge.Bottom : BarEdge.Top;
/// <summary>Fits measured tile widths (at <paramref name="requestedSize"/>) into the available
/// width: full size if they fit, otherwise shrink, and only if the shrink would drop below the
/// legibility floor does it wrap. Widths are passed in because the renderer MEASURES them —
/// guessing a character width would make the fit lie for a wide glyph or a long handle.</summary>
public static Fit FitLine(IReadOnlyList<double> tileWidths, double separatorWidth,
double availableWidth, double requestedSize = MaxFontSize, double floorSize = FloorFontSize)
{
var widths = tileWidths ?? System.Array.Empty<double>();
var count = widths.Count;
if (count == 0 || availableWidth <= 0)
return new Fit(requestedSize, 1, false);
var total = widths.Sum() + separatorWidth * (count - 1);
if (total <= availableWidth)
return new Fit(requestedSize, 1, false);
var scaled = requestedSize * (availableWidth / total);
if (scaled >= floorSize)
return new Fit(scaled, 1, true);
// Below the floor: keep the floor and wrap. Greedy packing, measured widths scaled to the
// floor size so the line breaks match what is actually drawn.
var factor = floorSize / requestedSize;
var lines = 1;
var lineWidth = 0.0;
foreach (var width in widths)
{
var w = width * factor;
var needed = lineWidth == 0 ? w : lineWidth + separatorWidth + w;
if (needed > availableWidth && lineWidth > 0)
{
lines++;
lineWidth = w;
}
else
{
lineWidth = needed;
}
}
return new Fit(floorSize, lines, true);
}
}
+51
View File
@@ -0,0 +1,51 @@
namespace ytLive.Models;
/// <summary>Owns the session's <see cref="ScoreboardState"/> and the one lifecycle rule that
/// matters: a new session starts from zero.
///
/// <para>Kept separate from the view-model so the rule is testable without a window, and so the
/// scoreboard layer has exactly one place that decides when counting begins.</para></summary>
public sealed class ScoreboardRecorder
{
public ScoreboardState State { get; private set; } = new();
/// <summary>Called when a session starts (go-live, test, or a local recording). Every start
/// clears the tallies — "session" is the only scope the numbers promise.</summary>
public void StartSession()
{
State = new ScoreboardState();
IsRunning = true;
RaiseChanged();
}
/// <summary>Folds a chat event in. Outside a session nothing is counted: a stale tally left over
/// from the last stream would be the "today" lie this design exists to avoid.</summary>
public void Record(ChatMessage message)
{
if (!IsRunning) return;
var before = Fingerprint(State);
State.Apply(message);
if (Fingerprint(State) != before) RaiseChanged();
}
/// <summary>True while the tallies are live.</summary>
public bool IsRunning { get; private set; }
/// <summary>Marks the session finished. The tallies stay visible (the End screen may show
/// them) but stop moving.</summary>
public void EndSession()
{
IsRunning = false;
RaiseChanged();
}
public event System.Action? Changed;
private void RaiseChanged() => Changed?.Invoke();
/// <summary>Cheap change detector: only re-render the strip when a displayed value moved.</summary>
private static string Fingerprint(ScoreboardState state) =>
$"{state.NewMembers}|{state.MilestoneCount}|{state.LastMemberName}|{state.LastGifterName}|" +
$"{state.LastGiftCount}|{state.LastSuperChatAuthor}|{state.LastSuperChatDisplay}|" +
$"{state.SessionTotal.Currency}|{state.SessionTotal.Total:0.######}";
}
+29
View File
@@ -0,0 +1,29 @@
namespace ytLive.Models;
/// <summary>Development-only sample data for the scoreboard.
///
/// <para>Creator (2026-10-30): the strip must be provable in the PREVIEW before a real stream —
/// "simulate the ticker so we can see it and ensure it's getting composited". Synthetic events go
/// through the same <see cref="ScoreboardRecorder"/> a live stream uses, so what the preview shows
/// is exactly what real events would produce; nothing here fakes the renderer.</para>
///
/// <para>Kept in the model (not the view-model) so the sample is testable without a window.</para></summary>
public static class ScoreboardSample
{
public static void Inject(ScoreboardRecorder recorder)
{
if (!recorder.IsRunning) recorder.StartSession();
recorder.Record(new ChatMessage { Kind = ChatEventKind.NewMember, AuthorName = "newviewer" });
recorder.Record(new ChatMessage
{
Kind = ChatEventKind.MemberGift, AuthorName = "giftgiver", GiftNewMembersCount = 3,
});
recorder.Record(new ChatMessage { Kind = ChatEventKind.MemberMilestone, AuthorName = "longtimer" });
recorder.Record(new ChatMessage
{
Kind = ChatEventKind.SuperChat, AuthorName = "tipper",
SuperChatAmount = 12.34, SuperChatCurrency = "USD", SuperChatDisplayString = "$12.34",
});
}
}
+120
View File
@@ -0,0 +1,120 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace ytLive.Models;
/// <summary>The session's scoreboard tallies — the facts a creator puts on screen to prove the
/// channel is moving.
///
/// <para>Scope is the RUNNING SESSION (creator ruling 2026-10-30): the counts start at zero when a
/// session starts and die with it. No persistence, nothing to reconcile, and every number is true
/// for exactly what this session saw — a stored "today" that survives a Windows restart would be
/// right only for the hours the app happened to be running, which is a lie the creator cannot
/// check.</para>
///
/// <para>The two polled tiles (subscribers, watch hours) are NOT here: they are channel state, not
/// session events, and live on the polled side of the scoreboard.</para></summary>
public sealed class ScoreboardState
{
/// <summary>Super Chat + Super Sticker money, per currency. Adding across currencies would be
/// the one arithmetic nobody forgives, so totals are keyed and only the dominant one is shown.</summary>
private readonly Dictionary<string, double> _moneyByCurrency = new(StringComparer.OrdinalIgnoreCase);
public string? LastMemberName { get; private set; }
public string? LastSuperChatAuthor { get; private set; }
public string? LastSuperChatDisplay { get; private set; }
public double LastSuperChatAmount { get; private set; }
public string? LastSuperChatCurrency { get; private set; }
public string? LastGifterName { get; private set; }
public int LastGiftCount { get; private set; }
/// <summary>Members gained this session: paid memberships plus gifted ones (counted once, from
/// the GIFT PURCHASE — see <see cref="Apply"/>).</summary>
public int NewMembers { get; private set; }
public int MilestoneCount { get; private set; }
public IReadOnlyDictionary<string, double> MoneyByCurrency => _moneyByCurrency;
/// <summary>The session's dominant currency (most money) and its total, or ("", 0) when nothing
/// has come in. Chosen by VALUE so a single EUR tip beside ten USD cents cannot flip the display
/// to the smaller currency.</summary>
public (string Currency, double Total) SessionTotal =>
_moneyByCurrency.Count == 0
? (string.Empty, 0)
: _moneyByCurrency.OrderByDescending(pair => pair.Value).First() is var top
? (top.Key, top.Value)
: (string.Empty, 0);
public bool HasMixedCurrencies => _moneyByCurrency.Count > 1;
/// <summary>Folds one chat message into the tallies. Every event kind the poll decodes is
/// handled; plain chat is ignored.
///
/// <para><b>giftMembershipReceivedEvent deliberately does nothing.</b> It is the RECIPIENT's view
/// of a purchase already counted by membershipGiftingEvent, so counting it would double both the
/// members and the money (creator ruling 2026-10-30: "come on").</para></summary>
public void Apply(ChatMessage message)
{
switch (message.Kind)
{
case ChatEventKind.NewMember:
NewMembers++;
LastMemberName = message.AuthorName;
break;
case ChatEventKind.MemberGift:
// The purchase: one event carrying how many memberships were bought.
var count = Math.Max(1, message.GiftNewMembersCount);
NewMembers += count;
LastGiftCount = count;
LastGifterName = message.AuthorName;
break;
case ChatEventKind.GiftReceived:
break; // recipient side of the event above — never counted
case ChatEventKind.MemberMilestone:
MilestoneCount++;
break;
case ChatEventKind.SuperChat:
case ChatEventKind.SuperSticker:
LastSuperChatAuthor = message.AuthorName;
LastSuperChatAmount = message.SuperChatAmount;
LastSuperChatCurrency = string.IsNullOrWhiteSpace(message.SuperChatCurrency)
? "USD"
: message.SuperChatCurrency!;
LastSuperChatDisplay = message.SuperChatDisplayString
?? FormatMoney(LastSuperChatAmount, LastSuperChatCurrency);
if (message.SuperChatAmount > 0)
{
_moneyByCurrency.TryGetValue(LastSuperChatCurrency!, out var running);
_moneyByCurrency[LastSuperChatCurrency!] = running + message.SuperChatAmount;
}
break;
}
}
/// <summary>A new session starts from zero.</summary>
public void Reset() => CopyFrom(new ScoreboardState());
private void CopyFrom(ScoreboardState other)
{
_moneyByCurrency.Clear();
foreach (var pair in other._moneyByCurrency) _moneyByCurrency[pair.Key] = pair.Value;
LastMemberName = other.LastMemberName;
LastSuperChatAuthor = other.LastSuperChatAuthor;
LastSuperChatDisplay = other.LastSuperChatDisplay;
LastSuperChatAmount = other.LastSuperChatAmount;
LastSuperChatCurrency = other.LastSuperChatCurrency;
LastGifterName = other.LastGifterName;
LastGiftCount = other.LastGiftCount;
NewMembers = other.NewMembers;
MilestoneCount = other.MilestoneCount;
}
private static string FormatMoney(double amount, string? currency) =>
string.IsNullOrEmpty(currency) ? amount.ToString("0.00") : $"{currency} {amount:0.00}";
}
+87
View File
@@ -0,0 +1,87 @@
using System;
using System.Collections.Generic;
using System.Globalization;
namespace ytLive.Models;
/// <summary>Which of the eight facts the creator put on screen, and how each renders.
///
/// <para>Every tile is optional because the strip is an ego wall — the creator chooses the
/// billboard. Goal tiles carry their targets here so the rendering stays a pure function of the
/// state plus this selection.</para></summary>
public sealed class ScoreboardTiles
{
/// <summary>Nothing enabled — the layer draws nothing.</summary>
public static ScoreboardTiles None => new();
/// <summary>The selection a new layer starts with: the six session facts, no channel polls
/// (those need the Analytics scope).</summary>
public static ScoreboardTiles Default => new()
{
SuperChats = true, NewMembers = true, Milestones = true,
LastMember = true, LastSuperChat = true, LastGift = true,
};
public bool Subscribers { get; set; }
public bool WatchHours { get; set; }
public bool SuperChats { get; set; }
public bool NewMembers { get; set; }
public bool Milestones { get; set; }
public bool LastMember { get; set; }
public bool LastSuperChat { get; set; }
public bool LastGift { get; set; }
/// <summary>Goal targets for the two polled tiles; 0 means "no goal, show the raw number".</summary>
public int SubscriberGoal { get; set; }
public int WatchHourGoal { get; set; }
/// <summary>Channel state fed by the polled side (subscribers, watch hours). Kept here so the
/// strip is built from ONE source of truth: tallies + channel values + this selection.</summary>
public int SubscriberCount { get; set; }
public int WatchHoursValue { get; set; }
/// <summary>Cache key: the selection alone. Values are folded in by the presenter's fingerprint,
/// so this only needs to change when the ENABLED SET changes.</summary>
public string CacheKey =>
$"{Subscribers}{WatchHours}{SuperChats}{NewMembers}{Milestones}{LastMember}{LastSuperChat}{LastGift}" +
$"{SubscriberGoal}{WatchHourGoal}";
/// <summary>The strip's text, in reading order. Empty strings are dropped by the renderer.</summary>
public IReadOnlyList<string> Build(ScoreboardState state)
{
var tiles = new List<string>(8);
if (Subscribers)
tiles.Add(SubscriberGoal > 0
? $"Subs {SubscriberCount:N0}/{SubscriberGoal:N0}"
: $"Subs {SubscriberCount:N0}");
if (WatchHours)
// Analytics is a DAILY aggregate, so the tile must not imply live data.
tiles.Add(WatchHourGoal > 0
? $"Hours {WatchHoursValue:N0}/{WatchHourGoal:N0} (as of {DateTime.Now:MMM d})"
: $"Hours {WatchHoursValue:N0} (as of {DateTime.Now:MMM d})");
if (SuperChats && state.SessionTotal.Total > 0)
tiles.Add(state.HasMixedCurrencies
? $"SC {state.SessionTotal.Currency} {state.SessionTotal.Total:0.00} +{state.MoneyByCurrency.Count - 1}"
: $"SC {state.SessionTotal.Currency} {state.SessionTotal.Total:0.00}");
if (NewMembers && state.NewMembers > 0)
tiles.Add($"Members +{state.NewMembers}");
if (Milestones && state.MilestoneCount > 0)
tiles.Add($"Milestones {state.MilestoneCount}");
if (LastMember && !string.IsNullOrWhiteSpace(state.LastMemberName))
tiles.Add($"Last member {state.LastMemberName}");
if (LastSuperChat && !string.IsNullOrWhiteSpace(state.LastSuperChatDisplay))
tiles.Add($"Last SC {state.LastSuperChatDisplay}");
if (LastGift && !string.IsNullOrWhiteSpace(state.LastGifterName))
tiles.Add($"Last gift {state.LastGifterName} ×{Math.Max(1, state.LastGiftCount)}");
return tiles;
}
}
+94
View File
@@ -0,0 +1,94 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
namespace ytLive.Models;
/// <summary>One Super Chat or Super Sticker event, as YouTube reports it.</summary>
public sealed record SuperChatEvent(
string Id,
ChatEventKind Kind,
long AmountMicros,
string Currency,
string? DisplayAmount,
DateTime PublishedAt);
/// <summary>
/// TASK 3·20: what this session's rewards added up to — <b>in memory only</b>.
/// <para>Derived on demand from <c>superChatEvents.list</c> rather than persisted: YouTube is the system
/// of record for money, and keeping a local ledger would contradict the published promise that no
/// viewer, member or donor information is retained after the session ends.</para>
/// <para>Totals are kept <b>per currency</b> and never summed across currencies — a $5 and a €5 are not
/// $10, and a single blended number would be a lie.</para>
/// </summary>
public sealed record SessionRewardSummary(
int SuperChatCount,
int SuperStickerCount,
IReadOnlyDictionary<string, long> MicrosByCurrency,
DateTime? FirstEventAt,
DateTime? LastEventAt)
{
public static readonly SessionRewardSummary Empty = new(
0, 0, new Dictionary<string, long>(), null, null);
public int TotalEvents => SuperChatCount + SuperStickerCount;
public bool HasEvents => TotalEvents > 0;
/// <summary>Filters to a session window and rolls the events up. Events outside
/// [<paramref name="sinceUtc"/>, <paramref name="untilUtc"/>] are ignored, which is what makes a
/// report "this session" rather than "this channel".</summary>
public static SessionRewardSummary From(
IEnumerable<SuperChatEvent> events, DateTime sinceUtc, DateTime? untilUtc = null)
{
var until = untilUtc ?? DateTime.MaxValue;
var inWindow = events
.Where(e => e.PublishedAt >= sinceUtc && e.PublishedAt <= until)
.OrderBy(e => e.PublishedAt)
.ToList();
if (inWindow.Count == 0) return Empty;
var byCurrency = new Dictionary<string, long>(StringComparer.OrdinalIgnoreCase);
foreach (var e in inWindow)
{
var currency = string.IsNullOrWhiteSpace(e.Currency) ? "?" : e.Currency.ToUpperInvariant();
byCurrency.TryGetValue(currency, out var total);
byCurrency[currency] = total + Math.Max(0, e.AmountMicros);
}
return new SessionRewardSummary(
inWindow.Count(e => e.Kind == ChatEventKind.SuperChat),
inWindow.Count(e => e.Kind == ChatEventKind.SuperSticker),
byCurrency,
inWindow[0].PublishedAt,
inWindow[^1].PublishedAt);
}
/// <summary>Money lines for display, e.g. "USD $12.34 · EUR €4.00". Empty when there is nothing to
/// show — a creator who never monetized must never be shown a zero.</summary>
public IReadOnlyList<string> MoneyLines()
=> MicrosByCurrency
.Where(pair => pair.Value > 0)
.OrderByDescending(pair => pair.Value)
.Select(pair => $"{pair.Key} {Format(pair.Key, pair.Value)}")
.ToList();
private static string Format(string currency, long micros)
{
var amount = micros / 1_000_000m;
var symbol = currency switch
{
"USD" => "$",
"EUR" => "€",
"GBP" => "£",
"CAD" => "CA$",
"AUD" => "A$",
_ => string.Empty,
};
return symbol.Length > 0
? symbol + amount.ToString("0.00", CultureInfo.InvariantCulture)
: amount.ToString("0.00", CultureInfo.InvariantCulture) + " " + currency;
}
}
+61
View File
@@ -0,0 +1,61 @@
namespace ytLive.Models;
/// <summary>What the top bar's primary action button will do.</summary>
public enum SessionStartKind
{
Record,
Live,
Test,
}
/// <summary>Creator ruling (2026-10-30): the primary button is context-sensitive, so the separate
/// TEST button is gone.
///
/// <list type="bullet">
/// <item>EDIT off + ON-AIR armed → Go Live (the livestream message)</item>
/// <item>EDIT on + ON-AIR armed → Test Stream (the test-livestream message) — a dry run you
/// cannot leak, because every broadcast is private pre-v1 anyway</item>
/// <item>REC armed → Start Recording, whatever EDIT says (there is no test recording)</item>
/// </list>
///
/// <para>Pure so the whole matrix is testable without a window.</para></summary>
public static class SessionStartDecision
{
/// <summary>
/// Which kind of session the armed output starts. Since 2026-11-12 there is no inference from
/// editing: the output stage offers "Private Test Stream" and "Review & Go Live" as separate
/// buttons, so ON-AIR always means the real livestream and Test is something the creator asks for.
/// </summary>
public static SessionStartKind For(bool recordArmed, bool onAirArmed)
{
// Neither output armed still starts something: local recording needs no account, and the
// no-dead-ends rule forbids an inert primary button.
if (recordArmed || !onAirArmed) return SessionStartKind.Record;
return SessionStartKind.Live;
}
/// <summary>What pressing the button actually does, given whether YouTube is connected.
/// A signed-out ON-AIR press must start the SIGN-IN flow, not sit there disabled — a dead
/// primary button is the dead end the design principle forbids (creator, 2026-10-30).</summary>
public enum Route
{
SignIn,
Record,
Live,
Test,
}
public static Route RouteFor(SessionStartKind kind, bool connected) => kind switch
{
SessionStartKind.Record => Route.Record,
SessionStartKind.Test => connected ? Route.Test : Route.SignIn,
_ => connected ? Route.Live : Route.SignIn,
};
public static string LabelFor(SessionStartKind kind) => kind switch
{
SessionStartKind.Record => "Start Recording",
SessionStartKind.Test => "Test Stream",
_ => "Go Live",
};
}
+44
View File
@@ -70,6 +70,50 @@ public sealed class SocialsConfig : INotifyPropertyChanged
set => Set(ref _barPosition, value);
}
// ─── Bar appearance (creator request 2026-10-30) ───────────────────────────
// The bar used to be hard-wired white-on-transparent. These five knobs are the whole
// appearance surface: text colour, an optional plate behind it, and a text shadow.
private string _barTextColor = "#FFFFFF";
/// <summary>Text and logo colour of the bar (hex, straight alpha).</summary>
public string BarTextColor
{
get => _barTextColor;
set => Set(ref _barTextColor, string.IsNullOrWhiteSpace(value) ? "#FFFFFF" : value);
}
private bool _barBackgroundEnabled;
/// <summary>Draw a rounded plate behind the entries. Off by default — the bar shipped
/// transparent and most creators keep it that way.</summary>
public bool BarBackgroundEnabled
{
get => _barBackgroundEnabled;
set => Set(ref _barBackgroundEnabled, value);
}
private string _barBackgroundColor = "#000000";
public string BarBackgroundColor
{
get => _barBackgroundColor;
set => Set(ref _barBackgroundColor, string.IsNullOrWhiteSpace(value) ? "#000000" : value);
}
private double _barBackgroundOpacity = 0.55;
/// <summary>0 = invisible plate, 1 = solid. Only meaningful when the plate is on.</summary>
public double BarBackgroundOpacity
{
get => _barBackgroundOpacity;
set => Set(ref _barBackgroundOpacity, Math.Clamp(value, 0, 1));
}
private bool _barTextShadow;
/// <summary>A soft black drop shadow behind the glyphs, for a bright or busy scene.</summary>
public bool BarTextShadow
{
get => _barTextShadow;
set => Set(ref _barTextShadow, value);
}
public event PropertyChangedEventHandler? PropertyChanged;
private bool Set<T>(ref T field, T value, [CallerMemberName] string? name = null)
{
+353 -11
View File
@@ -1,5 +1,6 @@
using System.Windows.Media;
using ytLive.Helpers;
using ytLive.Services;
namespace ytLive.Models;
@@ -13,10 +14,17 @@ public enum SourceType
Countdown,
WebSource,
ChatBox,
/// <summary>The session scoreboard strip: full-width, state not events.</summary>
Scoreboard,
MediaSource,
/// <summary>The designated alert zone: the six monetization events play their
/// unique celebration animations here (OBS alert-box equivalent, native).</summary>
AlertBox
AlertBox,
/// <summary>A privacy blur: blurs the frame beneath it inside a rect or ellipse. Live scene
/// only, always below the brand logo, several allowed (strongest wins on overlap).</summary>
Blur,
/// <summary>A scene-local feed of recent alert events.</summary>
Ticker
}
/// <summary>Distinguishes how a media source presents its content (TASK 21).</summary>
@@ -34,6 +42,20 @@ public enum MediaPlaybackState
Paused
}
public enum TextLayerAlignment
{
Left,
Center,
Right
}
public enum TextLayerVerticalAlignment
{
Top,
Center,
Bottom
}
public enum ClipShape
{
Traditional,
@@ -61,6 +83,19 @@ public enum AlertDisplayMethod
/// </summary>
public class Source : SceneElement
{
private readonly Ticker _ticker = new();
public Source()
{
_ticker.PropertyChanged += (_, e) =>
{
if (e.PropertyName == nameof(Ticker.IsEnabled)) Raise(nameof(AlertShowTicker));
if (e.PropertyName == nameof(Ticker.DisplayMethod)) Raise(nameof(AlertDisplayMethod));
};
}
public Ticker Ticker => _ticker;
private SourceType _type;
public SourceType Type
{
@@ -74,20 +109,36 @@ public class Source : SceneElement
Raise(nameof(DisplaySource));
Raise(nameof(IsWebSource));
Raise(nameof(IsTextOverlay));
Raise(nameof(IsScoreboard));
Raise(nameof(IsTicker));
Raise(nameof(ShowsChromaKey));
}
}
}
public override bool IsDraggable => Type is SourceType.Image or SourceType.WebSource or SourceType.ChatBox or SourceType.AlertBox;
/// <summary>Blur is draggable like a layer: it has no pixels of its own, but the creator places
/// and sizes it in the preview, so it needs the same selection box, grab and resize handle
/// (creator ruling 2026-11-01).</summary>
public override bool IsDraggable => Type is SourceType.Image or SourceType.WebSource or SourceType.ChatBox
or SourceType.TextOverlay or SourceType.AlertBox or SourceType.Blur or SourceType.Ticker
or SourceType.MediaSource;
/// <summary>Live captures, chat, web sources and media are dynamic; static art is fixed.</summary>
public override ElementKind Kind => Type is SourceType.DisplayCapture or SourceType.WindowCapture
or SourceType.ChatBox or SourceType.WebSource or SourceType.MediaSource or SourceType.AlertBox
or SourceType.Blur or SourceType.Ticker
? ElementKind.Dynamic : ElementKind.Static;
public override bool IsWebSource => Type == SourceType.WebSource;
public override bool IsTextOverlay => Type == SourceType.TextOverlay;
public override bool IsScoreboard => Type == SourceType.Scoreboard;
public bool IsTicker => Type == SourceType.Ticker;
/// <summary>The permanent brand logo layer (one per scene, directly above the background).
/// Locked against removal; its image comes from the App Settings brand logo.</summary>
public bool IsBrandLogo { get; set; }
/// <summary>The permanent scene background (live desktop/game capture). Never removable/reorderable.</summary>
private bool _isBackground;
public override bool IsBackground { get => _isBackground; set => Set(ref _isBackground, value); }
@@ -134,14 +185,37 @@ public class Source : SceneElement
public ImageSource? ImageSource => _imageSource;
/// <summary>Types whose frame is produced at runtime rather than stored as an asset. The
/// scoreboard joined this list 2026-10-30 — without it the strip was rendered, published to
/// VideoImageSource, and never displayed (the same shape as the invisible-alert defect).</summary>
private bool IsFrameSource =>
Type is SourceType.TextOverlay or SourceType.ChatBox or SourceType.WebSource or SourceType.MediaSource
or SourceType.AlertBox or SourceType.Scoreboard or SourceType.Ticker;
public override ImageSource? DisplaySource => IsLiveCapture
? (VideoImageSource ?? _imageSource)
: (Type is SourceType.ChatBox or SourceType.WebSource or SourceType.MediaSource or SourceType.AlertBox ? VideoImageSource : _imageSource);
: (IsFrameSource ? VideoImageSource : _imageSource);
public override bool IsImageSource => Type == SourceType.Image;
// Countdown config
private int _timerMinutes = 15;
// Privacy blur (Live scene only): 0–1, the same scale and default as the webcam's background
// blur so "half strength" means the same blur in both places.
private double _blurAmount = 0.5;
public double BlurAmount
{
get => _blurAmount;
set => Set(ref _blurAmount, Math.Clamp(value, 0, 1));
}
public bool IsBlur => Type == SourceType.Blur;
/// <summary>True for a round blur (circle) — the shape rides the existing clip surface, so the
/// box/circle choice is the same switch every other layer uses rather than a second concept.</summary>
public bool IsRoundBlur => IsBlur && ClipShape == ClipShape.Round;
public int TimerMinutes
{
get => _timerMinutes;
@@ -160,6 +234,16 @@ public class Source : SceneElement
set => Set(ref _mediaType, value);
}
private bool _isReplayLayer;
/// <summary>TASK 65: this media layer shows saved replay clips rather than a chosen file. The VM
/// owns its path and playlist; the compositor needs to know nothing.</summary>
public bool IsReplayLayer
{
get => _isReplayLayer;
set => Set(ref _isReplayLayer, value);
}
private string? _mediaPath;
public string? MediaPath
{
@@ -167,10 +251,17 @@ public class Source : SceneElement
set
{
if (Set(ref _mediaPath, value))
{
Raise(nameof(IsMediaSource));
Raise(nameof(MediaFileDisplay));
}
}
}
/// <summary>The chosen file's name for the properties panel (path stays in the model).</summary>
public string MediaFileDisplay =>
string.IsNullOrWhiteSpace(_mediaPath) ? "No video chosen" : System.IO.Path.GetFileName(_mediaPath);
private bool _mediaIsLooping;
public bool MediaIsLooping
{
@@ -185,6 +276,31 @@ public class Source : SceneElement
set => Set(ref _mediaVolume, Math.Clamp(value, 0.0, 1.0));
}
private bool _mediaMuted;
/// <summary>Silences this layer's soundtrack (the picture keeps playing). One decoder is
/// shared per file, so the level/mute are the file's — the layer is the control.</summary>
public bool MediaMuted
{
get => _mediaMuted;
set => Set(ref _mediaMuted, value);
}
private bool _isMediaRunning;
/// <summary>True while the clip is decoding. False means it has played out and is holding its
/// last frame — the panel then offers "Play again" instead of leaving a frozen picture with no
/// explanation (the 2026-11-05 "clicking Loop did nothing" report).</summary>
public bool IsMediaRunning
{
get => _isMediaRunning;
set
{
if (Set(ref _isMediaRunning, value)) Raise(nameof(IsMediaFinished));
}
}
public bool IsMediaFinished => !IsMediaRunning;
private MediaPlaybackState _mediaPlaybackState = MediaPlaybackState.Stopped;
public MediaPlaybackState MediaPlaybackState
{
@@ -194,6 +310,133 @@ public class Source : SceneElement
public override bool IsMediaSource => Type == SourceType.MediaSource;
// ── Green/Blue Screen keying (TASK 64) ────────────────────────────────────────────
// Settings live on the LAYER. The decoder is keyed by (path + these settings), so two
// layers with the same look share one decode and two different looks get two decoders.
private bool _chromaKeyEnabled;
public bool ChromaKeyEnabled
{
get => _chromaKeyEnabled;
set
{
if (Set(ref _chromaKeyEnabled, value))
{
Raise(nameof(ChromaKey));
Raise(nameof(HasChromaSuggestion));
}
}
}
private string _chromaKeyColor = "#00B140";
/// <summary>The screen colour as it was shot (the detector's median), not a canonical green.</summary>
public string ChromaKeyColor
{
get => _chromaKeyColor;
set
{
if (Set(ref _chromaKeyColor, value)) Raise(nameof(ChromaKey));
}
}
private double _chromaKeySimilarity = 0.12;
/// <summary>Creator-facing "Amount": how far from the key colour still counts as screen.</summary>
public double ChromaKeySimilarity
{
get => _chromaKeySimilarity;
set
{
if (Set(ref _chromaKeySimilarity, Math.Clamp(value, 0, 0.5))) Raise(nameof(ChromaKey));
}
}
private double _chromaKeySoftness = 0.08;
/// <summary>Creator-facing "Softness": the width of the fade at the keyed edge.</summary>
public double ChromaKeySoftness
{
get => _chromaKeySoftness;
set
{
if (Set(ref _chromaKeySoftness, Math.Clamp(value, 0, 0.5))) Raise(nameof(ChromaKey));
}
}
private bool _chromaKeySpill = true;
/// <summary>Creator-facing "Fringe": clean the coloured rim the screen leaves on hair and edges.</summary>
public bool ChromaKeySpill
{
get => _chromaKeySpill;
set
{
if (Set(ref _chromaKeySpill, value)) Raise(nameof(ChromaKey));
}
}
private ChromaScreen _chromaScreen = ChromaScreen.Green;
public ChromaScreen ChromaScreen
{
get => _chromaScreen;
set
{
if (Set(ref _chromaScreen, value)) Raise(nameof(ChromaKey));
}
}
/// <summary>The look this layer asks its decoder for — the one place the settings become an
/// ffmpeg chain and a session signature.</summary>
public ChromaKeyOptions ChromaKey
=> new(ChromaKeyEnabled, ChromaKeyColor, ChromaKeySimilarity, ChromaKeySoftness, ChromaKeySpill, ChromaScreen);
private ChromaKeySuggestion _chromaSuggestion;
/// <summary>The detector's offer for this clip. Transient by design — never persisted, because
/// it is a hint about a file, not a setting the creator made.</summary>
public ChromaKeySuggestion ChromaSuggestion
{
get => _chromaSuggestion;
set
{
_chromaSuggestion = value;
Raise(nameof(ChromaSuggestion));
Raise(nameof(HasChromaSuggestion));
Raise(nameof(ChromaSuggestionText));
Raise(nameof(HasChromaSuggestionColor));
}
}
/// <summary>True while there is an offer the creator has not accepted.</summary>
public bool HasChromaSuggestion => !ChromaKeyEnabled && ChromaSuggestion.Screen != ChromaScreen.None;
private bool _chromaKeyAutoApplied;
/// <summary>True when the app applied the key on its own (the "Auto Green/Blue Screen"
/// setting). The panel says so, so an automatic change is never silent.</summary>
public bool ChromaKeyAutoApplied
{
get => _chromaKeyAutoApplied;
set
{
if (Set(ref _chromaKeyAutoApplied, value)) Raise(nameof(HasAutoChromaKey));
}
}
public bool HasAutoChromaKey => ChromaKeyAutoApplied && ChromaKeyEnabled;
/// <summary>Which layers get the Green/Blue Screen block: a video layer, or the alert box's
/// own clip (TASK 64 — a clip is a clip).</summary>
public bool ShowsChromaKey => IsMediaSource
|| (Type == SourceType.AlertBox && !AlertUseDefaultVideo && !string.IsNullOrWhiteSpace(AlertVideoPath));
public string ChromaSuggestionText => ChromaSuggestion.Screen switch
{
ChromaScreen.Green => "Looks like a green screen.",
ChromaScreen.Blue => "Looks like a blue screen.",
_ => string.Empty,
};
/// <summary>The detected colour, for the swatch next to the offer.</summary>
public string ChromaSuggestionColor => ChromaSuggestion.ColorHex ?? "#00B140";
public bool HasChromaSuggestionColor => ChromaSuggestion.ColorHex != null;
// Alert video config (TASK 47): the shipped default clip lives in the Asset
// table (seeded like image assets); the creator's replacement is a FILE PATH
// reference only — never into the DB (creator ruling, 2026-09-26). Runtime
@@ -209,21 +452,26 @@ public class Source : SceneElement
public string? AlertVideoPath
{
get => _alertVideoPath;
set => Set(ref _alertVideoPath, value);
set
{
if (Set(ref _alertVideoPath, value)) Raise(nameof(ShowsChromaKey));
}
}
private bool _alertUseDefaultVideo = true;
public bool AlertUseDefaultVideo
{
get => _alertUseDefaultVideo;
set => Set(ref _alertUseDefaultVideo, value);
set
{
if (Set(ref _alertUseDefaultVideo, value)) Raise(nameof(ShowsChromaKey));
}
}
private bool _alertShowTicker = true;
public bool AlertShowTicker
{
get => _alertShowTicker;
set => Set(ref _alertShowTicker, value);
get => _ticker.IsEnabled;
set => _ticker.IsEnabled = value;
}
private double _alertVideoVolume = 1.0;
@@ -233,11 +481,32 @@ public class Source : SceneElement
set => Set(ref _alertVideoVolume, Math.Clamp(value, 0.0, 1.0));
}
private AlertDisplayMethod _alertDisplayMethod = AlertDisplayMethod.TickerScroll;
// Sound byte that plays with the alert clip (creator ruling 2026-11-01). Referenced from disk
// like the clip; the video always plays once to completion, the sound may loop under it.
private string? _alertAudioPath;
public string? AlertAudioPath
{
get => _alertAudioPath;
set => Set(ref _alertAudioPath, value);
}
private bool _alertAudioLoop;
public bool AlertAudioLoop
{
get => _alertAudioLoop;
set => Set(ref _alertAudioLoop, value);
}
public string AlertAudioFileDisplay =>
string.IsNullOrWhiteSpace(_alertAudioPath) ? "No sound byte" : System.IO.Path.GetFileName(_alertAudioPath);
/// <summary>True once a file is chosen — gates the loop switch and the clear button.</summary>
public bool HasAlertAudio => !string.IsNullOrWhiteSpace(_alertAudioPath);
public AlertDisplayMethod AlertDisplayMethod
{
get => _alertDisplayMethod;
set => Set(ref _alertDisplayMethod, value);
get => _ticker.DisplayMethod;
set => _ticker.DisplayMethod = value;
}
public override bool IsAlertBox => Type == SourceType.AlertBox;
@@ -314,6 +583,79 @@ public class Source : SceneElement
public override bool IsChatBox => Type == SourceType.ChatBox;
private string _textContent = "Text";
public string TextContent
{
get => _textContent;
set => Set(ref _textContent, value ?? string.Empty);
}
private string _textFontFamily = "Segoe UI";
public string TextFontFamily
{
get => _textFontFamily;
set => Set(ref _textFontFamily, string.IsNullOrWhiteSpace(value) ? "Segoe UI" : value.Trim());
}
private double _textFontSize = 48;
public double TextFontSize
{
get => _textFontSize;
set => Set(ref _textFontSize, double.IsFinite(value) ? Math.Clamp(value, 8, 300) : 48);
}
private string _textColor = "#FFFFFF";
public string TextColor
{
get => _textColor;
set
{
if (!Set(ref _textColor, IsValidTextColor(value) ? value.ToUpperInvariant() : "#FFFFFF")) return;
Raise(nameof(TextColorEntry));
Raise(nameof(TextColorOptions));
}
}
public NamedColor TextColorEntry
{
get => BorderColorPalette.EntryFor(TextColor);
set => TextColor = value.Hex;
}
public IReadOnlyList<NamedColor> TextColorOptions => BorderColorPalette.OptionsFor(TextColor);
private bool _textBold;
public bool TextBold { get => _textBold; set => Set(ref _textBold, value); }
private bool _textItalic;
public bool TextItalic { get => _textItalic; set => Set(ref _textItalic, value); }
private bool _textUnderline;
public bool TextUnderline { get => _textUnderline; set => Set(ref _textUnderline, value); }
private TextLayerAlignment _textAlignment;
public TextLayerAlignment TextAlignment
{
get => _textAlignment;
set => Set(ref _textAlignment, Enum.IsDefined(value) ? value : TextLayerAlignment.Left);
}
private TextLayerVerticalAlignment _textVerticalAlignment;
public TextLayerVerticalAlignment TextVerticalAlignment
{
get => _textVerticalAlignment;
set => Set(ref _textVerticalAlignment,
Enum.IsDefined(value) ? value : TextLayerVerticalAlignment.Top);
}
private static bool IsValidTextColor(string? value)
{
if (value is not { Length: 7 or 9 } || value[0] != '#') return false;
for (var i = 1; i < value.Length; i++)
if (!Uri.IsHexDigit(value[i])) return false;
return true;
}
public override bool IsCountdown => Type == SourceType.Countdown;
private bool _chatPreviewEnabled = true;
+286
View File
@@ -0,0 +1,286 @@
using System;
namespace ytLive.Models;
/// <summary>Why the policy acted. Reported in the toast and the session report, so it is never
/// guessed at again after the fact.</summary>
public enum StepCause
{
None,
/// <summary>The machine could not produce the frame: stalls, late renders, pump drops.</summary>
Machine,
/// <summary>The frames were produced but the sink could not take them: encoder drops.</summary>
Upload,
/// <summary>Both kinds of evidence in the same window — doing nothing is the only safe move.</summary>
Ambiguous,
}
public enum StepAction
{
None,
/// <summary>Cheapest first: halve LlamaCasty's own capture cost, change nothing the viewer sees.</summary>
ShedCaptureCadence,
LowerFrameRate,
LowerResolution,
LowerBitrate,
RaiseFrameRate,
RaiseResolution,
RaiseBitrate,
}
/// <summary>One statistics window as the pump and the encoder report it. Nothing here is derived
/// from the network directly: congestion shows up as frames the encoder failed to produce in
/// wall-clock time, and backpressure shows up as submit wait.</summary>
public readonly record struct StepDownSample(
double WindowSeconds,
int PumpStalls,
int PumpDrops,
int EncoderDrops,
double AvgRenderMs,
double AvgSubmitWaitMs);
/// <summary>The decision and the numbers behind it. <paramref name="Why"/> is for the log; the
/// creator-facing sentence is built in the VM from <see cref="Cause"/>.</summary>
public readonly record struct StepDownDecision(StepCause Cause, StepAction Action, string? Why)
{
public bool Acted => Action != StepAction.None;
}
/// <summary>
/// Decides when a session must give up quality, and why (TASK 33).
///
/// <para><b>Two levers, never mixed.</b> Machine starvation is answered with less work (60 → 30 fps,
/// then 1080 → 720); upload congestion is answered with fewer bits, sized by the observed loss
/// fraction. Resolution is never used as a bandwidth lever, because 1080p30 and 720p60 cost the same
/// 6–8 Mbps: dropping pixels at an unchanged bitrate relieves nothing.</para>
///
/// <para><b>One-way by default.</b> A step-down that climbs back is an encoder restart, which is a
/// visible hitch; the creator ruled that a show does not oscillate. The state machine still carries
/// the ceiling, so enabling recovery later is a constructor flag rather than a rewrite.
/// </para>
/// </summary>
public sealed class StepDownPolicy
{
/// <summary>Minimum evidence before acting: ~15 s of qualifying windows.</summary>
internal const double QualifyingSeconds = 15.0;
/// <summary>A sample window shorter than this is ignored — the pump reports every 5 s.</summary>
internal const double MinWindowSeconds = 4.0;
internal const double CooldownSeconds = 30.0;
/// <summary>Loss fraction at or above which the upload lever fires.</summary>
internal const double UploadLossThreshold = 0.05;
/// <summary>Render time relative to the frame budget that counts as machine starvation.</summary>
internal const double RenderBudgetFraction = 0.9;
internal const int StallThreshold = 5;
/// <summary>Clean seconds required before climbing when recovery is enabled.</summary>
internal const double ClimbAfterCleanSeconds = 180.0;
/// <summary>A climb followed by a fall inside this window freezes the session at the low value.</summary>
internal const double FlapWindowSeconds = 120.0;
private readonly bool _vertical;
private readonly bool _allowClimb;
private readonly int _ceilingFps;
private readonly int _ceilingWidth;
private readonly int _ceilingHeight;
private readonly double _ceilingBitrateMbps;
private readonly double _floorBitrateMbps;
private double _qualifyingSeconds;
private double _cleanSeconds;
private double _lastStepAt = double.NegativeInfinity;
private double _lastRaiseAt = double.NegativeInfinity;
private bool _frozen;
private bool _shedCapture;
public StepDownPolicy(bool vertical, int startFps, double startBitrateMbps, bool allowClimb = false)
: this(vertical ? 1080 : 1920, vertical ? 1920 : 1080, startFps, startBitrateMbps, allowClimb)
{
}
/// <summary>Explicit geometry: the tier the creator actually chose, which is what decides the
/// step budget (a 1080 start gets three, a 720 start gets one).</summary>
/// <param name="floorBitrateMbps">The lowest rate a step-down may reach — the tier's YouTube
/// minimum (<see cref="RateLadder"/>). Never below the starting rate, so an unusual start cannot
/// begin already under its floor. Omitted, the historical 3/4 Mbps floor applies.</param>
public StepDownPolicy(int startWidth, int startHeight, int startFps, double startBitrateMbps,
bool allowClimb = false, double? floorBitrateMbps = null)
{
_vertical = startHeight > startWidth;
_allowClimb = allowClimb;
_ceilingFps = startFps;
_ceilingWidth = startWidth;
_ceilingHeight = startHeight;
_ceilingBitrateMbps = startBitrateMbps;
_floorBitrateMbps = Math.Min(
floorBitrateMbps ?? (_vertical ? 4.0 : 3.0),
startBitrateMbps);
Fps = startFps;
Width = startWidth;
Height = startHeight;
BitrateMbps = startBitrateMbps;
// Creator ruling 2026-11-09: a 1080p start may spend three steps; 720 and vertical get one.
StepsAllowed = startWidth >= 1920 ? 3 : 1;
}
public int Fps { get; private set; }
public int Width { get; private set; }
public int Height { get; private set; }
public double BitrateMbps { get; private set; }
public int StepsUsed { get; private set; }
public int StepsAllowed { get; }
public bool Frozen => _frozen;
/// <summary>True when no lever has anything left: 30 fps, the floor geometry, the floor bitrate.</summary>
public bool AtFloor => Fps <= 30
&& (_vertical || Width <= 1280)
&& BitrateMbps <= _floorBitrateMbps + 0.001;
public double FrameBudgetMs => 1000.0 / Math.Max(1, Fps);
/// <summary>Classifies one window and, when the evidence is strong enough and the budget allows,
/// applies exactly one change. <paramref name="elapsed"/> is time since the session started.</summary>
public StepDownDecision Observe(in StepDownSample sample, TimeSpan elapsed)
{
if (sample.WindowSeconds < MinWindowSeconds) return new StepDownDecision(StepCause.None, StepAction.None, null);
var now = elapsed.TotalSeconds;
var budget = FrameBudgetMs;
var machine = sample.PumpStalls >= StallThreshold
|| sample.PumpDrops > 0
|| sample.AvgRenderMs > budget * RenderBudgetFraction;
var loss = sample.EncoderDrops / Math.Max(1.0, Fps * sample.WindowSeconds);
var upload = loss >= UploadLossThreshold;
if (machine && upload)
{
_qualifyingSeconds = 0;
_cleanSeconds = 0;
return new StepDownDecision(StepCause.Ambiguous, StepAction.None,
$"render {sample.AvgRenderMs:0.0}ms, stalls {sample.PumpStalls}, loss {loss:P0} — not acting");
}
if (!machine && !upload)
{
_qualifyingSeconds = 0;
_cleanSeconds += sample.WindowSeconds;
return TryClimb(sample, now);
}
_cleanSeconds = 0;
_qualifyingSeconds += sample.WindowSeconds;
if (_qualifyingSeconds < QualifyingSeconds)
return new StepDownDecision(machine ? StepCause.Machine : StepCause.Upload, StepAction.None, null);
if (now - _lastStepAt < CooldownSeconds)
return new StepDownDecision(machine ? StepCause.Machine : StepCause.Upload, StepAction.None, "cooldown");
// Machine starvation: shed OUR cost before the viewer's tier is touched. Costs no step and is
// not a quality change — if it does not fix the starvation, the fps ladder runs next window.
if (machine && !_shedCapture)
{
_shedCapture = true;
_qualifyingSeconds = 0;
_lastStepAt = now;
return new StepDownDecision(StepCause.Machine, StepAction.ShedCaptureCadence,
"halving capture cost before lowering quality");
}
if (StepsUsed >= StepsAllowed)
return new StepDownDecision(machine ? StepCause.Machine : StepCause.Upload, StepAction.None,
"step budget spent — holding");
var decision = machine ? StepMachine() : StepUpload(loss);
if (!decision.Acted) return decision;
if (now - _lastRaiseAt < FlapWindowSeconds) _frozen = true;
StepsUsed++;
_lastStepAt = now;
_qualifyingSeconds = 0;
return decision;
}
private StepDownDecision StepMachine()
{
if (Fps > 30)
{
Fps = 30;
return new StepDownDecision(StepCause.Machine, StepAction.LowerFrameRate,
$"frame rate 60 → 30 to give the machine its time back");
}
// The vertical tier's floor IS its only geometry (1080×1920), so it has no resolution step.
if (!_vertical && (Width > 1280 || Height > 720))
{
Width = 1280;
Height = 720;
return new StepDownDecision(StepCause.Machine, StepAction.LowerResolution,
$"resolution {Width}×{Height} — fewer pixels to composite");
}
return new StepDownDecision(StepCause.Machine, StepAction.None, "at the machine floor — holding");
}
private StepDownDecision StepUpload(double loss)
{
var reduction = Math.Max(0.5, BitrateMbps * loss);
var next = Math.Max(_floorBitrateMbps, BitrateMbps - reduction);
if (next >= BitrateMbps - 0.05)
return new StepDownDecision(StepCause.Upload, StepAction.None, "at the bitrate floor — holding");
var from = BitrateMbps;
BitrateMbps = Math.Round(next, 1);
return new StepDownDecision(StepCause.Upload, StepAction.LowerBitrate,
$"{from:0.0} → {BitrateMbps:0.0} Mbps on {loss:P0} loss");
}
private StepDownDecision TryClimb(in StepDownSample sample, double now)
{
if (!_allowClimb || _frozen) return new StepDownDecision(StepCause.None, StepAction.None, null);
if (StepsUsed == 0) return new StepDownDecision(StepCause.None, StepAction.None, null);
if (_cleanSeconds < ClimbAfterCleanSeconds) return new StepDownDecision(StepCause.None, StepAction.None, null);
if (now - _lastStepAt < CooldownSeconds) return new StepDownDecision(StepCause.None, StepAction.None, null);
var (action, caused) = RaiseOneStep();
if (!caused) return new StepDownDecision(StepCause.None, StepAction.None, null);
StepsUsed = Math.Max(0, StepsUsed - 1);
_lastRaiseAt = now;
_lastStepAt = now;
_cleanSeconds = 0;
return new StepDownDecision(StepCause.None, action, "conditions clean — one step back up");
}
private (StepAction Action, bool Changed) RaiseOneStep()
{
if (Fps < _ceilingFps)
{
Fps = _ceilingFps;
return (StepAction.RaiseFrameRate, true);
}
if (Width < _ceilingWidth || Height < _ceilingHeight)
{
Width = _ceilingWidth;
Height = _ceilingHeight;
return (StepAction.RaiseResolution, true);
}
if (BitrateMbps < _ceilingBitrateMbps - 0.05)
{
BitrateMbps = Math.Round(Math.Min(_ceilingBitrateMbps, BitrateMbps + 1.0), 1);
return (StepAction.RaiseBitrate, true);
}
return (StepAction.None, false);
}
}
+31
View File
@@ -0,0 +1,31 @@
using System.ComponentModel;
using System.Runtime.CompilerServices;
namespace ytLive.Models;
public sealed class Ticker : INotifyPropertyChanged
{
private bool _isEnabled = true;
private AlertDisplayMethod _displayMethod = AlertDisplayMethod.TickerScroll;
public bool IsEnabled
{
get => _isEnabled;
set => Set(ref _isEnabled, value);
}
public AlertDisplayMethod DisplayMethod
{
get => _displayMethod;
set => Set(ref _displayMethod, value);
}
public event PropertyChangedEventHandler? PropertyChanged;
private void Set<T>(ref T field, T value, [CallerMemberName] string? propertyName = null)
{
if (EqualityComparer<T>.Default.Equals(field, value)) return;
field = value;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
}
+56
View File
@@ -0,0 +1,56 @@
using System;
using System.Globalization;
namespace ytLive.Models;
/// <summary>
/// TASK 39·2: the three metrics that count towards YouTube Partner Program eligibility, as progress
/// towards the ad-revenue thresholds.
/// <para><b>YouTube decides.</b> This only reports what the published numbers say; it never claims
/// eligibility, and it cannot see strikes, AdSense state, 2FA or the review decision.</para>
/// <para><b>The view criteria are either/or</b> — 4,000 public watch hours <i>or</i> 10M Shorts views —
/// so "2 of 3" is reported with that qualifier rather than implying all three must pass.</para>
/// </summary>
public sealed record YppProgress(
long Subscribers,
long SubscriberTarget,
long WatchHours,
long WatchHoursTarget,
long ShortsViews,
long ShortsViewsTarget)
{
public bool SubscribersMet => Subscribers >= SubscriberTarget;
public bool WatchHoursMet => WatchHours >= WatchHoursTarget;
public bool ShortsViewsMet => ShortsViews >= ShortsViewsTarget;
/// <summary>Criteria currently met (0–3). Two of three can mean eligible: subs plus EITHER view route.</summary>
public int CriteriaMet => (SubscribersMet ? 1 : 0) + (WatchHoursMet ? 1 : 0) + (ShortsViewsMet ? 1 : 0);
/// <summary>True when the subscriber bar AND at least one view route are satisfied — the shape of the
/// published rule. Still not a qualification claim: YouTube reviews it.</summary>
public bool MeetsPublishedRule => SubscribersMet && (WatchHoursMet || ShortsViewsMet);
public double SubscribersFraction => Fraction(Subscribers, SubscriberTarget);
public double WatchHoursFraction => Fraction(WatchHours, WatchHoursTarget);
public double ShortsViewsFraction => Fraction(ShortsViews, ShortsViewsTarget);
public string SubscribersText => $"{Compact(Subscribers)} / {Compact(SubscriberTarget)}";
public string WatchHoursText => $"{Compact(WatchHours)} / {Compact(WatchHoursTarget)} h";
public string ShortsViewsText => $"{Compact(ShortsViews)} / {Compact(ShortsViewsTarget)}";
public string CriteriaMetText => CriteriaMet == 1
? "1 of 3 criteria met — YouTube reviews eligibility."
: $"{CriteriaMet} of 3 criteria met — YouTube reviews eligibility.";
private static double Fraction(long value, long target)
=> target <= 0 ? 0 : Math.Clamp((double)value / target, 0, 1);
/// <summary>1,234 → "1,234"; 12,345 → "12.3k"; 1,234,567 → "1.23M". Mono-face friendly and stable
/// in width, so the three bars do not jitter as values move.</summary>
internal static string Compact(long value) => value switch
{
>= 1_000_000 => (value / 1_000_000d).ToString("0.##", CultureInfo.InvariantCulture) + "M",
>= 10_000 => (value / 1_000d).ToString("0.#", CultureInfo.InvariantCulture) + "k",
_ => value.ToString("N0", CultureInfo.InvariantCulture),
};
}
+4
View File
@@ -13,6 +13,10 @@ public static class YppThresholds
/// <summary>The date the Tier-2 ad-revenue bars double for new applicants.</summary>
public static readonly DateTime Tier2DoublingDate = new(2027, 2, 1);
/// <summary>When these thresholds were last read from YouTube's published requirements. Shown in the
/// drawer and asserted by a test, because YouTube moves the bar and a stale bar must not look current.</summary>
public static readonly DateTime AsOfUtc = new(2026, 11, 5);
/// <summary>Fan-funding tier (before the full ad/YouTube Premium revenue tier).</summary>
public static TierThreshold Tier1 { get; } = new("Fan funding", 500, 3_000, 3_000_000);
+4 -3
View File
@@ -7,7 +7,8 @@ Plain data types. No logic beyond what a property can carry. See
|------|---------|
| `Scene.cs` | A scene: `Name`, `IsHidden`, `IsChatScene`, `IsEditing`, **`HasBackdrop`** (Live-only policy flag, default off — only the canonical Live scene has one, see `SceneCatalog`), `Elements` collection (images + webcam config), `WebcamConfig` accessor |
| `SceneCatalog.cs` | The five canonical scenes (Starting/Live/BRB/Chat/Ending) — the product, by name: `All`, `IsCanonical`, `Is(name, canonical)` (case-insensitive trim), `IsChat`, `HasBackdrop(name)` (true only for Live). Drives the empty-DB seed, the "+" missing-scenes menu, and `MainViewModel.EnforceBackdropPolicy` |
| `Source.cs` | A source: `SourceType` enum (DisplayCapture/WindowCapture/Background/Image/TextOverlay/ChatBox/Countdown/WebSource/MediaSource/**AlertBox (TASK 43 — the celebration zone, one per layout)** — **Webcam removed**), `X`/`Y`/`Width`/`Height`, `Opacity`, `IsEnabled`, `ImageSource` (static) / `VideoImageSource` (live frames), `DisplaySource` (`IsLiveCapture` → video, else static), `ClipShape` enum (Traditional/Round), `IsMirrored` + `MirrorScale`, `MirrorButtonText`, asset identity, `IsImageSource` (XAML binds this, never `Type`), **backdrop surface**: `IsBackdrop` (permanent bottom layer, never removable), `CaptureKey` (persisted `monitor:<n>`/`window:<hwnd>`/`picker:<name>`), `IsLiveCapture` (`Type` is Display/WindowCapture) |
| `Source.cs` | A source: `SourceType` enum (DisplayCapture/WindowCapture/Background/Image/TextOverlay/ChatBox/Countdown/WebSource/MediaSource/**AlertBox (one per layout)**/**Ticker (one per scene)**/Blur), `X`/`Y`/`Width`/`Height`, `Opacity`, `IsEnabled`, `ImageSource` (static) / `VideoImageSource` (live frames), `DisplaySource` (`IsLiveCapture` → video, else static), `ClipShape` enum (Traditional/Round), `IsMirrored` + `MirrorScale`, `MirrorButtonText`, asset identity, `IsImageSource` (XAML binds this, never `Type`), **TextOverlay content + font/size/color/emphasis/horizontal-alignment/vertical-alignment settings** (independent per source; static text), **backdrop surface**: `IsBackdrop` (permanent bottom layer, never removable), `CaptureKey` (persisted `monitor:<n>`/`window:<hwnd>`/`picker:<name>`), `IsLiveCapture` (`Type` is Display/WindowCapture); each source owns `Ticker` settings, with AlertBox compatibility properties persisted on that source row |
| `Ticker.cs` | Shared per-source ticker settings: `IsEnabled` and `DisplayMethod`; one independent instance for each scene ticker and the AlertBox ticker |
| `SceneElement.cs` | Base for anything placeable in a scene: shared layout/clip/mirror/border surface, `virtual IsWebcam`/`virtual IsImageSource`/`virtual IsZoomLayer` (TASK 52); `ToggleClipShape` + persisted `RectWidth`/`RectHeight` (pre-Round rect so round→rect restores after reload); **TASK 57:** the geometry setters (`X`/`Y`/`Width`/`Height`) are `virtual` so a derived element can CONSTRAIN every write (the webcam clamps itself into the vertical tier's band) instead of every writer re-implementing the rule; `ClipShape` + `ToggleClipShape()` are `virtual` too, so a derived element can CONSTRAIN the shape (the Chat scene's vertical camera is rect-only); **TASK 59:** `IsRectShape`/`IsRoundShape` — the shape as a CHOICE (the properties panel shows Rect | Circle with Rect selected by default), each a settable bool whose setter flips the shape through `ToggleClipShape()` and always re-raises, so the UI snaps back when a write is refused; **TASK 63:** `BorderColorEntry` (the palette entry for the current border colour — hex + NAME, a nameless entry for a custom hex; setting it writes the hex back) and `BorderColorOptions` (the palette + that entry), both raised when `BorderColor` changes — the drop-down binds to them |
| `VerticalCameraLayout.cs` | **TASK 57 (the "vv" feature):** the vertical (9:16) tier's camera rules as pure math — `TileWidth`/`TileHeight` (270×152 master px = 480×270 in the 1080×1920 output), `Inset` (32 master px from the band's outer edge), `IsVerticalTier(QualityOption)` (portrait tier), `AreaFor(band, bottom)` (the band splits into two 607×540 areas), `MaxSizeFor(band)`, `SpotFor(band, w, h, centreX, centreY)` (the six spots — the area and column the tile's **centre** falls in, inset, never outside the band) and `DefaultSpot` (bottom-right, the corner the 16:9 default webcam uses), `FillsArea(band, width)` (the camera is wide enough to fill a whole area = "enlarged") and `SizeFor(band, requestedWidth)` (the Chat camera's TWO sizes - the small tile or one whole 607×540 area — whichever is nearer the requested width, so the resize handle has two detents) |
| `ZoomSceneElement.cs` | **Transient zoom box (TASK 52, creator-corrected 2026-10-08):** carries the box rect AND the anchored `CaptureX`/`CaptureY` (the fixed box/2 capture window set on `ActivateZoom`; moving the box never touches it). `IsZoomActive` flips `Kind` Static→Dynamic (leaves the baked base while active); never persisted (LayoutStore save skips non-Source/non-WebcamSceneConfig rows), never shown in the layers list or the element template (`IsZoomLayer` gates both). The compositor SKIPS the active element in every layer loop and draws its 2x content LAST (layer n+1) via `ZoomMagnifier`. |
@@ -15,8 +16,8 @@ Plain data types. No logic beyond what a property can carry. See
| `BackgroundEffect.cs` | **TASK 51:** per-webcam background effect enum `None` / `Remove` / `Blur`, carried by `WebcamSceneConfig` |
| `BorderColorPalette.cs` | **TASK 60:** the colours the webcam's border-colour drop-down offers — `NamedColor(Hex, Name)` + `BorderColorPalette.All` (the single source of truth; the drop-down is bound to it) and `NameOf(hex)` (case-insensitive; **empty** for a colour outside the palette — "no name" is the honest answer, TASK 62). The creator asked for a colour NAME next to each hex: `#e94560 Crimson`. **TASK 63:** `EntryFor(hex)` (the palette entry for a hex, case-insensitively; a nameless entry for a colour outside the palette) and `OptionsFor(hex)` (the palette + that entry) — the two the drop-down binds to; `NamedColor.ToString()` is `"Crimson (#e94560)"` |
| `WebcamSceneConfig.cs` | Per-scene webcam placement (subclass of `SceneElement`): geometry + `IsVisible` + border (`BorderColor`/`BorderOpacity`/`BorderWidth`/`BorderAnimation`) + `VideoImageSource`; `WebcamId` links to `Webcam`; **TASK 51:** `BackgroundEffect` + `BackgroundStrength` (0..1, default 0.5) + the radio-pill surface `RemoveBackground`/`BlurBackground` (one on turns the other off; both off = None) + `HasBackgroundEffect` (shows the strength slider); **TASK 57 (the "vv" feature):** the vertical (9:16) tier's OWN placement per scene — `VerticalX/Y/Width/Height/VerticalClipShape/VerticalRectWidth/VerticalRectHeight` (persisted, NULL = no vertical placement yet) + the transient `VerticalBand`/`VerticalLayoutLive`/`LandscapeX…LandscapeRectHeight` (the parked 16:9 layout) + `BeginVerticalLayout`/`EndVerticalLayout`/`CommitLiveLayoutToVertical` (the tier swap) and the `X`/`Y`/`Width`/`Height` overrides that clamp every write into the band; `VerticalShapeLocked` (the Chat scene's vertical layout is rect-only) **overrides** `ClipShape` + `ToggleClipShape()` so no writer can slip a circle in |
| `QualityOption.cs` | Resolution tier record `(Display, Fps, Bitrate, Width, Height)`; `Label` formats as `1080p60 (8Mbps)` — drives the bottom-bar dropdown and the output-rect math |
| `StreamConfig.cs` | `StreamConfig` (note: `TargetBitrate`/`Resolution` defaults are stale — not yet wired to the dropdown), `StreamHealth` (bitrate/FPS/dropped/duration), `StreamStatus` enum |
| `QualityOption.cs` | Resolution tier record `(Display, Fps, Bitrate, Width, Height)`; `Label` formats as `1080p60 (8Mbps)`, `RequiresPro` derives from `OutputProfile` (current code uses pixel/FPS; the FPS-only policy correction is queued in `commercial.md`) — drives the quality dropdown and output-rect math |
| `StreamConfig.cs` | Legacy `StreamConfig` (no production call sites; defaults do not drive output), `StreamHealth` (bitrate/FPS/dropped/duration), `StreamStatus` enum; encoder output comes from the selected quality tier |
| `MicStatus.cs` | Footer mic indicator: `NotConnected` / `Connected` / `Problem` — the state behind the MIC button's status dot (red = no device/capture off, green = capturing, yellow = a requested mic failed: in use/unplugged) |
| `YouTube.cs` | `YouTubeChannel` and `ChatMessage` (chat feed) — since TASK 43 every message carries `Kind` (`ChatEventKind` None/SuperChat/SuperSticker/NewMember/MemberGift/GiftReceived/MemberMilestone) + event payload fields (`SuperChatDisplayString`, `SuperStickerDescription`, `GiftNewMembersCount`, `GifterName`, `MilestoneMonths`) |
| `Socials.cs` | **Social bar** (global layer, never a Source): `SocialService` enum (YouTube/Twitch/X/Instagram/TikTok/Facebook/Discord/Kick/Threads/Bluesky/GitHub/LinkedIn/Pinterest/Snapchat/Reddit/WhatsApp/Telegram/Link/Website/**Fediverse**), `SocialEntry` (Service/Handle/ProfileUrl/`FediverseSoftware`), `SocialsConfig` (Entries + `BarPosition` Top/Bottom + `BarEnabled` on/off — `BarJustify` dropped, column back-compat), `SocialServiceIcons` (canonical URL builder + `DetectService` (URL domain / fediverse `@user@domain` → **Fediverse** / bare→Website) + bundled SVG logo path data per service (`LogoDataFor`) + `LogoDataForFediverse(software)` mapping nodeinfo software names (mastodon/peertube/pixelfed/misskey/lemmy/pleroma/firefish) to logos with a generic fediverse glyph fallback (`FediverseIconData`) + `LockedIconData`/`DoNotIconData` — initials/colors gone) |
+313
View File
@@ -1,3 +1,23 @@
## 2026-10-30 — C# record struct: `new T()` ignores the parameter defaults
**Symptom:** two property controls "had no code behind them" — the Background checkbox and the
opacity slider changed nothing visible.
**Cause:** `public readonly record struct ScoreboardStyle(string TextColor = "#FFFFFF", bool BackgroundEnabled = true, …)`.
`new ScoreboardStyle()` binds to the implicit **parameterless constructor**, which zero-initialises —
the defaults in the parameter list are only used when the constructor is called with those arguments
omitted *at a call site that supplies some of them*. Result: null colour, `BackgroundEnabled = false`,
`FontOpacity = 0`, `FontSize = 0`. The renderer drew the zeros faithfully, so the UI looked dead
while every unit test of the renderer passed (they constructed the style explicitly).
**Rule:** a record struct with meaningful defaults needs an explicit parameterless constructor
(`public T() : this(<defaults>) { }`). And a "defaults" test must construct the type the same way
production does — with `new()` — never with explicit arguments, or it proves nothing.
**Guard:** `ScoreboardRendererTests` asserts `new ScoreboardStyle()` yields white / on / black / 0.20 /
max 200.
# MyMistakes.md
> **Two jobs**, distinguished by heading:
@@ -15,6 +35,267 @@
## 🔬 Recipes registry
### A SETTING THAT "DIDN'T STICK" — CHECK THE DEBOUNCE FIRST (RECIPE, 2026-11-12)
**Symptom:** a value is set in the UI, behaves correctly for one session, and is gone after a
restart. It looks like a wiring bug in the control; it is almost always `ScheduleSave()`.
**Cause:** `MainViewModel.ScheduleSave()` restarts a debounce timer that later calls `Save()`, which
writes the whole layout. Anything changed in the last few seconds before the app closes is lost —
and none of it is logged, so the next session reads a value the creator never chose.
**Quick fix:** for anything small and decisive (a sync offset, a toggle the creator just clicked),
call the `_layoutStore.Save…()` method **in the setter itself**, before `ScheduleSave()`. Two rows
cost nothing; a lost setting costs a whole reproduce-and-test cycle. Live example:
`MainViewModel.Audio.cs` → `AudioSyncOffsetMs` / `MicSyncOffsetMs`.
**Check list when a remembered setting misbehaves:** grep the property for `ScheduleSave`, confirm a
direct save exists, then reproduce with a *restart* (not just a new session) before debugging deeper.
**Bit us:** the lip-sync offset. `Audio.SyncOffsetMs` was set to −130, lost on close, and two clips
were measured against a setting that was not in force — the numbers disagreed by ~65 ms and the
disagreement was blamed on the audio path.
### A SYNC OFFSET IS NEVER A PRODUCT DEFAULT (2026-11-12)
**Mistake:** `MicSyncOffsetMs` shipped at **175 ms** because that suited one C920 on one desk. It
shifted every creator's voice to correct one machine and hid whether a desync was the app's fault.
**Rule:** offsets ship at 0 and are opt-in. `AudioSyncSettingsTests` pins it. And the tool that
measures them, `scripts/avsync.py`, varies **±35 ms between clips** on identical settings — it can
answer "is this 100 ms out?" but never "is this 20 ms out?". Below ~50 ms the creator's judgement is
the better instrument, so stop tuning rather than chasing the tool's noise.
### A LOOSE FILE IN `Assets/` IS NOT AN ASSET — THE CSProj LIST IS (RECIPE, 2026-11-04)
**Problem:** four untracked `Assets/llamacasty-*` exports sat in the tree for days, reported in
`HANDOFF.md` as "untouched", and every session spent tokens re-litigating whether to commit them.
**What it actually is:** shipped art is embedded in the assembly via **`<Resource Include="Assets\…"/>`**
in `ytLive.csproj` (icons, logo, backgrounds, fonts) or `<EmbeddedResource>` (the ONNX model), and the
Store tiles in `Packaging/Assets/` are generated from `Assets/llamacasty-logo.jpg` by
`scripts/make-store-logos.py`. The app reads art through `pack://`/`/LlamaCasty;component/Assets/…`
URIs — it never loads a file from disk at runtime. So a loose `.jpg`/`.png` in `Assets/` that appears in
**neither** the csproj nor any `Source=`/`Icon=`/script is a **source export**, not a dependency.
**The recipe (30 seconds, do it before mentioning the file):**
1. `git status --short` → the untracked list.
2. For each file: `grep -rIn -F "<name>" --exclude-dir=.git --exclude-dir=bin --exclude-dir=obj .`
plus `grep -n 'Assets' ytLive.csproj`. **No hit in either = dead.** (Check `scripts/` too — a
generator can be the only consumer.)
3. Referenced → add it to `<Resource>` **and** commit it in the same unit. Unreferenced → `rm` it and
commit the deletion; do not park it in `HANDOFF.md`.
**Deleted 2026-11-04 by this rule:** `llamacasty-icon.jpg`, `llamacasty-logo-white.jpg`,
`llamacasty-logo-white-nobg.png`, `llamacasty-logo-white-nobg-dim.png` — the shipped brand art is
`llamacasty-icon.ico`/`.png` and `llamacasty-logo.jpg`, all three already tracked and embedded.
### DEPLOYING llamacasty.com TO THE DROPLET (RECIPE, 2026-10-30)
**Problem:** "upload the site" burned a session: `ssh -p 2214 gramps@llamachile.tube` hung on a
prompt, then answered `Permission denied (publickey)` everywhere, and the two md files that document
the deploy only said *"copy this working tree into `/var/www/linkstack/`"* — a destination, not a
transport.
**What it actually is:** the static site lives in **`/var/www/linkstack/`** on the YunoHost droplet
(`llamachile.tube` = `llamacasty.com` = `143.244.176.131`), owned `linkstack:www-data`. The host
sshd is **port 2214** with **key-only auth** (`PasswordAuthentication no`; publickey is the only
method offered for every user). The `CREDENTIALS.md` password is the **sudo** password — it is not
an SSH auth method and never was.
**The hang was the second factor:** sshd had `AuthenticationMethods publickey,keyboard-interactive`
(PAM `pam_google_authenticator`), so a key got through and then the server asked for a verification
code that a non-interactive tool call cannot answer. Disabled permanently 2026-10-30 by commenting
that line and the PAM module in `/etc/pam.d/sshd`, then `sshd -t && systemctl restart ssh`.
**The recipe:** `llamacasty.web/deploy.sh` — tars the **public** files only (`index.html`,
`privacy.html`, `css/`, `js/`, `assets/`), `scp`s the tarball to `/tmp`, then one `sudo -S` shell
that backs up the live root to `/var/backups/linkstack-<stamp>.tgz`, extracts over it, restores
`linkstack:www-data` + `755/644`, deletes `build-this.md` from the root, and curls the site.
Password comes in on **stdin** (`SITE_SUDO_PW=... ./deploy.sh`), never on a command line.
- ⚠️ **Never publish the md files.** `build-this.md` shipped in the web root until 2026-10-30 and
now carries **INTERNAL** pricing notes. The deploy script's file list is explicit for that reason.
- ⚠️ **drvfs sets 777 on copied files** — the chmod pass in the script is not cosmetic; a world-
writable web root is a defacement waiting to happen.
- ⚠️ **`/var/www/linkstack__2` is a different app's directory** (the old LinkStack PHP install).
The static site is `linkstack`, without the suffix.
- ⚠️ Ports **22 and 1319 are closed**; 2214 is the only SSH door, and it is the *host*, not Gitea
(Gitea pushes go over **HTTPS** with the credential helper — see `docs/ai/environment.md`).
### POINTING www AT THE APEX ON THE YUNOHOST DROPLET (RECIPE, 2026-10-31)
**Problem:** `www.llamacasty.com` did not resolve at all, and once a record existed the server half
was four separate traps: YunoHost added the vhost with a **self-signed** cert, refused to issue
Let's Encrypt without a diagnosis, and served the **SSO login** because the domain has no app.
**1. DNS** — Namecheap BasicDNS, zone `llamacasty.com`. `www CNAME llamacasty.com.` (YunoHost's own
subdomain example uses a CNAME per subdomain, and a CNAME follows the apex if the droplet IP moves;
an `A www` record would need a second edit for every rebuild). The apex `A` is the only IP holder.
**2. Domain + cert on the droplet:**
```bash
yunohost domain add www.llamacasty.com --install-letsencrypt-cert # vhost + SELF-SIGNED cert
yunohost domain cert install www.llamacasty.com --no-checks # real LE cert
```
- ⚠️ The `--install-letsencrypt-cert` flag **does nothing here**: *"Skipping Let's Encrypt certificate
attempt because there's no wildcard configured on the parent domain's DNS records."* It exits
SUCCESS with a self-signed cert, so check `openssl s_client -servername <host>` afterwards.
- ⚠️ Plain `yunohost domain cert install` refuses with *"no diagnosis result for domain … use
`--no-checks`"*. `--no-checks` is safe when the name resolves publicly — LE's HTTP-01 validation
is itself the DNS proof (it failed over to a real cert on the first attempt).
**3. The redirect** — with no app installed the vhost 302s to `/yunohost/sso?r=…`, so a `301` is
needed. YunoHost includes `/etc/nginx/conf.d/<domain>.d/*.conf` **inside the server{} block**;
`location / { return 301 https://llamacasty.com$request_uri; }` there wins over SSO because `return`
runs in the rewrite phase, before `access_by_lua_file` (the global SSO guard). The `.d` include
survives YunoHost regenerating the `.conf`; never edit the `.conf` itself.
- ⚠️ **The heredoc ate `$request_uri`.** Writing that file through `ssh "sudo bash -c 'cat > … <<EOF'"`
expands nginx's variable to empty and silently produces `return 301 https://llamacasty.com;` —
every path redirects to the homepage. Write the file **locally** and `scp` + `install -m 644` it.
(`printf "---x---"` in the same command also dies on `--`: *invalid option*.)
- ⚠️ **Two resolvers lie about this name.** The workstation's resolver caches the old NXDOMAIN, and
on the droplet `getent hosts www.llamacasty.com` returns **`::1`** because YunoHost's dnsmasq
answers for its own vhosts. Neither is a DNS test — use a public DoH query or
`curl --resolve www.llamacasty.com:443:<ip>`.
**Verify:** `curl --resolve www.llamacasty.com:443:143.244.176.131 -o /dev/null -w '%{http_code}
%{redirect_url}' https://www.llamacasty.com/privacy.html` → `301 https://llamacasty.com/privacy.html`;
the cert must read `CN=www.llamacasty.com`, issuer Let's Encrypt.
### LICENSING WITHOUT A SERVER — POLAR'S CUSTOMER-PORTAL API (RECIPE, 2026-10-31)
**Problem:** the plan carried a "claim page" — a hosted OAuth flow that wrote the buyer's channel ID
onto the licence key — plus an implied server to hold keys. Both were invented from an assumption
that binding needs a browser.
**It does not.** Polar's customer-portal endpoints authenticate on the **key itself** plus the
`organization_id` (a public identifier, safe in the binary). The **org access token is only needed
to create products** and must never ship.
```bash
# activate (label is required; conditions are re-checked on every validate)
POST https://api.polar.sh/v1/customer-portal/license-keys/activate
{ "key": "LCYT-…", "organization_id": "c05fb364-…", "label": "DESKTOP-NAME",
"conditions": { "channel_id": "UC…" } }
# validate on launch + daily, with the SAME conditions object
POST https://api.polar.sh/v1/customer-portal/license-keys/validate
{ "key": "LCYT-…", "organization_id": "c05fb364-…", "activation_id": "…",
"conditions": { "channel_id": "UC…" } }
```
- `limit_activations` on the benefit = the device cap (2). Polar's **customer portal** already does
deactivation, key rotation and "where is my key", so build no UI for it.
- A **subscription key is auto-revoked when the subscription ends** — that is what makes lapse a
real state; check `status`, not `expires_at`.
- ⚠️ `conditions` is **agreement, not cryptography**: a patched client can send someone else's ID.
Accepted — the free tier is the defence, not the check.
- ⚠️ There is **no push to a desktop app**: delivery is email or the customer portal. Auto-delivery
needs a webhook plus an endpoint *you* host, holding keys. Not worth it for one copy-paste.
### YOUTUBE "VERIFIED" — THREE DIFFERENT THINGS (2026-10-31)
`youtube.com/verify` saying "your account is verified" answers only the first tier. The tiers live
in Studio → Settings → Channel → **Feature eligibility**:
| Tier | Requires | Gives |
|---|---|---|
| Standard | nothing | uploads, playlists, posts (low daily limits) |
| **Intermediate** | phone verification | >15 min videos, **live streaming on a computer**, custom thumbnails |
| Advanced | **channel history** or ID/video verification | higher daily limits, clickable description links, chapters, pin comments, monetization application |
- **Live streaming is intermediate, not advanced** — a verified channel can stream; the API's
"live streaming not enabled" error means the *user's* channel is unverified, so the app must map
it to "verify at youtube.com/verify".
- **2 channels per phone number per year**, 365-day reset; VoIP/Google Voice numbers are refused.
That is the "used too many times" wall, and there is no appeal.
- Verification is **sticky** — it survives the phone being removed, which is why an old account can
read "verified" while no number on it will pass today.
- Advanced via **ID/video verification** is a privacy trade with a 24 h review; channel history is
free and usually sufficient within ~2 months of normal activity. Prefer history.
- ⚠️ Only the **primary owner** can do ID/video verification, and brand-account managers hit
*"Advanced YouTube features aren't available for this account"*. If ID verification is ever
wanted on a personal channel, do it **before** converting to a brand account.
### THE `const` THAT MAKES A GUARD UNREACHABLE (2026-11-01)
A dev-build flag declared `const bool` turns `if (!DevBuild.IsDevelopment) return;` into a
compile-time branch: Debug reports **CS0162 unreachable** on the guard, Release reports it on the
body — and the repo builds at 0 warnings, so either config fails the gate.
**Fix:** `public static readonly bool IsDevelopment =` … set once from `#if DEBUG`. Both arms
compile clean in both configurations, and the JIT still folds the branch. The *real* guarantee for
"this must not ship" is not the flag anyway — it is removing the UI's `Page`/`Compile` items from
the Release build (`<ItemGroup Condition="'$(Configuration)' != 'Debug'">`).
⚠️ A Condition on the **ItemGroup** is invisible to a test that reads `item.Attribute("Condition")`.
Read the parent too — that mistake cost one failing test here.
### THE SINGLE-FILE PUBLISHER EATS ffmpeg (2026-11-01)
**Problem:** `dotnet publish` with `PublishSingleFile=true` +
`IncludeNativeLibrariesForSelfExtract=true` produced a **9-file** output with **no `ffmpeg\`
folder** and a **182 MB exe** — the libav\*.dll family had been bundled *inside* the binary. The
build was green, the tests passed, and the app would have shipped unable to find ffmpeg at
runtime (the locator looks for `ffmpeg\ffmpeg.exe` beside the exe).
**Diagnosis that worked:** diff the publish output against a **control publish with the
single-file flags removed** — ffmpeg was there. Then add flags back one at a time;
`IncludeNativeLibrariesForSelfExtract` was the one that broke it.
**Fix:** mark the content `ExcludeFromSingleFile="true"`.
```xml
<None Include="ffmpeg\**" CopyToOutputDirectory="PreserveNewest"
CopyToPublishDirectory="Always" ExcludeFromSingleFile="true" />
```
After: 127 MB exe, `ffmpeg\` present, depot ≈ 257 MB total.
- ⚠️ **The `<None Remove="ffmpeg\**" />` before it is not the fix** — it silences a duplicate-item
warning from the SDK's default glob, and I tried it first and wrongly believed it had worked.
Two failed remedies in a row is the research trigger: read the publisher's own metadata
(`ExcludeFromSingleFile`) instead of guessing at item globs.
- ⚠️ **A clean build and green tests cannot see this.** Only the artifact can, which is why
`scripts/publish.sh` fails loudly when `ffmpeg\`, `LICENSE`, `PRIVACY.md` or
`THIRD-PARTY-NOTICES.txt` are missing, and when test assemblies leak in.
### STEAM DISTRIBUTION, COMMERCIAL USE, AND THE DEMO OWNERSHIP CHECK (2026-11-02)
**Why recorded:** the Steam Subscriber Agreement's private-use wording was misread as a blocker
for monetized streaming. Check the exception and the established Steam software precedent before
raising it again.
- **Steam installs skip SmartScreen entirely** — Valve's client delivers the files, so there is no
browser download, no Mark-of-the-Web and no publisher warning. An unsigned exe is *fine* in this
channel, and a certificate is only needed if a non-Steam download ever exists.
- **Steam updates the app** — no self-updater, no version banner, no update endpoint.
- **⛔ In-app purchases must use the Steam Wallet.** Steamworks, *Microtransactions (In-Game
Purchases)*: *"For any in-game purchases, you'll need to use the microtransaction API so Steam
customers can only make purchases from the Steam Wallet."* LlamaCasty has no in-app checkout.
- **The private-use wording is not, by itself, a commercial-use ban for LlamaCasty.** The Steam
Subscriber Agreement §2.A permits commercial use when the applicable product terms expressly
allow it; §5 has general private/personal-use wording for third-party software. OBS Studio is
distributed on Steam as video-production software under GPL, and OBS confirms the Steam build is
the same as its website build. GPL permits commercial use. LlamaCasty's EULA should expressly
allow commercial and monetized streaming; confirm the wording with Valve or counsel. Do not call
this a Steam blocker based only on §5.
Sources: https://store.steampowered.com/subscriber_agreement/ ·
https://obsproject.com/kb/steam-faq · https://www.gnu.org/licenses/gpl-faq.html
- **Demo ownership check:** the free Demo uses its own App ID; `ISteamApps::BIsSubscribedApp`
checks ownership of the paid app. The paid App ID must be configured; zero/missing ID fails closed.
Valve reference: https://partner.steamgames.com/doc/api/ISteamApps#BIsSubscribedApp
- **Steam sells one paid app and distributes its associated free Demo.** Same code; no GitHub
binary. The demo links to the paid store page and can send a one-time wishlist notification.
- **Renaming a live exe is painful** (Steamworks → *Tip – Renaming a live exe*). Do the
`LlamaCasty.exe` rename **before** the first depot upload, not after.
- **The paid app's Steam store page must be public two weeks before release.** The Steamworks
application and $100 fee are already submitted; Valve's decision is pending.
### CONVERTING A MEDIAPIPE .tflite (WITH CUSTOM OPS) TO ONNX — AND PROVING IT (RECIPE, TASK 51)
**Problem:** MediaPipe segmentation models end in the custom op `Convolution2DTransposeBias`;
@@ -339,6 +620,17 @@ packages, high-quality downscale via `TransformedBitmap`. Screenshots compress
**Result:** 3.2MB screenshot → 1400×794 → **188KB** `ytLlive-preview.jpg` in `docs/`.
**Faster route (2026-11-09): the bundled ffmpeg does the same job in one command** — no throwaway
project, and it is already the tool used for every other asset:
```bash
./ffmpeg/ffmpeg.exe -hide_banner -loglevel error -y -i "C:\path\shot.png" \
-vf "scale=1400:-2:flags=lanczos" -q:v 4 "C:\...\docs\ytLlive-preview.jpg"
```
`-q:v 4` is the sweet spot for UI screenshots (text stays crisp); q3 runs ~340 KB, q6 ~230 KB.
The WPF recipe above stays as the fallback if ffmpeg is unavailable.
---
### Measuring audio-video A/V sync from a recording (no ears needed) — RECIPE
@@ -775,6 +1067,22 @@ with `#Name`) + panel properties (`IsHitTestVisible/Opacity/children/actual size
`camera 'test-camera' failed` are test noise, not DB state — to check pollution, query
the DB directly (`python3 sqlite3`, `SELECT DeviceId FROM Webcam`), not the log.
## Deterministic test for a selected layer above an overlap (2026-10-28)
When preview rendering uses WPF z-order but dragging uses an additional custom hit-test, test both
contracts without injecting a physical cursor: in a real `MainWindow` on the `RealApp` STA, overlap
two scene elements, select the lower one through the layer list, assert `Panel.GetZIndex` raises its
container, assert `window.InputHitTest(point)` resolves the selected visual, and call the same
production hit-test method used by the drag handler to assert it chooses that selected model. Clear
selection and assert both hit paths return the original top layer and the scene's element order is
unchanged. WPF documents that input hit testing returns the input element at a point and that visual
hit-test results follow z-order: <https://learn.microsoft.com/en-us/dotnet/desktop/wpf/graphics-multimedia/hit-testing-in-the-visual-layer>.
Do not use `SetCursorPos`/`mouse_event` for this specific assertion: the test passed alone but was
intermittent in the full suite, with a cursor move landing at the canvas edge (`X=1900`) rather than
the intended offset. Physical injection remains appropriate only when the behavior under test is the
actual OS mouse-routing/drag path (as in the layer-list reorder test).
## A "known failure" label without a recorded cause = a bug on life support
(2026-09-01, the audio triple-take) One line — `_delayedMix` (nullable, added by TASK 22,
@@ -1465,6 +1773,11 @@ afterward** — and I got it wrong in the very commit written to apply that idea
## Asked twice about a deliberate, already-documented decision (2026-10-04)
**Superseded 2026-11-04:** the creator clarified that forcing Private is a development/test safeguard,
not a permanent product lock. Release go-live must let the creator choose Private/Unlisted/Public,
default to Private, and allow live changes. Do not repeat the former “always Private until v1” rule
as current policy; keep development and test runs Private.
**What happened:** the creator asked whether the test streams still upload private, and added "I
don't think you're able to delete them". I answered, researched `videos.delete`, and then — in the
very next exchange — *offered to build a cleanup tool*, which re-litigated a decision that has been
+57
View File
@@ -0,0 +1,57 @@
# Privacy Policy — LlamaCasty
Last updated: 30 October 2026
LlamaCasty is a desktop application for streaming to YouTube. It runs entirely
on your computer. This page states exactly what leaves your machine, what is
kept locally, and what is never collected.
## What is stored on your computer
| Data | Where | Why |
|---|---|---|
| Your layout: scenes, sources, assets, settings | `%APPDATA%\ytLlive\ytLlive.db` | So the app looks the same next time |
| YouTube sign-in token | `%APPDATA%\ytLlive\ytLlive.auth`, encrypted with Windows DPAPI (CurrentUser) | So you stay signed in; only your Windows account can decrypt it |
| Diagnostic log | `%APPDATA%\ytLlive\startup.log`, capped at 5 MB | To diagnose crashes you report |
| Recordings | the folder you choose | Your files; the app never uploads them |
| Replay clips (if you use them) | `clips\` beside your recordings | Only the three most recent are kept; older ones are deleted automatically. Local only, never uploaded |
Nothing else is written to disk. There is no analytics service, no crash
reporter, no advertising SDK and no telemetry of any kind.
## What is sent, and to whom
**Google / YouTube** — only when you sign in and go live, using your own
account:
- your channel information, live broadcasts, streams and chat, to run the
stream and show the chat overlay;
- your watch-time and subscriber statistics, to draw the YPP progress and
scoreboard tiles;
- your recording, only if you press Publish.
**Nowhere else.** The app talks to `googleapis.com` and the YouTube ingest
servers. It does not contact the publisher's servers, and no content of yours
is stored on them.
## What is never done
- **Chat is rendered, never stored.** Live chat is drawn on your stream and
held in memory; it is not written to disk, not uploaded, and not analysed.
- No viewer, member or donor information is retained after the session ends.
- No microphone, camera or screen content is transmitted anywhere except the
YouTube stream you start yourself.
## Your controls
Deleting `%APPDATA%\ytLlive` removes every local trace: layout, assets, logs
and the saved sign-in. Signing out inside the app deletes the saved token.
## Children
The app requires a YouTube account, and YouTube requires users to be 13 or
older. The app is not directed at children and collects nothing from anyone.
## Changes and contact
Material changes will be dated in this file. Questions:
llamachile.shop@gmail.com · https://llamacasty.com
+1 -1
View File
@@ -50,7 +50,7 @@
<Applications>
<Application
Id="LlamaCasty"
Executable="ytLive.exe"
Executable="LlamaCasty.exe"
EntryPoint="Windows.FullTrustApplication"
uap10:TrustLevel="mediumIL"
uap10:RuntimeBehavior="packagedClassicApp">
+21 -19
View File
@@ -36,13 +36,18 @@ setup — they need one that's ready for *theirs*. So I built LlamaCasty around
The general-purpose tools are the right choice when you need everything. LlamaCasty is for when you
just want to get your show on air.
## Free, and staying that way
## Free, and the paid edition
LlamaCasty is free forever: no watermark, no branding on your stream, no subscription, no
LlamaCasty account. It's also open source (see [License](#license)) — enjoy it, explore it,
experiment with it. If it earns a spot in your setup and you'd like to say thanks, there's a
coffee jar and a merch store linked from https://llamacasty.com. Completely optional, never
expected, and the app will never ask.
The free version is the whole app, not a trial: every feature, every scene, every overlay, no
watermark, no LlamaCasty account, and nothing in your stream advertising us. It stays free.
A paid edition exists for creators who need more output quality — free streams and records at up to
720p30, and Pro unlocks the higher tiers. **No feature is ever gated behind it.** The app shows the
current price at the point of sale; the website deliberately does not.
If it earns a spot in your setup and you'd like to say thanks, there's a coffee jar and a merch
store linked from https://llamacasty.com. Completely optional, never expected, and the app will
never ask.
## How it's built
@@ -96,7 +101,7 @@ What v1 ships: the five-scene control surface, webcam + screen capture, media +
sources, chat on stream, the social bar, mic/desktop audio with auto-ducking and TRAX background
music, local recording, quality tiers including vertical 9:16, one-click go-live + scheduling,
stream resilience, monetization awareness (YPP tracker), animated alerts, and webcam background
removal and blur — all free and ungated.
removal and blur — every feature in the free tier, with a paid edition that buys output quality.
## Structure
@@ -117,18 +122,15 @@ queue; `HANDOFF.md` is the current state. Each code directory carries an `index.
## License
LlamaCasty is free software, released under the **GNU General Public License v3.0 or later**
([`LICENSE`](LICENSE)) with three additional terms permitted by GPL-3.0 section 7
([`ADDITIONAL-TERMS.md`](ADDITIONAL-TERMS.md)):
LlamaCasty is **proprietary commercial software** — see [`LICENSE`](LICENSE) for the full End User
License Agreement. The free tier streams **and records** at an output profile of up to **921,600
pixels at 30 fps** (720p30 landscape, 720×1280 vertical); a paid licence unlocks every shipped tier,
including 1080p60. Prices, entitlement and channels: `docs/ai/commercial.md`.
- **Keep the credit** — any copy, modified or not, shows "Based on LlamaCasty by Gramps —
https://llamacasty.com" in its About screen.
- **Rename your fork** — modified versions are marked as modified and use a different name.
- **No trademark rights** — the LlamaCasty name and llama logo aren't licensed. The icon and logo
files are all rights reserved and aren't covered by the GPL.
Earlier development builds were published under GPL-3.0 and remain covered by that licence for the
versions that shipped under it; nothing here revokes that.
The scene backgrounds and default alert video are AI-generated placeholders, licensed with the
code — replace them with your own. Bundled third-party components keep their own licenses: see
[`THIRD-PARTY-NOTICES.txt`](THIRD-PARTY-NOTICES.txt).
Third-party components and their licences are listed in
[`THIRD-PARTY-NOTICES.txt`](THIRD-PARTY-NOTICES.txt), which ships with the app and is shown in its
About screen. Your data and privacy are described in [`PRIVACY.md`](PRIVACY.md).
Source code: https://llgit.llamachile.tube/gramps/LlamaCasty
+5 -5
View File
@@ -1,10 +1,10 @@
<Window x:Class="ytLive.RenameRecordingDialog"
<Window x:Class="ytLive.RenameRecordingDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Rename Recording" Height="304" Width="460"
Icon="/ytLive;component/Assets/llamacasty-icon.png"
Icon="/LlamaCasty;component/Assets/llamacasty-icon.png"
WindowStartupLocation="CenterOwner" ResizeMode="NoResize"
ShowInTaskbar="False" Background="#1a1a2e">
ShowInTaskbar="False" Background="{StaticResource Brush.Background}">
<Grid Margin="24">
<Grid.RowDefinitions>
@@ -16,9 +16,9 @@
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Text="Rename Recording" FontSize="18" FontWeight="Bold"
Foreground="#e0e0e0" Margin="0,0,0,12"/>
Foreground="{StaticResource Brush.OnSurface}" Margin="0,0,0,12"/>
<TextBlock Grid.Row="1" TextWrapping="Wrap" Foreground="#a0a0b0" FontSize="13"
<TextBlock Grid.Row="1" TextWrapping="Wrap" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="13"
Margin="0,0,0,16">
The recording is saved with an auto-generated name. Give it a clearer name —
leave it empty to keep the auto name.
+8 -8
View File
@@ -1,10 +1,10 @@
<Window x:Class="ytLive.ReuseImageDialog"
<Window x:Class="ytLive.ReuseImageDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Add Image" Height="380" Width="460"
Icon="/Assets/llamacasty-icon.png"
WindowStartupLocation="CenterOwner" ResizeMode="NoResize"
ShowInTaskbar="False" Background="#1a1a2e">
ShowInTaskbar="False" Background="{StaticResource Brush.Background}">
<Window.Resources>
<ResourceDictionary>
@@ -20,10 +20,10 @@
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="#1c2a52"/>
<Setter TargetName="Bd" Property="Background" Value="{StaticResource Brush.SurfaceContainerHigh}"/>
</Trigger>
<Trigger Property="IsSelected" Value="True">
<Setter TargetName="Bd" Property="Background" Value="#2a2450"/>
<Setter TargetName="Bd" Property="Background" Value="{StaticResource Brush.SurfaceContainerHigh}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
@@ -42,10 +42,10 @@
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Text="Add Image" FontSize="18" FontWeight="Bold"
Foreground="#e0e0e0" Margin="0,0,0,14"/>
Foreground="{StaticResource Brush.OnSurface}" Margin="0,0,0,14"/>
<TextBlock Grid.Row="1" Text="Re-use an image that's already in your stream, or add a new one?"
Foreground="#a0a0b0" FontSize="13" TextWrapping="Wrap" Margin="0,0,0,12"/>
Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="13" TextWrapping="Wrap" Margin="0,0,0,12"/>
<ListBox Grid.Row="2" Background="Transparent" BorderThickness="0"
ItemContainerStyle="{StaticResource CandidateItem}"
@@ -59,11 +59,11 @@
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Border Background="#0f3460" CornerRadius="3" Width="48" Height="27" ClipToBounds="True">
<Border Background="{StaticResource Brush.SurfaceContainerHigh}" CornerRadius="3" Width="48" Height="27" ClipToBounds="True">
<Image Source="{Binding Thumbnail}" Stretch="Uniform" Margin="1"/>
</Border>
<TextBlock Grid.Column="1" Text="{Binding Header}" Foreground="#e0e0e0" FontSize="13"
<TextBlock Grid.Column="1" Text="{Binding Header}" Foreground="{StaticResource Brush.OnSurface}" FontSize="13"
VerticalAlignment="Center" Margin="10,0,0,0"
TextTrimming="CharacterEllipsis"/>
</Grid>
+60
View File
@@ -0,0 +1,60 @@
using System;
using System.IO;
namespace ytLive.Services;
/// <summary>
/// TASK 55·2: the default alert clip is materialised into <c>%TEMP%</c> so ffmpeg can read it as a file.
/// Nothing used to delete it, so every profile that ever played an alert left an orphan behind.
///
/// <para>Two touches, deliberately simple: delete this session's file on close, and sweep anything the
/// prefix matches at startup (a crash leaves the same debris a clean exit does not). Only files this app
/// created are touched — the prefix is the contract, and the sweep never deletes a directory.</para>
/// </summary>
public static class AlertClipCleanup
{
public const string Prefix = "ytLive-alert-";
/// <summary>Deletes stale alert clips left in <paramref name="directory"/>. Returns how many went.
/// Missing directory is not an error.</summary>
public static int Sweep(string directory)
{
try
{
if (!Directory.Exists(directory)) return 0;
var removed = 0;
foreach (var path in Directory.EnumerateFiles(directory, Prefix + "*.mp4"))
{
if (TryDelete(path)) removed++;
}
return removed;
}
catch (Exception ex)
{
Helpers.AppLog.Write(ex, "Alert clip sweep failed");
return 0;
}
}
/// <summary>Deletes one clip if it exists. Safe on null/blank and on a locked file.</summary>
public static bool Delete(string? path)
{
if (string.IsNullOrWhiteSpace(path)) return false;
return TryDelete(path);
}
private static bool TryDelete(string path)
{
try
{
if (!File.Exists(path)) return false;
File.Delete(path);
return true;
}
catch (Exception ex)
{
Helpers.AppLog.Write(ex, $"Alert clip delete failed: {path}");
return false;
}
}
}
+20 -178
View File
@@ -1,6 +1,5 @@
using System.Diagnostics;
using System.IO;
using ytLive.Helpers;
using System;
using System.Threading;
using ytLive.Services.Audio;
using ytLive.Services.Encoder;
@@ -11,10 +10,10 @@ namespace ytLive.Services;
/// same file, both paced to real time. The alert box owns a decoder per event
/// (start at play, dispose at drain) — unlike MediaSource's app-wide refcounted
/// session, an alert REPLAYS the same clip each event, so a fresh decode per
/// play is required. Video is rawvideo BGRA scaled to the box rect (the
/// <see cref="MediaVideoSource"/> shape); audio is ffmpeg <c>f32le</c> at the
/// mixer's native 48 kHz stereo (so the mixer ring needs no resampler). The
/// fade is applied by the alert layer, not here.
/// play is required. The fade is applied by the alert layer, not here.
/// <para>Since TASK 21's audio slice this is a thin wrapper over the shared
/// <see cref="VideoClipPipe"/> (the same pipe a media layer uses); the alert case
/// simply never sets <c>Looping</c>.</para>
/// </summary>
public interface IAlertClipDecoder : IDisposable
{
@@ -35,30 +34,14 @@ public interface IAlertClipDecoder : IDisposable
event Action? Completed;
}
/// <summary>Default <see cref="IAlertClipDecoder"/>: two ffmpeg subprocesses on
/// the same file. Video via <see cref="RawVideoFrameReader"/> on a <c>rawvideo
/// bgra</c> pipe (-an), audio via an <c>f32le -ac 2 -ar 48000</c> pipe (-vn).
/// The seams (locator, process factory, frame-rate probe, delay) let tests drive
/// a fake decoder without a real codec or Windows.</summary>
/// <summary>Default <see cref="IAlertClipDecoder"/>: the shared clip pipe with looping off.</summary>
public sealed class AlertClipDecoder : IAlertClipDecoder
{
public event Action<VideoFrame>? FrameAvailable;
public event Action<AudioSample>? AudioReady;
public event Action? Completed;
private const int AudioSampleRate = 48000;
private const int AudioChannels = 2;
private const int AudioChunkFloats = 4800; // 50ms of stereo
private readonly string _path;
private readonly int _width;
private readonly int _height;
private readonly IFfmpegLocator _locator;
private readonly Func<IDecodeProcess> _processFactory;
private readonly IFrameRateProbe? _probe;
private readonly Func<TimeSpan, CancellationToken, Task> _delay;
private readonly CancellationTokenSource _cts = new();
private Task? _run;
private readonly VideoClipPipe _clip;
public AlertClipDecoder(
string path,
@@ -67,161 +50,20 @@ public sealed class AlertClipDecoder : IAlertClipDecoder
IFfmpegLocator locator,
Func<IDecodeProcess> processFactory,
IFrameRateProbe? frameRateProbe = null,
Func<TimeSpan, CancellationToken, Task>? delay = null)
Func<TimeSpan, CancellationToken, Task>? delay = null,
ChromaKeyOptions? chromaKey = null)
{
_path = path;
_width = width;
_height = height;
_locator = locator;
_processFactory = processFactory;
_probe = frameRateProbe;
_delay = delay ?? ((t, ct) => Task.Delay(t, ct));
_clip = new VideoClipPipe(
path, width, height, locator, processFactory, frameRateProbe, delay,
audio: true, chromaKey: chromaKey);
_clip.FrameAvailable += frame => FrameAvailable?.Invoke(frame);
_clip.AudioAvailable += sample => AudioReady?.Invoke(sample);
_clip.Completed += () => Completed?.Invoke();
}
public void Start()
{
if (_run != null) return;
_run = Task.Run(RunAsync, CancellationToken.None);
}
public void Start() => _ = _clip.StartAsync();
public void Stop() => _cts.Cancel();
public void Stop() => _ = _clip.StopAsync();
public void Dispose()
{
_cts.Cancel();
_cts.Dispose();
}
private async Task RunAsync()
{
var ct = _cts.Token;
var videoFrames = 0;
var audioChunks = 0;
var failed = false;
try
{
double? frameRate = null;
if (_probe != null)
frameRate = await _probe.ProbeAsync(_path, ct).ConfigureAwait(false);
var frameDuration = frameRate is > 0 ? TimeSpan.FromSeconds(1.0 / frameRate.Value) : TimeSpan.Zero;
var ffmpegPath = await _locator.LocateAsync(ct).ConfigureAwait(false);
var videoTask = RunVideoAsync(ffmpegPath, frameDuration, ct);
var audioTask = RunAudioAsync(ffmpegPath, ct);
videoFrames = await videoTask.ConfigureAwait(false);
audioChunks = await audioTask.ConfigureAwait(false);
}
catch (OperationCanceledException)
{
failed = true;
return;
}
catch (Exception ex)
{
// A failed decode is not fatal: the alert layer falls back to the six
// animations (or the frame it already holds) exactly as a missing file.
// Logged so a silent blank box traces back to a decode failure.
failed = true;
AppLog.Write(ex, $"Alert clip decode failed ({Path.GetFileName(_path)})");
}
if (!ct.IsCancellationRequested)
{
AppLog.Write($"Alert clip end: {Path.GetFileName(_path)} frames={videoFrames} audioChunks={audioChunks} failed={failed}");
Completed?.Invoke();
}
}
private async Task<int> RunVideoAsync(string ffmpegPath, TimeSpan frameDuration, CancellationToken ct)
{
var args =
$"-hide_banner -loglevel error -i \"{_path}\" " +
$"-f rawvideo -pix_fmt bgra -vf scale={_width}:{_height} -an -";
using var process = _processFactory();
process.Start(new ProcessStartInfo
{
FileName = ffmpegPath,
Arguments = args,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
});
var reader = new RawVideoFrameReader(_width, _height);
var buffer = new byte[65536];
var stream = process.StandardOutput;
var frames = 0;
while (!ct.IsCancellationRequested && !process.HasExited)
{
var read = await stream.ReadAsync(buffer, 0, buffer.Length, ct).ConfigureAwait(false);
if (read <= 0) break;
var chunk = new byte[read];
Buffer.BlockCopy(buffer, 0, chunk, 0, read);
foreach (var frame in reader.Feed(chunk))
{
frames++;
FrameAvailable?.Invoke(frame);
if (frameDuration > TimeSpan.Zero)
await _delay(frameDuration, ct).ConfigureAwait(false);
}
}
return frames;
}
private async Task<int> RunAudioAsync(string ffmpegPath, CancellationToken ct)
{
var args =
$"-hide_banner -loglevel error -i \"{_path}\" " +
$"-f f32le -ac {AudioChannels} -ar {AudioSampleRate} -vn -";
using var process = _processFactory();
process.Start(new ProcessStartInfo
{
FileName = ffmpegPath,
Arguments = args,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
});
var stream = process.StandardOutput;
var raw = new byte[65536];
var carry = new byte[0];
var floatScratch = new float[AudioChunkFloats];
var floatIndex = 0;
var chunks = 0;
// The pipe is byte-delimited only: a read may split a float, so leftover
// bytes are carried into the next read instead of dropped (a dropped
// half-float would desync the entire stream).
while (!ct.IsCancellationRequested && !process.HasExited)
{
var read = await stream.ReadAsync(raw, 0, raw.Length, ct).ConfigureAwait(false);
if (read <= 0) break;
var total = carry.Length + read;
var bytes = total <= raw.Length ? raw : new byte[total];
if (carry.Length > 0)
{
Buffer.BlockCopy(carry, 0, bytes, 0, carry.Length);
Buffer.BlockCopy(raw, 0, bytes, carry.Length, read);
}
var floatsTo = total - (total % 4);
for (var i = 0; i < floatsTo && floatIndex < floatScratch.Length; i += 4)
floatScratch[floatIndex++] = BitConverter.ToSingle(bytes, i);
carry = total > floatsTo ? bytes[floatsTo..total] : new byte[0];
while (floatIndex == floatScratch.Length)
{
chunks++;
AudioReady?.Invoke(new AudioSample(floatScratch, AudioSampleRate, AudioChannels));
// Pace the NEXT chunk so the whole clip's audio lands at real-time
// cadence (the fade in the alert layer tracks the same wall clock).
await _delay(TimeSpan.FromSeconds(AudioChunkFloats / (double)(AudioSampleRate * AudioChannels)), ct).ConfigureAwait(false);
floatScratch = new float[AudioChunkFloats];
floatIndex = 0;
}
}
return chunks;
}
}
public void Dispose() => _clip.Dispose();
}
+119 -30
View File
@@ -58,6 +58,8 @@ public sealed class AlertOverlayLayer : IDisposable
private ChatMessage? _current;
private Source? _currentBox;
private sealed record TickerFeed(string Text, double Duration);
private volatile TickerFeed? _tickerFeed;
private IAlertClipDecoder? _clip;
private VideoFrame? _latestClipFrame;
private bool _clipEof;
@@ -66,8 +68,6 @@ public sealed class AlertOverlayLayer : IDisposable
private int _clipHeight;
private bool _clipSawFrame;
private bool _clipAudioHeard;
private string? _tickerText;
private AlertDisplayMethod _tickerMethod = AlertDisplayMethod.TickerScroll;
private double _elapsed;
private IEnumerable<Scene>? _scenes;
@@ -139,6 +139,7 @@ public sealed class AlertOverlayLayer : IDisposable
BeginClip();
}
_contentVersion++;
RefreshTickerFeed();
}
/// <summary>Advance the playback clock (the ticker's beat — and the test seam;
@@ -172,6 +173,7 @@ public sealed class AlertOverlayLayer : IDisposable
_current = null;
_elapsed = 0;
AdvanceToNext();
RefreshTickerFeed();
}
_contentVersion++;
return;
@@ -184,6 +186,7 @@ public sealed class AlertOverlayLayer : IDisposable
_elapsed = 0;
BeginClip();
_contentVersion++;
RefreshTickerFeed();
return;
}
@@ -193,6 +196,7 @@ public sealed class AlertOverlayLayer : IDisposable
_current = null;
_elapsed = 0;
AdvanceToNext();
RefreshTickerFeed();
}
_contentVersion++;
}
@@ -249,27 +253,55 @@ public sealed class AlertOverlayLayer : IDisposable
/// <see cref="VideoFrame.OriginX"/>/<see cref="VideoFrame.OriginY"/> instead of
/// a hardcoded 0. <b>No alert box in the scene means no ticker at all</b> — there
/// is no longer a global position for it to live in.</para></summary>
public VideoFrame? AlertTickerFrame
{
get
{
if (_current == null || string.IsNullOrWhiteSpace(_tickerText)) return null;
var box = _currentBox;
if (box == null) return null;
public VideoFrame? AlertTickerFrame => AlertBoxTickerFrame(_currentBox);
var w = (int)Math.Max(1, Math.Round(box.Width));
var h = (int)Math.Max(1, Math.Min(
Math.Round(box.Height), AlertTickerRenderer.Height));
var frame = AlertTickerRenderer.Render(
_tickerText!, _elapsed, _tickerMethod, AlertSeconds(),
TickerReadsPerAlert, w, h);
return frame == null
? null
: new VideoFrame(frame.Width, frame.Height, frame.BgraPixels)
{
Placement = ((int)Math.Round(box.X), (int)Math.Round(box.Y)),
};
public VideoFrame? AlertBoxTickerFrame(Source? alertBox)
{
if (_current == null || alertBox is not { Type: SourceType.AlertBox }) return null;
var w = (int)Math.Max(1, Math.Round(alertBox.Width));
var h = (int)Math.Max(1, Math.Min(
Math.Round(alertBox.Height), AlertTickerRenderer.Height));
var frame = AlertTickerRenderer.Render(
alertBox.Ticker, ComposeTicker(_current), _elapsed, AlertSeconds(),
TickerReadsPerAlert, w, h);
return frame == null
? null
: new VideoFrame(frame.Width, frame.Height, frame.BgraPixels)
{
Placement = ((int)Math.Round(alertBox.X), (int)Math.Round(alertBox.Y)),
};
}
public VideoFrame? RenderSceneTicker(Source tickerSource)
{
if (tickerSource.Type != SourceType.Ticker || !tickerSource.Ticker.IsEnabled)
return null;
var feed = _tickerFeed;
if (feed == null) return null;
return AlertTickerRenderer.Render(
tickerSource.Ticker,
feed.Text,
_elapsed,
feed.Duration,
TickerReadsPerAlert,
(int)Math.Max(1, Math.Round(tickerSource.Width)),
(int)Math.Max(1, Math.Round(tickerSource.Height)));
}
private void RefreshTickerFeed()
{
if (_current == null)
{
_tickerFeed = null;
return;
}
var messages = new[] { _current }.Concat(_queue).ToArray();
_tickerFeed = new TickerFeed(
string.Join(" • ", messages.Select(ComposeTicker)),
messages.Sum(DurationOf));
}
/// <summary>How many times the marquee must cross the frame per alert, so the
@@ -317,22 +349,52 @@ public sealed class AlertOverlayLayer : IDisposable
return Math.Clamp(_elapsed / FadeDurationSeconds, 0.0, 1.0);
}
// Sound byte (creator ruling 2026-11-01): a file the creator picks to play WITH the clip. It
// goes to the default device, not into the mixer, so it is audible and visible in the desktop
// sound bar. MediaPlayer handles the decode; the clip itself still stops playback.
private System.Windows.Media.MediaPlayer? _soundByte;
private void StartSoundByte(Source box)
{
StopSoundByte();
var path = box.AlertAudioPath;
if (string.IsNullOrWhiteSpace(path)) return;
try
{
var player = new System.Windows.Media.MediaPlayer { Volume = Math.Clamp(box.AlertVideoVolume, 0, 1) };
if (box.AlertAudioLoop) player.MediaEnded += (_, _) => { player.Position = TimeSpan.Zero; player.Play(); };
player.Open(new Uri(path, UriKind.Absolute));
player.Play();
_soundByte = player;
AppLog.Write($"Alert sound byte: '{path}' loop={box.AlertAudioLoop}");
}
catch (Exception ex)
{
AppLog.Write(ex, "Alert sound byte failed", AppLogErrorClass.Class2);
StopSoundByte();
}
}
private void StopSoundByte()
{
var player = _soundByte;
_soundByte = null;
if (player == null) return;
try { player.Stop(); player.Close(); } catch { /* already closed */ }
}
private void BeginClip()
{
StopClip();
_tickerText = null;
if (_current == null) return;
var box = _scenes?.SelectMany(s => s.Elements).OfType<Source>()
.FirstOrDefault(s => s.Type == SourceType.AlertBox);
_currentBox = box;
if (box != null && box.AlertShowTicker)
{
_tickerText = ComposeTicker(_current);
_tickerMethod = box.AlertDisplayMethod;
}
if (box == null || _clipDecoderFactory == null) return;
if (box == null) return;
StartSoundByte(box);
if (_clipDecoderFactory == null) return;
var path = _clipPathResolver?.Invoke(box);
if (string.IsNullOrWhiteSpace(path)) return;
@@ -351,13 +413,14 @@ public sealed class AlertOverlayLayer : IDisposable
}
catch (Exception ex)
{
AppLog.Write(ex, "Alert clip setup failed");
AppLog.Write(ex, "Alert clip setup failed", AppLogErrorClass.Class2);
StopClip(); // decode setup failed (bad path) — fall back to animations
}
}
private void StopClip()
{
StopSoundByte();
var clip = _clip;
_clip = null;
_latestClipFrame = null;
@@ -515,6 +578,9 @@ public sealed class AlertOverlayLayer : IDisposable
};
UpdatePreview(alertBox);
}
foreach (var ticker in scenes.SelectMany(s => s.Elements).OfType<Source>()
.Where(s => s.Type == SourceType.Ticker))
UpdateTickerPreview(ticker);
}
private void RefreshAlertPreviews()
@@ -529,13 +595,36 @@ public sealed class AlertOverlayLayer : IDisposable
foreach (var alertBox in _scenes.SelectMany(s => s.Elements).OfType<Source>()
.Where(s => s.Type == SourceType.AlertBox))
UpdatePreview(alertBox);
foreach (var ticker in _scenes.SelectMany(s => s.Elements).OfType<Source>()
.Where(s => s.Type == SourceType.Ticker))
UpdateTickerPreview(ticker);
}
private void UpdateTickerPreview(Source ticker)
{
var frame = RenderSceneTicker(ticker);
if (frame == null)
{
ticker.VideoImageSource = null;
return;
}
var bitmap = ticker.VideoImageSource;
if (bitmap == null || bitmap.PixelWidth != frame.Width || bitmap.PixelHeight != frame.Height)
{
bitmap = new WriteableBitmap(frame.Width, frame.Height, 96, 96, PixelFormats.Bgra32, null);
ticker.VideoImageSource = bitmap;
}
bitmap.WritePixels(new Int32Rect(0, 0, frame.Width, frame.Height), frame.BgraPixels, frame.Stride, 0);
}
public void Dispose()
{
StopSoundByte();
_ticker?.Stop();
_queue.Clear();
_current = null;
_tickerFeed = null;
StopClip();
}
}
}
+19 -18
View File
@@ -3,6 +3,7 @@ using System.Windows;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using ytLive.Models;
using ytLive.Services.Compositor;
namespace ytLive.Services;
@@ -27,9 +28,9 @@ public sealed class AlertRenderer
private static readonly Color White = Colors.White;
private static readonly Color[] ConfettiPalette =
{
Color.FromRgb(0xf4, 0x2f, 0x5f), Color.FromRgb(0xff, 0xd1, 0x66),
Color.FromRgb(0x38, 0xb6, 0xf8), Color.FromRgb(0xa7, 0x8b, 0xfa),
Color.FromRgb(0x39, 0xd3, 0x53),
OutputPalette.Crimson, OutputPalette.Gold,
OutputPalette.Tertiary, OutputPalette.Violet,
OutputPalette.Positive,
};
public VideoFrame Render(ChatMessage e, double t01, int width, int height)
@@ -70,10 +71,10 @@ public sealed class AlertRenderer
var shownAmount = e.SuperChatDisplayString
?? (e.SuperChatCurrency is { Length: > 0 } cur ? $"{cur}{e.SuperChatAmount:0.00}" : $"${e.SuperChatAmount:0.00}");
var fontSize = cardH * 0.24;
DrawCentered(ctx, shownAmount, cardCX, cardY + cardH * 0.24, fontSize, FontWeights.Bold, Color.FromRgb(0xff, 0xd1, 0x66), cardW);
DrawCentered(ctx, shownAmount, cardCX, cardY + cardH * 0.24, fontSize, FontWeights.Bold, OutputPalette.Gold, cardW);
DrawCentered(ctx, e.AuthorName, cardCX, cardY + cardH * 0.56, cardH * 0.13, FontWeights.SemiBold, White, cardW);
DrawCentered(ctx, e.Message, cardCX, cardY + cardH * 0.74, cardH * 0.10, FontWeights.Normal, Color.FromRgb(0xdd, 0xff, 0xf0), cardW * 0.92);
DrawCentered(ctx, e.Message, cardCX, cardY + cardH * 0.74, cardH * 0.10, FontWeights.Normal, OutputPalette.MintText, cardW * 0.92);
if (t >= EnterEnd && t <= ExitStart)
{
@@ -122,10 +123,10 @@ public sealed class AlertRenderer
var desc = string.IsNullOrWhiteSpace(e.SuperStickerDescription) ? "SUPER STICKER" : e.SuperStickerDescription.ToUpperInvariant();
DrawCentered(ctx, desc, cardCX, h / 2.0 - cardH * 0.12, cardH * 0.17, FontWeights.Bold, White, cardW * 0.9);
DrawCentered(ctx, e.AuthorName, cardCX, h / 2.0 + cardH * 0.16, cardH * 0.12, FontWeights.SemiBold, Color.FromRgb(0xff, 0xb3, 0xd9), cardW * 0.9);
DrawCentered(ctx, e.AuthorName, cardCX, h / 2.0 + cardH * 0.16, cardH * 0.12, FontWeights.SemiBold, OutputPalette.Pink, cardW * 0.9);
var tier = e.SuperChatDisplayString ?? (e.SuperChatCurrency is { Length: > 0 } cur ? $"{cur}{e.SuperChatAmount:0.00}" : null);
if (!string.IsNullOrWhiteSpace(tier))
DrawCentered(ctx, tier, cardCX, h / 2.0 + cardH * 0.32, cardH * 0.10, FontWeights.Bold, Color.FromRgb(0xff, 0xd1, 0x66), cardW * 0.9);
DrawCentered(ctx, tier, cardCX, h / 2.0 + cardH * 0.32, cardH * 0.10, FontWeights.Bold, OutputPalette.Gold, cardW * 0.9);
ctx.Pop();
ctx.Pop();
@@ -163,10 +164,10 @@ public sealed class AlertRenderer
}
DrawCentered(ctx, e.AuthorName, cardCX, cardY + cardH * 0.36, cardH * 0.22, FontWeights.Bold, White, cardW * 0.9);
DrawCentered(ctx, "JOINED THE MEMBERSHIP", cardCX, cardY + cardH * 0.62, cardH * 0.11, FontWeights.SemiBold, Color.FromRgb(0xf5, 0xd0, 0xfe), cardW * 0.9);
DrawCentered(ctx, "JOINED THE MEMBERSHIP", cardCX, cardY + cardH * 0.62, cardH * 0.11, FontWeights.SemiBold, OutputPalette.Lilac, cardW * 0.9);
var level = string.IsNullOrWhiteSpace(e.MembershipLevel) ? "MEMBER" : e.MembershipLevel.ToUpperInvariant();
DrawCentered(ctx, level, cardCX, cardY + cardH * 0.80, cardH * 0.10, FontWeights.Bold, Color.FromRgb(0xff, 0xd1, 0x66), cardW * 0.9);
DrawCentered(ctx, level, cardCX, cardY + cardH * 0.80, cardH * 0.10, FontWeights.Bold, OutputPalette.Gold, cardW * 0.9);
}
return CreateFrame(visual, w, h, dpi);
}
@@ -193,7 +194,7 @@ public sealed class AlertRenderer
var count = Math.Max(1, e.GiftNewMembersCount);
var spread = 1.0 - exit;
var hero = count > 1 ? $"×{count}" : "MEMBERSHIP";
DrawCentered(ctx, hero, cardCX, margin + cardH * 0.24, cardH * 0.26, FontWeights.Bold, Color.FromRgb(0xff, 0xd1, 0x66), cardW * 0.8);
DrawCentered(ctx, hero, cardCX, margin + cardH * 0.24, cardH * 0.26, FontWeights.Bold, OutputPalette.Gold, cardW * 0.8);
DrawCentered(ctx, $"{e.AuthorName} is gifting", cardCX, margin + cardH * 0.52, cardH * 0.11, FontWeights.SemiBold, White, cardW * 0.9);
var chipCount = Math.Min(count, 6);
@@ -204,7 +205,7 @@ public sealed class AlertRenderer
var chipX = cardCX + (i - slot) * gap;
var chipY = margin + cardH * 0.70 + Math.Sin((i + t * 6) * 2.4) * cardH * 0.015;
ctx.DrawRoundedRectangle(
new SolidColorBrush(Color.FromRgb(0xff, 0xf3, 0xc4)),
new SolidColorBrush(OutputPalette.GoldPale),
null,
new Rect(chipX - cardH * 0.05, chipY, cardH * 0.10, cardH * 0.10),
cardH * 0.03, cardH * 0.03);
@@ -252,10 +253,10 @@ public sealed class AlertRenderer
}
}
DrawCentered(ctx, "GIFTED MEMBERSHIP", cardCX, margin + cardH * 0.22, cardH * 0.12, FontWeights.Bold, Color.FromRgb(0xff, 0xd1, 0x66), cardW * 0.9);
DrawCentered(ctx, "GIFTED MEMBERSHIP", cardCX, margin + cardH * 0.22, cardH * 0.12, FontWeights.Bold, OutputPalette.Gold, cardW * 0.9);
var giver = string.IsNullOrWhiteSpace(e.GifterName) ? e.AuthorName : e.GifterName;
DrawCentered(ctx, giver, cardCX, margin + cardH * 0.42, cardH * 0.20, FontWeights.Bold, White, cardW * 0.9);
DrawCentered(ctx, "gave you a membership — celebrate!", cardCX, margin + cardH * 0.68, cardH * 0.11, FontWeights.Normal, Color.FromRgb(0xdb, 0xff, 0xf0), cardW * 0.9);
DrawCentered(ctx, "gave you a membership — celebrate!", cardCX, margin + cardH * 0.68, cardH * 0.11, FontWeights.Normal, OutputPalette.MintText, cardW * 0.9);
ctx.Pop();
ctx.Pop();
@@ -283,17 +284,17 @@ public sealed class AlertRenderer
DrawBrand(ctx, margin, cardY, cardW, cardH);
var hero = e.MilestoneMonths > 0 ? $"{e.MilestoneMonths}" : "MILESTONE";
DrawCentered(ctx, hero, cardCX, cardY + cardH * 0.26, cardH * 0.30, FontWeights.Bold, Color.FromRgb(0xff, 0xdd, 0x66), cardW * 0.8);
DrawCentered(ctx, hero, cardCX, cardY + cardH * 0.26, cardH * 0.30, FontWeights.Bold, OutputPalette.GoldBright, cardW * 0.8);
var sub = e.MilestoneMonths > 0 ? "MONTHS STRONG" : "SUPPORTING THE STREAM";
DrawCentered(ctx, sub, cardCX, cardY + cardH * 0.52, cardH * 0.11, FontWeights.SemiBold, White, cardW * 0.9);
DrawCentered(ctx, "— " + e.AuthorName + " —", cardCX, cardY + cardH * 0.70, cardH * 0.10, FontWeights.Normal, Color.FromRgb(0xff, 0xe8, 0xd0), cardW * 0.9);
DrawCentered(ctx, "— " + e.AuthorName + " —", cardCX, cardY + cardH * 0.70, cardH * 0.10, FontWeights.Normal, OutputPalette.WarmCream, cardW * 0.9);
// Tenure bar: grows through the first half, retracts on exit.
var barP = Clamp01((t - 0.10) / 0.55) * (1 - exit);
var barW = cardW * 0.6 * barP;
ctx.DrawRoundedRectangle(
new SolidColorBrush(Color.FromRgb(0xff, 0xd1, 0x66)),
new SolidColorBrush(OutputPalette.Gold),
null,
new Rect(cardCX - barW / 2, cardY + cardH * 0.84, barW, cardH * 0.045),
4, 4);
@@ -313,7 +314,7 @@ public sealed class AlertRenderer
{
var formatted = new FormattedText(
Brand, Invariant, FlowDirection.LeftToRight,
new Typeface(new FontFamily("Segoe UI"), FontStyles.Normal, FontWeights.Normal, FontStretches.Normal),
new Typeface(OutputPalette.Sans, FontStyles.Normal, FontWeights.Normal, FontStretches.Normal),
Math.Max(9, h * 0.055),
new SolidColorBrush(Color.FromArgb(0x88, 0xff, 0xff, 0xff)),
96.0);
@@ -324,7 +325,7 @@ public sealed class AlertRenderer
{
var formatted = new FormattedText(
text, Invariant, FlowDirection.LeftToRight,
new Typeface(new FontFamily("Segoe UI"), FontStyles.Normal, weight, FontStretches.Normal),
new Typeface(OutputPalette.Sans, FontStyles.Normal, weight, FontStretches.Normal),
size,
new SolidColorBrush(color),
96.0);
+83
View File
@@ -0,0 +1,83 @@
using System;
using NAudio.Wave;
using ytLive.Helpers;
namespace ytLive.Services.Audio;
/// <summary>
/// Plays alert audio on the **default output device**, which is what makes it visible in the
/// desktop sound bar (creator ruling 2026-11-01): the app mixes for the encoder, but only audio
/// that actually reaches the speakers is picked up by the WASAPI loopback that feeds both the
/// meter and the stream. Injecting straight into the mixer, as this used to do, produced sound
/// nobody could see and nobody could hear.
///
/// <para>One long-lived <see cref="WaveOutEvent"/> with a buffered float provider: the alert clip
/// submits PCM as it decodes, and a sound byte can be submitted through the same device, so they
/// mix with each other and with everything else on the machine.</para>
/// </summary>
public sealed class AlertAudioOutput : IDisposable
{
private readonly object _gate = new();
private WaveOutEvent? _device;
private BufferedWaveProvider? _buffer;
private int _sampleRate;
private int _channels;
/// <summary>Queue one decoded pulse of the alert clip. Never blocks the decoder: if the device
/// cannot keep up the buffer simply grows, and it is cleared when the alert ends.</summary>
public void Submit(AudioSample sample)
{
if (sample.Samples.Length == 0) return;
lock (_gate)
{
try
{
EnsureDevice(sample.SampleRate, sample.Channels);
if (_buffer == null) return;
var bytes = new byte[sample.Samples.Length * sizeof(float)];
Buffer.BlockCopy(sample.Samples, 0, bytes, 0, bytes.Length);
_buffer.AddSamples(bytes, 0, bytes.Length);
}
catch (Exception ex)
{
// No output device (a headless stream box, a locked-down session): the alert still
// renders, it is simply silent. Never let audio take the alert down.
AppLog.Write(ex, "Alert audio output failed", AppLogErrorClass.Class2);
Stop();
}
}
}
/// <summary>Stop and flush — called when the clip ends so a queued tail cannot bleed into the
/// next alert.</summary>
public void Stop()
{
lock (_gate)
{
try { _device?.Stop(); } catch { /* already stopped */ }
_buffer?.ClearBuffer();
_device?.Dispose();
_device = null;
_buffer = null;
}
}
private void EnsureDevice(int sampleRate, int channels)
{
if (_device != null && _sampleRate == sampleRate && _channels == channels) return;
_device?.Dispose();
_sampleRate = sampleRate;
_channels = channels;
_buffer = new BufferedWaveProvider(new WaveFormat(sampleRate, 32, channels))
{
BufferDuration = TimeSpan.FromSeconds(2),
DiscardOnBufferOverflow = true,
};
_device = new WaveOutEvent { DesiredLatency = 120 };
_device.Init(_buffer);
_device.Play();
}
public void Dispose() => Stop();
}
+43
View File
@@ -0,0 +1,43 @@
using System;
namespace ytLive.Services.Audio;
/// <summary>
/// How many frames the mix must produce <i>now</i>, derived from the monotonic clock rather than from a
/// tick count.
///
/// <para><b>Why this exists (2026-11-12):</b> the live loop used to write a fixed 480 frames per
/// iteration and trust that every iteration was 10 ms. It is not: `Task.Delay` plus processing lands
/// near 9.8 ms under a raised timer resolution, and 102 ticks per second where 100 are expected is
/// 102% of an audio second per wall second. FFmpeg stamps audio by sample count, so the track comes out
/// longer than the show and the sound slides later against a correct picture — measured on a 9.9 s
/// recording: video 9.888 s (right), audio 10.112 s (+2.1%, 224 ms).</para>
///
/// <para>The video side never had this bug because the pump paces off a stopwatch. This is the audio
/// equivalent: ask the clock what should exist, produce the difference, pad on underrun. Pure, so the
/// drift case is a test rather than a listening test.</para>
/// </summary>
public static class AudioClock
{
/// <summary>
/// Frames still owed at <paramref name="elapsedSeconds"/>: expected-minus-produced, clamped to
/// 0..<paramref name="maxFrames"/>. Zero means "not due yet"; the cap keeps a stalled loop from
/// dumping a huge burst in one write (the deficit stays owed and is paid off over the next ticks).
/// </summary>
public static int FramesDue(double elapsedSeconds, long framesProduced, int sampleRate, int maxFrames)
{
if (elapsedSeconds <= 0 || sampleRate <= 0 || maxFrames <= 0) return 0;
var expected = (long)Math.Round(elapsedSeconds * sampleRate);
var debt = expected - framesProduced;
return (int)Math.Clamp(debt, 0L, maxFrames);
}
/// <summary>
/// Advance the produced count for frames that actually reached the encoder. A write that went
/// nowhere (ffmpeg had not connected yet) must stay owed: counting it would shorten the track by
/// that gap — measured 2026-11-12 on the first clock-driven recording, where 27 start-up drops made
/// the audio 226 ms <i>short</i> after the same bug had made it 224 ms long.
/// </summary>
public static long Advance(long framesProduced, int frames, bool wrote)
=> framesProduced + (wrote ? frames : 0);
}

Some files were not shown because too many files have changed in this diff Show More