Compare commits
309
Commits
3239808a73
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
69f08a478e | ||
|
|
ba60f9ab3d | ||
|
|
f4caea09ad | ||
|
|
70b5b01920 | ||
|
|
bd61e8df2a | ||
|
|
5bd750eaf5 | ||
|
|
1ce6b9c1b8 | ||
|
|
70fcf89f9e | ||
|
|
caf5d6130d | ||
|
|
6b4aa99863 | ||
|
|
b92569a8c7 | ||
|
|
838ba17eaf | ||
|
|
16abb1ddc1 | ||
|
|
4bc41c488c | ||
|
|
a5a10fb0b0 | ||
|
|
6c5cd2f66c | ||
|
|
a5dc2f494c | ||
|
|
d46e84562f | ||
|
|
aae6104666 | ||
|
|
cc75212997 | ||
|
|
1eb04597d1 | ||
|
|
c1b3868913 | ||
|
|
af6210082c | ||
|
|
1abc86358a | ||
|
|
514fb8480d | ||
|
|
bb3bc12784 | ||
|
|
b216cbc56f | ||
|
|
e654a16b60 | ||
|
|
5ae52808bb | ||
|
|
751d9b199c | ||
|
|
102cb96b28 | ||
|
|
47f409f0e4 | ||
|
|
7f89534309 | ||
|
|
f6fdab3bf2 | ||
|
|
ada8d2457d | ||
|
|
7acf044e4a | ||
|
|
b0f8263414 | ||
|
|
f2cab5ca0f | ||
|
|
93719a286d | ||
|
|
1708667602 | ||
|
|
700c7a8c23 | ||
|
|
37ef74eb8d | ||
|
|
1e3a4f7fe0 | ||
|
|
9c0c40c688 | ||
|
|
ff5c005bd9 | ||
|
|
2f5d0c1759 | ||
|
|
9621db29c9 | ||
|
|
24077e58cf | ||
|
|
2c5a3d8c11 | ||
|
|
4e5b4e3999 | ||
|
|
b265d42fb4 | ||
|
|
1d35a7400f | ||
|
|
e6c6913994 | ||
|
|
41064bdd13 | ||
|
|
b8eb2b4451 | ||
|
|
ff1c1965e0 | ||
|
|
c4da06b56b | ||
|
|
1c6fc3223d | ||
|
|
37791086ae | ||
|
|
b093609ad3 | ||
|
|
de787e0dad | ||
|
|
5fcfa545e5 | ||
|
|
a5c2061b55 | ||
|
|
28657918f9 | ||
|
|
f7d3d1301e | ||
|
|
5066d81aab | ||
|
|
b93744ec7f | ||
|
|
1a6a549e17 | ||
|
|
c257de1516 | ||
|
|
80ccdc9cb8 | ||
|
|
ec6a5ca466 | ||
|
|
42228c7aab | ||
|
|
4b3d3f2645 | ||
|
|
4855d5cf8d | ||
|
|
0fa3006da7 | ||
|
|
134103c08b | ||
|
|
b84802e657 | ||
|
|
b39439abdc | ||
|
|
555ff5c0ac | ||
|
|
bc62bd81ec | ||
|
|
92d875c343 | ||
|
|
ed32151136 | ||
|
|
e53f9974a5 | ||
|
|
4a43b3e229 | ||
|
|
a8f27db4c2 | ||
|
|
b4e952fda9 | ||
|
|
bef818f4a1 | ||
|
|
dbcb69278f | ||
|
|
30824c6d6e | ||
|
|
70e2c48136 | ||
|
|
3db964e5cf | ||
|
|
a6f15a4bc3 | ||
|
|
f7e1b76cdd | ||
|
|
0f08fd7ade | ||
|
|
3261293d57 | ||
|
|
a1d861608f | ||
|
|
0fade9702b | ||
|
|
27f655c8de | ||
|
|
9611cee199 | ||
|
|
dd51f7842a | ||
|
|
ec1091a170 | ||
|
|
d8c1bdba24 | ||
|
|
f2c43909c8 | ||
|
|
0de953ef66 | ||
|
|
3fde7fcfe6 | ||
|
|
72cb3ad55a | ||
|
|
a80108dd96 | ||
|
|
adf9883ae9 | ||
|
|
77eebdb2ce | ||
|
|
17d835353d | ||
|
|
3a56807ee3 | ||
|
|
5577aaaf91 | ||
|
|
69e595da25 | ||
|
|
0f4a035376 | ||
|
|
242a13ca30 | ||
|
|
e37215d9c4 | ||
|
|
a814bcd502 | ||
|
|
7bb1207fb2 | ||
|
|
ea24405ba2 | ||
|
|
21db8a17b8 | ||
|
|
2670ac022b | ||
|
|
776f84fb88 | ||
|
|
db563c8644 | ||
|
|
77d7208bfa | ||
|
|
785b5ff8f3 | ||
|
|
d195c29f79 | ||
|
|
5f7bcc2425 | ||
|
|
5c4acf141b | ||
|
|
58eb640c4e | ||
|
|
60c4a0032f | ||
|
|
106896c408 | ||
|
|
7216c95449 | ||
|
|
1993e7cc8b | ||
|
|
ede6ecac8e | ||
|
|
bde2a4cf4b | ||
|
|
4b7e798757 | ||
|
|
f921c0174b | ||
|
|
6310f50c27 | ||
|
|
76e8c22815 | ||
|
|
a6aea5bf26 | ||
|
|
01a3fcaaf7 | ||
|
|
264a90dae8 | ||
|
|
c856d11fb5 | ||
|
|
c9bd7e6093 | ||
|
|
e6a7147dc3 | ||
|
|
0e1f948189 | ||
|
|
caf91e6564 | ||
|
|
82609a5046 | ||
|
|
91e308e8e2 | ||
|
|
221b81628a | ||
|
|
3985a413a7 | ||
|
|
9fa06c67c2 | ||
|
|
20f059422b | ||
|
|
6208cb9409 | ||
|
|
ff5e8d94ae | ||
|
|
54902cb838 | ||
|
|
790cff45c2 | ||
|
|
b3d7cf30b2 | ||
|
|
31b655cecb | ||
|
|
0fdd9d5faf | ||
|
|
41b3933b08 | ||
|
|
d6088f8e35 | ||
|
|
c297b3fb0f | ||
|
|
8221bd6478 | ||
|
|
82960d0ca3 | ||
|
|
5d836aecbb | ||
|
|
1c2eedcaba | ||
|
|
1074a46e9c | ||
|
|
b00bf48b9d | ||
|
|
46d12e5873 | ||
|
|
005b459fe0 | ||
|
|
754377c5db | ||
|
|
b75c2b3001 | ||
|
|
2fedc7cb64 | ||
|
|
789272142d | ||
|
|
35c7809c28 | ||
|
|
4c4f22e6bb | ||
|
|
f8e4f2cf29 | ||
|
|
9b337ad91b | ||
|
|
bb51164f2f | ||
|
|
bb80ec8370 | ||
|
|
da508f0a14 | ||
|
|
9b44a32054 | ||
|
|
1416548f24 | ||
|
|
0ed341fd18 | ||
|
|
71d0e87cac | ||
|
|
2e12c5651e | ||
|
|
a543d3629b | ||
|
|
a35ec0c5f8 | ||
|
|
c418288215 | ||
|
|
517e60d593 | ||
|
|
c3bbf1fdda | ||
|
|
15734a5477 | ||
|
|
1d2d58f1d7 | ||
|
|
4b91760f92 | ||
|
|
6d1c982e26 | ||
|
|
279eb08dea | ||
|
|
fdb27f0758 | ||
|
|
bd669f2e73 | ||
|
|
600088c794 | ||
|
|
68fdacc12d | ||
|
|
724f940f5e | ||
|
|
832955bbb8 | ||
|
|
7dab7ded36 | ||
|
|
b2e8b26b6e | ||
|
|
f189c5e328 | ||
|
|
1fe84be64a | ||
|
|
27fc6fb507 | ||
|
|
abfab25685 | ||
|
|
8f79b20a18 | ||
|
|
57ef9e7a5e | ||
|
|
0b41b7db67 | ||
|
|
7827d239e2 | ||
|
|
90d5140906 | ||
|
|
c72b61b61e | ||
|
|
54de9c3dfb | ||
|
|
0498f7e130 | ||
|
|
7829610cad | ||
|
|
c03acd0aa0 | ||
|
|
28525c90aa | ||
|
|
460b36204d | ||
|
|
882b8551d9 | ||
|
|
e27e79962c | ||
|
|
8caee21b9a | ||
|
|
9405c54750 | ||
|
|
e6abc13461 | ||
|
|
ea1017776d | ||
|
|
38c2ad6555 | ||
|
|
85a4a8a7d3 | ||
|
|
96d528b3ac | ||
|
|
d4a4b1c80e | ||
|
|
b205ce9ba9 | ||
|
|
9a6099af7c | ||
|
|
7048bd8bff | ||
|
|
534f56a7c4 | ||
|
|
deb74529a6 | ||
|
|
41b0cd6e98 | ||
|
|
fbcccea0f7 | ||
|
|
359e58a4c2 | ||
|
|
6b6ccd7b0a | ||
|
|
3ed8386303 | ||
|
|
e92ce4bb63 | ||
|
|
ec293e54b0 | ||
|
|
93dc6592ac | ||
|
|
8d23502220 | ||
|
|
2607ecf210 | ||
|
|
839bc991a2 | ||
|
|
a54149bd7c | ||
|
|
8aa483f933 | ||
|
|
16689c0a80 | ||
|
|
5889d71db6 | ||
|
|
a6f63f56f3 | ||
|
|
7339df5fcd | ||
|
|
a1d8e761ea | ||
|
|
3524ede480 | ||
|
|
7d8021ca09 | ||
|
|
5552bf97a1 | ||
|
|
a7d1afc70b | ||
|
|
1a9e8b8d36 | ||
|
|
0f62e55474 | ||
|
|
575caa7bc4 | ||
|
|
afe21682c2 | ||
|
|
17249229c8 | ||
|
|
0f8647790d | ||
|
|
fbeb04385b | ||
|
|
9b08c3af4f | ||
|
|
318871033f | ||
|
|
cb4f8ec758 | ||
|
|
feef763035 | ||
|
|
d25dbf58dc | ||
|
|
568586c541 | ||
|
|
4841c27514 | ||
|
|
df0118ac52 | ||
|
|
e89cc3682a | ||
|
|
0822552830 | ||
|
|
ec01a7deb8 | ||
|
|
7adff685c0 | ||
|
|
c4642f1e99 | ||
|
|
51d9dd09c7 | ||
|
|
c1212644b0 | ||
|
|
c82636e1a9 | ||
|
|
9528128ca7 | ||
|
|
7a49b38cae | ||
|
|
c885bbd782 | ||
|
|
8502c1d194 | ||
|
|
8415bb5f37 | ||
|
|
66b85d3fcc | ||
|
|
3edb0d10be | ||
|
|
2dbc35b285 | ||
|
|
8a2cb92d8c | ||
|
|
0275e30103 | ||
|
|
ffdf3b6fc9 | ||
|
|
1defb71568 | ||
|
|
4ef84f2a97 | ||
|
|
84c335a929 | ||
|
|
f2bf577f34 | ||
|
|
565b8dbd6f | ||
|
|
5a102a4a37 | ||
|
|
c9ac348976 | ||
|
|
a311c8feb1 | ||
|
|
ee3c59210d | ||
|
|
769c616706 | ||
|
|
7da287244b | ||
|
|
841c905c06 | ||
|
|
b86b6bb601 | ||
|
|
f67a263ccd | ||
|
|
90cd039d98 | ||
|
|
7ae30d4cfc | ||
|
|
a8814b42d0 |
+5
-1
@@ -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/
|
||||
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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.
@@ -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.
|
||||
@@ -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 |
@@ -0,0 +1 @@
|
||||
1122
|
||||
+10
-10
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
@@ -1,11 +0,0 @@
|
||||
using System.Windows.Controls;
|
||||
|
||||
namespace ytLive.Controls;
|
||||
|
||||
public partial class BottomBar : UserControl
|
||||
{
|
||||
public BottomBar()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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 "made with LlamaCasty!" 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 & 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 & 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 & 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
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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?";
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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.######}";
|
||||
}
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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}";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
@@ -50,7 +50,7 @@
|
||||
<Applications>
|
||||
<Application
|
||||
Id="LlamaCasty"
|
||||
Executable="ytLive.exe"
|
||||
Executable="LlamaCasty.exe"
|
||||
EntryPoint="Windows.FullTrustApplication"
|
||||
uap10:TrustLevel="mediumIL"
|
||||
uap10:RuntimeBehavior="packagedClassicApp">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user