Compare commits
| 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 | ||
|
|
3239808a73 | ||
|
|
24aca7d04a | ||
|
|
b7ab169718 | ||
|
|
818d316477 | ||
|
|
a4dfca6b6c | ||
|
|
096bf6debe | ||
|
|
804b1d2f5f | ||
|
|
ef60f1f3b6 | ||
|
|
c768a8ee49 | ||
|
|
daac7689fd | ||
|
|
20cfadca0c | ||
|
|
b1d6034662 | ||
|
|
826cc17e47 | ||
|
|
8be6a83dab | ||
|
|
987fddac24 | ||
|
|
ef52508b1e | ||
|
|
60b0218fa3 | ||
|
|
6271bf34c9 | ||
|
|
89f6aa3a67 | ||
|
|
2273d20160 | ||
|
|
17aa0273a9 | ||
|
|
3a77662a95 | ||
|
|
ccc1db52d3 | ||
|
|
6be062c45f | ||
|
|
58b9a7d413 | ||
|
|
b1d77ee510 | ||
|
|
fb851f089d | ||
|
|
d1e73a3aad | ||
|
|
2a15be21bd | ||
|
|
cdf82f688d | ||
|
|
84cbec2d19 | ||
|
|
a3a4681aac | ||
|
|
3cb29ea45c | ||
|
|
931ab46783 | ||
|
|
8e1d6f44db | ||
|
|
06f88f4142 | ||
|
|
63bf8444bd | ||
|
|
c2d64dcd6d | ||
|
|
305706956e | ||
|
|
5685c5dc3a | ||
|
|
87f05ba2ce | ||
|
|
b140024ebf | ||
|
|
8303b1cf7f | ||
|
|
352f117cb2 | ||
|
|
de22983b36 | ||
|
|
55210b9a61 | ||
|
|
227ee8fca6 | ||
|
|
fbeb63ba79 | ||
|
|
4e42871fae | ||
|
|
db2fdbf68d | ||
|
|
c6569a7ccb | ||
|
|
f529d47f42 | ||
|
|
9f3c154d84 | ||
|
|
f8dd1079f3 | ||
|
|
ed47480116 | ||
|
|
058bef4312 | ||
|
|
29ef5d5f85 | ||
|
|
d8febbd0d8 | ||
|
|
9fb628b473 | ||
|
|
fe80e8c994 | ||
|
|
948dd08214 | ||
|
|
6bff21b234 | ||
|
|
4b3d82ff0f | ||
|
|
966e718f1e |
@@ -10,9 +10,18 @@ Helpers/OAuthCredentials.cs
|
|||||||
.vs/
|
.vs/
|
||||||
*.DotSettings.user
|
*.DotSettings.user
|
||||||
|
|
||||||
|
# Build artifacts — the MSIX + its staging tree are produced by scripts/package-msix.sh,
|
||||||
|
# never committed (the package embeds the published app and the bundled ffmpeg).
|
||||||
|
artifacts/
|
||||||
|
|
||||||
# Confidential business files (gitignored, not committed)
|
# Confidential business files (gitignored, not committed)
|
||||||
MONETIZATION.md
|
MONETIZATION.md
|
||||||
MARCOM.md
|
MARCOM.md
|
||||||
CREDENTIALS.md
|
CREDENTIALS.md
|
||||||
.aider*
|
.aider*
|
||||||
ffmpeg/
|
ffmpeg/
|
||||||
|
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,7 +1,8 @@
|
|||||||
# AGENTS — how to work in this repo
|
# AGENTS — how to work in this repo
|
||||||
|
|
||||||
This file is auto-read at the start of every session. The project keeps its
|
Auto-read at the start of every session: the rules, the invariants that break the product, and
|
||||||
memory as a linked map of markdown files — read it before doing anything.
|
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
|
## ⚠️ 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.*
|
> *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
|
**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
|
work lands directly on `main`, committed per work unit. No `taskNN-*` branches, no PRs. The scope
|
||||||
`taskNN-*` branches, no PRs. The scope discipline above is about test focus,
|
discipline above is about test focus, not git mechanics.
|
||||||
not git mechanics.
|
|
||||||
|
|
||||||
**Commit / push policy (2026-08-30, user directive):** commit every work unit —
|
**Commit / push policy (2026-08-30, user directive):** commit every work unit —
|
||||||
committing is expected and never needs extra permission. Pushing is different:
|
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
|
session's agreed checkpoint(s), not per-commit. When a milestone warrants a
|
||||||
push the user will say so (or say "push it").
|
push the user will say so (or say "push it").
|
||||||
|
|
||||||
MUST read the architecture guide before planning or modifying the codebase:
|
## Onboarding (in order — and no further)
|
||||||
[`ai.md`](ai.md) (+ [`schema.md`](schema.md) for the memory-map conventions).
|
|
||||||
|
|
||||||
MUST read the coding guide before writing tests or code: the [Working
|
1. [`HANDOFF.md`](HANDOFF.md) — current state: branch, what's in flight, next action, landmines.
|
||||||
rules](#working-rules) in this file — this repo has no `CODING.md`;
|
**Trust it**; no branch hunting, no file-scanning to re-derive what it already says.
|
||||||
conventions live here and in `ai.md`.
|
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).
|
**Why it is this short:** the mandatory read used to be ~56k tokens of map, so sessions paid it
|
||||||
2. [`ai.md`](ai.md) — the AI guide: architecture, patterns, decisions, current state.
|
and then skimmed — which is exactly how stale facts survive. Read the one subsystem you touch.
|
||||||
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.
|
|
||||||
|
|
||||||
**If `HANDOFF.md` exists, trust it as current state** — no `fsck`, no branch
|
## Reading budget
|
||||||
hunting, no file-scanning to re-derive what it already states, unless it points
|
|
||||||
at a problem.
|
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
|
## Working rules
|
||||||
|
|
||||||
- **Never work without the map.** If the map contradicts the code, the code wins
|
- **Never work without the map.** If the map contradicts the code, the code wins and the facts
|
||||||
and the map gets fixed in the same change (stale facts are corrected, not appended).
|
your change **falsifies** are corrected in the same commit (stale facts are corrected, not
|
||||||
- **Every feature change ships with its memory update in the SAME commit:**
|
appended). Cosmetic drift — a stale task number, a heading — may wait for a sweep; it is not a
|
||||||
`ai.md` for architecture/patterns, `TASKS.md` for status, index files when
|
reason to stall a unit.
|
||||||
layout changes. No code commit without its docs — a follow-up "docs backfill"
|
- **Memory rides with the change, not as a unit of its own.** Correct what the change
|
||||||
commit is a broken rule, not a style.
|
falsifies in the same commit; a broad restatement can be batched at a milestone.
|
||||||
- **Rewrite `HANDOFF.md` at session end, compaction, or any interruption.**
|
- **`HANDOFF.md` is ≤40 lines and is overwritten, never appended:** branch, in flight, dirty
|
||||||
Never end a session with uncommitted work unrecorded — the handoff names the
|
files, next action, new landmines. History belongs to `git log`.
|
||||||
branch, the dirty files, and why it stopped.
|
- **The first time a fact costs a hunt, record it where it will be found:** paths / ports /
|
||||||
- **The first time a fact costs a hunt (secrets path, DB path, port, recovery
|
secrets / build commands → `docs/ai/environment.md`; subsystem traps → that subsystem's
|
||||||
source), record it** in `ai.md`/indexes/`HANDOFF.md` so the next session never
|
`docs/ai` file; one-off recipes → `MyMistakes.md` → Recipes registry.
|
||||||
re-hunts it.
|
- **Derived-solution rule (record once, then grep):** when you work out a reusable solution — a
|
||||||
- **Derived-solution rule (record once, then grep):** the moment you work out a
|
recipe, workaround, or how-to (e.g. how to shrink and re-encode an image for the README) —
|
||||||
reusable solution — a recipe, workaround, or how-to (e.g. how to shrink and
|
write it into `MyMistakes.md` **in the same session**, and **grep `MyMistakes.md` first** on
|
||||||
re-encode an image for the README) — write it into `MyMistakes.md` **in the same
|
any "I've done this before but have to figure it out again" wall. An un-recorded solution
|
||||||
session**, before you finish the task. And always **grep `MyMistakes.md` first**
|
guarantees the user hears it derived again (the image-shrink incident, 2026-08-29).
|
||||||
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.
|
|
||||||
- **Follow existing conventions** — MVVM, `RelayCommand` for actions,
|
- **Follow existing conventions** — MVVM, `RelayCommand` for actions,
|
||||||
`ViewModelBase.SetProperty<T>()`, all styles in `Themes/Controls.xaml`
|
`ViewModelBase.SetProperty<T>()`, all styles in `Themes/Controls.xaml`
|
||||||
(merged once in `App.xaml`; never duplicate per-window).
|
(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
|
- **Spin guard (objectively triggered):** a SECOND failed remedy for the same
|
||||||
symptom = STOP. No third guess over the same code. Research the established
|
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`,
|
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
|
then code it. Repeated failure is a research trigger, not a persistence trigger.
|
||||||
trigger, not a persistence trigger.
|
|
||||||
- **Response style: no default planning template.** Do the work, then report
|
- **Response style: no default planning template.** Do the work, then report
|
||||||
what changed and what's next. No "Plans & Pitfalls", pros/cons tables, or
|
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`).
|
step-by-step plans unless the user asks for a plan first. A short diff-style summary beats a
|
||||||
- **No-Fluff Mode is available on request** — unpadded, ruthless review that
|
proposal document every time.
|
||||||
argues rather than reassures (see `ai.md`). Optional, never the default.
|
- **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
|
## 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
|
### Pre-commit audit
|
||||||
|
|
||||||
Before every commit, run `scripts/scope-check.sh` with the declared file list.
|
One gate: `./scripts/verify.sh "<file1>" "<file2>" ...` with the declared scope. It runs the doc
|
||||||
If any file appears in `git diff` but not in the declared scope, either justify
|
budgets, a **clean** build (0 warnings), the test suite, and `scope-check.sh`. Docs-only units run
|
||||||
it or revert the change. The script enforces what the rule demands.
|
`scope-check.sh` alone.
|
||||||
|
|
||||||
```bash
|
```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
|
## 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
|
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
|
`windowsdesktop.app.*` packs over the slow 9p bridge and re-restores twice (WPF
|
||||||
`_wpftmp`), and `--no-restore` right after an interrupted restore produces bogus
|
`_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
|
```bash
|
||||||
"/mnt/c/Program Files/dotnet/dotnet.exe" build "C:\Users\gramp\Documents\Code\projects\ytLive\ytLive.csproj"
|
"/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"
|
<Application x:Class="ytLive.App"
|
||||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:local="clr-namespace:ytLive"
|
xmlns:local="clr-namespace:ytLive"
|
||||||
StartupUri="MainWindow.xaml">
|
StartupUri="MainWindow.xaml">
|
||||||
<Application.Resources>
|
<Application.Resources>
|
||||||
<ResourceDictionary>
|
<ResourceDictionary>
|
||||||
<ResourceDictionary.MergedDictionaries>
|
<ResourceDictionary.MergedDictionaries>
|
||||||
<ResourceDictionary Source="Themes/Controls.xaml"/>
|
<!-- Design tokens first: every style below resolves its colours, sizes and
|
||||||
</ResourceDictionary.MergedDictionaries>
|
radii from them (DESIGN.md is the source of truth). -->
|
||||||
</ResourceDictionary>
|
<ResourceDictionary Source="Themes/Tokens.xaml"/>
|
||||||
</Application.Resources>
|
<ResourceDictionary Source="Themes/Controls.xaml"/>
|
||||||
</Application>
|
</ResourceDictionary.MergedDictionaries>
|
||||||
|
</ResourceDictionary>
|
||||||
|
</Application.Resources>
|
||||||
|
</Application>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
using System.Windows;
|
using System.Windows;
|
||||||
using System.Windows.Threading;
|
using System.Windows.Threading;
|
||||||
using Velopack;
|
|
||||||
using ytLive.Helpers;
|
using ytLive.Helpers;
|
||||||
|
|
||||||
namespace ytLive;
|
namespace ytLive;
|
||||||
@@ -20,30 +19,24 @@ public partial class App : Application
|
|||||||
AppDomain.CurrentDomain.UnhandledException += (_, args) =>
|
AppDomain.CurrentDomain.UnhandledException += (_, args) =>
|
||||||
AppLog.Write(args.ExceptionObject is Exception ex
|
AppLog.Write(args.ExceptionObject is Exception ex
|
||||||
? "AppDomain unhandled exception: " + ex
|
? "AppDomain unhandled exception: " + ex
|
||||||
: "AppDomain unhandled exception object: " + args.ExceptionObject);
|
: "AppDomain unhandled exception object: " + args.ExceptionObject,
|
||||||
|
AppLogMessageType.Error, AppLogErrorClass.Class1);
|
||||||
|
|
||||||
DispatcherUnhandledException += (_, args) =>
|
DispatcherUnhandledException += (_, args) =>
|
||||||
AppLog.Write(args.Exception, "Dispatcher unhandled exception");
|
AppLog.Write(args.Exception, "Dispatcher unhandled exception", AppLogErrorClass.Class1);
|
||||||
|
|
||||||
AppLog.Write("App OnStartup begin");
|
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
|
// Attribution (2026-09-04): every take must be traceable to the exact binary
|
||||||
// that produced it — the wordmark shows the release counter (BuildStamp.Release);
|
// that produced it — the wordmark shows the release counter (BuildStamp.Release);
|
||||||
// the per-build GUID is forensic only and lands here.
|
// the per-build GUID is forensic only and lands here.
|
||||||
AppLog.Write($"Build {ytLive.Helpers.BuildStamp.Id} (compiled {ytLive.Helpers.BuildStamp.BuiltLocal})");
|
AppLog.Write($"Build {ytLive.Helpers.BuildStamp.Id} (compiled {ytLive.Helpers.BuildStamp.BuiltLocal})");
|
||||||
|
|
||||||
// Velopack auto-update bootstrap — checks for updates on startup and
|
// There is deliberately NO self-update bootstrap here: the Microsoft Store updates the
|
||||||
// applies them (the app restarts automatically if an update is found).
|
// app (TASK 48 — Store MSIX route), and a second updater would fight it.
|
||||||
// Update URL is configured per-deployment (self-hosted on DO droplet);
|
|
||||||
// when no URL is set, the check is a no-op.
|
|
||||||
try
|
|
||||||
{
|
|
||||||
VelopackApp.Build()
|
|
||||||
.Run();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
AppLog.Write($"Velopack bootstrap failed (non-fatal): {ex.Message}");
|
|
||||||
}
|
|
||||||
|
|
||||||
base.OnStartup(e);
|
base.OnStartup(e);
|
||||||
AppLog.Write("App OnStartup end");
|
AppLog.Write("App OnStartup end");
|
||||||
|
|||||||
@@ -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.
|
||||||
|
After Width: | Height: | Size: 2.8 MiB |
|
After Width: | Height: | Size: 423 KiB |
|
After Width: | Height: | Size: 422 KiB |
|
After Width: | Height: | Size: 660 KiB |
|
After Width: | Height: | Size: 717 KiB |
|
After Width: | Height: | Size: 560 KiB |
|
After Width: | Height: | Size: 553 KiB |
|
Before Width: | Height: | Size: 2.8 MiB After Width: | Height: | Size: 2.5 MiB |
|
After Width: | Height: | Size: 378 KiB |
@@ -0,0 +1 @@
|
|||||||
|
1122
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
<Window x:Class="ytLive.CameraPickerDialog"
|
<Window x:Class="ytLive.CameraPickerDialog"
|
||||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
Title="Add Webcam" Height="340" Width="460"
|
Title="Add Webcam" Height="340" Width="460"
|
||||||
Icon="/Assets/llamacasty-icon.png"
|
Icon="/Assets/llamacasty-icon.png"
|
||||||
WindowStartupLocation="CenterOwner" ResizeMode="NoResize"
|
WindowStartupLocation="CenterOwner" ResizeMode="NoResize"
|
||||||
ShowInTaskbar="False" Background="#1a1a2e">
|
ShowInTaskbar="False" Background="{StaticResource Brush.Background}">
|
||||||
|
|
||||||
<Window.Resources>
|
<Window.Resources>
|
||||||
<ResourceDictionary>
|
<ResourceDictionary>
|
||||||
@@ -21,10 +21,10 @@
|
|||||||
</Border>
|
</Border>
|
||||||
<ControlTemplate.Triggers>
|
<ControlTemplate.Triggers>
|
||||||
<Trigger Property="IsMouseOver" Value="True">
|
<Trigger Property="IsMouseOver" Value="True">
|
||||||
<Setter TargetName="Bd" Property="Background" Value="#1c2a52"/>
|
<Setter TargetName="Bd" Property="Background" Value="{StaticResource Brush.SurfaceContainerHigh}"/>
|
||||||
</Trigger>
|
</Trigger>
|
||||||
<Trigger Property="IsSelected" Value="True">
|
<Trigger Property="IsSelected" Value="True">
|
||||||
<Setter TargetName="Bd" Property="Background" Value="#2a2450"/>
|
<Setter TargetName="Bd" Property="Background" Value="{StaticResource Brush.SurfaceContainerHigh}"/>
|
||||||
</Trigger>
|
</Trigger>
|
||||||
</ControlTemplate.Triggers>
|
</ControlTemplate.Triggers>
|
||||||
</ControlTemplate>
|
</ControlTemplate>
|
||||||
@@ -43,10 +43,10 @@
|
|||||||
</Grid.RowDefinitions>
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
<TextBlock Grid.Row="0" Text="Add Webcam" FontSize="18" FontWeight="Bold"
|
<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."
|
<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">
|
<Grid Grid.Row="2">
|
||||||
<ListBox Background="Transparent" BorderThickness="0"
|
<ListBox Background="Transparent" BorderThickness="0"
|
||||||
@@ -58,7 +58,7 @@
|
|||||||
<DataTemplate>
|
<DataTemplate>
|
||||||
<StackPanel Orientation="Horizontal">
|
<StackPanel Orientation="Horizontal">
|
||||||
<TextBlock Text="📷" FontSize="15" VerticalAlignment="Center" Margin="0,0,10,0"/>
|
<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"/>
|
VerticalAlignment="Center" TextTrimming="CharacterEllipsis"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
@@ -67,15 +67,15 @@
|
|||||||
|
|
||||||
<StackPanel VerticalAlignment="Center" HorizontalAlignment="Center"
|
<StackPanel VerticalAlignment="Center" HorizontalAlignment="Center"
|
||||||
Visibility="{Binding IsLoading, Converter={StaticResource BoolToVis}}">
|
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>
|
||||||
|
|
||||||
<StackPanel VerticalAlignment="Center"
|
<StackPanel VerticalAlignment="Center"
|
||||||
Visibility="{Binding NoCameras, Converter={StaticResource BoolToVis}}">
|
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"/>
|
FontWeight="SemiBold" HorizontalAlignment="Center"/>
|
||||||
<TextBlock Text="Check that a webcam is connected and that Windows camera access is enabled."
|
<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"/>
|
TextAlignment="Center" HorizontalAlignment="Center" Margin="0,8,0,0"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Grid>
|
</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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ using System.Windows;
|
|||||||
using System.Windows.Controls;
|
using System.Windows.Controls;
|
||||||
using System.Windows.Input;
|
using System.Windows.Input;
|
||||||
using System.Windows.Media;
|
using System.Windows.Media;
|
||||||
|
using ytLive.Helpers;
|
||||||
using ytLive.Models;
|
using ytLive.Models;
|
||||||
using ytLive.ViewModels;
|
using ytLive.ViewModels;
|
||||||
|
|
||||||
@@ -54,9 +55,12 @@ public partial class LeftPanel : UserControl
|
|||||||
{
|
{
|
||||||
while (child != null && !ReferenceEquals(child, ancestor))
|
while (child != null && !ReferenceEquals(child, ancestor))
|
||||||
{
|
{
|
||||||
child = child is Visual or System.Windows.Media.Media3D.Visual3D
|
var next = child is Visual or System.Windows.Media.Media3D.Visual3D
|
||||||
? VisualTreeHelper.GetParent(child)
|
? VisualTreeHelper.GetParent(child)
|
||||||
: LogicalTreeHelper.GetParent(child);
|
: null;
|
||||||
|
child = next
|
||||||
|
?? LogicalTreeHelper.GetParent(child)
|
||||||
|
?? (child as FrameworkElement)?.TemplatedParent;
|
||||||
}
|
}
|
||||||
return child != null;
|
return child != null;
|
||||||
}
|
}
|
||||||
@@ -94,12 +98,6 @@ public partial class LeftPanel : UserControl
|
|||||||
webcam.IsMirrored = !webcam.IsMirrored;
|
webcam.IsMirrored = !webcam.IsMirrored;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ShapeButton_Click(object sender, RoutedEventArgs e)
|
|
||||||
{
|
|
||||||
if ((DataContext as MainViewModel)?.SelectedElement is WebcamSceneConfig webcam)
|
|
||||||
webcam.ToggleClipShape();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void SourceMenu_Remove(object sender, RoutedEventArgs e)
|
private void SourceMenu_Remove(object sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
if (sender is MenuItem { DataContext: SceneElement element } && DataContext is MainViewModel vm)
|
if (sender is MenuItem { DataContext: SceneElement element } && DataContext is MainViewModel vm)
|
||||||
@@ -181,8 +179,9 @@ public partial class LeftPanel : UserControl
|
|||||||
var position = e.GetPosition(listBox);
|
var position = e.GetPosition(listBox);
|
||||||
if (!_isDragging)
|
if (!_isDragging)
|
||||||
{
|
{
|
||||||
if (Math.Abs(position.X - _dragStartPoint.X) < SystemParameters.MinimumHorizontalDragDistance &&
|
if (!DragReorderMath.ExceededThreshold(
|
||||||
Math.Abs(position.Y - _dragStartPoint.Y) < SystemParameters.MinimumVerticalDragDistance)
|
position.X - _dragStartPoint.X, position.Y - _dragStartPoint.Y,
|
||||||
|
SystemParameters.MinimumHorizontalDragDistance, SystemParameters.MinimumVerticalDragDistance))
|
||||||
return;
|
return;
|
||||||
_isDragging = true;
|
_isDragging = true;
|
||||||
listBox.CaptureMouse();
|
listBox.CaptureMouse();
|
||||||
@@ -196,12 +195,10 @@ public partial class LeftPanel : UserControl
|
|||||||
return;
|
return;
|
||||||
|
|
||||||
var targetIndex = listBox.Items.IndexOf(targetItem);
|
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];
|
_dragIndex = landed;
|
||||||
items.RemoveAt(_dragIndex);
|
|
||||||
items.Insert(targetIndex, dragged);
|
|
||||||
_dragIndex = targetIndex;
|
|
||||||
_lastHoveredItem = targetItem;
|
_lastHoveredItem = targetItem;
|
||||||
_dragReordered = true;
|
_dragReordered = true;
|
||||||
}
|
}
|
||||||
@@ -247,36 +244,6 @@ public partial class LeftPanel : UserControl
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void WebcamBorderColorPicker_Click(object sender, RoutedEventArgs e)
|
|
||||||
{
|
|
||||||
if ((DataContext as MainViewModel)?.SelectedElement is not SceneElement src) return;
|
|
||||||
if (sender is FrameworkElement fe && fe.ContextMenu == null)
|
|
||||||
{
|
|
||||||
var menu = new System.Windows.Controls.ContextMenu();
|
|
||||||
string[] colors = ["#FFFFFF", "#000000", "#FF0000", "#00FF00", "#0000FF",
|
|
||||||
"#FFFF00", "#FF00FF", "#00FFFF", "#FF8800", "#8800FF",
|
|
||||||
"#e94560", "#22c55e", "#3b82f6", "#f59e0b"];
|
|
||||||
foreach (var c in colors)
|
|
||||||
{
|
|
||||||
var item = new System.Windows.Controls.MenuItem { Header = c, ToolTip = c };
|
|
||||||
var brush = new System.Windows.Media.SolidColorBrush(
|
|
||||||
(System.Windows.Media.Color)System.Windows.Media.ColorConverter.ConvertFromString(c));
|
|
||||||
item.Icon = new System.Windows.Controls.Border
|
|
||||||
{
|
|
||||||
Width = 16, Height = 16,
|
|
||||||
Background = brush,
|
|
||||||
CornerRadius = new System.Windows.CornerRadius(2)
|
|
||||||
};
|
|
||||||
var color = c;
|
|
||||||
item.Click += (_, _) => src.BorderColor = color;
|
|
||||||
menu.Items.Add(item);
|
|
||||||
}
|
|
||||||
fe.ContextMenu = menu;
|
|
||||||
}
|
|
||||||
if (sender is FrameworkElement fe2)
|
|
||||||
fe2.ContextMenu.IsOpen = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void WebcamBorderRevert_Click(object sender, RoutedEventArgs e)
|
private void WebcamBorderRevert_Click(object sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
if (_snapshotElement is Models.Source src)
|
if (_snapshotElement is Models.Source src)
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
<UserControl x:Class="ytLive.Controls.OverlayHost"
|
<UserControl x:Class="ytLive.Controls.OverlayHost"
|
||||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:Helpers="clr-namespace:ytLive.Helpers">
|
xmlns:Helpers="clr-namespace:ytLive.Helpers"
|
||||||
|
xmlns:Models="clr-namespace:ytLive.Models"
|
||||||
|
xmlns:services="clr-namespace:ytLive.Services">
|
||||||
<Grid Grid.RowSpan="3"
|
<Grid Grid.RowSpan="3"
|
||||||
Visibility="{Binding IsAnyOverlayOpen, Converter={StaticResource BoolToVis}}">
|
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}">
|
Command="{Binding CloseOverlayCommand}">
|
||||||
<Button.Template>
|
<Button.Template>
|
||||||
<ControlTemplate TargetType="Button">
|
<ControlTemplate TargetType="Button">
|
||||||
@@ -13,45 +15,28 @@
|
|||||||
</Button.Template>
|
</Button.Template>
|
||||||
</Button>
|
</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"
|
VerticalAlignment="Center" HorizontalAlignment="Center"
|
||||||
Padding="28" BorderBrush="#333" BorderThickness="1">
|
Padding="28" BorderBrush="#333" BorderThickness="1">
|
||||||
<Grid>
|
<Grid>
|
||||||
<Grid.RowDefinitions>
|
<Grid.RowDefinitions>
|
||||||
<RowDefinition Height="Auto"/>
|
<RowDefinition Height="Auto"/>
|
||||||
<RowDefinition Height="Auto"/>
|
<RowDefinition Height="*"/>
|
||||||
</Grid.RowDefinitions>
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<Grid Grid.Row="0">
|
<Grid Grid.Row="0">
|
||||||
<TextBlock Text="{Binding OverlayTitle}" FontSize="18" FontWeight="Bold"
|
<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}"
|
<Button HorizontalAlignment="Right" ToolTip="Close" Style="{StaticResource IconButton}"
|
||||||
Command="{Binding CloseOverlayCommand}">
|
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"
|
<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>
|
</Button>
|
||||||
</Grid>
|
</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">
|
|
||||||
<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>
|
|
||||||
</StackPanel>
|
|
||||||
|
|
||||||
<!-- Report Bug -->
|
<!-- 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}}">
|
Visibility="{Binding IsBugOpen, Converter={StaticResource BoolToVis}}">
|
||||||
<TextBlock Text="What went wrong?" Style="{StaticResource YtLabel}" Margin="0,0,0,4"/>
|
<TextBlock Text="What went wrong?" Style="{StaticResource YtLabel}" Margin="0,0,0,4"/>
|
||||||
<TextBox Style="{StaticResource YtTextBox}" Height="110" AcceptsReturn="True" TextWrapping="Wrap"
|
<TextBox Style="{StaticResource YtTextBox}" Height="110" AcceptsReturn="True" TextWrapping="Wrap"
|
||||||
@@ -88,56 +73,103 @@
|
|||||||
|
|
||||||
<!-- About -->
|
<!-- About -->
|
||||||
<Grid Grid.Row="1" Margin="0,16,0,0"
|
<Grid Grid.Row="1" Margin="0,16,0,0"
|
||||||
Background="#171928"
|
Background="{StaticResource Brush.SurfaceContainerLow}"
|
||||||
Visibility="{Binding IsAboutOpen, Converter={StaticResource BoolToVis}}">
|
Visibility="{Binding IsAboutOpen, Converter={StaticResource BoolToVis}}">
|
||||||
|
|
||||||
<!-- Hub view -->
|
<!-- Hub view — a conversion surface, not a credits page (creator, 2026-11-05):
|
||||||
<StackPanel Visibility="{Binding IsHubVisible, Converter={StaticResource BoolToVis}}">
|
large logo, one tagline, the offer in three short blocks, one primary CTA,
|
||||||
<Image Source="/ytLive;component/Assets/llamacasty-logo.jpg" Height="125" Stretch="Uniform"
|
everything else demoted. A paying customer never sees the price. -->
|
||||||
HorizontalAlignment="Center" Margin="0,0,0,12"/>
|
<ScrollViewer VerticalScrollBarVisibility="Auto"
|
||||||
<TextBlock Text="{Binding AppVersionLabel}" Foreground="#a0a0b0" FontSize="12"
|
Visibility="{Binding IsHubVisible, Converter={StaticResource BoolToVis}}">
|
||||||
HorizontalAlignment="Center" Margin="0,4,0,0"/>
|
<StackPanel>
|
||||||
<TextBlock Text="Live streaming for YouTube — simple enough that even the most right-brained person can intuit it."
|
<Image Source="/LlamaCasty;component/Assets/llamacasty-logo.jpg"
|
||||||
Foreground="#a0a0b0" FontSize="13" TextWrapping="Wrap" MaxWidth="380"
|
Width="520" Stretch="Uniform" HorizontalAlignment="Center" Margin="0,0,0,10"/>
|
||||||
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"/>
|
|
||||||
|
|
||||||
<!-- Creator hub links -->
|
<Button x:Name="ReleaseBadgeButton" Content="{Binding AppReleaseLabel}"
|
||||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center" Margin="0,0,0,8">
|
Style="{StaticResource PillBadgeButton}"
|
||||||
<Button Content="llama chile shop on YouTube" Command="{Binding OpenChannelCommand}"
|
Command="{Binding OpenPremiumCommand}"
|
||||||
Style="{StaticResource YtButtonSecondary}" Margin="0,0,8,0"/>
|
HorizontalAlignment="Center" Margin="0,0,0,10"
|
||||||
<Button Content="Mastodon" Command="{Binding OpenMastodonCommand}"
|
ToolTip="Pre-release — opens llamacasty.com when clicked"/>
|
||||||
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 Content="Licenses & legal" Command="{Binding OpenLicensesCommand}"
|
<TextBlock x:Name="AboutTagline"
|
||||||
Style="{StaticResource YtButtonSecondary}" HorizontalAlignment="Center"
|
Text="1-2-3, and go live to the world on YouTube!"
|
||||||
Margin="0,16,0,0"/>
|
Foreground="{StaticResource Brush.OnSurface}" FontSize="17" FontWeight="SemiBold"
|
||||||
</StackPanel>
|
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 -->
|
<!-- Licensing/legal panel -->
|
||||||
<StackPanel Visibility="{Binding IsLicensesOpen, Converter={StaticResource BoolToVis}}">
|
<StackPanel Visibility="{Binding IsLicensesOpen, Converter={StaticResource BoolToVis}}">
|
||||||
<TextBlock Text="Third-party notices & license texts" FontSize="15" FontWeight="Bold"
|
<TextBlock Text="Third-party notices & license texts" FontSize="15" FontWeight="Bold"
|
||||||
Foreground="#e0e0e0" Margin="0,0,0,8"/>
|
Foreground="{StaticResource Brush.OnSurface}" Margin="0,0,0,8"/>
|
||||||
<Border Background="#10101e" CornerRadius="4" Padding="10" MaxHeight="360"
|
<Border Background="{StaticResource Brush.SurfaceContainerLowest}" CornerRadius="4" Padding="10" MaxHeight="360"
|
||||||
BorderBrush="#333" BorderThickness="1">
|
BorderBrush="#333" BorderThickness="1">
|
||||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
<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"/>
|
FontFamily="Consolas" TextWrapping="Wrap"/>
|
||||||
</ScrollViewer>
|
</ScrollViewer>
|
||||||
</Border>
|
</Border>
|
||||||
@@ -146,25 +178,6 @@
|
|||||||
Click="BackToAbout_Click"/>
|
Click="BackToAbout_Click"/>
|
||||||
</StackPanel>
|
</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>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ public partial class PreviewPane : UserControl
|
|||||||
{
|
{
|
||||||
private bool _isDraggingOverlay;
|
private bool _isDraggingOverlay;
|
||||||
private bool _isResizing;
|
private bool _isResizing;
|
||||||
|
private bool _isZoomDragging;
|
||||||
private Point _grabOffset;
|
private Point _grabOffset;
|
||||||
private double _resizeAspect;
|
private double _resizeAspect;
|
||||||
|
|
||||||
@@ -29,8 +30,94 @@ public partial class PreviewPane : UserControl
|
|||||||
public PreviewPane()
|
public PreviewPane()
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
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.
|
// Static images and the webcam share the move/resize/selection behavior.
|
||||||
public static bool IsDraggableElement(SceneElement element)
|
public static bool IsDraggableElement(SceneElement element)
|
||||||
=> element.IsDraggable;
|
=> element.IsDraggable;
|
||||||
@@ -122,13 +209,6 @@ public partial class PreviewPane : UserControl
|
|||||||
return null;
|
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)
|
private void GameSpeaker_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
|
||||||
{
|
{
|
||||||
@@ -141,18 +221,7 @@ public partial class PreviewPane : UserControl
|
|||||||
// TRAX (TASK 9): left-click toggles play/pause (picks a track when none is
|
// 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
|
// 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.
|
// 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) ───
|
// ─── Webcam / background context menus (right-click on overlay elements) ───
|
||||||
private void WebcamMenu_ChangeWebcam(object sender, RoutedEventArgs e)
|
private void WebcamMenu_ChangeWebcam(object sender, RoutedEventArgs e)
|
||||||
@@ -279,6 +348,34 @@ public partial class PreviewPane : UserControl
|
|||||||
sb.Children.Add(anim);
|
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)
|
private void WebcamMenu_SetBorderColor(object sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
if (sender is MenuItem { DataContext: SceneElement element, Tag: string color })
|
if (sender is MenuItem { DataContext: SceneElement element, Tag: string color })
|
||||||
@@ -306,6 +403,18 @@ public partial class PreviewPane : UserControl
|
|||||||
e.Handled = true;
|
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)
|
private void SocialsButton_MouseEnter(object sender, MouseEventArgs e)
|
||||||
{
|
{
|
||||||
if (SocialBarElement == null) return;
|
if (SocialBarElement == null) return;
|
||||||
@@ -355,14 +464,35 @@ public partial class PreviewPane : UserControl
|
|||||||
private void Preview_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
|
private void Preview_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
|
||||||
{
|
{
|
||||||
var vm = DataContext as MainViewModel;
|
var vm = DataContext as MainViewModel;
|
||||||
if (vm is null || !vm.IsEditMode) return;
|
if (vm is null) return;
|
||||||
|
|
||||||
var grid = (Grid)sender;
|
var grid = (Grid)sender;
|
||||||
var toCanvas = CanvasGrid.TransformToVisual(grid).Inverse;
|
var toCanvas = CanvasGrid.TransformToVisual(grid).Inverse;
|
||||||
var p = toCanvas.Transform(e.GetPosition(grid));
|
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;
|
var selected = vm.SelectedElement;
|
||||||
|
|
||||||
if (selected is { } sel && IsDraggableElement(sel) && HitHandle(e.GetPosition(grid), sel))
|
if (selected is { } sel && IsDraggableElement(sel) && HitHandle(e.GetPosition(grid), sel))
|
||||||
{
|
{
|
||||||
|
vm.PushPreviewUndo();
|
||||||
_isResizing = true;
|
_isResizing = true;
|
||||||
_resizeAspect = sel.ClipShape == ClipShape.Round ? 1 : sel.Width / Math.Max(1, sel.Height);
|
_resizeAspect = sel.ClipShape == ClipShape.Round ? 1 : sel.Width / Math.Max(1, sel.Height);
|
||||||
grid.CaptureMouse();
|
grid.CaptureMouse();
|
||||||
@@ -370,10 +500,13 @@ public partial class PreviewPane : UserControl
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var hit = HitElement(vm, p);
|
var hit = HitElementAt(vm, p);
|
||||||
if (hit != null)
|
if (hit != null)
|
||||||
{
|
{
|
||||||
vm.SelectedElement = hit;
|
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;
|
_isDraggingOverlay = true;
|
||||||
_grabOffset = new Point(p.X - hit.X, p.Y - hit.Y);
|
_grabOffset = new Point(p.X - hit.X, p.Y - hit.Y);
|
||||||
grid.CaptureMouse();
|
grid.CaptureMouse();
|
||||||
@@ -387,12 +520,29 @@ public partial class PreviewPane : UserControl
|
|||||||
private void Preview_MouseMove(object sender, MouseEventArgs e)
|
private void Preview_MouseMove(object sender, MouseEventArgs e)
|
||||||
{
|
{
|
||||||
var vm = DataContext as MainViewModel;
|
var vm = DataContext as MainViewModel;
|
||||||
if (vm is null || !vm.IsEditMode) return;
|
if (vm is null) return;
|
||||||
if (!_isDraggingOverlay && !_isResizing) return;
|
|
||||||
|
|
||||||
var grid = (Grid)sender;
|
var grid = (Grid)sender;
|
||||||
var toCanvas = CanvasGrid.TransformToVisual(grid).Inverse;
|
var toCanvas = CanvasGrid.TransformToVisual(grid).Inverse;
|
||||||
var p = toCanvas.Transform(e.GetPosition(grid));
|
var p = toCanvas.Transform(e.GetPosition(grid));
|
||||||
|
|
||||||
|
if (e.LeftButton == MouseButtonState.Pressed && vm.IsZoomActive)
|
||||||
|
{
|
||||||
|
if (!_isZoomDragging)
|
||||||
|
{
|
||||||
|
vm.StartZoomMove(p.X, p.Y);
|
||||||
|
_isZoomDragging = true;
|
||||||
|
grid.CaptureMouse();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
vm.UpdateZoomMove(p.X, p.Y);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!vm.IsEditMode) return;
|
||||||
|
if (!_isDraggingOverlay && !_isResizing) return;
|
||||||
|
|
||||||
var selected = vm.SelectedElement;
|
var selected = vm.SelectedElement;
|
||||||
if (selected == null)
|
if (selected == null)
|
||||||
{
|
{
|
||||||
@@ -402,12 +552,22 @@ public partial class PreviewPane : UserControl
|
|||||||
|
|
||||||
if (_isDraggingOverlay)
|
if (_isDraggingOverlay)
|
||||||
{
|
{
|
||||||
var minX = -(selected.Width - 20);
|
// TASK 57: under the vertical tier the camera snaps into the
|
||||||
var maxX = 1920 - 20;
|
// cropped band (one of six spots) instead of free-floating over
|
||||||
var minY = -(selected.Height - 20);
|
// the master — outside the band it is not in the stream at all.
|
||||||
var maxY = 1080 - 20;
|
if (vm.IsVerticalTier && selected is WebcamSceneConfig verticalCam)
|
||||||
selected.X = Math.Clamp(p.X - _grabOffset.X, Math.Min(minX, maxX), Math.Max(minX, maxX));
|
{
|
||||||
selected.Y = Math.Clamp(p.Y - _grabOffset.Y, Math.Min(minY, maxY), Math.Max(minY, maxY));
|
vm.MoveWebcamInVerticalTier(verticalCam, p.X - _grabOffset.X, p.Y - _grabOffset.Y);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var minX = -(selected.Width - 20);
|
||||||
|
var maxX = 1920 - 20;
|
||||||
|
var minY = -(selected.Height - 20);
|
||||||
|
var maxY = 1080 - 20;
|
||||||
|
selected.X = Math.Clamp(p.X - _grabOffset.X, Math.Min(minX, maxX), Math.Max(minX, maxX));
|
||||||
|
selected.Y = Math.Clamp(p.Y - _grabOffset.Y, Math.Min(minY, maxY), Math.Max(minY, maxY));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else if (_isResizing)
|
else if (_isResizing)
|
||||||
{
|
{
|
||||||
@@ -425,18 +585,80 @@ public partial class PreviewPane : UserControl
|
|||||||
}
|
}
|
||||||
selected.Width = newW;
|
selected.Width = newW;
|
||||||
selected.Height = newH;
|
selected.Height = newH;
|
||||||
|
|
||||||
|
// TASK 57: inside the vertical band the size snaps (the Chat
|
||||||
|
// camera has two sizes: the small tile or one whole area) and the
|
||||||
|
// camera ends up on its spot.
|
||||||
|
if (vm.IsVerticalTier && selected is WebcamSceneConfig verticalCam)
|
||||||
|
vm.ResizeWebcamInVerticalTier(verticalCam, newW, newH);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void Preview_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
|
private void Preview_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
|
||||||
=> EndPreviewDrag((Grid)sender);
|
{
|
||||||
|
if (_isZoomDragging && DataContext is MainViewModel vm)
|
||||||
|
{
|
||||||
|
vm.EndZoomMove();
|
||||||
|
_isZoomDragging = false;
|
||||||
|
((Grid)sender).ReleaseMouseCapture();
|
||||||
|
e.Handled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
EndPreviewDrag((Grid)sender);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 is null) return;
|
||||||
|
if ((Keyboard.Modifiers & ModifierKeys.Control) == 0) return;
|
||||||
|
|
||||||
|
if (!vm.IsEditMode)
|
||||||
|
{
|
||||||
|
e.Handled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
if (_isZoomDragging)
|
||||||
|
{
|
||||||
|
var grid = (Grid)sender;
|
||||||
|
grid.ReleaseMouseCapture();
|
||||||
|
_isZoomDragging = false;
|
||||||
|
if (DataContext is MainViewModel vm) vm.EndZoomMove();
|
||||||
|
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)
|
private void EndPreviewDrag(Grid grid)
|
||||||
{
|
{
|
||||||
if (_isDraggingOverlay || _isResizing)
|
if (_isDraggingOverlay || _isResizing || _isZoomDragging)
|
||||||
grid.ReleaseMouseCapture();
|
grid.ReleaseMouseCapture();
|
||||||
_isDraggingOverlay = false;
|
_isDraggingOverlay = false;
|
||||||
_isResizing = false;
|
_isResizing = false;
|
||||||
|
_isZoomDragging = false;
|
||||||
|
if (DataContext is MainViewModel vm) vm.EndZoomMove();
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool HitHandle(Point mouseScreen, SceneElement element)
|
private bool HitHandle(Point mouseScreen, SceneElement element)
|
||||||
@@ -445,20 +667,173 @@ public partial class PreviewPane : UserControl
|
|||||||
return Math.Abs(mouseScreen.X - corner.X) <= 20 && Math.Abs(mouseScreen.Y - corner.Y) <= 20;
|
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;
|
var scene = vm.StagedScene;
|
||||||
if (scene == null) return null;
|
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--)
|
for (var i = scene.Elements.Count - 1; i >= 0; i--)
|
||||||
{
|
{
|
||||||
var element = scene.Elements[i];
|
var element = scene.Elements[i];
|
||||||
if (!IsDraggableElement(element)) continue;
|
if (IsHitTestable(element) && Contains(element, p))
|
||||||
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)
|
|
||||||
return element;
|
return element;
|
||||||
}
|
}
|
||||||
return null;
|
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;
|
||||||
|
if (vm.IsZoomActive && ZoomVisualBrush != null)
|
||||||
|
{
|
||||||
|
// The preview mirrors the output zoom layer: sample the WHOLE composed
|
||||||
|
// preview (CanvasGrid = backdrop + every element layer — the same all-layer
|
||||||
|
// source the output compositor magnifies) at the FIXED capture window (the
|
||||||
|
// box/2 region centred on the click) → 2x via Stretch=Fill. Moving the box
|
||||||
|
// only moves the ZoomBox element; the Viewbox stays glued to the capture.
|
||||||
|
ZoomVisualBrush.Visual = CanvasGrid;
|
||||||
|
ZoomVisualBrush.Viewbox = new Rect(
|
||||||
|
vm.ZoomState.CaptureX,
|
||||||
|
vm.ZoomState.CaptureY,
|
||||||
|
vm.ZoomState.Width / 2,
|
||||||
|
vm.ZoomState.Height / 2);
|
||||||
|
ZoomVisualBrush.ViewboxUnits = BrushMappingMode.Absolute;
|
||||||
|
if (vm.ZoomShape == Models.ZoomShape.Circle)
|
||||||
|
{
|
||||||
|
ZoomCircleOutline.Visibility = Visibility.Visible;
|
||||||
|
ZoomRectOutline.Visibility = Visibility.Collapsed;
|
||||||
|
ZoomBox.Clip = new EllipseGeometry(new Rect(0, 0, vm.ZoomState.Width, vm.ZoomState.Height));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ZoomCircleOutline.Visibility = Visibility.Collapsed;
|
||||||
|
ZoomRectOutline.Visibility = Visibility.Visible;
|
||||||
|
ZoomBox.Clip = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (!vm.IsZoomActive && ZoomVisualBrush != null && ReferenceEquals(ZoomVisualBrush.Visual, CanvasGrid))
|
||||||
|
{
|
||||||
|
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,54 +1,87 @@
|
|||||||
<UserControl x:Class="ytLive.Controls.SceneThumbnailStrip"
|
<UserControl x:Class="ytLive.Controls.SceneThumbnailStrip"
|
||||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||||
<Border Background="#0d1117" CornerRadius="4" Margin="0,0,0,4" Padding="4">
|
<Border Background="{StaticResource Brush.SurfaceContainerLowest}" CornerRadius="4" Padding="4">
|
||||||
<ItemsControl ItemsSource="{Binding Scenes}">
|
<ItemsControl ItemsSource="{Binding Scenes}" HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||||
<ItemsControl.ItemsPanel>
|
<ItemsControl.ItemsPanel>
|
||||||
<ItemsPanelTemplate>
|
<ItemsPanelTemplate>
|
||||||
<UniformGrid Rows="1" Columns="5"/>
|
<StackPanel Orientation="Horizontal"/>
|
||||||
</ItemsPanelTemplate>
|
</ItemsPanelTemplate>
|
||||||
</ItemsControl.ItemsPanel>
|
</ItemsControl.ItemsPanel>
|
||||||
<ItemsControl.ItemTemplate>
|
<ItemsControl.ItemTemplate>
|
||||||
<DataTemplate>
|
<DataTemplate>
|
||||||
<Border CornerRadius="4" Margin="2" Padding="2"
|
<Border Width="196" Height="130" CornerRadius="4" Margin="2" Padding="2"
|
||||||
BorderThickness="2"
|
BorderThickness="2"
|
||||||
BorderBrush="Transparent"
|
|
||||||
Cursor="Hand"
|
Cursor="Hand"
|
||||||
MouseLeftButtonDown="Thumbnail_MouseLeftButtonDown">
|
MouseLeftButtonDown="Thumbnail_MouseLeftButtonDown">
|
||||||
<Border.Style>
|
<Border.Style>
|
||||||
<Style TargetType="Border">
|
<Style TargetType="Border">
|
||||||
<Setter Property="BorderBrush" Value="#333344"/>
|
<Setter Property="BorderBrush" Value="{StaticResource Brush.Outline}"/>
|
||||||
<Style.Triggers>
|
<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">
|
<DataTrigger Binding="{Binding IsStaged}" Value="True">
|
||||||
<Setter Property="BorderBrush" Value="#e94560"/>
|
<Setter Property="BorderBrush" Value="{StaticResource Brush.Attention}"/>
|
||||||
</DataTrigger>
|
</DataTrigger>
|
||||||
<DataTrigger Binding="{Binding IsLive}" Value="True">
|
<DataTrigger Binding="{Binding IsLive}" Value="True">
|
||||||
<Setter Property="BorderBrush" Value="#22c55e"/>
|
<Setter Property="BorderBrush" Value="{StaticResource Brush.Primary}"/>
|
||||||
</DataTrigger>
|
</DataTrigger>
|
||||||
</Style.Triggers>
|
</Style.Triggers>
|
||||||
</Style>
|
</Style>
|
||||||
</Border.Style>
|
</Border.Style>
|
||||||
<Grid>
|
<Grid>
|
||||||
<Grid.RowDefinitions>
|
<Border CornerRadius="3" Background="{StaticResource Brush.SurfaceContainerLowest}" ClipToBounds="True">
|
||||||
<RowDefinition Height="*"/>
|
|
||||||
<RowDefinition Height="Auto"/>
|
|
||||||
</Grid.RowDefinitions>
|
|
||||||
<Border Grid.Row="0" CornerRadius="3"
|
|
||||||
Background="#0a0a1a" ClipToBounds="True">
|
|
||||||
<Image Source="{Binding Snapshot}"
|
<Image Source="{Binding Snapshot}"
|
||||||
Stretch="UniformToFill"
|
Stretch="UniformToFill"
|
||||||
RenderOptions.BitmapScalingMode="LowQuality"/>
|
RenderOptions.BitmapScalingMode="LowQuality"/>
|
||||||
</Border>
|
</Border>
|
||||||
<TextBlock Grid.Row="1" Foreground="#a0a0b0" FontSize="10"
|
|
||||||
HorizontalAlignment="Center"
|
<Border HorizontalAlignment="Left" VerticalAlignment="Top" Margin="6" Padding="8,3"
|
||||||
Margin="0,2,0,0"
|
CornerRadius="3" Background="{StaticResource Brush.Primary}">
|
||||||
TextTrimming="CharacterEllipsis">
|
<Border.Style>
|
||||||
<Run Text="{Binding Name, Mode=OneWay}"/><Run Text=" "/><Run Text="{Binding HotkeyLabel, Mode=OneWay}" Foreground="#6a6a7a" FontWeight="SemiBold"/>
|
<Style TargetType="Border">
|
||||||
</TextBlock>
|
<Setter Property="Visibility" Value="Collapsed"/>
|
||||||
|
<Style.Triggers>
|
||||||
|
<DataTrigger Binding="{Binding IsStaged}" Value="True">
|
||||||
|
<Setter Property="Visibility" Value="Visible"/>
|
||||||
|
</DataTrigger>
|
||||||
|
</Style.Triggers>
|
||||||
|
</Style>
|
||||||
|
</Border.Style>
|
||||||
|
<StackPanel Orientation="Horizontal">
|
||||||
|
<Ellipse Width="8" Height="8" Fill="{StaticResource Brush.Primary}" VerticalAlignment="Center" Margin="0,0,5,0">
|
||||||
|
<Ellipse.Triggers>
|
||||||
|
<EventTrigger RoutedEvent="FrameworkElement.Loaded">
|
||||||
|
<BeginStoryboard>
|
||||||
|
<Storyboard RepeatBehavior="Forever" AutoReverse="True">
|
||||||
|
<DoubleAnimation Storyboard.TargetProperty="Opacity" From="1" To="0.15" Duration="0:0:0.6"/>
|
||||||
|
</Storyboard>
|
||||||
|
</BeginStoryboard>
|
||||||
|
</EventTrigger>
|
||||||
|
</Ellipse.Triggers>
|
||||||
|
</Ellipse>
|
||||||
|
<TextBlock Text="PROGRAM" Foreground="White" FontSize="11" FontWeight="Bold" VerticalAlignment="Center"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<!-- 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>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
</ItemsControl.ItemTemplate>
|
</ItemsControl.ItemTemplate>
|
||||||
</ItemsControl>
|
</ItemsControl>
|
||||||
</Border>
|
</Border>
|
||||||
</UserControl>
|
</UserControl>
|
||||||
|
|||||||
@@ -11,6 +11,16 @@ public partial class SceneThumbnailStrip : UserControl
|
|||||||
public SceneThumbnailStrip()
|
public SceneThumbnailStrip()
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
|
Loaded += (_, _) =>
|
||||||
|
{
|
||||||
|
if (DataContext is MainViewModel vm)
|
||||||
|
foreach (var scene in vm.Scenes)
|
||||||
|
{
|
||||||
|
var staged = scene.IsStaged;
|
||||||
|
scene.IsStaged = !staged;
|
||||||
|
scene.IsStaged = staged;
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private void Thumbnail_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
|
private void Thumbnail_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -5,48 +5,55 @@
|
|||||||
x:Name="TopBarRoot">
|
x:Name="TopBarRoot">
|
||||||
<Grid>
|
<Grid>
|
||||||
<Grid.ColumnDefinitions>
|
<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="*"/>
|
||||||
<ColumnDefinition Width="Auto"/>
|
<ColumnDefinition Width="Auto"/>
|
||||||
</Grid.ColumnDefinitions>
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
<!-- LOGO / BRAND — clicking opens the in-app About overlay; the gear sits
|
<!-- BRAND + EDIT. The gear moved to the preview's right rail, first tab (2026-11-01):
|
||||||
a couple characters past the build stamp and opens App Settings. -->
|
app-level actions live with the other pull-outs now. -->
|
||||||
<StackPanel Grid.Column="0" Orientation="Horizontal" VerticalAlignment="Center"
|
<Grid Grid.Column="0">
|
||||||
Margin="0,0,24,0">
|
<Grid.ColumnDefinitions>
|
||||||
<Button Background="Transparent" BorderThickness="0" Cursor="Hand"
|
<ColumnDefinition Width="*"/>
|
||||||
VerticalAlignment="Center" ToolTip="About LlamaCasty"
|
<ColumnDefinition Width="Auto"/>
|
||||||
Command="{Binding OpenAboutCommand}">
|
</Grid.ColumnDefinitions>
|
||||||
<Button.Template>
|
|
||||||
<ControlTemplate TargetType="Button">
|
<!-- LOGO / BRAND — left-click opens the in-app About overlay; right-click opens the app
|
||||||
<ContentPresenter/>
|
menu (Report Bug / Feature Request / About). App Settings is no longer in this
|
||||||
</ControlTemplate>
|
menu: the gear rail tab opens the Settings drawer directly (creator ruling
|
||||||
</Button.Template>
|
2026-11-01). -->
|
||||||
<TextBlock FontSize="22" FontWeight="Bold" VerticalAlignment="Center">
|
<Button Grid.Column="0" Background="Transparent" BorderThickness="0" Cursor="Hand"
|
||||||
<Run Text="LlamaCas" Foreground="#e0e0e0"/><Run Text="ty" Foreground="#e94560"/><Run
|
HorizontalAlignment="Left"
|
||||||
x:Name="BuildIdStamp" Text="{x:Static helpers:BuildStamp.Label}" FontSize="9"
|
VerticalAlignment="Center" ToolTip="About LlamaCasty — right-click for the menu"
|
||||||
BaselineAlignment="Superscript" Foreground="#8888aa"
|
Command="{Binding OpenAboutCommand}"
|
||||||
ToolTipService.ToolTip="{x:Static helpers:BuildStamp.Label}"/><Run Text=" " FontSize="4"/>
|
PreviewMouseRightButtonDown="BrandWordmark_PreviewMouseRightButtonDown">
|
||||||
</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">
|
|
||||||
<Button.ContextMenu>
|
<Button.ContextMenu>
|
||||||
<ContextMenu DataContext="{Binding PlacementTarget.DataContext, RelativeSource={RelativeSource Self}}">
|
<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="Report Bug" Command="{Binding OpenBugCommand}"/>
|
||||||
<MenuItem Header="Feature Request" Command="{Binding OpenFeatureCommand}"/>
|
<MenuItem Header="Feature Request" Command="{Binding OpenFeatureCommand}"/>
|
||||||
<MenuItem Header="About" Command="{Binding OpenAboutCommand}"/>
|
<MenuItem Header="About" Command="{Binding OpenAboutCommand}"/>
|
||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
</Button.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"
|
<Button.Template>
|
||||||
Fill="#a0a0b0" Width="18" Height="18" Stretch="Uniform"/>
|
<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>
|
</Button>
|
||||||
</StackPanel>
|
|
||||||
|
</Grid>
|
||||||
|
|
||||||
<!-- Session surface (top bar redesign): the bar is ONE surface rendered by
|
<!-- 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
|
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. -->
|
reality line + End so nothing mid-session can be re-armed. -->
|
||||||
<StackPanel Grid.Column="1" Orientation="Horizontal"
|
<StackPanel Grid.Column="1" Orientation="Horizontal"
|
||||||
HorizontalAlignment="Center" VerticalAlignment="Center">
|
HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||||
<!-- IDLE: the mode switch + the action(s) of the armed world. The three
|
<!-- THE decision (creator ruling 2026-11-12): [ Build Scenes ][ Record / Go Live ].
|
||||||
controls share one height (build 77 feedback) so they read as one
|
Build Scenes is the new edit mode — there is no separate EDIT button, so the
|
||||||
family — segments host, Start/Go Live, and Test all sit at 32. -->
|
question "which world am I in?" and "can I edit?" cannot disagree. Clicking the
|
||||||
<StackPanel Orientation="Horizontal"
|
highlighted half re-enters it, which is how a creator gets back to the layers
|
||||||
Visibility="{Binding ShowIdleWorld, Converter={StaticResource BoolToVis}}">
|
during a session. -->
|
||||||
<Border Background="#0f1622" CornerRadius="4" Padding="2" Height="32" VerticalAlignment="Center">
|
<!-- THREE FIXED SLOTS (creator, 2026-11-13): [Build Scenes] [YouTube] [Record]. The bar's
|
||||||
<StackPanel Orientation="Horizontal">
|
shape NEVER changes — every transition happens inside one slot, so the neighbouring
|
||||||
<ToggleButton Style="{StaticResource SegmentToggle}"
|
buttons cannot move. Each slot is its own little flow. -->
|
||||||
IsChecked="{Binding RecordPillOn}">
|
<Border Background="{StaticResource Brush.SegmentTrack}" CornerRadius="12" Padding="4"
|
||||||
<TextBlock Text="REC" Style="{StaticResource SegmentLabel}"/>
|
VerticalAlignment="Center" Margin="0,0,12,0"
|
||||||
</ToggleButton>
|
KeyboardNavigation.TabNavigation="Cycle" KeyboardNavigation.DirectionalNavigation="Cycle">
|
||||||
<ToggleButton Style="{StaticResource SegmentToggle}"
|
<Grid>
|
||||||
IsChecked="{Binding OnAirPillOn}">
|
<Grid.ColumnDefinitions>
|
||||||
<TextBlock Text="ON-AIR" Style="{StaticResource SegmentLabel}"/>
|
<ColumnDefinition Width="Auto"/>
|
||||||
</ToggleButton>
|
<ColumnDefinition Width="Auto"/>
|
||||||
</StackPanel>
|
<ColumnDefinition Width="Auto"/>
|
||||||
</Border>
|
</Grid.ColumnDefinitions>
|
||||||
<!-- Go face names the world: "Start Recording" vs "Go Live".
|
|
||||||
(Signed-out ON-AIR Start routes to the sign-in — StartSession.) -->
|
<!-- ══ SLOT 1: BUILD SCENES ══ -->
|
||||||
<Button Content="{Binding PrimaryStartButtonLabel}" Style="{StaticResource YtButton}"
|
<Button x:Name="BuildSegmentButton" Grid.Column="0" Style="{StaticResource SegmentHalf}"
|
||||||
Height="32" Margin="10,0,0,0" Command="{Binding StartStreamCommand}"
|
Tag="True" Command="{Binding SelectBuildCommand}"
|
||||||
Visibility="{Binding ShowPrimaryStartButton, Converter={StaticResource BoolToVis}}"/>
|
IsEnabled="{Binding IsBuildScenesEnabled}"
|
||||||
<!-- Test is a child of Stream — a dry-run of the livestream, so it
|
ToolTip="{Binding BuildScenesTooltip}" FocusVisualStyle="{x:Null}">
|
||||||
exists only inside the Stream world (stream armed + signed in). -->
|
<StackPanel Orientation="Horizontal">
|
||||||
<Button Content="Test" Style="{StaticResource YtButtonSecondary}" Padding="12,4" FontSize="11"
|
<Path Style="{StaticResource SegmentIcon}" Tag="True" Data="M3,4h8v6H3V4zM13,4h8v6h-8V4zM3,12h8v8H3V12zM13,12h8v8h-8V12z"/>
|
||||||
Height="32" Margin="8,0,0,0" VerticalAlignment="Center"
|
<TextBlock Text="Build Scenes" FontSize="12" FontWeight="Bold" Margin="6,0,0,0"
|
||||||
Command="{Binding StartTestCommand}"
|
VerticalAlignment="Center"
|
||||||
ToolTip="Run a private test broadcast to check your chat overlay."
|
Foreground="{Binding Foreground, RelativeSource={RelativeSource AncestorType=Button}}"/>
|
||||||
Visibility="{Binding ShowTestButton, Converter={StaticResource BoolToVis}}"/>
|
</StackPanel>
|
||||||
</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 -->
|
<!-- RUNNING: one reality line (dot + word + elapsed) and the stop -->
|
||||||
<StackPanel Orientation="Horizontal"
|
<StackPanel Orientation="Horizontal"
|
||||||
@@ -94,8 +239,15 @@
|
|||||||
Foreground="White" Margin="8,0,0,0" VerticalAlignment="Center"/>
|
Foreground="White" Margin="8,0,0,0" VerticalAlignment="Center"/>
|
||||||
<TextBlock Text="{Binding LiveElapsedText}" Foreground="White" FontSize="14"
|
<TextBlock Text="{Binding LiveElapsedText}" Foreground="White" FontSize="14"
|
||||||
FontFamily="Consolas" VerticalAlignment="Center" Margin="8,0,0,0"/>
|
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}"
|
<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>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
@@ -118,11 +270,11 @@
|
|||||||
<MenuItem Header="Logout" Command="{Binding LogoutCommand}"/>
|
<MenuItem Header="Logout" Command="{Binding LogoutCommand}"/>
|
||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
</Button.ContextMenu>
|
</Button.ContextMenu>
|
||||||
<Border Width="40" Height="40" CornerRadius="20" Background="#16213e"
|
<Border Width="40" Height="40" CornerRadius="20" Background="{StaticResource Brush.SurfaceContainer}"
|
||||||
ClipToBounds="True" BorderBrush="#3a4a66" BorderThickness="1">
|
ClipToBounds="True" BorderBrush="{StaticResource Brush.Outline}" BorderThickness="1">
|
||||||
<Grid>
|
<Grid>
|
||||||
<Image x:Name="AvatarImage" Stretch="UniformToFill"/>
|
<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"
|
FontWeight="Bold" HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||||
Visibility="Collapsed"/>
|
Visibility="Collapsed"/>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|||||||
@@ -19,25 +19,31 @@ public partial class TopBar : UserControl
|
|||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
AvatarImage.Source = null;
|
AvatarImage.Source = null;
|
||||||
AvatarInitial.Visibility = System.Windows.Visibility.Collapsed;
|
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)
|
if (sender is Button { ContextMenu: { } menu } button)
|
||||||
{
|
{
|
||||||
menu.PlacementTarget = button;
|
menu.PlacementTarget = button;
|
||||||
menu.Placement = System.Windows.Controls.Primitives.PlacementMode.Bottom;
|
menu.Placement = System.Windows.Controls.Primitives.PlacementMode.Bottom;
|
||||||
menu.IsOpen = true;
|
menu.IsOpen = true;
|
||||||
|
e.Handled = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void RefreshAvatar()
|
||||||
|
{
|
||||||
|
_ = LoadAvatarImageAsync();
|
||||||
|
}
|
||||||
|
|
||||||
private async System.Threading.Tasks.Task LoadAvatarImageAsync()
|
private async System.Threading.Tasks.Task LoadAvatarImageAsync()
|
||||||
{
|
{
|
||||||
var vm = DataContext as MainViewModel;
|
var vm = DataContext as MainViewModel;
|
||||||
|
|||||||
@@ -7,16 +7,23 @@ See [`schema.md`](../schema.md) for the memory-map conventions.
|
|||||||
|
|
||||||
| File | Purpose |
|
| 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 |
|
| `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` |
|
| `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 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) |
|
| `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)` | Row 0 scene-picker strip (`ItemsControl` over `Scenes`), staged-red / live-green border highlight |
|
| `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 1 Col 0 left panel: edit mode → layers+properties; live → YouTube chat. Source add/edit/remove, list drag-to-reorder, snapshot revert-accept |
|
| `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 overlay dialogs: Settings / Report Bug / Feature Request / About (+ licenses + license-key entry). `BackToAbout_Click` lives here |
|
| `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 |
|
| `SceneThumbnailStrip.xaml.cs` | Code-behind for the strip above |
|
||||||
| `LeftPanel.xaml.cs` | Code-behind for the panel 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
|
## Window ↔ control contract
|
||||||
|
|
||||||
The shell (`MainWindow.xaml(.cs)`) wires up each control and routes the few
|
The shell (`MainWindow.xaml(.cs)`) wires up each control and routes the few
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
# DESIGN.md — LlamaCasty visual design standard
|
||||||
|
|
||||||
|
> **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.
|
||||||
|
|
||||||
|
## Colour
|
||||||
|
|
||||||
|
### Surfaces (dark navy scale, darkest to lightest)
|
||||||
|
|
||||||
|
| Token | Hex | Use |
|
||||||
|
|---|---|---|
|
||||||
|
| `background` | `#0b1326` | App window background |
|
||||||
|
| `surface-container-lowest` | `#060e20` | Deepest recess (scene strip background, canvas housing) |
|
||||||
|
| `surface-container-low` | `#131b2e` | Panel background (sidebars, headers) |
|
||||||
|
| `surface-container` | `#171f33` | Card / grouped-section background |
|
||||||
|
| `surface-container-high` | `#222a3d` | Hover state, input fields |
|
||||||
|
| `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 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
|
||||||
|
|
||||||
|
| Token | Hex | Use |
|
||||||
|
|---|---|---|
|
||||||
|
| `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** 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 |
|
||||||
|
| `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` |
|
||||||
|
|
||||||
|
**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) | Numeric/telemetry values — bitrate, FPS, dB, coordinates, timers. Tabular figures stop digits shifting width as values change |
|
||||||
|
|
||||||
|
Icons: Material Symbols Outlined (bundled, not fetched).
|
||||||
|
|
||||||
|
### Scale
|
||||||
|
|
||||||
|
| 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 |
|
||||||
|
|
||||||
|
**Floors and implementation notes**
|
||||||
|
|
||||||
|
- **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).
|
||||||
@@ -1,732 +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
|
> 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
|
||||||
> **Distribution is now the Microsoft Store: MSIX package + Store IAP.** The creator's criteria
|
> same place. The reasoning that survives lives in `commercial.md` and `docs/ai/decisions.md`.
|
||||||
> 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.
|
|
||||||
|
|
||||||
---
|
## 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*
|
```bash
|
||||||
> one-time beat subscription were worth working out. **None of the "How We Use It" rows are
|
dotnet publish ytLive.csproj -c Release -r win-x64 --self-contained true \
|
||||||
> current**, with one exception worth keeping: the Merchant-of-Record point — *someone else
|
-p:PublishSingleFile=true \
|
||||||
> collects and remits VAT/GST* — is still true under Store IAP, because **Microsoft** is now
|
-p:IncludeNativeLibrariesForSelfExtract=true \
|
||||||
> that someone. That fact alone was the strongest argument for the ruling.
|
-p:EnableCompressionInSingleFile=true \
|
||||||
|
-p:PublishReadyToRun=true -o artifacts/steam-stage
|
||||||
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 { ... }
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Rules:**
|
- **`PublishTrimmed` stays OFF.** Trimming breaks WPF's XAML reflection (`Binding`, `DataTemplate`,
|
||||||
- All ViewModels: `[Obfuscation(Exclude = true)]` (XAML `Binding Path=` resolves by name at runtime).
|
`StaticResource`). Expect ~110–150 MB in the depot; that is the cost of "no .NET install".
|
||||||
- All Models/DTOs used in SQLite: `[Obfuscation(Exclude = true)]` (column names are strings).
|
- **ffmpeg stays outside the exe** — `ffmpeg\` beside it (the app's locator contract, see
|
||||||
- All public service interfaces: `[Obfuscation(Exclude = true)]` (DI resolution).
|
[`output.md`](output.md) → "FFmpeg locator").
|
||||||
- Internal service implementations: safe to obfuscate.
|
- **`steam_api64.dll` ships beside the exe** and is loaded at startup; without it the app cannot
|
||||||
- Helpers/Converters in XAML: `[Obfuscation(Exclude = true)]` on the class.
|
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
|
### Status (updated 2026-11-05 — this is a WAIT on Valve, not a task of ours)
|
||||||
<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>
|
|
||||||
```
|
|
||||||
|
|
||||||
**Key decisions:**
|
| Step | State |
|
||||||
- `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.
|
| Partner account created | ✅ done by the creator |
|
||||||
- `win-x64`: Target the dominant platform. ARM64 is a separate RID if needed later.
|
| 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
|
5. **Store page** configured and made public **two weeks before release** — Valve's rule, and it is
|
||||||
# publish.ps1 — called after dotnet publish
|
the window where wishlists accumulate.
|
||||||
param([string]$PublishDir = "publish")
|
|
||||||
|
|
||||||
dotnet publish ytLive.csproj -c Release -r win-x64 `
|
## 3. Release
|
||||||
--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
|
|
||||||
|
|
||||||
```
|
```
|
||||||
LlamaCasty.exe (single-file, self-contained, ~80-120MB)
|
publish → steam-stage → steamcmd + app_build.vdf → build upload → set live → announcement
|
||||||
```
|
```
|
||||||
|
|
||||||
Single-file means everything is inside the `.exe`. No DLLs, no runtime dependency.
|
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.
|
||||||
**Why not an installer?**
|
2. **Set the build live** in Steamworks → *Builds* → the new build → **Set Build Live** on the
|
||||||
- Single `.exe` is simpler to distribute and update.
|
default branch. That *is* the release; there is no artifact to hash and no manifest to submit.
|
||||||
- No admin rights required (installs to `%LOCALAPPDATA%`).
|
3. **Announce** with Steam Events (release notes appear in the library and on the store page).
|
||||||
- No installer framework to maintain (WiX, Inno Setup, etc.).
|
4. **Verify**: install the Demo from a clean Steam account, confirm paid-app ownership is false,
|
||||||
- User drops it anywhere and runs it.
|
then test with an account that owns the paid app and confirm the higher tiers unlock.
|
||||||
|
|
||||||
**Caveat:** Self-contained single-file means ~80-120MB due to bundled .NET runtime. For smaller size, use framework-dependent publishing (requires .NET 8 runtime on user machines) — drops to ~5-10MB.
|
**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.2 Distribution via Polar — ⛔ OBSOLETE (2026-09-27)
|
|
||||||
|
## 4. Entitlement wiring
|
||||||
> **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
|
- `IEntitlement` reports `IsAppOwned`. Debug uses a fake owned result; Release checks the paid app
|
||||||
> payment — so file hosting, license keys, checkout, the customer portal, device deactivation and
|
ID through `BIsSubscribedApp`. `MainViewModel.IsAppOwned` is the app-wide binary ownership flag.
|
||||||
> key rotation are **all** handled by Microsoft and none of it needs building. Retained below as a
|
- The paid app ID is not yet configured. Until it is assigned, the Release path fails closed without
|
||||||
> record of the model that was designed and abandoned. ⚠️ **The `$29`/`$49` one-time prices in this
|
touching the native library.
|
||||||
> section are dead** — the settled pricing is **`$10/mo` or `$400` lifetime**, net of a verified
|
- **Fail closed on every path:** no id, no client, missing DLL, thrown load, not owned.
|
||||||
> 15% Store share.
|
- The EULA should expressly allow commercial/monetized streaming use; confirm with counsel or Valve
|
||||||
|
that this satisfies the Steam Subscriber Agreement's software terms.
|
||||||
**Product setup on Polar (as designed, never shipped):**
|
- **Never gate a feature.** The only delta is the output profile.
|
||||||
|
|
||||||
1. Create a product: "LlamaCasty" — **one-time purchase** (perpetual license; $29 founder / $49 list).
|
## 5. Release checklist
|
||||||
2. Add two benefits:
|
|
||||||
- **License Key** — brandable prefix `LLAMA-****`, activation limit (e.g., 3 devices), optional expiry.
|
- Build clean, 0 warnings (`./scripts/verify.sh`), tests green.
|
||||||
- **File Download** — upload the signed `LlamaCasty.exe` (~80-120MB). Polar generates signed, personal download URLs. SHA-256 checksum included.
|
- `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.
|
||||||
**Customer flow:**
|
- `LlamaCasty.exe` rename landed (never rename a live exe).
|
||||||
1. Customer visits `buy.polar.sh/polar_cl_...` (the Premium URL already in `MainViewModel.PremiumUrl`).
|
- Clean Steam install on a second machine, then an update pushed and observed.
|
||||||
2. Customer pays via Polar checkout.
|
- Store page public for the full two weeks; support contact and announcements configured.
|
||||||
3. Polar generates a license key + provides a signed download URL.
|
- Portable mode: `portable.txt` present → data lands in `.\data\`.
|
||||||
4. Customer downloads `LlamaCasty.exe`, runs it, enters license key in Settings.
|
|
||||||
5. App calls `PolarLicenseService.ValidateAsync(key)` → Polar API validates → `IsValid = true`.
|
## 6. Legal and takedown
|
||||||
6. Premium features unlock locally (persisted in `LayoutStore.Settings`).
|
|
||||||
|
- **`LICENSE`** (EULA) forbids redistribution, resale, reverse-engineering and circumvention, and
|
||||||
**Activation limits:**
|
disclaims warranty.
|
||||||
- Polar supports `limit_activations` per license key (e.g., 3 devices).
|
- **`PRIVACY.md`** must name any third party the app contacts — Google/YouTube. Steam itself is
|
||||||
- Client calls `/v1/customer-portal/license-keys/activate` before first validation.
|
named by Valve's own policy, not ours.
|
||||||
- Polar tracks activations; customer can deactivate via Polar's customer portal.
|
- **`THIRD-PARTY-NOTICES.txt`** stays shipped and shown in About; the ffmpeg pin stays
|
||||||
- No custom device registry needed — Polar handles it.
|
`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.
|
||||||
**Key rotation:**
|
Statutory damages need a registration ($65). Worth doing once there is something to pirate.
|
||||||
- If a key is compromised, customer or merchant can rotate it via Polar.
|
- **What actually protects the product** is in `commercial.md` → "Anti-piracy posture": a useful
|
||||||
- Previous key stops validating immediately. Status, usage, limits, expiry preserved.
|
free tier, Steam's own delivery, and Steam ownership of the paid app. **Not obfuscation, not a
|
||||||
|
native DLL, not hardware locking.**
|
||||||
**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
|
## 7. Effort, once
|
||||||
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.
|
| Step | Effort |
|
||||||
|
|---|---|
|
||||||
### 4.3 Code Signing
|
| Steamworks onboarding + $100 fee | 0.5–1 day, mostly waiting on verification |
|
||||||
|
| Publish profile + `steam_api64.dll` + `IEntitlement` Steam path | 1 day |
|
||||||
> ⚠️ **The cert choice below is a live decision, and the old plan's EV recommendation is
|
| Depot + first `steamcmd` upload | 0.5 day, then ~15 min per release |
|
||||||
> dead.** Microsoft **removed the SmartScreen instant-bypass for EV certificates in March
|
| Store page + assets (screenshots, capsule art) | 1–2 days |
|
||||||
> 2024** — an EV-signed file now accrues reputation exactly like an OV-signed one, so paying
|
| Clean-account Demo install + paid-app ownership test | 0.5 day |
|
||||||
> $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 currently in
|
|
||||||
> `App.xaml.cs` **downloads *and executes***, which Store policy **10.2.2** does not permit — its
|
|
||||||
> removal is a **compliance action**, not optional cleanup (TASK 48 item 3). 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,10 +1,10 @@
|
|||||||
<Window x:Class="ytLive.GoLiveWindow"
|
<Window x:Class="ytLive.GoLiveWindow"
|
||||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
Title="Start Stream" Height="480" Width="520"
|
Title="Start Stream" Height="480" Width="520"
|
||||||
Icon="/Assets/llamacasty-icon.png"
|
Icon="/Assets/llamacasty-icon.png"
|
||||||
WindowStartupLocation="CenterOwner" ResizeMode="NoResize"
|
WindowStartupLocation="CenterOwner" ResizeMode="NoResize"
|
||||||
ShowInTaskbar="False" Background="#1a1a2e">
|
ShowInTaskbar="False" Background="{StaticResource Brush.Background}">
|
||||||
|
|
||||||
<Window.Resources>
|
<Window.Resources>
|
||||||
<ResourceDictionary>
|
<ResourceDictionary>
|
||||||
@@ -22,12 +22,12 @@
|
|||||||
</Grid.RowDefinitions>
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
<TextBlock Grid.Row="0" Text="Start Stream" FontSize="18" FontWeight="Bold"
|
<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 -->
|
<!-- ACCOUNT -->
|
||||||
<StackPanel Grid.Row="1" Margin="0,0,0,16">
|
<StackPanel Grid.Row="1" Margin="0,0,0,16">
|
||||||
<TextBlock Text="ACCOUNT" FontSize="12" FontWeight="SemiBold"
|
<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 -->
|
<!-- signed in -->
|
||||||
<StackPanel Orientation="Horizontal">
|
<StackPanel Orientation="Horizontal">
|
||||||
@@ -41,11 +41,11 @@
|
|||||||
</Style.Triggers>
|
</Style.Triggers>
|
||||||
</Style>
|
</Style>
|
||||||
</StackPanel.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">
|
VerticalAlignment="Center" Margin="0,0,10,0">
|
||||||
<Image Source="{Binding AccountAvatarUrl}" Stretch="UniformToFill"/>
|
<Image Source="{Binding AccountAvatarUrl}" Stretch="UniformToFill"/>
|
||||||
</Border>
|
</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"/>
|
VerticalAlignment="Center" Margin="0,0,12,0"/>
|
||||||
<Button Content="Change Account" Command="{Binding SignInCommand}"
|
<Button Content="Change Account" Command="{Binding SignInCommand}"
|
||||||
Style="{StaticResource YtButtonSecondary}" VerticalAlignment="Center"/>
|
Style="{StaticResource YtButtonSecondary}" VerticalAlignment="Center"/>
|
||||||
@@ -65,12 +65,12 @@
|
|||||||
</StackPanel.Style>
|
</StackPanel.Style>
|
||||||
<Button Content="Sign in to YouTube" Command="{Binding SignInCommand}"
|
<Button Content="Sign in to YouTube" Command="{Binding SignInCommand}"
|
||||||
Style="{StaticResource YtButtonSecondary}" Margin="0,0,10,0"/>
|
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"/>
|
VerticalAlignment="Center"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<TextBlock Text="Signing in… open the browser to authorize, then come back here."
|
<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>
|
<TextBlock.Style>
|
||||||
<Style TargetType="TextBlock">
|
<Style TargetType="TextBlock">
|
||||||
<Setter Property="Visibility" Value="Collapsed"/>
|
<Setter Property="Visibility" Value="Collapsed"/>
|
||||||
@@ -87,7 +87,7 @@
|
|||||||
<!-- STREAM METADATA -->
|
<!-- STREAM METADATA -->
|
||||||
<StackPanel Grid.Row="2">
|
<StackPanel Grid.Row="2">
|
||||||
<TextBlock Text="STREAM DETAILS" FontSize="12" FontWeight="SemiBold"
|
<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}"/>
|
<TextBlock Text="Title" Style="{StaticResource YtLabel}"/>
|
||||||
<TextBox Style="{StaticResource YtTextBox}" Margin="0,0,0,12"
|
<TextBox Style="{StaticResource YtTextBox}" Margin="0,0,0,12"
|
||||||
@@ -100,10 +100,10 @@
|
|||||||
|
|
||||||
<TextBlock Text="Visibility" Style="{StaticResource YtLabel}"/>
|
<TextBlock Text="Visibility" Style="{StaticResource YtLabel}"/>
|
||||||
<StackPanel Orientation="Horizontal" Margin="0,0,0,12">
|
<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"/>
|
VerticalAlignment="Center"/>
|
||||||
<TextBlock Text="Streams always start Private — change it on YouTube after going live."
|
<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"/>
|
Margin="10,0,0,0" VerticalAlignment="Center"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|||||||
@@ -1,542 +1,40 @@
|
|||||||
# HANDOFF — current state
|
# HANDOFF — 2026-11-12 (bitrate + audio-clock fixes; sync number pending)
|
||||||
|
|
||||||
**Branch:** `main` (pre-1.0, no feature branches — creator ruling 2026-08-24).
|
## State
|
||||||
**Pushed 2026-10-04:** `origin/main` = `048cab7`. **NOT PUSHED since — local `main` is ahead and the
|
|
||||||
tree is clean.** The stale-push-state failure `MyMistakes.md` warns about has now bitten
|
- `main`; `b265d42` is the latest pushed commit. Everything after is local.
|
||||||
twice, so the rule stands: **check `git rev-list --count origin/main..main` before trusting any line
|
- **Bitrate saga closed.** An @8 stream averaged 5.1 Mbps because the args were capped VBR, not CBR
|
||||||
here about push state.**
|
(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).
|
||||||
Unpushed: `5ad0106` broadcast diagnostic → `74da99c` + `0fb2f9c` privacy/Test rulings →
|
- Ladder `1708667` (12/10/6); floor `93719a2` (tier YouTube minimum); rate box `f2cab5c`; rate history +
|
||||||
`8fbc370` diagnostic parse fix → `07a088a` **the explicit-bind fix** → `509ef36` window placement →
|
consent proposal `b0f8263`; pre-release 60 fps switch `1e3a4f7`.
|
||||||
`b2f2197` handoff → `UNIT 0` frame-cadence measurement.
|
- **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
|
||||||
## 🚧 IN FLIGHT 2026-10-05 — UNITS 4+5 landed: the render-budget work (needs a creator measurement)
|
across three recordings: +2.1% long → 226 ms short → 137 ms short → **lengths now match (34 ms in
|
||||||
|
13 s)**.
|
||||||
**The 141ms → 33ms mystery is SOLVED, and it was geometry, not the build.** The opaque-backdrop 1:1
|
- **Sync answer (measured, settled).** Mic delay ships at **0** and should stay off; with it off the audio
|
||||||
row walk only engages when the source rect is **exactly** 1920×1080. On a 21:9 monitor the source
|
path runs ~100 ms behind, so the **global** LIP-SYNC offset is **−130 ms**. Both offsets default to 0 —
|
||||||
rect shrinks to 1920×803 ("fit", not "fill"), so every render paid a ~2M-byte opaque-black prefill
|
a correction belongs to one camera, never to the product (`AudioSyncSettingsTests` pins it).
|
||||||
plus a ~1.54M-pixel per-pixel alpha-branch resample instead of one `BlockCopy`. 2560×1440 fits to
|
- **675 tests green, 0 warnings.** `scripts/avsync.py` measures any recording in seconds.
|
||||||
1920×1080 exactly and takes the fast path. **Debug-vs-Release was NOT the cause** — today's Debug
|
|
||||||
build with the game running on 16:9 measured 33ms average. Every recorded A/B matches.
|
## Next action
|
||||||
|
|
||||||
**Shipped (all pixel-identical, all gated):**
|
- Creator: set the mic delay to 350, record ~15 s, run `./scripts/avsync.py "<clip>" --crop
|
||||||
- **Unit 4** — opaque 1:1 fast paths widened to the whole-canvas case; **prefill only the uncovered
|
764x764+1032+159`. Target: `offset` inside ±30 ms and `lengths match`.
|
||||||
letterbox rows**; a **per-column LUT** for the scaled blit (source column + 8.8 weights computed
|
- Then resume the queue: **40·A/C/D → Move → 9·5 → 14·16/17/18 → 66 → 67 → 68 → 69 → 70 → 36**.
|
||||||
once per column, not a divide per pixel). Commits `0464593`, `08290ec`, `77d5120`, `dc13438`.
|
- Do not push app commits absent a checkpoint request.
|
||||||
- **Unit 5** — **`Helpers/StripPool.cs`**: one composite splits into four horizontal row strips
|
|
||||||
across four DEDICATED `BelowNormal` background threads (the caller takes a strip too; inline below
|
|
||||||
64 rows). ⛔ **Do not "simplify" this back to `Parallel.For` with a thread-priority poke** — the
|
|
||||||
pool is shared, the demotion leaks into unrelated work, and it broke an audio test for real. Two
|
|
||||||
sources + the failure are recorded in `MyMistakes.md`; the design note is in `ai.md` →
|
|
||||||
"Render budget". Commits `623d77e`, `3901cb8`, + the strip-pool commit.
|
|
||||||
- Gate test `SceneCompositorTests.StripParallel_RepeatedRenders_AreByteIdentical` — 8 renders of a
|
|
||||||
multi-layer scene must be byte-for-byte identical, and must differ from a backdrop-only render so
|
|
||||||
it cannot pass vacuously. It caught a loop-variable capture that crashed the test host on run 1.
|
|
||||||
|
|
||||||
**⏭ NEXT STEP — one Release recording, 60s, Live scene, Division 2 running.** This is the
|
|
||||||
measurement that decides whether Unit 5 was necessary. Read the `FramePump stats` line (it now
|
|
||||||
carries `social`/`ticker`/`flash`/`acq` per-stage timings and the pump-start line names the scene +
|
|
||||||
compositor rect):
|
|
||||||
- **avg render ≤12ms** → Units 4+5 did it; go straight to **Unit 6** (encoder backpressure:
|
|
||||||
`worst submit` has hit 53ms with ~357 cumulative drops — real frame loss on top of the padding).
|
|
||||||
- **still >16.67ms** → the remaining cost is NOT the blit loops; read the per-stage timings before
|
|
||||||
touching anything else (do not guess — that is exactly the UNIT 0 lesson).
|
|
||||||
- **Under ~30ms on 16:9 but 140ms on 21:9** → geometry is still costing us; the fix is the tier
|
|
||||||
source-rect, not the compositor.
|
|
||||||
|
|
||||||
## 🚨 ANSWERED 2026-10-05 — the judder is the COMPOSITOR, not the camera. Unit 1 is the wrong next move.
|
|
||||||
|
|
||||||
Take `ty-20261005-1201-556-0001.mp4` (75.3s, 1920×1080, 60fps) + `startup.log`, read together:
|
|
||||||
|
|
||||||
| Counter | Value | Reading |
|
|
||||||
|---|---|---|
|
|
||||||
| `Camera cadence … arrivals` | **30.0/s, 0 gaps ≥50ms**, avg 33.4ms | the camera is **perfect**. Not the culprit. |
|
|
||||||
| `Camera cadence … camera` (device stamps) | 30.0/s, identical | the USB link and driver are clean too |
|
|
||||||
| `FramePump … from N renders` | **34 renders per 5s = 6.8/s**, avg render **141ms**, worst 294ms | one full composite takes longer than four camera frames |
|
|
||||||
| `webcam …` on the pump's line | 13.0/s, 34 gaps ≥50ms | only 13 of the 30 delivered frames ever reached a composite |
|
|
||||||
| the recording itself | **6.8 distinct webcam images/s, 88.7% duplicates, ZERO arrival hitches** | the judder is *only* us repeating a frame — nothing arrives late |
|
|
||||||
|
|
||||||
**⇒ The camera delivers all 30 of its frames, evenly. The compositor can only draw ~7 composites a
|
|
||||||
second, so the 60fps container is 89% repeats of a face held for 140ms.** That is exactly what a
|
|
||||||
lipreader experiences as "cutting out".
|
|
||||||
|
|
||||||
**⛔ Do NOT do Unit 1 (720p / exclusive lock) as the judder fix — it is the wrong lever.** 720p would
|
|
||||||
triple the bytes the compositor stretches per render on a path that is already 4.4× over its budget.
|
|
||||||
The exclusive lock is still worth having **for sharpness** (the C920 is pinned at 640×480 YUY2 and
|
|
||||||
the Chat scene upscales that ~2.1×), but it must be scheduled as a quality win, not a smoothness win,
|
|
||||||
and it should land **after** the compositor work — otherwise it makes the judder measurably worse.
|
|
||||||
|
|
||||||
**The number that needs explaining:** the 10:01 take hit **25.6** distinct images/s; this one hit
|
|
||||||
**6.8**. Same scene shape both times (`full-render split=0 elements=6 dynamic=5`), same camera, same
|
|
||||||
640×480. Composites went **29–34ms at 10:03 → 143–155ms at 12:01**, a 4.5× regression between
|
|
||||||
sessions with nothing in the log explaining it. `resolve` is 0.1ms, so the cost is pure pixel work,
|
|
||||||
not frame lookup. **That regression is the next thing to find, and the log cannot say what changed** —
|
|
||||||
no scene dump is written at stream start. ⛔ Do not guess at it.
|
|
||||||
|
|
||||||
**Cheapest next step, and it is a measurement not a fix:** log the per-element composite breakdown
|
|
||||||
(size + ms for each of the 6 elements) plus the scene contents at pump start. One run names which
|
|
||||||
element eats 140ms and whether the 10:03→12:01 regression was a scene change or something else.
|
|
||||||
Everything else in the compositor is guesswork until that exists.
|
|
||||||
|
|
||||||
**Also worth knowing (a counter subtlety, so nobody re-derives it):** the pump's `webcam` line reads
|
|
||||||
13/s while the file shows 6.8/s. Not a discrepancy — the counter stamps a frame when the RESOLVER
|
|
||||||
first sees it, and one composite takes 141ms, so ~4 camera frames arrive during it: the signature
|
|
||||||
pass records one epoch and the actual draw uses a later one. **The file measurement is the truth;
|
|
||||||
the pump counter overcounts by design** (it is a pipeline-health signal, not a frame-delivery metric).
|
|
||||||
|
|
||||||
## 🚧 IN FLIGHT 2026-10-05 — UNIT 0 shipped: a dropped webcam frame is finally measurable
|
|
||||||
|
|
||||||
**The measurement the map never had.** Take `ty-20261005-1001-821-0001.mp4` (85.6s, 1920×1080,
|
|
||||||
H.264 NVENC, 5124 frames @60) holds **25.6 distinct webcam images/s** in its lower-right region
|
|
||||||
against the camera's 30fps: 1,153 normal 33ms intervals, **281 gaps of 50–100ms**, 96 bunched 17ms
|
|
||||||
gaps. One hitch in five — the judder a DHH creator lipreads against, invisible in a file that muxes
|
|
||||||
flawlessly. Measured with `signalstats`/YDIF over `crop=480:270:1400:780`; the bundled ffmpeg has no
|
|
||||||
`mpdecimate`. ⛔ The old "webcam renders at ~18fps" claim was wrong (different session, counted all
|
|
||||||
newly rendered layers) — corrected in `ai.md`.
|
|
||||||
|
|
||||||
**Shipped:** `Helpers/FrameCadence.cs` (pure counter: frames, gaps, hitches ≥50ms, longest/avg), fed
|
|
||||||
from three points — `MediaCaptureFrameSource` logs `Camera cadence WxH: arrivals … | camera …`
|
|
||||||
(our delivery vs the device's own `SystemRelativeTime`), and `FramePump` logs distinct webcam
|
|
||||||
`Epoch`s that reached a composite. Full detail in `ai.md` → "Frame cadence".
|
|
||||||
|
|
||||||
**Also fixed, and it was a real measurement bug:** every pump average was divided by the number of
|
|
||||||
frames **submitted**, which deadline padding always fills to the full 60fps target — so a pump fed
|
|
||||||
one webcam frame per 200ms logged "300/300 frames, avg render 12.4ms". Render/resolve/wait now divide
|
|
||||||
by `statRenders` and the line reads `300/300 frames per 5s from 60 renders`. That pair IS the
|
|
||||||
duplicate-on-lag ratio. Test: `FramePumpTests.Stats_ReportRealRendersAndDistinctWebcamFrames_…`.
|
|
||||||
|
|
||||||
**⏭ NEXT STEP — needs the creator, one short recording (60s is plenty):** record with the webcam
|
|
||||||
visible and paste nothing — I read `%APPDATA%\ytLlive\startup.log` for the `Camera cadence` and
|
|
||||||
`FramePump stats` lines. Reading them together names the culprit: camera steady + arrivals ragged =
|
|
||||||
we drop it (reader/compositor); ragged in both = device/USB/driver; pump `webcam` below `arrivals`
|
|
||||||
= the compositor missed frames the reader already had. **Then Unit 1** (exclusive lock + 720p, see
|
|
||||||
below) is judged against those numbers.
|
|
||||||
|
|
||||||
**Agreed but NOT yet built (decided 2026-10-05, so a later session does not re-derive it):**
|
|
||||||
- **Unit 1 — exclusive camera lock + 720p on the camera the creator picked.** Open with
|
|
||||||
`MediaCaptureSharingMode.ExclusiveControl`; if init is refused, reopen the **SAME camera** in
|
|
||||||
`SharedReadOnly`. Target MJPG ≥30fps up to 1280×720, ranked by frame rate then size (this also fixes
|
|
||||||
the existing candidate-ordering bug). **The creator REVERSED the ban on taking an exclusive camera
|
|
||||||
lock** — that ban was never written into the map, which is why the reversal must be recorded this
|
|
||||||
time. Accepted trade-off: an app needing camera *control* that opens it after us will fail.
|
|
||||||
- ✅ **API probe DONE 2026-10-05 against the actual 19041.57 projection** (a throwaway
|
|
||||||
compile-probe file, since deleted — do not re-derive, and note the build cost of finding out):
|
|
||||||
`MediaCaptureSharingMode.ExclusiveControl` **is** projected, so the old comment in
|
|
||||||
`MediaCaptureFrameSource.cs` ("Exclusive is not projected by the 19041 SDK") is wrong and must be
|
|
||||||
corrected. `Exclusive` and `SharedReadOnly` are the **only two** `MediaCaptureSharingMode` values.
|
|
||||||
`MediaFrameSource.SupportedFormats` and `CurrentFormat` exist.
|
|
||||||
⛔ **`MediaFrameSource.SetFormatAsync(MediaFrameFormat)` is a DEAD END here** — it takes a
|
|
||||||
`MediaFrameFormat`, and that type has **no public constructor and no static factory** in the
|
|
||||||
projection (it is only obtainable from a live frame's `VideoMediaFrame.Format`). The two-argument
|
|
||||||
`(subtype, properties)` overload in Microsoft's docs does not exist here. So the format change must
|
|
||||||
go through the `VideoDeviceController.SetMediaStreamPropertiesAsync` path the code ALREADY has —
|
|
||||||
it is only refused because `SharingMode` is `SharedReadOnly`. Under an exclusive lock the existing
|
|
||||||
negotiation should simply start working, which is a much smaller change than planned.
|
|
||||||
- **Remaining unknown (needs a real device, not research):** whether `SetMediaStreamPropertiesAsync`
|
|
||||||
actually succeeds under `ExclusiveControl` on this C920. The `MediaFrameSource.SupportedFormats`
|
|
||||||
route is the fallback probe if it refuses — enumerate, and log what the device offers before
|
|
||||||
assuming anything.
|
|
||||||
- **Unit 2 — blinking "SD" badge in the preview only**, when a better format was available and we did
|
|
||||||
not get it. Top-right corner of the webcam, or the 1:30 point ON THE RIM for a round clip (a round
|
|
||||||
webcam has no corner); outside the mirror transform. ~1Hz opacity blink (under the 3/sec
|
|
||||||
photosensitivity limit), tooltip on hover only, **must never appear in the recording or stream**
|
|
||||||
(the compositor draws from camera frames, not the WPF tree). Style lives in `Themes/Controls.xaml`
|
|
||||||
— a DataTrigger storyboard cannot be stopped inside a DataTemplate (`PreviewPane.xaml:168-171`).
|
|
||||||
- ⛔ **NO fallback-camera setting, ever** — creator ruling: *"no fallback - that's bypassing the
|
|
||||||
user's explicit choice."* Picking a vcam is a user action in the camera picker. Corollary, also
|
|
||||||
recorded in `ai.md`: **Windows Studio Effects is NOT a vcam** — it runs inside the physical camera's
|
|
||||||
driver and never appears as a selectable device. Only Broadcast / XSplit VCam install one.
|
|
||||||
- ⛔ **Background removal: permanently out.** Creator confirmed the existing note: *"no - not doing
|
|
||||||
it. the note is correct and accurate."* `ai.md` said "milestone 2", which was stale — now corrected.
|
|
||||||
|
|
||||||
## 🚨 FIXED 2026-10-04 — broadcasts never went live because they were never BOUND
|
|
||||||
|
|
||||||
**Root cause, proven by the diagnostic rather than inferred:** `contentDetails.boundStreamId` on
|
|
||||||
`liveBroadcasts.insert` **does not bind the broadcast.** The log line read
|
|
||||||
`lifeCycle=created privacy=private autoStart=true bound=NONE boundMatches=NO-BINDING |
|
|
||||||
streamStatus=active`.
|
|
||||||
|
|
||||||
`created` is Google's *"incomplete settings"* state, so `enableAutoStart` had no bound stream to fire
|
|
||||||
from, and close-out 403'd `invalidTransition` because `created → complete` is not a legal edge. The
|
|
||||||
reason it survived a week: **a reusable stream accepts bytes for a broadcast that is not watching it**,
|
|
||||||
so a perfectly healthy 99 MB RTMP session is exactly what an unbound broadcast looks like. Google's
|
|
||||||
migration guide states the rule outright — *"a liveBroadcast must be bound to exactly one stream
|
|
||||||
before the live broadcast can actually start"* — and it was in the docs read during the original
|
|
||||||
design.
|
|
||||||
|
|
||||||
Shipped `07a088a`: `BindBroadcastAsync` → `POST liveBroadcasts/bind?part=id&id&streamId` (no body),
|
|
||||||
called right after `CreateBroadcast` and before the pump; failure is fatal to the session because an
|
|
||||||
unbound broadcast can never go live. `boundStreamId` and `CreateBroadcast`'s `streamId` parameter both
|
|
||||||
removed. The old test asserting insert-time binding **laundered a guess into a guarantee** and was
|
|
||||||
replaced. Also corrected the TASK 44 claim that `liveChatId` only appears once live — it is present
|
|
||||||
while lifecycle is `created`; that claim came from reading a sample's active-status guard as a spec.
|
|
||||||
|
|
||||||
**⏭ NEXT STEP — needs the creator, one 60-second Test:** confirm `Broadcast bound:` appears and
|
|
||||||
lifecycle walks `created → ready → live`. If it binds but still won't auto-start, the *next* suspect
|
|
||||||
is an explicit `transition`, not the binding — do not re-open the binding question without log proof.
|
|
||||||
|
|
||||||
**Coverage gap:** no automated test asserts `PrepareAndStartLiveAsync` actually *calls* bind — the
|
|
||||||
service method is tested in isolation, but the wiring in the ViewModel is not. Acceptable only
|
|
||||||
because the end-to-end symptom was the whole point of the diagnostic; revisit if the wiring is ever
|
|
||||||
touched again.
|
|
||||||
|
|
||||||
## 🚨 FIXED 2026-09-27 — "Change Account" no longer signs you out before the browser opens
|
|
||||||
|
|
||||||
**Reported as:** *"I clicked on my signed-in account pic, then clicked sign-in to another account. It
|
|
||||||
opened the browser page to login under a different account and I canceled the request. But when
|
|
||||||
returned to the app, I see logged out — and should not have happened! Cannot sign-in because the
|
|
||||||
callback url was not released."* Also reported as hitting **both** running instances.
|
|
||||||
|
|
||||||
**Two independent defects, and the second one was masking a race:**
|
|
||||||
|
|
||||||
1. **The sign-out ran first.** `ChangeAccountAsync` was `await SignOutYouTubeAsync(); await
|
|
||||||
SignInAsync();`. `SignOutYouTubeAsync` → `TokenStore.Clear()` → **deletes** `ytLlive.auth`. So the
|
|
||||||
working account's token was destroyed *before the browser even opened*; cancelling the chooser left
|
|
||||||
nothing. Now `ChangeAccountAsync` is just `await SignInAsync()` — success overwrites the token via
|
|
||||||
the `sessionChanged` hook, cancel/failure keeps the current session. No sign-out is needed at all.
|
|
||||||
2. **The retry couldn't rebind the callback port.** Abandoning the browser gives the app no signal, so
|
|
||||||
the attempt held `localhost:8765` for its full 5-minute timeout. A new `AuthenticateAsync` now
|
|
||||||
cancels and awaits the abandoned attempt before binding, **serialised by a `SemaphoreSlim`**
|
|
||||||
(`_authGate`) — without the gate, the takeover window let a third caller see a free port and race
|
|
||||||
to bind it, i.e. the takeover reproduced the original failure. My own test caught this.
|
|
||||||
|
|
||||||
**The cross-instance claim was NOT the cause** and is still open as a *separate* question: no code path
|
|
||||||
can sign out a second instance (`TokenStore` is per-`InstanceProfile.DataRoot`, `Clear()` is a local
|
|
||||||
file delete with no server-side revoke, and no second instance had a token at inspection time). Verify
|
|
||||||
both processes carried distinct `YTLIVE_INSTANCE` values and ran Debug builds before chasing it.
|
|
||||||
|
|
||||||
Test: `YouTubeAuthServiceTests.RetryingAfterAnAbandonedAttempt_ReleasesTheCallbackPort`. Full suite
|
|
||||||
**370/370**, 0 warnings (build `ytLive.Tests.csproj`, not just the app project, or VSTest runs a stale
|
|
||||||
test DLL). Added an `openBrowser` ctor seam to `YouTubeAuthService` so tests never launch a real
|
|
||||||
browser. **Coverage gap:** the *ordering* fix (defect 1) has no automated test — `_youtubeAuth` is
|
|
||||||
hardcoded in `MainViewModel` and `ChangeAccountAsync` is private, so a real test would shell out to
|
|
||||||
the browser. Wiring an auth seam into `MainViewModel` is a deliberate follow-up, not a bug-fix extra.
|
|
||||||
|
|
||||||
## 🗄 PARKED — Google OAuth verification: attempted, hit a Google-side bug, tabled by ruling
|
|
||||||
|
|
||||||
**Creator ruling: unimportant for where we currently sit.** Verification is *not* a live blocker and
|
|
||||||
**must not be re-attempted, re-derived, or re-researched** without a new ruling. The 2026-09-27 entry
|
|
||||||
that preceded this one wrongly sat at `⏭ NEXT STEP` and framed it as the top priority — that was the
|
|
||||||
plan going in, not the outcome, and the outcome was never written down. Corrected 2026-09-28.
|
|
||||||
|
|
||||||
**What happened:** the verification attempt was made and failed on a bug **on Google's side** — not
|
|
||||||
something we can fix, work around, or retry our way past. ⛔ **The exact mechanism was never recorded
|
|
||||||
and is deliberately left blank** rather than reconstructed from memory: a confidently wrong note here
|
|
||||||
is worse than none, because the next session would trust it. Do not go looking for the cause.
|
|
||||||
|
|
||||||
**Owning account (creator-confirmed): `llamachile.shop@gmail.com` ("gramps").** Cloud project number
|
|
||||||
`198374416003` (from the ClientId) — banked in `ai.md:233`, **do not re-hunt it.** The one-hop console
|
|
||||||
link is `https://console.cloud.google.com/apis/credentials?project=198374416003` (picker defaults to
|
|
||||||
**"Recent"** — switch to **"All"**). ⛔ Not the same account as the YouTube channel signed into the app.
|
|
||||||
|
|
||||||
**What tabling costs, stated honestly:** if the consent screen is still in **Testing**, the 100-user
|
|
||||||
cap and the **7-day refresh-token expiry** both stand — that expiry is why the creator kept having to
|
|
||||||
re-consent. Parking verification does not fix that; it accepts it for the solo-use phase. It becomes
|
|
||||||
real work the moment a *second real user* needs to sign in, or at Store submission (TASK 48). The
|
|
||||||
sensitive-vs-restricted classification was never read and is moot until then.
|
|
||||||
|
|
||||||
**One of these two survives the tabling, and the other was a bad idea (corrected 2026-09-28):**
|
|
||||||
- ✅ **DONE — `Services/YouTubeAuthService.cs` no longer requests `auth/youtube.force-ssl`.**
|
|
||||||
It is **redundant**: its grant is *"see, edit, and permanently delete your YouTube videos,
|
|
||||||
ratings, comments and captions"*, a strict subset of `auth/youtube`'s *"Manage your YouTube
|
|
||||||
account"*, which is the scope we keep. Google's own `liveBroadcasts.insert` reference lists
|
|
||||||
`auth/youtube` as an accepted **alternative** to `force-ssl`, so every endpoint this app calls
|
|
||||||
(`channels`, `liveBroadcasts`, `liveStreams`, `liveChat`, `playlistItems`) is covered.
|
|
||||||
Two consent lines became one. **No forced re-consent** — a refresh token keeps the scopes it
|
|
||||||
was originally granted, so existing sessions are untouched; the reduced set applies at the
|
|
||||||
next fresh sign-in. Removing a scope cannot narrow capability when the one kept is broader.
|
|
||||||
- ❌ **NOT doing this, and the earlier note recommending it was wrong: moving
|
|
||||||
`playlistItems.list` off the user's session token.** It was written during the verification
|
|
||||||
hunt on the theory that "public data should not ride on user credentials". Walking the code
|
|
||||||
kills it: `ChannelStatsService` is built as `new ChannelStatsService(_youtubeAuth)` with **one**
|
|
||||||
`HttpClient`, and `channels?mine=true` (same service) **genuinely needs the session**. To split
|
|
||||||
it you would need a second unauthenticated client **plus a Google API key**, because every
|
|
||||||
YouTube Data API request needs a credential — without a key the call 403s. So the "fix" means
|
|
||||||
**shipping a new extractable credential in the binary, subject to quota abuse, to replace a
|
|
||||||
token the app already holds.** It buys **zero** verification-surface reduction (the data is
|
|
||||||
public; no scope is involved) and zero correctness. Strictly worse. ⛔ Do not "fix" this later
|
|
||||||
without a new ruling.
|
|
||||||
|
|
||||||
## ✅ DECIDED 2026-09-27 — pricing: `$10/mo` subscription, or `$400` lifetime, **no annual**
|
|
||||||
|
|
||||||
**Creator's ruling, verbatim: *"Let's just go with $10 monthly recurring subscription for option
|
|
||||||
A and, for option B, a lifetime sub for $400. No annual."* ⛔ **No `.99` prices** (earlier
|
|
||||||
ruling: *"Let's stop with the x.99 stuff - I find that irritating. Just say: ten bucks a
|
|
||||||
month"*). This **supersedes** the 2026-09-21 one-time `$29 → $49` model and the old `$99/yr`
|
|
||||||
sub. No feature gating: free gets everything, the branding flash is the only paid delta.
|
|
||||||
|
|
||||||
**✅ Store revenue share VERIFIED — 15%, and subscriptions are NOT penalised.** Read from the
|
|
||||||
ADA v8.10 PDF itself (downloaded, text-extracted — not a search excerpt). §6(b)(i) charges
|
|
||||||
**15%** on "any Apps (and any In-App Products in such Apps) that are **not listed in Section
|
|
||||||
6(b)(iii)**"; 12% is Games-only; 30% covers Xbox console apps/games, Xbox non-subscription
|
|
||||||
IAP, and Windows 8/Phone 8. LlamaCasty is a Windows PC App — not a Game, not Xbox, not Win8 —
|
|
||||||
so **6(b)(i) governs both tiers.** The agreement's own changelog (**v8.0, Oct 26 2017**) says
|
|
||||||
it outright: *"implement the **85/15 revenue share for non-Game subscriptions**."*
|
|
||||||
|
|
||||||
⇒ **`$10/mo` nets `$8.50/mo` (~$102/yr). `$400` lifetime nets `$340`.** Also confirmed: the
|
|
||||||
15% applies *after* VAT/GST (Net Receipts), payouts are monthly above a **$50** threshold, and
|
|
||||||
no better small-indie rate was found in the standard terms.
|
|
||||||
|
|
||||||
**⛔ Two obligations §6(h) attaches to the recurring tier** (why lifetime is the hedge):
|
|
||||||
(1) we must **fulfil the subscription for the entire period as marketed**, and on breach
|
|
||||||
Microsoft may refund the customer *"the full amount, plus taxes... in Microsoft's sole
|
|
||||||
discretion"*; (2) **raising the price disables auto-renew** — so `$10` is effectively locked
|
|
||||||
for the product's life absent Microsoft's price-change process.
|
|
||||||
|
|
||||||
**⏳ Still open:** the `$50` subscriber→lifetime upgrade-discount SKU was discussed and is
|
|
||||||
**not approved** — do not declare it. Individual vs Company Partner Center account still
|
|
||||||
unanswered (get it right before enrolling).
|
|
||||||
|
|
||||||
## ✅ DECIDED 2026-09-27 — distribution is the Microsoft Store: MSIX + Store IAP
|
|
||||||
|
|
||||||
**Creator's criteria, verbatim: "zero headaches, minimal maintenance (for me) while still
|
|
||||||
providing accountability and a reasonably easy upgrade flow."** Route A is the only option
|
|
||||||
where all four are solved by handing the work to Microsoft rather than to a certificate
|
|
||||||
vendor: **$0/yr, no certificate, no HSM, no annual renewal, no SmartScreen ramp**, plus Store
|
|
||||||
auto-update, Store-side payments/entitlements/refunds/support, and Microsoft review as the
|
|
||||||
accountability layer. The rejected options and their reasons are in
|
|
||||||
`TASKS/research-store-certification.md` §3 — **read that before reopening the question, not
|
|
||||||
before re-deriving it.**
|
|
||||||
|
|
||||||
**The big consequence: the whole licensing backend is deleted.** `PolarLicenseService`,
|
|
||||||
`PolarLicense`, `MainViewModel.License.cs` (`PremiumUrl`, customer portal, the
|
|
||||||
`OfflineGracePeriod = 14 days` subscription-era artifact, renewal/lapse copy) and the wrong
|
|
||||||
"Polar unlocks alerts" string all become dead code. `IsPremium` comes from the **Store
|
|
||||||
entitlement** instead of an HTTP call — which also deletes the entire "network flaky → app
|
|
||||||
thinks I'm expired" bug class. **Velopack, the update URL and the DO droplet go too.**
|
|
||||||
|
|
||||||
**What does not change: the entitlement.** Free gets everything; the branding flash stays the
|
|
||||||
ONLY paid delta. Store IAP changes how `IsPremium` is *obtained*, never what it *gates*.
|
|
||||||
|
|
||||||
**⛔ The old "still open: the price" note is retired — the price is settled, see the top of
|
|
||||||
this file.** The old "~$69 floor" and "don't break the launch-price promise" notes refer to
|
|
||||||
the old Polar storefront and are **not** current.
|
|
||||||
|
|
||||||
### ✅ DONE 2026-09-28 — TASK 48 item 1: bundle ffmpeg (was the "first code unit")
|
|
||||||
|
|
||||||
The app no longer downloads an executable at runtime. `scripts/fetch-ffmpeg.sh` populates a
|
|
||||||
gitignored `ffmpeg/` from a **SHA-256-verified** pinned month-end BtbN artifact; `ytLive.csproj`
|
|
||||||
copies it to output *and publish* and **fails the build** if it's missing; `FfmpegLocator` lost its
|
|
||||||
downloader, its zip extraction and the `%APPDATA%\ytLlive\tools` cache, and now resolves
|
|
||||||
**bundle → PATH** with a loud, actionable failure. The pin that 404'd is gone, so the
|
|
||||||
2026-09-01 failure class cannot recur. Suite **379/379 at that point** (380 now — see Test
|
|
||||||
state); 16 locator facts including an
|
|
||||||
integration test that **executes the bundled binary** (which is what proves the sibling DLLs
|
|
||||||
resolve). `THIRD-PARTY-NOTICES.txt` corrected — it still named the *stale* tag
|
|
||||||
`autobuild-2026-08-09-13-03`, i.e. the one that 404'd.
|
|
||||||
|
|
||||||
**Fresh-clone cost, deliberately accepted:** `./scripts/fetch-ffmpeg.sh` once (~65 MB). 135 MB of
|
|
||||||
DLLs in git would tax every commit on this 9p tree; the URL + SHA-256 are the audit trail.
|
|
||||||
|
|
||||||
**Answering "other 3p binaries needing bundling?" — no, verified.** ffmpeg was the only
|
|
||||||
download-and-execute. `e_sqlite3.dll` + `WebView2Loader.dll` already ship via NuGet
|
|
||||||
(`runtimes\win-x64\native\`), OpenH264 is compiled *into* `avcodec-63.dll`, the rest are managed.
|
|
||||||
Two gaps remain that bundling cannot fix, and they are the next real risks: the **WebView2 Evergreen
|
|
||||||
runtime** is a machine prerequisite (item 2's manifest dependency + a graceful-failure path), and
|
|
||||||
**Velopack's updater downloads and executes**, so its teardown in step 3 is a compliance action.
|
|
||||||
|
|
||||||
### Deferred, not next: the packaging project + `Package.appxmanifest` (TASK 48 items 2–3)
|
|
||||||
|
|
||||||
**Creator ruling 2026-10-04: still in active development, so ship-time work waits and
|
|
||||||
queued features come first.** The unit is unchanged and ready when it is wanted:
|
|
||||||
Full trust, `webcam`/`microphone`, English only; Velopack deleted. Add the `DefaultRecordFolder()`
|
|
||||||
comment in the same unit (the mediumIL invariant below becomes load-bearing the moment the manifest
|
|
||||||
lands). Full-trust writes under a real package identity are still **unverified** — confirm on a
|
|
||||||
real packaged build before trusting them with recordings.
|
|
||||||
|
|
||||||
Carried over from the research unit, still open:
|
|
||||||
- **⛔ `Distribution.md:318` recommended a $400+/yr EV certificate for a SmartScreen bypass
|
|
||||||
Microsoft removed in March 2024.** Fixed then — had it shipped, it would have cost
|
|
||||||
$400+/yr to buy what $150 buys. Now moot anyway (MSIX is signed by Microsoft), but the
|
|
||||||
lesson in `MyMistakes.md` stands: a policy citation is a claim about **scope**, not just
|
|
||||||
text.
|
|
||||||
- **WebView2 Evergreen runtime** is a machine prerequisite the app will hard-fail without. The
|
|
||||||
manifest needs the framework dependency, and WebView2 init needs a graceful error instead of a
|
|
||||||
crash. New, found while bundling ffmpeg.
|
|
||||||
|
|
||||||
### ⛔ Two invariants that break silently if touched
|
|
||||||
|
|
||||||
- **Full trust, or recording breaks.** `DefaultRecordFolder()` writes to Downloads/MyVideos;
|
|
||||||
that passes through unvirtualized **only** at `mediumIL`. Flip the manifest to
|
|
||||||
`appContainer` and output vanishes with no error. A comment is owed there **when the
|
|
||||||
manifest lands** (TASK 48 item 6) — not before.
|
|
||||||
- **Chat is rendered, never stored.** That non-persistence half is the load-bearing part of
|
|
||||||
the 11.12 certification answer. No chat history, moderation log, analytics, or crash payload
|
|
||||||
may be added without re-arguing 11.12 first.
|
|
||||||
|
|
||||||
## 🚨 OPEN 2026-09-28 — the RTMP push never lands: recordings reach YouTube as EMPTY placeholders
|
|
||||||
|
|
||||||
**This is a live bug, not a queued feature**, and it reframes a complaint the map previously
|
|
||||||
treated as closed. It was found on 2026-09-28 while scoping TASK 50, and the handoff had no
|
|
||||||
entry for it at all — corrected here.
|
|
||||||
|
|
||||||
**What the creator observed:** the channel carries **20+ private videos, all content-less
|
|
||||||
placeholders** (creator-inspected, 2026-09-28). This is the real explanation of the
|
|
||||||
2026-09-26 report "I still cannot get private test recordings saved" — it was never true that
|
|
||||||
the app could not *find* an archived VOD. **No video content is ever produced on the channel.**
|
|
||||||
|
|
||||||
**Mechanism (verified against the code AND the channel):** `liveBroadcasts.insert` succeeds and
|
|
||||||
creates the placeholder; everything after it is what fails. Content only exists if all three of
|
|
||||||
these land, and at least one does not:
|
|
||||||
1. broadcast reaches **LIVE** via `liveBroadcasts/transition`,
|
|
||||||
2. ffmpeg actually pushes RTMP to the bound stream key,
|
|
||||||
3. the broadcast ends and YouTube transcodes it into a VOD.
|
|
||||||
|
|
||||||
`enableAutomaticArchiving` is **never set**, so YouTube's `true` default applies — the archiving
|
|
||||||
route exists but has nothing to archive. Broadcasts are inserted `private`
|
|
||||||
(`Services/YouTubeStreamService.cs:49`), so the orphans are not public, and the app has **no
|
|
||||||
delete path** — the creator clears each one by hand in Studio.
|
|
||||||
|
|
||||||
**✅ DIAGNOSTIC ANSWERED 2026-10-04 — ffmpeg reaches YouTube; the broadcast never goes LIVE.**
|
|
||||||
One test-mode push, read from `startup.log`. The three steps, resolved:
|
|
||||||
1. `new MediaFoundationReader`/RTMP path — **WORKS.** ffmpeg opened
|
|
||||||
`rtmp://a.rtmp.youtube.com/live2/4pew-…`, muxed **7.4 MB video + 46 KB audio**, and
|
|
||||||
finalised the FLV with no socket error. Step 2 is fine.
|
|
||||||
2. **The broadcast stays `ready` — this is the break.** `enableAutoStart = true` *is* sent
|
|
||||||
on insert (`Services/YouTubeStreamService.cs:73`), yet at close-out YouTube refused
|
|
||||||
`transition=complete` with **403 `Invalid transition`** — which is only legal from `live`.
|
|
||||||
So YouTube never flipped `ready → live`.
|
|
||||||
3. Never reached (nothing to archive because nothing went live).
|
|
||||||
|
|
||||||
**Two unknowns, and the honest state of each:**
|
|
||||||
- **The push ran only 8 seconds** (08:48:23.9 → 08:48:32.8). YouTube may simply need longer
|
|
||||||
to accept and validate the ingest before it auto-starts. Untested — do not assume.
|
|
||||||
- **The app cannot see this either way.** During a stream it polls **stream** health
|
|
||||||
(`liveStreams`) and never the **broadcast** status; `GetLifeCycleStatusAsync` is called
|
|
||||||
*only* from the end path (`YouTubeStreamService.cs:146`). So the log contains no evidence
|
|
||||||
either way, and `EndBroadcastAsync` skips only on `complete`/`revoked`, meaning it tries
|
|
||||||
`complete` against a `ready` broadcast and earns the 403.
|
|
||||||
|
|
||||||
**⇒ Next step, small and cheap:** log the broadcast's `lifeCycleStatus` while streaming
|
|
||||||
(reuse the existing health poll timer — no new loop), then run **one 60-second** test. That
|
|
||||||
single run answers it. Do not go hunting beyond it until it has.
|
|
||||||
|
|
||||||
**Sequencing constraint — do not flip the flag early.** `enableAutomaticArchiving: false` is the
|
|
||||||
correct end state (TASK 50 will upload the local file deliberately), but archiving is
|
|
||||||
**currently the only route a recording takes to YouTube** — flipping it before TASK 50 ships
|
|
||||||
would leave test streams with nothing at all and re-create the 2026-09-26 complaint in a worse
|
|
||||||
form. Ship the upload, flip the flag in the same task, never before.
|
|
||||||
|
|
||||||
Detail and the open 11.12 re-check: [`TASKS/task-50-publish-recording.md`](TASKS/task-50-publish-recording.md).
|
|
||||||
|
|
||||||
## Landmines
|
## Landmines
|
||||||
|
|
||||||
- **Frame pump jitter — counters now honest, cause NOT yet named (2026-10-05).** The same test
|
- `Assets/starting-background.jpg` is modified in the working tree, **awaiting the creator's visual
|
||||||
stream logged **86 stalls per 5s** (`FramePump stall: iteration 40–50ms` against a 17ms budget)
|
check** — do not commit it with another unit.
|
||||||
while `FramePump stats` read 301/300 frames, 0 dropped. UNIT 0 removed the reason those two
|
- **Never read a field the constructor assigns later** (`RefreshRateProposal()` at ctor 354 reads
|
||||||
numbers could disagree (the averages were diluted by padded submits) and added the counters that
|
`_framePump`, built at 468 — 8 failing tests, `e654a16`).
|
||||||
separate reader from compositor — **but no one has read a post-UNIT-0 log yet.** A creator
|
- **Never pipe a long job through `tail`** — it buffers, so a live gate looks dead, and verify's phase-3
|
||||||
recording is required first. Do not "fix" the pump before that reading.
|
build writes to `/dev/null`. Use `vstest --logger:"console;verbosity=normal"`; it names the failure.
|
||||||
- **Stale fact inside `TASKS.md` (left alone, flagged here per the Scope Lock).** The "1.0 gates"
|
- A killed test run leaves `testhost.exe` holding `LlamaCasty.dll`; every later build then fails on the
|
||||||
section asserts "**TASK 36 is now shipped**", but the catalog row 36 reads **☐ Queued** and
|
copy. `taskkill.exe /F /IM testhost.exe` before rebuilding.
|
||||||
`task-36-gold-pass.md` still shows items 1–6 unchecked. The line most likely means *item 2*
|
- `ChromaKeyRealClipTests`, `AudioPipelineTests.Mix_*`, `AppLogRotationTests` and
|
||||||
(branding flash goes live) shipped at `9761b1d`. **Needs a one-line fix, not a code change** —
|
`NotificationAreaIntegrationTests` flake under load; each passes alone.
|
||||||
flagging rather than fixing because it is not this unit's job.
|
- Every rate change is a full encoder relaunch; rates go through `MainViewModel.EffectiveQuality`.
|
||||||
- **A stale `ytLive.exe` (PID 2544) locked `bin/.../ytLive.exe`** and broke `dotnet run` with
|
- `MainWindow` tests need a temporary `LayoutPathOverride` cleared in `finally`; read render numbers in Release.
|
||||||
MSB3027. Killed. **If the build fails to copy `ytLive.exe`, check for a running instance first.**
|
|
||||||
- **`LayerReorderPersistenceTests.RealMouseDrag_OnTheLayerList_PersistsTheReorder` needs an
|
|
||||||
interactive desktop session.** As of 2026-10-05 it fails deterministically here (`ImgC` vs
|
|
||||||
`ImgA`), verified identical on a clean tree. **Run the suite with ytLive CLOSED** and expect this
|
|
||||||
one to fail in this shell. Not a regression — don't diagnose it from a WSL-shell failure.
|
|
||||||
- **`RealAppHost.RunAsync` exists for a reason** — a frame-pump test MUST `await` inside it. A
|
|
||||||
blocking wait occupies the one shared STA thread and **hangs the whole suite with no output.**
|
|
||||||
Always background a `vstest` run and poll the log; a foreground piped `vstest` returns nothing
|
|
||||||
in this shell even on success.
|
|
||||||
- **`GlobalHotkeys`: two instances registering the SAME global hotkey** — Windows refuses the
|
|
||||||
second. Left alone deliberately.
|
|
||||||
- **MSIX writes under a real package identity are unverified.** The docs say full trust passes
|
|
||||||
user-profile writes through, but confirm on a real packaged build before trusting it with
|
|
||||||
recordings (TASK 48 item 6).
|
|
||||||
|
|
||||||
## Test state
|
|
||||||
|
|
||||||
**402/402, 0 warnings, verified 2026-10-05** (395 + 6 `FrameCadenceTests` + 1 pump-stats integration
|
|
||||||
test + 1 strip-parallel gate). Re-verified after UNITS 4+5. Build the **test** project, not just the app project, or VSTest runs a stale DLL.
|
|
||||||
**Launch exe for the creator's test run: `bin/Debug/net8.0-windows10.0.19041.0/ytLive.exe`.**
|
|
||||||
|
|
||||||
**Confirmed environmental flake, NOT a regression (re-checked 2026-10-05, and it now fails
|
|
||||||
deterministically rather than 2-of-3):** `LayerReorderPersistenceTests.RealMouseDrag_OnTheLayerList_
|
|
||||||
PersistsTheReorder` fails with `Expected "ImgC", Actual "ImgA"` at `RealAppHost.Run` line 68. **It
|
|
||||||
fails identically on a stashed clean tree**, so it has nothing to do with UNIT 0 — it needs an
|
|
||||||
interactive desktop session this shell does not have (the mouse drag never lands). Do not chase it;
|
|
||||||
it is listed under Landmines as the known interactive-session test.
|
|
||||||
|
|
||||||
**⚠️ A game-playing session breaks the audio tests — know this before debugging it.** On
|
|
||||||
2026-10-05 `AudioPipelineTests.Mix_HonorsProviderGains_AndGameMute_KillsTheLoopback` failed with
|
|
||||||
480/480 samples at `0.4` where `0` was expected, then passed in isolation minutes later. Cause: the
|
|
||||||
user was in a **Division 2 boss fight**, and that test captures **audio loopback** — the game's own
|
|
||||||
output landed in it. **An audio test failing during a gaming session is an environment fact, not a
|
|
||||||
regression.** Same bucket as the interactive-desktop flake above: re-run in a quiet session before
|
|
||||||
believing either. (It also briefly failed while I was probing `Parallel.For` thread priorities,
|
|
||||||
which sent me after the wrong culprit entirely — the game was the simpler explanation.)
|
|
||||||
|
|
||||||
**⚠️ New flake, do not chase it as a regression:** on the *first* full-suite run,
|
|
||||||
`AudioPipelineTests.MasterLimiter_CapsTheLiveMix_OnThePipe` failed with `Expected 0.891,
|
|
||||||
Actual 0` — a **zero**, not a slightly-off value, which is the signature of the pipe not being
|
|
||||||
fed yet rather than the limiter misbehaving. It passed 27/27 as a class run and 380/380 on an
|
|
||||||
immediate full-suite re-run. It reads the live mix **off the named pipe**, so it is a
|
|
||||||
whole-suite scheduling artifact of the same family as the `RealAppHost` STA landmine below —
|
|
||||||
**if it ever goes red, re-run before believing it.**
|
|
||||||
|
|
||||||
## Uncommitted / untracked
|
|
||||||
|
|
||||||
- `ffmpeg/` — the 9 bundled binaries (~135 MB), **gitignored by design**. Populated by
|
|
||||||
`scripts/fetch-ffmpeg.sh`. Do not commit it.
|
|
||||||
- `MARCOM.md`, `MONETIZATION.md` — both edited (privacy-copy guard; the Polar-obsolete banner
|
|
||||||
and the re-opened-price note) and both **gitignored by design** (confidential business
|
|
||||||
files, `.gitignore:10-11`). They stay local, as intended. Same for `CREDENTIALS.md` —
|
|
||||||
**never commit those.**
|
|
||||||
|
|
||||||
## Next
|
|
||||||
|
|
||||||
1. **~PUSH, then the creator's 60-second Test** — see the FIXED box above. Packaging is NOT next;
|
|
||||||
the creator's ruling 2026-10-04: still in active development, so ship-time work waits and queued
|
|
||||||
features come first.
|
|
||||||
2. **⏭ Read the Unit 4+5 Release recording (see the IN FLIGHT box at the top).** Then Unit 6
|
|
||||||
(encoder submit backpressure: `worst submit` 53ms, ~357 cumulative drops) if render is inside
|
|
||||||
budget; if render is still over, the per-stage timings name the stage — do not guess.
|
|
||||||
3. **Webcam cadence → Unit 1 (exclusive lock + 720p).** UNIT 0 is shipped and the counters are in
|
|
||||||
`startup.log`; the reading needs one 60-second creator recording (details + the exact
|
|
||||||
read-the-log-together table in the IN FLIGHT box above). Then Unit 1: exclusive camera lock +
|
|
||||||
720p, and Unit 2: the blinking SD badge. **Creator to-do, not yet queued: long-run
|
|
||||||
resource/consumption/recycling benchmarking** (how long the app stays healthy under full burden) —
|
|
||||||
record only, do not start it.
|
|
||||||
4. **The packaging project + `Package.appxmanifest`** (TASK 48 items 2–3) — the next code unit.
|
|
||||||
Full trust, `webcam`/`microphone`, English only; Velopack deleted. Add the
|
|
||||||
`DefaultRecordFolder()` comment in the same unit, and the WebView2 framework dependency +
|
|
||||||
graceful-failure path.
|
|
||||||
5. **TASK 50 — one-button "Publish this video"** (`TASKS/task-50-publish-recording.md`). Queued
|
|
||||||
behind the Store submission by its own precedence note ("MSIX first, publish second — unless
|
|
||||||
the creator reverses that"). Scoping is done; the build is not. ⛔ Do **not** flip
|
|
||||||
`enableAutomaticArchiving` until it ships. It also carries an **open 11.12 re-check**: a
|
|
||||||
publish feature makes the app *push* creator content to a third party, so the IARC answers
|
|
||||||
must not go to certification describing an app that cannot publish.
|
|
||||||
6. **Declare the IAP products in Partner Center** — the price is **settled** ($10/mo, $400
|
|
||||||
lifetime, no annual, 15% verified), so this is unblocked. **Blocks:** the Store listing and the
|
|
||||||
`OverlayHost.xaml` copy. ⛔ The Individual-vs-Company account question is still unanswered —
|
|
||||||
get it right before enrolling.
|
|
||||||
7. **Polar teardown** (TASK 48 item 0) — `PolarLicenseService`, `PolarLicense`,
|
|
||||||
`MainViewModel.License.cs`, the `OverlayHost.xaml` string, the `csproj`/`App.xaml.cs`
|
|
||||||
Velopack sites. `IsPremium` ← Store entitlement. **Do this as one unit** — a half-torn-down
|
|
||||||
licensing path is worse than either end state.
|
|
||||||
8. **Vertical recording verification** — the compositor tier is proven by
|
|
||||||
`Render_VerticalTier_Outputs_1080x1920_From_The_Center_Crop`; the *record* path has never
|
|
||||||
been run. `FramePump.cs:645` flags off-size tiers as a known follow-up.
|
|
||||||
7. **WACK + certification notes + the privacy policy** (TASK 48 items 4–5) — the policy must
|
|
||||||
state camera/mic is OS-gated, nothing is retained, and **nothing is fetched at runtime**
|
|
||||||
(now literally true — the ffmpeg bundle is what makes that sentence defensible).
|
|
||||||
8. **Post-session efficacy report** — roll up `CurrentHealth` (dropped frames, duration, health
|
|
||||||
message) when a stream or recording ends. `SessionTeardownTests` is the natural home.
|
|
||||||
9. **Measure whether the Windows camera toggle gates DShow** — gates all privacy-forward copy
|
|
||||||
(`MARCOM.md` guard). Not a certification risk; a trust risk.
|
|
||||||
10. **TASK 36:212 stale "shipped" line** — one-line fix, needs a hand.
|
|
||||||
|
|
||||||
**Dropped from the list** because the ruling made them moot: multi-instance's ffmpeg tools-dir
|
|
||||||
race (item 1 deleted the tools dir entirely), `scripts/publish.sh` + the `LlamaCasty.exe` rename
|
|
||||||
(the Store packages and delivers — *revisit only if we ever ship outside the Store*), and the
|
|
||||||
alert-gating copy (the wrong string dies with the Polar teardown in step 3).
|
|
||||||
|
|
||||||
**Everything else in the v1 queue:** stream resilience (32), bandwidth step-down (33), scheduled
|
|
||||||
streams (34), scene-linked audio (35), the master limiter (12), text source (3.16), reward
|
|
||||||
events (3.20), the media picker (21), webcam identity (9.5), settings units A/C/D (40), and the
|
|
||||||
defaults split (37). Ship-checklist items live in `TASKS.md` -> "1.0 gates".
|
|
||||||
|
|||||||
@@ -2,6 +2,24 @@ using System.IO;
|
|||||||
|
|
||||||
namespace ytLive.Helpers;
|
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>
|
/// <summary>
|
||||||
/// Minimal file logger used to diagnose startup/shutdown crashes on Windows,
|
/// Minimal file logger used to diagnose startup/shutdown crashes on Windows,
|
||||||
/// where the WPF app has no visible console. Writes to
|
/// where the WPF app has no visible console. Writes to
|
||||||
@@ -10,19 +28,62 @@ namespace ytLive.Helpers;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static class AppLog
|
public static class AppLog
|
||||||
{
|
{
|
||||||
private static readonly string LogPath = Path.Combine(
|
/// <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;
|
||||||
|
|
||||||
|
private static readonly string DefaultLogPath = Path.Combine(
|
||||||
InstanceProfile.DataRoot, "startup.log");
|
InstanceProfile.DataRoot, "startup.log");
|
||||||
|
|
||||||
private static readonly object Gate = new();
|
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; }
|
||||||
|
|
||||||
|
/// <summary>Test seam: the cap that triggers a roll.</summary>
|
||||||
|
internal static long MaxBytes { get; set; } = DefaultMaxBytes;
|
||||||
|
|
||||||
|
private static string LogPath => PathOverride ?? DefaultLogPath;
|
||||||
|
|
||||||
|
internal static IReadOnlyList<AppLogEntry> SnapshotCurrentLaunch()
|
||||||
|
{
|
||||||
|
lock (Gate)
|
||||||
|
return CurrentLaunchEntries.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
public static void Write(string message)
|
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
|
try
|
||||||
{
|
{
|
||||||
lock (Gate)
|
lock (Gate)
|
||||||
{
|
{
|
||||||
Directory.CreateDirectory(Path.GetDirectoryName(LogPath)!);
|
var entry = new AppLogEntry(
|
||||||
File.AppendAllText(LogPath, $"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff} {message}{Environment.NewLine}");
|
++_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, $"{entry.Text}{Environment.NewLine}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
@@ -33,6 +94,29 @@ public static class AppLog
|
|||||||
|
|
||||||
public static void Write(Exception exception, string message)
|
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,33 @@
|
|||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Controls;
|
||||||
|
using System.Windows.Data;
|
||||||
|
|
||||||
|
namespace ytLive.Helpers;
|
||||||
|
|
||||||
|
/// <summary>Gives a ComboBox's CLOSED box a different template from its drop-down
|
||||||
|
/// rows (TASK 63).
|
||||||
|
///
|
||||||
|
/// <para>WPF asks an <see cref="ItemTemplateSelector"/> with the ContentPresenter's
|
||||||
|
/// templated parent: the <see cref="ComboBox"/> itself when it renders the closed
|
||||||
|
/// selection box, the <see cref="ComboBoxItem"/> when it renders a row. That is the
|
||||||
|
/// only way to tell the two apart — <c>ComboBox.SelectionBoxItemTemplate</c> is
|
||||||
|
/// read-only, and WPF copies <c>ItemTemplate</c> into it, so a ComboBox otherwise
|
||||||
|
/// cannot show one thing when shut and another when open.</para>
|
||||||
|
///
|
||||||
|
/// <para>Verified against WPF on .NET 8 (2026-10-21): with the rows' presenter the
|
||||||
|
/// templated parent is the ComboBoxItem, with the closed box's it is the ComboBox.</para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ClosedBoxTemplateSelector : DataTemplateSelector
|
||||||
|
{
|
||||||
|
/// <summary>The template for the closed box — what the creator reads while the
|
||||||
|
/// drop-down is shut.</summary>
|
||||||
|
public DataTemplate? ClosedTemplate { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The template for the drop-down rows.</summary>
|
||||||
|
public DataTemplate? RowTemplate { get; set; }
|
||||||
|
|
||||||
|
public override DataTemplate? SelectTemplate(object item, DependencyObject container)
|
||||||
|
=> container is FrameworkElement { TemplatedParent: ComboBox }
|
||||||
|
? ClosedTemplate
|
||||||
|
: RowTemplate;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
using System;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Windows.Data;
|
||||||
|
using System.Windows.Media;
|
||||||
|
|
||||||
|
namespace ytLive.Helpers;
|
||||||
|
|
||||||
|
/// <summary>Turns a "#RRGGBB" hex string into a brush, so a template can paint a colour
|
||||||
|
/// swatch straight from the palette (TASK 63).
|
||||||
|
///
|
||||||
|
/// <para>Without it the UI would have to hand-build a brush in code — the hand-painted
|
||||||
|
/// approach the theme forbids. A colour is data; the theme paints it.</para></summary>
|
||||||
|
public class HexToBrushConverter : IValueConverter
|
||||||
|
{
|
||||||
|
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||||
|
{
|
||||||
|
if (value is not string hex || string.IsNullOrWhiteSpace(hex))
|
||||||
|
return Brushes.Transparent;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var colour = (Color)ColorConverter.ConvertFromString(hex.Trim());
|
||||||
|
return new SolidColorBrush(colour);
|
||||||
|
}
|
||||||
|
catch (FormatException)
|
||||||
|
{
|
||||||
|
return Brushes.Transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||||
|
=> throw new NotSupportedException("A brush is not a hex value.");
|
||||||
|
}
|
||||||
@@ -69,6 +69,18 @@ public static class InstanceProfile
|
|||||||
public static string? WebViewDataFolder => null;
|
public static string? WebViewDataFolder => null;
|
||||||
#endif
|
#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
|
/// <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>
|
/// double-L — do not "fix" it, users already have their layouts there.</summary>
|
||||||
public static string DefaultRoot => Path.Combine(
|
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,63 @@
|
|||||||
|
using System.IO;
|
||||||
|
|
||||||
|
namespace ytLive.Helpers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Size-capped log rotation.
|
||||||
|
/// <para>When a log file grows past a cap, it is rolled to <c><name>.1.log</c> and a
|
||||||
|
/// fresh one is started, keeping a bounded number of rolled files. The pattern is the
|
||||||
|
/// one every sized logger uses — Serilog's file sink pairs <c>fileSizeLimitBytes</c>
|
||||||
|
/// (the cap) with <c>retainedFileCountLimit</c> (how many rolled files to keep):
|
||||||
|
/// https://github.com/serilog/serilog-sinks-file</para>
|
||||||
|
/// <para><b>Why it lives here and not in the logger:</b> <see cref="AppLog"/>'s call surface
|
||||||
|
/// (156 call sites) must not change, and the rotation rule is worth testing on its own —
|
||||||
|
/// so the policy is a pure function over a path, and <see cref="AppLog"/> just calls it.</para>
|
||||||
|
/// </summary>
|
||||||
|
public static class LogRotation
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Rolls <paramref name="logPath"/> if it has reached <paramref name="maxBytes"/>,
|
||||||
|
/// keeping <paramref name="retainedFiles"/> rolled copies
|
||||||
|
/// (<c>name.1.log</c>, <c>name.2.log</c>, …). Does nothing when the file is missing
|
||||||
|
/// or still under the cap.
|
||||||
|
/// <para>Never throws: a failed rotation must not take the app down — the log just
|
||||||
|
/// keeps growing, which is the old behaviour.</para>
|
||||||
|
/// </summary>
|
||||||
|
public static void RollIfNeeded(string logPath, long maxBytes, int retainedFiles)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var info = new FileInfo(logPath);
|
||||||
|
if (!info.Exists || info.Length < maxBytes)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Oldest first: name.1 → name.2 → … → name.N, then the live file → name.1.
|
||||||
|
for (var i = retainedFiles; i >= 1; i--)
|
||||||
|
{
|
||||||
|
var source = i == 1 ? logPath : RolledPath(logPath, i - 1);
|
||||||
|
var target = RolledPath(logPath, i);
|
||||||
|
if (!File.Exists(source))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
File.Move(source, target, overwrite: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Never let logging itself crash the app.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The rolled path for a given index: <c>startup.log</c> → <c>startup.1.log</c>.</summary>
|
||||||
|
public static string RolledPath(string logPath, int index)
|
||||||
|
{
|
||||||
|
var directory = Path.GetDirectoryName(logPath) ?? string.Empty;
|
||||||
|
var name = Path.GetFileNameWithoutExtension(logPath);
|
||||||
|
var extension = Path.GetExtension(logPath);
|
||||||
|
return Path.Combine(directory, $"{name}.{index}{extension}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,34 @@
|
|||||||
|
using System;
|
||||||
|
using System.Windows.Input;
|
||||||
|
|
||||||
|
namespace ytLive.Helpers;
|
||||||
|
|
||||||
|
public class RelayCommand<T> : ICommand
|
||||||
|
{
|
||||||
|
private readonly Action<T?> _execute;
|
||||||
|
private readonly Predicate<T?>? _canExecute;
|
||||||
|
|
||||||
|
public RelayCommand(Action<T?> execute, Predicate<T?>? canExecute = null)
|
||||||
|
{
|
||||||
|
_execute = execute;
|
||||||
|
_canExecute = canExecute;
|
||||||
|
}
|
||||||
|
|
||||||
|
public event EventHandler? CanExecuteChanged
|
||||||
|
{
|
||||||
|
add => CommandManager.RequerySuggested += value;
|
||||||
|
remove => CommandManager.RequerySuggested -= value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool CanExecute(object? parameter)
|
||||||
|
{
|
||||||
|
if (parameter is T t) return _canExecute?.Invoke(t) ?? true;
|
||||||
|
return _canExecute?.Invoke(default) ?? true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Execute(object? parameter)
|
||||||
|
{
|
||||||
|
if (parameter is T t) _execute(t);
|
||||||
|
else _execute(default);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,8 @@ Cross-cutting utilities. See [`schema.md`](../schema.md) for the memory-map conv
|
|||||||
|------|---------|
|
|------|---------|
|
||||||
| `ViewModelBase.cs` | `INotifyPropertyChanged` + `SetProperty<T>()` — base for all ViewModels |
|
| `ViewModelBase.cs` | `INotifyPropertyChanged` + `SetProperty<T>()` — base for all ViewModels |
|
||||||
| `RelayCommand.cs` | `ICommand` implementation for all button actions |
|
| `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) |
|
| `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 |
|
| `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) |
|
| `OAuthCredentials.cs` | Baked-in Google OAuth client ID/secret (desktop app; loopback callback) |
|
||||||
| `TokenStore.cs` | DPAPI-protected OAuth session persistence (`%APPDATA%\ytLlive\ytLlive.auth`, CurrentUser scope); `Save`/`Load`/`Clear` — sign-in survives restarts |
|
| `TokenStore.cs` | DPAPI-protected OAuth session persistence (`%APPDATA%\ytLlive\ytLlive.auth`, CurrentUser scope); `Save`/`Load`/`Clear` — sign-in survives restarts |
|
||||||
@@ -14,8 +15,10 @@ Cross-cutting utilities. See [`schema.md`](../schema.md) for the memory-map conv
|
|||||||
| `InverseBoolToVisibilityConverter.cs` / `NotNullToVisibilityConverter.cs` | XAML value converters for visibility bindings |
|
| `InverseBoolToVisibilityConverter.cs` / `NotNullToVisibilityConverter.cs` | XAML value converters for visibility bindings |
|
||||||
| `AllTrueToVisibilityConverter.cs` | Multi-value AND-gate → Visible; gates the layer-row capture menu items on row-IsBackground + staged-scene-is-Live (TASK 25) |
|
| `AllTrueToVisibilityConverter.cs` | Multi-value AND-gate → Visible; gates the layer-row capture menu items on row-IsBackground + staged-scene-is-Live (TASK 25) |
|
||||||
| `CameraConflictProbe.cs` | Best-effort diagnostic: when a camera won't start, enumerates running processes known to hold cameras (OBS, Zoom, Teams, NVIDIA Broadcast, browsers, etc.) — Windows doesn't expose "which process has this device" via any public API, so this is a suspect list, not a verdict |
|
| `CameraConflictProbe.cs` | Best-effort diagnostic: when a camera won't start, enumerates running processes known to hold cameras (OBS, Zoom, Teams, NVIDIA Broadcast, browsers, etc.) — Windows doesn't expose "which process has this device" via any public API, so this is a suspect list, not a verdict |
|
||||||
| `StripPool.cs` | Four DEDICATED background threads at `BelowNormal` priority that split one composite into horizontal row strips (the caller runs a strip too); started lazily, runs inline below 64 rows. UNIT 5 — do NOT demote the ThreadPool from `Parallel.For`, it leaks into unrelated work (see ai.md "Render budget") |
|
| `StripPool.cs` | Four DEDICATED background threads at `BelowNormal` priority that split one composite into horizontal row strips (the caller runs a strip too); started lazily, runs inline below 64 rows. UNIT 5 — do NOT demote the ThreadPool from `Parallel.For`, it leaks into unrelated work (see [`docs/ai/frame-pipeline.md`](../docs/ai/frame-pipeline.md) → "Render budget") |
|
||||||
| `FrameCadence.cs` | Pure arrival-cadence counter (`FrameCadence` + `FrameCadenceSummary.Describe`): frames, gaps, hitches, longest/avg gap for a window of a timestamped stream. Fed by `MediaCaptureFrameSource` (camera stamps vs our arrivals) and `FramePump` (distinct webcam Epochs that reached the output) — UNIT 0, see ai.md "Frame cadence" |
|
| `FrameCadence.cs` | Pure arrival-cadence counter (`FrameCadence` + `FrameCadenceSummary.Describe`): frames, gaps, hitches, longest/avg gap for a window of a timestamped stream. Fed by `MediaCaptureFrameSource` (camera stamps vs our arrivals) and `FramePump` (distinct webcam Epochs that reached the output) — UNIT 0, see [`docs/ai/frame-pipeline.md`](../docs/ai/frame-pipeline.md) → "Frame cadence" |
|
||||||
|
| `HexToBrushConverter.cs` | A "#RRGGBB" hex string → `SolidColorBrush` (transparent on bad input), so a template can paint a colour swatch straight from the palette. Registered in the theme as `HexToBrush` — the theme paints the colour, never the UI code (TASK 63) |
|
||||||
|
| `ClosedBoxTemplateSelector.cs` | An `ItemTemplateSelector` that gives a ComboBox's CLOSED box a different template from its drop-down rows: WPF calls it with the `ContentPresenter`'s templated parent — the `ComboBox` for the closed box, the `ComboBoxItem` for a row. The only way to tell them apart (`SelectionBoxItemTemplate` is read-only and mirrors `ItemTemplate`) — TASK 63 |
|
||||||
|
|
||||||
Related: [`Themes/Controls.xaml`](../Themes/Controls.xaml) styles the lists this
|
Related: [`Themes/Controls.xaml`](../Themes/Controls.xaml) styles the lists this
|
||||||
class backs; [`Models/index.md`](../Models/index.md) and
|
class backs; [`Models/index.md`](../Models/index.md) and
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<Window x:Class="ytLive.HotkeyConfigDialog"
|
<Window x:Class="ytLive.HotkeyConfigDialog"
|
||||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
Title="Global Hotkey Bindings" Height="520" Width="480"
|
Title="Global Hotkey Bindings" Height="520" Width="480"
|
||||||
Icon="/Assets/llamacasty-icon.png"
|
Icon="/Assets/llamacasty-icon.png"
|
||||||
WindowStartupLocation="CenterOwner" ResizeMode="NoResize"
|
WindowStartupLocation="CenterOwner" ResizeMode="NoResize"
|
||||||
ShowInTaskbar="False" Background="#1a1a2e">
|
ShowInTaskbar="False" Background="{StaticResource Brush.Background}">
|
||||||
|
|
||||||
<Window.Resources>
|
<Window.Resources>
|
||||||
<BooleanToVisibilityConverter x:Key="BoolToVis"/>
|
<BooleanToVisibilityConverter x:Key="BoolToVis"/>
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
</Grid.RowDefinitions>
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
<TextBlock Grid.Row="0" Text="Global Hotkey Bindings" FontSize="18" FontWeight="Bold"
|
<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">
|
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto">
|
||||||
<ItemsControl ItemsSource="{Binding Rows}">
|
<ItemsControl ItemsSource="{Binding Rows}">
|
||||||
@@ -34,7 +34,7 @@
|
|||||||
</Grid.ColumnDefinitions>
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
<TextBlock Grid.Column="0" Text="{Binding ActionName}"
|
<TextBlock Grid.Column="0" Text="{Binding ActionName}"
|
||||||
Foreground="#e0e0e0" FontSize="13"
|
Foreground="{StaticResource Brush.OnSurface}" FontSize="13"
|
||||||
VerticalAlignment="Center" Margin="0,0,12,0"/>
|
VerticalAlignment="Center" Margin="0,0,12,0"/>
|
||||||
|
|
||||||
<Button Grid.Column="1" Click="ChordButton_Click"
|
<Button Grid.Column="1" Click="ChordButton_Click"
|
||||||
@@ -42,8 +42,8 @@
|
|||||||
HorizontalAlignment="Left" MinWidth="140"
|
HorizontalAlignment="Left" MinWidth="140"
|
||||||
Padding="10,5" FontSize="13"
|
Padding="10,5" FontSize="13"
|
||||||
VerticalAlignment="Center">
|
VerticalAlignment="Center">
|
||||||
<Border Background="#16213e" CornerRadius="4" Padding="10,5">
|
<Border Background="{StaticResource Brush.SurfaceContainer}" CornerRadius="4" Padding="10,5">
|
||||||
<TextBlock Text="{Binding DisplayText}" Foreground="#e0e0e0"
|
<TextBlock Text="{Binding DisplayText}" Foreground="{StaticResource Brush.OnSurface}"
|
||||||
FontSize="13"/>
|
FontSize="13"/>
|
||||||
</Border>
|
</Border>
|
||||||
</Button>
|
</Button>
|
||||||
@@ -51,7 +51,7 @@
|
|||||||
<Button Grid.Column="2" Content="✕" Click="UnbindButton_Click"
|
<Button Grid.Column="2" Content="✕" Click="UnbindButton_Click"
|
||||||
Tag="{Binding}" Cursor="Hand"
|
Tag="{Binding}" Cursor="Hand"
|
||||||
ToolTip="Remove binding"
|
ToolTip="Remove binding"
|
||||||
FontSize="12" Foreground="#8a93a8"
|
FontSize="12" Foreground="{StaticResource Brush.OnSurfaceVariant}"
|
||||||
Background="Transparent" BorderThickness="0"
|
Background="Transparent" BorderThickness="0"
|
||||||
Padding="6,4" VerticalAlignment="Center">
|
Padding="6,4" VerticalAlignment="Center">
|
||||||
<Button.Style>
|
<Button.Style>
|
||||||
@@ -71,8 +71,8 @@
|
|||||||
</ScrollViewer>
|
</ScrollViewer>
|
||||||
|
|
||||||
<!-- Conflict warning: hidden when no message -->
|
<!-- Conflict warning: hidden when no message -->
|
||||||
<Border Grid.Row="2" Background="#3d1a1a" CornerRadius="4" Padding="10,8"
|
<Border Grid.Row="2" Background="{StaticResource Brush.ErrorContainer}" CornerRadius="4" Padding="10,8"
|
||||||
Margin="0,8,0,0" BorderBrush="#e94560" BorderThickness="1">
|
Margin="0,8,0,0" BorderBrush="{StaticResource Brush.Error}" BorderThickness="1">
|
||||||
<Border.Style>
|
<Border.Style>
|
||||||
<Style TargetType="Border">
|
<Style TargetType="Border">
|
||||||
<Style.Triggers>
|
<Style.Triggers>
|
||||||
@@ -82,7 +82,7 @@
|
|||||||
</Style.Triggers>
|
</Style.Triggers>
|
||||||
</Style>
|
</Style>
|
||||||
</Border.Style>
|
</Border.Style>
|
||||||
<TextBlock Text="{Binding ConflictMessage}" Foreground="#e94560"
|
<TextBlock Text="{Binding ConflictMessage}" Foreground="{StaticResource Brush.Error}"
|
||||||
FontSize="12" TextWrapping="Wrap"/>
|
FontSize="12" TextWrapping="Wrap"/>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<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="/LlamaCasty;component/Assets/llamacasty-icon.png"
|
||||||
|
WindowStartupLocation="CenterOwner" ResizeMode="NoResize"
|
||||||
|
ShowInTaskbar="False" Background="{StaticResource Brush.Background}">
|
||||||
|
|
||||||
|
<Grid Margin="24">
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
|
<TextBlock Grid.Row="0" Text="Keep this broadcast?" FontSize="18" FontWeight="Bold"
|
||||||
|
Foreground="{StaticResource Brush.OnSurface}" Margin="0,0,0,12"/>
|
||||||
|
|
||||||
|
<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">
|
||||||
|
<Button x:Name="DeleteBroadcastButton" Content="Delete" IsCancel="True" Click="Delete_Click"
|
||||||
|
Style="{StaticResource YtButtonSecondary}" Margin="0,0,8,0"
|
||||||
|
ToolTip="Esc — delete the recording from YouTube"/>
|
||||||
|
<Button x:Name="KeepBroadcastButton" Content="Keep" IsDefault="True" Click="Keep_Click"
|
||||||
|
Style="{StaticResource YtButton}" MinWidth="90"
|
||||||
|
ToolTip="Enter — keep it on YouTube as a private video"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</Window>
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
using System.Windows;
|
||||||
|
|
||||||
|
namespace ytLive;
|
||||||
|
|
||||||
|
/// <summary>Asked when a PRIVATE broadcast ends — the same contract as the recording's save
|
||||||
|
/// dialog (creator ruling 2026-10-09): <b>Enter keeps</b> it on YouTube as a private video,
|
||||||
|
/// <b>Escape deletes</b> it. <c>DialogResult == true</c> = keep; false (Delete, Escape, the X)
|
||||||
|
/// = delete.</summary>
|
||||||
|
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?";
|
||||||
|
Loaded += (_, _) => KeepBroadcastButton.Focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Keep_Click(object sender, RoutedEventArgs e) => DialogResult = true;
|
||||||
|
|
||||||
|
private void Delete_Click(object sender, RoutedEventArgs e) => DialogResult = false;
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
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
|
||||||
@@ -11,16 +11,26 @@
|
|||||||
mc:Ignorable="d"
|
mc:Ignorable="d"
|
||||||
Title="{Binding WindowTitle}" Height="1040" Width="1920"
|
Title="{Binding WindowTitle}" Height="1040" Width="1920"
|
||||||
MinWidth="1366" MinHeight="768"
|
MinWidth="1366" MinHeight="768"
|
||||||
Icon="/ytLive;component/Assets/llamacasty-icon.png"
|
Icon="/LlamaCasty;component/Assets/llamacasty-icon.png"
|
||||||
Background="#1a1a2e"
|
Background="{StaticResource Brush.Background}"
|
||||||
|
FontFamily="{StaticResource FontFamily.Sans}"
|
||||||
WindowStartupLocation="Manual"
|
WindowStartupLocation="Manual"
|
||||||
Closing="MainWindow_Closing"
|
Closing="MainWindow_Closing"
|
||||||
Loaded="MainWindow_Loaded"
|
Loaded="MainWindow_Loaded"
|
||||||
Activated="MainWindow_Activated"
|
Activated="MainWindow_Activated"
|
||||||
Deactivated="MainWindow_Deactivated"
|
Deactivated="MainWindow_Deactivated"
|
||||||
|
PreviewKeyDown="MainWindow_PreviewKeyDown"
|
||||||
PreviewMouseLeftButtonDown="Window_PreviewMouseLeftButtonDown"
|
PreviewMouseLeftButtonDown="Window_PreviewMouseLeftButtonDown"
|
||||||
ContextMenuOpening="Window_ContextMenuOpening">
|
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>
|
<Window.DataContext>
|
||||||
<vm:MainViewModel/>
|
<vm:MainViewModel/>
|
||||||
</Window.DataContext>
|
</Window.DataContext>
|
||||||
@@ -31,12 +41,14 @@
|
|||||||
|
|
||||||
<Window.Resources>
|
<Window.Resources>
|
||||||
<ResourceDictionary>
|
<ResourceDictionary>
|
||||||
|
<Helpers:DroppedFramesToBrushConverter x:Key="DroppedToBrush"/>
|
||||||
|
<Helpers:DroppedPercentConverter x:Key="DroppedPercent"/>
|
||||||
|
|
||||||
<!-- Red dot shown in the taskbar icon while live -->
|
<!-- Red dot shown in the taskbar icon while live -->
|
||||||
<DrawingImage x:Key="LiveOverlay">
|
<DrawingImage x:Key="LiveOverlay">
|
||||||
<DrawingImage.Drawing>
|
<DrawingImage.Drawing>
|
||||||
<DrawingGroup>
|
<DrawingGroup>
|
||||||
<GeometryDrawing Brush="#e94560">
|
<GeometryDrawing Brush="{StaticResource Brush.Tertiary}">
|
||||||
<GeometryDrawing.Geometry>
|
<GeometryDrawing.Geometry>
|
||||||
<EllipseGeometry Center="8,8" RadiusX="8" RadiusY="8"/>
|
<EllipseGeometry Center="8,8" RadiusX="8" RadiusY="8"/>
|
||||||
</GeometryDrawing.Geometry>
|
</GeometryDrawing.Geometry>
|
||||||
@@ -72,34 +84,135 @@
|
|||||||
<Grid.RowDefinitions>
|
<Grid.RowDefinitions>
|
||||||
<RowDefinition Height="Auto"/>
|
<RowDefinition Height="Auto"/>
|
||||||
<RowDefinition Height="*"/>
|
<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.RowDefinitions>
|
||||||
<Grid.ColumnDefinitions>
|
<Grid.ColumnDefinitions>
|
||||||
<ColumnDefinition Width="1*" MinWidth="220" MaxWidth="360"/>
|
<ColumnDefinition Width="360"/>
|
||||||
<ColumnDefinition Width="3*"/>
|
<ColumnDefinition Width="3*"/>
|
||||||
</Grid.ColumnDefinitions>
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
<!-- ═══ THUMBNAIL STRIP ═══ -->
|
<!-- STREAM READOUTS (top-left cell, above the layers) + resolution -->
|
||||||
<controls:SceneThumbnailStrip Grid.Row="0" Grid.ColumnSpan="2"/>
|
<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 x:Name="SceneStrip" Grid.Row="0" Grid.Column="1" Margin="8,0" Height="154"/>
|
||||||
|
|
||||||
<!-- LEFT PANEL: one panel, two states.
|
<!-- LEFT PANEL: one panel, two states.
|
||||||
Offline (edit mode): LAYERS + PROPERTIES
|
Offline (edit mode): LAYERS + PROPERTIES
|
||||||
Live/Recording: YouTube Live Chat -->
|
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 -->
|
<!-- CENTER: Preview Area -->
|
||||||
<controls:PreviewPane x:Name="PreviewPane" Grid.Row="1" Grid.Column="1"/>
|
<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>
|
</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"
|
<notifications:NotificationArea x:Name="ToastArea"
|
||||||
Grid.RowSpan="4"
|
Grid.RowSpan="4"
|
||||||
Position="BottomRight" MaxItems="4"
|
Position="{Binding ToastPosition}" MaxItems="4"
|
||||||
Margin="0,0,12,96"/>
|
Margin="{Binding ToastAreaMargin}"/>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Window>
|
</Window>
|
||||||
|
|||||||
@@ -20,9 +20,15 @@ public partial class MainWindow : Window
|
|||||||
{
|
{
|
||||||
AppLog.Write("MainWindow ctor: before InitializeComponent");
|
AppLog.Write("MainWindow ctor: before InitializeComponent");
|
||||||
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");
|
AppLog.Write("MainWindow ctor: after InitializeComponent");
|
||||||
_viewModel = (MainViewModel)DataContext;
|
_viewModel = (MainViewModel)DataContext;
|
||||||
_viewModel.PropertyChanged += OnViewModelPropertyChanged;
|
_viewModel.PropertyChanged += OnViewModelPropertyChanged;
|
||||||
|
// Don't steal focus from an exclusive-fullscreen game the creator is already
|
||||||
|
// playing (e.g. while starting a gameplay stream): activating our window would
|
||||||
|
// drop that game to the taskbar and make the desktop look "locked".
|
||||||
|
ShowActivated = !_viewModel.FullscreenGameRunningNow;
|
||||||
_topBar = (Controls.TopBar)FindName("TopBar")!;
|
_topBar = (Controls.TopBar)FindName("TopBar")!;
|
||||||
_previewPane = (Controls.PreviewPane)FindName("PreviewPane")!;
|
_previewPane = (Controls.PreviewPane)FindName("PreviewPane")!;
|
||||||
_leftPanel = (Controls.LeftPanel)FindName("LeftPanel")!;
|
_leftPanel = (Controls.LeftPanel)FindName("LeftPanel")!;
|
||||||
@@ -141,8 +147,28 @@ public partial class MainWindow : Window
|
|||||||
_topBar.RefreshAvatar();
|
_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();
|
SaveWindowGeometry();
|
||||||
_viewModel.Shutdown();
|
_viewModel.Shutdown();
|
||||||
}
|
}
|
||||||
@@ -156,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()
|
private void UpdateTaskbarOverlay()
|
||||||
{
|
{
|
||||||
TaskbarInfo.Overlay = _viewModel.IsLive
|
TaskbarInfo.Overlay = _viewModel.IsLive
|
||||||
@@ -174,6 +239,10 @@ public partial class MainWindow : Window
|
|||||||
if (form.IsDrawerOpen) form.CloseDrawerCommand.Execute(null);
|
if (form.IsDrawerOpen) form.CloseDrawerCommand.Execute(null);
|
||||||
if (ypp.IsDrawerOpen) ypp.CloseDrawerCommand.Execute(null);
|
if (ypp.IsDrawerOpen) ypp.CloseDrawerCommand.Execute(null);
|
||||||
if (test.IsDrawerOpen) test.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 (_previewPane.IsClickInsidePreview(original)) return;
|
||||||
if (_leftPanel.IsClickInside(original)) return;
|
if (_leftPanel.IsClickInside(original)) return;
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<Window x:Class="ytLive.MicPickerDialog"
|
<Window x:Class="ytLive.MicPickerDialog"
|
||||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
Title="Select Microphone" Height="340" Width="460"
|
Title="Select Microphone" Height="340" Width="460"
|
||||||
Icon="/Assets/llamacasty-icon.png"
|
Icon="/Assets/llamacasty-icon.png"
|
||||||
WindowStartupLocation="CenterOwner" ResizeMode="NoResize"
|
WindowStartupLocation="CenterOwner" ResizeMode="NoResize"
|
||||||
ShowInTaskbar="False" Background="#1a1a2e">
|
ShowInTaskbar="False" Background="{StaticResource Brush.Background}">
|
||||||
|
|
||||||
<Window.Resources>
|
<Window.Resources>
|
||||||
<ResourceDictionary>
|
<ResourceDictionary>
|
||||||
@@ -21,10 +21,10 @@
|
|||||||
</Border>
|
</Border>
|
||||||
<ControlTemplate.Triggers>
|
<ControlTemplate.Triggers>
|
||||||
<Trigger Property="IsMouseOver" Value="True">
|
<Trigger Property="IsMouseOver" Value="True">
|
||||||
<Setter TargetName="Bd" Property="Background" Value="#1c2a52"/>
|
<Setter TargetName="Bd" Property="Background" Value="{StaticResource Brush.SurfaceContainerHigh}"/>
|
||||||
</Trigger>
|
</Trigger>
|
||||||
<Trigger Property="IsSelected" Value="True">
|
<Trigger Property="IsSelected" Value="True">
|
||||||
<Setter TargetName="Bd" Property="Background" Value="#2a2450"/>
|
<Setter TargetName="Bd" Property="Background" Value="{StaticResource Brush.SurfaceContainerHigh}"/>
|
||||||
</Trigger>
|
</Trigger>
|
||||||
</ControlTemplate.Triggers>
|
</ControlTemplate.Triggers>
|
||||||
</ControlTemplate>
|
</ControlTemplate>
|
||||||
@@ -43,10 +43,10 @@
|
|||||||
</Grid.RowDefinitions>
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
<TextBlock Grid.Row="0" Text="Select Microphone" FontSize="18" FontWeight="Bold"
|
<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."
|
<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">
|
<Grid Grid.Row="2">
|
||||||
<ListBox Background="Transparent" BorderThickness="0"
|
<ListBox Background="Transparent" BorderThickness="0"
|
||||||
@@ -58,7 +58,7 @@
|
|||||||
<DataTemplate>
|
<DataTemplate>
|
||||||
<StackPanel Orientation="Horizontal">
|
<StackPanel Orientation="Horizontal">
|
||||||
<TextBlock Text="🎙️" FontSize="15" VerticalAlignment="Center" Margin="0,0,10,0"/>
|
<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"/>
|
VerticalAlignment="Center" TextTrimming="CharacterEllipsis"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
@@ -67,15 +67,15 @@
|
|||||||
|
|
||||||
<StackPanel VerticalAlignment="Center" HorizontalAlignment="Center"
|
<StackPanel VerticalAlignment="Center" HorizontalAlignment="Center"
|
||||||
Visibility="{Binding IsLoading, Converter={StaticResource BoolToVis}}">
|
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>
|
||||||
|
|
||||||
<StackPanel VerticalAlignment="Center"
|
<StackPanel VerticalAlignment="Center"
|
||||||
Visibility="{Binding NoMicrophones, Converter={StaticResource BoolToVis}}">
|
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"/>
|
FontWeight="SemiBold" HorizontalAlignment="Center"/>
|
||||||
<TextBlock Text="Check that a microphone is connected and that Windows microphone access is enabled."
|
<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"/>
|
TextAlignment="Center" HorizontalAlignment="Center" Margin="0,8,0,0"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Grid>
|
</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,11 @@
|
|||||||
|
namespace ytLive.Models;
|
||||||
|
|
||||||
|
/// <summary>Per-webcam background effect (TASK 51). <see cref="Remove"/> makes everything
|
||||||
|
/// outside the person transparent so the layers below show through; <see cref="Blur"/>
|
||||||
|
/// blurs the background behind the person.</summary>
|
||||||
|
public enum BackgroundEffect
|
||||||
|
{
|
||||||
|
None,
|
||||||
|
Remove,
|
||||||
|
Blur,
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
namespace ytLive.Models;
|
||||||
|
|
||||||
|
/// <summary>A colour the border picker offers: its hex value and the plain-English
|
||||||
|
/// name shown next to it (TASK 60).</summary>
|
||||||
|
/// <param name="Hex">The hex value, e.g. <c>#e94560</c>.</param>
|
||||||
|
/// <param name="Name">The name the creator reads, e.g. <c>Crimson</c>.</param>
|
||||||
|
public readonly record struct NamedColor(string Hex, string Name)
|
||||||
|
{
|
||||||
|
/// <summary>"Crimson (#e94560)" — the tooltip the border drop-down shows.</summary>
|
||||||
|
public override string ToString()
|
||||||
|
=> string.IsNullOrEmpty(Name) ? Hex : $"{Name} ({Hex})";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The colours the webcam's border-colour drop-down offers — the SINGLE
|
||||||
|
/// source of truth for the list and for the names.
|
||||||
|
///
|
||||||
|
/// <para>TASK 60 (the creator, 2026-10-21): *"in the drop down box, the #hex values are
|
||||||
|
/// good, but add a corresponding color name/label next to the value."* The list used to
|
||||||
|
/// be a hard-coded array in the click handler, which is why a name could not be added
|
||||||
|
/// to it without touching the UI code.</para>
|
||||||
|
/// </summary>
|
||||||
|
public static class BorderColorPalette
|
||||||
|
{
|
||||||
|
/// <summary>The offered colours, in menu order.</summary>
|
||||||
|
public static IReadOnlyList<NamedColor> All { get; } = new NamedColor[]
|
||||||
|
{
|
||||||
|
new("#FFFFFF", "White"),
|
||||||
|
new("#000000", "Black"),
|
||||||
|
new("#FF0000", "Red"),
|
||||||
|
new("#00FF00", "Lime"),
|
||||||
|
new("#0000FF", "Blue"),
|
||||||
|
new("#FFFF00", "Yellow"),
|
||||||
|
new("#FF00FF", "Magenta"),
|
||||||
|
new("#00FFFF", "Cyan"),
|
||||||
|
new("#FF8800", "Orange"),
|
||||||
|
new("#8800FF", "Violet"),
|
||||||
|
new("#e94560", "Crimson"),
|
||||||
|
new("#22c55e", "Emerald"),
|
||||||
|
new("#3b82f6", "Azure"),
|
||||||
|
new("#f59e0b", "Amber"),
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>The colour's name, or an **empty string** when the palette has no name
|
||||||
|
/// for that hex (a custom hex the creator typed) — the caller decides what to show
|
||||||
|
/// then, and the border field shows the hex alone.</summary>
|
||||||
|
public static string NameOf(string hex) => EntryFor(hex).Name;
|
||||||
|
|
||||||
|
/// <summary>The palette entry for a hex value — the palette's OWN entry (so a
|
||||||
|
/// drop-down bound to it matches by value), or a nameless entry for a custom hex
|
||||||
|
/// the creator typed, so the drop-down always has a selection (TASK 63).</summary>
|
||||||
|
public static NamedColor EntryFor(string hex)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(hex)) return new NamedColor(string.Empty, string.Empty);
|
||||||
|
var trimmed = hex.Trim();
|
||||||
|
foreach (var colour in All)
|
||||||
|
if (string.Equals(colour.Hex, trimmed, StringComparison.OrdinalIgnoreCase))
|
||||||
|
return colour;
|
||||||
|
return new NamedColor(trimmed, string.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The colours a border drop-down offers for a given hex — the palette, plus
|
||||||
|
/// the custom colour the creator typed when it is not one of the palette's, so the
|
||||||
|
/// drop-down always has a selection (TASK 63).</summary>
|
||||||
|
public static IReadOnlyList<NamedColor> OptionsFor(string hex)
|
||||||
|
{
|
||||||
|
var entry = EntryFor(hex);
|
||||||
|
var options = new List<NamedColor>(All);
|
||||||
|
if (!string.IsNullOrEmpty(entry.Hex) && !All.Contains(entry))
|
||||||
|
options.Add(entry);
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
/// <summary>Self-documenting label, e.g. "1080p60 (8Mbps)".</summary>
|
||||||
public string Label => $"{Display} ({Bitrate:0.#}Mbps)";
|
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,36 @@ public class Scene : INotifyPropertyChanged
|
|||||||
|
|
||||||
public bool IsChatScene { get; init; }
|
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
|
||||||
|
/// rendering the scene's original background, the strip shows this instead.
|
||||||
|
/// Null = the strip falls back to the background art (creator ruling 2026-10-10).
|
||||||
|
/// </summary>
|
||||||
|
public string? ThumbnailAssetId { get; set; }
|
||||||
|
|
||||||
private BitmapSource? _snapshot;
|
private BitmapSource? _snapshot;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -60,6 +60,10 @@ public abstract class SceneElement : INotifyPropertyChanged
|
|||||||
/// Dynamic layer once (see <c>SceneGraph.GetSplitPoint</c>).</summary>
|
/// Dynamic layer once (see <c>SceneGraph.GetSplitPoint</c>).</summary>
|
||||||
public virtual ElementKind Kind => ElementKind.Static;
|
public virtual ElementKind Kind => ElementKind.Static;
|
||||||
|
|
||||||
|
/// <summary>True only for the transient zoom layer. Gates zoom-only UI:
|
||||||
|
/// hidden from the layers list, never selectable/draggable in the preview.</summary>
|
||||||
|
public virtual bool IsZoomLayer => false;
|
||||||
|
|
||||||
/// <summary>True only for singleton resources (webcam). Gates webcam-only UI.</summary>
|
/// <summary>True only for singleton resources (webcam). Gates webcam-only UI.</summary>
|
||||||
public virtual bool IsWebcam => false;
|
public virtual bool IsWebcam => false;
|
||||||
|
|
||||||
@@ -80,6 +84,12 @@ public abstract class SceneElement : INotifyPropertyChanged
|
|||||||
|
|
||||||
/// <summary>True only for AlertBox-type sources. Gates stream alerts properties panel.</summary>
|
/// <summary>True only for AlertBox-type sources. Gates stream alerts properties panel.</summary>
|
||||||
public virtual bool IsAlertBox => false;
|
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; }
|
public virtual bool IsBackground { get; set; }
|
||||||
|
|
||||||
private bool _useDefaultBackground = true;
|
private bool _useDefaultBackground = true;
|
||||||
@@ -107,13 +117,45 @@ public abstract class SceneElement : INotifyPropertyChanged
|
|||||||
public bool IsEditing { get => _isEditing; set => Set(ref _isEditing, value); }
|
public bool IsEditing { get => _isEditing; set => Set(ref _isEditing, value); }
|
||||||
|
|
||||||
private ClipShape _clipShape = ClipShape.Traditional;
|
private ClipShape _clipShape = ClipShape.Traditional;
|
||||||
public ClipShape ClipShape
|
// Virtual so a derived element can CONSTRAIN the shape (the webcam is
|
||||||
|
// rect-only in the vertical tier's Chat layout — TASK 57) instead of every
|
||||||
|
// writer re-implementing the rule.
|
||||||
|
public virtual ClipShape ClipShape
|
||||||
{
|
{
|
||||||
get => _clipShape;
|
get => _clipShape;
|
||||||
set
|
set
|
||||||
{
|
{
|
||||||
if (Set(ref _clipShape, value))
|
if (Set(ref _clipShape, value))
|
||||||
Raise(nameof(ShapeButtonText));
|
{
|
||||||
|
Raise(nameof(IsRectShape));
|
||||||
|
Raise(nameof(IsRoundShape));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>True when the element's clip is the plain rectangle — the shape a
|
||||||
|
/// camera has when none was ever picked (TASK 59: the properties panel shows
|
||||||
|
/// the shape as a CHOICE, and Rect is the selected default).</summary>
|
||||||
|
public bool IsRectShape
|
||||||
|
{
|
||||||
|
get => ClipShape != ClipShape.Round;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
// Round -> rect (restores the pre-round rect, like the toggle does).
|
||||||
|
if (value && ClipShape == ClipShape.Round) ToggleClipShape();
|
||||||
|
Raise(nameof(IsRectShape));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>True when the element's clip is a circle (TASK 59).</summary>
|
||||||
|
public bool IsRoundShape
|
||||||
|
{
|
||||||
|
get => ClipShape == ClipShape.Round;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
// Rect -> round (stores the pre-round rect, like the toggle does).
|
||||||
|
if (value && ClipShape != ClipShape.Round) ToggleClipShape();
|
||||||
|
Raise(nameof(IsRoundShape));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,7 +168,7 @@ public abstract class SceneElement : INotifyPropertyChanged
|
|||||||
public double? RectWidth { get => _rectWidth; set => Set(ref _rectWidth, value); }
|
public double? RectWidth { get => _rectWidth; set => Set(ref _rectWidth, value); }
|
||||||
public double? RectHeight { get => _rectHeight; set => Set(ref _rectHeight, value); }
|
public double? RectHeight { get => _rectHeight; set => Set(ref _rectHeight, value); }
|
||||||
|
|
||||||
public void ToggleClipShape()
|
public virtual void ToggleClipShape()
|
||||||
{
|
{
|
||||||
if (ClipShape == ClipShape.Traditional)
|
if (ClipShape == ClipShape.Traditional)
|
||||||
{
|
{
|
||||||
@@ -163,7 +205,6 @@ public abstract class SceneElement : INotifyPropertyChanged
|
|||||||
|
|
||||||
public double MirrorScale => IsMirrored ? -1 : 1;
|
public double MirrorScale => IsMirrored ? -1 : 1;
|
||||||
public string MirrorButtonText => IsMirrored ? "Unmirror" : "Mirror";
|
public string MirrorButtonText => IsMirrored ? "Unmirror" : "Mirror";
|
||||||
public string ShapeButtonText => ClipShape == ClipShape.Round ? "Rect" : "Round";
|
|
||||||
|
|
||||||
// Live camera frames: the shared WriteableBitmap owned by CameraManager.
|
// Live camera frames: the shared WriteableBitmap owned by CameraManager.
|
||||||
private WriteableBitmap? _videoImageSource;
|
private WriteableBitmap? _videoImageSource;
|
||||||
@@ -180,15 +221,18 @@ public abstract class SceneElement : INotifyPropertyChanged
|
|||||||
/// <summary>What the preview shows: live frames (webcam) or a static image (sources).</summary>
|
/// <summary>What the preview shows: live frames (webcam) or a static image (sources).</summary>
|
||||||
public abstract ImageSource? DisplaySource { get; }
|
public abstract ImageSource? DisplaySource { get; }
|
||||||
|
|
||||||
// Position/transform (per-scene usage)
|
// Position/transform (per-scene usage). The geometry setters are virtual so
|
||||||
|
// a derived element can CONSTRAIN them (the webcam clamps itself into the
|
||||||
|
// vertical tier's band — TASK 57) instead of every writer re-implementing
|
||||||
|
// the rule.
|
||||||
private double _x;
|
private double _x;
|
||||||
public double X { get => _x; set => Set(ref _x, value); }
|
public virtual double X { get => _x; set => Set(ref _x, value); }
|
||||||
|
|
||||||
private double _y;
|
private double _y;
|
||||||
public double Y { get => _y; set => Set(ref _y, value); }
|
public virtual double Y { get => _y; set => Set(ref _y, value); }
|
||||||
|
|
||||||
private double _width;
|
private double _width;
|
||||||
public double Width
|
public virtual double Width
|
||||||
{
|
{
|
||||||
get => _width;
|
get => _width;
|
||||||
set
|
set
|
||||||
@@ -199,7 +243,7 @@ public abstract class SceneElement : INotifyPropertyChanged
|
|||||||
}
|
}
|
||||||
|
|
||||||
private double _height;
|
private double _height;
|
||||||
public double Height
|
public virtual double Height
|
||||||
{
|
{
|
||||||
get => _height;
|
get => _height;
|
||||||
set
|
set
|
||||||
@@ -226,10 +270,26 @@ public abstract class SceneElement : INotifyPropertyChanged
|
|||||||
{
|
{
|
||||||
Raise(nameof(HasBorder));
|
Raise(nameof(HasBorder));
|
||||||
Raise(nameof(BorderBrush));
|
Raise(nameof(BorderBrush));
|
||||||
|
Raise(nameof(BorderColorEntry));
|
||||||
|
Raise(nameof(BorderColorOptions));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>The border colour as the drop-down sees it: the palette's entry for the
|
||||||
|
/// current hex — with its NAME — or a nameless entry for a custom hex the creator
|
||||||
|
/// typed. Setting it writes the hex back (TASK 63).</summary>
|
||||||
|
public NamedColor BorderColorEntry
|
||||||
|
{
|
||||||
|
get => BorderColorPalette.EntryFor(BorderColor);
|
||||||
|
set => BorderColor = value.Hex;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The colours the border drop-down offers — the palette, plus the custom
|
||||||
|
/// colour the creator typed (so the drop-down always has a selection) (TASK 63).</summary>
|
||||||
|
public IReadOnlyList<NamedColor> BorderColorOptions
|
||||||
|
=> BorderColorPalette.OptionsFor(BorderColor);
|
||||||
|
|
||||||
private double _borderOpacity = 1.0;
|
private double _borderOpacity = 1.0;
|
||||||
public double BorderOpacity
|
public double BorderOpacity
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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);
|
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;
|
public event PropertyChangedEventHandler? PropertyChanged;
|
||||||
private bool Set<T>(ref T field, T value, [CallerMemberName] string? name = null)
|
private bool Set<T>(ref T field, T value, [CallerMemberName] string? name = null)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.Windows.Media;
|
using System.Windows.Media;
|
||||||
using ytLive.Helpers;
|
using ytLive.Helpers;
|
||||||
|
using ytLive.Services;
|
||||||
|
|
||||||
namespace ytLive.Models;
|
namespace ytLive.Models;
|
||||||
|
|
||||||
@@ -13,10 +14,17 @@ public enum SourceType
|
|||||||
Countdown,
|
Countdown,
|
||||||
WebSource,
|
WebSource,
|
||||||
ChatBox,
|
ChatBox,
|
||||||
|
/// <summary>The session scoreboard strip: full-width, state not events.</summary>
|
||||||
|
Scoreboard,
|
||||||
MediaSource,
|
MediaSource,
|
||||||
/// <summary>The designated alert zone: the six monetization events play their
|
/// <summary>The designated alert zone: the six monetization events play their
|
||||||
/// unique celebration animations here (OBS alert-box equivalent, native).</summary>
|
/// 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>
|
/// <summary>Distinguishes how a media source presents its content (TASK 21).</summary>
|
||||||
@@ -34,6 +42,20 @@ public enum MediaPlaybackState
|
|||||||
Paused
|
Paused
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public enum TextLayerAlignment
|
||||||
|
{
|
||||||
|
Left,
|
||||||
|
Center,
|
||||||
|
Right
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum TextLayerVerticalAlignment
|
||||||
|
{
|
||||||
|
Top,
|
||||||
|
Center,
|
||||||
|
Bottom
|
||||||
|
}
|
||||||
|
|
||||||
public enum ClipShape
|
public enum ClipShape
|
||||||
{
|
{
|
||||||
Traditional,
|
Traditional,
|
||||||
@@ -61,6 +83,19 @@ public enum AlertDisplayMethod
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class Source : SceneElement
|
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;
|
private SourceType _type;
|
||||||
public SourceType Type
|
public SourceType Type
|
||||||
{
|
{
|
||||||
@@ -73,18 +108,36 @@ public class Source : SceneElement
|
|||||||
Raise(nameof(IsLiveCapture));
|
Raise(nameof(IsLiveCapture));
|
||||||
Raise(nameof(DisplaySource));
|
Raise(nameof(DisplaySource));
|
||||||
Raise(nameof(IsWebSource));
|
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>
|
/// <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
|
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.ChatBox or SourceType.WebSource or SourceType.MediaSource or SourceType.AlertBox
|
||||||
|
or SourceType.Blur or SourceType.Ticker
|
||||||
? ElementKind.Dynamic : ElementKind.Static;
|
? ElementKind.Dynamic : ElementKind.Static;
|
||||||
|
|
||||||
public override bool IsWebSource => Type == SourceType.WebSource;
|
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>
|
/// <summary>The permanent scene background (live desktop/game capture). Never removable/reorderable.</summary>
|
||||||
private bool _isBackground;
|
private bool _isBackground;
|
||||||
@@ -110,7 +163,7 @@ public class Source : SceneElement
|
|||||||
private string? _captureKey;
|
private string? _captureKey;
|
||||||
public string? CaptureKey { get => _captureKey; set => Set(ref _captureKey, value); }
|
public string? CaptureKey { get => _captureKey; set => Set(ref _captureKey, value); }
|
||||||
|
|
||||||
public bool IsLiveCapture => Type is SourceType.DisplayCapture or SourceType.WindowCapture;
|
public override bool IsLiveCapture => Type is SourceType.DisplayCapture or SourceType.WindowCapture;
|
||||||
|
|
||||||
// Image (asset stored in the layout database)
|
// Image (asset stored in the layout database)
|
||||||
private string? _assetId;
|
private string? _assetId;
|
||||||
@@ -132,14 +185,37 @@ public class Source : SceneElement
|
|||||||
|
|
||||||
public ImageSource? ImageSource => _imageSource;
|
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
|
public override ImageSource? DisplaySource => IsLiveCapture
|
||||||
? (VideoImageSource ?? _imageSource)
|
? (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;
|
public override bool IsImageSource => Type == SourceType.Image;
|
||||||
|
|
||||||
// Countdown config
|
// Countdown config
|
||||||
private int _timerMinutes = 15;
|
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
|
public int TimerMinutes
|
||||||
{
|
{
|
||||||
get => _timerMinutes;
|
get => _timerMinutes;
|
||||||
@@ -158,6 +234,16 @@ public class Source : SceneElement
|
|||||||
set => Set(ref _mediaType, value);
|
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;
|
private string? _mediaPath;
|
||||||
public string? MediaPath
|
public string? MediaPath
|
||||||
{
|
{
|
||||||
@@ -165,10 +251,17 @@ public class Source : SceneElement
|
|||||||
set
|
set
|
||||||
{
|
{
|
||||||
if (Set(ref _mediaPath, value))
|
if (Set(ref _mediaPath, value))
|
||||||
|
{
|
||||||
Raise(nameof(IsMediaSource));
|
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;
|
private bool _mediaIsLooping;
|
||||||
public bool MediaIsLooping
|
public bool MediaIsLooping
|
||||||
{
|
{
|
||||||
@@ -183,6 +276,31 @@ public class Source : SceneElement
|
|||||||
set => Set(ref _mediaVolume, Math.Clamp(value, 0.0, 1.0));
|
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;
|
private MediaPlaybackState _mediaPlaybackState = MediaPlaybackState.Stopped;
|
||||||
public MediaPlaybackState MediaPlaybackState
|
public MediaPlaybackState MediaPlaybackState
|
||||||
{
|
{
|
||||||
@@ -190,7 +308,134 @@ public class Source : SceneElement
|
|||||||
set => Set(ref _mediaPlaybackState, value);
|
set => Set(ref _mediaPlaybackState, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool IsMediaSource => Type == SourceType.MediaSource;
|
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
|
// 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
|
// table (seeded like image assets); the creator's replacement is a FILE PATH
|
||||||
@@ -207,21 +452,26 @@ public class Source : SceneElement
|
|||||||
public string? AlertVideoPath
|
public string? AlertVideoPath
|
||||||
{
|
{
|
||||||
get => _alertVideoPath;
|
get => _alertVideoPath;
|
||||||
set => Set(ref _alertVideoPath, value);
|
set
|
||||||
|
{
|
||||||
|
if (Set(ref _alertVideoPath, value)) Raise(nameof(ShowsChromaKey));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool _alertUseDefaultVideo = true;
|
private bool _alertUseDefaultVideo = true;
|
||||||
public bool AlertUseDefaultVideo
|
public bool AlertUseDefaultVideo
|
||||||
{
|
{
|
||||||
get => _alertUseDefaultVideo;
|
get => _alertUseDefaultVideo;
|
||||||
set => Set(ref _alertUseDefaultVideo, value);
|
set
|
||||||
|
{
|
||||||
|
if (Set(ref _alertUseDefaultVideo, value)) Raise(nameof(ShowsChromaKey));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool _alertShowTicker = true;
|
|
||||||
public bool AlertShowTicker
|
public bool AlertShowTicker
|
||||||
{
|
{
|
||||||
get => _alertShowTicker;
|
get => _ticker.IsEnabled;
|
||||||
set => Set(ref _alertShowTicker, value);
|
set => _ticker.IsEnabled = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
private double _alertVideoVolume = 1.0;
|
private double _alertVideoVolume = 1.0;
|
||||||
@@ -231,11 +481,32 @@ public class Source : SceneElement
|
|||||||
set => Set(ref _alertVideoVolume, Math.Clamp(value, 0.0, 1.0));
|
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
|
public AlertDisplayMethod AlertDisplayMethod
|
||||||
{
|
{
|
||||||
get => _alertDisplayMethod;
|
get => _ticker.DisplayMethod;
|
||||||
set => Set(ref _alertDisplayMethod, value);
|
set => _ticker.DisplayMethod = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
public override bool IsAlertBox => Type == SourceType.AlertBox;
|
public override bool IsAlertBox => Type == SourceType.AlertBox;
|
||||||
@@ -312,6 +583,79 @@ public class Source : SceneElement
|
|||||||
|
|
||||||
public override bool IsChatBox => Type == SourceType.ChatBox;
|
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;
|
public override bool IsCountdown => Type == SourceType.Countdown;
|
||||||
|
|
||||||
private bool _chatPreviewEnabled = true;
|
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,85 @@
|
|||||||
|
using System.Windows;
|
||||||
|
|
||||||
|
namespace ytLive.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The vertical (9:16) tier's camera rules — TASK 57, the "vv" feature.
|
||||||
|
///
|
||||||
|
/// The vertical tier is a centred band crop of the SAME 1920×1080 master the
|
||||||
|
/// 16:9 tiers use, so a camera placed for 16:9 lands outside the cropped
|
||||||
|
/// window and vanishes from the vertical output. The vertical tier therefore
|
||||||
|
/// keeps its OWN placement per scene: a small tile inside the band, snapped to
|
||||||
|
/// one of six spots — the band splits into a top and a bottom half, each with
|
||||||
|
/// a left, a centre and a right slot.
|
||||||
|
///
|
||||||
|
/// Geometry only: the preview and the compositor both read the element's own
|
||||||
|
/// X/Y/W/H + ClipShape, so the placement is resolved by swapping those values
|
||||||
|
/// when the tier changes. No render-path change.
|
||||||
|
/// </summary>
|
||||||
|
public static class VerticalCameraLayout
|
||||||
|
{
|
||||||
|
/// <summary>The vertical tier's camera tile over the master frame —
|
||||||
|
/// 270×152 master px = 480×270 in the 1080×1920 output.</summary>
|
||||||
|
public const double TileWidth = 270;
|
||||||
|
public const double TileHeight = 152;
|
||||||
|
|
||||||
|
/// <summary>How far the tile keeps from the band's outer edge — the app's
|
||||||
|
/// own placement margin (32 master px) used by the default webcam spot.</summary>
|
||||||
|
public const double Inset = 32;
|
||||||
|
|
||||||
|
/// <summary>True for the portrait tier (the vertical 1080×1920 one).</summary>
|
||||||
|
public static bool IsVerticalTier(QualityOption quality) => quality.Height > quality.Width;
|
||||||
|
|
||||||
|
/// <summary>The half of the band a tile lives in — top or bottom.</summary>
|
||||||
|
public static Rect AreaFor(Rect band, bool bottom)
|
||||||
|
=> new(band.Left, bottom ? band.Top + band.Height / 2 : band.Top, band.Width, band.Height / 2);
|
||||||
|
|
||||||
|
/// <summary>The largest a camera may be inside the band: one whole area.</summary>
|
||||||
|
public static (double Width, double Height) MaxSizeFor(Rect band)
|
||||||
|
=> (band.Width, band.Height / 2);
|
||||||
|
|
||||||
|
/// <summary>True when a camera is wide enough to fill one whole area of the
|
||||||
|
/// band — the "enlarged" Chat layout (one 607×540 area).</summary>
|
||||||
|
public static bool FillsArea(Rect band, double width) => width >= band.Width - 1;
|
||||||
|
|
||||||
|
/// <summary>The two sizes a camera may take in the vertical tier's Chat
|
||||||
|
/// layout: the small tile (normal) or one whole area (enlarged). Returns
|
||||||
|
/// the one nearer the requested width, so the resize handle snaps between
|
||||||
|
/// them instead of taking any size in between.</summary>
|
||||||
|
public static (double Width, double Height) SizeFor(Rect band, double requestedWidth)
|
||||||
|
{
|
||||||
|
var (areaWidth, areaHeight) = MaxSizeFor(band);
|
||||||
|
return Math.Abs(requestedWidth - TileWidth) <= Math.Abs(requestedWidth - areaWidth)
|
||||||
|
? (TileWidth, TileHeight)
|
||||||
|
: (areaWidth, areaHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The spot a tile snaps to: the area (top/bottom) and the column
|
||||||
|
/// (left/centre/right) its centre falls in, then the tile's top-left for
|
||||||
|
/// that spot — inset from the band's outer edge, and never outside the band
|
||||||
|
/// (a tile wider than the inset eats it).
|
||||||
|
/// </summary>
|
||||||
|
public static (double X, double Y) SpotFor(Rect band, double width, double height, double centreX, double centreY)
|
||||||
|
{
|
||||||
|
var bottom = centreY >= band.Top + band.Height / 2;
|
||||||
|
var area = AreaFor(band, bottom);
|
||||||
|
var third = area.Width / 3;
|
||||||
|
|
||||||
|
double x;
|
||||||
|
if (centreX < area.Left + third) x = area.Left + Inset;
|
||||||
|
else if (centreX < area.Left + 2 * third) x = area.Left + (area.Width - width) / 2;
|
||||||
|
else x = area.Right - Inset - width;
|
||||||
|
|
||||||
|
var y = bottom ? area.Bottom - Inset - height : area.Top + Inset;
|
||||||
|
|
||||||
|
x = Math.Clamp(x, band.Left, Math.Max(band.Left, band.Right - width));
|
||||||
|
y = Math.Clamp(y, band.Top, Math.Max(band.Top, band.Bottom - height));
|
||||||
|
return (x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The default tile spot: the bottom-right corner of the band —
|
||||||
|
/// the same corner the 16:9 default webcam placement uses.</summary>
|
||||||
|
public static (double X, double Y) DefaultSpot(Rect band, double width, double height)
|
||||||
|
=> SpotFor(band, width, height, band.Right, band.Bottom);
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Windows;
|
||||||
using System.Windows.Media;
|
using System.Windows.Media;
|
||||||
|
|
||||||
namespace ytLive.Models;
|
namespace ytLive.Models;
|
||||||
@@ -15,4 +16,221 @@ public class WebcamSceneConfig : SceneElement
|
|||||||
public override bool IsDraggable => true;
|
public override bool IsDraggable => true;
|
||||||
public override ElementKind Kind => ElementKind.Dynamic;
|
public override ElementKind Kind => ElementKind.Dynamic;
|
||||||
public override ImageSource? DisplaySource => VideoImageSource;
|
public override ImageSource? DisplaySource => VideoImageSource;
|
||||||
|
|
||||||
|
private BackgroundEffect _backgroundEffect = BackgroundEffect.None;
|
||||||
|
/// <summary>TASK 51: this scene's background effect for the webcam (persisted).</summary>
|
||||||
|
public BackgroundEffect BackgroundEffect
|
||||||
|
{
|
||||||
|
get => _backgroundEffect;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (!Set(ref _backgroundEffect, value)) return;
|
||||||
|
Raise(nameof(RemoveBackground));
|
||||||
|
Raise(nameof(BlurBackground));
|
||||||
|
Raise(nameof(HasBackgroundEffect));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>True while Remove or Blur is on — shows the strength slider.</summary>
|
||||||
|
public bool HasBackgroundEffect => BackgroundEffect != BackgroundEffect.None;
|
||||||
|
|
||||||
|
private double _backgroundStrength = 0.5;
|
||||||
|
/// <summary>TASK 51: 0..1 — Blur amount, or Remove's edge softness (persisted).</summary>
|
||||||
|
public double BackgroundStrength
|
||||||
|
{
|
||||||
|
get => _backgroundStrength;
|
||||||
|
set => Set(ref _backgroundStrength, Math.Clamp(value, 0, 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Pill-switch surface: Remove and Blur behave like radio buttons — turning one
|
||||||
|
/// on turns the other off; turning the active one off means <see cref="BackgroundEffect.None"/>.</summary>
|
||||||
|
public bool RemoveBackground
|
||||||
|
{
|
||||||
|
get => BackgroundEffect == BackgroundEffect.Remove;
|
||||||
|
set => BackgroundEffect = value ? BackgroundEffect.Remove
|
||||||
|
: BackgroundEffect == BackgroundEffect.Remove ? BackgroundEffect.None : BackgroundEffect;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc cref="RemoveBackground"/>
|
||||||
|
public bool BlurBackground
|
||||||
|
{
|
||||||
|
get => BackgroundEffect == BackgroundEffect.Blur;
|
||||||
|
set => BackgroundEffect = value ? BackgroundEffect.Blur
|
||||||
|
: BackgroundEffect == BackgroundEffect.Blur ? BackgroundEffect.None : BackgroundEffect;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Vertical (9:16) tier placement (TASK 57, the "vv" feature) ────────
|
||||||
|
// The vertical tier is a centred band crop of the same master frame, so the
|
||||||
|
// 16:9 placement lands outside the cropped window and the camera vanishes
|
||||||
|
// from the vertical output. The vertical tier therefore keeps its OWN
|
||||||
|
// position, size and shape per scene, stored here and resolved when the
|
||||||
|
// tier changes; switching back to 16:9 restores the desktop layout exactly.
|
||||||
|
|
||||||
|
/// <summary>The vertical placement's X (null until the scene has one).</summary>
|
||||||
|
public double? VerticalX { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The vertical placement's Y (null until the scene has one).</summary>
|
||||||
|
public double? VerticalY { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The vertical placement's width (null until the scene has one).</summary>
|
||||||
|
public double? VerticalWidth { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The vertical placement's height (null until the scene has one).</summary>
|
||||||
|
public double? VerticalHeight { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The vertical placement's clip shape (circle or rect) — the
|
||||||
|
/// creator's own choice per scene, independent of the 16:9 shape.</summary>
|
||||||
|
public ClipShape? VerticalClipShape { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The vertical placement's pre-Round rect (so a Round tile
|
||||||
|
/// restores its aspect on toggle-back, like the 16:9 one does).</summary>
|
||||||
|
public double? VerticalRectWidth { get; set; }
|
||||||
|
|
||||||
|
/// <inheritdoc cref="VerticalRectWidth"/>
|
||||||
|
public double? VerticalRectHeight { get; set; }
|
||||||
|
|
||||||
|
/// <summary>True once this scene has a vertical placement of its own.</summary>
|
||||||
|
public bool HasVerticalLayout => VerticalX is not null;
|
||||||
|
|
||||||
|
/// <summary>True while the vertical placement is the LIVE geometry (the
|
||||||
|
/// element's own X/Y/W/H). The 16:9 layout is parked in
|
||||||
|
/// <see cref="LandscapeX"/> and friends until the tier switches back.</summary>
|
||||||
|
public bool VerticalLayoutLive { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>True while the vertical placement must stay RECT — the Chat
|
||||||
|
/// scene's vertical layout (the creator's table: "vv · Chat = rect only, no
|
||||||
|
/// circle option"). The circle is not offered there, so the shape cannot
|
||||||
|
/// become Round by any writer.</summary>
|
||||||
|
public bool VerticalShapeLocked { get; internal set; }
|
||||||
|
|
||||||
|
/// <summary>The vertical tier's band over the master frame (transient — the
|
||||||
|
/// VM sets it when the tier changes). While the vertical placement is live
|
||||||
|
/// the camera's geometry is clamped to it: outside the band is invisible.</summary>
|
||||||
|
public Rect VerticalBand { get; set; } = Rect.Empty;
|
||||||
|
|
||||||
|
// The parked 16:9 layout — meaningful only while the vertical placement is
|
||||||
|
// live (it is the 16:9 geometry the tier switch restores).
|
||||||
|
public double LandscapeX { get; private set; }
|
||||||
|
public double LandscapeY { get; private set; }
|
||||||
|
public double LandscapeWidth { get; private set; }
|
||||||
|
public double LandscapeHeight { get; private set; }
|
||||||
|
public ClipShape LandscapeClipShape { get; private set; }
|
||||||
|
public double? LandscapeRectWidth { get; private set; }
|
||||||
|
public double? LandscapeRectHeight { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>Parks the live (16:9) layout and brings a vertical placement
|
||||||
|
/// in. The vertical placement becomes the live geometry from here on.</summary>
|
||||||
|
public void BeginVerticalLayout(double x, double y, double width, double height, ClipShape clipShape)
|
||||||
|
{
|
||||||
|
LandscapeX = X;
|
||||||
|
LandscapeY = Y;
|
||||||
|
LandscapeWidth = Width;
|
||||||
|
LandscapeHeight = Height;
|
||||||
|
LandscapeClipShape = ClipShape;
|
||||||
|
LandscapeRectWidth = RectWidth;
|
||||||
|
LandscapeRectHeight = RectHeight;
|
||||||
|
|
||||||
|
VerticalLayoutLive = true;
|
||||||
|
Width = width;
|
||||||
|
Height = height;
|
||||||
|
X = x;
|
||||||
|
Y = y;
|
||||||
|
ClipShape = clipShape;
|
||||||
|
RectWidth = VerticalRectWidth;
|
||||||
|
RectHeight = VerticalRectHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Restores the parked 16:9 layout — the tier switched back.</summary>
|
||||||
|
public void EndVerticalLayout()
|
||||||
|
{
|
||||||
|
if (!VerticalLayoutLive) return;
|
||||||
|
|
||||||
|
// The vertical placement is the creator's own — keep it, so switching
|
||||||
|
// back and forth does not lose where they put it.
|
||||||
|
CommitLiveLayoutToVertical();
|
||||||
|
|
||||||
|
VerticalLayoutLive = false;
|
||||||
|
Width = LandscapeWidth;
|
||||||
|
Height = LandscapeHeight;
|
||||||
|
X = LandscapeX;
|
||||||
|
Y = LandscapeY;
|
||||||
|
ClipShape = LandscapeClipShape;
|
||||||
|
RectWidth = LandscapeRectWidth;
|
||||||
|
RectHeight = LandscapeRectHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Copies the live geometry into the stored vertical placement, so
|
||||||
|
/// the stored copy never lags the live one (the save path calls this).</summary>
|
||||||
|
public void CommitLiveLayoutToVertical()
|
||||||
|
{
|
||||||
|
VerticalX = X;
|
||||||
|
VerticalY = Y;
|
||||||
|
VerticalWidth = Width;
|
||||||
|
VerticalHeight = Height;
|
||||||
|
VerticalClipShape = ClipShape;
|
||||||
|
VerticalRectWidth = RectWidth;
|
||||||
|
VerticalRectHeight = RectHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The Chat scene's vertical layout is rect-only (the creator's table, the
|
||||||
|
// circle option is not offered there). Locking the SHAPE property — not
|
||||||
|
// just the toggle — means no writer can slip a Round camera in: the
|
||||||
|
// toggle, the shape button and the placement swap all go through here.
|
||||||
|
public override ClipShape ClipShape
|
||||||
|
{
|
||||||
|
get => base.ClipShape;
|
||||||
|
set => base.ClipShape = VerticalShapeLocked ? ClipShape.Traditional : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void ToggleClipShape()
|
||||||
|
{
|
||||||
|
if (VerticalShapeLocked)
|
||||||
|
{
|
||||||
|
ClipShape = ClipShape.Traditional;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
base.ToggleClipShape();
|
||||||
|
}
|
||||||
|
|
||||||
|
// The geometry is clamped into the band while the vertical placement is
|
||||||
|
// live — a camera outside the cropped window is a camera the viewers never
|
||||||
|
// see, and every writer (drag, resize handle, the properties fields) goes
|
||||||
|
// through these setters.
|
||||||
|
public override double X
|
||||||
|
{
|
||||||
|
get => base.X;
|
||||||
|
set => base.X = ClampToBand(value, base.Width, vertical: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override double Y
|
||||||
|
{
|
||||||
|
get => base.Y;
|
||||||
|
set => base.Y = ClampToBand(value, base.Height, vertical: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override double Width
|
||||||
|
{
|
||||||
|
get => base.Width;
|
||||||
|
set => base.Width = VerticalLayoutLive && !VerticalBand.IsEmpty
|
||||||
|
? Math.Min(value, VerticalBand.Width)
|
||||||
|
: value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override double Height
|
||||||
|
{
|
||||||
|
get => base.Height;
|
||||||
|
set => base.Height = VerticalLayoutLive && !VerticalBand.IsEmpty
|
||||||
|
? Math.Min(value, VerticalBand.Height / 2)
|
||||||
|
: value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private double ClampToBand(double value, double size, bool vertical)
|
||||||
|
{
|
||||||
|
if (!VerticalLayoutLive || VerticalBand.IsEmpty) return value;
|
||||||
|
var band = VerticalBand;
|
||||||
|
var min = vertical ? band.Left : band.Top;
|
||||||
|
var max = vertical ? band.Right - size : band.Bottom - size;
|
||||||
|
if (max < min) return min;
|
||||||
|
return Math.Clamp(value, min, max);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,4 +70,5 @@ public class ChatMessage
|
|||||||
/// subscriber events locally (YouTube's insert API only supports text), so the
|
/// subscriber events locally (YouTube's insert API only supports text), so the
|
||||||
/// buffer can label them honestly — simulated events never reach YouTube.</summary>
|
/// buffer can label them honestly — simulated events never reach YouTube.</summary>
|
||||||
public bool IsSimulated { get; set; }
|
public bool IsSimulated { get; set; }
|
||||||
|
public bool IsPinned { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
};
|
||||||
|
}
|
||||||