Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
6b6284d05c | ||
|
|
3901cb8298 | ||
|
|
623d77e531 | ||
|
|
dc1343896d | ||
|
|
77d5120c87 | ||
|
|
08290ece16 | ||
|
|
bdea18e12c | ||
|
|
0464593a02 | ||
|
|
cea641d429 | ||
|
|
25d5416a93 | ||
|
|
81da58ab9c | ||
|
|
a8e3954f01 | ||
|
|
9d04aea55b | ||
|
|
55ca1029a3 | ||
|
|
b2f219739d | ||
|
|
509ef36aa0 | ||
|
|
07a088a23f | ||
|
|
8fbc3701d2 | ||
|
|
0fb2f9c6c3 | ||
|
|
74da99c988 | ||
|
|
5ad0106502 | ||
|
|
048cab79f8 | ||
|
|
cc2d1ef622 | ||
|
|
ad10176e8b | ||
|
|
f3fed0a2a8 | ||
|
|
d95a23c6fb | ||
|
|
2569c09617 | ||
|
|
3637950df5 | ||
|
|
967df83925 | ||
|
|
b3edc2e42e | ||
|
|
4fcf76289c | ||
|
|
eb0dd91126 | ||
|
|
4873ef85ea | ||
|
|
44344c9327 | ||
|
|
536676f11a | ||
|
|
e7cded6879 | ||
|
|
b2036a38e8 | ||
|
|
85fad1ab79 | ||
|
|
e78c58fc28 | ||
|
|
938c5b3de4 | ||
|
|
9761b1d4be | ||
|
|
e3e69d941d | ||
|
|
de81fa3c5d | ||
|
|
f5a9d46881 | ||
|
|
5aedca7305 | ||
|
|
fae8ec5f32 | ||
|
|
7f14ffb11e | ||
|
|
e2ecc248e0 | ||
|
|
aea0670723 | ||
|
|
80038ff152 | ||
|
|
7b940b6a5a | ||
|
|
a11b15e444 | ||
|
|
2c9af9426b | ||
|
|
ecb329e578 | ||
|
|
0981a72eea | ||
|
|
94f015044a | ||
|
|
d4aa588da0 | ||
|
|
3a4e17c735 | ||
|
|
cb750660c3 | ||
|
|
d72949e2f9 | ||
|
|
ac6e67aff0 | ||
|
|
18ab553b0e | ||
|
|
e69db4d26c | ||
|
|
bb5dcb4ba2 | ||
|
|
57841d39ac | ||
|
|
4d9188ab2e | ||
|
|
197ee815a0 | ||
|
|
a9d5f29084 | ||
|
|
4e0915a36e | ||
|
|
523c17b7da | ||
|
|
0c556450cb | ||
|
|
94e114bfdf | ||
|
|
5a1993b6db | ||
|
|
f91bf8715d | ||
|
|
2ac00db0da | ||
|
|
0d0e55de31 | ||
|
|
c11788574e | ||
|
|
fd5198df9f | ||
|
|
75723acc1b | ||
|
|
9d00955004 | ||
|
|
1e4017df03 | ||
|
|
94a934ffa9 | ||
|
|
64a5a6d06f | ||
|
|
b37b8a30f9 | ||
|
|
71932b9756 | ||
|
|
76f51e6f4e | ||
|
|
394d86b94c | ||
|
|
c01206fb8a | ||
|
|
40c453805d | ||
|
|
d0e4a0fb25 | ||
|
|
b22d08eca6 | ||
|
|
11a7af2dc0 | ||
|
|
5ba4d709ae | ||
|
|
724af1499b | ||
|
|
a62a283fd4 | ||
|
|
992bb21bdb | ||
|
|
58050d9ab1 | ||
|
|
efe88b8a0b | ||
|
|
0f72c53aa5 | ||
|
|
b4bba4bf68 | ||
|
|
ea347f211c | ||
|
|
f3d578c81b | ||
|
|
5e78065c2d | ||
|
|
ec7c734bdd | ||
|
|
c45cbc93b7 | ||
|
|
bd396e488c | ||
|
|
d212d5ae2c | ||
|
|
1a39b091a0 | ||
|
|
e564e9884c | ||
|
|
081e4c1bf8 | ||
|
|
5175c1c8c9 | ||
|
|
f6802c7124 | ||
|
|
84aa70f532 | ||
|
|
ed9d7c1ffc | ||
|
|
5ca9778e23 | ||
|
|
e002847777 | ||
|
|
70db34455f | ||
|
|
0f059ece33 | ||
|
|
6d11e8b3fc | ||
|
|
87509bcf99 | ||
|
|
98d30c2056 | ||
|
|
1295e0ed80 | ||
|
|
0f441d825a | ||
|
|
d35823a4a9 | ||
|
|
99aeef7994 | ||
|
|
c46f3a1168 | ||
|
|
fbc8562cf4 | ||
|
|
eb4c379b91 | ||
|
|
09a866e6a1 | ||
|
|
6af2026906 | ||
|
|
27bf74389d | ||
|
|
1c48849853 | ||
|
|
432adfdaef | ||
|
|
716a77f61a | ||
|
|
7fcb2ad3da | ||
|
|
4509befb98 | ||
|
|
af0d372b65 | ||
|
|
89fee6ca4a | ||
|
|
97ffc426fe | ||
|
|
8dcaee0b5b | ||
|
|
7f2bda8ed3 | ||
|
|
5ead064d54 | ||
|
|
5a1a3c566a | ||
|
|
688682d5b5 | ||
|
|
22b780e075 | ||
|
|
7294a1aa2d | ||
|
|
b4f3b52b85 | ||
|
|
6799c40175 | ||
|
|
711ba54af7 | ||
|
|
e1d8b10388 | ||
|
|
7c6ec3e40a | ||
|
|
8fa54d2de7 | ||
|
|
4a321b19ff | ||
|
|
097dd0d9bd | ||
|
|
881addb5b4 | ||
|
|
a2219be198 | ||
|
|
8fa78423e6 | ||
|
|
071ec8be0c | ||
|
|
480ead0fc7 | ||
|
|
a1d9751a6f | ||
|
|
8f490102ee | ||
|
|
f89b9f9ffa | ||
|
|
e8ff4df05f | ||
|
|
670fe3a10a | ||
|
|
3bf053a3b1 | ||
|
|
84a038c498 | ||
|
|
85893ea709 | ||
|
|
3107f928ab | ||
|
|
5b9ff5b742 | ||
|
|
dcb3637cca | ||
|
|
d3271a0405 | ||
|
|
fab2e09903 | ||
|
|
31362d1a43 | ||
|
|
6944db8ab4 | ||
|
|
cb54637e9f | ||
|
|
1f4624c636 | ||
|
|
544208e002 | ||
|
|
c9fd1bdaaa | ||
|
|
73397cfea7 |
@@ -1,12 +1,27 @@
|
|||||||
bin/
|
bin/
|
||||||
obj/
|
obj/
|
||||||
Helpers/OAuthCredentials.cs
|
Helpers/OAuthCredentials.cs
|
||||||
|
|
||||||
|
# Bundled FFmpeg (135 MB of DLLs). Fetched once by scripts/fetch-ffmpeg.sh, which pins
|
||||||
|
# the URL + SHA-256; the build then copies it to the output. Never committed — see the
|
||||||
|
# script header for why the hash, not the binaries, is the audit trail.
|
||||||
*.user
|
*.user
|
||||||
*.suo
|
*.suo
|
||||||
.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*
|
||||||
|
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 queue + authoritative YouTube API research.
|
|
||||||
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,63 @@ 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`
|
||||||
|
- **The private-only broadcast lock is deliberate** — do not unlock it, do not raise it as a
|
||||||
|
question; it ends at v1 by the creator's ruling. → `docs/ai/product.md`
|
||||||
|
- **Read render numbers in Release only** — Debug pixel paths are several× slower.
|
||||||
|
→ `docs/ai/environment.md`
|
||||||
|
|
||||||
## Scope Lock
|
## Scope Lock
|
||||||
|
|
||||||
@@ -115,12 +170,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 +183,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"
|
||||||
|
|||||||
@@ -6,6 +6,9 @@
|
|||||||
<Application.Resources>
|
<Application.Resources>
|
||||||
<ResourceDictionary>
|
<ResourceDictionary>
|
||||||
<ResourceDictionary.MergedDictionaries>
|
<ResourceDictionary.MergedDictionaries>
|
||||||
|
<!-- Design tokens first: every style below resolves its colours, sizes and
|
||||||
|
radii from them (DESIGN.md is the source of truth). -->
|
||||||
|
<ResourceDictionary Source="Themes/Tokens.xaml"/>
|
||||||
<ResourceDictionary Source="Themes/Controls.xaml"/>
|
<ResourceDictionary Source="Themes/Controls.xaml"/>
|
||||||
</ResourceDictionary.MergedDictionaries>
|
</ResourceDictionary.MergedDictionaries>
|
||||||
</ResourceDictionary>
|
</ResourceDictionary>
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -26,20 +25,13 @@ public partial class App : Application
|
|||||||
AppLog.Write(args.Exception, "Dispatcher unhandled exception");
|
AppLog.Write(args.Exception, "Dispatcher unhandled exception");
|
||||||
|
|
||||||
AppLog.Write("App OnStartup begin");
|
AppLog.Write("App OnStartup begin");
|
||||||
|
// Attribution (2026-09-04): every take must be traceable to the exact binary
|
||||||
|
// that produced it — the wordmark shows the release counter (BuildStamp.Release);
|
||||||
|
// the per-build GUID is forensic only and lands here.
|
||||||
|
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: 660 KiB |
|
After Width: | Height: | Size: 717 KiB |
|
After Width: | Height: | Size: 560 KiB |
|
After Width: | Height: | Size: 553 KiB |
|
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,278 @@
|
|||||||
|
<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>
|
||||||
|
</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,93 @@
|
|||||||
|
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 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,64 +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>
|
|
||||||
|
|
||||||
<!-- Gear icon: far left -->
|
|
||||||
<Button Grid.Column="0" ToolTip="Menu" Style="{StaticResource IconButton}"
|
|
||||||
VerticalAlignment="Center" Click="GearButton_Click">
|
|
||||||
<Button.ContextMenu>
|
|
||||||
<ContextMenu DataContext="{Binding PlacementTarget.DataContext, RelativeSource={RelativeSource Self}}">
|
|
||||||
<MenuItem Header="App Settings" Command="{Binding OpenSettingsCommand}"/>
|
|
||||||
<Separator/>
|
|
||||||
<MenuItem Header="Report Bug" Command="{Binding OpenBugCommand}"/>
|
|
||||||
<MenuItem Header="Feature Request" Command="{Binding OpenFeatureCommand}"/>
|
|
||||||
<MenuItem Header="About" Command="{Binding OpenAboutCommand}"/>
|
|
||||||
</ContextMenu>
|
|
||||||
</Button.ContextMenu>
|
|
||||||
<Path Data="M19.14,12.94c0.04,-0.3 0.06,-0.61 0.06,-0.94c0,-0.32 -0.02,-0.64 -0.07,-0.94l2.03,-1.58c0.18,-0.14 0.23,-0.41 0.12,-0.61l-1.92,-3.32c-0.12,-0.22 -0.37,-0.29 -0.59,-0.22l-2.39,0.96c-0.5,-0.38 -1.03,-0.7 -1.62,-0.94L14.4,2.81c-0.04,-0.24 -0.24,-0.41 -0.48,-0.41h-3.84c-0.24,0 -0.43,0.17 -0.47,0.41L9.25,5.35C8.66,5.59 8.12,5.92 7.63,6.29L5.24,5.33c-0.22,-0.08 -0.47,0 -0.59,0.22L2.74,8.87C2.62,9.08 2.66,9.34 2.86,9.48l2.03,1.58C4.84,11.36 4.8,11.69 4.8,12s0.02,0.64 0.07,0.94l-2.03,1.58c-0.18,0.14 -0.23,0.41 -0.12,0.61l1.92,3.32c0.12,0.22 0.37,0.29 0.59,0.22l2.39,-0.96c0.5,0.38 1.03,0.7 1.62,0.94l0.36,2.54c0.05,0.24 0.24,0.41 0.48,0.41h3.84c0.24,0 0.44,-0.17 0.47,-0.41l0.36,-2.54c0.59,-0.24 1.13,-0.56 1.62,-0.94l2.39,0.96c0.22,0.08 0.47,0 0.59,-0.22l1.92,-3.32c0.12,-0.22 0.07,-0.47 -0.12,-0.61L19.14,12.94zM12,15.6c-1.98,0 -3.6,-1.62 -3.6,-3.6s1.62,-3.6 3.6,-3.6s3.6,1.62 3.6,3.6S13.98,15.6 12,15.6z"
|
|
||||||
Fill="#d0d0d0" Width="16" Height="16" Stretch="Uniform"/>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<!-- 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,22 +0,0 @@
|
|||||||
using System.Windows;
|
|
||||||
using System.Windows.Controls;
|
|
||||||
|
|
||||||
namespace ytLive.Controls;
|
|
||||||
|
|
||||||
public partial class BottomBar : UserControl
|
|
||||||
{
|
|
||||||
public BottomBar()
|
|
||||||
{
|
|
||||||
InitializeComponent();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void GearButton_Click(object sender, RoutedEventArgs e)
|
|
||||||
{
|
|
||||||
if (sender is Button { ContextMenu: { } menu } button)
|
|
||||||
{
|
|
||||||
menu.PlacementTarget = button;
|
|
||||||
menu.Placement = System.Windows.Controls.Primitives.PlacementMode.Bottom;
|
|
||||||
menu.IsOpen = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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" Foreground="{StaticResource Brush.Attention}" FontSize="11"
|
||||||
|
FontWeight="SemiBold" Margin="0,10,0,2"/>
|
||||||
|
|
||||||
|
<CheckBox Margin="10,2,0,0" IsChecked="{Binding DeveloperMode, Mode=TwoWay}"
|
||||||
|
Foreground="{StaticResource Brush.OnSurface}"
|
||||||
|
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."
|
||||||
|
Foreground="{StaticResource Brush.Attention}" FontSize="10" TextWrapping="Wrap"
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,961 @@
|
|||||||
|
<UserControl x:Class="ytLive.Controls.LeftPanel"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:Helpers="clr-namespace:ytLive.Helpers"
|
||||||
|
xmlns:models="clr-namespace:ytLive.Models">
|
||||||
|
<UserControl.Resources>
|
||||||
|
<!-- TASK 63: the border-colour drop-down is the app's own YtComboBox, so the
|
||||||
|
colour entries are drawn by TEMPLATES, in the theme's colours and with the
|
||||||
|
theme's HexToBrush converter — no hand-built menu, no hand-painted brush.
|
||||||
|
The closed box shows the swatch + the colour's NAME; the rows show the
|
||||||
|
swatch + the hex + the name. -->
|
||||||
|
<DataTemplate x:Key="BorderColorClosedTemplate">
|
||||||
|
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
|
||||||
|
<Border Width="12" Height="12" CornerRadius="2" VerticalAlignment="Center"
|
||||||
|
Background="{Binding Hex, Converter={StaticResource HexToBrush}}"
|
||||||
|
BorderBrush="{StaticResource Brush.Outline}" BorderThickness="1"/>
|
||||||
|
<TextBlock Text="{Binding Name}" Margin="6,0,0,0" FontSize="12"
|
||||||
|
Foreground="{StaticResource Brush.OnSurface}" VerticalAlignment="Center"/>
|
||||||
|
</StackPanel>
|
||||||
|
</DataTemplate>
|
||||||
|
<DataTemplate x:Key="BorderColorRowTemplate">
|
||||||
|
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
|
||||||
|
<Border Width="12" Height="12" CornerRadius="2" VerticalAlignment="Center"
|
||||||
|
Background="{Binding Hex, Converter={StaticResource HexToBrush}}"
|
||||||
|
BorderBrush="{StaticResource Brush.Outline}" BorderThickness="1"/>
|
||||||
|
<TextBlock Text="{Binding Hex}" Margin="6,0,0,0" FontSize="12"
|
||||||
|
Foreground="{StaticResource Brush.OnSurface}" VerticalAlignment="Center"/>
|
||||||
|
<TextBlock Text="{Binding Name}" Margin="8,0,0,0" FontSize="12"
|
||||||
|
Foreground="{StaticResource Brush.OnSurfaceVariant}" VerticalAlignment="Center"/>
|
||||||
|
</StackPanel>
|
||||||
|
</DataTemplate>
|
||||||
|
<Helpers:ClosedBoxTemplateSelector x:Key="BorderColorTemplates"
|
||||||
|
ClosedTemplate="{StaticResource BorderColorClosedTemplate}"
|
||||||
|
RowTemplate="{StaticResource BorderColorRowTemplate}"/>
|
||||||
|
</UserControl.Resources>
|
||||||
|
|
||||||
|
<Border Background="{StaticResource Brush.SurfaceContainer}" CornerRadius="6" Padding="12">
|
||||||
|
<Grid>
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="*"/>
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
|
<!-- Edit mode (offline): layers + properties -->
|
||||||
|
<Grid Grid.Row="0" Visibility="{Binding IsEditMode, Converter={StaticResource BoolToVis}}">
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="*"/>
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
|
<!-- LAYERS header + Add button -->
|
||||||
|
<StackPanel Grid.Row="0" Orientation="Horizontal" Margin="0,8,0,0">
|
||||||
|
<Path Data="M12,3 L21,8 L12,13 L3,8 Z M3,12 L12,17 L21,12 M3,16 L12,21 L21,16"
|
||||||
|
Stroke="{StaticResource Brush.Tertiary}" StrokeThickness="1.8" StrokeLineJoin="Round"
|
||||||
|
Width="16" Height="16" Stretch="Uniform" VerticalAlignment="Center" Margin="0,-4,6,0"/>
|
||||||
|
<TextBlock Text="SCENE LAYERS" Style="{StaticResource SectionHeader}" VerticalAlignment="Center"/>
|
||||||
|
<Border Width="20" Height="20" CornerRadius="10" Background="{StaticResource Brush.Outline}" Margin="6,-4,0,0" VerticalAlignment="Center">
|
||||||
|
<TextBlock Text="{Binding LayerCount}" Foreground="{StaticResource Brush.Tertiary}" FontSize="11" FontWeight="SemiBold"
|
||||||
|
HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||||
|
</Border>
|
||||||
|
<Button ToolTip="Add Layer" Style="{StaticResource IconButton}" Margin="4,-4,0,0"
|
||||||
|
Click="AddSourceButton_Click">
|
||||||
|
<Button.ContextMenu>
|
||||||
|
<ContextMenu DataContext="{Binding PlacementTarget.DataContext, RelativeSource={RelativeSource Self}}">
|
||||||
|
<MenuItem Header="Webcam" Command="{Binding AddWebcamCommand}"
|
||||||
|
IsEnabled="{Binding CanAddWebcam}"
|
||||||
|
ToolTip="{Binding WebcamAddToolTip}"/>
|
||||||
|
<MenuItem Header="Image" Command="{Binding AddImageCommand}"/>
|
||||||
|
<MenuItem Header="Text" Command="{Binding AddSourceCommand}" CommandParameter="{x:Static models:SourceType.TextOverlay}"/>
|
||||||
|
<MenuItem Header="Countdown" Command="{Binding AddSourceCommand}" CommandParameter="{x:Static models:SourceType.Countdown}"
|
||||||
|
Visibility="{Binding IsCountdownAvailable, Converter={StaticResource BoolToVis}}"
|
||||||
|
ToolTip="Timer overlay — Starting and BRB screens only"/>
|
||||||
|
<MenuItem Header="Web Resource" Command="{Binding AddSourceCommand}" CommandParameter="{x:Static models:SourceType.WebSource}"
|
||||||
|
ToolTip="Embedded web page — set URI in Properties panel"/>
|
||||||
|
<MenuItem Header="YouTube Chat" Command="{Binding AddSourceCommand}" CommandParameter="{x:Static models:SourceType.ChatBox}"
|
||||||
|
IsEnabled="{Binding CanAddYouTubeChat}"
|
||||||
|
ToolTip="One chat layer at a time — it's already in your stream"/>
|
||||||
|
<MenuItem Header="Stream Alerts" Command="{Binding AddSourceCommand}" CommandParameter="{x:Static models:SourceType.AlertBox}"
|
||||||
|
IsEnabled="{Binding CanAddAlerts}"
|
||||||
|
ToolTip="Celebration zone for Super Chat & memberships — one alert layer at a time"/>
|
||||||
|
<!-- Privacy blur: Live scene only (creator ruling 2026-11-01). Sits
|
||||||
|
above the desktop and below the brand logo; several allowed. -->
|
||||||
|
<MenuItem Header="Blur" Command="{Binding AddSourceCommand}" CommandParameter="{x:Static models:SourceType.Blur}"
|
||||||
|
Visibility="{Binding CanAddBlur, Converter={StaticResource BoolToVis}}"
|
||||||
|
ToolTip="Hide part of your desktop from viewers — box or circle"/>
|
||||||
|
</ContextMenu>
|
||||||
|
</Button.ContextMenu>
|
||||||
|
<Path Data="M12,4 L12,20 M4,12 L20,12" Stroke="{StaticResource Brush.Tertiary}" StrokeThickness="2"
|
||||||
|
Width="14" Height="14" Stretch="Uniform"/>
|
||||||
|
</Button>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<Helpers:FocusPreservingListBox x:Name="SourceList" Grid.Row="1" MinHeight="84" MaxHeight="240" Background="Transparent" BorderThickness="0"
|
||||||
|
ItemsSource="{Binding StagedScene.Elements}"
|
||||||
|
SelectedItem="{Binding SelectedElement, Mode=TwoWay}"
|
||||||
|
PreviewMouseLeftButtonDown="List_PreviewMouseLeftButtonDown"
|
||||||
|
PreviewMouseMove="List_PreviewMouseMove"
|
||||||
|
PreviewMouseLeftButtonUp="List_PreviewMouseLeftButtonUp"
|
||||||
|
Foreground="{StaticResource Brush.OnSurface}" FontSize="13">
|
||||||
|
<Helpers:FocusPreservingListBox.ItemContainerStyle>
|
||||||
|
<Style TargetType="ListBoxItem" BasedOn="{StaticResource YtListBoxItem}">
|
||||||
|
<Setter Property="HorizontalContentAlignment" Value="Stretch"/>
|
||||||
|
<Style.Triggers>
|
||||||
|
<DataTrigger Binding="{Binding IsVisible}" Value="False">
|
||||||
|
<Setter Property="Opacity" Value="0.45"/>
|
||||||
|
</DataTrigger>
|
||||||
|
<!-- The transient zoom layer has no editing UI — never
|
||||||
|
show it as a layer row (TASK 51). -->
|
||||||
|
<DataTrigger Binding="{Binding IsZoomLayer}" Value="True">
|
||||||
|
<Setter Property="Visibility" Value="Collapsed"/>
|
||||||
|
</DataTrigger>
|
||||||
|
</Style.Triggers>
|
||||||
|
</Style>
|
||||||
|
</Helpers:FocusPreservingListBox.ItemContainerStyle>
|
||||||
|
<ListBox.ItemTemplate>
|
||||||
|
<DataTemplate>
|
||||||
|
<Grid Margin="8,2,0,2"
|
||||||
|
Tag="{Binding DataContext.CanChangeBackground, RelativeSource={RelativeSource AncestorType=Window}}">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
|
|
||||||
|
<Grid.ContextMenu>
|
||||||
|
<ContextMenu DataContext="{Binding PlacementTarget.DataContext, RelativeSource={RelativeSource Self}}">
|
||||||
|
<MenuItem Header="Change Capture…" Click="BackgroundMenu_ChangeCapture">
|
||||||
|
<MenuItem.Visibility>
|
||||||
|
<MultiBinding Converter="{StaticResource AllTrueToVis}">
|
||||||
|
<Binding Path="IsBackground"/>
|
||||||
|
<Binding Path="PlacementTarget.Tag" RelativeSource="{RelativeSource AncestorType={x:Type ContextMenu}}"/>
|
||||||
|
</MultiBinding>
|
||||||
|
</MenuItem.Visibility>
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem Header="Refresh Capture" Click="BackgroundMenu_RefreshCapture">
|
||||||
|
<MenuItem.Visibility>
|
||||||
|
<MultiBinding Converter="{StaticResource AllTrueToVis}">
|
||||||
|
<Binding Path="IsBackground"/>
|
||||||
|
<Binding Path="PlacementTarget.Tag" RelativeSource="{RelativeSource AncestorType={x:Type ContextMenu}}"/>
|
||||||
|
</MultiBinding>
|
||||||
|
</MenuItem.Visibility>
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem Header="Capture Window…"
|
||||||
|
ItemsSource="{Binding DataContext.OpenWindows, RelativeSource={RelativeSource AncestorType=Window}}">
|
||||||
|
<MenuItem.Visibility>
|
||||||
|
<MultiBinding Converter="{StaticResource AllTrueToVis}">
|
||||||
|
<Binding Path="IsBackground"/>
|
||||||
|
<Binding Path="PlacementTarget.Tag" RelativeSource="{RelativeSource AncestorType={x:Type ContextMenu}}"/>
|
||||||
|
</MultiBinding>
|
||||||
|
</MenuItem.Visibility>
|
||||||
|
<MenuItem.ItemContainerStyle>
|
||||||
|
<Style TargetType="MenuItem">
|
||||||
|
<Setter Property="Header" Value="{Binding Label}"/>
|
||||||
|
<Setter Property="Command"
|
||||||
|
Value="{Binding DataContext.CaptureWindowCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
|
||||||
|
<Setter Property="CommandParameter" Value="{Binding}"/>
|
||||||
|
</Style>
|
||||||
|
</MenuItem.ItemContainerStyle>
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem Header="Refresh Window List" Click="BackgroundMenu_RefreshWindowList">
|
||||||
|
<MenuItem.Visibility>
|
||||||
|
<MultiBinding Converter="{StaticResource AllTrueToVis}">
|
||||||
|
<Binding Path="IsBackground"/>
|
||||||
|
<Binding Path="PlacementTarget.Tag" RelativeSource="{RelativeSource AncestorType={x:Type ContextMenu}}"/>
|
||||||
|
</MultiBinding>
|
||||||
|
</MenuItem.Visibility>
|
||||||
|
</MenuItem>
|
||||||
|
<Separator>
|
||||||
|
<Separator.Visibility>
|
||||||
|
<MultiBinding Converter="{StaticResource AllTrueToVis}">
|
||||||
|
<Binding Path="IsBackground"/>
|
||||||
|
<Binding Path="PlacementTarget.Tag" RelativeSource="{RelativeSource AncestorType={x:Type ContextMenu}}"/>
|
||||||
|
</MultiBinding>
|
||||||
|
</Separator.Visibility>
|
||||||
|
</Separator>
|
||||||
|
<MenuItem Header="Remove Layer" Click="SourceMenu_Remove"
|
||||||
|
Visibility="{Binding IsBackground, Converter={StaticResource InverseBoolToVis}}"/>
|
||||||
|
</ContextMenu>
|
||||||
|
</Grid.ContextMenu>
|
||||||
|
|
||||||
|
<Grid Grid.Column="0" Width="16" Height="16" Margin="0,0,6,0" VerticalAlignment="Center">
|
||||||
|
<Path Data="M7,8 H17 A4,4 0 0 1 21,12 V14 A3,3 0 0 1 17,16 L15,14 H9 L7,16 A3,3 0 0 1 3,14 V12 A4,4 0 0 1 7,8 Z M8,11 V13 M7,12 H9 M15,11 H15.01 M17,13 H17.01" Stroke="{StaticResource Brush.Tertiary}" StrokeThickness="1.6" StrokeLineJoin="Round" Stretch="Uniform">
|
||||||
|
<Path.Style><Style TargetType="Path"><Setter Property="Visibility" Value="Collapsed"/><Style.Triggers><DataTrigger Binding="{Binding IsLiveCapture}" Value="True"><Setter Property="Visibility" Value="Visible"/></DataTrigger></Style.Triggers></Style></Path.Style>
|
||||||
|
</Path>
|
||||||
|
<Path Data="M3,5 H21 V19 H3 Z M3,16 L9,10 L13,14 L16,11 L21,16" Stroke="{StaticResource Brush.Tertiary}" StrokeThickness="1.6" StrokeLineJoin="Round" Stretch="Uniform">
|
||||||
|
<Path.Style><Style TargetType="Path"><Setter Property="Visibility" Value="Collapsed"/><Style.Triggers><MultiDataTrigger><MultiDataTrigger.Conditions><Condition Binding="{Binding IsBackground}" Value="True"/><Condition Binding="{Binding IsLiveCapture}" Value="False"/></MultiDataTrigger.Conditions><Setter Property="Visibility" Value="Visible"/></MultiDataTrigger></Style.Triggers></Style></Path.Style>
|
||||||
|
</Path>
|
||||||
|
<Path Data="M3,7 H15 V17 H3 Z M15,11 L21,8 V16 L15,13" Stroke="{StaticResource Brush.Tertiary}" StrokeThickness="1.6" StrokeLineJoin="Round" Stretch="Uniform">
|
||||||
|
<Path.Style><Style TargetType="Path"><Setter Property="Visibility" Value="Collapsed"/><Style.Triggers><DataTrigger Binding="{Binding IsWebcam}" Value="True"><Setter Property="Visibility" Value="Visible"/></DataTrigger></Style.Triggers></Style></Path.Style>
|
||||||
|
</Path>
|
||||||
|
<Path Data="M4,4 H20 V20 H4 Z M4,16 L9,11 L13,15 L16,12 L20,16 M15,8 H15.01" Stroke="{StaticResource Brush.Tertiary}" StrokeThickness="1.6" StrokeLineJoin="Round" Stretch="Uniform">
|
||||||
|
<Path.Style><Style TargetType="Path"><Setter Property="Visibility" Value="Collapsed"/><Style.Triggers><DataTrigger Binding="{Binding IsImageSource}" Value="True"><Setter Property="Visibility" Value="Visible"/></DataTrigger></Style.Triggers></Style></Path.Style>
|
||||||
|
</Path>
|
||||||
|
<Path Data="M5,5 H19 M12,5 V19 M8,19 H16" Stroke="{StaticResource Brush.Tertiary}" StrokeThickness="1.6" StrokeLineJoin="Round" Stretch="Uniform">
|
||||||
|
<Path.Style><Style TargetType="Path"><Setter Property="Visibility" Value="Collapsed"/><Style.Triggers><DataTrigger Binding="{Binding IsTextOverlay}" Value="True"><Setter Property="Visibility" Value="Visible"/></DataTrigger></Style.Triggers></Style></Path.Style>
|
||||||
|
</Path>
|
||||||
|
<Path Data="M12,3 A9,9 0 1 0 12.01,3 M12,7 V12 L15,14" Stroke="{StaticResource Brush.Tertiary}" StrokeThickness="1.6" StrokeLineJoin="Round" Stretch="Uniform">
|
||||||
|
<Path.Style><Style TargetType="Path"><Setter Property="Visibility" Value="Collapsed"/><Style.Triggers><DataTrigger Binding="{Binding IsCountdown}" Value="True"><Setter Property="Visibility" Value="Visible"/></DataTrigger></Style.Triggers></Style></Path.Style>
|
||||||
|
</Path>
|
||||||
|
<Path Data="M12,3 A9,9 0 1 0 12.01,3 M3,12 H21 M12,3 C8,7 8,17 12,21 M12,3 C16,7 16,17 12,21" Stroke="{StaticResource Brush.Tertiary}" StrokeThickness="1.6" StrokeLineJoin="Round" Stretch="Uniform">
|
||||||
|
<Path.Style><Style TargetType="Path"><Setter Property="Visibility" Value="Collapsed"/><Style.Triggers><DataTrigger Binding="{Binding IsWebSource}" Value="True"><Setter Property="Visibility" Value="Visible"/></DataTrigger></Style.Triggers></Style></Path.Style>
|
||||||
|
</Path>
|
||||||
|
<Path Data="M4,5 H20 V16 H10 L6,20 V16 H4 Z" Stroke="{StaticResource Brush.Tertiary}" StrokeThickness="1.6" StrokeLineJoin="Round" Stretch="Uniform">
|
||||||
|
<Path.Style><Style TargetType="Path"><Setter Property="Visibility" Value="Collapsed"/><Style.Triggers><DataTrigger Binding="{Binding IsChatBox}" Value="True"><Setter Property="Visibility" Value="Visible"/></DataTrigger></Style.Triggers></Style></Path.Style>
|
||||||
|
</Path>
|
||||||
|
<Path Data="M4,5 H20 V19 H4 Z M10,9 L15,12 L10,15 Z" Stroke="{StaticResource Brush.Tertiary}" StrokeThickness="1.6" StrokeLineJoin="Round" Stretch="Uniform">
|
||||||
|
<Path.Style><Style TargetType="Path"><Setter Property="Visibility" Value="Collapsed"/><Style.Triggers><DataTrigger Binding="{Binding IsMediaSource}" Value="True"><Setter Property="Visibility" Value="Visible"/></DataTrigger></Style.Triggers></Style></Path.Style>
|
||||||
|
</Path>
|
||||||
|
<Path Data="M3,9 H21 V15 H3 Z M6,12 H12 M15,12 H18" Stroke="{StaticResource Brush.Tertiary}" StrokeThickness="1.6" StrokeLineJoin="Round" Stretch="Uniform">
|
||||||
|
<Path.Style><Style TargetType="Path"><Setter Property="Visibility" Value="Collapsed"/><Style.Triggers><DataTrigger Binding="{Binding IsScoreboard}" Value="True"><Setter Property="Visibility" Value="Visible"/></DataTrigger></Style.Triggers></Style></Path.Style>
|
||||||
|
</Path>
|
||||||
|
<Path Data="M6,16 V11 A6,6 0 0 1 18,11 V16 L20,18 H4 Z M10,20 H14" Stroke="{StaticResource Brush.Tertiary}" StrokeThickness="1.6" StrokeLineJoin="Round" Stretch="Uniform">
|
||||||
|
<Path.Style><Style TargetType="Path"><Setter Property="Visibility" Value="Collapsed"/><Style.Triggers><DataTrigger Binding="{Binding IsAlertBox}" Value="True"><Setter Property="Visibility" Value="Visible"/></DataTrigger></Style.Triggers></Style></Path.Style>
|
||||||
|
</Path>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<TextBlock Grid.Column="1" Text="{Binding Name}" VerticalAlignment="Center"
|
||||||
|
Padding="4,2" TextTrimming="CharacterEllipsis">
|
||||||
|
<TextBlock.Style>
|
||||||
|
<Style TargetType="TextBlock">
|
||||||
|
<Setter Property="Visibility" Value="Visible"/>
|
||||||
|
<Style.Triggers>
|
||||||
|
<DataTrigger Binding="{Binding IsEditing}" Value="True">
|
||||||
|
<Setter Property="Visibility" Value="Collapsed"/>
|
||||||
|
</DataTrigger>
|
||||||
|
</Style.Triggers>
|
||||||
|
</Style>
|
||||||
|
</TextBlock.Style>
|
||||||
|
</TextBlock>
|
||||||
|
|
||||||
|
<TextBox Grid.Column="1" Text="{Binding Name, UpdateSourceTrigger=PropertyChanged}"
|
||||||
|
BorderThickness="0" FontSize="12"
|
||||||
|
IsVisibleChanged="SourceNameBox_IsVisibleChanged"
|
||||||
|
KeyDown="SourceNameBox_KeyDown" LostFocus="SourceNameBox_LostFocus">
|
||||||
|
<TextBox.Style>
|
||||||
|
<Style TargetType="TextBox" BasedOn="{StaticResource YtTextBoxCompact}">
|
||||||
|
<Setter Property="Visibility" Value="Collapsed"/>
|
||||||
|
<Style.Triggers>
|
||||||
|
<DataTrigger Binding="{Binding IsEditing}" Value="True">
|
||||||
|
<Setter Property="Visibility" Value="Visible"/>
|
||||||
|
</DataTrigger>
|
||||||
|
</Style.Triggers>
|
||||||
|
</Style>
|
||||||
|
</TextBox.Style>
|
||||||
|
</TextBox>
|
||||||
|
|
||||||
|
<Button Grid.Column="2" ToolTip="Edit Layer Name"
|
||||||
|
Command="{Binding DataContext.EditElementCommand, RelativeSource={RelativeSource AncestorType=ListBox}}"
|
||||||
|
CommandParameter="{Binding}" Style="{StaticResource IconButton}">
|
||||||
|
<Path Data="M3,17.25V21h3.75L17.81,9.94l-3.75,-3.75L3,17.25zM20.71,7.04c0.39,-0.39 0.39,-1.02 0,-1.41l-2.34,-2.34c-0.39,-0.39 -1.02,-0.39 -1.41,0l-1.83,1.83 3.75,3.75 1.83,-1.83z"
|
||||||
|
Fill="{StaticResource Brush.OnSurface}" Width="13" Height="13" Stretch="Uniform"/>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button Grid.Column="3" Style="{StaticResource EyeButton}"
|
||||||
|
Command="{Binding DataContext.ToggleElementVisibilityCommand, RelativeSource={RelativeSource AncestorType=ListBox}}"
|
||||||
|
CommandParameter="{Binding}">
|
||||||
|
<Path Style="{StaticResource EyeIconStyle}" Fill="{StaticResource Brush.OnSurface}"
|
||||||
|
Width="13" Height="13" Stretch="Uniform"/>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button Grid.Column="4" ToolTip="Remove Layer"
|
||||||
|
Command="{Binding DataContext.RemoveSourceCommand, RelativeSource={RelativeSource AncestorType=ListBox}}"
|
||||||
|
CommandParameter="{Binding}" Style="{StaticResource IconButton}"
|
||||||
|
Visibility="{Binding IsBackground, Converter={StaticResource HiddenBoolToVis}}">
|
||||||
|
<Path Data="M6,19c0,1.1 0.9,2 2,2h8c1.1,0 2,-0.9 2,-2V7H6v12zM19,4h-3.5l-1,-1h-5l-1,1H5v2h14V4z"
|
||||||
|
Fill="{StaticResource Brush.OnSurface}" Width="13" Height="13" Stretch="Uniform"/>
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
</DataTemplate>
|
||||||
|
</ListBox.ItemTemplate>
|
||||||
|
</Helpers:FocusPreservingListBox>
|
||||||
|
|
||||||
|
<TextBlock Grid.Row="2" Text="No layers yet" Foreground="#555" FontSize="12"
|
||||||
|
HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||||
|
Visibility="{Binding ShowSourcesEmptyHint, Converter={StaticResource BoolToVis}}"/>
|
||||||
|
|
||||||
|
<!-- Resource alert: pinned to the bottom of the Layers panel.
|
||||||
|
Red when a required resource (the one-and-only webcam)
|
||||||
|
could not be locked; persists and re-polls until resolved. -->
|
||||||
|
<Border Grid.Row="3" Margin="0,8,0,0" Padding="8,6" CornerRadius="4"
|
||||||
|
Background="{StaticResource Brush.ErrorContainer}" BorderBrush="{StaticResource Brush.Error}" BorderThickness="1"
|
||||||
|
Visibility="{Binding WebcamLockAlert, Converter={StaticResource NotNullToVis}}">
|
||||||
|
<StackPanel>
|
||||||
|
<TextBlock Text="{Binding WebcamLockAlert}" Foreground="{StaticResource Brush.OnError}" FontSize="11"
|
||||||
|
TextWrapping="Wrap"/>
|
||||||
|
<Button Content="Retry" Command="{Binding RetryWebcamLockCommand}"
|
||||||
|
Style="{StaticResource YtButtonSecondary}" Padding="10,2"
|
||||||
|
HorizontalAlignment="Right" Margin="0,6,0,0"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- ELEMENTS panel: per-element config beneath Sources.
|
||||||
|
Collapses entirely when no element is selected. -->
|
||||||
|
<Rectangle Grid.Row="4" Height="1" Fill="{StaticResource Brush.Outline}" Margin="0,22,0,10"/>
|
||||||
|
<StackPanel Grid.Row="5" Orientation="Horizontal" Margin="0,8,0,0">
|
||||||
|
<Path Data="M12,8 A4,4 0 1 0 12.01,8 M19.4,15 L21,16.2 L19.4,19 L17.2,18.3 A7,7 0 0 1 15,19.4 L14.5,21.5 H9.5 L9,19.4 A7,7 0 0 1 6.8,18.3 L4.6,19 L3,16.2 L4.6,15 A7,7 0 0 1 4.6,9 L3,7.8 L4.6,5 L6.8,5.7 A7,7 0 0 1 9,4.6 L9.5,2.5 H14.5 L15,4.6 A7,7 0 0 1 17.2,5.7 L19.4,5 L21,7.8 L19.4,9 A7,7 0 0 1 19.4,15 Z"
|
||||||
|
Stroke="{StaticResource Brush.Tertiary}" StrokeThickness="1.6" StrokeLineJoin="Round"
|
||||||
|
Width="16" Height="16" Stretch="Uniform" VerticalAlignment="Center" Margin="0,0,6,0"/>
|
||||||
|
<TextBlock Text="PROPERTIES" Style="{StaticResource SectionHeader}" VerticalAlignment="Center"/>
|
||||||
|
</StackPanel>
|
||||||
|
<TextBlock Grid.Row="6" Text="Select an item to edit its properties" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="12"
|
||||||
|
Margin="8,4,0,0" TextWrapping="Wrap"
|
||||||
|
Visibility="{Binding SelectedElement, Converter={StaticResource NullToVis}}"/>
|
||||||
|
<ScrollViewer x:Name="ElementsScrollViewer" Grid.Row="6" VerticalScrollBarVisibility="Auto" Background="Transparent"
|
||||||
|
Visibility="{Binding SelectedElement, Converter={StaticResource NotNullToVis}}">
|
||||||
|
<StackPanel x:Name="ElementsPanel" Margin="8,4,0,0">
|
||||||
|
<!-- Opacity + mirror/shape (draggable elements: image, webcam) -->
|
||||||
|
<StackPanel Visibility="{Binding SelectedElement.IsDraggable, Converter={StaticResource BoolToVis}}" Margin="0,0,0,8">
|
||||||
|
<StackPanel Visibility="{Binding SelectedElement.IsChatBox, Converter={StaticResource InverseBoolToVis}}">
|
||||||
|
<StackPanel Visibility="{Binding SelectedElement.IsBlur, Converter={StaticResource InverseBoolToVis}}">
|
||||||
|
<TextBlock Text="Opacity" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" Margin="0,0,0,2"/>
|
||||||
|
<DockPanel Margin="0,0,0,6">
|
||||||
|
<TextBlock x:Name="OpacityValueText" Text="100%" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11"
|
||||||
|
DockPanel.Dock="Right" VerticalAlignment="Center" Margin="8,0,0,0" Width="32"/>
|
||||||
|
<Slider x:Name="OpacitySlider" Width="120" Minimum="0" Maximum="1"
|
||||||
|
Value="{Binding SelectedElement.Opacity, Mode=TwoWay}"
|
||||||
|
ValueChanged="OpacitySlider_ValueChanged" VerticalAlignment="Center"/>
|
||||||
|
</DockPanel>
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
<!-- TASK 59: the shape is a CHOICE (Rect | Circle) under a
|
||||||
|
"Shape" title, with Rect selected by default — the circle
|
||||||
|
is not offered in the vertical Chat layout, so that option
|
||||||
|
is not even shown there (CanToggleClipShape). -->
|
||||||
|
<StackPanel Visibility="{Binding IsWebcamSelected, Converter={StaticResource BoolToVis}}">
|
||||||
|
<TextBlock Text="Shape" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" FontWeight="SemiBold" Margin="0,0,0,4"/>
|
||||||
|
<StackPanel Orientation="Horizontal" Margin="10,0,0,4">
|
||||||
|
<Button Content="{Binding SelectedElement.MirrorButtonText}"
|
||||||
|
Style="{StaticResource YtButtonSecondary}" Padding="10,4"
|
||||||
|
Click="MirrorButton_Click"/>
|
||||||
|
<ToggleButton Style="{StaticResource SegmentToggle}" Padding="10,4" Margin="8,0,0,0"
|
||||||
|
IsChecked="{Binding SelectedElement.IsRectShape, Mode=TwoWay}">
|
||||||
|
<TextBlock Text="Rect" Style="{StaticResource SegmentLabel}"/>
|
||||||
|
</ToggleButton>
|
||||||
|
<ToggleButton Style="{StaticResource SegmentToggle}" Padding="10,4" Margin="4,0,0,0"
|
||||||
|
IsChecked="{Binding SelectedElement.IsRoundShape, Mode=TwoWay}"
|
||||||
|
Visibility="{Binding CanToggleClipShape, Converter={StaticResource BoolToVis}}">
|
||||||
|
<TextBlock Text="Circle" Style="{StaticResource SegmentLabel}"/>
|
||||||
|
</ToggleButton>
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
<!-- Privacy blur (creator ruling 2026-11-01): a shape choice and one
|
||||||
|
strength slider, on the SAME 0–1 scale and default as the
|
||||||
|
webcam's background blur — one radius curve, shared. -->
|
||||||
|
<StackPanel Visibility="{Binding IsBlurSelected, Converter={StaticResource BoolToVis}}" Margin="0,0,0,8">
|
||||||
|
<TextBlock Text="Blur" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" FontWeight="SemiBold" Margin="0,0,0,4"/>
|
||||||
|
<StackPanel Orientation="Horizontal" Margin="10,0,0,6">
|
||||||
|
<ToggleButton Style="{StaticResource SegmentToggle}" Padding="10,4"
|
||||||
|
IsChecked="{Binding SelectedElement.IsRectShape, Mode=TwoWay}">
|
||||||
|
<TextBlock Text="Box" Style="{StaticResource SegmentLabel}"/>
|
||||||
|
</ToggleButton>
|
||||||
|
<ToggleButton Style="{StaticResource SegmentToggle}" Padding="10,4" Margin="4,0,0,0"
|
||||||
|
IsChecked="{Binding SelectedElement.IsRoundShape, Mode=TwoWay}">
|
||||||
|
<TextBlock Text="Circle" Style="{StaticResource SegmentLabel}"/>
|
||||||
|
</ToggleButton>
|
||||||
|
</StackPanel>
|
||||||
|
<DockPanel Margin="10,0,0,4">
|
||||||
|
<TextBlock DockPanel.Dock="Right" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" VerticalAlignment="Center" Margin="8,0,0,0" Width="34">
|
||||||
|
<Run Text="{Binding SelectedElement.BlurAmount, Mode=OneWay, StringFormat={}{0:P0}}"/>
|
||||||
|
</TextBlock>
|
||||||
|
<Slider Minimum="0" Maximum="1" SmallChange="0.05" LargeChange="0.1"
|
||||||
|
Value="{Binding SelectedElement.BlurAmount, Mode=TwoWay}"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
</DockPanel>
|
||||||
|
<TextBlock Text="Strongest blur wins where they overlap — layers cannot add up."
|
||||||
|
Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="10" TextWrapping="Wrap" Margin="10,2,0,0"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- The drag hint sits under the shape row — indented, so the
|
||||||
|
parentage shows. -->
|
||||||
|
<TextBlock Text="Drag to move · drag the corner dot to resize"
|
||||||
|
Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="10" Margin="10,2,0,0"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- Webcam geometry (TASK 57): under the vertical tier the camera
|
||||||
|
keeps its OWN placement, so these fields edit the LIVE one —
|
||||||
|
the placement the current tier shows. Gated on the VM's
|
||||||
|
IsWebcamSelected (the same condition as the mirror/shape row
|
||||||
|
above) so the webcam's border/background section stays the
|
||||||
|
one panel bound to SelectedElement.IsWebcam. -->
|
||||||
|
<StackPanel Visibility="{Binding SelectedElement.IsDraggable, Converter={StaticResource BoolToVis}}" Margin="0,0,0,8">
|
||||||
|
<TextBlock Text="Geometry" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" FontWeight="SemiBold" Margin="0,8,0,4"/>
|
||||||
|
<!-- TASK 61: the four fields share ONE fixed width — wide enough
|
||||||
|
for the largest geometry value (1920, plus a typed sign) and
|
||||||
|
no wider, so Y/H no longer stretch to fill the row. -->
|
||||||
|
<DockPanel Margin="10,0,0,4" LastChildFill="False">
|
||||||
|
<TextBlock DockPanel.Dock="Left" Text="X" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" VerticalAlignment="Center" Width="16"/>
|
||||||
|
<TextBox Text="{Binding SelectedElement.X, UpdateSourceTrigger=PropertyChanged, StringFormat=F0}"
|
||||||
|
Style="{StaticResource YtTextBoxCompact}" Width="44"/>
|
||||||
|
<TextBlock DockPanel.Dock="Left" Text=" Y" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" VerticalAlignment="Center" Width="20" Margin="8,0,0,0"/>
|
||||||
|
<TextBox Text="{Binding SelectedElement.Y, UpdateSourceTrigger=PropertyChanged, StringFormat=F0}"
|
||||||
|
Style="{StaticResource YtTextBoxCompact}" Width="44"/>
|
||||||
|
</DockPanel>
|
||||||
|
<DockPanel Margin="10,0,0,4" LastChildFill="False">
|
||||||
|
<TextBlock DockPanel.Dock="Left" Text="W" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" VerticalAlignment="Center" Width="16"/>
|
||||||
|
<TextBox Text="{Binding SelectedElement.Width, UpdateSourceTrigger=PropertyChanged, StringFormat=F0}"
|
||||||
|
Style="{StaticResource YtTextBoxCompact}" Width="44"/>
|
||||||
|
<TextBlock DockPanel.Dock="Left" Text=" H" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" VerticalAlignment="Center" Width="20" Margin="8,0,0,0"/>
|
||||||
|
<TextBox Text="{Binding SelectedElement.Height, UpdateSourceTrigger=PropertyChanged, StringFormat=F0}"
|
||||||
|
Style="{StaticResource YtTextBoxCompact}" Width="44"/>
|
||||||
|
</DockPanel>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- Webcam border config -->
|
||||||
|
<StackPanel Visibility="{Binding SelectedElement.IsWebcam, Converter={StaticResource BoolToVis}}" Margin="0,0,0,8">
|
||||||
|
<TextBlock Text="Border Color" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" FontWeight="SemiBold" Margin="0,8,0,2"/>
|
||||||
|
<!-- TASK 63: the border colour is picked with the app's OWN
|
||||||
|
drop-down (YtComboBox) — the same widget the Display
|
||||||
|
and Font pickers use — not a hand-rolled menu. The hex
|
||||||
|
field stays for a colour the creator types; the
|
||||||
|
drop-down carries the colour's name. -->
|
||||||
|
<DockPanel Margin="10,0,0,6">
|
||||||
|
<TextBox DockPanel.Dock="Left"
|
||||||
|
Text="{Binding SelectedElement.BorderColor, UpdateSourceTrigger=PropertyChanged}"
|
||||||
|
Style="{StaticResource YtTextBoxCompact}"
|
||||||
|
MaxLength="7" ToolTip="#RRGGBB" Width="60"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
<ComboBox ItemsSource="{Binding SelectedElement.BorderColorOptions}"
|
||||||
|
SelectedItem="{Binding SelectedElement.BorderColorEntry, Mode=TwoWay}"
|
||||||
|
ItemTemplateSelector="{StaticResource BorderColorTemplates}"
|
||||||
|
Style="{StaticResource YtComboBox}"
|
||||||
|
ToolTip="Border colour" MinWidth="100"
|
||||||
|
Margin="8,0,0,0" VerticalAlignment="Center"/>
|
||||||
|
</DockPanel>
|
||||||
|
<TextBlock Text="Border Thickness" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" FontWeight="SemiBold" Margin="0,8,0,2"/>
|
||||||
|
<DockPanel Margin="10,0,0,6">
|
||||||
|
<Button DockPanel.Dock="Right" Style="{StaticResource IconButton}"
|
||||||
|
Width="22" Height="22" Margin="4,0,0,0"
|
||||||
|
Click="WebcamBorderAccept_Click">
|
||||||
|
<Path Data="M4,9 L8,14 L16,4" Stroke="{StaticResource Brush.Positive}" StrokeThickness="2"
|
||||||
|
Width="14" Height="14" Stretch="Uniform"/>
|
||||||
|
</Button>
|
||||||
|
<Button DockPanel.Dock="Right" Style="{StaticResource IconButton}"
|
||||||
|
Width="22" Height="22" ToolTip="Revert"
|
||||||
|
Click="WebcamBorderRevert_Click" Margin="4,0,0,0">
|
||||||
|
<Path Data="M5,5 L15,15 M15,5 L5,15" Stroke="{StaticResource Brush.OnSurfaceVariant}" StrokeThickness="2"
|
||||||
|
Width="14" Height="14" Stretch="Uniform"/>
|
||||||
|
</Button>
|
||||||
|
<Slider DockPanel.Dock="Left" Width="120" Minimum="0" Maximum="10"
|
||||||
|
Value="{Binding SelectedElement.BorderWidth, Mode=TwoWay}"
|
||||||
|
VerticalAlignment="Center" TickFrequency="1" IsSnapToTickEnabled="True"/>
|
||||||
|
<TextBlock Text="{Binding SelectedElement.BorderWidth}" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11"
|
||||||
|
VerticalAlignment="Center" Margin="8,0,0,0" Width="20"/>
|
||||||
|
</DockPanel>
|
||||||
|
|
||||||
|
<!-- TASK 51: background effects — two pills acting as radio buttons
|
||||||
|
(one on turns the other off; both off = no effect). TASK 59:
|
||||||
|
the pills are indented under the Background title, and the
|
||||||
|
strength row one level deeper, under Blur. -->
|
||||||
|
<TextBlock Text="Background" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" FontWeight="SemiBold" Margin="0,8,0,6"/>
|
||||||
|
<DockPanel Margin="10,0,0,4">
|
||||||
|
<TextBlock DockPanel.Dock="Left" Text="Remove" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" VerticalAlignment="Center" Width="84"/>
|
||||||
|
<ToggleButton x:Name="RemoveBackgroundPill" Style="{StaticResource PillToggle}"
|
||||||
|
IsChecked="{Binding SelectedElement.RemoveBackground, Mode=TwoWay}"
|
||||||
|
AutomationProperties.Name="Remove background"
|
||||||
|
ToolTip="Remove the background behind you — the layers below show through"
|
||||||
|
HorizontalAlignment="Left"/>
|
||||||
|
</DockPanel>
|
||||||
|
<DockPanel Margin="10,0,0,4">
|
||||||
|
<TextBlock DockPanel.Dock="Left" Text="Blur" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" VerticalAlignment="Center" Width="84"/>
|
||||||
|
<ToggleButton x:Name="BlurBackgroundPill" Style="{StaticResource PillToggle}"
|
||||||
|
IsChecked="{Binding SelectedElement.BlurBackground, Mode=TwoWay}"
|
||||||
|
AutomationProperties.Name="Blur background"
|
||||||
|
ToolTip="Blur the background behind you"
|
||||||
|
HorizontalAlignment="Left"/>
|
||||||
|
</DockPanel>
|
||||||
|
<DockPanel Margin="20,0,0,6"
|
||||||
|
Visibility="{Binding SelectedElement.HasBackgroundEffect, Converter={StaticResource BoolToVis}}">
|
||||||
|
<TextBlock DockPanel.Dock="Left" Text="Strength" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" VerticalAlignment="Center" Width="84"/>
|
||||||
|
<TextBlock DockPanel.Dock="Right" Text="{Binding SelectedElement.BackgroundStrength, StringFormat={}{0:P0}}"
|
||||||
|
Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" VerticalAlignment="Center" Margin="8,0,0,0" Width="34"/>
|
||||||
|
<Slider x:Name="BackgroundStrengthSlider" Minimum="0" Maximum="1"
|
||||||
|
Value="{Binding SelectedElement.BackgroundStrength, Mode=TwoWay}"
|
||||||
|
TickFrequency="0.05" IsSnapToTickEnabled="True" VerticalAlignment="Center"
|
||||||
|
AutomationProperties.Name="Background effect strength"
|
||||||
|
ToolTip="Blur: how strong the blur is · Remove: how soft the edge is"/>
|
||||||
|
</DockPanel>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- Scoreboard config (creator 2026-10-30): the ego wall's billboard —
|
||||||
|
which eight facts show, and how the strip is painted. -->
|
||||||
|
<!-- The scoreboard is fed by the signed-in channel, so its controls are
|
||||||
|
inert until there is an account; signing in re-enables them through
|
||||||
|
the ordinary IsConnected notification. -->
|
||||||
|
<StackPanel Visibility="{Binding SelectedElement.IsScoreboard, Converter={StaticResource BoolToVis}}"
|
||||||
|
IsEnabled="{Binding CanUseScoreboard}" Margin="0,0,0,8">
|
||||||
|
<TextBlock Text="Sign in to YouTube to use the scoreboard."
|
||||||
|
Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" Margin="0,0,0,4"
|
||||||
|
Visibility="{Binding CanUseScoreboard, Converter={StaticResource InverseBoolToVis}}"/>
|
||||||
|
<!-- DEBUG builds only: prove the strip in the preview before a real
|
||||||
|
stream. Absent from Release, not merely hidden by a flag. -->
|
||||||
|
<CheckBox Style="{StaticResource YtCheckBox}" Margin="0,0,0,4"
|
||||||
|
IsChecked="{Binding ScoreboardSimulate, Mode=TwoWay}"
|
||||||
|
ToolTip="Push sample events so the strip appears in the preview."
|
||||||
|
Visibility="{Binding IsDevelopmentBuild, Converter={StaticResource BoolToVis}}">
|
||||||
|
<!-- Dev-only affordances wear orange so a development control can never
|
||||||
|
be mistaken for product. -->
|
||||||
|
<TextBlock Text="Simulate (dev)" Foreground="{StaticResource Brush.Attention}" FontSize="12"/>
|
||||||
|
</CheckBox>
|
||||||
|
<CheckBox Style="{StaticResource YtCheckBox}" Margin="0,0,0,4"
|
||||||
|
Content="Show the scoreboard" IsChecked="{Binding ScoreboardEnabled, Mode=TwoWay}"
|
||||||
|
ToolTip="Off hides the strip in every scene AND stops the channel polling."/>
|
||||||
|
<TextBlock Text="Edge (socials take the other)" Foreground="{StaticResource Brush.OnSurfaceVariant}"
|
||||||
|
FontSize="11" Margin="10,0,0,2"/>
|
||||||
|
<ComboBox Style="{StaticResource YtComboBox}" Height="24" FontSize="11" Margin="10,0,0,6"
|
||||||
|
ItemsSource="{Binding ScoreboardEdgeOptions}"
|
||||||
|
SelectedItem="{Binding ScoreboardEdge, Mode=TwoWay}"/>
|
||||||
|
|
||||||
|
<DockPanel Margin="10,0,0,6">
|
||||||
|
<TextBlock DockPanel.Dock="Left" Text="Scroll speed" Foreground="{StaticResource Brush.OnSurfaceVariant}"
|
||||||
|
FontSize="11" Width="76" VerticalAlignment="Center"/>
|
||||||
|
<TextBlock DockPanel.Dock="Right" Width="56" TextAlignment="Right" FontSize="11"
|
||||||
|
Foreground="{StaticResource Brush.OnSurfaceVariant}" VerticalAlignment="Center"
|
||||||
|
Text="{Binding ScoreboardScrollSpeed, StringFormat={}{0:0} px/s}"/>
|
||||||
|
<Slider Minimum="20" Maximum="200" SmallChange="10" LargeChange="40"
|
||||||
|
Value="{Binding ScoreboardScrollSpeed, Mode=TwoWay}" VerticalAlignment="Center"/>
|
||||||
|
</DockPanel>
|
||||||
|
|
||||||
|
<TextBlock Text="Facts" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" FontWeight="SemiBold" Margin="0,0,0,4"/>
|
||||||
|
<WrapPanel Margin="10,0,0,0">
|
||||||
|
<CheckBox Style="{StaticResource YtCheckBox}" Content="Subscribers" Margin="0,0,10,4"
|
||||||
|
IsChecked="{Binding ScoreboardShowSubscribers, Mode=TwoWay}"/>
|
||||||
|
<CheckBox Style="{StaticResource YtCheckBox}" Content="Watch hours" Margin="0,0,10,4"
|
||||||
|
IsChecked="{Binding ScoreboardShowWatchHours, Mode=TwoWay}"/>
|
||||||
|
<CheckBox Style="{StaticResource YtCheckBox}" Content="Super Chats" Margin="0,0,10,4"
|
||||||
|
IsChecked="{Binding ScoreboardShowSuperChats, Mode=TwoWay}"/>
|
||||||
|
<CheckBox Style="{StaticResource YtCheckBox}" Content="New members" Margin="0,0,10,4"
|
||||||
|
IsChecked="{Binding ScoreboardShowNewMembers, Mode=TwoWay}"/>
|
||||||
|
<CheckBox Style="{StaticResource YtCheckBox}" Content="Milestones" Margin="0,0,10,4"
|
||||||
|
IsChecked="{Binding ScoreboardShowMilestones, Mode=TwoWay}"/>
|
||||||
|
<CheckBox Style="{StaticResource YtCheckBox}" Content="Last member" Margin="0,0,10,4"
|
||||||
|
IsChecked="{Binding ScoreboardShowLastMember, Mode=TwoWay}"/>
|
||||||
|
<CheckBox Style="{StaticResource YtCheckBox}" Content="Last Super Chat" Margin="0,0,10,4"
|
||||||
|
IsChecked="{Binding ScoreboardShowLastSuperChat, Mode=TwoWay}"/>
|
||||||
|
<CheckBox Style="{StaticResource YtCheckBox}" Content="Last gift" Margin="0,0,10,4"
|
||||||
|
IsChecked="{Binding ScoreboardShowLastGift, Mode=TwoWay}"/>
|
||||||
|
</WrapPanel>
|
||||||
|
|
||||||
|
<TextBlock Text="Text colour" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" Margin="10,6,0,2"/>
|
||||||
|
<ComboBox Style="{StaticResource YtComboBox}" Height="24" FontSize="11" Margin="10,0,0,6"
|
||||||
|
ItemsSource="{Binding ScoreboardColorOptions}" DisplayMemberPath="Name"
|
||||||
|
SelectedValuePath="Hex" SelectedValue="{Binding ScoreboardTextColor, Mode=TwoWay}"/>
|
||||||
|
|
||||||
|
<CheckBox Style="{StaticResource YtCheckBox}" Content="Background" Margin="10,0,0,4"
|
||||||
|
IsChecked="{Binding ScoreboardBackgroundEnabled, Mode=TwoWay}"/>
|
||||||
|
<ComboBox Style="{StaticResource YtComboBox}" Height="24" FontSize="11" Margin="10,0,0,6"
|
||||||
|
ItemsSource="{Binding ScoreboardColorOptions}" DisplayMemberPath="Name"
|
||||||
|
SelectedValuePath="Hex" SelectedValue="{Binding ScoreboardBackgroundColor, Mode=TwoWay}"/>
|
||||||
|
<DockPanel Margin="10,0,0,6">
|
||||||
|
<TextBlock DockPanel.Dock="Left" Text="Opacity" Foreground="{StaticResource Brush.OnSurfaceVariant}"
|
||||||
|
FontSize="11" Width="60" VerticalAlignment="Center"/>
|
||||||
|
<TextBlock DockPanel.Dock="Right" Width="42" TextAlignment="Right" FontSize="11"
|
||||||
|
Foreground="{StaticResource Brush.OnSurfaceVariant}" VerticalAlignment="Center"
|
||||||
|
Text="{Binding ScoreboardBackgroundOpacity, StringFormat={}{0:P0}}"/>
|
||||||
|
<Slider Minimum="0" Maximum="1" SmallChange="0.05" LargeChange="0.1"
|
||||||
|
Value="{Binding ScoreboardBackgroundOpacity, Mode=TwoWay}" VerticalAlignment="Center"/>
|
||||||
|
</DockPanel>
|
||||||
|
|
||||||
|
<DockPanel Margin="10,0,0,0">
|
||||||
|
<TextBlock DockPanel.Dock="Left" Text="Text size" Foreground="{StaticResource Brush.OnSurfaceVariant}"
|
||||||
|
FontSize="11" Width="60" VerticalAlignment="Center"/>
|
||||||
|
<TextBlock DockPanel.Dock="Right" Width="56" TextAlignment="Right" FontSize="11"
|
||||||
|
Foreground="{StaticResource Brush.OnSurfaceVariant}" VerticalAlignment="Center"
|
||||||
|
Text="{Binding ScoreboardFontSizeLabel}"/>
|
||||||
|
<Slider Minimum="14" Maximum="24" SmallChange="1" LargeChange="2" TickFrequency="1" IsSnapToTickEnabled="True"
|
||||||
|
Value="{Binding ScoreboardFontSize, Mode=TwoWay}" VerticalAlignment="Center"/>
|
||||||
|
</DockPanel>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- Countdown config -->
|
||||||
|
<StackPanel Visibility="{Binding SelectedElement.IsCountdown, Converter={StaticResource BoolToVis}}" Margin="0,0,0,8">
|
||||||
|
<TextBlock Text="Timer (minutes)" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" Margin="0,0,0,2"/>
|
||||||
|
<DockPanel Margin="0,0,0,6">
|
||||||
|
<Button DockPanel.Dock="Right" Style="{StaticResource IconButton}"
|
||||||
|
Width="22" Height="22" Margin="4,0,0,0"
|
||||||
|
Click="CountdownAccept_Click">
|
||||||
|
<Path Data="M4,9 L8,14 L16,4" Stroke="{StaticResource Brush.Positive}" StrokeThickness="2"
|
||||||
|
Width="14" Height="14" Stretch="Uniform"/>
|
||||||
|
</Button>
|
||||||
|
<Button DockPanel.Dock="Right" Style="{StaticResource IconButton}"
|
||||||
|
Width="22" Height="22" ToolTip="Revert"
|
||||||
|
Click="CountdownRevert_Click" Margin="4,0,0,0">
|
||||||
|
<Path Data="M5,5 L15,15 M15,5 L5,15" Stroke="{StaticResource Brush.OnSurfaceVariant}" StrokeThickness="2"
|
||||||
|
Width="14" Height="14" Stretch="Uniform"/>
|
||||||
|
</Button>
|
||||||
|
<Slider DockPanel.Dock="Left" Width="120" Minimum="1" Maximum="60"
|
||||||
|
Value="{Binding SelectedElement.TimerMinutes, Mode=TwoWay}"
|
||||||
|
VerticalAlignment="Center" TickFrequency="1" IsSnapToTickEnabled="True"/>
|
||||||
|
<TextBlock Text="{Binding SelectedElement.TimerMinutes}" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11"
|
||||||
|
VerticalAlignment="Center" Margin="8,0,0,0" Width="20"/>
|
||||||
|
</DockPanel>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- Web source config -->
|
||||||
|
<StackPanel Visibility="{Binding SelectedElement.IsWebSource, Converter={StaticResource BoolToVis}}" Margin="0,0,0,8">
|
||||||
|
<TextBlock Text="Geometry" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" FontWeight="SemiBold" Margin="0,0,0,4"/>
|
||||||
|
<DockPanel Margin="0,0,0,4">
|
||||||
|
<TextBlock DockPanel.Dock="Left" Text="X" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" VerticalAlignment="Center" Width="16"/>
|
||||||
|
<TextBox Text="{Binding SelectedElement.X, UpdateSourceTrigger=PropertyChanged, StringFormat=F0}"
|
||||||
|
Style="{StaticResource YtTextBoxCompact}"/>
|
||||||
|
<TextBlock DockPanel.Dock="Left" Text=" Y" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" VerticalAlignment="Center" Width="20" Margin="8,0,0,0"/>
|
||||||
|
<TextBox Text="{Binding SelectedElement.Y, UpdateSourceTrigger=PropertyChanged, StringFormat=F0}"
|
||||||
|
Style="{StaticResource YtTextBoxCompact}"/>
|
||||||
|
</DockPanel>
|
||||||
|
<DockPanel Margin="0,0,0,4">
|
||||||
|
<TextBlock DockPanel.Dock="Left" Text="W" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" VerticalAlignment="Center" Width="16"/>
|
||||||
|
<TextBox Text="{Binding SelectedElement.Width, UpdateSourceTrigger=PropertyChanged, StringFormat=F0}"
|
||||||
|
Style="{StaticResource YtTextBoxCompact}"/>
|
||||||
|
<TextBlock DockPanel.Dock="Left" Text=" H" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" VerticalAlignment="Center" Width="20" Margin="8,0,0,0"/>
|
||||||
|
<TextBox Text="{Binding SelectedElement.Height, UpdateSourceTrigger=PropertyChanged, StringFormat=F0}"
|
||||||
|
Style="{StaticResource YtTextBoxCompact}"/>
|
||||||
|
</DockPanel>
|
||||||
|
<TextBlock Text="URI" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" Margin="0,0,0,2"/>
|
||||||
|
<DockPanel>
|
||||||
|
<Button DockPanel.Dock="Right" Style="{StaticResource IconButton}"
|
||||||
|
Width="22" Height="22" Margin="4,0,0,0" IsTabStop="False"
|
||||||
|
Click="WebAccept_Click">
|
||||||
|
<Path Data="M4,9 L8,14 L16,4" Stroke="{StaticResource Brush.Positive}" StrokeThickness="2"
|
||||||
|
Width="14" Height="14" Stretch="Uniform"/>
|
||||||
|
</Button>
|
||||||
|
<Button DockPanel.Dock="Right" Style="{StaticResource IconButton}"
|
||||||
|
Width="22" Height="22" ToolTip="Revert" IsTabStop="False"
|
||||||
|
Click="WebRevert_Click" Margin="4,0,0,0">
|
||||||
|
<Path Data="M5,5 L15,15 M15,5 L5,15" Stroke="{StaticResource Brush.OnSurfaceVariant}" StrokeThickness="2"
|
||||||
|
Width="14" Height="14" Stretch="Uniform"/>
|
||||||
|
</Button>
|
||||||
|
<TextBox Text="{Binding SelectedElement.WebUri, UpdateSourceTrigger=PropertyChanged}"
|
||||||
|
Style="{StaticResource YtTextBoxCompact}"
|
||||||
|
ToolTip="https://example.com"/>
|
||||||
|
</DockPanel>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- Chat box config -->
|
||||||
|
<StackPanel Visibility="{Binding SelectedElement.IsChatBox, Converter={StaticResource BoolToVis}}" Margin="0,0,0,8">
|
||||||
|
<TextBlock Text="Chat Overlay" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" FontWeight="SemiBold" Margin="0,0,0,6"/>
|
||||||
|
|
||||||
|
<!-- Border (same controls as the webcam's border) -->
|
||||||
|
<TextBlock Text="Border Color" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" FontWeight="SemiBold" Margin="0,0,0,2"/>
|
||||||
|
<DockPanel Margin="10,0,0,6">
|
||||||
|
<TextBox DockPanel.Dock="Left"
|
||||||
|
Text="{Binding SelectedElement.BorderColor, UpdateSourceTrigger=PropertyChanged}"
|
||||||
|
Style="{StaticResource YtTextBoxCompact}"
|
||||||
|
MaxLength="7" ToolTip="#RRGGBB" Width="60"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
<ComboBox ItemsSource="{Binding SelectedElement.BorderColorOptions}"
|
||||||
|
SelectedItem="{Binding SelectedElement.BorderColorEntry, Mode=TwoWay}"
|
||||||
|
ItemTemplateSelector="{StaticResource BorderColorTemplates}"
|
||||||
|
Style="{StaticResource YtComboBox}"
|
||||||
|
ToolTip="Border colour" MinWidth="100"
|
||||||
|
Margin="8,0,0,0" VerticalAlignment="Center"/>
|
||||||
|
</DockPanel>
|
||||||
|
<TextBlock Text="Border Thickness" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" FontWeight="SemiBold" Margin="0,0,0,2"/>
|
||||||
|
<DockPanel Margin="10,0,0,6">
|
||||||
|
<Slider DockPanel.Dock="Left" Width="120" Minimum="0" Maximum="10"
|
||||||
|
Value="{Binding SelectedElement.BorderWidth, Mode=TwoWay}"
|
||||||
|
VerticalAlignment="Center" TickFrequency="1" IsSnapToTickEnabled="True"/>
|
||||||
|
<TextBlock Text="{Binding SelectedElement.BorderWidth}" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11"
|
||||||
|
VerticalAlignment="Center" Margin="8,0,0,0" Width="20"/>
|
||||||
|
</DockPanel>
|
||||||
|
<TextBlock Text="Border Opacity" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" FontWeight="SemiBold" Margin="0,0,0,2"/>
|
||||||
|
<DockPanel Margin="10,0,0,8">
|
||||||
|
<Slider DockPanel.Dock="Left" Width="120" Minimum="0" Maximum="1"
|
||||||
|
Value="{Binding SelectedElement.BorderOpacity, Mode=TwoWay}"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
<TextBlock Text="{Binding SelectedElement.BorderOpacity, StringFormat={}{0:P0}}" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11"
|
||||||
|
VerticalAlignment="Center" Margin="8,0,0,0" Width="34"/>
|
||||||
|
</DockPanel>
|
||||||
|
|
||||||
|
<!-- Font Size -->
|
||||||
|
<DockPanel Margin="0,0,0,4">
|
||||||
|
<TextBlock DockPanel.Dock="Left" Text="Font Size" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" VerticalAlignment="Center" Width="70"/>
|
||||||
|
<Slider x:Name="ChatFontSizeSlider" Width="120" Minimum="8" Maximum="72" TickFrequency="2" IsSnapToTickEnabled="True"
|
||||||
|
Value="{Binding SelectedElement.ChatFontSize, Mode=TwoWay}"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
<TextBlock DockPanel.Dock="Right" Text="{Binding SelectedElement.ChatFontSize, StringFormat={}{0:0}}" Foreground="{StaticResource Brush.OnSurface}" FontSize="11" Margin="6,0,0,0" VerticalAlignment="Center" Width="24"/>
|
||||||
|
</DockPanel>
|
||||||
|
|
||||||
|
<!-- Font Family -->
|
||||||
|
<DockPanel Margin="0,0,0,4">
|
||||||
|
<TextBlock DockPanel.Dock="Left" Text="Font" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" VerticalAlignment="Center" Width="70"/>
|
||||||
|
<ComboBox SelectedValue="{Binding SelectedElement.ChatFontFamily, Mode=TwoWay}"
|
||||||
|
SelectedValuePath="Content"
|
||||||
|
Style="{StaticResource YtComboBox}"
|
||||||
|
Width="140" VerticalAlignment="Center">
|
||||||
|
<ComboBoxItem Content="Segoe UI"/>
|
||||||
|
<ComboBoxItem Content="Arial"/>
|
||||||
|
<ComboBoxItem Content="Consolas"/>
|
||||||
|
<ComboBoxItem Content="Courier New"/>
|
||||||
|
<ComboBoxItem Content="Comic Sans MS"/>
|
||||||
|
<ComboBoxItem Content="Mouse Memoirs"/>
|
||||||
|
</ComboBox>
|
||||||
|
</DockPanel>
|
||||||
|
|
||||||
|
<!-- Font Color -->
|
||||||
|
<DockPanel Margin="0,0,0,4">
|
||||||
|
<TextBlock DockPanel.Dock="Left" Text="Font Color" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" VerticalAlignment="Center" Width="70"/>
|
||||||
|
<TextBox Text="{Binding SelectedElement.ChatFontColor, UpdateSourceTrigger=PropertyChanged}"
|
||||||
|
Style="{StaticResource YtTextBoxCompact}" Width="80"/>
|
||||||
|
</DockPanel>
|
||||||
|
|
||||||
|
<!-- Member Color -->
|
||||||
|
<DockPanel Margin="0,0,0,4">
|
||||||
|
<TextBlock DockPanel.Dock="Left" Text="Member" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" VerticalAlignment="Center" Width="70"/>
|
||||||
|
<TextBox Text="{Binding SelectedElement.ChatMemberColor, UpdateSourceTrigger=PropertyChanged}"
|
||||||
|
Style="{StaticResource YtTextBoxCompact}" Width="80"/>
|
||||||
|
</DockPanel>
|
||||||
|
|
||||||
|
<!-- Super Chat Color -->
|
||||||
|
<DockPanel Margin="0,0,0,4">
|
||||||
|
<TextBlock DockPanel.Dock="Left" Text="Super Chat" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" VerticalAlignment="Center" Width="70"/>
|
||||||
|
<TextBox Text="{Binding SelectedElement.ChatSuperChatColor, UpdateSourceTrigger=PropertyChanged}"
|
||||||
|
Style="{StaticResource YtTextBoxCompact}" Width="80"/>
|
||||||
|
</DockPanel>
|
||||||
|
|
||||||
|
<!-- Max Messages -->
|
||||||
|
<DockPanel Margin="0,0,0,4">
|
||||||
|
<TextBlock DockPanel.Dock="Left" Text="Max Msgs" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" VerticalAlignment="Center" Width="70"/>
|
||||||
|
<Slider x:Name="ChatMaxMsgsSlider" Width="120" Minimum="1" Maximum="20" TickFrequency="1" IsSnapToTickEnabled="True"
|
||||||
|
Value="{Binding SelectedElement.ChatMaxMessages, Mode=TwoWay}"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
<TextBlock DockPanel.Dock="Right" Text="{Binding SelectedElement.ChatMaxMessages}" Foreground="{StaticResource Brush.OnSurface}" FontSize="11" Margin="6,0,0,0" VerticalAlignment="Center" Width="24"/>
|
||||||
|
</DockPanel>
|
||||||
|
|
||||||
|
<!-- Background Color -->
|
||||||
|
<DockPanel Margin="0,0,0,4">
|
||||||
|
<TextBlock DockPanel.Dock="Left" Text="Background" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" VerticalAlignment="Center" Width="70"/>
|
||||||
|
<TextBox Text="{Binding SelectedElement.ChatBackgroundColor, UpdateSourceTrigger=PropertyChanged}"
|
||||||
|
Style="{StaticResource YtTextBoxCompact}" Width="80"/>
|
||||||
|
</DockPanel>
|
||||||
|
|
||||||
|
<!-- Background Opacity -->
|
||||||
|
<DockPanel Margin="0,0,0,4">
|
||||||
|
<TextBlock DockPanel.Dock="Left" Text="Bg Opacity" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" VerticalAlignment="Center" Width="70"/>
|
||||||
|
<Slider x:Name="ChatBgOpacitySlider" Width="120" Minimum="0" Maximum="1"
|
||||||
|
Value="{Binding SelectedElement.ChatBackgroundOpacity, Mode=TwoWay}"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
<TextBlock DockPanel.Dock="Right" Text="{Binding SelectedElement.ChatBackgroundOpacity, StringFormat={}{0:0%}}" Foreground="{StaticResource Brush.OnSurface}" FontSize="11" Margin="6,0,0,0" VerticalAlignment="Center" Width="32"/>
|
||||||
|
</DockPanel>
|
||||||
|
|
||||||
|
<!-- Fade After (seconds) -->
|
||||||
|
<DockPanel Margin="0,0,0,4">
|
||||||
|
<TextBlock DockPanel.Dock="Left" Text="Fade After" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" VerticalAlignment="Center" Width="70"/>
|
||||||
|
<Slider x:Name="ChatFadeSlider" Width="120" Minimum="0" Maximum="60" TickFrequency="5" IsSnapToTickEnabled="True"
|
||||||
|
Value="{Binding SelectedElement.ChatFadeSeconds, Mode=TwoWay}"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
<TextBlock DockPanel.Dock="Right" Text="{Binding SelectedElement.ChatFadeSeconds, StringFormat='{}{0}s'}" Foreground="{StaticResource Brush.OnSurface}" FontSize="11" Margin="6,0,0,0" VerticalAlignment="Center" Width="28"/>
|
||||||
|
</DockPanel>
|
||||||
|
|
||||||
|
<!-- Toggles -->
|
||||||
|
<CheckBox Content="Show timestamps" Style="{StaticResource YtCheckBox}" Margin="0,4,0,2"
|
||||||
|
IsChecked="{Binding SelectedElement.ChatShowTimestamps, Mode=TwoWay}"/>
|
||||||
|
<CheckBox Content="Show badges" Style="{StaticResource YtCheckBox}" Margin="0,0,0,2"
|
||||||
|
IsChecked="{Binding SelectedElement.ChatShowBadges, Mode=TwoWay}"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- Stream Alerts config -->
|
||||||
|
<StackPanel Visibility="{Binding SelectedElement.IsAlertBox, Converter={StaticResource BoolToVis}}" Margin="0,0,0,8">
|
||||||
|
<TextBlock Text="Stream Alerts" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" FontWeight="SemiBold" Margin="0,0,0,6"/>
|
||||||
|
<TextBlock Text="The built-in clip plays on every alert (Super Chat, membership…) unless a custom video is picked."
|
||||||
|
Foreground="#888" FontSize="10" TextWrapping="Wrap" Margin="0,0,0,6"/>
|
||||||
|
|
||||||
|
<DockPanel Margin="0,0,0,4">
|
||||||
|
<!-- Test alert (creator ruling 2026-11-01): plays the clip this box
|
||||||
|
would play — custom if one is set, built-in otherwise. -->
|
||||||
|
<Button DockPanel.Dock="Right" Content="▶ Test alert"
|
||||||
|
Command="{Binding TestAlertCommand}"
|
||||||
|
Style="{StaticResource YtButtonSecondary}"
|
||||||
|
Padding="8,2" FontSize="11" VerticalAlignment="Center"
|
||||||
|
ToolTip="Play the alert here, exactly as a viewer would trigger it"/>
|
||||||
|
<TextBlock DockPanel.Dock="Left" Text="Built-in video" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" VerticalAlignment="Center" Width="84"/>
|
||||||
|
<ToggleButton Style="{StaticResource PillToggle}"
|
||||||
|
IsChecked="{Binding SelectedElement.AlertUseDefaultVideo, Mode=TwoWay}"
|
||||||
|
HorizontalAlignment="Left"/>
|
||||||
|
</DockPanel>
|
||||||
|
|
||||||
|
<!-- Sound byte (creator ruling 2026-11-01): plays WITH the alert clip, on
|
||||||
|
the default device, so it is audible and shows in the desktop meter.
|
||||||
|
The video itself always plays once to completion. -->
|
||||||
|
<TextBlock Text="Sound byte" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11"
|
||||||
|
FontWeight="SemiBold" Margin="0,6,0,2"/>
|
||||||
|
<DockPanel Margin="0,0,0,2">
|
||||||
|
<Button DockPanel.Dock="Right" Content="Browse…" Command="{Binding BrowseAlertAudioCommand}"
|
||||||
|
CommandParameter="{Binding SelectedElement}" Style="{StaticResource YtButtonSecondary}"
|
||||||
|
Padding="8,2" Margin="6,0,0,0" FontSize="11"/>
|
||||||
|
<Button DockPanel.Dock="Right" Content="✕" Command="{Binding ClearAlertAudioCommand}"
|
||||||
|
CommandParameter="{Binding SelectedElement}" Style="{StaticResource YtButtonSecondary}"
|
||||||
|
Padding="7,2" FontSize="11" ToolTip="Remove the sound byte"
|
||||||
|
Visibility="{Binding SelectedElement.HasAlertAudio, Converter={StaticResource BoolToVis}}"/>
|
||||||
|
<TextBox Text="{Binding SelectedElement.AlertAudioFileDisplay, Mode=OneWay}"
|
||||||
|
Style="{StaticResource YtTextBoxCompact}" IsReadOnly="True"
|
||||||
|
Foreground="{StaticResource Brush.OnSurfaceVariant}" VerticalAlignment="Center"/>
|
||||||
|
</DockPanel>
|
||||||
|
<CheckBox Content="Loop the sound until the video ends" Style="{StaticResource YtCheckBox}" Margin="0,2,0,6"
|
||||||
|
IsChecked="{Binding SelectedElement.AlertAudioLoop, Mode=TwoWay}"
|
||||||
|
IsEnabled="{Binding SelectedElement.HasAlertAudio}"/>
|
||||||
|
|
||||||
|
<Grid Margin="0,0,0,4"
|
||||||
|
Visibility="{Binding SelectedElement.AlertUseDefaultVideo, Converter={StaticResource InverseBoolToVis}}">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<TextBox Grid.Column="0" Text="{Binding SelectedElement.AlertVideoPath, Mode=OneWay}"
|
||||||
|
Style="{StaticResource YtTextBoxCompact}"
|
||||||
|
IsReadOnly="True" Foreground="{StaticResource Brush.OnSurfaceVariant}" VerticalAlignment="Center"/>
|
||||||
|
<Button Grid.Column="1" Content="Browse…" Command="{Binding BrowseAlertVideoCommand}"
|
||||||
|
CommandParameter="{Binding SelectedElement}" Style="{StaticResource YtButtonSecondary}"
|
||||||
|
Margin="4,0,0,0" Padding="8,2"/>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Button Content="Reset to built-in" Command="{Binding ResetAlertVideoCommand}"
|
||||||
|
CommandParameter="{Binding SelectedElement}" Style="{StaticResource YtButtonSecondary}"
|
||||||
|
HorizontalAlignment="Left" Padding="8,2" Margin="0,0,4,0"/>
|
||||||
|
<TextBlock Text="Custom clips are referenced from disk — never stored in the layout."
|
||||||
|
Foreground="#888" FontSize="10" TextWrapping="Wrap" Margin="0,4,0,6"/>
|
||||||
|
|
||||||
|
<DockPanel Margin="0,0,0,4">
|
||||||
|
<TextBlock DockPanel.Dock="Left" Text="Message ticker" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" VerticalAlignment="Center" Width="84"/>
|
||||||
|
<ToggleButton Style="{StaticResource PillToggle}"
|
||||||
|
IsChecked="{Binding SelectedElement.AlertShowTicker, Mode=TwoWay}"
|
||||||
|
HorizontalAlignment="Left"/>
|
||||||
|
</DockPanel>
|
||||||
|
|
||||||
|
<!-- Announcement display: scroll (marquee), flash (pulse) or
|
||||||
|
solid (still). Inline items, not a bound ItemsSource —
|
||||||
|
same shape as the chat Font selector above. -->
|
||||||
|
<DockPanel Margin="0,0,0,4">
|
||||||
|
<TextBlock DockPanel.Dock="Left" Text="Display" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" VerticalAlignment="Center" Width="84"/>
|
||||||
|
<ComboBox SelectedValue="{Binding SelectedElement.AlertDisplayMethod, Mode=TwoWay}"
|
||||||
|
SelectedValuePath="Tag"
|
||||||
|
Style="{StaticResource YtComboBox}"
|
||||||
|
Width="140" VerticalAlignment="Center">
|
||||||
|
<ComboBoxItem Content="Scroll" Tag="TickerScroll"/>
|
||||||
|
<ComboBoxItem Content="Flash" Tag="Flash"/>
|
||||||
|
<ComboBoxItem Content="Solid" Tag="Solid"/>
|
||||||
|
</ComboBox>
|
||||||
|
</DockPanel>
|
||||||
|
|
||||||
|
<DockPanel Margin="0,0,0,4">
|
||||||
|
<TextBlock DockPanel.Dock="Left" Text="Volume" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" VerticalAlignment="Center" Width="84"/>
|
||||||
|
<Slider Width="120" Minimum="0" Maximum="1"
|
||||||
|
Value="{Binding SelectedElement.AlertVideoVolume, Mode=TwoWay}"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
<TextBlock DockPanel.Dock="Right" Text="{Binding SelectedElement.AlertVideoVolume, StringFormat={}{0:0%}}" Foreground="{StaticResource Brush.OnSurface}" FontSize="11" Margin="6,0,0,0" VerticalAlignment="Center" Width="32"/>
|
||||||
|
</DockPanel>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- Background properties -->
|
||||||
|
<StackPanel Margin="0,8,0,0"
|
||||||
|
Visibility="{Binding SelectedElement.IsBackground, Converter={StaticResource BoolToVis}}">
|
||||||
|
<TextBlock Text="Background" Style="{StaticResource SubHeader}"/>
|
||||||
|
<StackPanel Margin="4,0,0,0">
|
||||||
|
<StackPanel Orientation="Horizontal" Margin="0,0,0,8">
|
||||||
|
<TextBlock Text="Use default background:" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" VerticalAlignment="Center"/>
|
||||||
|
<ToggleButton Style="{StaticResource PillToggle}"
|
||||||
|
IsChecked="{Binding SelectedElement.UseDefaultBackground, Mode=TwoWay}"
|
||||||
|
Margin="8,0,0,0"/>
|
||||||
|
</StackPanel>
|
||||||
|
<Grid Margin="0,0,0,4"
|
||||||
|
Visibility="{Binding SelectedElement.UseDefaultBackground, Converter={StaticResource InverseBoolToVis}}">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<TextBox Grid.Column="0" Text="{Binding SelectedElement.CustomBackgroundPath, Mode=OneWay}"
|
||||||
|
Style="{StaticResource YtTextBoxCompact}"
|
||||||
|
IsReadOnly="True" Foreground="{StaticResource Brush.OnSurfaceVariant}" VerticalAlignment="Center"/>
|
||||||
|
<Button Grid.Column="1" Content="Browse…" Command="{Binding BrowseBackgroundCommand}"
|
||||||
|
Style="{StaticResource YtButtonSecondary}" Margin="4,0,0,0" Padding="8,2"/>
|
||||||
|
</Grid>
|
||||||
|
<TextBlock Text="{Binding BackgroundSizeMismatchText}"
|
||||||
|
Foreground="{StaticResource Brush.Error}" FontSize="11" TextWrapping="Wrap"
|
||||||
|
Margin="0,4,0,0"
|
||||||
|
Visibility="{Binding BackgroundSizeMismatch, Converter={StaticResource BoolToVis}}"/>
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!-- Live mode: YouTube Live Chat (entire left panel replaced) -->
|
||||||
|
<Grid Grid.Row="0" Visibility="{Binding IsEditMode, Converter={StaticResource InverseBoolToVis}}">
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="*"/>
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
|
<TextBlock Grid.Row="0" Text="LIVE CHAT" Style="{StaticResource SectionHeader}"/>
|
||||||
|
|
||||||
|
<!-- Inactive overlay (shown when NOT live) -->
|
||||||
|
<StackPanel Grid.Row="1" VerticalAlignment="Center" HorizontalAlignment="Center"
|
||||||
|
Visibility="{Binding ShowChatInactiveMessage, Converter={StaticResource BoolToVis}}">
|
||||||
|
<TextBlock Text="Chat is inactive" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="13"
|
||||||
|
HorizontalAlignment="Center" Margin="0,0,0,4"/>
|
||||||
|
<TextBlock Text="Chat becomes active once LlamaCasty is connected and streaming on YouTube."
|
||||||
|
Foreground="#666" FontSize="11" TextWrapping="Wrap"
|
||||||
|
HorizontalAlignment="Center" TextAlignment="Center" MaxWidth="240"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- Pinned message -->
|
||||||
|
<Border Grid.Row="1" VerticalAlignment="Top" Margin="4,4,4,0" Padding="6,4" CornerRadius="4"
|
||||||
|
Background="{StaticResource Brush.SurfaceContainerHigh}" BorderBrush="{StaticResource Brush.Tertiary}" BorderThickness="1"
|
||||||
|
Visibility="{Binding PinnedChatMessage, Converter={StaticResource NotNullToVis}}">
|
||||||
|
<StackPanel>
|
||||||
|
<TextBlock Text="{Binding PinnedChatMessage.AuthorName}" Foreground="{StaticResource Brush.Tertiary}" FontWeight="SemiBold" FontSize="11"/>
|
||||||
|
<TextBlock Text="{Binding PinnedChatMessage.Message}" Foreground="{StaticResource Brush.OnSurface}" FontSize="11" TextWrapping="Wrap"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- Message feed (shown when live) -->
|
||||||
|
<ListBox Grid.Row="1" Background="Transparent" BorderThickness="0" Margin="0,40,0,0"
|
||||||
|
ItemsSource="{Binding ChatMessages}"
|
||||||
|
ItemContainerStyle="{StaticResource YtListBoxItem}"
|
||||||
|
Visibility="{Binding ShowChatInactiveMessage, Converter={StaticResource InverseBoolToVis}}"
|
||||||
|
x:Name="ChatListBox">
|
||||||
|
<ListBox.ItemTemplate><DataTemplate>
|
||||||
|
<Border x:Name="MessageBorder" Padding="6,4" CornerRadius="4">
|
||||||
|
<Border.Style>
|
||||||
|
<Style TargetType="Border">
|
||||||
|
<Setter Property="Background" Value="Transparent"/>
|
||||||
|
<Style.Triggers>
|
||||||
|
<DataTrigger Binding="{Binding IsSuperChat}" Value="True">
|
||||||
|
<Setter Property="Background" Value="{StaticResource Brush.SurfaceContainerHigh}"/>
|
||||||
|
<Setter Property="BorderBrush" Value="{StaticResource Brush.Secondary}"/>
|
||||||
|
<Setter Property="BorderThickness" Value="1,0,1,0"/>
|
||||||
|
</DataTrigger>
|
||||||
|
</Style.Triggers>
|
||||||
|
</Style>
|
||||||
|
</Border.Style>
|
||||||
|
<StackPanel>
|
||||||
|
<StackPanel Orientation="Horizontal" Margin="0,0,0,2">
|
||||||
|
<TextBlock Text="⭐" FontSize="10" Margin="0,0,4,0"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Visibility="{Binding IsMember, Converter={StaticResource BoolToVis}}"/>
|
||||||
|
<TextBlock Text="{Binding AuthorName}" Foreground="{StaticResource Brush.Tertiary}"
|
||||||
|
FontWeight="Bold" FontSize="12" VerticalAlignment="Center"/>
|
||||||
|
<TextBlock Text="{Binding SuperChatAmount, StringFormat=${0:F2}}"
|
||||||
|
Foreground="{StaticResource Brush.Secondary}" FontWeight="Bold" FontSize="12"
|
||||||
|
Margin="8,0,0,0" VerticalAlignment="Center"
|
||||||
|
Visibility="{Binding IsSuperChat, Converter={StaticResource BoolToVis}}"/>
|
||||||
|
<TextBlock Text="{Binding SuperChatCurrency}" Foreground="{StaticResource Brush.Secondary}"
|
||||||
|
FontSize="11" Margin="2,0,0,0" VerticalAlignment="Center"
|
||||||
|
Visibility="{Binding IsSuperChat, Converter={StaticResource BoolToVis}}"/>
|
||||||
|
</StackPanel>
|
||||||
|
<TextBlock Text="{Binding Message}" Foreground="{StaticResource Brush.OnSurface}"
|
||||||
|
TextWrapping="Wrap" Margin="0,2,0,0"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</DataTemplate></ListBox.ItemTemplate>
|
||||||
|
</ListBox>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</UserControl>
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
using System.Collections;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Controls;
|
||||||
|
using System.Windows.Input;
|
||||||
|
using System.Windows.Media;
|
||||||
|
using ytLive.Models;
|
||||||
|
using ytLive.ViewModels;
|
||||||
|
|
||||||
|
namespace ytLive.Controls;
|
||||||
|
|
||||||
|
public partial class LeftPanel : UserControl
|
||||||
|
{
|
||||||
|
private Models.SceneElement? _snapshotElement;
|
||||||
|
private string _snapshotBorderColor = string.Empty;
|
||||||
|
private int _snapshotBorderWidth;
|
||||||
|
private int _snapshotTimerMinutes = 15;
|
||||||
|
private string? _snapshotWebUri;
|
||||||
|
|
||||||
|
private Point _dragStartPoint;
|
||||||
|
private int _dragIndex = -1;
|
||||||
|
private bool _isDragging;
|
||||||
|
private object? _lastHoveredItem;
|
||||||
|
private bool _dragReordered;
|
||||||
|
|
||||||
|
public LeftPanel()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
Loaded += (_, _) =>
|
||||||
|
{
|
||||||
|
if (DataContext is MainViewModel vm)
|
||||||
|
vm.ChatMessages.CollectionChanged += OnChatMessagesChanged;
|
||||||
|
};
|
||||||
|
Unloaded += (_, _) =>
|
||||||
|
{
|
||||||
|
if (DataContext is MainViewModel vm)
|
||||||
|
vm.ChatMessages.CollectionChanged -= OnChatMessagesChanged;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Called by the window's UpdateSelectionOverlay (edit mode only).
|
||||||
|
public void OnSelectionChanged(MainViewModel vm)
|
||||||
|
{
|
||||||
|
if (vm.SelectedElement is { } s && PreviewPane.IsDraggableElement(s))
|
||||||
|
OpacityValueText.Text = $"{Math.Round(s.Opacity * 100)}%";
|
||||||
|
TakeSnapshot(vm);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deselection guard: true when the click is inside the source list or the
|
||||||
|
// element property scroller (window's Window_PreviewMouseLeftButtonDown).
|
||||||
|
public bool IsClickInside(DependencyObject original)
|
||||||
|
=> IsDescendantOf(original, SourceList) || IsDescendantOf(original, ElementsScrollViewer);
|
||||||
|
|
||||||
|
private static bool IsDescendantOf(DependencyObject? child, DependencyObject ancestor)
|
||||||
|
{
|
||||||
|
while (child != null && !ReferenceEquals(child, ancestor))
|
||||||
|
{
|
||||||
|
var next = child is Visual or System.Windows.Media.Media3D.Visual3D
|
||||||
|
? VisualTreeHelper.GetParent(child)
|
||||||
|
: null;
|
||||||
|
child = next
|
||||||
|
?? LogicalTreeHelper.GetParent(child)
|
||||||
|
?? (child as FrameworkElement)?.TemplatedParent;
|
||||||
|
}
|
||||||
|
return child != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SourceNameBox_IsVisibleChanged(object sender, DependencyPropertyChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (sender is TextBox { IsVisible: true } box)
|
||||||
|
{
|
||||||
|
box.Focus();
|
||||||
|
box.SelectAll();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddSourceButton_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (sender is Button { ContextMenu: { } menu } button)
|
||||||
|
{
|
||||||
|
menu.PlacementTarget = button;
|
||||||
|
menu.Placement = System.Windows.Controls.Primitives.PlacementMode.Bottom;
|
||||||
|
menu.IsOpen = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnChatMessagesChanged(object? sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.Action == System.Collections.Specialized.NotifyCollectionChangedAction.Add)
|
||||||
|
{
|
||||||
|
ChatListBox.ScrollIntoView(ChatListBox.Items[^1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void MirrorButton_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if ((DataContext as MainViewModel)?.SelectedElement is WebcamSceneConfig webcam)
|
||||||
|
webcam.IsMirrored = !webcam.IsMirrored;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SourceMenu_Remove(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (sender is MenuItem { DataContext: SceneElement element } && DataContext is MainViewModel vm)
|
||||||
|
vm.RemoveSourceCommand.Execute(element);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BackgroundMenu_ChangeCapture(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
_ = (DataContext as MainViewModel)?.ChangeBackgroundCaptureAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BackgroundMenu_RefreshCapture(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
(DataContext as MainViewModel)?.RefreshBackgroundAutoCapture();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BackgroundMenu_RefreshWindowList(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
(DataContext as MainViewModel)?.RefreshWindowsCommand.Execute(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OpacitySlider_ValueChanged(object sender, RoutedPropertyChangedEventArgs<double> e)
|
||||||
|
=> OpacityValueText.Text = $"{Math.Round(e.NewValue * 100)}%";
|
||||||
|
|
||||||
|
private void SourceNameBox_KeyDown(object sender, KeyEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.Key is Key.Enter or Key.Escape)
|
||||||
|
{
|
||||||
|
CommitSourceEdit(sender);
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SourceNameBox_LostFocus(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
CommitSourceEdit(sender);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void CommitSourceEdit(object sender)
|
||||||
|
{
|
||||||
|
if (sender is FrameworkElement { DataContext: SceneElement element })
|
||||||
|
element.IsEditing = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── List drag-to-reorder (scenes list + sources list) ───
|
||||||
|
private void List_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
|
||||||
|
{
|
||||||
|
var listBox = (ListBox)sender;
|
||||||
|
var item = FindItemContainerAt(listBox, e.GetPosition(listBox));
|
||||||
|
if (item == null)
|
||||||
|
{
|
||||||
|
if (ReferenceEquals(listBox, SourceList))
|
||||||
|
(DataContext as MainViewModel)!.SelectedElement = null;
|
||||||
|
_dragIndex = -1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (listBox.ItemsSource is not IList items || items.Count == 0)
|
||||||
|
{
|
||||||
|
_dragIndex = -1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_dragIndex = listBox.Items.IndexOf(item.DataContext);
|
||||||
|
_dragStartPoint = e.GetPosition(listBox);
|
||||||
|
_isDragging = false;
|
||||||
|
_lastHoveredItem = null;
|
||||||
|
_dragReordered = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void List_PreviewMouseMove(object sender, MouseEventArgs e)
|
||||||
|
{
|
||||||
|
if (_dragIndex < 0) return;
|
||||||
|
var listBox = (ListBox)sender;
|
||||||
|
if (e.LeftButton != MouseButtonState.Pressed || listBox.ItemsSource is not IList items)
|
||||||
|
{
|
||||||
|
EndListDrag(listBox);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var position = e.GetPosition(listBox);
|
||||||
|
if (!_isDragging)
|
||||||
|
{
|
||||||
|
if (Math.Abs(position.X - _dragStartPoint.X) < SystemParameters.MinimumHorizontalDragDistance &&
|
||||||
|
Math.Abs(position.Y - _dragStartPoint.Y) < SystemParameters.MinimumVerticalDragDistance)
|
||||||
|
return;
|
||||||
|
_isDragging = true;
|
||||||
|
listBox.CaptureMouse();
|
||||||
|
}
|
||||||
|
|
||||||
|
var item = FindItemContainerAt(listBox, position);
|
||||||
|
if (item == null) return;
|
||||||
|
|
||||||
|
var targetItem = item.DataContext;
|
||||||
|
if (ReferenceEquals(targetItem, items[_dragIndex]) || ReferenceEquals(targetItem, _lastHoveredItem))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var targetIndex = listBox.Items.IndexOf(targetItem);
|
||||||
|
if (targetIndex < 0) return;
|
||||||
|
|
||||||
|
var dragged = items[_dragIndex];
|
||||||
|
items.RemoveAt(_dragIndex);
|
||||||
|
items.Insert(targetIndex, dragged);
|
||||||
|
_dragIndex = targetIndex;
|
||||||
|
_lastHoveredItem = targetItem;
|
||||||
|
_dragReordered = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void List_PreviewMouseLeftButtonUp(object sender, MouseButtonEventArgs e)
|
||||||
|
{
|
||||||
|
EndListDrag((ListBox)sender);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EndListDrag(ListBox listBox)
|
||||||
|
{
|
||||||
|
if (_isDragging)
|
||||||
|
listBox.ReleaseMouseCapture();
|
||||||
|
var reordered = _dragReordered;
|
||||||
|
_dragIndex = -1;
|
||||||
|
_isDragging = false;
|
||||||
|
_lastHoveredItem = null;
|
||||||
|
_dragReordered = false;
|
||||||
|
if (reordered && DataContext is MainViewModel vm)
|
||||||
|
vm.OnSceneElementsReordered();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ListBoxItem? FindItemContainerAt(ListBox listBox, Point position)
|
||||||
|
{
|
||||||
|
var hit = listBox.InputHitTest(position) as DependencyObject;
|
||||||
|
while (hit != null && hit != listBox)
|
||||||
|
{
|
||||||
|
if (hit is ListBoxItem item) return item;
|
||||||
|
hit = VisualTreeHelper.GetParent(hit);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void TakeSnapshot(MainViewModel vm)
|
||||||
|
{
|
||||||
|
if (vm.SelectedElement is Models.Source src)
|
||||||
|
{
|
||||||
|
_snapshotElement = src;
|
||||||
|
_snapshotBorderColor = src.BorderColor;
|
||||||
|
_snapshotBorderWidth = src.BorderWidth;
|
||||||
|
_snapshotTimerMinutes = src.TimerMinutes;
|
||||||
|
_snapshotWebUri = src.WebUri;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void WebcamBorderRevert_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (_snapshotElement is Models.Source src)
|
||||||
|
{
|
||||||
|
src.BorderColor = _snapshotBorderColor;
|
||||||
|
src.BorderWidth = _snapshotBorderWidth;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void WebcamBorderAccept_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (_snapshotElement is Models.Source src)
|
||||||
|
{
|
||||||
|
_snapshotBorderColor = src.BorderColor;
|
||||||
|
_snapshotBorderWidth = src.BorderWidth;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CountdownRevert_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (_snapshotElement is Models.Source src)
|
||||||
|
src.TimerMinutes = _snapshotTimerMinutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CountdownAccept_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (_snapshotElement is Models.Source src)
|
||||||
|
_snapshotTimerMinutes = src.TimerMinutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void WebRevert_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (_snapshotElement is Models.Source src)
|
||||||
|
src.WebUri = string.Empty;
|
||||||
|
_snapshotWebUri = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void WebAccept_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (_snapshotElement is Models.Source src)
|
||||||
|
_snapshotWebUri = src.WebUri;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
<UserControl x:Class="ytLive.Controls.OverlayHost"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:Helpers="clr-namespace:ytLive.Helpers"
|
||||||
|
xmlns:Models="clr-namespace:ytLive.Models"
|
||||||
|
xmlns:services="clr-namespace:ytLive.Services">
|
||||||
|
<Grid Grid.RowSpan="3"
|
||||||
|
Visibility="{Binding IsAnyOverlayOpen, Converter={StaticResource BoolToVis}}">
|
||||||
|
<Button Background="{StaticResource Brush.ScrimDarkStrong}" BorderThickness="0" Cursor="Hand"
|
||||||
|
Command="{Binding CloseOverlayCommand}">
|
||||||
|
<Button.Template>
|
||||||
|
<ControlTemplate TargetType="Button">
|
||||||
|
<Border Background="{TemplateBinding Background}"/>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Button.Template>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Border Background="{StaticResource Brush.Background}" CornerRadius="8" Width="650" MaxHeight="700"
|
||||||
|
VerticalAlignment="Center" HorizontalAlignment="Center"
|
||||||
|
Padding="28" BorderBrush="#333" BorderThickness="1">
|
||||||
|
<Grid>
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="*"/>
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<Grid Grid.Row="0">
|
||||||
|
<TextBlock Text="{Binding OverlayTitle}" FontSize="18" FontWeight="Bold"
|
||||||
|
Foreground="{StaticResource Brush.OnSurface}" VerticalAlignment="Center"/>
|
||||||
|
<Button HorizontalAlignment="Right" ToolTip="Close" Style="{StaticResource IconButton}"
|
||||||
|
Command="{Binding CloseOverlayCommand}">
|
||||||
|
<Path Data="M19,6.41L17.59,5 12,10.59 6.41,5 5,6.41 10.59,12 5,17.59 6.41,19 12,13.41 17.59,19 19,17.59 13.41,12z"
|
||||||
|
Fill="{StaticResource Brush.OnSurface}" Width="14" Height="14" Stretch="Uniform"/>
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!-- Report Bug -->
|
||||||
|
<StackPanel Grid.Row="1" Margin="0,16,0,0"
|
||||||
|
Visibility="{Binding IsBugOpen, Converter={StaticResource BoolToVis}}">
|
||||||
|
<TextBlock Text="What went wrong?" Style="{StaticResource YtLabel}" Margin="0,0,0,4"/>
|
||||||
|
<TextBox Style="{StaticResource YtTextBox}" Height="110" AcceptsReturn="True" TextWrapping="Wrap"
|
||||||
|
Margin="0,0,0,12"
|
||||||
|
Text="{Binding BugReportText, UpdateSourceTrigger=PropertyChanged}"
|
||||||
|
VerticalScrollBarVisibility="Auto"/>
|
||||||
|
|
||||||
|
<TextBlock Text="Email (optional)" Style="{StaticResource YtLabel}" Margin="0,0,0,4"/>
|
||||||
|
<TextBox Style="{StaticResource YtTextBox}" Margin="0,0,0,12"
|
||||||
|
Text="{Binding BugReportEmail, UpdateSourceTrigger=PropertyChanged}"/>
|
||||||
|
|
||||||
|
<TextBlock Text="Opens a pre-filled email to the LlamaCasty team." Style="{StaticResource YtLabel}"/>
|
||||||
|
<Button Content="Send Report" Command="{Binding SubmitBugCommand}"
|
||||||
|
Style="{StaticResource YtButton}" HorizontalAlignment="Right" Margin="0,16,0,0"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- Feature Request -->
|
||||||
|
<StackPanel Grid.Row="1" Margin="0,16,0,0"
|
||||||
|
Visibility="{Binding IsFeatureOpen, Converter={StaticResource BoolToVis}}">
|
||||||
|
<TextBlock Text="What would you like to see?" Style="{StaticResource YtLabel}" Margin="0,0,0,4"/>
|
||||||
|
<TextBox Style="{StaticResource YtTextBox}" Height="110" AcceptsReturn="True" TextWrapping="Wrap"
|
||||||
|
Margin="0,0,0,12"
|
||||||
|
Text="{Binding FeatureRequestText, UpdateSourceTrigger=PropertyChanged}"
|
||||||
|
VerticalScrollBarVisibility="Auto"/>
|
||||||
|
|
||||||
|
<TextBlock Text="Email (optional)" Style="{StaticResource YtLabel}" Margin="0,0,0,4"/>
|
||||||
|
<TextBox Style="{StaticResource YtTextBox}" Margin="0,0,0,12"
|
||||||
|
Text="{Binding FeatureRequestEmail, UpdateSourceTrigger=PropertyChanged}"/>
|
||||||
|
|
||||||
|
<TextBlock Text="Opens a pre-filled email to the LlamaCasty team." Style="{StaticResource YtLabel}"/>
|
||||||
|
<Button Content="Send Request" Command="{Binding SubmitFeatureCommand}"
|
||||||
|
Style="{StaticResource YtButton}" HorizontalAlignment="Right" Margin="0,16,0,0"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- About -->
|
||||||
|
<Grid Grid.Row="1" Margin="0,16,0,0"
|
||||||
|
Background="{StaticResource Brush.SurfaceContainerLow}"
|
||||||
|
Visibility="{Binding IsAboutOpen, Converter={StaticResource BoolToVis}}">
|
||||||
|
|
||||||
|
<!-- Hub view -->
|
||||||
|
<StackPanel Visibility="{Binding IsHubVisible, Converter={StaticResource BoolToVis}}">
|
||||||
|
<Image Source="/LlamaCasty;component/Assets/llamacasty-logo.jpg" Height="125" Stretch="Uniform"
|
||||||
|
HorizontalAlignment="Center" Margin="0,0,0,12"/>
|
||||||
|
<TextBlock Text="{Binding AppVersionLabel}" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="12"
|
||||||
|
HorizontalAlignment="Center" Margin="0,4,0,0"/>
|
||||||
|
<TextBlock Text="Live streaming for YouTube — simple enough that even the most right-brained person can intuit it."
|
||||||
|
Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="13" TextWrapping="Wrap" MaxWidth="380"
|
||||||
|
TextAlignment="Center" Margin="0,16,0,0"/>
|
||||||
|
<TextBlock Text="Free forever — live streams carry a brief "made with LlamaCasty!" flash. A subscription removes it and adds alerts."
|
||||||
|
Foreground="#888" FontSize="12" TextWrapping="Wrap" MaxWidth="380"
|
||||||
|
TextAlignment="Center" Margin="0,12,0,0"/>
|
||||||
|
<TextBlock Text="Made by gramps · llama chile shop" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="12"
|
||||||
|
HorizontalAlignment="Center" Margin="0,16,0,8"/>
|
||||||
|
|
||||||
|
<!-- Creator hub links -->
|
||||||
|
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center" Margin="0,0,0,8">
|
||||||
|
<Button Content="llama chile shop on YouTube" Command="{Binding OpenChannelCommand}"
|
||||||
|
Style="{StaticResource YtButtonSecondary}" Margin="0,0,8,0"/>
|
||||||
|
<Button Content="Mastodon" Command="{Binding OpenMastodonCommand}"
|
||||||
|
Style="{StaticResource YtButtonSecondary}"/>
|
||||||
|
</StackPanel>
|
||||||
|
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center">
|
||||||
|
<Button Content="Buy me a coffee" Command="{Binding OpenCoffeeCommand}"
|
||||||
|
Style="{StaticResource YtButtonSecondary}" Margin="0,0,8,0"/>
|
||||||
|
<Button Content="Unlock Premium" Command="{Binding OpenPremiumCommand}"
|
||||||
|
Style="{StaticResource YtButtonSecondary}"
|
||||||
|
IsEnabled="{Binding IsPremiumAvailable}"
|
||||||
|
ToolTip="Open the LlamaCasty subscription page"/>
|
||||||
|
<Button Content="Enter License Key" Command="{Binding OpenLicenseEntryCommand}"
|
||||||
|
Style="{StaticResource YtButtonSecondary}"
|
||||||
|
ToolTip="Activate a license key to remove the watermark"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<Button Content="Licenses & legal" Command="{Binding OpenLicensesCommand}"
|
||||||
|
Style="{StaticResource YtButtonSecondary}" HorizontalAlignment="Center"
|
||||||
|
Margin="0,16,0,0"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- Licensing/legal panel -->
|
||||||
|
<StackPanel Visibility="{Binding IsLicensesOpen, Converter={StaticResource BoolToVis}}">
|
||||||
|
<TextBlock Text="Third-party notices & license texts" FontSize="15" FontWeight="Bold"
|
||||||
|
Foreground="{StaticResource Brush.OnSurface}" Margin="0,0,0,8"/>
|
||||||
|
<Border Background="{StaticResource Brush.SurfaceContainerLowest}" CornerRadius="4" Padding="10" MaxHeight="360"
|
||||||
|
BorderBrush="#333" BorderThickness="1">
|
||||||
|
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||||
|
<TextBlock Text="{Binding LicensesText}" Foreground="{StaticResource Brush.OnSurface}" FontSize="11"
|
||||||
|
FontFamily="Consolas" TextWrapping="Wrap"/>
|
||||||
|
</ScrollViewer>
|
||||||
|
</Border>
|
||||||
|
<Button Content="← Back to About" Style="{StaticResource YtButtonSecondary}"
|
||||||
|
HorizontalAlignment="Left" Margin="0,12,0,0"
|
||||||
|
Click="BackToAbout_Click"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- License key entry panel -->
|
||||||
|
<StackPanel Visibility="{Binding IsLicenseEntryOpen, Converter={StaticResource BoolToVis}}">
|
||||||
|
<TextBlock Text="Activate LlamaCasty Premium" FontSize="15" FontWeight="Bold"
|
||||||
|
Foreground="{StaticResource Brush.OnSurface}" Margin="0,0,0,8"/>
|
||||||
|
<TextBlock Text="Enter your license key to remove the branding watermark."
|
||||||
|
Foreground="{StaticResource Brush.OnSurfaceVariant}" 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="{StaticResource Brush.OnSurfaceVariant}" FontSize="12"
|
||||||
|
TextWrapping="Wrap" Margin="0,0,0,8"/>
|
||||||
|
<Button Content="← Back to About" Style="{StaticResource YtButtonSecondary}"
|
||||||
|
HorizontalAlignment="Left" Margin="0,8,0,0"
|
||||||
|
Click="BackToAbout_Click"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</Grid>
|
||||||
|
</UserControl>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Controls;
|
||||||
|
using ytLive.ViewModels;
|
||||||
|
|
||||||
|
namespace ytLive.Controls;
|
||||||
|
|
||||||
|
public partial class OverlayHost : UserControl
|
||||||
|
{
|
||||||
|
public OverlayHost()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BackToAbout_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (DataContext is MainViewModel vm)
|
||||||
|
{
|
||||||
|
vm.IsLicensesOpen = false;
|
||||||
|
vm.IsLicenseEntryOpen = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
{
|
{
|
||||||
@@ -401,6 +551,15 @@ public partial class PreviewPane : UserControl
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (_isDraggingOverlay)
|
if (_isDraggingOverlay)
|
||||||
|
{
|
||||||
|
// TASK 57: under the vertical tier the camera snaps into the
|
||||||
|
// cropped band (one of six spots) instead of free-floating over
|
||||||
|
// the master — outside the band it is not in the stream at all.
|
||||||
|
if (vm.IsVerticalTier && selected is WebcamSceneConfig verticalCam)
|
||||||
|
{
|
||||||
|
vm.MoveWebcamInVerticalTier(verticalCam, p.X - _grabOffset.X, p.Y - _grabOffset.Y);
|
||||||
|
}
|
||||||
|
else
|
||||||
{
|
{
|
||||||
var minX = -(selected.Width - 20);
|
var minX = -(selected.Width - 20);
|
||||||
var maxX = 1920 - 20;
|
var maxX = 1920 - 20;
|
||||||
@@ -409,6 +568,7 @@ public partial class PreviewPane : UserControl
|
|||||||
selected.X = Math.Clamp(p.X - _grabOffset.X, Math.Min(minX, maxX), Math.Max(minX, maxX));
|
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));
|
selected.Y = Math.Clamp(p.Y - _grabOffset.Y, Math.Min(minY, maxY), Math.Max(minY, maxY));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
else if (_isResizing)
|
else if (_isResizing)
|
||||||
{
|
{
|
||||||
var isWebcam = selected is WebcamSceneConfig;
|
var isWebcam = selected is WebcamSceneConfig;
|
||||||
@@ -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,166 @@ 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;
|
||||||
|
|
||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
<UserControl x:Class="ytLive.Controls.SceneThumbnailStrip"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||||
|
<Border Background="{StaticResource Brush.SurfaceContainerLowest}" CornerRadius="4" Padding="4">
|
||||||
|
<ItemsControl ItemsSource="{Binding Scenes}" HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||||
|
<ItemsControl.ItemsPanel>
|
||||||
|
<ItemsPanelTemplate>
|
||||||
|
<StackPanel Orientation="Horizontal"/>
|
||||||
|
</ItemsPanelTemplate>
|
||||||
|
</ItemsControl.ItemsPanel>
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate>
|
||||||
|
<Border Width="196" Height="130" CornerRadius="4" Margin="2" Padding="2"
|
||||||
|
BorderThickness="2"
|
||||||
|
Cursor="Hand"
|
||||||
|
MouseLeftButtonDown="Thumbnail_MouseLeftButtonDown">
|
||||||
|
<Border.Style>
|
||||||
|
<Style TargetType="Border">
|
||||||
|
<Setter Property="BorderBrush" Value="{StaticResource Brush.Outline}"/>
|
||||||
|
<Style.Triggers>
|
||||||
|
<!-- Active scene = orange (creator ruling 2026-10-30): it marks
|
||||||
|
the stage you are working on, not a selection — blue is
|
||||||
|
the interactive accent and read as "selected". Live
|
||||||
|
stays red. -->
|
||||||
|
<DataTrigger Binding="{Binding IsStaged}" Value="True">
|
||||||
|
<Setter Property="BorderBrush" Value="{StaticResource Brush.Attention}"/>
|
||||||
|
</DataTrigger>
|
||||||
|
<DataTrigger Binding="{Binding IsLive}" Value="True">
|
||||||
|
<Setter Property="BorderBrush" Value="{StaticResource Brush.Primary}"/>
|
||||||
|
</DataTrigger>
|
||||||
|
</Style.Triggers>
|
||||||
|
</Style>
|
||||||
|
</Border.Style>
|
||||||
|
<Grid>
|
||||||
|
<Border CornerRadius="3" Background="{StaticResource Brush.SurfaceContainerLowest}" ClipToBounds="True">
|
||||||
|
<Image Source="{Binding Snapshot}"
|
||||||
|
Stretch="UniformToFill"
|
||||||
|
RenderOptions.BitmapScalingMode="LowQuality"/>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<Border HorizontalAlignment="Left" VerticalAlignment="Top" Margin="6" Padding="8,3"
|
||||||
|
CornerRadius="3" Background="{StaticResource Brush.Primary}">
|
||||||
|
<Border.Style>
|
||||||
|
<Style TargetType="Border">
|
||||||
|
<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>
|
||||||
|
</Border>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</Border>
|
||||||
|
</UserControl>
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Controls;
|
||||||
|
using System.Windows.Input;
|
||||||
|
using ytLive.Models;
|
||||||
|
using ytLive.ViewModels;
|
||||||
|
|
||||||
|
namespace ytLive.Controls;
|
||||||
|
|
||||||
|
public partial class SceneThumbnailStrip : UserControl
|
||||||
|
{
|
||||||
|
public SceneThumbnailStrip()
|
||||||
|
{
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
if (sender is Border { DataContext: Scene scene } && DataContext is MainViewModel vm)
|
||||||
|
vm.TransitionToScene(scene);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,104 @@
|
|||||||
|
<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">
|
||||||
|
<!-- 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>
|
||||||
|
|
||||||
|
<!-- ═══ 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="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>
|
||||||
|
|
||||||
|
<!-- ═══ 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>
|
||||||
|
|
||||||
|
<!-- 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;
|
||||||
|
}
|
||||||
@@ -1,118 +1,160 @@
|
|||||||
<UserControl x:Class="ytLive.Controls.TopBar"
|
<UserControl x:Class="ytLive.Controls.TopBar"
|
||||||
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"
|
||||||
x:Name="TopBarRoot">
|
x:Name="TopBarRoot">
|
||||||
<!-- LOGO / BRAND — clicking opens the in-app About overlay -->
|
|
||||||
<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 -->
|
<!-- BRAND + EDIT. The gear moved to the preview's right rail, first tab (2026-11-01):
|
||||||
|
app-level actions live with the other pull-outs now. -->
|
||||||
|
<Grid Grid.Column="0">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
|
<!-- LOGO / BRAND — left-click opens the in-app About overlay; right-click opens the app
|
||||||
|
menu (Report Bug / Feature Request / About). App Settings is no longer in this
|
||||||
|
menu: the gear rail tab opens the Settings drawer directly (creator ruling
|
||||||
|
2026-11-01). -->
|
||||||
<Button Grid.Column="0" Background="Transparent" BorderThickness="0" Cursor="Hand"
|
<Button Grid.Column="0" Background="Transparent" BorderThickness="0" Cursor="Hand"
|
||||||
VerticalAlignment="Center" Margin="0,0,24,0" ToolTip="About LlamaCasty"
|
HorizontalAlignment="Left"
|
||||||
Command="{Binding OpenAboutCommand}">
|
VerticalAlignment="Center" ToolTip="About LlamaCasty — right-click for the menu"
|
||||||
|
Command="{Binding OpenAboutCommand}"
|
||||||
|
PreviewMouseRightButtonDown="BrandWordmark_PreviewMouseRightButtonDown">
|
||||||
|
<Button.ContextMenu>
|
||||||
|
<ContextMenu DataContext="{Binding PlacementTarget.DataContext, RelativeSource={RelativeSource Self}}">
|
||||||
|
<MenuItem Header="Report Bug" Command="{Binding OpenBugCommand}"/>
|
||||||
|
<MenuItem Header="Feature Request" Command="{Binding OpenFeatureCommand}"/>
|
||||||
|
<MenuItem Header="About" Command="{Binding OpenAboutCommand}"/>
|
||||||
|
</ContextMenu>
|
||||||
|
</Button.ContextMenu>
|
||||||
<Button.Template>
|
<Button.Template>
|
||||||
<ControlTemplate TargetType="Button">
|
<ControlTemplate TargetType="Button">
|
||||||
<ContentPresenter/>
|
<ContentPresenter/>
|
||||||
</ControlTemplate>
|
</ControlTemplate>
|
||||||
</Button.Template>
|
</Button.Template>
|
||||||
<TextBlock FontSize="22" FontWeight="Bold">
|
<!-- Wordmark: "LlamaCas" + "ty" in YouTube red (creator ruling 2026-10-30 — never
|
||||||
<Run Text="LlamaCas" Foreground="#e0e0e0"/><Run Text="ty" Foreground="#e94560"/>
|
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>
|
</TextBlock>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<!-- REC / ON-AIR pills (intent) + status signs (reality) -->
|
<!-- EDIT: explicit toggle. Neutral = off (chat), blue = on (layers/properties). The
|
||||||
|
trigger used to set the SAME colour as the resting state, so the button never
|
||||||
|
looked pressed. Right edge aligns with the layers panel below. -->
|
||||||
|
<ToggleButton Grid.Column="1" IsChecked="{Binding EditingActive, Mode=TwoWay}" Content="EDIT"
|
||||||
|
Cursor="Hand" Height="32" Padding="14,0" Margin="18,0,0,2"
|
||||||
|
HorizontalAlignment="Right" VerticalAlignment="Center"
|
||||||
|
FontWeight="Bold" FontSize="12" Foreground="{StaticResource Brush.OnAccent}" BorderThickness="0">
|
||||||
|
<ToggleButton.Template>
|
||||||
|
<ControlTemplate TargetType="ToggleButton">
|
||||||
|
<Border x:Name="EditBody" Background="{StaticResource Brush.SurfaceContainerHigh}" CornerRadius="4" Padding="{TemplateBinding Padding}">
|
||||||
|
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||||
|
</Border>
|
||||||
|
<ControlTemplate.Triggers>
|
||||||
|
<Trigger Property="IsChecked" Value="True">
|
||||||
|
<Setter TargetName="EditBody" Property="Background" Value="{StaticResource Brush.Tertiary}"/>
|
||||||
|
</Trigger>
|
||||||
|
</ControlTemplate.Triggers>
|
||||||
|
</ControlTemplate>
|
||||||
|
</ToggleButton.Template>
|
||||||
|
</ToggleButton>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!-- Session surface (top bar redesign): the bar is ONE surface rendered by
|
||||||
|
the FIRST decision — Record or Stream. Each world is the whole bar; the
|
||||||
|
other world never exists on screen. While running, the bar drops to a
|
||||||
|
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">
|
||||||
<!-- REC pill: local recording needs no sign-in -->
|
<!-- IDLE: the mode switch + the action(s) of the armed world. The three
|
||||||
<TextBlock Text="REC" Foreground="#e94560" FontWeight="Bold" FontSize="12"
|
controls share one height (build 77 feedback) so they read as one
|
||||||
VerticalAlignment="Center" Margin="0,0,6,0"/>
|
family — segments host, Start/Go Live, and Test all sit at 32. -->
|
||||||
<ToggleButton Style="{StaticResource PillToggle}" VerticalAlignment="Center"
|
<StackPanel Orientation="Horizontal"
|
||||||
IsChecked="{Binding RecordPillOn}"/>
|
Visibility="{Binding ShowIdleWorld, Converter={StaticResource BoolToVis}}">
|
||||||
<!-- ON-AIR pill: greyed out until signed in -->
|
<Border Background="{StaticResource Brush.SurfaceContainerLowest}" CornerRadius="4" Padding="2" Height="32" VerticalAlignment="Center">
|
||||||
<TextBlock Text="ON-AIR" Foreground="#e94560" FontWeight="Bold" FontSize="12"
|
<StackPanel Orientation="Horizontal">
|
||||||
VerticalAlignment="Center" Margin="16,0,6,0"/>
|
<ToggleButton Style="{StaticResource RecordPillToggle}"
|
||||||
<ToggleButton Style="{StaticResource PillToggle}" VerticalAlignment="Center"
|
IsChecked="{Binding RecordPillOn}">
|
||||||
IsChecked="{Binding OnAirPillOn}"
|
<!-- the label follows the pill's own foreground: muted when armed-off,
|
||||||
IsEnabled="{Binding CanToggleOnAir}"
|
dark on red when armed (SegmentLabel pins its own colour) -->
|
||||||
ToolTip="{Binding AccountStatusLightToolTip}"/>
|
<TextBlock Text="REC" FontSize="12" FontWeight="Bold" VerticalAlignment="Center"
|
||||||
<!-- Status signs: REC dot + ON-AIR dot -->
|
Foreground="{Binding Foreground, RelativeSource={RelativeSource AncestorType=ToggleButton}}"/>
|
||||||
<Ellipse Width="12" Height="12" VerticalAlignment="Center" Margin="16,0,0,0"
|
</ToggleButton>
|
||||||
Fill="{Binding RecDotBrush}" Opacity="{Binding RecDotOpacity}"/>
|
<ToggleButton Style="{StaticResource RecordPillToggle}"
|
||||||
<TextBlock Text="REC" Style="{StaticResource StatusSignText}"/>
|
IsChecked="{Binding OnAirPillOn}">
|
||||||
<!-- ON-AIR sign: gray offline, red public, green private -->
|
<TextBlock Text="ON-AIR" FontSize="12" FontWeight="Bold" VerticalAlignment="Center"
|
||||||
<Ellipse Width="12" Height="12" VerticalAlignment="Center" Margin="16,0,0,0"
|
Foreground="{Binding Foreground, RelativeSource={RelativeSource AncestorType=ToggleButton}}"/>
|
||||||
Fill="{Binding OnAirBrush}"/>
|
</ToggleButton>
|
||||||
<TextBlock Text="ON-AIR" Style="{StaticResource StatusSignText}"/>
|
</StackPanel>
|
||||||
<Border Background="#8f1f1f" CornerRadius="3" Padding="6,1" Margin="8,0,0,0"
|
|
||||||
VerticalAlignment="Center"
|
|
||||||
Visibility="{Binding IsLivePrivate, Converter={StaticResource BoolToVis}}">
|
|
||||||
<TextBlock Text="PRIVATE" FontSize="11" FontWeight="Bold" Foreground="White"/>
|
|
||||||
</Border>
|
</Border>
|
||||||
<TextBlock Text="{Binding LiveElapsedText}" Foreground="White" FontSize="14"
|
<!-- Go face names the world: "Start Recording" vs "Go Live".
|
||||||
FontFamily="Consolas" VerticalAlignment="Center" Margin="14,0,0,0"
|
(Signed-out ON-AIR Start routes to the sign-in — StartSession.) -->
|
||||||
Visibility="{Binding IsLive, Converter={StaticResource BoolToVis}}"/>
|
<Button Content="{Binding PrimaryStartButtonLabel}" Style="{StaticResource YtButton}"
|
||||||
|
Height="32" Margin="10,0,0,0" Command="{Binding PrimaryStartCommand}"
|
||||||
|
Visibility="{Binding ShowPrimaryStartButton, Converter={StaticResource BoolToVis}}"/>
|
||||||
|
<!-- No separate TEST button (creator ruling 2026-10-30): EDIT decides whether
|
||||||
|
the ON-AIR start is the real livestream or the test one. -->
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<!-- Three-state top bar: logged-out / idle / live -->
|
<!-- RUNNING: one reality line (dot + word + elapsed) and the stop -->
|
||||||
|
<StackPanel Orientation="Horizontal"
|
||||||
|
Visibility="{Binding RunningVisible, Converter={StaticResource BoolToVis}}">
|
||||||
|
<Ellipse Width="12" Height="12" VerticalAlignment="Center"
|
||||||
|
Fill="{Binding RunningDotBrush}"/>
|
||||||
|
<TextBlock Text="{Binding RunningText}" FontSize="16" FontWeight="Bold"
|
||||||
|
Foreground="White" Margin="8,0,0,0" VerticalAlignment="Center"/>
|
||||||
|
<TextBlock Text="{Binding LiveElapsedText}" Foreground="White" FontSize="14"
|
||||||
|
FontFamily="Consolas" VerticalAlignment="Center" Margin="8,0,0,0"/>
|
||||||
|
<Button Content="{Binding EndButtonText}" Style="{StaticResource YtButton}"
|
||||||
|
Background="{StaticResource Brush.Primary}" Margin="14,0,0,0"
|
||||||
|
Command="{Binding EndStreamCommand}"/>
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- Account zone: lives only in the Stream world. Signed out → Sign In;
|
||||||
|
signed in → a big avatar of the streaming account (build 77 feedback:
|
||||||
|
"make the user icon much bigger"). Record world has no YouTube
|
||||||
|
identity at all. -->
|
||||||
<StackPanel Grid.Column="2" Orientation="Horizontal" VerticalAlignment="Center">
|
<StackPanel Grid.Column="2" Orientation="Horizontal" VerticalAlignment="Center">
|
||||||
<!-- Status light -->
|
<Button Content="Sign In" Style="{StaticResource YtButtonSecondary}"
|
||||||
<Ellipse Width="8" Height="8" Margin="0,0,8,0" VerticalAlignment="Center"
|
Margin="0,0,8,0" Command="{Binding SignInCommand}"
|
||||||
ToolTip="{Binding AccountStatusLightToolTip}">
|
ToolTip="Sign in to YouTube to go live."
|
||||||
<Ellipse.Style>
|
Visibility="{Binding ShowSignInButton, Converter={StaticResource BoolToVis}}"/>
|
||||||
<Style TargetType="Ellipse">
|
<Button Style="{StaticResource IconButton}" Margin="0,0,0,0"
|
||||||
<Setter Property="Fill" Value="#ef4444"/>
|
|
||||||
<Style.Triggers>
|
|
||||||
<DataTrigger Binding="{Binding IsConnected}" Value="True">
|
|
||||||
<Setter Property="Fill" Value="#22c55e"/>
|
|
||||||
</DataTrigger>
|
|
||||||
<DataTrigger Binding="{Binding IsLive}" Value="True">
|
|
||||||
<Setter Property="Fill" Value="#e94560"/>
|
|
||||||
</DataTrigger>
|
|
||||||
</Style.Triggers>
|
|
||||||
</Style>
|
|
||||||
</Ellipse.Style>
|
|
||||||
</Ellipse>
|
|
||||||
<!-- Avatar (connected only); shows the channel initial when
|
|
||||||
the profile image is missing or fails to download -->
|
|
||||||
<Border Width="26" Height="26" CornerRadius="13" Background="#16213e" ClipToBounds="True"
|
|
||||||
Margin="0,0,8,0" VerticalAlignment="Center"
|
|
||||||
ToolTip="{Binding AccountDisplayName}"
|
ToolTip="{Binding AccountDisplayName}"
|
||||||
Visibility="{Binding IsConnected, Converter={StaticResource BoolToVis}}">
|
Visibility="{Binding ShowAvatarButton, Converter={StaticResource BoolToVis}}">
|
||||||
<Grid>
|
|
||||||
<Image x:Name="AvatarImage" Stretch="UniformToFill"/>
|
|
||||||
<TextBlock x:Name="AvatarInitial" Foreground="#e0e0e0" FontSize="13"
|
|
||||||
FontWeight="Bold" HorizontalAlignment="Center" VerticalAlignment="Center"
|
|
||||||
Visibility="Collapsed"/>
|
|
||||||
</Grid>
|
|
||||||
</Border>
|
|
||||||
<!-- Sign In button (disconnected + offline) -->
|
|
||||||
<Button Content="Sign In" Style="{StaticResource YtButton}"
|
|
||||||
Margin="0,0,12,0"
|
|
||||||
Command="{Binding StartStreamCommand}"
|
|
||||||
Visibility="{Binding ShowLogInButton, Converter={StaticResource BoolToVis}}"/>
|
|
||||||
<!-- Primary Start button (offline): dynamic label from the pills -->
|
|
||||||
<Button Content="Start" Style="{StaticResource YtButton}"
|
|
||||||
Command="{Binding StartStreamCommand}"
|
|
||||||
Visibility="{Binding ShowPrimaryStartButton, Converter={StaticResource BoolToVis}}">
|
|
||||||
<Button.ContextMenu>
|
<Button.ContextMenu>
|
||||||
<ContextMenu>
|
<ContextMenu>
|
||||||
<MenuItem Header="Choose Record Folder" Command="{Binding ChooseRecordFolderCommand}"/>
|
|
||||||
<Separator/>
|
|
||||||
<MenuItem Header="Change Account" Command="{Binding ChangeAccountCommand}"/>
|
<MenuItem Header="Change Account" Command="{Binding ChangeAccountCommand}"/>
|
||||||
<Separator/>
|
<Separator/>
|
||||||
<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="{StaticResource Brush.SurfaceContainer}"
|
||||||
|
ClipToBounds="True" BorderBrush="{StaticResource Brush.Outline}" BorderThickness="1">
|
||||||
|
<Grid>
|
||||||
|
<Image x:Name="AvatarImage" Stretch="UniformToFill"/>
|
||||||
|
<TextBlock x:Name="AvatarInitial" Foreground="{StaticResource Brush.OnSurface}" FontSize="20"
|
||||||
|
FontWeight="Bold" HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||||
|
Visibility="Collapsed"/>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
</Button>
|
</Button>
|
||||||
<!-- End button (live OR recording) — the only button whose
|
|
||||||
label changes; "Stop Recording" while recording -->
|
|
||||||
<Button Content="{Binding EndButtonText}" Style="{StaticResource YtButton}"
|
|
||||||
Background="#333" Command="{Binding EndStreamCommand}"
|
|
||||||
Visibility="{Binding ShowEndStreamButton, Converter={StaticResource BoolToVis}}"/>
|
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Grid>
|
</Grid>
|
||||||
</UserControl>
|
</UserControl>
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Net.Http;
|
using System.Net.Http;
|
||||||
|
using System.Windows;
|
||||||
using System.Windows.Controls;
|
using System.Windows.Controls;
|
||||||
using System.Windows.Media.Imaging;
|
using System.Windows.Media.Imaging;
|
||||||
using ytLive.Helpers;
|
using ytLive.Helpers;
|
||||||
@@ -20,6 +21,20 @@ public partial class TopBar : UserControl
|
|||||||
AvatarInitial.Visibility = System.Windows.Visibility.Collapsed;
|
AvatarInitial.Visibility = System.Windows.Visibility.Collapsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <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)
|
||||||
|
{
|
||||||
|
if (sender is Button { ContextMenu: { } menu } button)
|
||||||
|
{
|
||||||
|
menu.PlacementTarget = button;
|
||||||
|
menu.Placement = System.Windows.Controls.Primitives.PlacementMode.Bottom;
|
||||||
|
menu.IsOpen = true;
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public void RefreshAvatar()
|
public void RefreshAvatar()
|
||||||
{
|
{
|
||||||
_ = LoadAvatarImageAsync();
|
_ = LoadAvatarImageAsync();
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# Controls — index
|
||||||
|
|
||||||
|
The phase-2 carved pieces of what used to be one monolithic `MainWindow.xaml`
|
||||||
|
(1,681 lines → a 123-line shell). Each is a `UserControl` in the `ytLive.Controls`
|
||||||
|
namespace, extracted one roll-back commit at a time (refactor-commit-12..18).
|
||||||
|
See [`schema.md`](../schema.md) for the memory-map conventions.
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `BottomBar.xaml(.cs)` | Footer (Row 3): intentionally empty, height-preserving spacer; stream readouts and resolution picker moved to the top-left cell in the 2026-10-26 surface redesign |
|
||||||
|
| `HealthBanner.xaml(.cs)` | Row 1 status banner + the 7 value→visibility converters live in the app theme, not here |
|
||||||
|
| `TopBar.xaml(.cs)` | Row 0 session surface: brand + gear menu (left), explicit `EDIT` toggle beside the REC|ON-AIR mode switch and world actions (Record: Start Recording · Stream: Go Live + Test), running-state reality line + End (center), account zone (right). Left-click gear opens the menu; right-click opens App Settings directly. `RefreshAvatar()`, `GearButton_Click` |
|
||||||
|
| `PreviewPane.xaml(.cs)` | Row 2 Col 1 center: 1920×1080 `OverlayCanvas`/`SelectionOverlay`/`BrandFlashLayer`/`SocialBarElement`, text drawer, outgoing-picture transition overlay, right-edge Stream Info/YPP/Socials/TEST tabs and drawers, and live controls (TRAX / game / mic meters + sliders + speakers). The list-selected draggable layer is temporarily raised in preview z-order and custom hit-testing; deselection restores the scene's unchanged order (`SelectedLayerPreviewHitTestingTests`). **TASK 57:** dragging/resizing a webcam in the vertical tier routes through `MainViewModel.MoveWebcamInVerticalTier` / `ResizeWebcamInVerticalTier` for the six-spot snap or Chat camera's two sizes. |
|
||||||
|
| `SceneThumbnailStrip.xaml(.cs)` | Top row over the preview column: five fixed-size scene cards with scene name, hotkey, staged `PROGRAM` badge, orange staged/live border |
|
||||||
|
| `LeftPanel.xaml(.cs)` | Row 2 Col 0 left panel: explicit EditingActive toggle on → Scene Layers+properties; off → YouTube chat, independent of stream status. Source add/edit/remove, list drag-to-reorder, snapshot revert-accept. **TASK 51:** the webcam properties carry the Background section — `RemoveBackgroundPill` / `BlurBackgroundPill` (`PillToggle`, radio-exclusive via `WebcamSceneConfig.RemoveBackground`/`BlurBackground`) + `BackgroundStrengthSlider` (0..1, 5% snaps, shown only while an effect is on). **TASK 57:** the webcam properties carry a Geometry section (X/Y/W/H) — manual placement of the camera, bound to the **live** placement (the one the current tier shows), gated on `IsWebcamSelected` (not `SelectedElement.IsWebcam` — the TASK 51 test finds that panel by binding text and would match twice); the Shape button's `Visibility` is bound to `CanToggleClipShape`, so the circle option is NOT OFFERED in the vertical Chat layout. **TASK 58:** every drop-down, check box and field in the properties panel carries the theme's style (`YtComboBox`/`YtCheckBox`/`YtTextBoxCompact`) and paints no colour of its own — the guard is `ytLive.Tests/PropertiesStyleTests.cs`. **TASK 59:** the webcam's properties are a HIERARCHY — a `Shape` title owns the mirror switch and the shape choice (`Rect` | `Circle` as two `SegmentToggle` options, Rect selected by default, the circle option hidden where `CanToggleClipShape` is false), and every titled block (Geometry, Border Color, Border Thickness, Background) owns its content one indent deeper (Strength sits under Blur). **TASK 60:** the border-colour drop-down builds its menu from `Models/BorderColorPalette.cs` — each row is the hex plus the colour's NAME (`#e94560 Crimson`, tooltip "Crimson (#e94560)"); the swatch icon is unchanged. **TASK 61:** the four geometry fields (X/Y/W/H) share one fixed width (44px — the largest value, 1920, plus a sign) and their rows set `LastChildFill="False"`, so Y/H no longer stretch to fill the row. **TASK 62:** the border-colour field shows the palette's name beside the hex (`SelectedElement.BorderColorName`, empty for a custom hex) and keeps a fixed width so the name stays next to the hex. **TASK 63:** the border colour is picked with the **app's own drop-down** (`YtComboBox`) — the `...` button and its code-built `ContextMenu` are gone; the closed box shows the swatch + the colour's NAME, the rows show swatch + hex + name (the panel's own `DataTemplate`s + `Helpers/ClosedBoxTemplateSelector.cs`), and the hex field stays for a custom colour; `IsDescendantOf` falls back to `LogicalTreeHelper.GetParent` / `TemplatedParent` when `VisualTreeHelper.GetParent` returns `null` (at `PopupRoot`) so clicking a popup row does not trigger `MainWindow`'s deselection guard |
|
||||||
|
| `OverlayHost.xaml(.cs)` | Root-grid overlays: App Settings / Report Bug / Feature Request / About; App Settings now groups recording, brand-logo, preview, and transition controls. `BackToAbout_Click` lives here |
|
||||||
|
| `SceneThumbnailStrip.xaml.cs` | Code-behind for the strip above |
|
||||||
|
| `LeftPanel.xaml.cs` | Code-behind for the panel above |
|
||||||
|
|
||||||
|
## Window ↔ control contract
|
||||||
|
|
||||||
|
The shell (`MainWindow.xaml(.cs)`) wires up each control and routes the few
|
||||||
|
cross-cutting calls through it:
|
||||||
|
|
||||||
|
- Ctor finds each control by `x:Name` (`TopBar`, `PreviewPane`, `LeftPanel`)
|
||||||
|
AFTER `InitializeComponent`, **before** any method touches it (null-ref guard).
|
||||||
|
- `_previewPane.IsClickInsidePreview/Drawer` + `_leftPanel.IsClickInside` →
|
||||||
|
deselection guard in `Window_PreviewMouseLeftButtonDown`.
|
||||||
|
- `_previewPane.UpdateSelectionOverlay(bool)` + `_leftPanel.OnSelectionChanged(vm)`
|
||||||
|
→ selection/edit-mode routing from the VM's `SelectedElement` change.
|
||||||
|
- `_topBar.RefreshAvatar()` on `IsConnected`/`AccountAvatarUrl` change.
|
||||||
|
- Handlers in moved controls cast `(DataContext as MainViewModel)` — controls
|
||||||
|
inherit the window's DataContext; never pass the VM through a property.
|
||||||
|
|
||||||
|
## Gotchas learned the hard way
|
||||||
|
|
||||||
|
- **A UserControl cannot see `Window.Resources`** (StaticResource resolves by
|
||||||
|
element lookup, so it stops at the control boundary). The 7 converters
|
||||||
|
(`BoolToVis`, `InverseBoolToVis`, `NotNullToVis`, `NullToVis`, `HiddenBoolToVis`,
|
||||||
|
`EnumToBool`, `AllTrueToVis`) were promoted from `MainWindow.Resources` into
|
||||||
|
`Themes/Controls.xaml` (see [`Themes/index.md`](../Themes/index.md)).
|
||||||
|
- **Moved XAML can lose its namespace**: a block cut from MainWindow carries no
|
||||||
|
root xmlns, so any `models:`/`Helpers:` ref in it breaks the build with
|
||||||
|
`MC2000: Value cannot be null (key)`. Declare `xmlns:models` / `xmlns:Helpers`
|
||||||
|
on each control's root (LeftPanel hit this).
|
||||||
|
- **Name-looked-up host stubs must stay in the window**, not a control:
|
||||||
|
`WebViewHostPanel` (ctor `InitWebView2(WebViewHostPanel)`) and `ToastArea`
|
||||||
|
(`NotificationService.InArea("ToastArea")`) are resolved by name from the
|
||||||
|
window root — so `OverlayHost` deliberately leaves them as thin window-root
|
||||||
|
children (Commit 18). Since the 2026-09-14 composition-capture redesign only
|
||||||
|
`ToastArea` remains — `WebViewHostPanel` is deleted (web sources no longer
|
||||||
|
host a multiline XAML control; `InitWebView2()` runs from `MainWindow_Loaded`
|
||||||
|
with no panel argument).
|
||||||
|
- When cutting visual wrappers, don't drop the outer `Border`'s
|
||||||
|
background/glow/margin — Commit 15 initially lost the preview's outer border
|
||||||
|
and had to restore it same-commit.
|
||||||
|
|
||||||
|
Related: [`ViewModels/index.md`](../ViewModels/index.md) (MainViewModel the
|
||||||
|
controls bind to), [`Themes/index.md`](../Themes/index.md) (styles/converters),
|
||||||
|
[`Helpers/index.md`](../Helpers/index.md) (ViewModelBase, converters), the
|
||||||
|
`MainWindow.xaml` shell at repo root that hosts these.
|
||||||
@@ -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,644 +1,144 @@
|
|||||||
# Distribution & IP Protection Plan
|
# Distribution & release mechanics
|
||||||
|
|
||||||
> 360-degree protection strategy for LlamaCasty (.NET 8 / WPF), built around Polar.sh.
|
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 twice on 2026-10-31. The 736-line Polar/MSIX/obfuscation plan is in
|
||||||
|
> `git log -- Distribution.md`; the zip-plus-winget plan written earlier the same day is in the
|
||||||
|
> same place. The reasoning that survives lives in `commercial.md` and `docs/ai/decisions.md`.
|
||||||
|
|
||||||
## Polar.sh as the Distribution Backbone
|
## 1. The artifact
|
||||||
|
|
||||||
Polar handles everything between "customer wants to pay" and "customer has a working license key." We don't build any of that. What Polar gives us:
|
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.
|
||||||
|
|
||||||
| Capability | How We Use It |
|
```bash
|
||||||
|------------|---------------|
|
dotnet publish ytLive.csproj -c Release -r win-x64 --self-contained true \
|
||||||
| **License key management** | Polar generates, validates, expires, rotates, and revokes keys. Activation limits (device caps) are built-in — no custom device registry. |
|
-p:PublishSingleFile=true \
|
||||||
| **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. |
|
-p:IncludeNativeLibrariesForSelfExtract=true \
|
||||||
| **Checkout** | Polar's hosted checkout page handles payment. No Stripe integration, no PCI scope, no custom checkout UI. |
|
-p:EnableCompressionInSingleFile=true \
|
||||||
| **Customer portal** | Customers self-serve: view/copy license keys, see expiry, deactivate devices, rotate compromised keys. We don't build a portal. |
|
-p:PublishReadyToRun=true -o artifacts/steam-stage
|
||||||
| **Subscriptions** | If we go subscription model (deferred), Polar handles recurring billing, dunning, cancellation. |
|
|
||||||
| **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.
|
|
||||||
|
|
||||||
**What Polar does 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` |
|
||||||
|
| DLC configuration | `partner.steamgames.com/doc/store/application/dlc` |
|
||||||
|
| `BIsDlcInstalled` | `partner.steamgames.com/doc/api/ISteamApps` |
|
||||||
|
| 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
|
1. **Steamworks account** → onboarding (NDA, identity, company details, tax interview, payout bank,
|
||||||
<PropertyGroup>
|
then the **$100 Steam Direct fee** per app). The store page cannot be configured before the fee.
|
||||||
<TargetFramework>net8.0-windows10.0.19041.0</TargetFramework>
|
2. **App IDs.** One **base app** (free) and one **DLC app** (the lifetime Pro). Note both numbers:
|
||||||
<OutputType>WinExe</OutputType>
|
the DLC app ID is what the app passes to `BIsDlcInstalled`.
|
||||||
<PublishSingleFile>true</PublishSingleFile>
|
3. **Depot.** One depot holding the publish output; the base app and the DLC share the install
|
||||||
<SelfContained>true</SelfContained>
|
directory, and the DLC package grants no files of its own — it is entitlement only.
|
||||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
4. **Steamworks SDK.** `./scripts/steam-sdk.sh ~/Downloads/steamworks_sdk_165.zip` extracts
|
||||||
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
|
`steam_api64.dll` from `sdk/redistributable_bin/win64/` into `steam/` (gitignored) and stages
|
||||||
<EnableCompressionInSingleFile>true</EnableCompressionInSingleFile>
|
`steam_appid.txt` = 480 — Valve's Spacewar app, so `SteamAPI.Init()` works locally before
|
||||||
<PublishTrimmed>true</PublishTrimmed>
|
LlamaCasty has an App ID. Add `--contentbuilder` on the machine that uploads a depot. The csproj
|
||||||
<TrimMode>partial</TrimMode> <!-- 'full' breaks WPF reflection; 'partial' is safe -->
|
copies the DLL **beside the exe** (`Link`) with `ExcludeFromSingleFile`, and copies
|
||||||
<AssemblyName>LlamaCasty</AssemblyName>
|
`steam_appid.txt` in Debug only — never into a depot. The managed side is the `Steamworks.NET`
|
||||||
<Version>0.9.0</Version>
|
package (`SteamApps.BIsDlcInstalled`).
|
||||||
<Company>LlamaCasty</Company>
|
5. **Store page** configured and made public **two weeks before release** — Valve's rule, and it is
|
||||||
<Product>LlamaCasty</Product>
|
the window where wishlists accumulate.
|
||||||
</PropertyGroup>
|
|
||||||
```
|
|
||||||
|
|
||||||
**Key decisions:**
|
## 3. Release
|
||||||
- `PublishTrimmed` + `TrimMode=partial`: Trims unused BCL code but preserves WPF infrastructure. `full` trimming breaks `Binding`, `DataTemplate`, and XAML reflection.
|
|
||||||
- `PublishSingleFile`: One `.exe` to distribute. Self-contained = no .NET runtime dependency on user machines.
|
|
||||||
- `win-x64`: Target the dominant platform. ARM64 is a separate RID if needed later.
|
|
||||||
|
|
||||||
### 2.2 Obfuscator Integration (est. 2–3 days)
|
|
||||||
|
|
||||||
**Recommended: Eazfuscator.NET** (free for projects under $1M revenue, excellent WPF support, .NET 8 compatible).
|
|
||||||
|
|
||||||
Alternative commercial: **ArmDot** ($399/yr, better control flow, built-in anti-tamper).
|
|
||||||
|
|
||||||
**Integration as post-publish step:**
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
# publish.ps1 — called after dotnet publish
|
|
||||||
param([string]$PublishDir = "publish")
|
|
||||||
|
|
||||||
dotnet publish ytLive.csproj -c Release -r win-x64 `
|
|
||||||
--self-contained true `
|
|
||||||
-p:PublishSingleFile=true `
|
|
||||||
-p:PublishTrimmed=true `
|
|
||||||
-p:TrimMode=partial `
|
|
||||||
-o $PublishDir
|
|
||||||
|
|
||||||
# Obfuscate the Services assembly (core business logic)
|
|
||||||
eazfuscator.exe "$PublishDir\LlamaCasty.Services.dll" `
|
|
||||||
--target-framework net8.0 `
|
|
||||||
--Renaming-mode unprintable `
|
|
||||||
--Control-flow-obfuscation true `
|
|
||||||
--String-encryption true `
|
|
||||||
--Resource-encryption true
|
|
||||||
|
|
||||||
# Obfuscate the Core assembly (lighter touch)
|
|
||||||
eazfuscator.exe "$PublishDir\LlamaCasty.Core.dll" `
|
|
||||||
--target-framework net8.0 `
|
|
||||||
--Renaming-mode unprintable
|
|
||||||
|
|
||||||
# Do NOT obfuscate LlamaCasty.exe (WPF entry point + XAML)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2.3 XAML/BAML Handling (est. 1 day research)
|
|
||||||
|
|
||||||
**Problem:** WPF compiles XAML into BAML (binary XAML) embedded as resources. Obfuscators that rename types break BAML because it references types by assembly-qualified name.
|
|
||||||
|
|
||||||
**Solution:**
|
|
||||||
- Do NOT obfuscate the main `.exe` assembly (contains all BAML).
|
|
||||||
- Only obfuscate `Core` and `Services` DLLs (no XAML).
|
|
||||||
- If an obfuscator must touch the exe: use Eazfuscator.NET's `[Obfuscation(Exclude = true, Feature = "XAML")]` attribute on types referenced in BAML, or configure the obfuscator to skip the `LlamaCasty.g.resources` manifest resource.
|
|
||||||
|
|
||||||
**Testing:** After obfuscation, launch the app and verify:
|
|
||||||
- All screens render correctly
|
|
||||||
- `Binding` expressions resolve (no blank/missing data)
|
|
||||||
- `DataContext` inheritance works
|
|
||||||
- Storyboards/animations play
|
|
||||||
- `ContextMenu` items bind correctly
|
|
||||||
- Dialogs open/close properly
|
|
||||||
|
|
||||||
### 2.4 CI/CD Pipeline (est. 1 day)
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# .github/workflows/release.yml
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: actions/setup-dotnet@v4
|
|
||||||
with:
|
|
||||||
dotnet-version: 8.0.x
|
|
||||||
|
|
||||||
- name: Test
|
|
||||||
run: dotnet test --no-restore -c Release
|
|
||||||
|
|
||||||
- name: Publish
|
|
||||||
run: dotnet publish ytLive.csproj -c Release -r win-x64 --self-contained -p:PublishSingleFile=true -p:PublishTrimmed=true -p:TrimMode=partial -o publish
|
|
||||||
|
|
||||||
- name: Obfuscate
|
|
||||||
run: |
|
|
||||||
eazfuscator.exe publish/LlamaCasty.Services.dll --target-framework net8.0 --Renaming-mode unprintable --Control-flow-obfuscation true --String-encryption true
|
|
||||||
eazfuscator.exe publish/LlamaCasty.Core.dll --target-framework net8.0 --Renaming-mode unprintable
|
|
||||||
|
|
||||||
- name: Sign
|
|
||||||
run: |
|
|
||||||
$cert = Get-Content -Raw -Path secrets/certificate.pfx | ConvertTo-SecureString -AsPlainText -Force
|
|
||||||
Set-AuthenticodeSignature -FilePath publish/LlamaCasty.exe -Certificate $cert -TimestampServer http://timestamp.digicert.com -HashAlgorithm SHA256
|
|
||||||
|
|
||||||
- name: Upload to Polar
|
|
||||||
run: |
|
|
||||||
# Upload the signed .exe to Polar as a file download benefit
|
|
||||||
# Update the product's file benefit with the new version
|
|
||||||
curl -X POST "https://api.polar.sh/v1/products/{product_id}/benefits" \
|
|
||||||
-H "Authorization: Bearer $POLAR_ACCESS_TOKEN" \
|
|
||||||
-F "type=file_download" \
|
|
||||||
-F "file=@publish/LlamaCasty.exe"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Obfuscation Strategy
|
|
||||||
|
|
||||||
### 3.1 Recommended Obfuscator
|
|
||||||
|
|
||||||
**Eazfuscator.NET** (primary):
|
|
||||||
- Free for projects under $1M revenue — fits solo dev.
|
|
||||||
- Excellent WPF support: knows to skip BAML resources, handles `INotifyPropertyChanged` patterns.
|
|
||||||
- .NET 8 support confirmed.
|
|
||||||
- Symbol renaming (unprintable Unicode), control flow obfuscation, string encryption, resource encryption.
|
|
||||||
- Simple CLI integration.
|
|
||||||
|
|
||||||
**ArmDot** (commercial, $399/yr):
|
|
||||||
- Better control flow (virtualization of critical methods).
|
|
||||||
- Anti-tamper and anti-debug built in.
|
|
||||||
- Better at handling complex WPF scenarios.
|
|
||||||
|
|
||||||
**What NOT to use:**
|
|
||||||
- ConfuserEx — abandoned, doesn't support .NET 8.
|
|
||||||
- Obfuscar — limited WPF support, BAML breakage issues.
|
|
||||||
- Dotfuscator Community — too basic, no string encryption.
|
|
||||||
|
|
||||||
### 3.2 What to Obfuscate
|
|
||||||
|
|
||||||
| Technique | Where | Effect |
|
|
||||||
|-----------|-------|--------|
|
|
||||||
| Symbol renaming (unprintable) | `Services.dll`, `Core.dll` | All type/method/field names → Unicode garbage. 10x decompilation difficulty. |
|
|
||||||
| Control flow obfuscation | `Services.dll` | Flattens `if/else/switch` into state machines. Decompiler produces spaghetti. |
|
|
||||||
| String encryption | `Services.dll` | All string literals encrypted; decrypted at runtime. Hides API URLs, error messages, config values. |
|
|
||||||
| Resource encryption | `Services.dll` | Embedded resources encrypted. Prevents extraction of embedded data. |
|
|
||||||
| Anti-tamper | `Services.dll` | CRC check on method bodies at runtime. Detects binary patching. |
|
|
||||||
|
|
||||||
### 3.3 What to Exclude
|
|
||||||
|
|
||||||
| Item | Reason |
|
|
||||||
|------|--------|
|
|
||||||
| `LlamaCasty.exe` (WPF entry point) | BAML resources break if types are renamed. |
|
|
||||||
| All ViewModels | `Binding Path=` resolves property names at runtime. |
|
|
||||||
| All Models/DTOs | SQLite column mapping uses property names as strings. |
|
|
||||||
| `LayoutStore` (public interface) | Called by ViewModels via interface — renaming breaks DI. |
|
|
||||||
| All `IValueConverter` implementations | XAML references converters by type name in `StaticResource`. |
|
|
||||||
| All `MarkupExtension` subclasses | XAML parser resolves by type name. |
|
|
||||||
| `App.xaml.cs` entry point | Must be discoverable. |
|
|
||||||
|
|
||||||
### 3.4 Testing Obfuscated Build (est. 1 day per release)
|
|
||||||
|
|
||||||
**Automated checks:**
|
|
||||||
1. App launches without crash (startup log at `%APPDATA%\ytLlive\startup.log`).
|
|
||||||
2. All 5 scenes load (Starting/Live/BRB/Chat/Ending).
|
|
||||||
3. Social bar renders with entries.
|
|
||||||
4. TRAX button click opens file picker.
|
|
||||||
5. Recording starts/stops correctly.
|
|
||||||
6. YouTube OAuth flow completes (if signed in).
|
|
||||||
7. Settings panel opens from Gear menu.
|
|
||||||
8. All 247 unit tests pass against the unobfuscated assemblies (tests never run against obfuscated code).
|
|
||||||
|
|
||||||
**Manual smoke test:**
|
|
||||||
- Start a recording, verify file appears in configured folder.
|
|
||||||
- Open social dialog, add/edit/delete a handle.
|
|
||||||
- Toggle mic mute, verify meter responds.
|
|
||||||
- Switch scenes via thumbnail strip.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Distribution Artifacts
|
|
||||||
|
|
||||||
### 4.1 Files to Ship
|
|
||||||
|
|
||||||
```
|
```
|
||||||
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 from a clean Steam account, confirm `BIsDlcInstalled` returns false, buy the
|
||||||
- User drops it anywhere and runs it.
|
DLC (or use a test licence), confirm the high tiers unlock, then refund the test purchase.
|
||||||
|
|
||||||
**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
|
|
||||||
|
## 4. Entitlement wiring
|
||||||
**Product setup on Polar:**
|
|
||||||
|
- `IEntitlement` (`Services/Entitlement/`) has one method, `IsPro`. **Steam DLC** answers it today
|
||||||
1. Create a product: "LlamaCasty" — one-time purchase (or subscription if deferred).
|
(`SteamEntitlement`, `Steamworks.NET` + `steam_api64.dll`); the **Polar** monthly rail plugs in
|
||||||
2. Add two benefits:
|
beside it. Resolution is `Pro = steamDlc || polarLease`, then `MainViewModel.HasProOutput` adds
|
||||||
- **License Key** — brandable prefix `LLAMA-****`, activation limit (e.g., 3 devices), optional expiry.
|
the Debug-only `DeveloperMode`; anything unresolved is the free profile.
|
||||||
- **File Download** — upload the signed `LlamaCasty.exe` (~80-120MB). Polar generates signed, personal download URLs. SHA-256 checksum included.
|
- **`SteamEntitlement.NotConfigured()` is the shipping default** until the DLC app id exists — it
|
||||||
|
answers false without touching the native library. Set the id when Steamworks creates the DLC.
|
||||||
**Customer flow:**
|
- **Fail closed on every path:** no id, no client, missing DLL, thrown load, not owned.
|
||||||
1. Customer visits `buy.polar.sh/polar_cl_...` (the Premium URL already in `MainViewModel.PremiumUrl`).
|
- **Never sell the monthly inside the Steam build.** Steamworks requires in-app purchases to use
|
||||||
2. Customer pays via Polar checkout.
|
the Steam Wallet; the app may *state* that monthly plans exist on the site, with no checkout,
|
||||||
3. Polar generates a license key + provides a signed download URL.
|
no price and no payment field. Confirm the wording with Steamworks support before shipping it.
|
||||||
4. Customer downloads `LlamaCasty.exe`, runs it, enters license key in Settings.
|
- **Never gate a feature.** The only delta is the output profile.
|
||||||
5. App calls `PolarLicenseService.ValidateAsync(key)` → Polar API validates → `IsValid = true`.
|
|
||||||
6. Premium features unlock locally (persisted in `LayoutStore.Settings`).
|
## 5. Release checklist
|
||||||
|
|
||||||
**Activation limits:**
|
- Build clean, 0 warnings (`./scripts/verify.sh`), tests green.
|
||||||
- Polar supports `limit_activations` per license key (e.g., 3 devices).
|
- `steam_api64.dll` present; app starts **without** Steam and falls back to the free profile.
|
||||||
- Client calls `/v1/customer-portal/license-keys/activate` before first validation.
|
- DLC ownership false → free profile; true → every tier offered.
|
||||||
- Polar tracks activations; customer can deactivate via Polar's customer portal.
|
- `LlamaCasty.exe` rename landed (never rename a live exe).
|
||||||
- No custom device registry needed — Polar handles it.
|
- Clean Steam install on a second machine, then an update pushed and observed.
|
||||||
|
- Store page public for the full two weeks; support contact and announcements configured.
|
||||||
**Key rotation:**
|
- Portable mode: `portable.txt` present → data lands in `.\data\`.
|
||||||
- If a key is compromised, customer or merchant can rotate it via Polar.
|
|
||||||
- Previous key stops validating immediately. Status, usage, limits, expiry preserved.
|
## 6. Legal and takedown
|
||||||
|
|
||||||
### 4.3 Code Signing
|
- **`LICENSE`** (EULA) forbids redistribution, resale, reverse-engineering and circumvention, and
|
||||||
|
disclaims warranty.
|
||||||
**Certificate:** Buy an EV (Extended Validation) code-signing certificate from DigiCert or Sectigo ($200-400/year). EV certificates get instant SmartScreen reputation; DV/OV certs trigger SmartScreen warnings for months.
|
- **`PRIVACY.md`** must name any third party the app contacts — Google/YouTube today, **Polar** the
|
||||||
|
day licence validation ships. Steam itself is named by Valve's own policy, not ours.
|
||||||
**Signing process:**
|
- **`THIRD-PARTY-NOTICES.txt`** stays shipped and shown in About; the ffmpeg pin stays
|
||||||
```powershell
|
`lgpl-shared`; no GPL component may be linked (see `product.md` → "Licensing").
|
||||||
$cert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2(
|
- **DMCA**: a takedown needs only the notice — host, link, good-faith statement, signature.
|
||||||
"path\to\certificate.pfx",
|
Statutory damages need a registration ($65). Worth doing once there is something to pirate.
|
||||||
"password",
|
- **What actually protects the product** is in `commercial.md` → "Anti-piracy posture": a free tier
|
||||||
[System.Security.Cryptography.X509Certificates.X509KeyStorageFlags]::Exportable
|
good enough that a crack buys nothing, Steam's own delivery, short Polar leases, and a licence ID
|
||||||
)
|
in About. **Not obfuscation, not a native DLL, not hardware locking.**
|
||||||
Set-AuthenticodeSignature -FilePath "LlamaCasty.exe" `
|
|
||||||
-Certificate $cert `
|
## 7. Effort, once
|
||||||
-TimestampServer "http://timestamp.digicert.com" `
|
|
||||||
-HashAlgorithm SHA256
|
| Step | Effort |
|
||||||
```
|
|---|---|
|
||||||
|
| Steamworks onboarding + $100 fee | 0.5–1 day, mostly waiting on verification |
|
||||||
**Storage:** Certificate `.pfx` file stored in GitHub Secrets (or Azure DevOps secure files). Never committed to the repo.
|
| Publish profile + `steam_api64.dll` + `IEntitlement` Steam path | 1 day |
|
||||||
|
| Depot + first `steamcmd` upload | 0.5 day, then ~15 min per release |
|
||||||
### 4.4 Update Mechanism
|
| Store page + assets (screenshots, capsule art) | 1–2 days |
|
||||||
|
| Clean-account install + DLC purchase/refund test | 0.5 day |
|
||||||
**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 (already implemented)
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
// Services/PolarLicenseService.cs — already ships
|
|
||||||
// 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's already done:**
|
|
||||||
- `PolarLicenseService` validates keys against Polar API.
|
|
||||||
- `OrganizationId` is hardcoded (safe — it's public knowledge, tied to our Polar account).
|
|
||||||
- License state persists in `LayoutStore.Settings` (SQLite) with offline grace period.
|
|
||||||
- `PremiumUrl` points 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,88 +1,40 @@
|
|||||||
# HANDOFF — Session State
|
# HANDOFF — 2026-11-01 (exe renamed, depot builds, output rule enforced)
|
||||||
|
|
||||||
## Branch / Commit State
|
## State
|
||||||
|
|
||||||
**`main`**, working tree is **CLEAN**.
|
- Branch `main`, level with `origin` through `8f79b20`; this unit is local.
|
||||||
|
- **The shipped exe is `LlamaCasty.exe`** (`AssemblyName` renamed; four pack URIs fixed — the docs
|
||||||
|
said three, `KeepBroadcastDialog.xaml:5` was the fourth). Guarded by `AssemblyIdentityTests`.
|
||||||
|
- **`scripts/publish.sh`** stages `artifacts/steam-stage` and refuses to finish if `ffmpeg\`, the
|
||||||
|
licence, privacy or notices file is missing, or test assemblies leaked in. Output 257 MB.
|
||||||
|
- **The output-profile rule is built and enforced**: `Models/OutputProfile.cs` (921,600 px / 30 fps,
|
||||||
|
fail closed) resolved once in `BuildEncoderOptions` — one ffmpeg process, two outputs.
|
||||||
|
`MainViewModel.DeveloperMode` bypasses it in Debug only.
|
||||||
|
- **Distribution is Steam-exclusive** (2026-10-31): free app + paid edition as a Steam **DLC**; the
|
||||||
|
**$10/month** plan sold off-Steam by Polar, *stated, never sold*, in-app. Entitlement is
|
||||||
|
`steamDlcOwned || polarKeyValid`; the app must start without Steam, falling back to free.
|
||||||
|
- **Site live with Steam** (`a9d07ce`); `www.llamacasty.com` 301s to the apex.
|
||||||
|
|
||||||
Commits `46978b8` (AGENTS push policy), `fc33022` (HANDOFF), and refactor
|
## Next action
|
||||||
commits `0–5` through `e286719` are **pushed** (incl. `refactor-commit-0..5`).
|
|
||||||
Refactor commits **6–11 are LOCAL ONLY** (policy 2026-08-30: no per-commit
|
|
||||||
push; `git push --tags` only at a user-agreed milestone / "push it"):
|
|
||||||
|
|
||||||
| Commit | Hash | Tag | Partial | Lines |
|
**Surge, 2026-11-02 → 11-07 — six days, all remaining code.** Day 1 is partly done ahead of time:
|
||||||
|--------|------|-----|---------|-------|
|
`IEntitlement` + `SteamEntitlement` (Steamworks.NET, fail-closed on every path) + `FakeEntitlement`,
|
||||||
| 6 Audio | `c1126a5` | `refactor-commit-6` | `MainViewModel.Audio.cs` | 408 |
|
with `HasProOutput` reading the rail. Undo/redo, the rail gear and the EDIT alignment also landed
|
||||||
| 7 Trax | `535a501` | `refactor-commit-7` | `MainViewModel.Trax.cs` | 132 |
|
early. Remaining on day 1: greyed tiers, licence UI's four states, system messages, licence ID in
|
||||||
| 8 Socials | `c41ff7f` | `refactor-commit-8` | `MainViewModel.Socials.cs` | 161 |
|
About. Days 2–6 in `TASKS.md` → "The surge".
|
||||||
| 9 Streaming | `e1006a0` | `refactor-commit-9` | `MainViewModel.Streaming.cs` | 787 |
|
|
||||||
| 10 Chat | `3b77a13` | `refactor-commit-10` | `MainViewModel.Chat.cs` | 194 |
|
|
||||||
| 11 Overlays | `1efde5a` | `refactor-commit-11` | `MainViewModel.Overlays.cs` | 194 |
|
|
||||||
|
|
||||||
Nightly summary: **Phase 1 complete.** The plan was 11 partials + scaffold;
|
|
||||||
all live in `ViewModels/`. Core `MainViewModel.cs` shrank 1,890 → **758
|
|
||||||
lines**. Every change: clean build (0 warnings), `scripts/verify.sh` (246
|
|
||||||
passed + 2 known failures only), `scripts/scope-check.sh` with explicit file
|
|
||||||
args, `ViewModels/index.md` updated in the SAME commit, tagged
|
|
||||||
`refactor-commit-N`, no per-commit push.
|
|
||||||
|
|
||||||
## What's In Flight
|
|
||||||
|
|
||||||
Nothing code-in-flight — working tree clean, Phase 1 done.
|
|
||||||
|
|
||||||
**Next (queued, not started): Phase 2 — split `MainWindow.xaml` (1,681 lines)
|
|
||||||
into UserControls.** Same rules: one work unit per control, docs in the same
|
|
||||||
commit, verify + scope-check, commit locally, `git push` only on user go.
|
|
||||||
|
|
||||||
**Push pending:** refactor commits 6–11 + their tags are local. The Session-1
|
|
||||||
push checkpoint was never reached because the user directive was to run the
|
|
||||||
series to completion; Phase 1 completion is the natural milestone to push on
|
|
||||||
explicit user confirmation.
|
|
||||||
|
|
||||||
## Landmines
|
## Landmines
|
||||||
|
|
||||||
- **Pre-existing test failures (do not chase):**
|
- **⛔ `ExcludeFromSingleFile="true"` on the ffmpeg content** — without it the publisher bundles
|
||||||
`AudioPipelineTests.Mix_HonorsProviderGains_AndGameMute_KillsTheLoopback` +
|
libav\*.dll into the exe and the app ships unable to find ffmpeg. Recipe in `MyMistakes.md`.
|
||||||
`RoundClipInteractionTests.Round_Clip_Corner_Is_Grabbable_And_Shape_Is_Circle`.
|
- **Dev affordances are compiled out, not hidden** — `DeveloperPanel` is removed from the Release
|
||||||
- **Tests occasionally abort early (236/237)** — known flake, accepted by `verify.sh`.
|
build; `Helpers/DevBuild.cs` is the single source for the flag (never `const`).
|
||||||
- **App running locks `ytLive.exe`** → build fails MSB3027. Kill PID before building.
|
- **Nothing may gate a feature.** The only paid delta is the output profile; `ShowBrandFlash` stays
|
||||||
- **Implicit usings are SDK-default but partials must still carry their own
|
`false` and entitlement must never enable it.
|
||||||
domain usings** (they do not inherit core's): `ICommand`/`ObservableCollection`/
|
- **No checkout inside a Steam build** — the Wallet rule. The monthly is named, never sold, in-app.
|
||||||
`WriteableBitmap`/`DispatcherTimer` etc. are NOT implicit. Grep-verified usings:
|
- **The org access token never ships.** `organization_id` is public; the token is not.
|
||||||
- Audio: Input, Media, Threading, `NAudio.CoreAudioApi`, `ytLive`, Helpers, Models, Services, Services.Audio
|
- **The site publishes the model, never the numbers**, and never takes payment or links a checkout.
|
||||||
- Trax: IO, Media, `Microsoft.Win32`, Helpers, Models, Services.Audio
|
- **No self-updater and no version banner**: Steam updates the app.
|
||||||
- Socials: Windows, Input, Media, `ytLive`, Helpers, Models, Services, Services.Compositor
|
- Pre-existing: the **full test suite hangs** (WASAPI teardown) — per-class runs; `ytLive.Tests`
|
||||||
- Streaming: ObjectModel, IO, Windows, Input, Threading, `Microsoft.Win32`, Helpers, Models, Services, Services.Encoder
|
carries 3 xUnit warnings the app-only gate does not see.
|
||||||
- Chat: ObjectModel, Media, Media.Imaging, Threading, Helpers, Models, Services
|
- Not every hex is a theme colour — `BorderColorPalette`/chat defaults are USER DATA, not tokens.
|
||||||
- Overlays: Diagnostics, IO, Input, Helpers
|
|
||||||
- **Pre-commit gate:** `scripts/verify.sh` (0-warning build + full tests) and
|
|
||||||
`scripts/scope-check.sh "file1" …` — scope-check REQUIRES explicit file args.
|
|
||||||
- Build from WSL with the Windows dotnet host only (see `AGENTS.md`).
|
|
||||||
|
|
||||||
## Todo state
|
|
||||||
|
|
||||||
- MainViewModel refactor: **Phase 1 complete (11/11 partials + scaffold).**
|
|
||||||
`ViewModels/index.md` has a per-partial table row (code↔commit map in the
|
|
||||||
tracker section). Core row now describes only what remains (ctor wiring,
|
|
||||||
lifecycle, source commands, WebView2 hosting, frame-pump seam,
|
|
||||||
`SelectedElement`, `BrandFlash*`, `BuildCompositorOptions`/`ResolveOutputFrame`).
|
|
||||||
- TASK 3: 27/30 (preview compositor 16, text source 17, alerts 20 still open).
|
|
||||||
- TASK 4: ✅ shipped. TASK 9: items 1–3 shipped; 4–7 open. TASK 10: steps 1–7; Velopack pending.
|
|
||||||
- TASK 18: shipped, creator verification pending. TASK 19/23, 20, 21-A: shipped.
|
|
||||||
- TASK 22 (audio sync offset): not started — required for v1.
|
|
||||||
- Monitoring: cross-partial private access is the point of partial classes —
|
|
||||||
no moved member re-declared anywhere; moved setters reference other partials
|
|
||||||
purely by name.
|
|
||||||
|
|
||||||
## Session summary (2026-08-30)
|
|
||||||
|
|
||||||
- Ran the MainViewModel split to completion under the standing directive
|
|
||||||
("continue until you can continue no longer" — no interruption prompts).
|
|
||||||
- Commits 9 (Streaming), 10 (Chat), 11 (Overlays) landed locally, each ~8:40
|
|
||||||
back-to-back: cut → 0-warning build → verify (246 pass + 2 known) →
|
|
||||||
scope-check → index.md in-commit → `git tag refactor-commit-N`. No push.
|
|
||||||
- Streaming was the hardest cut (interleaved props, master-frame consts,
|
|
||||||
session timer, health pipeline, record rename-on-stop); Chat and Overlays
|
|
||||||
were clean contiguous cuts. One build fix each: Streaming needed
|
|
||||||
`ObjectModel`/`Input`, Chat needed `Media.Imaging` for `WriteableBitmap`.
|
|
||||||
- Docs kept honest throughout: `index.md` rows for Streaming/Chat/Overlays,
|
|
||||||
About-hub row re-pointed to the Overlays partial, core row trimmed each time.
|
|
||||||
|
|||||||
@@ -5,26 +5,40 @@ namespace ytLive.Helpers;
|
|||||||
/// <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
|
||||||
/// %APPDATA%\ytLlive\startup.log, appending synchronously so entries survive
|
/// %APPDATA%\ytLlive\startup.log (or a per-instance subfolder in DEBUG — see
|
||||||
/// a hard crash.
|
/// InstanceProfile), appending synchronously so entries survive a hard crash.
|
||||||
/// </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>
|
||||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
private const long DefaultMaxBytes = 5 * 1024 * 1024;
|
||||||
"ytLlive",
|
|
||||||
"startup.log");
|
/// <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");
|
||||||
|
|
||||||
private static readonly object Gate = new();
|
private static readonly object Gate = new();
|
||||||
|
|
||||||
|
/// <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;
|
||||||
|
|
||||||
public static void Write(string message)
|
public static void Write(string message)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
lock (Gate)
|
lock (Gate)
|
||||||
{
|
{
|
||||||
Directory.CreateDirectory(Path.GetDirectoryName(LogPath)!);
|
var path = LogPath;
|
||||||
File.AppendAllText(LogPath, $"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff} {message}{Environment.NewLine}");
|
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||||
|
LogRotation.RollIfNeeded(path, MaxBytes, RetainedFiles);
|
||||||
|
File.AppendAllText(path, $"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff} {message}{Environment.NewLine}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
|
|||||||
@@ -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,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,123 @@
|
|||||||
|
using System;
|
||||||
|
using System.Diagnostics;
|
||||||
|
|
||||||
|
namespace ytLive.Helpers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Arrival cadence for one window of a timestamped frame stream: how many frames
|
||||||
|
/// arrived, how far apart they were, and how many gaps were long enough to read
|
||||||
|
/// as a hitch. Fed any shared monotonic clock (<see cref="Stopwatch.GetTimestamp"/>
|
||||||
|
/// values, or a device's system-relative stamps on the same clock) and read through
|
||||||
|
/// <see cref="Summarize"/>.
|
||||||
|
///
|
||||||
|
/// Why it exists: a 60fps container makes a missing camera frame indistinguishable
|
||||||
|
/// from a padded repeat. rawvideo carries no per-frame timestamps — ffmpeg stamps
|
||||||
|
/// frames by ARRIVAL at the declared rate — so the OBS "duplicate on lag" loop
|
||||||
|
/// (FramePump) emits 300 authored frames per 5s whether the renderer managed 300
|
||||||
|
/// composites or 84. The recorded file therefore cannot name the stage that lost
|
||||||
|
/// the frames; only these counters can. The same counter run twice tells the two
|
||||||
|
/// apart: the camera's OWN timestamps vs our ARRIVAL timestamps. Steady camera
|
||||||
|
/// stamps with ragged arrivals means WE dropped it (compositor/reader); ragged in
|
||||||
|
/// both means the camera, USB link, or driver did.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FrameCadence
|
||||||
|
{
|
||||||
|
/// <summary>Default hitch threshold in ms. 50ms is ~1.5x a 30fps period — a
|
||||||
|
/// missed 30fps frame arrives as ~66ms — so this counts dropped frames without
|
||||||
|
/// flagging ordinary jitter (a 33ms frame can legitimately measure 35ms).</summary>
|
||||||
|
public const double DefaultHitchThresholdMs = 50;
|
||||||
|
|
||||||
|
private readonly double _hitchThresholdMs;
|
||||||
|
private long _firstTicks;
|
||||||
|
private long _previousTicks;
|
||||||
|
private double _gapTicksTotal;
|
||||||
|
private int _frames;
|
||||||
|
private int _gaps;
|
||||||
|
private int _hitches;
|
||||||
|
private double _longestGapMs;
|
||||||
|
|
||||||
|
public FrameCadence(double hitchThresholdMs = DefaultHitchThresholdMs)
|
||||||
|
{
|
||||||
|
if (hitchThresholdMs <= 0)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(hitchThresholdMs), hitchThresholdMs,
|
||||||
|
"Hitch threshold must be positive.");
|
||||||
|
_hitchThresholdMs = hitchThresholdMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int Frames => _frames;
|
||||||
|
public int Gaps => _gaps;
|
||||||
|
public int Hitches => _hitches;
|
||||||
|
public double HitchThresholdMs => _hitchThresholdMs;
|
||||||
|
|
||||||
|
/// <summary>Records one frame at <paramref name="timestampTicks"/> on the shared
|
||||||
|
/// monotonic clock. Monotonicity is asserted, not trusted: an out-of-order stamp
|
||||||
|
/// (a device whose clock domain differs from ours, a wrapped counter, a dropped
|
||||||
|
/// timestamp) is counted as a frame but produces no gap AND does not become the
|
||||||
|
/// reference for the next one — otherwise a single bad stamp would poison both
|
||||||
|
/// its own interval and the one after it, inventing a multi-second "hitch".</summary>
|
||||||
|
public void Add(long timestampTicks)
|
||||||
|
{
|
||||||
|
if (_frames == 0)
|
||||||
|
{
|
||||||
|
_firstTicks = timestampTicks;
|
||||||
|
_previousTicks = timestampTicks;
|
||||||
|
_frames++;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var delta = timestampTicks - _previousTicks;
|
||||||
|
_frames++;
|
||||||
|
if (delta <= 0) return;
|
||||||
|
var gapMs = delta * 1000d / Stopwatch.Frequency;
|
||||||
|
_gaps++;
|
||||||
|
_gapTicksTotal += gapMs;
|
||||||
|
if (gapMs > _longestGapMs) _longestGapMs = gapMs;
|
||||||
|
if (gapMs >= _hitchThresholdMs) _hitches++;
|
||||||
|
_previousTicks = timestampTicks;
|
||||||
|
}
|
||||||
|
|
||||||
|
public FrameCadenceSummary Summarize()
|
||||||
|
{
|
||||||
|
var spanMs = _frames >= 2
|
||||||
|
? (_previousTicks - _firstTicks) * 1000d / Stopwatch.Frequency
|
||||||
|
: 0d;
|
||||||
|
// Rate is gaps-per-span, not frames-per-span: N frames span N-1 periods, and
|
||||||
|
// counting the extra interval would report one extra frame per window.
|
||||||
|
var rate = _gaps > 0 && spanMs > 0 ? _gaps / (spanMs / 1000d) : 0d;
|
||||||
|
return new FrameCadenceSummary(
|
||||||
|
_frames, rate, spanMs, _gaps, _hitches, _longestGapMs,
|
||||||
|
_gaps > 0 ? _gapTicksTotal / _gaps : 0d, _hitchThresholdMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Starts a fresh window, keeping the configured threshold.</summary>
|
||||||
|
public void Reset()
|
||||||
|
{
|
||||||
|
_firstTicks = 0;
|
||||||
|
_previousTicks = 0;
|
||||||
|
_gapTicksTotal = 0;
|
||||||
|
_frames = 0;
|
||||||
|
_gaps = 0;
|
||||||
|
_hitches = 0;
|
||||||
|
_longestGapMs = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>One measured cadence window — see <see cref="FrameCadence"/>.</summary>
|
||||||
|
public readonly record struct FrameCadenceSummary(
|
||||||
|
int Frames,
|
||||||
|
double RatePerSecond,
|
||||||
|
double SpanMs,
|
||||||
|
int Gaps,
|
||||||
|
int Hitches,
|
||||||
|
double LongestGapMs,
|
||||||
|
double AverageGapMs,
|
||||||
|
double HitchThresholdMs)
|
||||||
|
{
|
||||||
|
/// <summary>One-line log form. Identical in shape from every producer (camera
|
||||||
|
/// source, frame pump) so two lines can be read against each other: same window
|
||||||
|
/// length, same fields, same order.</summary>
|
||||||
|
public string Describe(string label) => Frames == 0
|
||||||
|
? $"{label} no frames"
|
||||||
|
: $"{label} {Frames}f/{SpanMs / 1000d:F1}s {RatePerSecond:F1}/s, {Gaps} gaps, " +
|
||||||
|
$"{Hitches} >= {HitchThresholdMs:F0}ms, avg {AverageGapMs:F1}ms, longest {LongestGapMs:F1}ms";
|
||||||
|
}
|
||||||
@@ -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.");
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
|
||||||
|
namespace ytLive.Helpers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Which run of LlamaCasty this process is, and where its private state lives.
|
||||||
|
/// <para><b>Dev-only affordance (creator directive, 2026-09-26).</b> Pre-1.0 the creator
|
||||||
|
/// needs two instances side by side: one streaming, one screen-capturing the other.
|
||||||
|
/// Two instances on one Windows profile collide on state, and two of those collisions
|
||||||
|
/// are hard failures rather than annoyances:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><b>The SQLite layout DB.</b> The app's model is read-whole-scene / write-whole-scene,
|
||||||
|
/// so two instances saving different layouts clobber each other.</item>
|
||||||
|
/// <item><b>The WebView2 user data folder.</b> Chromium takes an exclusive lock on it; the
|
||||||
|
/// second instance of the same exe simply fails to start.</item>
|
||||||
|
/// <item><b>The auth token store.</b> A test instance would overwrite the real YouTube
|
||||||
|
/// sign-in.</item>
|
||||||
|
/// <item><b>startup.log.</b> Two appenders interleave into one file, so a crash in either
|
||||||
|
/// instance is ambiguous.</item>
|
||||||
|
/// </list>
|
||||||
|
/// So in <c>DEBUG</c> a process can claim an identity and gets its own private root
|
||||||
|
/// under <c>instances\<id></c>: <c>$env:YTLIVE_INSTANCE=2; dotnet run</c>.
|
||||||
|
/// Recording, screen capture and the ffmpeg <c>tools\</c> cache are deliberately LEFT
|
||||||
|
/// shared — the cache should be shared, and the creator chooses the record folder.
|
||||||
|
/// Global hotkeys are also left alone: if both instances register the same one, Windows
|
||||||
|
/// refuses the second, which is the correct answer.</para>
|
||||||
|
/// <para><b>At 1.0 this disappears.</b> The whole implementation is inside
|
||||||
|
/// <c>#if DEBUG</c>; a Release build compiles to the two pass-throughs at the bottom —
|
||||||
|
/// no environment variable, no alternate folder, not even the variable's NAME in the
|
||||||
|
/// binary. Call sites are unconditional, so Release cannot accidentally diverge: it
|
||||||
|
/// always resolves the one real user profile.</para>
|
||||||
|
/// </summary>
|
||||||
|
public static class InstanceProfile
|
||||||
|
{
|
||||||
|
#if DEBUG
|
||||||
|
/// <summary>Environment variable naming this instance. Absent or blank = the primary
|
||||||
|
/// instance on the real user profile.</summary>
|
||||||
|
public const string InstanceVariable = "YTLIVE_INSTANCE";
|
||||||
|
|
||||||
|
/// <summary>This instance's id, or null for the primary instance.</summary>
|
||||||
|
public static string? Id => Sanitize(Environment.GetEnvironmentVariable(InstanceVariable));
|
||||||
|
|
||||||
|
/// <summary>Private state root: the real profile normally, a per-instance subfolder
|
||||||
|
/// when an id is claimed.</summary>
|
||||||
|
public static string DataRoot => Id is { } id
|
||||||
|
? Path.Combine(DefaultRoot, "instances", id)
|
||||||
|
: DefaultRoot;
|
||||||
|
|
||||||
|
/// <summary>Chromium's user data folder, or null to let WebView2 pick its default
|
||||||
|
/// (which is what Release always does). Must be per-instance or the second
|
||||||
|
/// instance dies on Chromium's exclusive lock.</summary>
|
||||||
|
public static string? WebViewDataFolder => Id is null ? null : Path.Combine(DataRoot, "webview2");
|
||||||
|
|
||||||
|
/// <summary>Reject anything that is not a plain path segment, so the variable can
|
||||||
|
/// never be used to walk out of the profile directory or name a UNC path.</summary>
|
||||||
|
private static string? Sanitize(string? raw)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(raw)) return null;
|
||||||
|
var trimmed = raw.Trim();
|
||||||
|
foreach (var c in trimmed)
|
||||||
|
if (!char.IsLetterOrDigit(c) && c != '-' && c != '_') return null;
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
// Release: one profile, no alternate paths, no environment variable. Multi-instance
|
||||||
|
// is a development affordance, not a shipping feature.
|
||||||
|
public static string DataRoot => DefaultRoot;
|
||||||
|
public static string? WebViewDataFolder => null;
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/// <summary>The one real user profile: <c>%APPDATA%\ytLlive</c>. Note the historical
|
||||||
|
/// double-L — do not "fix" it, users already have their layouts there.</summary>
|
||||||
|
public static string DefaultRoot => Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||||
|
"ytLlive");
|
||||||
|
}
|
||||||
@@ -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,204 @@
|
|||||||
|
using System;
|
||||||
|
using System.Threading;
|
||||||
|
|
||||||
|
namespace ytLive.Helpers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A fixed pool of four DEDICATED background threads at <see cref="ThreadPriority.BelowNormal"/>
|
||||||
|
/// that split one composite into horizontal row strips. The compositor needs the split to get
|
||||||
|
/// render inside a 16.67ms slot, but it must not compete with the encoder, the WGC capture
|
||||||
|
/// callbacks, the WebView2 chat or the game the user is playing — hence the hard cap of four
|
||||||
|
/// and the demotion.
|
||||||
|
///
|
||||||
|
/// Research (2026-10-05, Unit 5): the demotion must NOT be done by poking
|
||||||
|
/// <c>Thread.CurrentThread.Priority</c> inside <c>Parallel.For</c> — the pool is shared, so the
|
||||||
|
/// demotion leaks into every unrelated work item that thread later runs
|
||||||
|
/// (https://stackoverflow.com/questions/524483/net-backgroundworker-objects-thread-priority:
|
||||||
|
/// "you have next to no control of which thread pool thread will actually run your task");
|
||||||
|
/// https://stackoverflow.com/questions/1578599/c-sharp-lower-thread-priority-in-thread-pool:
|
||||||
|
/// "You shouldn't mess with the priority of the regular ThreadPool, since you aren't the only
|
||||||
|
/// consumer ... write your own producer/consumer queue, using your own Thread(s) as the
|
||||||
|
/// consumer(s) — setting the thread priority yourself"). A real failed attempt confirmed it: a
|
||||||
|
/// full test run after demoting the pool threads broke an unrelated audio-mix timing test.
|
||||||
|
/// This class IS that producer/consumer queue; the caller thread is the producer and also runs
|
||||||
|
/// one strip itself, so four workers never means five-way oversubscription.
|
||||||
|
///
|
||||||
|
/// Contract: <see cref="For"/> blocks until every strip has run, each strip must write only its
|
||||||
|
/// own rows (the compositor's blits already partition by row), and it must not be called from
|
||||||
|
/// inside a strip (the blits never nest).
|
||||||
|
/// </summary>
|
||||||
|
internal static class StripPool
|
||||||
|
{
|
||||||
|
/// <summary>Four workers: the measured sweet spot on this box. Fewer leaves render over
|
||||||
|
/// budget; more steals cycles from the game and the encoder and made render worse, not better.</summary>
|
||||||
|
public const int Workers = 4;
|
||||||
|
|
||||||
|
/// <summary>Below this many rows the fan-out costs more than it saves — run it inline.</summary>
|
||||||
|
private const int MinRowsForFanOut = 64;
|
||||||
|
|
||||||
|
private static readonly object[] _queueLocks = CreateLocks();
|
||||||
|
private static readonly Queue<Strip>[] _queues = CreateQueues();
|
||||||
|
private static readonly Queue<Strip>[] _free = CreateQueues();
|
||||||
|
private static readonly SemaphoreSlim[] _signals = CreateSignals();
|
||||||
|
private static Thread[]? _threads;
|
||||||
|
private static int _roundRobin = -1;
|
||||||
|
|
||||||
|
private sealed class Strip
|
||||||
|
{
|
||||||
|
public int From;
|
||||||
|
public int To; // exclusive
|
||||||
|
public Action<int>? Body;
|
||||||
|
public Batch? Owner;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class Batch
|
||||||
|
{
|
||||||
|
public int Remaining;
|
||||||
|
public Exception? Error;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Run <paramref name="body"/> for every row in [from, to), split across the pool.</summary>
|
||||||
|
public static void For(int from, int to, Action<int> body)
|
||||||
|
{
|
||||||
|
if (from >= to) return;
|
||||||
|
|
||||||
|
var rows = to - from;
|
||||||
|
if (rows < MinRowsForFanOut || !EnsureStarted())
|
||||||
|
{
|
||||||
|
for (var y = from; y < to; y++) body(y);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strips are handed to whole workers (not per-row tasks) so the strip boundaries —
|
||||||
|
// and therefore the destination row each strip owns — are fixed before any of them run.
|
||||||
|
var strips = Math.Min(Workers, rows);
|
||||||
|
var chunk = (rows + strips - 1) / strips;
|
||||||
|
var batch = new Batch { Remaining = strips - 1 }; // the caller runs the last one
|
||||||
|
|
||||||
|
var first = from;
|
||||||
|
for (var k = 0; k < strips - 1; k++)
|
||||||
|
{
|
||||||
|
var end = Math.Min(first + chunk, to);
|
||||||
|
var w = NextWorker();
|
||||||
|
var strip = Rent(w);
|
||||||
|
strip.From = first;
|
||||||
|
strip.To = end;
|
||||||
|
strip.Body = body;
|
||||||
|
strip.Owner = batch;
|
||||||
|
lock (_queueLocks[w]) _queues[w].Enqueue(strip);
|
||||||
|
_signals[w].Release();
|
||||||
|
first = end;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
for (var y = first; y < to; y++) body(y);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
batch.Error ??= ex;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (batch.Remaining > 0)
|
||||||
|
{
|
||||||
|
lock (batch)
|
||||||
|
{
|
||||||
|
while (batch.Remaining > 0) Monitor.Wait(batch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (batch.Error != null) throw new InvalidOperationException("A compositor strip failed.", batch.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Worker(int index)
|
||||||
|
{
|
||||||
|
var signal = _signals[index];
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
signal.Wait();
|
||||||
|
Strip? strip = null;
|
||||||
|
lock (_queueLocks[index])
|
||||||
|
{
|
||||||
|
if (_queues[index].Count > 0) strip = _queues[index].Dequeue();
|
||||||
|
}
|
||||||
|
if (strip == null) continue; // released for an already-drained queue
|
||||||
|
var batch = strip.Owner!;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
for (var y = strip.From; y < strip.To; y++) strip.Body!(y);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
batch.Error ??= ex;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
strip.Body = null;
|
||||||
|
strip.Owner = null;
|
||||||
|
lock (_queueLocks[index]) _free[index].Enqueue(strip);
|
||||||
|
lock (batch)
|
||||||
|
{
|
||||||
|
if (--batch.Remaining == 0) Monitor.PulseAll(batch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Strip Rent(int worker)
|
||||||
|
{
|
||||||
|
lock (_queueLocks[worker])
|
||||||
|
return _free[worker].Count > 0 ? _free[worker].Dequeue() : new Strip();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int NextWorker()
|
||||||
|
{
|
||||||
|
var w = Interlocked.Increment(ref _roundRobin) % Workers;
|
||||||
|
return w < 0 ? w + Workers : w;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Start the workers on first use, so a WPF cold start pays nothing for them.</summary>
|
||||||
|
private static bool EnsureStarted()
|
||||||
|
{
|
||||||
|
if (_threads != null) return true;
|
||||||
|
lock (_signals)
|
||||||
|
{
|
||||||
|
if (_threads != null) return true;
|
||||||
|
var threads = new Thread[Workers];
|
||||||
|
for (var i = 0; i < Workers; i++)
|
||||||
|
{
|
||||||
|
var index = i; // capture per-iteration: the loop variable would be Workers by the time the thread runs
|
||||||
|
var t = new Thread(() => Worker(index), 256 * 1024)
|
||||||
|
{
|
||||||
|
IsBackground = true,
|
||||||
|
Name = $"compositor-strip-{i}",
|
||||||
|
Priority = ThreadPriority.BelowNormal,
|
||||||
|
};
|
||||||
|
threads[i] = t;
|
||||||
|
t.Start();
|
||||||
|
}
|
||||||
|
_threads = threads;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static object[] CreateLocks()
|
||||||
|
{
|
||||||
|
var l = new object[Workers];
|
||||||
|
for (var i = 0; i < Workers; i++) l[i] = new object();
|
||||||
|
return l;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Queue<Strip>[] CreateQueues()
|
||||||
|
{
|
||||||
|
var q = new Queue<Strip>[Workers];
|
||||||
|
for (var i = 0; i < Workers; i++) q[i] = new Queue<Strip>();
|
||||||
|
return q;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SemaphoreSlim[] CreateSignals()
|
||||||
|
{
|
||||||
|
var s = new SemaphoreSlim[Workers];
|
||||||
|
for (var i = 0; i < Workers; i++) s[i] = new SemaphoreSlim(0, int.MaxValue);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,9 +14,7 @@ namespace ytLive.Helpers;
|
|||||||
public static class TokenStore
|
public static class TokenStore
|
||||||
{
|
{
|
||||||
public static string DefaultPath => Path.Combine(
|
public static string DefaultPath => Path.Combine(
|
||||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
InstanceProfile.DataRoot, "ytLlive.auth");
|
||||||
"ytLlive",
|
|
||||||
"ytLlive.auth");
|
|
||||||
|
|
||||||
public static void Save(YouTubeChannel channel, string? path = null)
|
public static void Save(YouTubeChannel channel, string? path = null)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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 (the crash this map is named for). Size-capped: rolls to `startup.1.log` at 5 MB (TASK 55) |
|
||||||
|
| `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,6 +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 [`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 [`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,23 @@
|
|||||||
|
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();
|
||||||
|
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>
|
||||||
@@ -45,24 +57,10 @@
|
|||||||
</DrawingImage.Drawing>
|
</DrawingImage.Drawing>
|
||||||
</DrawingImage>
|
</DrawingImage>
|
||||||
|
|
||||||
<!-- Eye icon: open when visible, slashed when hidden (source rows) -->
|
<!-- EyeButton/EyeIconStyle MOVED to Themes/Controls.xaml (2026-09-01): Controls/
|
||||||
<Style x:Key="EyeIconStyle" TargetType="Path">
|
LeftPanel.xaml consumes them via StaticResource, and window-scope resources
|
||||||
<Setter Property="Data" Value="M12,4.5C7,4.5 2.73,7.61 1,12c1.73,4.39 6,7.5 11,7.5s9.27,-3.11 11,-7.5c-1.73,-4.39 -6,-7.5 -11,-7.5zM12,17c-2.76,0 -5,-2.24 -5,-5s2.24,-5 5,-5 5,2.24 5,5 -2.24,5 -5,5zM12,9c-1.66,0 -3,1.34 -3,3s1.34,3 3,3 3,-1.34 3,-3 -1.34,-3 -3,-3z"/>
|
are invisible to a UserControl at parse time — the "known test failure" that
|
||||||
<Style.Triggers>
|
was actually an app-crash regression from the LeftPanel extraction. -->
|
||||||
<DataTrigger Binding="{Binding IsVisible}" Value="False">
|
|
||||||
<Setter Property="Data" Value="M12,7c2.76,0 5,2.24 5,5 0,0.65 -0.13,1.26 -0.36,1.83l2.92,2.92c1.51,-1.26 2.7,-2.89 3.43,-4.75 -1.73,-4.39 -6,-7.5 -11,-7.5 -1.4,0 -2.74,0.25 -3.98,0.7l2.16,2.16C10.74,7.13 11.35,7 12,7zM2,4.27l2.28,2.28 0.46,0.46C3.08,8.3 1.78,10.02 1,12c1.73,4.39 6,7.5 11,7.5 1.55,0 3.03,-0.3 4.38,-0.84l0.42,0.42L19.73,22 21,20.73 3.27,3 2,4.27zM7.53,9.8l1.55,1.55c-0.05,0.21 -0.08,0.43 -0.08,0.65 0,1.66 1.34,3 3,3 0.22,0 0.44,-0.03 0.65,-0.08l1.55,1.55c-0.67,0.33 -1.41,0.53 -2.2,0.53 -2.76,0 -5,-2.24 -5,-5 0,-0.79 0.2,-1.53 0.53,-2.2zM11.84,9.02l3.15,3.15 0.02,-0.16c0,-1.66 -1.34,-3 -3,-3l-0.17,0.01z"/>
|
|
||||||
</DataTrigger>
|
|
||||||
</Style.Triggers>
|
|
||||||
</Style>
|
|
||||||
|
|
||||||
<Style x:Key="EyeButton" TargetType="Button" BasedOn="{StaticResource IconButton}">
|
|
||||||
<Setter Property="ToolTip" Value="Hide Layer"/>
|
|
||||||
<Style.Triggers>
|
|
||||||
<DataTrigger Binding="{Binding IsVisible}" Value="False">
|
|
||||||
<Setter Property="ToolTip" Value="Show Layer"/>
|
|
||||||
</DataTrigger>
|
|
||||||
</Style.Triggers>
|
|
||||||
</Style>
|
|
||||||
</ResourceDictionary>
|
</ResourceDictionary>
|
||||||
</Window.Resources>
|
</Window.Resources>
|
||||||
|
|
||||||
@@ -86,740 +84,88 @@
|
|||||||
<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 -->
|
||||||
<Border Grid.Row="0" Grid.ColumnSpan="2" Background="#0d1117" CornerRadius="4" Margin="0,0,0,4"
|
<Border Grid.Row="0" Grid.Column="0" Background="{StaticResource Brush.SurfaceContainerLowest}" CornerRadius="4" Padding="10,8" Margin="0,0,8,0">
|
||||||
Padding="4">
|
|
||||||
<ItemsControl ItemsSource="{Binding Scenes}">
|
|
||||||
<ItemsControl.ItemsPanel>
|
|
||||||
<ItemsPanelTemplate>
|
|
||||||
<UniformGrid Rows="1" Columns="5"/>
|
|
||||||
</ItemsPanelTemplate>
|
|
||||||
</ItemsControl.ItemsPanel>
|
|
||||||
<ItemsControl.ItemTemplate>
|
|
||||||
<DataTemplate>
|
|
||||||
<Border CornerRadius="4" Margin="2" Padding="2"
|
|
||||||
BorderThickness="2"
|
|
||||||
BorderBrush="Transparent"
|
|
||||||
Cursor="Hand"
|
|
||||||
MouseLeftButtonDown="Thumbnail_MouseLeftButtonDown">
|
|
||||||
<Border.Style>
|
|
||||||
<Style TargetType="Border">
|
|
||||||
<Setter Property="BorderBrush" Value="#333344"/>
|
|
||||||
<Style.Triggers>
|
|
||||||
<DataTrigger Binding="{Binding IsStaged}" Value="True">
|
|
||||||
<Setter Property="BorderBrush" Value="#e94560"/>
|
|
||||||
</DataTrigger>
|
|
||||||
<DataTrigger Binding="{Binding IsLive}" Value="True">
|
|
||||||
<Setter Property="BorderBrush" Value="#22c55e"/>
|
|
||||||
</DataTrigger>
|
|
||||||
</Style.Triggers>
|
|
||||||
</Style>
|
|
||||||
</Border.Style>
|
|
||||||
<Grid>
|
<Grid>
|
||||||
<Grid.RowDefinitions>
|
<Grid.RowDefinitions>
|
||||||
<RowDefinition Height="*"/>
|
|
||||||
<RowDefinition Height="Auto"/>
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="*"/>
|
||||||
</Grid.RowDefinitions>
|
</Grid.RowDefinitions>
|
||||||
<Border Grid.Row="0" CornerRadius="3"
|
<Grid.ColumnDefinitions>
|
||||||
Background="#0a0a1a" ClipToBounds="True">
|
<ColumnDefinition Width="Auto"/>
|
||||||
<Image Source="{Binding Snapshot}"
|
<ColumnDefinition Width="*"/>
|
||||||
Stretch="UniformToFill"
|
</Grid.ColumnDefinitions>
|
||||||
RenderOptions.BitmapScalingMode="LowQuality"/>
|
|
||||||
</Border>
|
<TextBlock Grid.Row="0" Grid.Column="0" Text="Bitrate" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="12" Margin="0,0,12,2"/>
|
||||||
<TextBlock Grid.Row="1" Foreground="#a0a0b0" FontSize="10"
|
<TextBlock Grid.Row="0" Grid.Column="1" Foreground="{StaticResource Brush.OnSurface}" FontSize="13" FontFamily="{StaticResource FontFamily.Mono}" HorizontalAlignment="Right" Margin="0,0,0,2">
|
||||||
HorizontalAlignment="Center"
|
<Run Text="{Binding CurrentHealth.CurrentBitrate, Mode=OneWay, StringFormat={}{0:0.0}}"/><Run Text=" Mbps"/>
|
||||||
Margin="0,2,0,0"
|
|
||||||
TextTrimming="CharacterEllipsis">
|
|
||||||
<Run Text="{Binding Name, Mode=OneWay}"/><Run Text=" "/><Run Text="{Binding HotkeyLabel, Mode=OneWay}" Foreground="#6a6a7a" FontWeight="SemiBold"/>
|
|
||||||
</TextBlock>
|
</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"/>
|
||||||
|
|
||||||
|
<ComboBox Grid.Row="5" Grid.ColumnSpan="2" VerticalAlignment="Bottom" Style="{StaticResource YtComboBox}"
|
||||||
|
ItemsSource="{Binding QualityOptions}"
|
||||||
|
SelectedItem="{Binding SelectedQuality}"
|
||||||
|
DisplayMemberPath="Label"
|
||||||
|
ToolTip="{Binding ResolutionHelp}"
|
||||||
|
IsEnabled="{Binding IsOffline}"/>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
</DataTemplate>
|
|
||||||
</ItemsControl.ItemTemplate>
|
<!-- ═══ THUMBNAIL STRIP (over the preview column; left column stays empty) ═══ -->
|
||||||
</ItemsControl>
|
<controls:SceneThumbnailStrip x:Name="SceneStrip" Grid.Row="0" Grid.Column="1" Margin="8,0" Height="154"/>
|
||||||
</Border>
|
|
||||||
|
|
||||||
<!-- 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 -->
|
||||||
<Border Grid.Row="1" Grid.Column="0" Background="#16213e" CornerRadius="6" Padding="12">
|
<controls:LeftPanel x:Name="LeftPanel" Grid.Row="1" Grid.Column="0" Grid.RowSpan="2"/>
|
||||||
<Grid>
|
|
||||||
<Grid.RowDefinitions>
|
|
||||||
<RowDefinition Height="*"/>
|
|
||||||
</Grid.RowDefinitions>
|
|
||||||
|
|
||||||
<!-- Edit mode (offline): layers + properties -->
|
|
||||||
<Grid Grid.Row="0" Visibility="{Binding IsEditMode, Converter={StaticResource BoolToVis}}">
|
|
||||||
<Grid.RowDefinitions>
|
|
||||||
<RowDefinition Height="Auto"/>
|
|
||||||
<RowDefinition Height="Auto"/>
|
|
||||||
<RowDefinition Height="Auto"/>
|
|
||||||
<RowDefinition Height="Auto"/>
|
|
||||||
<RowDefinition Height="Auto"/>
|
|
||||||
<RowDefinition Height="*"/>
|
|
||||||
</Grid.RowDefinitions>
|
|
||||||
|
|
||||||
<!-- LAYERS header + Add button -->
|
|
||||||
<StackPanel Grid.Row="0" Orientation="Horizontal" Margin="0,8,0,0">
|
|
||||||
<TextBlock Text="LAYERS" Style="{StaticResource SectionHeader}" VerticalAlignment="Center"/>
|
|
||||||
<Button ToolTip="Add Layer" Style="{StaticResource IconButton}" Margin="4,-4,0,0"
|
|
||||||
Click="AddSourceButton_Click">
|
|
||||||
<Button.ContextMenu>
|
|
||||||
<ContextMenu DataContext="{Binding PlacementTarget.DataContext, RelativeSource={RelativeSource Self}}">
|
|
||||||
<MenuItem Header="Webcam" Command="{Binding AddWebcamCommand}"
|
|
||||||
IsEnabled="{Binding CanAddWebcam}"
|
|
||||||
ToolTip="One webcam at a time — it's already in your stream"/>
|
|
||||||
<MenuItem Header="Image" Command="{Binding AddImageCommand}"/>
|
|
||||||
<MenuItem Header="Text" Command="{Binding AddSourceCommand}" CommandParameter="{x:Static models:SourceType.TextOverlay}"/>
|
|
||||||
<MenuItem Header="Countdown" Command="{Binding AddSourceCommand}" CommandParameter="{x:Static models:SourceType.Countdown}"
|
|
||||||
Visibility="{Binding IsCountdownAvailable, Converter={StaticResource BoolToVis}}"
|
|
||||||
ToolTip="Timer overlay — Starting and BRB screens only"/>
|
|
||||||
<MenuItem Header="Web Resource" Command="{Binding AddSourceCommand}" CommandParameter="{x:Static models:SourceType.WebSource}"
|
|
||||||
ToolTip="Embedded web page — set URI in Properties panel"/>
|
|
||||||
<MenuItem Header="YouTube Chat" Command="{Binding AddSourceCommand}" CommandParameter="{x:Static models:SourceType.ChatBox}"
|
|
||||||
IsEnabled="{Binding CanAddYouTubeChat}"
|
|
||||||
ToolTip="One chat layer at a time — it's already in your stream"/>
|
|
||||||
</ContextMenu>
|
|
||||||
</Button.ContextMenu>
|
|
||||||
<Path Data="M12,4 L12,20 M4,12 L20,12" Stroke="#e94560" StrokeThickness="2"
|
|
||||||
Width="14" Height="14" Stretch="Uniform"/>
|
|
||||||
</Button>
|
|
||||||
</StackPanel>
|
|
||||||
|
|
||||||
<Helpers:FocusPreservingListBox x:Name="SourceList" Grid.Row="1" Background="Transparent" BorderThickness="0"
|
|
||||||
ItemsSource="{Binding StagedScene.Elements}"
|
|
||||||
SelectedItem="{Binding SelectedElement, Mode=TwoWay}"
|
|
||||||
PreviewMouseLeftButtonDown="List_PreviewMouseLeftButtonDown"
|
|
||||||
PreviewMouseMove="List_PreviewMouseMove"
|
|
||||||
PreviewMouseLeftButtonUp="List_PreviewMouseLeftButtonUp"
|
|
||||||
Foreground="#e0e0e0" FontSize="13">
|
|
||||||
<Helpers:FocusPreservingListBox.ItemContainerStyle>
|
|
||||||
<Style TargetType="ListBoxItem" BasedOn="{StaticResource YtListBoxItem}">
|
|
||||||
<Setter Property="HorizontalContentAlignment" Value="Stretch"/>
|
|
||||||
<Style.Triggers>
|
|
||||||
<DataTrigger Binding="{Binding IsVisible}" Value="False">
|
|
||||||
<Setter Property="Opacity" Value="0.45"/>
|
|
||||||
</DataTrigger>
|
|
||||||
</Style.Triggers>
|
|
||||||
</Style>
|
|
||||||
</Helpers:FocusPreservingListBox.ItemContainerStyle>
|
|
||||||
<ListBox.ItemTemplate>
|
|
||||||
<DataTemplate>
|
|
||||||
<Grid Margin="0,2"
|
|
||||||
Tag="{Binding DataContext.CanChangeBackground, RelativeSource={RelativeSource AncestorType=Window}}">
|
|
||||||
<Grid.ColumnDefinitions>
|
|
||||||
<ColumnDefinition Width="*"/>
|
|
||||||
<ColumnDefinition Width="Auto"/>
|
|
||||||
<ColumnDefinition Width="Auto"/>
|
|
||||||
<ColumnDefinition Width="Auto"/>
|
|
||||||
</Grid.ColumnDefinitions>
|
|
||||||
|
|
||||||
<Grid.ContextMenu>
|
|
||||||
<ContextMenu DataContext="{Binding PlacementTarget.DataContext, RelativeSource={RelativeSource Self}}">
|
|
||||||
<MenuItem Header="Change Capture…" Click="BackgroundMenu_ChangeCapture">
|
|
||||||
<MenuItem.Visibility>
|
|
||||||
<MultiBinding Converter="{StaticResource AllTrueToVis}">
|
|
||||||
<Binding Path="IsBackground"/>
|
|
||||||
<Binding Path="PlacementTarget.Tag" RelativeSource="{RelativeSource AncestorType={x:Type ContextMenu}}"/>
|
|
||||||
</MultiBinding>
|
|
||||||
</MenuItem.Visibility>
|
|
||||||
</MenuItem>
|
|
||||||
<MenuItem Header="Refresh Capture" Click="BackgroundMenu_RefreshCapture">
|
|
||||||
<MenuItem.Visibility>
|
|
||||||
<MultiBinding Converter="{StaticResource AllTrueToVis}">
|
|
||||||
<Binding Path="IsBackground"/>
|
|
||||||
<Binding Path="PlacementTarget.Tag" RelativeSource="{RelativeSource AncestorType={x:Type ContextMenu}}"/>
|
|
||||||
</MultiBinding>
|
|
||||||
</MenuItem.Visibility>
|
|
||||||
</MenuItem>
|
|
||||||
<Separator>
|
|
||||||
<Separator.Visibility>
|
|
||||||
<MultiBinding Converter="{StaticResource AllTrueToVis}">
|
|
||||||
<Binding Path="IsBackground"/>
|
|
||||||
<Binding Path="PlacementTarget.Tag" RelativeSource="{RelativeSource AncestorType={x:Type ContextMenu}}"/>
|
|
||||||
</MultiBinding>
|
|
||||||
</Separator.Visibility>
|
|
||||||
</Separator>
|
|
||||||
<MenuItem Header="Remove Layer" Click="SourceMenu_Remove"
|
|
||||||
Visibility="{Binding IsBackground, Converter={StaticResource InverseBoolToVis}}"/>
|
|
||||||
</ContextMenu>
|
|
||||||
</Grid.ContextMenu>
|
|
||||||
|
|
||||||
<TextBlock Grid.Column="0" Text="{Binding Name}" VerticalAlignment="Center"
|
|
||||||
Padding="4,2" TextTrimming="CharacterEllipsis">
|
|
||||||
<TextBlock.Style>
|
|
||||||
<Style TargetType="TextBlock">
|
|
||||||
<Setter Property="Visibility" Value="Visible"/>
|
|
||||||
<Style.Triggers>
|
|
||||||
<DataTrigger Binding="{Binding IsEditing}" Value="True">
|
|
||||||
<Setter Property="Visibility" Value="Collapsed"/>
|
|
||||||
</DataTrigger>
|
|
||||||
</Style.Triggers>
|
|
||||||
</Style>
|
|
||||||
</TextBlock.Style>
|
|
||||||
</TextBlock>
|
|
||||||
|
|
||||||
<TextBox Grid.Column="0" Text="{Binding Name, UpdateSourceTrigger=PropertyChanged}"
|
|
||||||
Background="#0f3460" Foreground="#e0e0e0" BorderThickness="0"
|
|
||||||
Padding="4,2" VerticalContentAlignment="Center"
|
|
||||||
IsVisibleChanged="SourceNameBox_IsVisibleChanged"
|
|
||||||
KeyDown="SourceNameBox_KeyDown" LostFocus="SourceNameBox_LostFocus">
|
|
||||||
<TextBox.Style>
|
|
||||||
<Style TargetType="TextBox">
|
|
||||||
<Setter Property="Visibility" Value="Collapsed"/>
|
|
||||||
<Style.Triggers>
|
|
||||||
<DataTrigger Binding="{Binding IsEditing}" Value="True">
|
|
||||||
<Setter Property="Visibility" Value="Visible"/>
|
|
||||||
</DataTrigger>
|
|
||||||
</Style.Triggers>
|
|
||||||
</Style>
|
|
||||||
</TextBox.Style>
|
|
||||||
</TextBox>
|
|
||||||
|
|
||||||
<Button Grid.Column="1" ToolTip="Edit Layer Name"
|
|
||||||
Command="{Binding DataContext.EditElementCommand, RelativeSource={RelativeSource AncestorType=ListBox}}"
|
|
||||||
CommandParameter="{Binding}" Style="{StaticResource IconButton}">
|
|
||||||
<Path Data="M3,17.25V21h3.75L17.81,9.94l-3.75,-3.75L3,17.25zM20.71,7.04c0.39,-0.39 0.39,-1.02 0,-1.41l-2.34,-2.34c-0.39,-0.39 -1.02,-0.39 -1.41,0l-1.83,1.83 3.75,3.75 1.83,-1.83z"
|
|
||||||
Fill="#d0d0d0" Width="13" Height="13" Stretch="Uniform"/>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button Grid.Column="2" Style="{StaticResource EyeButton}"
|
|
||||||
Command="{Binding DataContext.ToggleElementVisibilityCommand, RelativeSource={RelativeSource AncestorType=ListBox}}"
|
|
||||||
CommandParameter="{Binding}">
|
|
||||||
<Path Style="{StaticResource EyeIconStyle}" Fill="#d0d0d0"
|
|
||||||
Width="13" Height="13" Stretch="Uniform"/>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button Grid.Column="3" ToolTip="Remove Layer"
|
|
||||||
Command="{Binding DataContext.RemoveSourceCommand, RelativeSource={RelativeSource AncestorType=ListBox}}"
|
|
||||||
CommandParameter="{Binding}" Style="{StaticResource IconButton}"
|
|
||||||
Visibility="{Binding IsBackground, Converter={StaticResource HiddenBoolToVis}}">
|
|
||||||
<Path Data="M6,19c0,1.1 0.9,2 2,2h8c1.1,0 2,-0.9 2,-2V7H6v12zM19,4h-3.5l-1,-1h-5l-1,1H5v2h14V4z"
|
|
||||||
Fill="#d0d0d0" Width="13" Height="13" Stretch="Uniform"/>
|
|
||||||
</Button>
|
|
||||||
</Grid>
|
|
||||||
</DataTemplate>
|
|
||||||
</ListBox.ItemTemplate>
|
|
||||||
</Helpers:FocusPreservingListBox>
|
|
||||||
|
|
||||||
<TextBlock Grid.Row="2" Text="No layers yet" Foreground="#555" FontSize="12"
|
|
||||||
HorizontalAlignment="Center" VerticalAlignment="Center"
|
|
||||||
Visibility="{Binding ShowSourcesEmptyHint, Converter={StaticResource BoolToVis}}"/>
|
|
||||||
|
|
||||||
<!-- ELEMENTS panel: per-element config beneath Sources.
|
|
||||||
Collapses entirely when no element is selected. -->
|
|
||||||
<Rectangle Grid.Row="3" Height="1" Fill="#2c3a5c" Margin="0,10,0,2"
|
|
||||||
Visibility="{Binding SelectedElement, Converter={StaticResource NotNullToVis}}"/>
|
|
||||||
<StackPanel Grid.Row="4" Orientation="Horizontal" Margin="0,8,0,0"
|
|
||||||
Visibility="{Binding SelectedElement, Converter={StaticResource NotNullToVis}}">
|
|
||||||
<TextBlock Text="PROPERTIES" Style="{StaticResource SectionHeader}" VerticalAlignment="Center"/>
|
|
||||||
</StackPanel>
|
|
||||||
<ScrollViewer x:Name="ElementsScrollViewer" Grid.Row="5" VerticalScrollBarVisibility="Auto" Background="Transparent"
|
|
||||||
Visibility="{Binding SelectedElement, Converter={StaticResource NotNullToVis}}">
|
|
||||||
<StackPanel x:Name="ElementsPanel" Margin="0,4,0,0">
|
|
||||||
<!-- Opacity + mirror/shape (draggable elements: image, webcam) -->
|
|
||||||
<StackPanel Visibility="{Binding SelectedElement.IsDraggable, Converter={StaticResource BoolToVis}}" Margin="0,0,0,8">
|
|
||||||
<StackPanel Visibility="{Binding SelectedElement.IsChatBox, Converter={StaticResource InverseBoolToVis}}">
|
|
||||||
<TextBlock Text="Opacity" Foreground="#a0a0b0" FontSize="11" Margin="0,0,0,2"/>
|
|
||||||
<DockPanel Margin="0,0,0,6">
|
|
||||||
<TextBlock x:Name="OpacityValueText" Text="100%" Foreground="#a0a0b0" FontSize="11"
|
|
||||||
DockPanel.Dock="Right" VerticalAlignment="Center" Margin="8,0,0,0" Width="32"/>
|
|
||||||
<Slider x:Name="OpacitySlider" Width="120" Minimum="0" Maximum="1"
|
|
||||||
Value="{Binding SelectedElement.Opacity, Mode=TwoWay}"
|
|
||||||
ValueChanged="OpacitySlider_ValueChanged" VerticalAlignment="Center"/>
|
|
||||||
</DockPanel>
|
|
||||||
</StackPanel>
|
|
||||||
<StackPanel Orientation="Horizontal" Margin="0,0,0,4"
|
|
||||||
Visibility="{Binding IsWebcamSelected, Converter={StaticResource BoolToVis}}">
|
|
||||||
<Button Content="{Binding SelectedElement.MirrorButtonText}"
|
|
||||||
Style="{StaticResource YtButtonSecondary}" Padding="10,4"
|
|
||||||
Click="MirrorButton_Click"/>
|
|
||||||
<Button Content="{Binding SelectedElement.ShapeButtonText}"
|
|
||||||
Style="{StaticResource YtButtonSecondary}" Padding="10,4"
|
|
||||||
Margin="8,0,0,0" Click="ShapeButton_Click"/>
|
|
||||||
</StackPanel>
|
|
||||||
<TextBlock Text="Drag to move · drag the corner dot to resize"
|
|
||||||
Foreground="#a0a0b0" FontSize="10" Margin="0,2,0,0"/>
|
|
||||||
</StackPanel>
|
|
||||||
|
|
||||||
<!-- Webcam border config -->
|
|
||||||
<StackPanel Visibility="{Binding SelectedElement.IsWebcam, Converter={StaticResource BoolToVis}}" Margin="0,0,0,8">
|
|
||||||
<TextBlock Text="Border Color" Foreground="#a0a0b0" FontSize="11" Margin="0,0,0,2"/>
|
|
||||||
<DockPanel Margin="0,0,0,6">
|
|
||||||
<Button DockPanel.Dock="Right" Content="..." Width="24" Height="24"
|
|
||||||
Click="WebcamBorderColorPicker_Click" Margin="4,0,0,0"
|
|
||||||
ToolTip="Pick color"/>
|
|
||||||
<TextBox Text="{Binding SelectedElement.BorderColor, UpdateSourceTrigger=PropertyChanged}"
|
|
||||||
Background="#0f3460" Foreground="#e0d0e0" BorderThickness="1"
|
|
||||||
BorderBrush="#2c3a5c" Padding="4,2" FontSize="12"
|
|
||||||
MaxLength="7" ToolTip="#RRGGBB"/>
|
|
||||||
</DockPanel>
|
|
||||||
<TextBlock Text="Border Thickness" Foreground="#a0a0b0" FontSize="11" Margin="0,0,0,2"/>
|
|
||||||
<DockPanel Margin="0,0,0,6">
|
|
||||||
<Button DockPanel.Dock="Right" Style="{StaticResource IconButton}"
|
|
||||||
Width="22" Height="22" Margin="4,0,0,0"
|
|
||||||
Click="WebcamBorderAccept_Click">
|
|
||||||
<Path Data="M4,9 L8,14 L16,4" Stroke="#22c55e" StrokeThickness="2"
|
|
||||||
Width="14" Height="14" Stretch="Uniform"/>
|
|
||||||
</Button>
|
|
||||||
<Button DockPanel.Dock="Right" Style="{StaticResource IconButton}"
|
|
||||||
Width="22" Height="22" ToolTip="Revert"
|
|
||||||
Click="WebcamBorderRevert_Click" Margin="4,0,0,0">
|
|
||||||
<Path Data="M5,5 L15,15 M15,5 L5,15" Stroke="#e94560" StrokeThickness="2"
|
|
||||||
Width="14" Height="14" Stretch="Uniform"/>
|
|
||||||
</Button>
|
|
||||||
<Slider DockPanel.Dock="Left" Width="120" Minimum="0" Maximum="10"
|
|
||||||
Value="{Binding SelectedElement.BorderWidth, Mode=TwoWay}"
|
|
||||||
VerticalAlignment="Center" TickFrequency="1" IsSnapToTickEnabled="True"/>
|
|
||||||
<TextBlock Text="{Binding SelectedElement.BorderWidth}" Foreground="#a0a0b0" FontSize="11"
|
|
||||||
VerticalAlignment="Center" Margin="8,0,0,0" Width="20"/>
|
|
||||||
</DockPanel>
|
|
||||||
</StackPanel>
|
|
||||||
|
|
||||||
<!-- Countdown config -->
|
|
||||||
<StackPanel Visibility="{Binding SelectedElement.IsCountdown, Converter={StaticResource BoolToVis}}" Margin="0,0,0,8">
|
|
||||||
<TextBlock Text="Timer (minutes)" Foreground="#a0a0b0" FontSize="11" Margin="0,0,0,2"/>
|
|
||||||
<DockPanel Margin="0,0,0,6">
|
|
||||||
<Button DockPanel.Dock="Right" Style="{StaticResource IconButton}"
|
|
||||||
Width="22" Height="22" Margin="4,0,0,0"
|
|
||||||
Click="CountdownAccept_Click">
|
|
||||||
<Path Data="M4,9 L8,14 L16,4" Stroke="#22c55e" StrokeThickness="2"
|
|
||||||
Width="14" Height="14" Stretch="Uniform"/>
|
|
||||||
</Button>
|
|
||||||
<Button DockPanel.Dock="Right" Style="{StaticResource IconButton}"
|
|
||||||
Width="22" Height="22" ToolTip="Revert"
|
|
||||||
Click="CountdownRevert_Click" Margin="4,0,0,0">
|
|
||||||
<Path Data="M5,5 L15,15 M15,5 L5,15" Stroke="#e94560" StrokeThickness="2"
|
|
||||||
Width="14" Height="14" Stretch="Uniform"/>
|
|
||||||
</Button>
|
|
||||||
<Slider DockPanel.Dock="Left" Width="120" Minimum="1" Maximum="60"
|
|
||||||
Value="{Binding SelectedElement.TimerMinutes, Mode=TwoWay}"
|
|
||||||
VerticalAlignment="Center" TickFrequency="1" IsSnapToTickEnabled="True"/>
|
|
||||||
<TextBlock Text="{Binding SelectedElement.TimerMinutes}" Foreground="#a0a0b0" FontSize="11"
|
|
||||||
VerticalAlignment="Center" Margin="8,0,0,0" Width="20"/>
|
|
||||||
</DockPanel>
|
|
||||||
</StackPanel>
|
|
||||||
|
|
||||||
<!-- Web source config -->
|
|
||||||
<StackPanel Visibility="{Binding SelectedElement.IsWebSource, Converter={StaticResource BoolToVis}}" Margin="0,0,0,8">
|
|
||||||
<TextBlock Text="Geometry" Foreground="#a0a0b0" FontSize="11" FontWeight="SemiBold" Margin="0,0,0,4"/>
|
|
||||||
<DockPanel Margin="0,0,0,4">
|
|
||||||
<TextBlock DockPanel.Dock="Left" Text="X" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="16"/>
|
|
||||||
<TextBox Text="{Binding SelectedElement.X, UpdateSourceTrigger=PropertyChanged, StringFormat=F0}"
|
|
||||||
Background="#0f3460" Foreground="#e0d0e0" BorderThickness="1"
|
|
||||||
BorderBrush="#2c3a5c" Padding="4,2" FontSize="12"/>
|
|
||||||
<TextBlock DockPanel.Dock="Left" Text=" Y" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="20" Margin="8,0,0,0"/>
|
|
||||||
<TextBox Text="{Binding SelectedElement.Y, UpdateSourceTrigger=PropertyChanged, StringFormat=F0}"
|
|
||||||
Background="#0f3460" Foreground="#e0d0e0" BorderThickness="1"
|
|
||||||
BorderBrush="#2c3a5c" Padding="4,2" FontSize="12"/>
|
|
||||||
</DockPanel>
|
|
||||||
<DockPanel Margin="0,0,0,4">
|
|
||||||
<TextBlock DockPanel.Dock="Left" Text="W" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="16"/>
|
|
||||||
<TextBox Text="{Binding SelectedElement.Width, UpdateSourceTrigger=PropertyChanged, StringFormat=F0}"
|
|
||||||
Background="#0f3460" Foreground="#e0d0e0" BorderThickness="1"
|
|
||||||
BorderBrush="#2c3a5c" Padding="4,2" FontSize="12"/>
|
|
||||||
<TextBlock DockPanel.Dock="Left" Text=" H" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="20" Margin="8,0,0,0"/>
|
|
||||||
<TextBox Text="{Binding SelectedElement.Height, UpdateSourceTrigger=PropertyChanged, StringFormat=F0}"
|
|
||||||
Background="#0f3460" Foreground="#e0d0e0" BorderThickness="1"
|
|
||||||
BorderBrush="#2c3a5c" Padding="4,2" FontSize="12"/>
|
|
||||||
</DockPanel>
|
|
||||||
<TextBlock Text="URI" Foreground="#a0a0b0" FontSize="11" Margin="0,0,0,2"/>
|
|
||||||
<DockPanel>
|
|
||||||
<Button DockPanel.Dock="Right" Style="{StaticResource IconButton}"
|
|
||||||
Width="22" Height="22" Margin="4,0,0,0" IsTabStop="False"
|
|
||||||
Click="WebAccept_Click">
|
|
||||||
<Path Data="M4,9 L8,14 L16,4" Stroke="#22c55e" StrokeThickness="2"
|
|
||||||
Width="14" Height="14" Stretch="Uniform"/>
|
|
||||||
</Button>
|
|
||||||
<Button DockPanel.Dock="Right" Style="{StaticResource IconButton}"
|
|
||||||
Width="22" Height="22" ToolTip="Revert" IsTabStop="False"
|
|
||||||
Click="WebRevert_Click" Margin="4,0,0,0">
|
|
||||||
<Path Data="M5,5 L15,15 M15,5 L5,15" Stroke="#e94560" StrokeThickness="2"
|
|
||||||
Width="14" Height="14" Stretch="Uniform"/>
|
|
||||||
</Button>
|
|
||||||
<TextBox Text="{Binding SelectedElement.WebUri, UpdateSourceTrigger=PropertyChanged}"
|
|
||||||
Background="#0f3460" Foreground="#e0d0e0" BorderThickness="1"
|
|
||||||
BorderBrush="#2c3a5c" Padding="4,2" FontSize="12"
|
|
||||||
ToolTip="https://example.com"/>
|
|
||||||
</DockPanel>
|
|
||||||
</StackPanel>
|
|
||||||
|
|
||||||
<!-- Chat box config -->
|
|
||||||
<StackPanel Visibility="{Binding SelectedElement.IsChatBox, Converter={StaticResource BoolToVis}}" Margin="0,0,0,8">
|
|
||||||
<TextBlock Text="Chat Overlay" Foreground="#a0a0b0" FontSize="11" FontWeight="SemiBold" Margin="0,0,0,6"/>
|
|
||||||
|
|
||||||
<!-- Font Size -->
|
|
||||||
<DockPanel Margin="0,0,0,4">
|
|
||||||
<TextBlock DockPanel.Dock="Left" Text="Font Size" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="70"/>
|
|
||||||
<Slider x:Name="ChatFontSizeSlider" Width="120" Minimum="8" Maximum="72" TickFrequency="2" IsSnapToTickEnabled="True"
|
|
||||||
Value="{Binding SelectedElement.ChatFontSize, Mode=TwoWay}"
|
|
||||||
VerticalAlignment="Center"/>
|
|
||||||
<TextBlock DockPanel.Dock="Right" Text="{Binding SelectedElement.ChatFontSize, StringFormat={}{0:0}}" Foreground="#e0e0e0" FontSize="11" Margin="6,0,0,0" VerticalAlignment="Center" Width="24"/>
|
|
||||||
</DockPanel>
|
|
||||||
|
|
||||||
<!-- Font Family -->
|
|
||||||
<DockPanel Margin="0,0,0,4">
|
|
||||||
<TextBlock DockPanel.Dock="Left" Text="Font" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="70"/>
|
|
||||||
<ComboBox SelectedValue="{Binding SelectedElement.ChatFontFamily, Mode=TwoWay}"
|
|
||||||
SelectedValuePath="Content"
|
|
||||||
Background="#0f3460" Foreground="#e0e0e0" FontSize="11"
|
|
||||||
Width="140" VerticalAlignment="Center">
|
|
||||||
<ComboBoxItem Content="Segoe UI"/>
|
|
||||||
<ComboBoxItem Content="Arial"/>
|
|
||||||
<ComboBoxItem Content="Consolas"/>
|
|
||||||
<ComboBoxItem Content="Courier New"/>
|
|
||||||
<ComboBoxItem Content="Comic Sans MS"/>
|
|
||||||
<ComboBoxItem Content="Mouse Memoirs"/>
|
|
||||||
</ComboBox>
|
|
||||||
</DockPanel>
|
|
||||||
|
|
||||||
<!-- Font Color -->
|
|
||||||
<DockPanel Margin="0,0,0,4">
|
|
||||||
<TextBlock DockPanel.Dock="Left" Text="Font Color" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="70"/>
|
|
||||||
<TextBox Text="{Binding SelectedElement.ChatFontColor, UpdateSourceTrigger=PropertyChanged}"
|
|
||||||
Background="#0f3460" Foreground="#e0e0e0" BorderThickness="1"
|
|
||||||
BorderBrush="#2c3a5c" Padding="4,2" FontSize="11" Width="80"/>
|
|
||||||
</DockPanel>
|
|
||||||
|
|
||||||
<!-- Member Color -->
|
|
||||||
<DockPanel Margin="0,0,0,4">
|
|
||||||
<TextBlock DockPanel.Dock="Left" Text="Member" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="70"/>
|
|
||||||
<TextBox Text="{Binding SelectedElement.ChatMemberColor, UpdateSourceTrigger=PropertyChanged}"
|
|
||||||
Background="#0f3460" Foreground="#e0e0e0" BorderThickness="1"
|
|
||||||
BorderBrush="#2c3a5c" Padding="4,2" FontSize="11" Width="80"/>
|
|
||||||
</DockPanel>
|
|
||||||
|
|
||||||
<!-- Super Chat Color -->
|
|
||||||
<DockPanel Margin="0,0,0,4">
|
|
||||||
<TextBlock DockPanel.Dock="Left" Text="Super Chat" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="70"/>
|
|
||||||
<TextBox Text="{Binding SelectedElement.ChatSuperChatColor, UpdateSourceTrigger=PropertyChanged}"
|
|
||||||
Background="#0f3460" Foreground="#e0e0e0" BorderThickness="1"
|
|
||||||
BorderBrush="#2c3a5c" Padding="4,2" FontSize="11" Width="80"/>
|
|
||||||
</DockPanel>
|
|
||||||
|
|
||||||
<!-- Max Messages -->
|
|
||||||
<DockPanel Margin="0,0,0,4">
|
|
||||||
<TextBlock DockPanel.Dock="Left" Text="Max Msgs" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="70"/>
|
|
||||||
<Slider x:Name="ChatMaxMsgsSlider" Width="120" Minimum="1" Maximum="20" TickFrequency="1" IsSnapToTickEnabled="True"
|
|
||||||
Value="{Binding SelectedElement.ChatMaxMessages, Mode=TwoWay}"
|
|
||||||
VerticalAlignment="Center"/>
|
|
||||||
<TextBlock DockPanel.Dock="Right" Text="{Binding SelectedElement.ChatMaxMessages}" Foreground="#e0e0e0" FontSize="11" Margin="6,0,0,0" VerticalAlignment="Center" Width="24"/>
|
|
||||||
</DockPanel>
|
|
||||||
|
|
||||||
<!-- Background Color -->
|
|
||||||
<DockPanel Margin="0,0,0,4">
|
|
||||||
<TextBlock DockPanel.Dock="Left" Text="Background" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="70"/>
|
|
||||||
<TextBox Text="{Binding SelectedElement.ChatBackgroundColor, UpdateSourceTrigger=PropertyChanged}"
|
|
||||||
Background="#0f3460" Foreground="#e0e0e0" BorderThickness="1"
|
|
||||||
BorderBrush="#2c3a5c" Padding="4,2" FontSize="11" Width="80"/>
|
|
||||||
</DockPanel>
|
|
||||||
|
|
||||||
<!-- Background Opacity -->
|
|
||||||
<DockPanel Margin="0,0,0,4">
|
|
||||||
<TextBlock DockPanel.Dock="Left" Text="Bg Opacity" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="70"/>
|
|
||||||
<Slider x:Name="ChatBgOpacitySlider" Width="120" Minimum="0" Maximum="1"
|
|
||||||
Value="{Binding SelectedElement.ChatBackgroundOpacity, Mode=TwoWay}"
|
|
||||||
VerticalAlignment="Center"/>
|
|
||||||
<TextBlock DockPanel.Dock="Right" Text="{Binding SelectedElement.ChatBackgroundOpacity, StringFormat={}{0:0%}}" Foreground="#e0e0e0" FontSize="11" Margin="6,0,0,0" VerticalAlignment="Center" Width="32"/>
|
|
||||||
</DockPanel>
|
|
||||||
|
|
||||||
<!-- Fade After (seconds) -->
|
|
||||||
<DockPanel Margin="0,0,0,4">
|
|
||||||
<TextBlock DockPanel.Dock="Left" Text="Fade After" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="70"/>
|
|
||||||
<Slider x:Name="ChatFadeSlider" Width="120" Minimum="0" Maximum="60" TickFrequency="5" IsSnapToTickEnabled="True"
|
|
||||||
Value="{Binding SelectedElement.ChatFadeSeconds, Mode=TwoWay}"
|
|
||||||
VerticalAlignment="Center"/>
|
|
||||||
<TextBlock DockPanel.Dock="Right" Text="{Binding SelectedElement.ChatFadeSeconds, StringFormat='{}{0}s'}" Foreground="#e0e0e0" FontSize="11" Margin="6,0,0,0" VerticalAlignment="Center" Width="28"/>
|
|
||||||
</DockPanel>
|
|
||||||
|
|
||||||
<!-- Toggles -->
|
|
||||||
<CheckBox Content="Show timestamps" Foreground="#a0a0b0" FontSize="11" Margin="0,4,0,2"
|
|
||||||
IsChecked="{Binding SelectedElement.ChatShowTimestamps, Mode=TwoWay}"/>
|
|
||||||
<CheckBox Content="Show badges" Foreground="#a0a0b0" FontSize="11" Margin="0,0,0,2"
|
|
||||||
IsChecked="{Binding SelectedElement.ChatShowBadges, Mode=TwoWay}"/>
|
|
||||||
</StackPanel>
|
|
||||||
|
|
||||||
<!-- Background properties -->
|
|
||||||
<StackPanel Margin="0,8,0,0"
|
|
||||||
Visibility="{Binding SelectedElement.IsBackground, Converter={StaticResource BoolToVis}}">
|
|
||||||
<TextBlock Text="Background" Style="{StaticResource SubHeader}"/>
|
|
||||||
<StackPanel Margin="4,0,0,0">
|
|
||||||
<StackPanel Orientation="Horizontal" Margin="0,0,0,8">
|
|
||||||
<TextBlock Text="Use default background:" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center"/>
|
|
||||||
<ToggleButton Style="{StaticResource PillToggle}"
|
|
||||||
IsChecked="{Binding SelectedElement.UseDefaultBackground, Mode=TwoWay}"
|
|
||||||
Margin="8,0,0,0"/>
|
|
||||||
</StackPanel>
|
|
||||||
<Grid Margin="0,0,0,4"
|
|
||||||
Visibility="{Binding SelectedElement.UseDefaultBackground, Converter={StaticResource InverseBoolToVis}}">
|
|
||||||
<Grid.ColumnDefinitions>
|
|
||||||
<ColumnDefinition Width="*"/>
|
|
||||||
<ColumnDefinition Width="Auto"/>
|
|
||||||
</Grid.ColumnDefinitions>
|
|
||||||
<TextBox Grid.Column="0" Text="{Binding SelectedElement.CustomBackgroundPath, Mode=OneWay}"
|
|
||||||
IsReadOnly="True" Background="#0f3460" Foreground="#a0a0b0"
|
|
||||||
FontSize="11" VerticalAlignment="Center"/>
|
|
||||||
<Button Grid.Column="1" Content="Browse…" Command="{Binding BrowseBackgroundCommand}"
|
|
||||||
Style="{StaticResource YtButtonSecondary}" Margin="4,0,0,0" Padding="8,2"/>
|
|
||||||
</Grid>
|
|
||||||
<TextBlock Text="{Binding BackgroundSizeMismatchText}"
|
|
||||||
Foreground="#ef4444" FontSize="11" TextWrapping="Wrap"
|
|
||||||
Margin="0,4,0,0"
|
|
||||||
Visibility="{Binding BackgroundSizeMismatch, Converter={StaticResource BoolToVis}}"/>
|
|
||||||
</StackPanel>
|
|
||||||
</StackPanel>
|
|
||||||
</StackPanel>
|
|
||||||
</ScrollViewer>
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
<!-- Live mode: YouTube Live Chat (entire left panel replaced) -->
|
|
||||||
<Grid Grid.Row="0" Visibility="{Binding IsEditMode, Converter={StaticResource InverseBoolToVis}}">
|
|
||||||
<Grid.RowDefinitions>
|
|
||||||
<RowDefinition Height="Auto"/>
|
|
||||||
<RowDefinition Height="*"/>
|
|
||||||
</Grid.RowDefinitions>
|
|
||||||
|
|
||||||
<TextBlock Grid.Row="0" Text="LIVE CHAT" Style="{StaticResource SectionHeader}"/>
|
|
||||||
|
|
||||||
<!-- Inactive overlay (shown when NOT live) -->
|
|
||||||
<StackPanel Grid.Row="1" VerticalAlignment="Center" HorizontalAlignment="Center"
|
|
||||||
Visibility="{Binding ShowChatInactiveMessage, Converter={StaticResource BoolToVis}}">
|
|
||||||
<TextBlock Text="Chat is inactive" Foreground="#a0a0b0" FontSize="13"
|
|
||||||
HorizontalAlignment="Center" Margin="0,0,0,4"/>
|
|
||||||
<TextBlock Text="Chat becomes active once LlamaCasty is connected and streaming on YouTube."
|
|
||||||
Foreground="#666" FontSize="11" TextWrapping="Wrap"
|
|
||||||
HorizontalAlignment="Center" TextAlignment="Center" MaxWidth="240"/>
|
|
||||||
</StackPanel>
|
|
||||||
|
|
||||||
<!-- Message feed (shown when live) -->
|
|
||||||
<ListBox Grid.Row="1" Background="Transparent" BorderThickness="0"
|
|
||||||
ItemsSource="{Binding ChatMessages}"
|
|
||||||
ItemContainerStyle="{StaticResource YtListBoxItem}"
|
|
||||||
Visibility="{Binding ShowChatInactiveMessage, Converter={StaticResource InverseBoolToVis}}"
|
|
||||||
x:Name="ChatListBox">
|
|
||||||
<ListBox.ItemTemplate><DataTemplate>
|
|
||||||
<Border x:Name="MessageBorder" Padding="6,4" CornerRadius="4">
|
|
||||||
<Border.Style>
|
|
||||||
<Style TargetType="Border">
|
|
||||||
<Setter Property="Background" Value="Transparent"/>
|
|
||||||
<Style.Triggers>
|
|
||||||
<DataTrigger Binding="{Binding IsSuperChat}" Value="True">
|
|
||||||
<Setter Property="Background" Value="#2a1a0a"/>
|
|
||||||
<Setter Property="BorderBrush" Value="#eab308"/>
|
|
||||||
<Setter Property="BorderThickness" Value="1,0,1,0"/>
|
|
||||||
</DataTrigger>
|
|
||||||
</Style.Triggers>
|
|
||||||
</Style>
|
|
||||||
</Border.Style>
|
|
||||||
<StackPanel>
|
|
||||||
<StackPanel Orientation="Horizontal" Margin="0,0,0,2">
|
|
||||||
<TextBlock Text="⭐" FontSize="10" Margin="0,0,4,0"
|
|
||||||
VerticalAlignment="Center"
|
|
||||||
Visibility="{Binding IsMember, Converter={StaticResource BoolToVis}}"/>
|
|
||||||
<TextBlock Text="{Binding AuthorName}" Foreground="#e94560"
|
|
||||||
FontWeight="Bold" FontSize="12" VerticalAlignment="Center"/>
|
|
||||||
<TextBlock Text="{Binding SuperChatAmount, StringFormat=${0:F2}}"
|
|
||||||
Foreground="#eab308" FontWeight="Bold" FontSize="12"
|
|
||||||
Margin="8,0,0,0" VerticalAlignment="Center"
|
|
||||||
Visibility="{Binding IsSuperChat, Converter={StaticResource BoolToVis}}"/>
|
|
||||||
<TextBlock Text="{Binding SuperChatCurrency}" Foreground="#eab308"
|
|
||||||
FontSize="11" Margin="2,0,0,0" VerticalAlignment="Center"
|
|
||||||
Visibility="{Binding IsSuperChat, Converter={StaticResource BoolToVis}}"/>
|
|
||||||
</StackPanel>
|
|
||||||
<TextBlock Text="{Binding Message}" Foreground="#e0e0e0"
|
|
||||||
TextWrapping="Wrap" Margin="0,2,0,0"/>
|
|
||||||
</StackPanel>
|
|
||||||
</Border>
|
|
||||||
</DataTemplate></ListBox.ItemTemplate>
|
|
||||||
</ListBox>
|
|
||||||
</Grid>
|
|
||||||
</Grid>
|
|
||||||
</Border>
|
|
||||||
|
|
||||||
<!-- 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 ═══ -->
|
<!-- ═══ OVERLAY: Settings / Bug / Feature / About ═══ -->
|
||||||
<Grid Grid.RowSpan="3"
|
<controls:OverlayHost Grid.RowSpan="3"/>
|
||||||
Visibility="{Binding IsAnyOverlayOpen, Converter={StaticResource BoolToVis}}">
|
|
||||||
<Button Background="#99000000" BorderThickness="0" Cursor="Hand"
|
|
||||||
Command="{Binding CloseOverlayCommand}">
|
|
||||||
<Button.Template>
|
|
||||||
<ControlTemplate TargetType="Button">
|
|
||||||
<Border Background="{TemplateBinding Background}"/>
|
|
||||||
</ControlTemplate>
|
|
||||||
</Button.Template>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Border Background="#0A0C1A" CornerRadius="8" Width="650" MaxHeight="700"
|
|
||||||
VerticalAlignment="Center" HorizontalAlignment="Center"
|
|
||||||
Padding="28" BorderBrush="#333" BorderThickness="1">
|
|
||||||
<Grid>
|
|
||||||
<Grid.RowDefinitions>
|
|
||||||
<RowDefinition Height="Auto"/>
|
|
||||||
<RowDefinition Height="Auto"/>
|
|
||||||
</Grid.RowDefinitions>
|
|
||||||
|
|
||||||
<!-- Header -->
|
|
||||||
<Grid Grid.Row="0">
|
|
||||||
<TextBlock Text="{Binding OverlayTitle}" FontSize="18" FontWeight="Bold"
|
|
||||||
Foreground="#e0e0e0" VerticalAlignment="Center"/>
|
|
||||||
<Button HorizontalAlignment="Right" ToolTip="Close" Style="{StaticResource IconButton}"
|
|
||||||
Command="{Binding CloseOverlayCommand}">
|
|
||||||
<Path Data="M19,6.41L17.59,5 12,10.59 6.41,5 5,6.41 10.59,12 5,17.59 6.41,19 12,13.41 17.59,19 19,17.59 13.41,12z"
|
|
||||||
Fill="#d0d0d0" Width="14" Height="14" Stretch="Uniform"/>
|
|
||||||
</Button>
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
<!-- App Settings: broadcast fields live in the preview's
|
|
||||||
Text pull-out now — this panel keeps app-level prefs only.
|
|
||||||
(Currently empty; the overlay shell stays for future use.) -->
|
|
||||||
<StackPanel Grid.Row="1" Margin="0,16,0,0"
|
|
||||||
Visibility="{Binding IsSettingsOpen, Converter={StaticResource BoolToVis}}">
|
|
||||||
<TextBlock Text="Default Location for Recordings" Style="{StaticResource YtLabel}" Margin="0,0,0,4"/>
|
|
||||||
<TextBox Style="{StaticResource YtTextBox}"
|
|
||||||
Text="{Binding RecordFolderDisplay, Mode=OneWay}"
|
|
||||||
IsReadOnly="True" Margin="0,0,0,12" Foreground="#a0a0b0"/>
|
|
||||||
<StackPanel Orientation="Horizontal">
|
|
||||||
<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 -->
|
|
||||||
<StackPanel Grid.Row="1" Margin="0,16,0,0"
|
|
||||||
Visibility="{Binding IsBugOpen, Converter={StaticResource BoolToVis}}">
|
|
||||||
<TextBlock Text="What went wrong?" Style="{StaticResource YtLabel}" Margin="0,0,0,4"/>
|
|
||||||
<TextBox Style="{StaticResource YtTextBox}" Height="110" AcceptsReturn="True" TextWrapping="Wrap"
|
|
||||||
Margin="0,0,0,12"
|
|
||||||
Text="{Binding BugReportText, UpdateSourceTrigger=PropertyChanged}"
|
|
||||||
VerticalScrollBarVisibility="Auto"/>
|
|
||||||
|
|
||||||
<TextBlock Text="Email (optional)" Style="{StaticResource YtLabel}" Margin="0,0,0,4"/>
|
|
||||||
<TextBox Style="{StaticResource YtTextBox}" Margin="0,0,0,12"
|
|
||||||
Text="{Binding BugReportEmail, UpdateSourceTrigger=PropertyChanged}"/>
|
|
||||||
|
|
||||||
<TextBlock Text="Opens a pre-filled email to the LlamaCasty team." Style="{StaticResource YtLabel}"/>
|
|
||||||
<Button Content="Send Report" Command="{Binding SubmitBugCommand}"
|
|
||||||
Style="{StaticResource YtButton}" HorizontalAlignment="Right" Margin="0,16,0,0"/>
|
|
||||||
</StackPanel>
|
|
||||||
|
|
||||||
<!-- Feature Request -->
|
|
||||||
<StackPanel Grid.Row="1" Margin="0,16,0,0"
|
|
||||||
Visibility="{Binding IsFeatureOpen, Converter={StaticResource BoolToVis}}">
|
|
||||||
<TextBlock Text="What would you like to see?" Style="{StaticResource YtLabel}" Margin="0,0,0,4"/>
|
|
||||||
<TextBox Style="{StaticResource YtTextBox}" Height="110" AcceptsReturn="True" TextWrapping="Wrap"
|
|
||||||
Margin="0,0,0,12"
|
|
||||||
Text="{Binding FeatureRequestText, UpdateSourceTrigger=PropertyChanged}"
|
|
||||||
VerticalScrollBarVisibility="Auto"/>
|
|
||||||
|
|
||||||
<TextBlock Text="Email (optional)" Style="{StaticResource YtLabel}" Margin="0,0,0,4"/>
|
|
||||||
<TextBox Style="{StaticResource YtTextBox}" Margin="0,0,0,12"
|
|
||||||
Text="{Binding FeatureRequestEmail, UpdateSourceTrigger=PropertyChanged}"/>
|
|
||||||
|
|
||||||
<TextBlock Text="Opens a pre-filled email to the LlamaCasty team." Style="{StaticResource YtLabel}"/>
|
|
||||||
<Button Content="Send Request" Command="{Binding SubmitFeatureCommand}"
|
|
||||||
Style="{StaticResource YtButton}" HorizontalAlignment="Right" Margin="0,16,0,0"/>
|
|
||||||
</StackPanel>
|
|
||||||
|
|
||||||
<!-- About -->
|
|
||||||
<Grid Grid.Row="1" Margin="0,16,0,0"
|
|
||||||
Background="#171928"
|
|
||||||
Visibility="{Binding IsAboutOpen, Converter={StaticResource BoolToVis}}">
|
|
||||||
|
|
||||||
<!-- Hub view -->
|
|
||||||
<StackPanel Visibility="{Binding IsHubVisible, Converter={StaticResource BoolToVis}}">
|
|
||||||
<Image Source="/ytLive;component/Assets/llamacasty-logo.jpg" Height="125" Stretch="Uniform"
|
|
||||||
HorizontalAlignment="Center" Margin="0,0,0,12"/>
|
|
||||||
<TextBlock Text="{Binding AppVersionLabel}" Foreground="#a0a0b0" FontSize="12"
|
|
||||||
HorizontalAlignment="Center" Margin="0,4,0,0"/>
|
|
||||||
<TextBlock Text="Live streaming for YouTube — simple enough that even the most right-brained person can intuit it."
|
|
||||||
Foreground="#a0a0b0" FontSize="13" TextWrapping="Wrap" MaxWidth="380"
|
|
||||||
TextAlignment="Center" Margin="0,16,0,0"/>
|
|
||||||
<TextBlock Text="Free forever — live streams carry a brief "made with LlamaCasty!" flash. A subscription removes it and adds alerts."
|
|
||||||
Foreground="#888" FontSize="12" TextWrapping="Wrap" MaxWidth="380"
|
|
||||||
TextAlignment="Center" Margin="0,12,0,0"/>
|
|
||||||
<TextBlock Text="Made by gramps · llama chile shop" Foreground="#a0a0b0" FontSize="12"
|
|
||||||
HorizontalAlignment="Center" Margin="0,16,0,8"/>
|
|
||||||
|
|
||||||
<!-- Creator hub links -->
|
|
||||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center" Margin="0,0,0,8">
|
|
||||||
<Button Content="llama chile shop on YouTube" Command="{Binding OpenChannelCommand}"
|
|
||||||
Style="{StaticResource YtButtonSecondary}" Margin="0,0,8,0"/>
|
|
||||||
<Button Content="Mastodon" Command="{Binding OpenMastodonCommand}"
|
|
||||||
Style="{StaticResource YtButtonSecondary}"/>
|
|
||||||
</StackPanel>
|
|
||||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center">
|
|
||||||
<Button Content="Buy me a coffee" Command="{Binding OpenCoffeeCommand}"
|
|
||||||
Style="{StaticResource YtButtonSecondary}" Margin="0,0,8,0"/>
|
|
||||||
<Button Content="Unlock Premium" Command="{Binding OpenPremiumCommand}"
|
|
||||||
Style="{StaticResource YtButtonSecondary}"
|
|
||||||
IsEnabled="{Binding IsPremiumAvailable}"
|
|
||||||
ToolTip="Open the LlamaCasty subscription page"/>
|
|
||||||
<Button Content="Enter License Key" Command="{Binding OpenLicenseEntryCommand}"
|
|
||||||
Style="{StaticResource YtButtonSecondary}"
|
|
||||||
ToolTip="Activate a license key to remove the watermark"/>
|
|
||||||
</StackPanel>
|
|
||||||
|
|
||||||
<Button Content="Licenses & legal" Command="{Binding OpenLicensesCommand}"
|
|
||||||
Style="{StaticResource YtButtonSecondary}" HorizontalAlignment="Center"
|
|
||||||
Margin="0,16,0,0"/>
|
|
||||||
</StackPanel>
|
|
||||||
|
|
||||||
<!-- Licensing/legal panel -->
|
|
||||||
<StackPanel Visibility="{Binding IsLicensesOpen, Converter={StaticResource BoolToVis}}">
|
|
||||||
<TextBlock Text="Third-party notices & license texts" FontSize="15" FontWeight="Bold"
|
|
||||||
Foreground="#e0e0e0" Margin="0,0,0,8"/>
|
|
||||||
<Border Background="#10101e" CornerRadius="4" Padding="10" MaxHeight="360"
|
|
||||||
BorderBrush="#333" BorderThickness="1">
|
|
||||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
|
||||||
<TextBlock Text="{Binding LicensesText}" Foreground="#b8b8c8" FontSize="11"
|
|
||||||
FontFamily="Consolas" TextWrapping="Wrap"/>
|
|
||||||
</ScrollViewer>
|
|
||||||
</Border>
|
|
||||||
<Button Content="← Back to About" Style="{StaticResource YtButtonSecondary}"
|
|
||||||
HorizontalAlignment="Left" Margin="0,12,0,0"
|
|
||||||
Click="BackToAbout_Click"/>
|
|
||||||
</StackPanel>
|
|
||||||
|
|
||||||
<!-- License key entry panel -->
|
|
||||||
<StackPanel Visibility="{Binding IsLicenseEntryOpen, Converter={StaticResource BoolToVis}}">
|
|
||||||
<TextBlock Text="Activate LlamaCasty Premium" FontSize="15" FontWeight="Bold"
|
|
||||||
Foreground="#e0e0e0" Margin="0,0,0,8"/>
|
|
||||||
<TextBlock Text="Enter your license key to remove the branding watermark."
|
|
||||||
Foreground="#a0a0b0" FontSize="12" TextWrapping="Wrap" Margin="0,0,0,12"/>
|
|
||||||
<TextBlock Text="License Key" Style="{StaticResource YtLabel}" Margin="0,0,0,4"/>
|
|
||||||
<TextBox Style="{StaticResource YtTextBox}" Margin="0,0,0,12"
|
|
||||||
Text="{Binding LicenseKey, UpdateSourceTrigger=PropertyChanged}"
|
|
||||||
MaxLength="100"/>
|
|
||||||
<Button Content="Activate" Command="{Binding ActivateLicenseCommand}"
|
|
||||||
Style="{StaticResource YtButton}" HorizontalAlignment="Right"
|
|
||||||
Margin="0,0,0,8"/>
|
|
||||||
<TextBlock Text="{Binding LicenseMessage}" Foreground="#a0a0b0" FontSize="12"
|
|
||||||
TextWrapping="Wrap" Margin="0,0,0,8"/>
|
|
||||||
<Button Content="← Back to About" Style="{StaticResource YtButtonSecondary}"
|
|
||||||
HorizontalAlignment="Left" Margin="0,8,0,0"
|
|
||||||
Click="BackToAbout_Click"/>
|
|
||||||
</StackPanel>
|
|
||||||
</Grid>
|
|
||||||
</Grid>
|
|
||||||
</Border>
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
<!-- ═══ WEB SOURCE HOST: hidden panel for WebView2 controls ═══ -->
|
|
||||||
<Canvas x:Name="WebViewHostPanel" Grid.RowSpan="4"
|
|
||||||
Opacity="0" IsHitTestVisible="False" Background="Transparent"/>
|
|
||||||
|
|
||||||
<!-- ═══ TOASTS: non-blocking notifications (bottom-right, above footer) ═══ -->
|
<!-- ═══ TOASTS: non-blocking notifications (bottom-right, above footer) ═══ -->
|
||||||
<notifications:NotificationArea x:Name="ToastArea"
|
<notifications:NotificationArea x:Name="ToastArea"
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections;
|
|
||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
using System.Windows;
|
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.Helpers;
|
||||||
using ytLive.Models;
|
|
||||||
using ytLive.ViewModels;
|
using ytLive.ViewModels;
|
||||||
|
|
||||||
namespace ytLive;
|
namespace ytLive;
|
||||||
@@ -16,18 +14,24 @@ public partial class MainWindow : Window
|
|||||||
private readonly MainViewModel _viewModel;
|
private readonly MainViewModel _viewModel;
|
||||||
private Controls.TopBar _topBar = null!;
|
private Controls.TopBar _topBar = null!;
|
||||||
private Controls.PreviewPane _previewPane = null!;
|
private Controls.PreviewPane _previewPane = null!;
|
||||||
|
private Controls.LeftPanel _leftPanel = null!;
|
||||||
|
|
||||||
public MainWindow()
|
public MainWindow()
|
||||||
{
|
{
|
||||||
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.InitWebView2(WebViewHostPanel);
|
|
||||||
_viewModel.PropertyChanged += OnViewModelPropertyChanged;
|
_viewModel.PropertyChanged += OnViewModelPropertyChanged;
|
||||||
_viewModel.ChatMessages.CollectionChanged += OnChatMessagesChanged;
|
// 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")!;
|
||||||
UpdateTaskbarOverlay();
|
UpdateTaskbarOverlay();
|
||||||
UpdateSelectionOverlay();
|
UpdateSelectionOverlay();
|
||||||
_topBar.RefreshAvatar();
|
_topBar.RefreshAvatar();
|
||||||
@@ -38,33 +42,62 @@ public partial class MainWindow : Window
|
|||||||
private void MainWindow_Loaded(object sender, RoutedEventArgs e)
|
private void MainWindow_Loaded(object sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
AppLog.Write("MainWindow loaded");
|
AppLog.Write("MainWindow loaded");
|
||||||
|
// Web sources need the window HWND as the composition controller's parent,
|
||||||
|
// which does not exist in the ctor — InitWebView2 runs here (Loaded), after
|
||||||
|
// SourceInitialized. Nothing renders from web frames before this.
|
||||||
|
_viewModel.InitWebView2();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Window geometry persistence (launch size/position) ───
|
// ─── Window geometry (launch position/size) ───
|
||||||
// Saved on close (RestoreBounds so a maximized window remembers its
|
// Launch POSITION is derived, never restored: upper-left of the work area plus
|
||||||
// un-maximize rect); restored in the ctor before the window is shown.
|
// padding (creator ruling 2026-10-04). The old restore clamped the saved position
|
||||||
// Values clamp to the minimums and to the primary work area — a position
|
// to [work.Left - width + 120, work.Right - 120] horizontally and
|
||||||
// saved on a since-disconnected secondary monitor falls back on-screen.
|
// [work.Top, work.Bottom - 80] vertically — ranges wide enough to leave the window
|
||||||
|
// almost entirely off-screen, which is what the creator was seeing ("below the
|
||||||
|
// fold"). Size and maximized state are still persisted. Placement math is pure and
|
||||||
|
// tested (WindowPlacementTests); changing it back to restore position is reverting
|
||||||
|
// this block only, since the saved values are still written.
|
||||||
|
|
||||||
|
/// <summary>Launch inset from the work-area corner, in device-independent units —
|
||||||
|
/// so it scales with DPI rather than being a fixed physical pixel count.</summary>
|
||||||
|
internal const double LaunchPadding = 75;
|
||||||
|
|
||||||
|
/// <summary>Upper-left of <paramref name="workArea"/> plus padding, clamped so the
|
||||||
|
/// window is never pushed off-screen. The clamp is the point: a window that fits
|
||||||
|
/// stays wholly visible, and one too big to fit falls back to the work-area origin
|
||||||
|
/// rather than hanging off an edge.</summary>
|
||||||
|
internal static Point TopLeftPlacement(Rect workArea, double width, double height, double padding)
|
||||||
|
{
|
||||||
|
var left = workArea.Left + padding;
|
||||||
|
var top = workArea.Top + padding;
|
||||||
|
|
||||||
|
if (width <= workArea.Width) left = Math.Min(left, workArea.Right - width);
|
||||||
|
else left = workArea.Left;
|
||||||
|
|
||||||
|
if (height <= workArea.Height) top = Math.Min(top, workArea.Bottom - height);
|
||||||
|
else top = workArea.Top;
|
||||||
|
|
||||||
|
return new Point(left, top);
|
||||||
|
}
|
||||||
|
|
||||||
private void RestoreWindowGeometry()
|
private void RestoreWindowGeometry()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var saved = _viewModel.RestoreWindowState();
|
|
||||||
if (saved == null) return;
|
|
||||||
var (left, top, width, height, state) = saved.Value;
|
|
||||||
|
|
||||||
var work = SystemParameters.WorkArea;
|
var work = SystemParameters.WorkArea;
|
||||||
width = Math.Clamp(width, MinWidth, work.Width);
|
var saved = _viewModel.RestoreWindowState();
|
||||||
height = Math.Clamp(height, MinHeight, work.Height);
|
|
||||||
left = Math.Clamp(left, work.Left - width + 120, work.Right - 120);
|
|
||||||
top = Math.Clamp(top, work.Top, work.Bottom - 80);
|
|
||||||
|
|
||||||
Left = left;
|
// No saved state: keep the XAML-declared size rather than collapsing to Min*.
|
||||||
Top = top;
|
var width = Math.Clamp(saved?.Width ?? Width, MinWidth, work.Width);
|
||||||
|
var height = Math.Clamp(saved?.Height ?? Height, MinHeight, work.Height);
|
||||||
Width = width;
|
Width = width;
|
||||||
Height = height;
|
Height = height;
|
||||||
if (state == "Maximized") WindowState = WindowState.Maximized;
|
|
||||||
|
var place = TopLeftPlacement(work, width, height, LaunchPadding);
|
||||||
|
Left = place.X;
|
||||||
|
Top = place.Y;
|
||||||
|
|
||||||
|
if (saved?.State == "Maximized") WindowState = WindowState.Maximized;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -129,6 +162,27 @@ 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void UpdateTaskbarOverlay()
|
private void UpdateTaskbarOverlay()
|
||||||
{
|
{
|
||||||
TaskbarInfo.Overlay = _viewModel.IsLive
|
TaskbarInfo.Overlay = _viewModel.IsLive
|
||||||
@@ -136,65 +190,23 @@ public partial class MainWindow : Window
|
|||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void SourceNameBox_IsVisibleChanged(object sender, DependencyPropertyChangedEventArgs e)
|
|
||||||
{
|
|
||||||
if (sender is TextBox { IsVisible: true } box)
|
|
||||||
{
|
|
||||||
box.Focus();
|
|
||||||
box.SelectAll();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void BackToAbout_Click(object sender, RoutedEventArgs e)
|
|
||||||
{
|
|
||||||
if (DataContext is ViewModels.MainViewModel vm)
|
|
||||||
{
|
|
||||||
vm.IsLicensesOpen = false;
|
|
||||||
vm.IsLicenseEntryOpen = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void AddSourceButton_Click(object sender, RoutedEventArgs e)
|
|
||||||
{
|
|
||||||
if (sender is Button { ContextMenu: { } menu } button)
|
|
||||||
{
|
|
||||||
menu.PlacementTarget = button;
|
|
||||||
menu.Placement = System.Windows.Controls.Primitives.PlacementMode.Bottom;
|
|
||||||
menu.IsOpen = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Preview image selection / move / resize ───
|
|
||||||
private void Thumbnail_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
|
|
||||||
{
|
|
||||||
if (sender is Border { DataContext: Scene scene })
|
|
||||||
_viewModel.TransitionToScene(scene);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
private void Window_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
|
private void Window_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
|
||||||
{
|
{
|
||||||
if (e.OriginalSource is not DependencyObject original) return;
|
if (e.OriginalSource is not DependencyObject original) return;
|
||||||
var form = _viewModel.BroadcastForm;
|
var form = _viewModel.BroadcastForm;
|
||||||
if (form.IsDrawerOpen && !_previewPane.IsClickInsideDrawer(original))
|
var ypp = _viewModel.Ypp;
|
||||||
form.CloseDrawerCommand.Execute(null);
|
var test = _viewModel.TestSession;
|
||||||
|
if (!_previewPane.IsClickInsideDrawer(original))
|
||||||
|
{
|
||||||
|
if (form.IsDrawerOpen) form.CloseDrawerCommand.Execute(null);
|
||||||
|
if (ypp.IsDrawerOpen) ypp.CloseDrawerCommand.Execute(null);
|
||||||
|
if (test.IsDrawerOpen) test.CloseDrawerCommand.Execute(null);
|
||||||
|
}
|
||||||
if (_previewPane.IsClickInsidePreview(original)) return;
|
if (_previewPane.IsClickInsidePreview(original)) return;
|
||||||
if (IsDescendantOf(original, SourceList)) return;
|
if (_leftPanel.IsClickInside(original)) return;
|
||||||
if (IsDescendantOf(original, ElementsScrollViewer)) return;
|
|
||||||
_viewModel.SelectedElement = null;
|
_viewModel.SelectedElement = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool IsDescendantOf(DependencyObject? child, DependencyObject ancestor)
|
|
||||||
{
|
|
||||||
while (child != null && !ReferenceEquals(child, ancestor))
|
|
||||||
{
|
|
||||||
child = child is System.Windows.Media.Visual or System.Windows.Media.Media3D.Visual3D
|
|
||||||
? VisualTreeHelper.GetParent(child)
|
|
||||||
: LogicalTreeHelper.GetParent(child);
|
|
||||||
}
|
|
||||||
return child != null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void UpdateSelectionOverlay()
|
private void UpdateSelectionOverlay()
|
||||||
{
|
{
|
||||||
if (!_viewModel.IsEditMode)
|
if (!_viewModel.IsEditMode)
|
||||||
@@ -204,249 +216,6 @@ public partial class MainWindow : Window
|
|||||||
}
|
}
|
||||||
var selected = _viewModel.SelectedElement is { } s && Controls.PreviewPane.IsDraggableElement(s);
|
var selected = _viewModel.SelectedElement is { } s && Controls.PreviewPane.IsDraggableElement(s);
|
||||||
_previewPane.UpdateSelectionOverlay(selected);
|
_previewPane.UpdateSelectionOverlay(selected);
|
||||||
if (selected)
|
_leftPanel.OnSelectionChanged(_viewModel);
|
||||||
OpacityValueText.Text = $"{Math.Round(_viewModel.SelectedElement!.Opacity * 100)}%";
|
|
||||||
TakeSnapshot();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnChatMessagesChanged(object? sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
|
|
||||||
{
|
|
||||||
if (e.Action == System.Collections.Specialized.NotifyCollectionChangedAction.Add)
|
|
||||||
{
|
|
||||||
ChatListBox.ScrollIntoView(ChatListBox.Items[^1]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void MirrorButton_Click(object sender, RoutedEventArgs e)
|
|
||||||
{
|
|
||||||
if (_viewModel.SelectedElement is WebcamSceneConfig webcam)
|
|
||||||
webcam.IsMirrored = !webcam.IsMirrored;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ShapeButton_Click(object sender, RoutedEventArgs e)
|
|
||||||
{
|
|
||||||
if (_viewModel.SelectedElement is WebcamSceneConfig webcam)
|
|
||||||
webcam.ToggleClipShape();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void SourceMenu_Remove(object sender, RoutedEventArgs e)
|
|
||||||
{
|
|
||||||
if (sender is MenuItem { DataContext: SceneElement element })
|
|
||||||
_viewModel.RemoveSourceCommand.Execute(element);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void BackgroundMenu_ChangeCapture(object sender, RoutedEventArgs e)
|
|
||||||
{
|
|
||||||
_ = _viewModel.ChangeBackgroundCaptureAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void BackgroundMenu_RefreshCapture(object sender, RoutedEventArgs e)
|
|
||||||
{
|
|
||||||
_viewModel.RefreshBackgroundAutoCapture();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OpacitySlider_ValueChanged(object sender, RoutedPropertyChangedEventArgs<double> e)
|
|
||||||
=> OpacityValueText.Text = $"{Math.Round(e.NewValue * 100)}%";
|
|
||||||
|
|
||||||
|
|
||||||
private void SourceNameBox_KeyDown(object sender, KeyEventArgs e)
|
|
||||||
{
|
|
||||||
if (e.Key is Key.Enter or Key.Escape)
|
|
||||||
{
|
|
||||||
CommitSourceEdit(sender);
|
|
||||||
e.Handled = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void SourceNameBox_LostFocus(object sender, RoutedEventArgs e)
|
|
||||||
{
|
|
||||||
CommitSourceEdit(sender);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void CommitSourceEdit(object sender)
|
|
||||||
{
|
|
||||||
if (sender is FrameworkElement { DataContext: SceneElement element })
|
|
||||||
element.IsEditing = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── List drag-to-reorder (scenes list + sources list) ───
|
|
||||||
private Point _dragStartPoint;
|
|
||||||
private int _dragIndex = -1;
|
|
||||||
private bool _isDragging;
|
|
||||||
private object? _lastHoveredItem;
|
|
||||||
|
|
||||||
private void List_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
|
|
||||||
{
|
|
||||||
var listBox = (ListBox)sender;
|
|
||||||
var item = FindItemContainerAt(listBox, e.GetPosition(listBox));
|
|
||||||
if (item == null)
|
|
||||||
{
|
|
||||||
if (ReferenceEquals(listBox, SourceList))
|
|
||||||
_viewModel.SelectedElement = null;
|
|
||||||
_dragIndex = -1;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (listBox.ItemsSource is not IList items || items.Count == 0)
|
|
||||||
{
|
|
||||||
_dragIndex = -1;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
_dragIndex = listBox.Items.IndexOf(item.DataContext);
|
|
||||||
_dragStartPoint = e.GetPosition(listBox);
|
|
||||||
_isDragging = false;
|
|
||||||
_lastHoveredItem = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void List_PreviewMouseMove(object sender, MouseEventArgs e)
|
|
||||||
{
|
|
||||||
if (_dragIndex < 0) return;
|
|
||||||
var listBox = (ListBox)sender;
|
|
||||||
if (e.LeftButton != MouseButtonState.Pressed || listBox.ItemsSource is not IList items)
|
|
||||||
{
|
|
||||||
EndListDrag(listBox);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var position = e.GetPosition(listBox);
|
|
||||||
if (!_isDragging)
|
|
||||||
{
|
|
||||||
if (Math.Abs(position.X - _dragStartPoint.X) < SystemParameters.MinimumHorizontalDragDistance &&
|
|
||||||
Math.Abs(position.Y - _dragStartPoint.Y) < SystemParameters.MinimumVerticalDragDistance)
|
|
||||||
return;
|
|
||||||
_isDragging = true;
|
|
||||||
listBox.CaptureMouse();
|
|
||||||
}
|
|
||||||
|
|
||||||
var item = FindItemContainerAt(listBox, position);
|
|
||||||
if (item == null) return;
|
|
||||||
|
|
||||||
var targetItem = item.DataContext;
|
|
||||||
if (ReferenceEquals(targetItem, items[_dragIndex]) || ReferenceEquals(targetItem, _lastHoveredItem))
|
|
||||||
return;
|
|
||||||
|
|
||||||
var targetIndex = listBox.Items.IndexOf(targetItem);
|
|
||||||
if (targetIndex < 0) return;
|
|
||||||
|
|
||||||
var dragged = items[_dragIndex];
|
|
||||||
items.RemoveAt(_dragIndex);
|
|
||||||
items.Insert(targetIndex, dragged);
|
|
||||||
_dragIndex = targetIndex;
|
|
||||||
_lastHoveredItem = targetItem;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void List_PreviewMouseLeftButtonUp(object sender, MouseButtonEventArgs e)
|
|
||||||
{
|
|
||||||
EndListDrag((ListBox)sender);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void EndListDrag(ListBox listBox)
|
|
||||||
{
|
|
||||||
if (_isDragging)
|
|
||||||
listBox.ReleaseMouseCapture();
|
|
||||||
_dragIndex = -1;
|
|
||||||
_isDragging = false;
|
|
||||||
_lastHoveredItem = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static ListBoxItem? FindItemContainerAt(ListBox listBox, Point position)
|
|
||||||
{
|
|
||||||
var hit = listBox.InputHitTest(position) as DependencyObject;
|
|
||||||
while (hit != null && hit != listBox)
|
|
||||||
{
|
|
||||||
if (hit is ListBoxItem item) return item;
|
|
||||||
hit = VisualTreeHelper.GetParent(hit);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private Models.SceneElement? _snapshotElement;
|
|
||||||
private string _snapshotBorderColor = string.Empty;
|
|
||||||
private int _snapshotBorderWidth;
|
|
||||||
private int _snapshotTimerMinutes = 15;
|
|
||||||
private string? _snapshotWebUri;
|
|
||||||
|
|
||||||
private void TakeSnapshot()
|
|
||||||
{
|
|
||||||
if (_viewModel.SelectedElement is Models.Source src)
|
|
||||||
{
|
|
||||||
_snapshotElement = src;
|
|
||||||
_snapshotBorderColor = src.BorderColor;
|
|
||||||
_snapshotBorderWidth = src.BorderWidth;
|
|
||||||
_snapshotTimerMinutes = src.TimerMinutes;
|
|
||||||
_snapshotWebUri = src.WebUri;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void WebcamBorderColorPicker_Click(object sender, RoutedEventArgs e)
|
|
||||||
{
|
|
||||||
if (_viewModel.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)
|
|
||||||
{
|
|
||||||
if (_snapshotElement is Models.Source src)
|
|
||||||
{
|
|
||||||
src.BorderColor = _snapshotBorderColor;
|
|
||||||
src.BorderWidth = _snapshotBorderWidth;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void WebcamBorderAccept_Click(object sender, RoutedEventArgs e)
|
|
||||||
{
|
|
||||||
if (_snapshotElement is Models.Source src)
|
|
||||||
{
|
|
||||||
_snapshotBorderColor = src.BorderColor;
|
|
||||||
_snapshotBorderWidth = src.BorderWidth;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void CountdownRevert_Click(object sender, RoutedEventArgs e)
|
|
||||||
{
|
|
||||||
if (_snapshotElement is Models.Source src)
|
|
||||||
src.TimerMinutes = _snapshotTimerMinutes;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void CountdownAccept_Click(object sender, RoutedEventArgs e)
|
|
||||||
{
|
|
||||||
if (_snapshotElement is Models.Source src)
|
|
||||||
_snapshotTimerMinutes = src.TimerMinutes;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void WebRevert_Click(object sender, RoutedEventArgs e)
|
|
||||||
{
|
|
||||||
if (_snapshotElement is Models.Source src)
|
|
||||||
src.WebUri = string.Empty;
|
|
||||||
_snapshotWebUri = string.Empty;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void WebAccept_Click(object sender, RoutedEventArgs e)
|
|
||||||
{
|
|
||||||
if (_snapshotElement is Models.Source src)
|
|
||||||
_snapshotWebUri = src.WebUri;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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,62 @@
|
|||||||
|
namespace ytLive.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The one entitlement rule, as pure policy: what a build is allowed to emit.
|
||||||
|
///
|
||||||
|
/// <para><b>Why pixels and a frame rate rather than "720p".</b> The paid delta is output
|
||||||
|
/// quality, and a resolution name is a marketing word, not a measurable bound — 720p60 is
|
||||||
|
/// 921,600 pixels at twice the frame rate, and vertical 720×1280 is the same pixel count
|
||||||
|
/// with a different shape. A pixel budget plus a frame-rate ceiling expresses the same
|
||||||
|
/// promise without a lookup table that a new tier can quietly escape.</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: 921,600 px = 1280×720, i.e. one 720p frame.</summary>
|
||||||
|
public const int FreeMaxPixels = 921_600;
|
||||||
|
|
||||||
|
/// <summary>Free ceiling: 30 frames per second.</summary>
|
||||||
|
public const int FreeMaxFps = 30;
|
||||||
|
|
||||||
|
/// <summary>The named tier a capped build falls back to when nothing else qualifies.</summary>
|
||||||
|
public const string FreeFallbackName = "720p30";
|
||||||
|
|
||||||
|
/// <summary>Is this tier within the free budget? Pro may use anything shipped.</summary>
|
||||||
|
public static bool IsAllowed(QualityOption tier, bool pro)
|
||||||
|
=> pro
|
||||||
|
|| (tier.Width * tier.Height <= FreeMaxPixels && 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, 6.0, 1280, 720);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -72,6 +72,14 @@ public class Scene : INotifyPropertyChanged
|
|||||||
|
|
||||||
public bool IsChatScene { get; init; }
|
public bool IsChatScene { get; init; }
|
||||||
|
|
||||||
|
/// <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>
|
||||||
|
|||||||
@@ -19,6 +19,17 @@ public enum BorderAnimation
|
|||||||
Sparkles
|
Sparkles
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Whether an element's pixel content is fixed (bake once into the
|
||||||
|
/// compositor's cached base) or changes every frame (composite each tick).
|
||||||
|
/// See ai.md "SceneGraph component + baked-crust compositor" (TASK 31).</summary>
|
||||||
|
public enum ElementKind
|
||||||
|
{
|
||||||
|
/// <summary>Pixels never change at runtime (images, background art).</summary>
|
||||||
|
Static,
|
||||||
|
/// <summary>Pixels change every frame (webcam, live capture, chat, web source).</summary>
|
||||||
|
Dynamic
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A thing rendered in a scene: a multi-instance Source (image/background/text)
|
/// A thing rendered in a scene: a multi-instance Source (image/background/text)
|
||||||
/// or a singleton resource's per-scene config (webcam). Carries the shared
|
/// or a singleton resource's per-scene config (webcam). Carries the shared
|
||||||
@@ -44,6 +55,15 @@ public abstract class SceneElement : INotifyPropertyChanged
|
|||||||
private string _name = string.Empty;
|
private string _name = string.Empty;
|
||||||
public string Name { get => _name; set => Set(ref _name, value); }
|
public string Name { get => _name; set => Set(ref _name, value); }
|
||||||
|
|
||||||
|
/// <summary>Whether this element's pixels are fixed (Static) or change every
|
||||||
|
/// frame (Dynamic). The compositor bakes all Static layers below the first
|
||||||
|
/// Dynamic layer once (see <c>SceneGraph.GetSplitPoint</c>).</summary>
|
||||||
|
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;
|
||||||
|
|
||||||
@@ -61,6 +81,15 @@ public abstract class SceneElement : INotifyPropertyChanged
|
|||||||
|
|
||||||
/// <summary>True only for Countdown-type sources. Gates timer properties panel.</summary>
|
/// <summary>True only for Countdown-type sources. Gates timer properties panel.</summary>
|
||||||
public virtual bool IsCountdown => false;
|
public virtual bool IsCountdown => false;
|
||||||
|
|
||||||
|
/// <summary>True only for AlertBox-type sources. Gates stream alerts properties panel.</summary>
|
||||||
|
public virtual bool IsAlertBox => false;
|
||||||
|
public virtual bool IsTextOverlay => false;
|
||||||
|
|
||||||
|
/// <summary>The scoreboard strip: a per-scene, full-width bar.</summary>
|
||||||
|
public virtual bool IsScoreboard => false;
|
||||||
|
public virtual bool IsMediaSource => false;
|
||||||
|
public virtual bool IsLiveCapture => false;
|
||||||
public virtual bool IsBackground { get; set; }
|
public virtual bool IsBackground { get; set; }
|
||||||
|
|
||||||
private bool _useDefaultBackground = true;
|
private bool _useDefaultBackground = true;
|
||||||
@@ -88,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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,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)
|
||||||
{
|
{
|
||||||
@@ -144,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;
|
||||||
@@ -161,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
|
||||||
@@ -180,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
|
||||||
@@ -207,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,56 @@
|
|||||||
|
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
|
||||||
|
{
|
||||||
|
public static SessionStartKind For(bool recordArmed, bool onAirArmed, bool editing)
|
||||||
|
{
|
||||||
|
// Neither pill 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 editing ? SessionStartKind.Test : 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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -13,7 +13,15 @@ public enum SourceType
|
|||||||
Countdown,
|
Countdown,
|
||||||
WebSource,
|
WebSource,
|
||||||
ChatBox,
|
ChatBox,
|
||||||
MediaSource
|
/// <summary>The session scoreboard strip: full-width, state not events.</summary>
|
||||||
|
Scoreboard,
|
||||||
|
MediaSource,
|
||||||
|
/// <summary>The designated alert zone: the six monetization events play their
|
||||||
|
/// unique celebration animations here (OBS alert-box equivalent, native).</summary>
|
||||||
|
AlertBox,
|
||||||
|
/// <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>Distinguishes how a media source presents its content (TASK 21).</summary>
|
/// <summary>Distinguishes how a media source presents its content (TASK 21).</summary>
|
||||||
@@ -37,6 +45,19 @@ public enum ClipShape
|
|||||||
Round
|
Round
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>How an alert box presents its announcement strip (TASK 47). The strip is
|
||||||
|
/// the full-width pill naming the event ("Viewer — Super Chat · $12.34").</summary>
|
||||||
|
public enum AlertDisplayMethod
|
||||||
|
{
|
||||||
|
/// <summary>Marquee: the pill scrolls across the top edge, paced so the text is
|
||||||
|
/// read <see cref="AlertOverlayLayer.TickerReadsPerAlert"/> times per alert.</summary>
|
||||||
|
TickerScroll,
|
||||||
|
/// <summary>Pulse: the static pill blinks on and off for the whole alert.</summary>
|
||||||
|
Flash,
|
||||||
|
/// <summary>Static: the pill sits centred and still for the whole alert.</summary>
|
||||||
|
Solid
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A multi-instance scene object: image/background/text (screen/window later).
|
/// A multi-instance scene object: image/background/text (screen/window later).
|
||||||
/// The webcam is NOT a Source — it's a singleton resource whose per-scene usage
|
/// The webcam is NOT a Source — it's a singleton resource whose per-scene usage
|
||||||
@@ -57,13 +78,32 @@ 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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public override bool IsDraggable => Type is SourceType.Image or SourceType.WebSource or SourceType.ChatBox;
|
/// <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.AlertBox or SourceType.Blur;
|
||||||
|
|
||||||
|
/// <summary>Live captures, chat, web sources and media are dynamic; static art is fixed.</summary>
|
||||||
|
public override ElementKind Kind => Type is SourceType.DisplayCapture or SourceType.WindowCapture
|
||||||
|
or SourceType.ChatBox or SourceType.WebSource or SourceType.MediaSource or SourceType.AlertBox
|
||||||
|
or SourceType.Blur
|
||||||
|
? 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;
|
||||||
|
|
||||||
|
/// <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;
|
||||||
@@ -89,7 +129,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;
|
||||||
@@ -111,14 +151,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.ChatBox or SourceType.WebSource or SourceType.MediaSource
|
||||||
|
or SourceType.AlertBox or SourceType.Scoreboard;
|
||||||
|
|
||||||
public override ImageSource? DisplaySource => IsLiveCapture
|
public override ImageSource? DisplaySource => IsLiveCapture
|
||||||
? (VideoImageSource ?? _imageSource)
|
? (VideoImageSource ?? _imageSource)
|
||||||
: (Type is SourceType.ChatBox or SourceType.WebSource ? 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;
|
||||||
@@ -169,7 +232,77 @@ 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;
|
||||||
|
|
||||||
|
// Alert video config (TASK 47): the shipped default clip lives in the Asset
|
||||||
|
// table (seeded like image assets); the creator's replacement is a FILE PATH
|
||||||
|
// reference only — never into the DB (creator ruling, 2026-09-26). Runtime
|
||||||
|
// order: replacement path exists → DB default → six celebration animations.
|
||||||
|
private string? _alertVideoAssetId;
|
||||||
|
public string? AlertVideoAssetId
|
||||||
|
{
|
||||||
|
get => _alertVideoAssetId;
|
||||||
|
set => Set(ref _alertVideoAssetId, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string? _alertVideoPath;
|
||||||
|
public string? AlertVideoPath
|
||||||
|
{
|
||||||
|
get => _alertVideoPath;
|
||||||
|
set => Set(ref _alertVideoPath, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool _alertUseDefaultVideo = true;
|
||||||
|
public bool AlertUseDefaultVideo
|
||||||
|
{
|
||||||
|
get => _alertUseDefaultVideo;
|
||||||
|
set => Set(ref _alertUseDefaultVideo, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool _alertShowTicker = true;
|
||||||
|
public bool AlertShowTicker
|
||||||
|
{
|
||||||
|
get => _alertShowTicker;
|
||||||
|
set => Set(ref _alertShowTicker, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private double _alertVideoVolume = 1.0;
|
||||||
|
public double AlertVideoVolume
|
||||||
|
{
|
||||||
|
get => _alertVideoVolume;
|
||||||
|
set => Set(ref _alertVideoVolume, Math.Clamp(value, 0.0, 1.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
private AlertDisplayMethod _alertDisplayMethod = AlertDisplayMethod.TickerScroll;
|
||||||
|
public AlertDisplayMethod AlertDisplayMethod
|
||||||
|
{
|
||||||
|
get => _alertDisplayMethod;
|
||||||
|
set => Set(ref _alertDisplayMethod, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override bool IsAlertBox => Type == SourceType.AlertBox;
|
||||||
|
|
||||||
// Chat box config (TASK 3.18)
|
// Chat box config (TASK 3.18)
|
||||||
private double _chatFontSize = 16;
|
private double _chatFontSize = 16;
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -13,5 +14,223 @@ public class WebcamSceneConfig : SceneElement
|
|||||||
public string WebcamId { get; init; } = string.Empty;
|
public string WebcamId { get; init; } = string.Empty;
|
||||||
public override bool IsWebcam => true;
|
public override bool IsWebcam => true;
|
||||||
public override bool IsDraggable => true;
|
public override bool IsDraggable => true;
|
||||||
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
namespace ytLive.Models;
|
||||||
|
|
||||||
|
/// <summary>One user-visible open window offered by the "Capture Window…"
|
||||||
|
/// submenu. Carries its owning process name for disambiguation and the raw
|
||||||
|
/// HWND that becomes the "window:<hwnd>" capture key.</summary>
|
||||||
|
public sealed class WindowInfo
|
||||||
|
{
|
||||||
|
public WindowInfo(string title, string processName, long hwnd, int processId)
|
||||||
|
{
|
||||||
|
Title = title;
|
||||||
|
ProcessName = processName;
|
||||||
|
Hwnd = hwnd;
|
||||||
|
ProcessId = processId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Title { get; }
|
||||||
|
public string ProcessName { get; }
|
||||||
|
public long Hwnd { get; }
|
||||||
|
public int ProcessId { get; }
|
||||||
|
|
||||||
|
/// <summary>Menu label: "Title — ProcessName" (e.g. "bash — WindowsTerminal").</summary>
|
||||||
|
public string Label => $"{Title} — {ProcessName}";
|
||||||
|
|
||||||
|
/// <summary>The capture key fed to the screen-capture factory
|
||||||
|
/// ("window:<hwnd>").</summary>
|
||||||
|
public string CaptureKey => $"window:0x{Hwnd:X}";
|
||||||
|
}
|
||||||
@@ -18,6 +18,21 @@ public record ReusableStream(string Id, string IngestionAddress, string StreamNa
|
|||||||
public string RtmpUrl => $"{IngestionAddress}/{StreamName}";
|
public string RtmpUrl => $"{IngestionAddress}/{StreamName}";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>The six monetization events YouTube's live chat feed emits (the ONLY
|
||||||
|
/// native event stream there is — free subscribers produce no event). Each kind
|
||||||
|
/// drives its own celebration animation in the AlertBox. <c>None</c> = a normal
|
||||||
|
/// chat row: it never enqueues an alert (subscriber mentions stay chat rows).</summary>
|
||||||
|
public enum ChatEventKind
|
||||||
|
{
|
||||||
|
None,
|
||||||
|
SuperChat,
|
||||||
|
SuperSticker,
|
||||||
|
NewMember,
|
||||||
|
MemberGift,
|
||||||
|
GiftReceived,
|
||||||
|
MemberMilestone,
|
||||||
|
}
|
||||||
|
|
||||||
public class ChatMessage
|
public class ChatMessage
|
||||||
{
|
{
|
||||||
public string Id { get; init; } = Guid.NewGuid().ToString();
|
public string Id { get; init; } = Guid.NewGuid().ToString();
|
||||||
@@ -29,6 +44,31 @@ public class ChatMessage
|
|||||||
public bool IsSuperChat { get; set; }
|
public bool IsSuperChat { get; set; }
|
||||||
public double SuperChatAmount { get; set; }
|
public double SuperChatAmount { get; set; }
|
||||||
public string? SuperChatCurrency { get; set; }
|
public string? SuperChatCurrency { get; set; }
|
||||||
|
|
||||||
|
/// <summary>YouTube's own pre-rendered amount ("$12.34") — preferred over
|
||||||
|
/// recomposing amount+currency ourselves, which mangles non-dollar locales.</summary>
|
||||||
|
public string? SuperChatDisplayString { get; set; }
|
||||||
|
|
||||||
public bool IsMember { get; set; }
|
public bool IsMember { get; set; }
|
||||||
public string? MembershipLevel { get; set; }
|
public string? MembershipLevel { get; set; }
|
||||||
|
|
||||||
|
public ChatEventKind Kind { get; set; }
|
||||||
|
|
||||||
|
public string? SuperStickerDescription { get; set; }
|
||||||
|
|
||||||
|
/// <summary>membershipGiftingEvent: how many memberships were bought.</summary>
|
||||||
|
public int GiftNewMembersCount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>giftMembershipReceivedEvent: who gifted this one.</summary>
|
||||||
|
public string? GifterName { get; set; }
|
||||||
|
|
||||||
|
/// <summary>memberMilestoneChatEvent: streak months (0 = YouTube didn't
|
||||||
|
/// expose a count — the animation falls back to the message text).</summary>
|
||||||
|
public int MilestoneMonths { get; set; }
|
||||||
|
|
||||||
|
/// <summary>TASK 41: the Test Stream dock injects synthetic member / SuperChat /
|
||||||
|
/// subscriber events locally (YouTube's insert API only supports text), so the
|
||||||
|
/// buffer can label them honestly — simulated events never reach YouTube.</summary>
|
||||||
|
public bool IsSimulated { get; set; }
|
||||||
|
public bool IsPinned { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
namespace ytLive.Models;
|
||||||
|
|
||||||
|
/// <summary>One captured snapshot of a connected channel's YPP-relevant numbers
|
||||||
|
/// (TASK 39). Appended on every refresh and archived in SQLite so a later slice
|
||||||
|
/// can compute velocity/(de)acceleration/ETA from the history.</summary>
|
||||||
|
public sealed class YppStatSnapshot
|
||||||
|
{
|
||||||
|
public DateTime CapturedAtUtc { get; set; }
|
||||||
|
public long SubscriberCount { get; set; }
|
||||||
|
public long VideoCount { get; set; }
|
||||||
|
public long TotalViews { get; set; }
|
||||||
|
public int UploadsLast90Days { get; set; }
|
||||||
|
public bool CommunityGuidelinesGoodStanding { get; set; }
|
||||||
|
public bool CopyrightStrikesGoodStanding { get; set; }
|
||||||
|
public bool ContentIdClaimsGoodStanding { get; set; }
|
||||||
|
public bool OverallGoodStanding { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
namespace ytLive.Models;
|
||||||
|
|
||||||
|
/// <summary>YPP eligibility thresholds as versioned, date-aware DATA — never
|
||||||
|
/// constants (ai.md "Journey tracker"). Tier 1 (fan funding) is time-invariant;
|
||||||
|
/// Tier 2 (ad revenue) doubles its long-form-hours and Shorts-view bars for NEW
|
||||||
|
/// applicants after 2027-02-01. Hardcoding the pre-2027 numbers would show the
|
||||||
|
/// wrong ETA once the doubling lands, so callers resolve the active Tier 2
|
||||||
|
/// threshold through <see cref="Tier2For"/>.</summary>
|
||||||
|
public static class YppThresholds
|
||||||
|
{
|
||||||
|
public sealed record TierThreshold(string Name, long Subscribers, long WatchHours, long ShortsViews);
|
||||||
|
|
||||||
|
/// <summary>The date the Tier-2 ad-revenue bars double for new applicants.</summary>
|
||||||
|
public static readonly DateTime Tier2DoublingDate = new(2027, 2, 1);
|
||||||
|
|
||||||
|
/// <summary>Fan-funding tier (before the full ad/YouTube Premium revenue tier).</summary>
|
||||||
|
public static TierThreshold Tier1 { get; } = new("Fan funding", 500, 3_000, 3_000_000);
|
||||||
|
|
||||||
|
/// <summary>The Tier-2 bar for a given point in time — 4,000h / 10M Shorts
|
||||||
|
/// before the doubling date, 8,000h / 20M Shorts on or after it.</summary>
|
||||||
|
public static TierThreshold Tier2For(DateTime when) => when >= Tier2DoublingDate
|
||||||
|
? new TierThreshold("Ad revenue", 1_000, 8_000, 20_000_000)
|
||||||
|
: new TierThreshold("Ad revenue", 1_000, 4_000, 10_000_000);
|
||||||
|
|
||||||
|
/// <summary>The currently-active Tier-2 threshold (resolved against today).</summary>
|
||||||
|
public static TierThreshold Tier2Now => Tier2For(DateTime.UtcNow);
|
||||||
|
|
||||||
|
/// <summary>Valid public long-form uploads required in the trailing 90 days
|
||||||
|
/// to keep each tier route eligible.</summary>
|
||||||
|
public const int UploadsRequiredPer90Days = 3;
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
using System.Windows.Media;
|
||||||
|
|
||||||
|
namespace ytLive.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The transient zoom layer. It is a real scene element, but a special one: while
|
||||||
|
/// active the SceneCompositor draws it LAST (layer n+1, above everything) as a live
|
||||||
|
/// 2x view of a FIXED capture window (the box/2 region centred on where the creator
|
||||||
|
/// clicked), sampled from the just-composited frame — so it captures ALL layers and
|
||||||
|
/// keeps showing the same content no matter where the box is dragged (creator ruling
|
||||||
|
/// 2026-10-08). Its X/Y/W/H are the movable BOX placement; CaptureX/CaptureY is the
|
||||||
|
/// anchored capture window. While inactive it is an invisible placeholder that is
|
||||||
|
/// never persisted (LayoutStore hand-rolls only Source/WebcamSceneConfig rows) — the
|
||||||
|
/// VM re-inserts it on load and on scene creation, mirroring how backgrounds heal.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ZoomSceneElement : SceneElement
|
||||||
|
{
|
||||||
|
/// <summary>True only while a zoom box is live; flips Kind so the layer leaves
|
||||||
|
/// the baked static base and composites per-frame.</summary>
|
||||||
|
public bool IsZoomActive { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Master-space top-left of the fixed capture window. Set once by
|
||||||
|
/// <c>ActivateZoom</c>; moving the box must never touch it.</summary>
|
||||||
|
public double CaptureX { get; set; }
|
||||||
|
public double CaptureY { get; set; }
|
||||||
|
|
||||||
|
public override ElementKind Kind => IsZoomActive ? ElementKind.Dynamic : ElementKind.Static;
|
||||||
|
|
||||||
|
public override bool IsZoomLayer => true;
|
||||||
|
|
||||||
|
public override bool IsDraggable => false;
|
||||||
|
|
||||||
|
/// <summary>Never rendered by the element template or the resolver — the preview
|
||||||
|
/// magnifier (ZoomBox) and the output compositor's zoom pass own its pixels.</summary>
|
||||||
|
public override ImageSource? DisplaySource => null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace ytLive.Models;
|
||||||
|
|
||||||
|
public enum ZoomShape
|
||||||
|
{
|
||||||
|
Circle,
|
||||||
|
VerticalRectangle,
|
||||||
|
HorizontalRectangle
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace ytLive.Models;
|
||||||
|
|
||||||
|
public class ZoomState
|
||||||
|
{
|
||||||
|
public bool IsActive { get; set; }
|
||||||
|
public double X { get; set; }
|
||||||
|
public double Y { get; set; }
|
||||||
|
public double Width { get; set; }
|
||||||
|
public double Height { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Master-space top-left of the FIXED capture window (the box/2 region
|
||||||
|
/// centred on the click). The box can be dragged anywhere; this never changes —
|
||||||
|
/// the box always shows this same region magnified 2x (creator ruling 2026-10-08).</summary>
|
||||||
|
public double CaptureX { get; set; }
|
||||||
|
public double CaptureY { get; set; }
|
||||||
|
|
||||||
|
public bool IsMoving { get; set; }
|
||||||
|
public double GrabOffsetX { get; set; }
|
||||||
|
public double GrabOffsetY { get; set; }
|
||||||
|
|
||||||
|
public double Left => X;
|
||||||
|
public double Top => Y;
|
||||||
|
public double Right => X + Width;
|
||||||
|
public double Bottom => Y + Height;
|
||||||
|
}
|
||||||
@@ -7,14 +7,18 @@ Plain data types. No logic beyond what a property can carry. See
|
|||||||
|------|---------|
|
|------|---------|
|
||||||
| `Scene.cs` | A scene: `Name`, `IsHidden`, `IsChatScene`, `IsEditing`, **`HasBackdrop`** (Live-only policy flag, default off — only the canonical Live scene has one, see `SceneCatalog`), `Elements` collection (images + webcam config), `WebcamConfig` accessor |
|
| `Scene.cs` | A scene: `Name`, `IsHidden`, `IsChatScene`, `IsEditing`, **`HasBackdrop`** (Live-only policy flag, default off — only the canonical Live scene has one, see `SceneCatalog`), `Elements` collection (images + webcam config), `WebcamConfig` accessor |
|
||||||
| `SceneCatalog.cs` | The five canonical scenes (Starting/Live/BRB/Chat/Ending) — the product, by name: `All`, `IsCanonical`, `Is(name, canonical)` (case-insensitive trim), `IsChat`, `HasBackdrop(name)` (true only for Live). Drives the empty-DB seed, the "+" missing-scenes menu, and `MainViewModel.EnforceBackdropPolicy` |
|
| `SceneCatalog.cs` | The five canonical scenes (Starting/Live/BRB/Chat/Ending) — the product, by name: `All`, `IsCanonical`, `Is(name, canonical)` (case-insensitive trim), `IsChat`, `HasBackdrop(name)` (true only for Live). Drives the empty-DB seed, the "+" missing-scenes menu, and `MainViewModel.EnforceBackdropPolicy` |
|
||||||
| `Source.cs` | A source: `SourceType` enum (DisplayCapture/WindowCapture/Background/Image/TextOverlay — **Webcam removed**), `X`/`Y`/`Width`/`Height`, `Opacity`, `IsEnabled`, `ImageSource` (static) / `VideoImageSource` (live frames), `DisplaySource` (`IsLiveCapture` → video, else static), `ClipShape` enum (Traditional/Round), `IsMirrored` + `MirrorScale`, `MirrorButtonText`/`ShapeButtonText`, asset identity, `IsImageSource` (XAML binds this, never `Type`), **backdrop surface**: `IsBackdrop` (permanent bottom layer, never removable), `CaptureKey` (persisted `monitor:<n>`/`window:<hwnd>`/`picker:<name>`), `IsLiveCapture` (`Type` is Display/WindowCapture) |
|
| `Source.cs` | A source: `SourceType` enum (DisplayCapture/WindowCapture/Background/Image/TextOverlay/ChatBox/Countdown/WebSource/MediaSource/**AlertBox (TASK 43 — the celebration zone, one per layout)** — **Webcam removed**), `X`/`Y`/`Width`/`Height`, `Opacity`, `IsEnabled`, `ImageSource` (static) / `VideoImageSource` (live frames), `DisplaySource` (`IsLiveCapture` → video, else static), `ClipShape` enum (Traditional/Round), `IsMirrored` + `MirrorScale`, `MirrorButtonText`, asset identity, `IsImageSource` (XAML binds this, never `Type`), **backdrop surface**: `IsBackdrop` (permanent bottom layer, never removable), `CaptureKey` (persisted `monitor:<n>`/`window:<hwnd>`/`picker:<name>`), `IsLiveCapture` (`Type` is Display/WindowCapture) |
|
||||||
| `SceneElement.cs` | Base for anything placeable in a scene: shared layout/clip/mirror/border surface, `virtual IsWebcam`/`virtual IsImageSource`; `ToggleClipShape` + persisted `RectWidth`/`RectHeight` (pre-Round rect so round→rect restores after reload) |
|
| `SceneElement.cs` | Base for anything placeable in a scene: shared layout/clip/mirror/border surface, `virtual IsWebcam`/`virtual IsImageSource`/`virtual IsZoomLayer` (TASK 52); `ToggleClipShape` + persisted `RectWidth`/`RectHeight` (pre-Round rect so round→rect restores after reload); **TASK 57:** the geometry setters (`X`/`Y`/`Width`/`Height`) are `virtual` so a derived element can CONSTRAIN every write (the webcam clamps itself into the vertical tier's band) instead of every writer re-implementing the rule; `ClipShape` + `ToggleClipShape()` are `virtual` too, so a derived element can CONSTRAIN the shape (the Chat scene's vertical camera is rect-only); **TASK 59:** `IsRectShape`/`IsRoundShape` — the shape as a CHOICE (the properties panel shows Rect | Circle with Rect selected by default), each a settable bool whose setter flips the shape through `ToggleClipShape()` and always re-raises, so the UI snaps back when a write is refused; **TASK 63:** `BorderColorEntry` (the palette entry for the current border colour — hex + NAME, a nameless entry for a custom hex; setting it writes the hex back) and `BorderColorOptions` (the palette + that entry), both raised when `BorderColor` changes — the drop-down binds to them |
|
||||||
|
| `VerticalCameraLayout.cs` | **TASK 57 (the "vv" feature):** the vertical (9:16) tier's camera rules as pure math — `TileWidth`/`TileHeight` (270×152 master px = 480×270 in the 1080×1920 output), `Inset` (32 master px from the band's outer edge), `IsVerticalTier(QualityOption)` (portrait tier), `AreaFor(band, bottom)` (the band splits into two 607×540 areas), `MaxSizeFor(band)`, `SpotFor(band, w, h, centreX, centreY)` (the six spots — the area and column the tile's **centre** falls in, inset, never outside the band) and `DefaultSpot` (bottom-right, the corner the 16:9 default webcam uses), `FillsArea(band, width)` (the camera is wide enough to fill a whole area = "enlarged") and `SizeFor(band, requestedWidth)` (the Chat camera's TWO sizes - the small tile or one whole 607×540 area — whichever is nearer the requested width, so the resize handle has two detents) |
|
||||||
|
| `ZoomSceneElement.cs` | **Transient zoom box (TASK 52, creator-corrected 2026-10-08):** carries the box rect AND the anchored `CaptureX`/`CaptureY` (the fixed box/2 capture window set on `ActivateZoom`; moving the box never touches it). `IsZoomActive` flips `Kind` Static→Dynamic (leaves the baked base while active); never persisted (LayoutStore save skips non-Source/non-WebcamSceneConfig rows), never shown in the layers list or the element template (`IsZoomLayer` gates both). The compositor SKIPS the active element in every layer loop and draws its 2x content LAST (layer n+1) via `ZoomMagnifier`. |
|
||||||
| `Webcam.cs` | Singleton webcam identity: `Id`, `DeviceId`, `Name` (one row app-wide) |
|
| `Webcam.cs` | Singleton webcam identity: `Id`, `DeviceId`, `Name` (one row app-wide) |
|
||||||
| `WebcamSceneConfig.cs` | Per-scene webcam placement (subclass of `SceneElement`): geometry + `IsVisible` + border (`BorderColor`/`BorderOpacity`/`BorderWidth`/`BorderAnimation`) + `VideoImageSource`; `WebcamId` links to `Webcam` |
|
| `BackgroundEffect.cs` | **TASK 51:** per-webcam background effect enum `None` / `Remove` / `Blur`, carried by `WebcamSceneConfig` |
|
||||||
|
| `BorderColorPalette.cs` | **TASK 60:** the colours the webcam's border-colour drop-down offers — `NamedColor(Hex, Name)` + `BorderColorPalette.All` (the single source of truth; the drop-down is bound to it) and `NameOf(hex)` (case-insensitive; **empty** for a colour outside the palette — "no name" is the honest answer, TASK 62). The creator asked for a colour NAME next to each hex: `#e94560 Crimson`. **TASK 63:** `EntryFor(hex)` (the palette entry for a hex, case-insensitively; a nameless entry for a colour outside the palette) and `OptionsFor(hex)` (the palette + that entry) — the two the drop-down binds to; `NamedColor.ToString()` is `"Crimson (#e94560)"` |
|
||||||
|
| `WebcamSceneConfig.cs` | Per-scene webcam placement (subclass of `SceneElement`): geometry + `IsVisible` + border (`BorderColor`/`BorderOpacity`/`BorderWidth`/`BorderAnimation`) + `VideoImageSource`; `WebcamId` links to `Webcam`; **TASK 51:** `BackgroundEffect` + `BackgroundStrength` (0..1, default 0.5) + the radio-pill surface `RemoveBackground`/`BlurBackground` (one on turns the other off; both off = None) + `HasBackgroundEffect` (shows the strength slider); **TASK 57 (the "vv" feature):** the vertical (9:16) tier's OWN placement per scene — `VerticalX/Y/Width/Height/VerticalClipShape/VerticalRectWidth/VerticalRectHeight` (persisted, NULL = no vertical placement yet) + the transient `VerticalBand`/`VerticalLayoutLive`/`LandscapeX…LandscapeRectHeight` (the parked 16:9 layout) + `BeginVerticalLayout`/`EndVerticalLayout`/`CommitLiveLayoutToVertical` (the tier swap) and the `X`/`Y`/`Width`/`Height` overrides that clamp every write into the band; `VerticalShapeLocked` (the Chat scene's vertical layout is rect-only) **overrides** `ClipShape` + `ToggleClipShape()` so no writer can slip a circle in |
|
||||||
| `QualityOption.cs` | Resolution tier record `(Display, Fps, Bitrate, Width, Height)`; `Label` formats as `1080p60 (8Mbps)` — drives the bottom-bar dropdown and the output-rect math |
|
| `QualityOption.cs` | Resolution tier record `(Display, Fps, Bitrate, Width, Height)`; `Label` formats as `1080p60 (8Mbps)` — drives the bottom-bar dropdown and the output-rect math |
|
||||||
| `StreamConfig.cs` | `StreamConfig` (note: `TargetBitrate`/`Resolution` defaults are stale — not yet wired to the dropdown), `StreamHealth` (bitrate/FPS/dropped/duration), `StreamStatus` enum |
|
| `StreamConfig.cs` | `StreamConfig` (note: `TargetBitrate`/`Resolution` defaults are stale — not yet wired to the dropdown), `StreamHealth` (bitrate/FPS/dropped/duration), `StreamStatus` enum |
|
||||||
| `MicStatus.cs` | Footer mic indicator: `NotConnected` / `Connected` / `Problem` — the state behind the MIC button's status dot (red = no device/capture off, green = capturing, yellow = a requested mic failed: in use/unplugged) |
|
| `MicStatus.cs` | Footer mic indicator: `NotConnected` / `Connected` / `Problem` — the state behind the MIC button's status dot (red = no device/capture off, green = capturing, yellow = a requested mic failed: in use/unplugged) |
|
||||||
| `YouTube.cs` | `YouTubeChannel` and `ChatMessage` (chat feed) |
|
| `YouTube.cs` | `YouTubeChannel` and `ChatMessage` (chat feed) — since TASK 43 every message carries `Kind` (`ChatEventKind` None/SuperChat/SuperSticker/NewMember/MemberGift/GiftReceived/MemberMilestone) + event payload fields (`SuperChatDisplayString`, `SuperStickerDescription`, `GiftNewMembersCount`, `GifterName`, `MilestoneMonths`) |
|
||||||
| `Socials.cs` | **Social bar** (global layer, never a Source): `SocialService` enum (YouTube/Twitch/X/Instagram/TikTok/Facebook/Discord/Kick/Threads/Bluesky/GitHub/LinkedIn/Pinterest/Snapchat/Reddit/WhatsApp/Telegram/Link/Website/**Fediverse**), `SocialEntry` (Service/Handle/ProfileUrl/`FediverseSoftware`), `SocialsConfig` (Entries + `BarPosition` Top/Bottom + `BarEnabled` on/off — `BarJustify` dropped, column back-compat), `SocialServiceIcons` (canonical URL builder + `DetectService` (URL domain / fediverse `@user@domain` → **Fediverse** / bare→Website) + bundled SVG logo path data per service (`LogoDataFor`) + `LogoDataForFediverse(software)` mapping nodeinfo software names (mastodon/peertube/pixelfed/misskey/lemmy/pleroma/firefish) to logos with a generic fediverse glyph fallback (`FediverseIconData`) + `LockedIconData`/`DoNotIconData` — initials/colors gone) |
|
| `Socials.cs` | **Social bar** (global layer, never a Source): `SocialService` enum (YouTube/Twitch/X/Instagram/TikTok/Facebook/Discord/Kick/Threads/Bluesky/GitHub/LinkedIn/Pinterest/Snapchat/Reddit/WhatsApp/Telegram/Link/Website/**Fediverse**), `SocialEntry` (Service/Handle/ProfileUrl/`FediverseSoftware`), `SocialsConfig` (Entries + `BarPosition` Top/Bottom + `BarEnabled` on/off — `BarJustify` dropped, column back-compat), `SocialServiceIcons` (canonical URL builder + `DetectService` (URL domain / fediverse `@user@domain` → **Fediverse** / bare→Website) + bundled SVG logo path data per service (`LogoDataFor`) + `LogoDataForFediverse(software)` mapping nodeinfo software names (mastodon/peertube/pixelfed/misskey/lemmy/pleroma/firefish) to logos with a generic fediverse glyph fallback (`FediverseIconData`) + `LockedIconData`/`DoNotIconData` — initials/colors gone) |
|
||||||
|
|
||||||
Related: [`ViewModels/index.md`](../ViewModels/index.md) consume these;
|
Related: [`ViewModels/index.md`](../ViewModels/index.md) consume these;
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# Privacy Policy — LlamaCasty
|
||||||
|
|
||||||
|
Last updated: 30 October 2026
|
||||||
|
|
||||||
|
LlamaCasty is a desktop application for streaming to YouTube. It runs entirely
|
||||||
|
on your computer. This page states exactly what leaves your machine, what is
|
||||||
|
kept locally, and what is never collected.
|
||||||
|
|
||||||
|
## What is stored on your computer
|
||||||
|
|
||||||
|
| Data | Where | Why |
|
||||||
|
|---|---|---|
|
||||||
|
| Your layout: scenes, sources, assets, settings | `%APPDATA%\ytLlive\ytLlive.db` | So the app looks the same next time |
|
||||||
|
| YouTube sign-in token | `%APPDATA%\ytLlive\ytLlive.auth`, encrypted with Windows DPAPI (CurrentUser) | So you stay signed in; only your Windows account can decrypt it |
|
||||||
|
| Diagnostic log | `%APPDATA%\ytLlive\startup.log`, capped at 5 MB | To diagnose crashes you report |
|
||||||
|
| Recordings | the folder you choose | Your files; the app never uploads them unless you press Publish |
|
||||||
|
|
||||||
|
Nothing else is written to disk. There is no analytics service, no crash
|
||||||
|
reporter, no advertising SDK and no telemetry of any kind.
|
||||||
|
|
||||||
|
## What is sent, and to whom
|
||||||
|
|
||||||
|
**Google / YouTube** — only when you sign in and go live, using your own
|
||||||
|
account:
|
||||||
|
- your channel information, live broadcasts, streams and chat, to run the
|
||||||
|
stream and show the chat overlay;
|
||||||
|
- your watch-time and subscriber statistics, to draw the YPP progress and
|
||||||
|
scoreboard tiles;
|
||||||
|
- your recording, only if you press Publish.
|
||||||
|
|
||||||
|
**Nowhere else.** The app talks to `googleapis.com` and the YouTube ingest
|
||||||
|
servers. It does not contact the publisher's servers, and no content of yours
|
||||||
|
is stored on them.
|
||||||
|
|
||||||
|
## What is never done
|
||||||
|
|
||||||
|
- **Chat is rendered, never stored.** Live chat is drawn on your stream and
|
||||||
|
held in memory; it is not written to disk, not uploaded, and not analysed.
|
||||||
|
- No viewer, member or donor information is retained after the session ends.
|
||||||
|
- No microphone, camera or screen content is transmitted anywhere except the
|
||||||
|
YouTube stream you start yourself.
|
||||||
|
|
||||||
|
## Your controls
|
||||||
|
|
||||||
|
Deleting `%APPDATA%\ytLlive` removes every local trace: layout, assets, logs
|
||||||
|
and the saved sign-in. Signing out inside the app deletes the saved token.
|
||||||
|
|
||||||
|
## Children
|
||||||
|
|
||||||
|
The app requires a YouTube account, and YouTube requires users to be 13 or
|
||||||
|
older. The app is not directed at children and collects nothing from anyone.
|
||||||
|
|
||||||
|
## Changes and contact
|
||||||
|
|
||||||
|
Material changes will be dated in this file. Questions:
|
||||||
|
llamachile.shop@gmail.com · https://llamacasty.com
|
||||||
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 109 KiB |