Compare commits
103 Commits
32b1c54fbc
...
deploy/mus
| Author | SHA1 | Date | |
|---|---|---|---|
| cf38ae6fe2 | |||
| de94bd4118 | |||
| 13d14c00aa | |||
| 701e90153c | |||
| e94e39addc | |||
| 8ddfafebcb | |||
| 17db811bba | |||
| b56b41996b | |||
| 7b9e072713 | |||
| 4cb2d085ca | |||
| a737ccf108 | |||
| a0ac48ccbf | |||
| 751637d2f1 | |||
| 7a7ad9f0a7 | |||
| b791beed9c | |||
| 14b5133543 | |||
| ecb35dd5b5 | |||
| 4428357e13 | |||
| 34f2f016f9 | |||
| d465a74cde | |||
| a55cc4f67f | |||
| 30065bb9e2 | |||
| 1254790374 | |||
| c74cb09324 | |||
| 077c4e819f | |||
| 459599d2ba | |||
| 92305bece0 | |||
| 2d0caeb732 | |||
| 92644e9519 | |||
| f67f84ded3 | |||
| 34656a8517 | |||
| 1da2274076 | |||
| 2a629faf50 | |||
| 21d65cad9e | |||
| 6981890be0 | |||
| 5af5c54c62 | |||
| 0f08e1d16c | |||
| 61104d1e25 | |||
| 683cda35cb | |||
| 2db4368fc9 | |||
| 9fd106b757 | |||
| 794126bfdb | |||
| ec9d617ad8 | |||
| 6ccb3e458f | |||
| 83d16e0058 | |||
| ebab03b700 | |||
| 3fea56d4e1 | |||
| e1c10f5408 | |||
| 0ef5a04cb9 | |||
| 3cdad1e714 | |||
| 2514ecbc35 | |||
| cc3db44c4e | |||
| a7fb56c9fe | |||
| fa3d92189c | |||
| dd14f5d901 | |||
| 274085b92e | |||
| f3337975c2 | |||
| e6f6b15cc7 | |||
| f3f7082fb8 | |||
| 36e93be79b | |||
| b6342cc117 | |||
| fd6283718c | |||
| 75b0eed080 | |||
| a69db98241 | |||
| e243862769 | |||
| b8f9f6d537 | |||
| 57ead93497 | |||
| 7f5e2733c2 | |||
| f7a5d24d8d | |||
| a5210fead2 | |||
| 498243af46 | |||
| f97de193d8 | |||
| 7e5fd5ab75 | |||
| fbf03a9847 | |||
| df89acd9a8 | |||
| 829ea89386 | |||
| a3b2c0ce2f | |||
| ba7f082f48 | |||
| 69119bb72a | |||
| 747c390303 | |||
| 57afc32f4a | |||
| 2e0e6ad199 | |||
| 8aed3b022b | |||
| a8ed350aec | |||
| edb6e5e027 | |||
| d44c24ec97 | |||
| 55bcc9448c | |||
| 1cb3387fbe | |||
| 7aa7fe4693 | |||
| bd8925a278 | |||
|
|
b2c060fcc9 | ||
|
|
11bf0505fb | ||
|
|
ef038a29a4 | ||
|
|
d5a4bbb7e2 | ||
|
|
6128d8706d | ||
|
|
6850c9646e | ||
|
|
828d1b3360 | ||
|
|
804940bfac | ||
|
|
a67c6078c5 | ||
|
|
42f0701c0b | ||
|
|
4c5db49c61 | ||
|
|
1458793d74 | ||
|
|
1a17d49599 |
9
.gitignore
vendored
9
.gitignore
vendored
@@ -1,5 +1,14 @@
|
|||||||
generated_3d
|
generated_3d
|
||||||
venv
|
venv
|
||||||
|
.venv
|
||||||
build
|
build
|
||||||
|
*.egg-info
|
||||||
*.FCStd1
|
*.FCStd1
|
||||||
*.blend1
|
*.blend1
|
||||||
|
__pycache__
|
||||||
|
.ipynb_checkpoints
|
||||||
|
.pytest_cache
|
||||||
|
.mypy_cache
|
||||||
|
.ruff_cache
|
||||||
|
.envrc
|
||||||
|
.direnv
|
||||||
|
|||||||
10
.vscode/settings.json
vendored
Normal file
10
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"files.associations": {
|
||||||
|
"*.tcc": "cpp",
|
||||||
|
"deque": "cpp",
|
||||||
|
"string": "cpp",
|
||||||
|
"unordered_map": "cpp",
|
||||||
|
"vector": "cpp",
|
||||||
|
"system_error": "cpp"
|
||||||
|
}
|
||||||
|
}
|
||||||
27
claude-design/AppHeader.dc.html
Normal file
27
claude-design/AppHeader.dc.html
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<script src="./support.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<x-dc>
|
||||||
|
<div style="display:grid;grid-template-columns:1fr auto 1fr;align-items:center;gap:16px;padding:14px 78px 8px 32px;">
|
||||||
|
<a href="{{ linkHref }}" style="justify-self:start;text-decoration:none;background:oklch(97% 0.01 230 / .95);border-radius:999px;padding:9px 16px;font-size:14px;font-weight:800;color:oklch(30% 0.03 230);display:inline-flex;align-items:center;gap:8px;box-shadow:0 6px 18px oklch(15% 0.04 230 / .4);white-space:nowrap;">{{ linkLabel }}</a>
|
||||||
|
|
||||||
|
<div style="display:flex;align-items:center;gap:12px;">
|
||||||
|
<img src="{{ mascot }}" alt="Delfin" style="width:82px;height:82px;object-fit:contain;flex:none;filter:drop-shadow(0 4px 10px oklch(15% 0.04 230 / .45));">
|
||||||
|
<div style="font-size:44px;font-weight:900;color:oklch(97% 0.01 230);letter-spacing:.5px;white-space:nowrap;">{{ title }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="justify-self:end;display:flex;align-items:center;gap:8px;">
|
||||||
|
<sc-for list="{{ actions }}" as="action" hint-placeholder-count="0">
|
||||||
|
<button onClick="{{ action.onClick }}" style="{{ action.style }}">{{ action.label }}</button>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</x-dc>
|
||||||
|
<script type="text/x-dc" data-dc-script data-props="{"$preview": {"width": 1180, "height": 100}, "title": {"editor": "text", "default": "Musik Delphin", "tsType": "string"}, "mascot": {"editor": "text", "default": "dolphin-mascot.png", "tsType": "string"}, "linkHref": {"editor": "text", "default": "./Mein%20Zimmer.dc.html", "tsType": "string"}, "linkLabel": {"editor": "text", "default": "💡 Mein Zimmer", "tsType": "string"}, "actions": {"editor": null, "tsType": "Array<{label: string, style: string, onClick: () => void}>"}}"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
248
claude-design/Delphin Uhr.dc.html
Normal file
248
claude-design/Delphin Uhr.dc.html
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<script src="./support.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<x-dc>
|
||||||
|
<helmet>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Nunito:wght@700;800;900&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
@keyframes bubbleUp { 0% { transform: translateY(0) scale(1); opacity: .55; } 100% { transform: translateY(-115vh) scale(1.3); opacity: 0; } }
|
||||||
|
@keyframes dolphinSwim {
|
||||||
|
0% { transform: translate(-35vmin, 0) scaleX(1); }
|
||||||
|
46% { transform: translate(calc(100vw + 5vmin), -18vh) scaleX(1); }
|
||||||
|
50% { transform: translate(calc(100vw + 5vmin), -18vh) scaleX(-1); }
|
||||||
|
96% { transform: translate(-35vmin, 22vh) scaleX(-1); }
|
||||||
|
100% { transform: translate(-35vmin, 0) scaleX(1); }
|
||||||
|
}
|
||||||
|
@keyframes dolphinBob { 0%, 100% { transform: translateY(0) rotate(-4deg); } 50% { transform: translateY(-3vmin) rotate(5deg); } }
|
||||||
|
html, body { margin: 0; height: 100%; overflow: hidden; font-family: 'Nunito', system-ui, sans-serif; }
|
||||||
|
a { color: oklch(70% 0.16 340); }
|
||||||
|
a:hover { color: oklch(55% 0.16 340); }
|
||||||
|
</style>
|
||||||
|
</helmet>
|
||||||
|
|
||||||
|
<div style="position:relative;width:100vw;height:100vh;overflow:hidden;display:flex;align-items:center;justify-content:center;gap:6vmin;background:linear-gradient(180deg, oklch(55% 0.07 210) 0%, oklch(38% 0.06 210) 45%, oklch(20% 0.045 210) 100%);">
|
||||||
|
|
||||||
|
<div style="position:absolute;left:5%;bottom:-40px;width:14px;height:14px;border-radius:50%;background:oklch(90% 0.02 210 / .5);animation:bubbleUp 9s linear infinite;animation-delay:0s;"></div>
|
||||||
|
<div style="position:absolute;left:12%;bottom:-40px;width:22px;height:22px;border-radius:50%;background:oklch(90% 0.02 210 / .5);animation:bubbleUp 12s linear infinite;animation-delay:-3s;"></div>
|
||||||
|
<div style="position:absolute;left:22%;bottom:-40px;width:10px;height:10px;border-radius:50%;background:oklch(90% 0.02 210 / .5);animation:bubbleUp 8s linear infinite;animation-delay:-6s;"></div>
|
||||||
|
<div style="position:absolute;left:48%;bottom:-40px;width:16px;height:16px;border-radius:50%;background:oklch(90% 0.02 210 / .5);animation:bubbleUp 11s linear infinite;animation-delay:-2s;"></div>
|
||||||
|
<div style="position:absolute;left:63%;bottom:-40px;width:12px;height:12px;border-radius:50%;background:oklch(90% 0.02 210 / .5);animation:bubbleUp 10s linear infinite;animation-delay:-7s;"></div>
|
||||||
|
<div style="position:absolute;left:78%;bottom:-40px;width:24px;height:24px;border-radius:50%;background:oklch(90% 0.02 210 / .5);animation:bubbleUp 14s linear infinite;animation-delay:-5s;"></div>
|
||||||
|
<div style="position:absolute;left:88%;bottom:-40px;width:12px;height:12px;border-radius:50%;background:oklch(90% 0.02 210 / .5);animation:bubbleUp 9s linear infinite;animation-delay:-1s;"></div>
|
||||||
|
<div style="position:absolute;left:95%;bottom:-40px;width:18px;height:18px;border-radius:50%;background:oklch(90% 0.02 210 / .5);animation:bubbleUp 13s linear infinite;animation-delay:-9s;"></div>
|
||||||
|
|
||||||
|
<div style="position:absolute;left:0;top:30%;width:min(30vmin, 260px);pointer-events:none;animation:dolphinSwim 46s ease-in-out infinite;">
|
||||||
|
<img src="dolphin-watch.png" alt="Delphin" style="width:100%;height:auto;display:block;filter:drop-shadow(0 2vmin 3vmin oklch(15% 0.05 210 / .45));animation:dolphinBob 3.2s ease-in-out infinite;">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- watch -->
|
||||||
|
<div style="position:relative;width:78vmin;height:96vmin;flex:none;filter:drop-shadow(0 3vmin 5vmin oklch(15% 0.05 210 / .5));">
|
||||||
|
<div style="position:absolute;width:15vmin;height:24vmin;border:.55vmin solid oklch(28% 0.04 220);box-sizing:border-box;background:linear-gradient(90deg, oklch(70% 0.16 340) 0%, oklch(70% 0.16 340) 70%, oklch(from oklch(70% 0.16 340) calc(l - .08) c h) 100%);left:16vmin;top:0;border-radius:2.4vmin 2.4vmin .8vmin .8vmin;transform:perspective(40vmin) rotateX(10deg) rotate(-8deg);transform-origin:50% 100%;"></div>
|
||||||
|
<div style="position:absolute;width:15vmin;height:24vmin;border:.55vmin solid oklch(28% 0.04 220);box-sizing:border-box;background:linear-gradient(90deg, oklch(66% 0.12 205) 0%, oklch(66% 0.12 205) 70%, oklch(from oklch(66% 0.12 205) calc(l - .08) c h) 100%);right:16vmin;top:0;border-radius:2.4vmin 2.4vmin .8vmin .8vmin;transform:perspective(40vmin) rotateX(10deg) rotate(8deg);transform-origin:50% 100%;"></div>
|
||||||
|
<div style="position:absolute;width:15vmin;height:24vmin;border:.55vmin solid oklch(28% 0.04 220);box-sizing:border-box;background:linear-gradient(90deg, oklch(64% 0.11 175) 0%, oklch(64% 0.11 175) 70%, oklch(from oklch(64% 0.11 175) calc(l - .08) c h) 100%);left:16vmin;bottom:0;border-radius:.8vmin .8vmin 2.4vmin 2.4vmin;transform:perspective(40vmin) rotateX(-10deg) rotate(8deg);transform-origin:50% 0%;"></div>
|
||||||
|
<div style="position:absolute;width:15vmin;height:24vmin;border:.55vmin solid oklch(28% 0.04 220);box-sizing:border-box;background:linear-gradient(90deg, oklch(58% 0.12 245) 0%, oklch(58% 0.12 245) 70%, oklch(from oklch(58% 0.12 245) calc(l - .08) c h) 100%);right:16vmin;bottom:0;border-radius:.8vmin .8vmin 2.4vmin 2.4vmin;transform:perspective(40vmin) rotateX(-10deg) rotate(-8deg);transform-origin:50% 0%;"></div>
|
||||||
|
|
||||||
|
<div style="position:absolute;left:50%;top:50%;width:6vmin;height:10vmin;margin:-5vmin 0 0 -3vmin;border-radius:1.6vmin;border:.55vmin solid oklch(28% 0.04 220);box-sizing:border-box;background:oklch(70% 0.16 340);transform:rotate(60deg) translateY(-38vmin);"></div>
|
||||||
|
<div style="position:absolute;left:50%;top:50%;width:6vmin;height:10vmin;margin:-5vmin 0 0 -3vmin;border-radius:1.6vmin;border:.55vmin solid oklch(28% 0.04 220);box-sizing:border-box;background:oklch(66% 0.12 205);transform:rotate(118deg) translateY(-38vmin);"></div>
|
||||||
|
|
||||||
|
<div style="position:absolute;left:0;top:9vmin;width:78vmin;height:78vmin;border-radius:50%;box-sizing:border-box;border:.6vmin solid oklch(28% 0.04 220);background:radial-gradient(circle closest-side, transparent 88.6%, oklch(28% 0.04 220) 88.6%, oklch(28% 0.04 220) 89.6%, transparent 89.6%), conic-gradient(from 210deg, oklch(94% 0.008 210), oklch(81% 0.015 210), oklch(96% 0.006 210), oklch(79% 0.015 210), oklch(94% 0.008 210));">
|
||||||
|
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(0deg) translateY(-36.8vmin) rotate(0deg);width:1.9vmin;height:1.9vmin;border-radius:50%;background:oklch(64% 0.12 205);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
|
||||||
|
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(7.5deg) translateY(-36.8vmin) rotate(-7.5deg);width:1.1vmin;height:1.1vmin;border-radius:50%;background:oklch(62% 0.02 220);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
|
||||||
|
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(15deg) translateY(-36.8vmin) rotate(-15deg);width:1.9vmin;height:1.9vmin;border-radius:50%;background:oklch(64% 0.12 175);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
|
||||||
|
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(22.5deg) translateY(-36.8vmin) rotate(-22.5deg);width:1.1vmin;height:1.1vmin;border-radius:50%;background:oklch(62% 0.02 220);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
|
||||||
|
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(30deg) translateY(-36.8vmin) rotate(-30deg);width:1.9vmin;height:1.9vmin;border-radius:50%;background:oklch(64% 0.12 245);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
|
||||||
|
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(37.5deg) translateY(-36.8vmin) rotate(-37.5deg);width:1.1vmin;height:1.1vmin;border-radius:50%;background:oklch(62% 0.02 220);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
|
||||||
|
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(45deg) translateY(-36.8vmin) rotate(-45deg);width:1.9vmin;height:1.9vmin;border-radius:50%;background:oklch(70% 0.16 340);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
|
||||||
|
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(52.5deg) translateY(-36.8vmin) rotate(-52.5deg);width:1.1vmin;height:1.1vmin;border-radius:50%;background:oklch(62% 0.02 220);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
|
||||||
|
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(60deg) translateY(-36.8vmin) rotate(-60deg);width:1.9vmin;height:1.9vmin;border-radius:50%;background:oklch(64% 0.12 205);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
|
||||||
|
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(67.5deg) translateY(-36.8vmin) rotate(-67.5deg);width:1.1vmin;height:1.1vmin;border-radius:50%;background:oklch(62% 0.02 220);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
|
||||||
|
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(75deg) translateY(-36.8vmin) rotate(-75deg);width:1.9vmin;height:1.9vmin;border-radius:50%;background:oklch(64% 0.12 175);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
|
||||||
|
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(82.5deg) translateY(-36.8vmin) rotate(-82.5deg);width:1.1vmin;height:1.1vmin;border-radius:50%;background:oklch(62% 0.02 220);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
|
||||||
|
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(90deg) translateY(-36.8vmin) rotate(-90deg);width:1.9vmin;height:1.9vmin;border-radius:50%;background:oklch(64% 0.12 245);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
|
||||||
|
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(97.5deg) translateY(-36.8vmin) rotate(-97.5deg);width:1.1vmin;height:1.1vmin;border-radius:50%;background:oklch(62% 0.02 220);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
|
||||||
|
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(105deg) translateY(-36.8vmin) rotate(-105deg);width:1.9vmin;height:1.9vmin;border-radius:50%;background:oklch(70% 0.16 340);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
|
||||||
|
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(112.5deg) translateY(-36.8vmin) rotate(-112.5deg);width:1.1vmin;height:1.1vmin;border-radius:50%;background:oklch(62% 0.02 220);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
|
||||||
|
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(120deg) translateY(-36.8vmin) rotate(-120deg);width:1.9vmin;height:1.9vmin;border-radius:50%;background:oklch(64% 0.12 205);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
|
||||||
|
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(127.5deg) translateY(-36.8vmin) rotate(-127.5deg);width:1.1vmin;height:1.1vmin;border-radius:50%;background:oklch(62% 0.02 220);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
|
||||||
|
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(135deg) translateY(-36.8vmin) rotate(-135deg);width:1.9vmin;height:1.9vmin;border-radius:50%;background:oklch(64% 0.12 175);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
|
||||||
|
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(142.5deg) translateY(-36.8vmin) rotate(-142.5deg);width:1.1vmin;height:1.1vmin;border-radius:50%;background:oklch(62% 0.02 220);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
|
||||||
|
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(150deg) translateY(-36.8vmin) rotate(-150deg);width:1.9vmin;height:1.9vmin;border-radius:50%;background:oklch(64% 0.12 245);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
|
||||||
|
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(157.5deg) translateY(-36.8vmin) rotate(-157.5deg);width:1.1vmin;height:1.1vmin;border-radius:50%;background:oklch(62% 0.02 220);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
|
||||||
|
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(165deg) translateY(-36.8vmin) rotate(-165deg);width:1.9vmin;height:1.9vmin;border-radius:50%;background:oklch(70% 0.16 340);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
|
||||||
|
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(172.5deg) translateY(-36.8vmin) rotate(-172.5deg);width:1.1vmin;height:1.1vmin;border-radius:50%;background:oklch(62% 0.02 220);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
|
||||||
|
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(180deg) translateY(-36.8vmin) rotate(-180deg);width:1.9vmin;height:1.9vmin;border-radius:50%;background:oklch(64% 0.12 205);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
|
||||||
|
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(187.5deg) translateY(-36.8vmin) rotate(-187.5deg);width:1.1vmin;height:1.1vmin;border-radius:50%;background:oklch(62% 0.02 220);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
|
||||||
|
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(195deg) translateY(-36.8vmin) rotate(-195deg);width:1.9vmin;height:1.9vmin;border-radius:50%;background:oklch(64% 0.12 175);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
|
||||||
|
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(202.5deg) translateY(-36.8vmin) rotate(-202.5deg);width:1.1vmin;height:1.1vmin;border-radius:50%;background:oklch(62% 0.02 220);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
|
||||||
|
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(210deg) translateY(-36.8vmin) rotate(-210deg);width:1.9vmin;height:1.9vmin;border-radius:50%;background:oklch(64% 0.12 245);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
|
||||||
|
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(217.5deg) translateY(-36.8vmin) rotate(-217.5deg);width:1.1vmin;height:1.1vmin;border-radius:50%;background:oklch(62% 0.02 220);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
|
||||||
|
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(225deg) translateY(-36.8vmin) rotate(-225deg);width:1.9vmin;height:1.9vmin;border-radius:50%;background:oklch(70% 0.16 340);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
|
||||||
|
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(232.5deg) translateY(-36.8vmin) rotate(-232.5deg);width:1.1vmin;height:1.1vmin;border-radius:50%;background:oklch(62% 0.02 220);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
|
||||||
|
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(240deg) translateY(-36.8vmin) rotate(-240deg);width:1.9vmin;height:1.9vmin;border-radius:50%;background:oklch(64% 0.12 205);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
|
||||||
|
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(247.5deg) translateY(-36.8vmin) rotate(-247.5deg);width:1.1vmin;height:1.1vmin;border-radius:50%;background:oklch(62% 0.02 220);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
|
||||||
|
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(255deg) translateY(-36.8vmin) rotate(-255deg);width:1.9vmin;height:1.9vmin;border-radius:50%;background:oklch(64% 0.12 175);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
|
||||||
|
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(262.5deg) translateY(-36.8vmin) rotate(-262.5deg);width:1.1vmin;height:1.1vmin;border-radius:50%;background:oklch(62% 0.02 220);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
|
||||||
|
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(270deg) translateY(-36.8vmin) rotate(-270deg);width:1.9vmin;height:1.9vmin;border-radius:50%;background:oklch(64% 0.12 245);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
|
||||||
|
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(277.5deg) translateY(-36.8vmin) rotate(-277.5deg);width:1.1vmin;height:1.1vmin;border-radius:50%;background:oklch(62% 0.02 220);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
|
||||||
|
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(285deg) translateY(-36.8vmin) rotate(-285deg);width:1.9vmin;height:1.9vmin;border-radius:50%;background:oklch(70% 0.16 340);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
|
||||||
|
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(292.5deg) translateY(-36.8vmin) rotate(-292.5deg);width:1.1vmin;height:1.1vmin;border-radius:50%;background:oklch(62% 0.02 220);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
|
||||||
|
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(300deg) translateY(-36.8vmin) rotate(-300deg);width:1.9vmin;height:1.9vmin;border-radius:50%;background:oklch(64% 0.12 205);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
|
||||||
|
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(307.5deg) translateY(-36.8vmin) rotate(-307.5deg);width:1.1vmin;height:1.1vmin;border-radius:50%;background:oklch(62% 0.02 220);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
|
||||||
|
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(315deg) translateY(-36.8vmin) rotate(-315deg);width:1.9vmin;height:1.9vmin;border-radius:50%;background:oklch(64% 0.12 175);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
|
||||||
|
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(322.5deg) translateY(-36.8vmin) rotate(-322.5deg);width:1.1vmin;height:1.1vmin;border-radius:50%;background:oklch(62% 0.02 220);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
|
||||||
|
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(330deg) translateY(-36.8vmin) rotate(-330deg);width:1.9vmin;height:1.9vmin;border-radius:50%;background:oklch(64% 0.12 245);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
|
||||||
|
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(337.5deg) translateY(-36.8vmin) rotate(-337.5deg);width:1.1vmin;height:1.1vmin;border-radius:50%;background:oklch(62% 0.02 220);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
|
||||||
|
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(345deg) translateY(-36.8vmin) rotate(-345deg);width:1.9vmin;height:1.9vmin;border-radius:50%;background:oklch(70% 0.16 340);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
|
||||||
|
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(352.5deg) translateY(-36.8vmin) rotate(-352.5deg);width:1.1vmin;height:1.1vmin;border-radius:50%;background:oklch(62% 0.02 220);box-shadow:inset 0 0 0 .2vmin oklch(30% 0.03 220 / .35);"></div>
|
||||||
|
|
||||||
|
<div style="position:absolute;left:50%;top:50%;width:68.4vmin;height:68.4vmin;margin:-34.2vmin 0 0 -34.2vmin;border-radius:50%;background:oklch(99% 0.005 210);box-shadow:inset 0 .6vmin 1.4vmin oklch(30% 0.03 220 / .18);"></div>
|
||||||
|
|
||||||
|
<!-- hour sections -->
|
||||||
|
<div style="position:absolute;left:50%;top:50%;width:55.6vmin;height:55.6vmin;margin:-27.8vmin 0 0 -27.8vmin;border-radius:50%;background:conic-gradient(from -15deg, oklch(91% 0.06 205) 0deg 30deg, oklch(91% 0.06 175) 30deg 60deg, oklch(91% 0.06 245) 60deg 90deg, oklch(91% 0.07 345) 90deg 120deg, oklch(91% 0.06 205) 120deg 150deg, oklch(91% 0.06 175) 150deg 180deg, oklch(91% 0.06 245) 180deg 210deg, oklch(91% 0.07 345) 210deg 240deg, oklch(91% 0.06 205) 240deg 270deg, oklch(91% 0.06 175) 270deg 300deg, oklch(91% 0.06 245) 300deg 330deg, oklch(91% 0.07 345) 330deg 360deg);"></div>
|
||||||
|
<div style="position:absolute;left:50%;top:50%;width:55.6vmin;height:55.6vmin;margin:-27.8vmin 0 0 -27.8vmin;border-radius:50%;background:repeating-conic-gradient(from 14.6deg, oklch(99% 0.005 210) 0deg .8deg, transparent .8deg 30deg);"></div>
|
||||||
|
<div style="position:absolute;left:50%;top:50%;width:55.6vmin;height:55.6vmin;margin:-27.8vmin 0 0 -27.8vmin;border-radius:50%;box-sizing:border-box;border:.4vmin solid oklch(28% 0.04 220);"></div>
|
||||||
|
<div style="position:absolute;left:50%;top:50%;width:20vmin;height:20vmin;margin:-10vmin 0 0 -10vmin;border-radius:50%;background:oklch(99% 0.005 210);box-shadow:0 0 0 .4vmin oklch(28% 0.04 220 / .25);"></div>
|
||||||
|
|
||||||
|
<!-- minute track -->
|
||||||
|
<div style="position:absolute;left:50%;top:50%;width:66.8vmin;height:66.8vmin;margin:-33.4vmin 0 0 -33.4vmin;border-radius:50%;background:conic-gradient(from -15deg, oklch(80% 0.10 205) 0deg 30deg, oklch(80% 0.10 175) 30deg 60deg, oklch(80% 0.10 245) 60deg 90deg, oklch(80% 0.13 345) 90deg 120deg, oklch(80% 0.10 205) 120deg 150deg, oklch(80% 0.10 175) 150deg 180deg, oklch(80% 0.10 245) 180deg 210deg, oklch(80% 0.13 345) 210deg 240deg, oklch(80% 0.10 205) 240deg 270deg, oklch(80% 0.10 175) 270deg 300deg, oklch(80% 0.10 245) 300deg 330deg, oklch(80% 0.13 345) 330deg 360deg);-webkit-mask-image:radial-gradient(circle closest-side, transparent 82.8%, #000 83.2%);mask-image:radial-gradient(circle closest-side, transparent 82.8%, #000 83.2%);"></div>
|
||||||
|
<div style="position:absolute;left:50%;top:50%;width:66.8vmin;height:66.8vmin;margin:-33.4vmin 0 0 -33.4vmin;border-radius:50%;background:repeating-conic-gradient(from -.5deg, oklch(28% 0.04 220) 0deg 1deg, transparent 1deg 6deg);-webkit-mask-image:radial-gradient(circle closest-side, transparent 94.6%, #000 95%);mask-image:radial-gradient(circle closest-side, transparent 94.6%, #000 95%);opacity:.7;"></div>
|
||||||
|
<div style="position:absolute;left:50%;top:50%;width:66.8vmin;height:66.8vmin;margin:-33.4vmin 0 0 -33.4vmin;border-radius:50%;background:repeating-conic-gradient(from -1.3deg, oklch(28% 0.04 220) 0deg 2.6deg, transparent 2.6deg 30deg);-webkit-mask-image:radial-gradient(circle closest-side, transparent 93.2%, #000 93.6%);mask-image:radial-gradient(circle closest-side, transparent 93.2%, #000 93.6%);"></div>
|
||||||
|
|
||||||
|
<sc-if value="{{ showMinutes }}" hint-placeholder-val="{{ true }}">
|
||||||
|
<div style="position:absolute;inset:0;pointer-events:none;font-weight:900;font-size:2.3vmin;line-height:1;color:oklch(28% 0.04 220);">
|
||||||
|
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(0deg) translateY(-29.5vmin) rotate(0deg);">60</div>
|
||||||
|
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(30deg) translateY(-29.5vmin) rotate(-30deg);">5</div>
|
||||||
|
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(60deg) translateY(-29.5vmin) rotate(-60deg);">10</div>
|
||||||
|
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(90deg) translateY(-29.5vmin) rotate(-90deg);">15</div>
|
||||||
|
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(120deg) translateY(-29.5vmin) rotate(-120deg);">20</div>
|
||||||
|
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(150deg) translateY(-29.5vmin) rotate(-150deg);">25</div>
|
||||||
|
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(180deg) translateY(-29.5vmin) rotate(-180deg);">30</div>
|
||||||
|
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(210deg) translateY(-29.5vmin) rotate(-210deg);">35</div>
|
||||||
|
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(240deg) translateY(-29.5vmin) rotate(-240deg);">40</div>
|
||||||
|
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(270deg) translateY(-29.5vmin) rotate(-270deg);">45</div>
|
||||||
|
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(300deg) translateY(-29.5vmin) rotate(-300deg);">50</div>
|
||||||
|
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(330deg) translateY(-29.5vmin) rotate(-330deg);">55</div>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
|
||||||
|
<div style="position:absolute;inset:0;pointer-events:none;font-weight:900;font-size:8.2vmin;line-height:1;letter-spacing:-.3vmin;-webkit-text-stroke:.45vmin oklch(28% 0.04 220);paint-order:stroke fill;">
|
||||||
|
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(30deg) translateY(-22.4vmin) rotate(-30deg);color:oklch(56% 0.13 175);">1</div>
|
||||||
|
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(60deg) translateY(-22.4vmin) rotate(-60deg);color:oklch(56% 0.13 245);">2</div>
|
||||||
|
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(90deg) translateY(-22.4vmin) rotate(-90deg);color:oklch(60% 0.19 345);">3</div>
|
||||||
|
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(120deg) translateY(-22.4vmin) rotate(-120deg);color:oklch(56% 0.13 205);">4</div>
|
||||||
|
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(150deg) translateY(-22.4vmin) rotate(-150deg);color:oklch(56% 0.13 175);">5</div>
|
||||||
|
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(180deg) translateY(-22.4vmin) rotate(-180deg);color:oklch(56% 0.13 245);">6</div>
|
||||||
|
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(210deg) translateY(-22.4vmin) rotate(-210deg);color:oklch(60% 0.19 345);">7</div>
|
||||||
|
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(240deg) translateY(-22.4vmin) rotate(-240deg);color:oklch(56% 0.13 205);">8</div>
|
||||||
|
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(270deg) translateY(-22.4vmin) rotate(-270deg);color:oklch(56% 0.13 175);">9</div>
|
||||||
|
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(300deg) translateY(-22.4vmin) rotate(-300deg);color:oklch(56% 0.13 245);">10</div>
|
||||||
|
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(330deg) translateY(-22.4vmin) rotate(-330deg);color:oklch(60% 0.19 345);">11</div>
|
||||||
|
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(360deg) translateY(-22.4vmin) rotate(-360deg);color:oklch(56% 0.13 205);">12</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="position:absolute;left:50%;bottom:50%;width:5.4vmin;height:15vmin;margin-left:-2.7vmin;transform-origin:50% 100%;filter:drop-shadow(.35vmin 0 0 oklch(28% 0.04 220)) drop-shadow(-.35vmin 0 0 oklch(28% 0.04 220)) drop-shadow(0 .35vmin 0 oklch(28% 0.04 220)) drop-shadow(0 -.35vmin 0 oklch(28% 0.04 220)) drop-shadow(0 1vmin 1vmin oklch(15% 0.05 210 / .3));{{ hourStyle }}">
|
||||||
|
<div style="position:absolute;left:0;right:0;top:0;bottom:-3.5vmin;background:oklch(92% 0.06 345);clip-path:polygon(50% 0%, 100% 22%, 66% 22%, 66% 100%, 34% 100%, 34% 22%, 0% 22%);"></div>
|
||||||
|
</div>
|
||||||
|
<div style="position:absolute;left:50%;bottom:50%;width:5vmin;height:24vmin;margin-left:-2.5vmin;transform-origin:50% 100%;filter:drop-shadow(.35vmin 0 0 oklch(28% 0.04 220)) drop-shadow(-.35vmin 0 0 oklch(28% 0.04 220)) drop-shadow(0 .35vmin 0 oklch(28% 0.04 220)) drop-shadow(0 -.35vmin 0 oklch(28% 0.04 220)) drop-shadow(0 1vmin 1vmin oklch(15% 0.05 210 / .3));{{ minuteStyle }}">
|
||||||
|
<div style="position:absolute;left:0;right:0;top:0;bottom:-3.5vmin;background:oklch(92% 0.05 200);clip-path:polygon(50% 0%, 100% 14%, 64% 14%, 64% 100%, 36% 100%, 36% 14%, 0% 14%);"></div>
|
||||||
|
</div>
|
||||||
|
<sc-if value="{{ showSeconds }}" hint-placeholder-val="{{ true }}">
|
||||||
|
<div style="position:absolute;left:50%;bottom:50%;width:.7vmin;height:27vmin;margin-left:-.35vmin;transform-origin:50% 100%;{{ secondStyle }}">
|
||||||
|
<div style="position:absolute;inset:0 0 -5vmin 0;border-radius:1vmin;background:oklch(64% 0.19 345);"></div>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
<div style="position:absolute;left:50%;top:50%;width:4.2vmin;height:4.2vmin;margin:-2.1vmin 0 0 -2.1vmin;border-radius:50%;background:oklch(80% 0.10 205);box-shadow:0 0 0 .5vmin oklch(28% 0.04 220);"></div>
|
||||||
|
<div style="position:absolute;left:50%;top:50%;width:1.4vmin;height:1.4vmin;margin:-.7vmin 0 0 -.7vmin;border-radius:50%;background:oklch(28% 0.04 220);"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- countdowns -->
|
||||||
|
<sc-if value="{{ hasCountdowns }}" hint-placeholder-val="{{ true }}">
|
||||||
|
<div style="position:relative;flex:0 1 74vmin;min-width:0;display:flex;flex-direction:column;gap:3vmin;">
|
||||||
|
<sc-for list="{{ countdowns }}" as="cd" hint-placeholder-count="2">
|
||||||
|
<div style="display:flex;align-items:center;gap:3.5vmin;padding:3vmin 4vmin 3vmin 3vmin;border-radius:4.5vmin;background:oklch(97% 0.01 210 / .68);backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);box-shadow:0 2vmin 5vmin oklch(15% 0.05 210 / .35);">
|
||||||
|
<div style="width:24vmin;height:18vmin;flex:none;display:flex;align-items:center;justify-content:center;">
|
||||||
|
<img src="{{ cd.image }}" alt="{{ cd.label }}" style="max-width:100%;max-height:100%;object-fit:contain;filter:drop-shadow(0 1vmin 1.5vmin oklch(15% 0.05 210 / .3));">
|
||||||
|
</div>
|
||||||
|
<div style="flex:1;min-width:0;display:flex;flex-direction:column;gap:.6vmin;">
|
||||||
|
<div style="font-size:15vmin;font-weight:900;line-height:.95;font-variant-numeric:tabular-nums;letter-spacing:-.3vmin;{{ cd.timeStyle }}">{{ cd.remaining }}</div>
|
||||||
|
<div style="height:2vmin;border-radius:999px;background:oklch(30% 0.04 210 / .15);overflow:hidden;margin-top:.8vmin;">
|
||||||
|
<div style="height:100%;border-radius:999px;{{ cd.barStyle }}"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</x-dc>
|
||||||
|
<script type="text/x-dc" data-dc-script data-props="{"showSeconds":{"editor":"boolean","default":true,"tsType":"boolean"},"showMinuteNumbers":{"editor":"boolean","default":true,"tsType":"boolean"},"demoCountdowns":{"editor":"boolean","default":true,"tsType":"boolean"}}">
|
||||||
|
// Countdown rules. Each shows from (target - lead) until target on the given weekdays (0 = So … 6 = Sa).
|
||||||
|
const COUNTDOWNS = [
|
||||||
|
{ id: "bus", label: "Bus", image: "bus.png", target: "07:25", lead: 45, days: [1, 2, 3, 4, 5] },
|
||||||
|
{ id: "bett", label: "Schlafenszeit", image: "dolphin-watch.png", target: "19:30", lead: 30, days: [0, 1, 2, 3, 4, 5, 6] },
|
||||||
|
];
|
||||||
|
|
||||||
|
class Component extends DCLogic {
|
||||||
|
state = { now: new Date() };
|
||||||
|
componentDidMount() { this._t = setInterval(() => this.setState({ now: new Date() }), 250); }
|
||||||
|
componentWillUnmount() { clearInterval(this._t); }
|
||||||
|
|
||||||
|
activeCountdowns(d, demo) {
|
||||||
|
const daySec = d.getHours() * 3600 + d.getMinutes() * 60 + d.getSeconds();
|
||||||
|
const out = [];
|
||||||
|
COUNTDOWNS.forEach((r, i) => {
|
||||||
|
const [th, tm] = r.target.split(":").map(Number);
|
||||||
|
const targetSec = th * 3600 + tm * 60;
|
||||||
|
const leadSec = r.lead * 60;
|
||||||
|
let rem = targetSec - daySec;
|
||||||
|
const active = r.days.includes(d.getDay()) && rem > 0 && rem <= leadSec;
|
||||||
|
if (!active) {
|
||||||
|
if (!demo) return;
|
||||||
|
rem = leadSec - ((daySec + i * 1100) % leadSec);
|
||||||
|
}
|
||||||
|
out.push({ r, rem, frac: rem / leadSec });
|
||||||
|
});
|
||||||
|
return out.sort((a, b) => a.rem - b.rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderVals() {
|
||||||
|
const d = this.state.now;
|
||||||
|
const day = d.getHours() * 3600 + d.getMinutes() * 60 + d.getSeconds();
|
||||||
|
const rot = (deg) => `transform:rotate(${deg}deg);`;
|
||||||
|
const pad = (n) => String(n).padStart(2, "0");
|
||||||
|
const list = this.activeCountdowns(d, this.props.demoCountdowns ?? true).map(({ r, rem, frac }) => {
|
||||||
|
const mins = Math.ceil(rem / 60);
|
||||||
|
const soon = mins <= 10;
|
||||||
|
return {
|
||||||
|
id: r.id,
|
||||||
|
label: r.label,
|
||||||
|
image: r.image,
|
||||||
|
target: r.target.replace(/^0/, ""),
|
||||||
|
remaining: `${pad(Math.floor(mins / 60))}:${pad(mins % 60)}`,
|
||||||
|
timeStyle: `color:${soon ? "oklch(58% 0.19 345)" : "oklch(30% 0.05 210)"};`,
|
||||||
|
barStyle: `width:${(frac * 100).toFixed(2)}%;background:${soon ? "oklch(70% 0.16 340)" : "oklch(66% 0.12 205)"};transition:width .25s linear;`,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
hourStyle: rot(day / 120),
|
||||||
|
minuteStyle: rot(day / 10),
|
||||||
|
secondStyle: rot(day * 6),
|
||||||
|
showSeconds: this.props.showSeconds ?? true,
|
||||||
|
showMinutes: this.props.showMinuteNumbers ?? true,
|
||||||
|
countdowns: list,
|
||||||
|
hasCountdowns: list.length > 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
920
claude-design/Dolphin Beats.dc.html
Normal file
920
claude-design/Dolphin Beats.dc.html
Normal file
@@ -0,0 +1,920 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<script src="./support.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<x-dc>
|
||||||
|
<helmet>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Nunito:wght@500;700;800;900&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
@keyframes bubbleRise { 0% { transform: translateY(0) scale(1); opacity: .55; } 100% { transform: translateY(-120vh) scale(1.3); opacity: 0; } }
|
||||||
|
@keyframes dolphinBob { 0%, 100% { transform: translateY(0) rotate(-2deg); } 50% { transform: translateY(-10px) rotate(2deg); } }
|
||||||
|
@keyframes dolphinSwim {
|
||||||
|
0% { transform: translate(-20vw, 10vh) rotate(-3deg) scaleX(1); }
|
||||||
|
15% { transform: translate(2vw, 2vh) rotate(4deg) scaleX(1); }
|
||||||
|
30% { transform: translate(24vw, 12vh) rotate(-3deg) scaleX(1); }
|
||||||
|
45% { transform: translate(46vw, 2vh) rotate(4deg) scaleX(1); }
|
||||||
|
60% { transform: translate(68vw, 12vh) rotate(-3deg) scaleX(1); }
|
||||||
|
70% { transform: translate(84vw, 6vh) rotate(0deg) scaleX(1); }
|
||||||
|
74% { transform: translate(84vw, 6vh) rotate(0deg) scaleX(-1); }
|
||||||
|
85% { transform: translate(52vw, 18vh) rotate(-3deg) scaleX(-1); }
|
||||||
|
95% { transform: translate(16vw, 6vh) rotate(3deg) scaleX(-1); }
|
||||||
|
100% { transform: translate(-20vw, 10vh) rotate(-3deg) scaleX(-1); }
|
||||||
|
}
|
||||||
|
body { margin: 0; font-family: 'Nunito', system-ui, sans-serif; }
|
||||||
|
a { color: oklch(70% 0.16 340); }
|
||||||
|
a:hover { color: oklch(55% 0.16 340); }
|
||||||
|
</style>
|
||||||
|
</helmet>
|
||||||
|
|
||||||
|
<div style="position:relative;width:100%;height:100vh;overflow:hidden;background:linear-gradient(180deg, oklch(55% 0.07 210) 0%, oklch(38% 0.06 210) 45%, oklch(20% 0.045 210) 100%);">
|
||||||
|
|
||||||
|
<div style="position:absolute;left:5%;bottom:-40px;width:14px;height:14px;border-radius:50%;background:oklch(90% 0.02 210 / .5);animation:bubbleRise 9s linear infinite;animation-delay:0s;"></div>
|
||||||
|
<div style="position:absolute;left:15%;bottom:-40px;width:22px;height:22px;border-radius:50%;background:oklch(90% 0.02 210 / .4);animation:bubbleRise 12s linear infinite;animation-delay:2s;"></div>
|
||||||
|
<div style="position:absolute;left:28%;bottom:-40px;width:10px;height:10px;border-radius:50%;background:oklch(90% 0.02 210 / .5);animation:bubbleRise 7s linear infinite;animation-delay:1s;"></div>
|
||||||
|
<div style="position:absolute;left:42%;bottom:-40px;width:18px;height:18px;border-radius:50%;background:oklch(90% 0.02 210 / .45);animation:bubbleRise 10s linear infinite;animation-delay:4s;"></div>
|
||||||
|
<div style="position:absolute;left:58%;bottom:-40px;width:12px;height:12px;border-radius:50%;background:oklch(90% 0.02 210 / .5);animation:bubbleRise 8s linear infinite;animation-delay:3s;"></div>
|
||||||
|
<div style="position:absolute;left:70%;bottom:-40px;width:26px;height:26px;border-radius:50%;background:oklch(90% 0.02 210 / .35);animation:bubbleRise 13s linear infinite;animation-delay:5s;"></div>
|
||||||
|
<div style="position:absolute;left:82%;bottom:-40px;width:16px;height:16px;border-radius:50%;background:oklch(90% 0.02 210 / .5);animation:bubbleRise 9.5s linear infinite;animation-delay:1.5s;"></div>
|
||||||
|
<div style="position:absolute;left:92%;bottom:-40px;width:10px;height:10px;border-radius:50%;background:oklch(90% 0.02 210 / .5);animation:bubbleRise 6.5s linear infinite;animation-delay:2.5s;"></div>
|
||||||
|
<div style="position:absolute;left:2%;bottom:-40px;width:8px;height:8px;border-radius:50%;background:oklch(90% 0.02 210 / .45);animation:bubbleRise 7.5s linear infinite;animation-delay:3.5s;"></div>
|
||||||
|
<div style="position:absolute;left:9%;bottom:-40px;width:18px;height:18px;border-radius:50%;background:oklch(90% 0.02 210 / .3);animation:bubbleRise 14s linear infinite;animation-delay:6s;"></div>
|
||||||
|
<div style="position:absolute;left:21%;bottom:-40px;width:12px;height:12px;border-radius:50%;background:oklch(90% 0.02 210 / .5);animation:bubbleRise 8.5s linear infinite;animation-delay:5.5s;"></div>
|
||||||
|
<div style="position:absolute;left:34%;bottom:-40px;width:24px;height:24px;border-radius:50%;background:oklch(90% 0.02 210 / .32);animation:bubbleRise 12.5s linear infinite;animation-delay:1.2s;"></div>
|
||||||
|
<div style="position:absolute;left:38%;bottom:-40px;width:9px;height:9px;border-radius:50%;background:oklch(90% 0.02 210 / .5);animation:bubbleRise 6.8s linear infinite;animation-delay:4.6s;"></div>
|
||||||
|
<div style="position:absolute;left:48%;bottom:-40px;width:14px;height:14px;border-radius:50%;background:oklch(90% 0.02 210 / .42);animation:bubbleRise 10.5s linear infinite;animation-delay:0.6s;"></div>
|
||||||
|
<div style="position:absolute;left:53%;bottom:-40px;width:20px;height:20px;border-radius:50%;background:oklch(90% 0.02 210 / .3);animation:bubbleRise 13.5s linear infinite;animation-delay:7s;"></div>
|
||||||
|
<div style="position:absolute;left:63%;bottom:-40px;width:8px;height:8px;border-radius:50%;background:oklch(90% 0.02 210 / .5);animation:bubbleRise 7.2s linear infinite;animation-delay:2.2s;"></div>
|
||||||
|
<div style="position:absolute;left:66%;bottom:-40px;width:16px;height:16px;border-radius:50%;background:oklch(90% 0.02 210 / .38);animation:bubbleRise 11.5s linear infinite;animation-delay:5.2s;"></div>
|
||||||
|
<div style="position:absolute;left:76%;bottom:-40px;width:11px;height:11px;border-radius:50%;background:oklch(90% 0.02 210 / .48);animation:bubbleRise 9.2s linear infinite;animation-delay:6.4s;"></div>
|
||||||
|
<div style="position:absolute;left:87%;bottom:-40px;width:22px;height:22px;border-radius:50%;background:oklch(90% 0.02 210 / .28);animation:bubbleRise 15s linear infinite;animation-delay:3.2s;"></div>
|
||||||
|
<div style="position:absolute;left:96%;bottom:-40px;width:14px;height:14px;border-radius:50%;background:oklch(90% 0.02 210 / .42);animation:bubbleRise 10.8s linear infinite;animation-delay:8s;"></div>
|
||||||
|
<div style="position:absolute;left:45%;bottom:-40px;width:7px;height:7px;border-radius:50%;background:oklch(90% 0.02 210 / .5);animation:bubbleRise 6.2s linear infinite;animation-delay:7.6s;"></div>
|
||||||
|
|
||||||
|
<sc-if value="{{ isBrowse }}" hint-placeholder-val="{{ true }}">
|
||||||
|
<div style="position:relative;z-index:1;height:100%;display:flex;flex-direction:column;">
|
||||||
|
|
||||||
|
<!-- header -->
|
||||||
|
<dc-import name="AppHeader" title="Musik Delphin" mascot="dolphin-mascot.png" link-href="./Mein%20Zimmer.dc.html" link-label="💡 Mein Zimmer" hint-size="100%,116px"></dc-import>
|
||||||
|
|
||||||
|
<div style="display:flex;align-items:center;justify-content:center;gap:10px;padding:4px 32px 6px;">
|
||||||
|
<button onClick="{{ setFilterAll }}" style="{{ filterAllStyle }}">Alles</button>
|
||||||
|
<button onClick="{{ setFilterMusic }}" style="{{ filterMusicStyle }}">🎵 Musik</button>
|
||||||
|
<button onClick="{{ setFilterBook }}" style="{{ filterBookStyle }}">📖 Hörbücher</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- search pill -->
|
||||||
|
<sc-if value="{{ showSearchBar }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<div style="margin:2px 32px 6px;display:flex;align-items:center;justify-content:center;gap:12px;">
|
||||||
|
<div style="background:oklch(97% 0.01 210);color:oklch(30% 0.04 210);font-weight:800;font-size:20px;padding:10px 20px;border-radius:999px;box-shadow:0 4px 14px oklch(15% 0.05 210 / .35);display:flex;align-items:center;gap:10px;">
|
||||||
|
<span style="font-size:14px;font-weight:800;background:oklch(30% 0.04 210);color:#fff;padding:4px 10px;border-radius:999px;">{{ modeLabel }}</span>
|
||||||
|
<span>{{ searchDisplay }}</span>
|
||||||
|
</div>
|
||||||
|
<div style="color:oklch(90% 0.02 210 / .85);font-weight:700;font-size:14px;">{{ resultCountLabel }}</div>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
|
||||||
|
<!-- results -->
|
||||||
|
<div ref="{{ scrollerRef }}" style="flex:1;overflow:auto;min-height:0;padding:14px 32px 250px;">
|
||||||
|
|
||||||
|
<!-- song hits -->
|
||||||
|
<sc-if value="{{ hasSongHits }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<div style="margin-bottom:30px;">
|
||||||
|
<div style="display:flex;align-items:baseline;justify-content:center;gap:10px;margin-bottom:12px;">
|
||||||
|
<div style="font-size:19px;font-weight:900;color:oklch(97% 0.01 210);">Titel</div>
|
||||||
|
<div style="font-size:13px;font-weight:700;color:oklch(88% 0.02 210 / .7);">{{ songHitLabel }}</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;flex-direction:column;gap:8px;max-width:760px;margin:0 auto;">
|
||||||
|
<sc-for list="{{ songHits }}" as="song" hint-placeholder-count="3">
|
||||||
|
<div onClick="{{ playSongHit }}" data-nav-index="{{ song.navIndex }}" data-album="{{ song.albumId }}" data-index="{{ song.index }}" style="{{ song.rowStyle }}">
|
||||||
|
<div style="{{ song.chipStyle }}"></div>
|
||||||
|
<div style="flex:1;min-width:0;">
|
||||||
|
<div style="font-size:16px;font-weight:800;color:oklch(97% 0.01 210);">{{ song.title }}</div>
|
||||||
|
<div style="font-size:12px;font-weight:700;color:oklch(88% 0.02 210 / .65);">{{ song.albumLine }}</div>
|
||||||
|
</div>
|
||||||
|
<div style="font:700 13px ui-monospace,Menlo,monospace;color:oklch(88% 0.02 210 / .6);">{{ song.duration }}</div>
|
||||||
|
</div>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
|
||||||
|
<!-- category grid -->
|
||||||
|
<sc-if value="{{ hasCategories }}" hint-placeholder-val="{{ true }}">
|
||||||
|
<div>
|
||||||
|
<div style="font-size:19px;font-weight:900;color:oklch(97% 0.01 210);margin-bottom:12px;text-align:center;">{{ categorySectionLabel }}</div>
|
||||||
|
<div ref="{{ gridRef }}" style="display:grid;grid-template-columns:repeat(auto-fill, minmax(180px, 1fr));gap:24px;max-width:1180px;margin:0 auto;">
|
||||||
|
<sc-for list="{{ categories }}" as="cat" hint-placeholder-count="8">
|
||||||
|
<div onClick="{{ openCategory }}" data-nav-index="{{ cat.navIndex }}" data-key="{{ cat.key }}" style="{{ cat.cardStyle }}">
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:4px;padding:8px;aspect-ratio:1;">
|
||||||
|
<sc-for list="{{ cat.tiles }}" as="tile" hint-placeholder-count="4">
|
||||||
|
<div style="{{ tile.style }}"></div>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
<div style="padding:4px 12px 14px;">
|
||||||
|
<div style="font-size:16px;font-weight:800;color:oklch(22% 0.03 210);line-height:1.2;">{{ cat.name }}</div>
|
||||||
|
<div style="font-size:12px;font-weight:800;color:oklch(40% 0.17 340);margin-top:5px;">{{ cat.countLabel }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
|
||||||
|
<!-- album grid -->
|
||||||
|
<sc-if value="{{ hasAlbumHits }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<div>
|
||||||
|
<sc-if value="{{ hasCategoryCrumb }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<div style="display:flex;align-items:center;justify-content:center;gap:12px;margin-bottom:14px;">
|
||||||
|
<button onClick="{{ clearCategory }}" style="border:none;cursor:pointer;font-family:inherit;font-size:14px;font-weight:800;padding:9px 16px;border-radius:999px;background:oklch(97% 0.01 210 / .95);color:oklch(30% 0.04 210);">← Alle</button>
|
||||||
|
<div style="font-size:22px;font-weight:900;color:oklch(97% 0.01 210);">{{ categoryCrumb }}</div>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
<sc-if value="{{ hasSearch }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<div style="font-size:19px;font-weight:900;color:oklch(97% 0.01 210);margin-bottom:12px;text-align:center;">{{ albumSectionLabel }}</div>
|
||||||
|
</sc-if>
|
||||||
|
<div ref="{{ gridRef }}" style="display:grid;grid-template-columns:repeat(auto-fill, minmax(180px, 1fr));gap:24px;max-width:1180px;margin:0 auto;justify-content:center;">
|
||||||
|
<sc-for list="{{ filteredAlbums }}" as="album" hint-placeholder-count="8">
|
||||||
|
<div onClick="{{ openAlbumCard }}" data-nav-index="{{ album.navIndex }}" data-id="{{ album.id }}" style="{{ album.cardStyle }}">
|
||||||
|
<div style="{{ album.coverStyle }}">
|
||||||
|
<span style="{{ album.coverLabelStyle }}">{{ album.coverLabel }}</span>
|
||||||
|
</div>
|
||||||
|
<div style="{{ album.bodyStyle }}">
|
||||||
|
<div style="font-size:16px;font-weight:800;color:oklch(22% 0.03 210);line-height:1.2;">{{ album.title }}</div>
|
||||||
|
<div style="font-size:13px;font-weight:700;color:oklch(30% 0.03 210);margin-top:2px;">{{ album.artist }}</div>
|
||||||
|
<div style="font-size:12px;font-weight:800;color:oklch(40% 0.17 340);margin-top:6px;">{{ album.songCountLabel }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
|
||||||
|
<sc-if value="{{ hasNoResults }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<div style="text-align:center;margin-top:60px;color:oklch(92% 0.02 210 / .8);">
|
||||||
|
<img src="dolphin-mascot.png" alt="" style="width:120px;height:120px;object-fit:contain;opacity:.9;">
|
||||||
|
<div style="font-size:22px;font-weight:800;margin-top:10px;">Nichts gefunden — probier andere Buchstaben!</div>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
|
||||||
|
<!-- play view -->
|
||||||
|
<sc-if value="{{ isPlay }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<div style="position:relative;z-index:1;height:100%;min-height:0;overflow:auto;">
|
||||||
|
<div style="min-height:100%;box-sizing:border-box;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:clamp(8px, 1.8vh, 26px);padding:clamp(14px, 3.5vh, 40px) 40px;">
|
||||||
|
|
||||||
|
<img src="dolphin-mascot.png" alt="Delfin mit Kopfhörern" style="position:absolute;top:14%;left:0;width:140px;height:140px;object-fit:contain;z-index:0;pointer-events:none;opacity:.92;animation:dolphinSwim 62s linear infinite;filter:drop-shadow(0 10px 26px oklch(10% 0.05 210 / .45));">
|
||||||
|
|
||||||
|
<div style="{{ bigCoverStyle }}">
|
||||||
|
<span style="font-family:ui-monospace,Menlo,monospace;font-size:13px;color:oklch(98% 0 0 / .85);letter-spacing:1px;">ALBUM COVER</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="text-align:center;max-width:760px;position:relative;z-index:1;flex:none;">
|
||||||
|
<div style="font-size:clamp(28px, 4.6vh, 46px);font-weight:900;color:oklch(97% 0.01 210);line-height:1.1;text-wrap:pretty;">{{ currentSongTitle }}</div>
|
||||||
|
<div style="font-size:20px;font-weight:700;color:oklch(88% 0.02 210 / .8);margin-top:8px;">{{ currentAlbumLine }}</div>
|
||||||
|
<div style="font-size:15px;font-weight:800;color:oklch(78% 0.14 340);margin-top:6px;">{{ trackCounter }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="width:min(680px, 90%);position:relative;z-index:1;">
|
||||||
|
<div style="height:14px;border-radius:999px;background:oklch(30% 0.03 210 / .55);overflow:hidden;">
|
||||||
|
<div style="height:100%;border-radius:999px;background:oklch(70% 0.16 340);{{ progressStyle }}"></div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;margin-top:8px;font-size:14px;font-weight:800;color:oklch(88% 0.02 210 / .8);">
|
||||||
|
<span style="font-family:ui-monospace,Menlo,monospace;">{{ trackRemainingLabel }}</span>
|
||||||
|
<span>{{ albumRemainingLabel }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex;align-items:center;gap:22px;position:relative;z-index:1;flex:none;">
|
||||||
|
<button onClick="{{ prevTrack }}" style="width:clamp(50px, 9vh, 76px);height:clamp(50px, 9vh, 76px);border-radius:999px;border:none;padding:0;background:oklch(97% 0.01 210 / .16);cursor:pointer;display:flex;align-items:center;justify-content:center;"><span style="display:flex;align-items:center;gap:3px;"><span style="width:6px;height:26px;border-radius:2px;background:#fff;"></span><span style="width:22px;height:26px;background:#fff;clip-path:polygon(100% 0%, 100% 100%, 0% 50%);"></span></span></button>
|
||||||
|
<button onClick="{{ togglePlay }}" style="width:clamp(68px, 12vh, 110px);height:clamp(68px, 12vh, 110px);border-radius:999px;border:none;padding:0;background:oklch(70% 0.16 340);cursor:pointer;display:flex;align-items:center;justify-content:center;box-shadow:0 10px 30px oklch(70% 0.16 340 / .5);"><sc-if value="{{ isPlaying }}" hint-placeholder-val="{{ false }}"><span style="display:flex;gap:9px;"><span style="width:10px;height:clamp(26px, 4.6vh, 42px);border-radius:3px;background:#fff;"></span><span style="width:10px;height:clamp(26px, 4.6vh, 42px);border-radius:3px;background:#fff;"></span></span></sc-if><sc-if value="{{ isPaused }}" hint-placeholder-val="{{ true }}"><span style="width:clamp(24px, 4.2vh, 38px);height:clamp(26px, 4.6vh, 42px);background:#fff;clip-path:polygon(6% 0%, 100% 50%, 6% 100%);"></span></sc-if></button>
|
||||||
|
<button onClick="{{ nextTrack }}" style="width:clamp(50px, 9vh, 76px);height:clamp(50px, 9vh, 76px);border-radius:999px;border:none;padding:0;background:oklch(97% 0.01 210 / .16);cursor:pointer;display:flex;align-items:center;justify-content:center;"><span style="display:flex;align-items:center;gap:3px;"><span style="width:22px;height:26px;background:#fff;clip-path:polygon(0% 0%, 0% 100%, 100% 50%);"></span><span style="width:6px;height:26px;border-radius:2px;background:#fff;"></span></span></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex;align-items:center;gap:12px;position:relative;z-index:1;">
|
||||||
|
<button onClick="{{ muteToggle }}" title="Stumm" style="border:none;background:none;padding:0;cursor:pointer;font-size:24px;line-height:1;">🔊</button>
|
||||||
|
<div style="display:flex;gap:5px;">
|
||||||
|
<sc-for list="{{ volumeBars }}" as="bar" hint-placeholder-count="5">
|
||||||
|
<button onClick="{{ setVolume }}" data-level="{{ bar.level }}" title="Lautstärke" style="width:18px;height:clamp(26px, 4.6vh, 44px);border-radius:5px;{{ bar.style }}"></button>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button onClick="{{ goBrowse }}" style="position:absolute;top:28px;left:32px;border:none;cursor:pointer;background:oklch(97% 0.01 210 / .95);border-radius:999px;padding:11px 20px;font-family:inherit;font-size:14px;font-weight:800;color:oklch(30% 0.04 210);display:flex;align-items:center;gap:8px;box-shadow:0 8px 24px oklch(15% 0.05 210 / .4);">
|
||||||
|
<span style="font:800 13px ui-monospace,monospace;background:oklch(30% 0.04 210);color:#fff;padding:2px 8px;border-radius:6px;">/</span>
|
||||||
|
Zurück zur Suche
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
|
||||||
|
<!-- album detail panel -->
|
||||||
|
<sc-if value="{{ hasOpenAlbum }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<div onClick="{{ closeAlbum }}" style="position:absolute;inset:0;z-index:4;background:oklch(15% 0.03 210 / .6);display:flex;align-items:center;justify-content:center;padding:40px;">
|
||||||
|
<div onClick="{{ stopClick }}" style="background:oklch(96% 0.012 210);border-radius:26px;padding:26px;width:640px;max-width:100%;max-height:100%;overflow:auto;box-shadow:0 24px 60px oklch(10% 0.04 210 / .55);">
|
||||||
|
<div style="display:flex;gap:20px;align-items:flex-start;">
|
||||||
|
<div style="{{ openAlbumCoverStyle }}">
|
||||||
|
<span style="font-family:ui-monospace,Menlo,monospace;font-size:11px;color:oklch(98% 0 0 / .8);">ALBUM COVER</span>
|
||||||
|
</div>
|
||||||
|
<div style="flex:1;min-width:0;">
|
||||||
|
<div style="font-size:26px;font-weight:900;color:oklch(22% 0.03 210);line-height:1.15;">{{ openAlbumTitle }}</div>
|
||||||
|
<div style="font-size:15px;font-weight:700;color:oklch(45% 0.03 210 / .85);margin-top:2px;">{{ openAlbumArtist }}</div>
|
||||||
|
<div style="font-size:13px;font-weight:700;color:oklch(50% 0.03 210 / .7);margin-top:2px;">{{ openAlbumMeta }}</div>
|
||||||
|
<button onClick="{{ playOpenAlbum }}" style="margin-top:14px;border:none;cursor:pointer;background:oklch(70% 0.16 340);color:#fff;font-family:inherit;font-size:16px;font-weight:800;padding:12px 22px;border-radius:999px;display:flex;align-items:center;gap:10px;box-shadow:0 4px 14px oklch(70% 0.16 340 / .45);">
|
||||||
|
<span style="width:14px;height:16px;background:#fff;clip-path:polygon(6% 0%, 100% 50%, 6% 100%);"></span>
|
||||||
|
Alle Songs abspielen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button onClick="{{ closeAlbum }}" style="border:none;cursor:pointer;background:oklch(88% 0.02 210);color:oklch(30% 0.04 210);font-family:inherit;font-size:18px;font-weight:900;width:40px;height:40px;border-radius:999px;flex:none;">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex;flex-direction:column;gap:6px;margin-top:22px;">
|
||||||
|
<sc-for list="{{ openAlbumSongs }}" as="song" hint-placeholder-count="5">
|
||||||
|
<div onClick="{{ playSongHit }}" data-album="{{ song.albumId }}" data-index="{{ song.index }}" style="{{ song.rowStyle }}">
|
||||||
|
<div style="{{ song.numStyle }}">{{ song.number }}</div>
|
||||||
|
<div style="flex:1;min-width:0;font-size:16px;font-weight:800;{{ song.titleStyle }}">{{ song.title }}</div>
|
||||||
|
<div style="font:700 13px ui-monospace,Menlo,monospace;color:oklch(45% 0.03 210 / .7);">{{ song.duration }}</div>
|
||||||
|
</div>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
|
||||||
|
<!-- shortcut help toggle + overlay -->
|
||||||
|
<button onClick="{{ toggleHelp }}" title="Zaubertasten (F1)" style="position:absolute;top:18px;right:24px;z-index:3;width:44px;height:44px;background:oklch(97% 0.01 210 / .95);border:none;border-radius:999px;box-shadow:0 6px 18px oklch(15% 0.05 210 / .4);font-size:20px;font-weight:900;color:oklch(30% 0.04 210);cursor:pointer;display:flex;align-items:center;justify-content:center;">?</button>
|
||||||
|
|
||||||
|
<sc-if value="{{ showHelp }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<div onClick="{{ toggleHelp }}" style="position:absolute;inset:0;background:oklch(15% 0.03 210 / .55);z-index:5;display:flex;align-items:center;justify-content:center;">
|
||||||
|
<div style="background:oklch(97% 0.01 210);border-radius:24px;padding:26px 30px;width:300px;box-shadow:0 20px 50px oklch(10% 0.04 210 / .5);">
|
||||||
|
<div style="font-size:17px;font-weight:800;color:oklch(30% 0.04 210);margin-bottom:14px;letter-spacing:.3px;">⌨️ Zaubertasten</div>
|
||||||
|
<div style="display:flex;flex-direction:column;gap:10px;">
|
||||||
|
<div style="display:flex;align-items:center;gap:10px;">
|
||||||
|
<div style="font:800 12px ui-monospace,monospace;background:oklch(30% 0.04 210);color:#fff;padding:5px 12px;border-radius:8px;min-width:56px;text-align:center;">LEER</div>
|
||||||
|
<div style="font-size:14px;font-weight:700;color:oklch(35% 0.03 210);">Play / Pause</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;align-items:center;gap:10px;">
|
||||||
|
<div style="display:flex;gap:4px;">
|
||||||
|
<div style="font:800 12px ui-monospace,monospace;background:oklch(30% 0.04 210);color:#fff;padding:5px 8px;border-radius:8px;">CTRL+H</div>
|
||||||
|
<div style="font:800 12px ui-monospace,monospace;background:oklch(30% 0.04 210);color:#fff;padding:5px 8px;border-radius:8px;">CTRL+L</div>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:14px;font-weight:700;color:oklch(35% 0.03 210);">Song zurück / vor</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;align-items:center;gap:10px;">
|
||||||
|
<div style="display:flex;gap:4px;">
|
||||||
|
<div style="font:800 12px ui-monospace,monospace;background:oklch(30% 0.04 210);color:#fff;padding:5px 8px;border-radius:8px;">CTRL+J</div>
|
||||||
|
<div style="font:800 12px ui-monospace,monospace;background:oklch(30% 0.04 210);color:#fff;padding:5px 8px;border-radius:8px;">CTRL+K</div>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:14px;font-weight:700;color:oklch(35% 0.03 210);">Leiser / lauter</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;align-items:center;gap:10px;">
|
||||||
|
<div style="font:800 12px ui-monospace,monospace;background:oklch(30% 0.04 210);color:#fff;padding:5px 12px;border-radius:8px;min-width:56px;text-align:center;">A-Z</div>
|
||||||
|
<div style="font-size:14px;font-weight:700;color:oklch(35% 0.03 210);">Album oder Hörbuch suchen</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;align-items:center;gap:10px;">
|
||||||
|
<div style="font:800 12px ui-monospace,monospace;background:oklch(30% 0.04 210);color:#fff;padding:5px 12px;border-radius:8px;min-width:56px;text-align:center;">?</div>
|
||||||
|
<div style="font-size:14px;font-weight:700;color:oklch(35% 0.03 210);">Einzelne Titel suchen</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;align-items:center;gap:10px;">
|
||||||
|
<div style="font:800 12px ui-monospace,monospace;background:oklch(30% 0.04 210);color:#fff;padding:5px 12px;border-radius:8px;min-width:56px;text-align:center;">F1</div>
|
||||||
|
<div style="font-size:14px;font-weight:700;color:oklch(35% 0.03 210);">Diese Hilfe</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;align-items:center;gap:10px;">
|
||||||
|
<div style="font:800 12px ui-monospace,monospace;background:oklch(30% 0.04 210);color:#fff;padding:5px 12px;border-radius:8px;min-width:56px;text-align:center;">TAB</div>
|
||||||
|
<div style="font-size:14px;font-weight:700;color:oklch(35% 0.03 210);">Musik / Hörbücher / alles</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;align-items:center;gap:10px;">
|
||||||
|
<div style="font:800 12px ui-monospace,monospace;background:oklch(30% 0.04 210);color:#fff;padding:5px 12px;border-radius:8px;min-width:56px;text-align:center;">← ↑ ↓ →</div>
|
||||||
|
<div style="font-size:14px;font-weight:700;color:oklch(35% 0.03 210);">Auswahl bewegen</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;align-items:center;gap:10px;">
|
||||||
|
<div style="font:800 12px ui-monospace,monospace;background:oklch(30% 0.04 210);color:#fff;padding:5px 12px;border-radius:8px;min-width:56px;text-align:center;">ENTER</div>
|
||||||
|
<div style="font-size:14px;font-weight:700;color:oklch(35% 0.03 210);">Auswahl abspielen</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;align-items:center;gap:10px;">
|
||||||
|
<div style="font:800 12px ui-monospace,monospace;background:oklch(30% 0.04 210);color:#fff;padding:5px 12px;border-radius:8px;min-width:56px;text-align:center;">ESC</div>
|
||||||
|
<div style="font-size:14px;font-weight:700;color:oklch(35% 0.03 210);">Schließen / Suche löschen</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:16px;font-size:12px;font-weight:600;color:oklch(50% 0.03 210 / .8);text-align:center;">Tippe irgendwohin, um zu schließen</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
|
||||||
|
<!-- player bar -->
|
||||||
|
<sc-if value="{{ isBrowse }}" hint-placeholder-val="{{ true }}">
|
||||||
|
<div style="position:absolute;left:0;right:0;bottom:0;z-index:2;background:oklch(18% 0.05 210 / .96);backdrop-filter:blur(6px);padding:16px clamp(12px, 3vw, 32px);box-sizing:border-box;display:flex;align-items:center;justify-content:center;gap:clamp(10px, 1.6vw, 22px);border-top:3px solid oklch(70% 0.16 340 / .5);">
|
||||||
|
|
||||||
|
<img src="dolphin-mascot.png" alt="Delfin mit Kopfhörern" style="width:clamp(56px, 7vw, 84px);height:clamp(56px, 7vw, 84px);object-fit:contain;flex:0 1 auto;min-width:0;animation:{{ dolphinBobAnim }};filter:drop-shadow(0 4px 12px oklch(10% 0.05 210 / .5));">
|
||||||
|
|
||||||
|
<div style="width:52px;height:52px;border-radius:12px;flex:none;{{ playerCoverStyle }}"></div>
|
||||||
|
|
||||||
|
<div style="flex:0 1 220px;min-width:0;overflow:hidden;">
|
||||||
|
<div style="font-size:16px;font-weight:800;color:oklch(97% 0.01 210);">{{ currentSongTitle }}</div>
|
||||||
|
<div style="font-size:13px;font-weight:600;color:oklch(85% 0.02 210 / .75);">{{ currentAlbumLine }}</div>
|
||||||
|
<div style="font-size:12px;font-weight:800;color:oklch(75% 0.14 340);margin-top:1px;">{{ trackCounter }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="flex:1 1 auto;max-width:360px;min-width:90px;">
|
||||||
|
<div style="height:10px;border-radius:999px;background:oklch(30% 0.03 210 / .6);overflow:hidden;">
|
||||||
|
<div style="height:100%;border-radius:999px;background:oklch(70% 0.16 340);{{ progressStyle }}"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex;align-items:center;gap:10px;">
|
||||||
|
<button onClick="{{ prevTrack }}" style="width:48px;height:48px;border-radius:999px;border:none;padding:0;background:oklch(30% 0.04 210);color:#fff;line-height:1;cursor:pointer;display:flex;align-items:center;justify-content:center;"><span style="display:flex;align-items:center;gap:2px;"><span style="width:4px;height:16px;border-radius:1px;background:#fff;"></span><span style="width:14px;height:16px;background:#fff;clip-path:polygon(100% 0%, 100% 100%, 0% 50%);"></span></span></button>
|
||||||
|
<button onClick="{{ togglePlay }}" style="width:60px;height:60px;border-radius:999px;border:none;padding:0;background:oklch(70% 0.16 340);color:#fff;line-height:1;cursor:pointer;display:flex;align-items:center;justify-content:center;box-shadow:0 4px 14px oklch(70% 0.16 340 / .5);"><sc-if value="{{ isPlaying }}" hint-placeholder-val="{{ false }}"><span style="display:flex;gap:5px;"><span style="width:6px;height:22px;border-radius:2px;background:#fff;"></span><span style="width:6px;height:22px;border-radius:2px;background:#fff;"></span></span></sc-if><sc-if value="{{ isPaused }}" hint-placeholder-val="{{ true }}"><span style="width:20px;height:22px;background:#fff;clip-path:polygon(6% 0%, 100% 50%, 6% 100%);"></span></sc-if></button>
|
||||||
|
<button onClick="{{ nextTrack }}" style="width:48px;height:48px;border-radius:999px;border:none;padding:0;background:oklch(30% 0.04 210);color:#fff;line-height:1;cursor:pointer;display:flex;align-items:center;justify-content:center;"><span style="display:flex;align-items:center;gap:2px;"><span style="width:14px;height:16px;background:#fff;clip-path:polygon(0% 0%, 0% 100%, 100% 50%);"></span><span style="width:4px;height:16px;border-radius:1px;background:#fff;"></span></span></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex;align-items:center;gap:8px;flex:0 0 auto;">
|
||||||
|
<button onClick="{{ muteToggle }}" title="Stumm" style="border:none;background:none;padding:0;cursor:pointer;font-size:18px;line-height:1;">🔊</button>
|
||||||
|
<div style="display:flex;gap:3px;">
|
||||||
|
<sc-for list="{{ volumeBars }}" as="bar" hint-placeholder-count="5">
|
||||||
|
<button onClick="{{ setVolume }}" data-level="{{ bar.level }}" title="Lautstärke" style="width:11px;height:30px;border-radius:4px;{{ bar.style }}"></button>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</x-dc>
|
||||||
|
<script type="text/x-dc" data-dc-script data-props="{"$preview": {"width": 1280, "height": 800}}">
|
||||||
|
const ALBUMS = [
|
||||||
|
{ id: 1, title: "Ocean Adventure", artist: "The Coral Crew", hue: 180, songs: [["Set Sail", "2:41"], ["Wave Rider", "3:05"], ["Lighthouse", "2:28"], ["Salty Wind", "3:22"], ["Safe Harbour", "2:55"]] },
|
||||||
|
{ id: 2, title: "Splash Party", artist: "Wavy Tunes", hue: 200, songs: [["Cannonball", "2:12"], ["Pool Noodle Boogie", "2:47"], ["Big Splash", "3:01"], ["Towel Dance", "2:33"]] },
|
||||||
|
{ id: 3, title: "Deep Blue Dreams", artist: "Finn & Friends", hue: 220, songs: [["Sinking Slow", "3:48"], ["Blue Lullaby", "4:12"], ["Silent Depths", "3:30"], ["Bioluminescence", "4:02"], ["Morning Surface", "3:14"]] },
|
||||||
|
{ id: 4, title: "Bubble Trouble", artist: "The Coral Crew", hue: 250, songs: [["Bubble Machine", "2:20"], ["Pop Pop Pop", "1:58"], ["Fizzy Water", "2:44"], ["Upside Down", "3:06"]] },
|
||||||
|
{ id: 5, title: "Starfish Disco", artist: "Reef Riders", hue: 20, songs: [["Five Arms Up", "3:18"], ["Glitter Sand", "2:52"], ["Disco Tide", "3:40"], ["Neon Anemone", "3:02"], ["Last Dance", "4:05"]] },
|
||||||
|
{ id: 6, title: "Whale Song", artist: "Finn & Friends", hue: 240, songs: [["Long Call", "5:12"], ["Migration", "4:36"], ["Under Ice", "3:58"], ["Deep Echo", "4:20"]] },
|
||||||
|
{ id: 7, title: "Tidal Groove", artist: "Wavy Tunes", hue: 195, songs: [["High Tide", "3:11"], ["Low Tide", "2:49"], ["Moon Pull", "3:35"], ["Rock Pool", "2:58"], ["Undertow", "3:24"]] },
|
||||||
|
{ id: 8, title: "Coral Reef Jam", artist: "Reef Riders", hue: 15, songs: [["Clownfish Shuffle", "2:36"], ["Parrotfish Parade", "3:08"], ["Coral Bloom", "3:44"], ["Reef Party", "2:50"]] },
|
||||||
|
{ id: 9, title: "Sandy Shores", artist: "Beach Buddies", hue: 40, songs: [["Barefoot", "2:26"], ["Sandcastle", "3:02"], ["Seagull Song", "2:14"], ["Sunset Picnic", "3:30"], ["Beach Fire", "3:52"]] },
|
||||||
|
{ id: 10, title: "Moonlit Lagoon", artist: "Finn & Friends", hue: 260, songs: [["Silver Water", "4:08"], ["Night Swim", "3:36"], ["Sleepy Palms", "3:12"], ["Star Reflection", "4:24"]] },
|
||||||
|
{ id: 11, title: "Jellyfish Jive", artist: "The Coral Crew", hue: 300, songs: [["Wobble Wobble", "2:18"], ["Glow Parade", "2:56"], ["Tentacle Twist", "3:14"], ["Drifting", "3:40"], ["Sting Free", "2:44"]] },
|
||||||
|
{ id: 12, title: "Seahorse Shuffle", artist: "Beach Buddies", hue: 30, songs: [["Tiny Trot", "2:22"], ["Seagrass Swing", "2:58"], ["Curly Tail", "3:16"], ["Slow Parade", "3:04"]] },
|
||||||
|
{ id: 13, title: "Current Rhythm", artist: "Wavy Tunes", hue: 205, songs: [["Fast Current", "3:26"], ["Drift Beat", "3:02"], ["Whirlpool", "3:48"], ["Cross Stream", "2:52"], ["Open Water", "4:10"]] },
|
||||||
|
{ id: 14, title: "Anchor Down", artist: "Reef Riders", hue: 10, songs: [["Chain Rattle", "2:40"], ["Harbour Lights", "3:20"], ["Old Rope", "3:04"], ["Anchor Down", "3:52"]] },
|
||||||
|
{ id: 15, type: "book", series: "Wal Wilma", title: "Der kleine Wal Wilma", artist: "Erzählt von Lena Fisch", hue: 230, songs: [["Kapitel 1 — Wilma wacht auf", "8:12"], ["Kapitel 2 — Die große Welle", "9:04"], ["Kapitel 3 — Ein Freund im Dunkeln", "7:48"], ["Kapitel 4 — Heimweg", "8:36"]] },
|
||||||
|
{ id: 16, type: "book", series: "Käpt'n Krabbe", title: "Käpt'n Krabbe und der Schatz", artist: "Erzählt von Jonas Meer", hue: 25, songs: [["Kapitel 1 — Die alte Karte", "10:20"], ["Kapitel 2 — Sturm vor Kap Kork", "11:02"], ["Kapitel 3 — Die Höhle", "9:44"], ["Kapitel 4 — Gold und Muscheln", "10:12"], ["Kapitel 5 — Zurück im Hafen", "8:58"]] },
|
||||||
|
{ id: 17, type: "book", series: "Nele", title: "Nele taucht ab", artist: "Erzählt von Mira Sand", hue: 190, songs: [["Kapitel 1 — Die neue Taucherbrille", "7:30"], ["Kapitel 2 — Unten am Riff", "8:14"], ["Kapitel 3 — Das leise Lied", "9:06"], ["Kapitel 4 — Wieder oben", "6:52"]] },
|
||||||
|
{ id: 18, type: "book", series: "Muschelbande", title: "Die Muschelbande", artist: "Erzählt von Tom Anker", hue: 300, songs: [["Kapitel 1 — Drei Freunde", "8:48"], ["Kapitel 2 — Der Plan", "9:22"], ["Kapitel 3 — Verfolgt", "10:04"], ["Kapitel 4 — Das Versteck", "8:16"], ["Kapitel 5 — Alles gut", "7:40"]] },
|
||||||
|
{ id: 19, type: "book", series: "Riff-Geschichten", title: "Gute-Nacht-Geschichten vom Riff", artist: "Erzählt von Lena Fisch", hue: 265, songs: [["Der schläfrige Seestern", "6:40"], ["Wo die Quallen leuchten", "7:12"], ["Das müde Seepferdchen", "6:28"], ["Lied der Strömung", "8:02"]] },
|
||||||
|
{ id: 20, type: "book", series: "Wal Wilma", title: "Wal Wilma im Eismeer", artist: "Erzählt von Lena Fisch", hue: 245, songs: [["Kapitel 1 — Kaltes Wasser", "9:10"], ["Kapitel 2 — Das Eisloch", "8:26"], ["Kapitel 3 — Der Pinguin", "9:44"], ["Kapitel 4 — Nach Hause", "8:02"]] },
|
||||||
|
{ id: 21, type: "book", series: "Käpt'n Krabbe", title: "Käpt'n Krabbe segelt los", artist: "Erzählt von Jonas Meer", hue: 35, songs: [["Kapitel 1 — Neue Segel", "9:52"], ["Kapitel 2 — Der blinde Passagier", "10:36"], ["Kapitel 3 — Nebelbank", "9:18"], ["Kapitel 4 — Land in Sicht", "8:44"]] },
|
||||||
|
{ id: 22, type: "book", series: "Nele", title: "Nele und der Seestern", artist: "Erzählt von Mira Sand", hue: 200, songs: [["Kapitel 1 — Fünf Arme", "7:22"], ["Kapitel 2 — Der Gezeitenteich", "8:06"], ["Kapitel 3 — Zurück ins Meer", "7:54"]] },
|
||||||
|
];
|
||||||
|
const TRACK_DURATION_MS = 24000;
|
||||||
|
|
||||||
|
class Component extends DCLogic {
|
||||||
|
state = {
|
||||||
|
search: "",
|
||||||
|
mode: "albums",
|
||||||
|
category: null,
|
||||||
|
filter: "all",
|
||||||
|
albumId: null,
|
||||||
|
trackIndex: 0,
|
||||||
|
openAlbumId: null,
|
||||||
|
view: "browse",
|
||||||
|
selIndex: 0,
|
||||||
|
cols: 4,
|
||||||
|
isPlaying: false,
|
||||||
|
volume: 70,
|
||||||
|
progress: 0,
|
||||||
|
showHelp: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
playPop(freq) {
|
||||||
|
try {
|
||||||
|
if (!this._ac) this._ac = new (window.AudioContext || window.webkitAudioContext)();
|
||||||
|
const ac = this._ac;
|
||||||
|
const osc = ac.createOscillator();
|
||||||
|
const gain = ac.createGain();
|
||||||
|
osc.type = "sine";
|
||||||
|
osc.frequency.setValueAtTime(freq, ac.currentTime);
|
||||||
|
osc.frequency.exponentialRampToValueAtTime(freq * 1.8, ac.currentTime + 0.08);
|
||||||
|
gain.gain.setValueAtTime(0.15, ac.currentTime);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.001, ac.currentTime + 0.15);
|
||||||
|
osc.connect(gain).connect(ac.destination);
|
||||||
|
osc.start();
|
||||||
|
osc.stop(ac.currentTime + 0.16);
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
this._onKey = (e) => this.handleKey(e);
|
||||||
|
window.addEventListener("keydown", this._onKey);
|
||||||
|
this._onResize = () => this.measureCols();
|
||||||
|
window.addEventListener("resize", this._onResize);
|
||||||
|
this.measureCols();
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
window.removeEventListener("keydown", this._onKey);
|
||||||
|
window.removeEventListener("resize", this._onResize);
|
||||||
|
if (this._timer) clearInterval(this._timer);
|
||||||
|
}
|
||||||
|
|
||||||
|
navCounts() {
|
||||||
|
return { songs: this.songMatches().length, albums: this.albumMatches().length + this.categoryMatches().length };
|
||||||
|
}
|
||||||
|
|
||||||
|
moveSel(dx, dy) {
|
||||||
|
const { songs, albums } = this.navCounts();
|
||||||
|
const total = songs + albums;
|
||||||
|
if (!total) return;
|
||||||
|
const cols = Math.max(1, this.state.cols);
|
||||||
|
this.setState((s) => {
|
||||||
|
let i = Math.min(s.selIndex, total - 1);
|
||||||
|
if (dx) i += dx;
|
||||||
|
if (dy) {
|
||||||
|
if (i < songs) i += dy;
|
||||||
|
else i += dy * cols;
|
||||||
|
}
|
||||||
|
if (i < 0) i = 0;
|
||||||
|
if (i > total - 1) i = total - 1;
|
||||||
|
return { selIndex: i };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
playSelection() {
|
||||||
|
const songs = this.songMatches();
|
||||||
|
const albums = this.albumMatches();
|
||||||
|
const cats = this.categoryMatches();
|
||||||
|
const i = Math.min(this.state.selIndex, songs.length + albums.length + cats.length - 1);
|
||||||
|
if (i < 0) return;
|
||||||
|
if (i < songs.length) return this.playSong(songs[i].album.id, songs[i].index);
|
||||||
|
if (cats.length) {
|
||||||
|
const c = cats[i - songs.length];
|
||||||
|
if (c) this.setState({ category: c.key, selIndex: 0 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const a = albums[i - songs.length];
|
||||||
|
if (a) this.playSong(a.id, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate() {
|
||||||
|
const sc = this._scroller;
|
||||||
|
if (!sc) return;
|
||||||
|
if (this._lastSel === this.state.selIndex) return;
|
||||||
|
this._lastSel = this.state.selIndex;
|
||||||
|
const el = sc.querySelector('[data-nav-index="' + this.state.selIndex + '"]');
|
||||||
|
if (!el) return;
|
||||||
|
const top = el.offsetTop - sc.offsetTop;
|
||||||
|
const bottom = top + el.offsetHeight;
|
||||||
|
const pad = 24;
|
||||||
|
if (top - pad < sc.scrollTop) sc.scrollTop = Math.max(0, top - pad);
|
||||||
|
else if (bottom + pad > sc.scrollTop + sc.clientHeight) sc.scrollTop = bottom + pad - sc.clientHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
measureCols() {
|
||||||
|
const g = this._grid;
|
||||||
|
if (!g) return;
|
||||||
|
const n = getComputedStyle(g).gridTemplateColumns.split(" ").filter(Boolean).length;
|
||||||
|
if (n && n !== this.state.cols) this.setState({ cols: n });
|
||||||
|
}
|
||||||
|
|
||||||
|
normalize(s) {
|
||||||
|
return (s || "").toLowerCase().replace(/[^a-z0-9]/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
setFilter(f) {
|
||||||
|
this.playPop(380);
|
||||||
|
this.setState({ filter: f, selIndex: 0, view: "browse", category: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
cycleFilter() {
|
||||||
|
const order = ["all", "music", "book"];
|
||||||
|
this.setFilter(order[(order.indexOf(this.state.filter) + 1) % order.length]);
|
||||||
|
}
|
||||||
|
|
||||||
|
secs(d) {
|
||||||
|
const p = (d || "0:00").split(":");
|
||||||
|
return Number(p[0]) * 60 + Number(p[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
clock(total) {
|
||||||
|
const t = Math.max(0, Math.round(total));
|
||||||
|
const h = Math.floor(t / 3600);
|
||||||
|
const m = Math.floor((t % 3600) / 60);
|
||||||
|
const s = t % 60;
|
||||||
|
const pad = (n) => String(n).padStart(2, "0");
|
||||||
|
return h ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
remainingSecs(album) {
|
||||||
|
if (!album) return 0;
|
||||||
|
const i = this.state.trackIndex;
|
||||||
|
const cur = this.secs(album.songs[i] ? album.songs[i][1] : "0:00");
|
||||||
|
let rest = cur * (1 - this.state.progress / 100);
|
||||||
|
for (let k = i + 1; k < album.songs.length; k++) rest += this.secs(album.songs[k][1]);
|
||||||
|
return rest;
|
||||||
|
}
|
||||||
|
|
||||||
|
isBook(a) {
|
||||||
|
return a.type === "book";
|
||||||
|
}
|
||||||
|
|
||||||
|
categoryOf(a) {
|
||||||
|
return this.isBook(a) ? a.series || a.artist : a.artist;
|
||||||
|
}
|
||||||
|
|
||||||
|
listMode() {
|
||||||
|
if (this.state.mode === "tracks") return "tracks";
|
||||||
|
if (this.state.search || this.state.category) return "albums";
|
||||||
|
return "categories";
|
||||||
|
}
|
||||||
|
|
||||||
|
categoryMatches() {
|
||||||
|
if (this.listMode() !== "categories") return [];
|
||||||
|
const map = new Map();
|
||||||
|
this.pool().forEach((a) => {
|
||||||
|
const key = this.categoryOf(a);
|
||||||
|
if (!map.has(key)) map.set(key, { key, name: key, albums: [] });
|
||||||
|
map.get(key).albums.push(a);
|
||||||
|
});
|
||||||
|
return Array.from(map.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
navTotal() {
|
||||||
|
return this.songMatches().length + this.albumMatches().length + this.categoryMatches().length;
|
||||||
|
}
|
||||||
|
|
||||||
|
inFilter(a) {
|
||||||
|
const f = this.state.filter;
|
||||||
|
if (f === "book") return this.isBook(a);
|
||||||
|
if (f === "music") return !this.isBook(a);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
pool() {
|
||||||
|
return ALBUMS.filter((a) => this.inFilter(a));
|
||||||
|
}
|
||||||
|
|
||||||
|
albumMatches() {
|
||||||
|
if (this.state.mode === "tracks") return [];
|
||||||
|
const q = this.normalize(this.state.search);
|
||||||
|
let pool = this.pool();
|
||||||
|
if (!q && !this.state.category) return [];
|
||||||
|
if (this.state.category) pool = pool.filter((a) => this.categoryOf(a) === this.state.category);
|
||||||
|
if (!q) return pool;
|
||||||
|
return pool.filter((a) => this.normalize(a.title + a.artist).includes(q));
|
||||||
|
}
|
||||||
|
|
||||||
|
songMatches() {
|
||||||
|
if (this.state.mode !== "tracks") return [];
|
||||||
|
const q = this.normalize(this.state.search);
|
||||||
|
const hits = [];
|
||||||
|
this.pool().forEach((a) => {
|
||||||
|
a.songs.forEach((s, i) => {
|
||||||
|
if (!q || this.normalize(s[0]).includes(q)) hits.push({ album: a, index: i, title: s[0], duration: s[1] });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return hits.slice(0, 40);
|
||||||
|
}
|
||||||
|
|
||||||
|
playSong(albumId, index) {
|
||||||
|
this.playPop(300);
|
||||||
|
this.setState({ albumId, trackIndex: index, isPlaying: true, progress: 0, view: "play", openAlbumId: null });
|
||||||
|
this.startTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
startTimer() {
|
||||||
|
if (this._timer) clearInterval(this._timer);
|
||||||
|
this._timer = setInterval(() => {
|
||||||
|
this.setState((s) => {
|
||||||
|
if (!s.isPlaying) return {};
|
||||||
|
const next = s.progress + 100 / (TRACK_DURATION_MS / 200);
|
||||||
|
if (next >= 100) {
|
||||||
|
this.advance(1, true);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
return { progress: next };
|
||||||
|
});
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
advance(dir, silent) {
|
||||||
|
if (!silent) this.playPop(260);
|
||||||
|
this.setState((s) => {
|
||||||
|
const album = ALBUMS.find((a) => a.id === s.albumId);
|
||||||
|
if (!album) {
|
||||||
|
const first = this.albumMatches()[0] || ALBUMS[0];
|
||||||
|
return { albumId: first.id, trackIndex: 0, progress: 0, isPlaying: true };
|
||||||
|
}
|
||||||
|
const next = s.trackIndex + dir;
|
||||||
|
if (next >= 0 && next < album.songs.length) {
|
||||||
|
return { trackIndex: next, progress: 0, isPlaying: true };
|
||||||
|
}
|
||||||
|
// step to the neighbouring album and continue there
|
||||||
|
const order = ALBUMS;
|
||||||
|
const ai = order.findIndex((a) => a.id === album.id);
|
||||||
|
const nextAlbum = order[(ai + (dir > 0 ? 1 : -1) + order.length) % order.length];
|
||||||
|
return {
|
||||||
|
albumId: nextAlbum.id,
|
||||||
|
trackIndex: dir > 0 ? 0 : nextAlbum.songs.length - 1,
|
||||||
|
progress: 0,
|
||||||
|
isPlaying: true,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
handleKey(e) {
|
||||||
|
const key = e.key;
|
||||||
|
if (e.ctrlKey || e.metaKey) {
|
||||||
|
const k = key.toLowerCase();
|
||||||
|
if (k === "l") { e.preventDefault(); this.advance(1); return; }
|
||||||
|
if (k === "h") { e.preventDefault(); this.advance(-1); return; }
|
||||||
|
if (k === "k") { e.preventDefault(); this.setState((s) => ({ volume: Math.min(100, s.volume + 10) })); return; }
|
||||||
|
if (k === "j") { e.preventDefault(); this.setState((s) => ({ volume: Math.max(0, s.volume - 10) })); return; }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (key === "Tab") {
|
||||||
|
e.preventDefault();
|
||||||
|
this.cycleFilter();
|
||||||
|
} else if (key === "/") {
|
||||||
|
e.preventDefault();
|
||||||
|
this.setState({ view: "browse" });
|
||||||
|
} else if (key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
this.togglePlayInternal();
|
||||||
|
} else if (key === "ArrowRight" || key === "ArrowLeft" || key === "ArrowUp" || key === "ArrowDown") {
|
||||||
|
e.preventDefault();
|
||||||
|
const browsing = this.state.view === "browse" && this.state.openAlbumId === null && !this.state.showHelp;
|
||||||
|
if (browsing) {
|
||||||
|
if (key === "ArrowRight") this.moveSel(1, 0);
|
||||||
|
else if (key === "ArrowLeft") this.moveSel(-1, 0);
|
||||||
|
else if (key === "ArrowDown") this.moveSel(0, 1);
|
||||||
|
else this.moveSel(0, -1);
|
||||||
|
} else if (key === "ArrowRight") this.advance(1);
|
||||||
|
else if (key === "ArrowLeft") this.advance(-1);
|
||||||
|
else if (key === "ArrowUp") this.setState((s) => ({ volume: Math.min(100, s.volume + 10) }));
|
||||||
|
else this.setState((s) => ({ volume: Math.max(0, s.volume - 10) }));
|
||||||
|
} else if (key === "?") {
|
||||||
|
e.preventDefault();
|
||||||
|
this.playPop(460);
|
||||||
|
this.setState({ mode: "tracks", search: "", selIndex: 0, view: "browse", showHelp: false });
|
||||||
|
} else if (key === "F1") {
|
||||||
|
e.preventDefault();
|
||||||
|
this.setState((s) => ({ showHelp: !s.showHelp }));
|
||||||
|
} else if (key === "Escape") {
|
||||||
|
this.setState((s) => (s.showHelp || s.openAlbumId !== null
|
||||||
|
? { showHelp: false, openAlbumId: null }
|
||||||
|
: s.search
|
||||||
|
? { search: "", selIndex: 0 }
|
||||||
|
: s.mode === "tracks"
|
||||||
|
? { mode: "albums", selIndex: 0 }
|
||||||
|
: { category: null, selIndex: 0 }));
|
||||||
|
} else if (key === "Backspace") {
|
||||||
|
this.setState((s) => ({ search: s.search.slice(0, -1), selIndex: 0 }));
|
||||||
|
} else if (key === "Enter") {
|
||||||
|
if (this.state.openAlbumId !== null) {
|
||||||
|
this.playSong(this.state.openAlbumId, 0);
|
||||||
|
} else {
|
||||||
|
this.playSelection();
|
||||||
|
}
|
||||||
|
} else if (/^[a-zA-Z0-9]$/.test(key)) {
|
||||||
|
this.setState((s) => ({ search: s.search + key, view: "browse", selIndex: 0 }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
togglePlayInternal() {
|
||||||
|
this.playPop(340);
|
||||||
|
this.setState((s) => {
|
||||||
|
if (s.albumId === null) {
|
||||||
|
const first = this.albumMatches()[0];
|
||||||
|
if (!first) return {};
|
||||||
|
this.startTimer();
|
||||||
|
return { albumId: first.id, trackIndex: 0, isPlaying: true, progress: 0 };
|
||||||
|
}
|
||||||
|
return { isPlaying: !s.isPlaying };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
coverBg(a, w) {
|
||||||
|
if (this.isBook(a)) {
|
||||||
|
const h = a.hue;
|
||||||
|
return `background:
|
||||||
|
linear-gradient(90deg, transparent 0 95%, oklch(97% 0.02 88) 95% 97.5%, oklch(90% 0.03 88) 97.5% 100%),
|
||||||
|
linear-gradient(90deg, transparent 0 3.5%, oklch(92% 0.04 ${h} / .45) 3.5% 4.3%, transparent 4.3% 7%, oklch(92% 0.04 ${h} / .45) 7% 7.8%, transparent 7.8%),
|
||||||
|
linear-gradient(90deg, oklch(30% 0.06 ${h}) 0 11%, oklch(24% 0.05 ${h}) 11% 13%, transparent 13%),
|
||||||
|
linear-gradient(155deg, oklch(64% 0.10 ${h}) 0%, oklch(48% 0.09 ${h}) 100%);`;
|
||||||
|
}
|
||||||
|
return this.stripes(a.hue, w);
|
||||||
|
}
|
||||||
|
|
||||||
|
filterPill(value, active) {
|
||||||
|
return `border:none;cursor:pointer;font-family:inherit;font-size:14px;font-weight:800;padding:9px 15px;border-radius:999px;white-space:nowrap;${
|
||||||
|
active
|
||||||
|
? "background:oklch(97% 0.01 210);color:oklch(28% 0.04 210);box-shadow:0 4px 14px oklch(15% 0.05 210 / .35);"
|
||||||
|
: "background:oklch(97% 0.01 210 / .16);color:oklch(97% 0.01 210);"
|
||||||
|
}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
stripes(hue, w) {
|
||||||
|
return `background:repeating-linear-gradient(135deg, oklch(62% 0.12 ${hue}) 0px, oklch(62% 0.12 ${hue}) ${w}px, oklch(50% 0.12 ${hue}) ${w}px, oklch(50% 0.12 ${hue}) ${w * 2}px);`;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderVals() {
|
||||||
|
const albums = this.albumMatches();
|
||||||
|
const songHits = this.songMatches();
|
||||||
|
const album = ALBUMS.find((a) => a.id === this.state.albumId) || null;
|
||||||
|
const song = album ? album.songs[this.state.trackIndex] : null;
|
||||||
|
const openAlbum = ALBUMS.find((a) => a.id === this.state.openAlbumId) || null;
|
||||||
|
|
||||||
|
const cats = this.categoryMatches();
|
||||||
|
const total = songHits.length + albums.length + cats.length;
|
||||||
|
const sel = total ? Math.min(this.state.selIndex, total - 1) : -1;
|
||||||
|
|
||||||
|
const categoryCards = cats.map((c, ci) => {
|
||||||
|
const isSel = sel === songHits.length + ci;
|
||||||
|
const books = c.albums.filter((a) => this.isBook(a)).length;
|
||||||
|
const allBooks = books === c.albums.length;
|
||||||
|
const tiles = c.albums.slice(0, 4).map((a) => ({
|
||||||
|
style: `border-radius:6px;${this.coverBg(a, 7)}`,
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
key: c.key,
|
||||||
|
navIndex: songHits.length + ci,
|
||||||
|
name: c.name,
|
||||||
|
typeLabel: allBooks ? "📖 Hörbücher" : books ? "🎵📖 Gemischt" : "🎵 Musik",
|
||||||
|
countLabel: `${c.albums.length} ${allBooks ? (c.albums.length === 1 ? "Hörbuch" : "Hörbücher") : c.albums.length === 1 ? "Album" : "Alben"}`,
|
||||||
|
tiles,
|
||||||
|
cardStyle: `background:${allBooks ? "oklch(93% 0.055 88 / .7)" : "oklch(95% 0.015 210 / .66)"};backdrop-filter:blur(6px);border-radius:${allBooks ? "6px 18px 18px 6px" : "16px"};overflow:hidden;cursor:pointer;transition:transform .12s ease;box-shadow:0 6px 18px oklch(15% 0.05 210 / .35);${
|
||||||
|
isSel ? "outline:5px solid oklch(97% 0.01 210);outline-offset:3px;transform:translateY(-3px);" : ""
|
||||||
|
}`,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const albumCards = albums.map((a, ai) => {
|
||||||
|
const isSel = sel === songHits.length + cats.length + ai;
|
||||||
|
const ring = isSel
|
||||||
|
? "outline:5px solid oklch(97% 0.01 210);outline-offset:3px;transform:translateY(-3px);"
|
||||||
|
: a.id === this.state.albumId
|
||||||
|
? "outline:4px solid oklch(70% 0.16 340);"
|
||||||
|
: "";
|
||||||
|
const book = this.isBook(a);
|
||||||
|
return {
|
||||||
|
id: a.id,
|
||||||
|
navIndex: songHits.length + cats.length + ai,
|
||||||
|
title: a.title,
|
||||||
|
artist: a.artist,
|
||||||
|
typeLabel: book ? "📖 Hörbuch" : "🎵 Musik",
|
||||||
|
coverLabel: book ? "HÖRBUCH COVER" : "ALBUM COVER",
|
||||||
|
coverLabelStyle: book
|
||||||
|
? "font-family:ui-monospace,Menlo,monospace;font-size:10px;color:oklch(98% 0 0 / .9);letter-spacing:.5px;text-align:center;margin-left:11%;padding:14px 10px;border:2px solid oklch(98% 0 0 / .55);border-radius:4px;"
|
||||||
|
: "font-family:ui-monospace,Menlo,monospace;font-size:11px;color:oklch(98% 0 0 / .8);letter-spacing:.5px;",
|
||||||
|
songCountLabel: book ? `${a.songs.length} Kapitel` : `${a.songs.length} Songs`,
|
||||||
|
bodyStyle: `padding:10px 12px 14px;${book ? "padding-right:22px;" : ""}`,
|
||||||
|
badgeStyle: `position:absolute;top:8px;left:8px;font-size:11px;font-weight:800;padding:4px 9px;border-radius:999px;background:${book ? "oklch(94% 0.09 88)" : "oklch(97% 0.01 210)"};color:oklch(26% 0.04 210);box-shadow:0 2px 6px oklch(15% 0.05 210 / .3);`,
|
||||||
|
cardStyle: `background:${book ? "oklch(93% 0.055 88 / .7)" : "oklch(95% 0.015 210 / .66)"};backdrop-filter:blur(6px);border-radius:${book ? "6px 18px 18px 6px" : "16px"};overflow:hidden;cursor:pointer;transition:transform .12s ease;box-shadow:${
|
||||||
|
book
|
||||||
|
? "inset -7px 0 0 oklch(88% 0.06 88 / .7), inset -11px 0 0 oklch(82% 0.06 88 / .7), 0 6px 18px oklch(15% 0.05 210 / .35)"
|
||||||
|
: "0 6px 18px oklch(15% 0.05 210 / .35)"
|
||||||
|
};${ring}`,
|
||||||
|
coverStyle: `width:100%;aspect-ratio:${book ? "0.82" : "1"};position:relative;display:flex;align-items:center;justify-content:center;${this.coverBg(a, 14)}`,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const songRows = songHits.map((h, hi) => ({
|
||||||
|
albumId: h.album.id,
|
||||||
|
navIndex: hi,
|
||||||
|
index: h.index,
|
||||||
|
title: h.title,
|
||||||
|
duration: h.duration,
|
||||||
|
albumLine: `${this.isBook(h.album) ? "📖 " : ""}${h.album.title} · ${h.album.artist}`,
|
||||||
|
rowStyle: `display:flex;align-items:center;gap:14px;padding:10px 16px;border-radius:14px;cursor:pointer;background:oklch(97% 0.01 210 / ${album && album.id === h.album.id && this.state.trackIndex === h.index ? ".22" : ".10"});${sel === hi ? "outline:4px solid oklch(97% 0.01 210);outline-offset:2px;" : ""}`,
|
||||||
|
chipStyle: `width:38px;height:38px;flex:none;border-radius:${this.isBook(h.album) ? "6px 12px 12px 6px" : "10px"};${this.coverBg(h.album, 7)}`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const openSongs = openAlbum
|
||||||
|
? openAlbum.songs.map((s, i) => {
|
||||||
|
const isCurrent = album && album.id === openAlbum.id && this.state.trackIndex === i;
|
||||||
|
return {
|
||||||
|
albumId: openAlbum.id,
|
||||||
|
index: i,
|
||||||
|
number: String(i + 1),
|
||||||
|
title: s[0],
|
||||||
|
duration: s[1],
|
||||||
|
rowStyle: `display:flex;align-items:center;gap:14px;padding:10px 14px;border-radius:14px;cursor:pointer;background:${isCurrent ? "oklch(70% 0.16 340 / .16)" : "oklch(30% 0.03 210 / .05)"};`,
|
||||||
|
numStyle: `width:30px;height:30px;border-radius:999px;flex:none;display:flex;align-items:center;justify-content:center;font:800 13px ui-monospace,Menlo,monospace;background:${isCurrent ? "oklch(70% 0.16 340)" : "oklch(88% 0.02 210)"};color:${isCurrent ? "#fff" : "oklch(35% 0.03 210)"};`,
|
||||||
|
titleStyle: `color:${isCurrent ? "oklch(45% 0.16 340)" : "oklch(24% 0.03 210)"};`,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const volumeBars = [0, 1, 2, 3, 4].map((i) => ({
|
||||||
|
level: (i + 1) * 20,
|
||||||
|
style: `border:none;padding:0;cursor:pointer;background:${this.state.volume >= (i + 1) * 20 ? "oklch(70% 0.16 340)" : "oklch(88% 0.02 210 / .4)"};`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const parts = [];
|
||||||
|
if (this.state.mode === "tracks") parts.push(`${songHits.length} Titel`);
|
||||||
|
else if (albums.length) parts.push(`${albums.length} Album${albums.length === 1 ? "" : "en"}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
filterAllStyle: this.filterPill("all", this.state.filter === "all"),
|
||||||
|
filterMusicStyle: this.filterPill("music", this.state.filter === "music"),
|
||||||
|
filterBookStyle: this.filterPill("book", this.state.filter === "book"),
|
||||||
|
setFilterAll: () => this.setFilter("all"),
|
||||||
|
setFilterMusic: () => this.setFilter("music"),
|
||||||
|
setFilterBook: () => this.setFilter("book"),
|
||||||
|
scrollerRef: (el) => { this._scroller = el; },
|
||||||
|
gridRef: (el) => { this._grid = el; if (el) requestAnimationFrame(() => this.measureCols()); },
|
||||||
|
showHelp: this.state.showHelp,
|
||||||
|
toggleHelp: () => this.setState((s) => ({ showHelp: !s.showHelp })),
|
||||||
|
hasSearch: this.state.search.length > 0,
|
||||||
|
showSearchBar: this.state.search.length > 0 || this.state.mode === "tracks",
|
||||||
|
modeLabel: this.state.mode === "tracks" ? "♪ Titel" : "🔎 Alben",
|
||||||
|
searchDisplay: this.state.search ? `"${this.state.search}"` : "tippe …",
|
||||||
|
resultCountLabel: parts.length ? parts.join(" · ") + " gefunden" : "nichts gefunden",
|
||||||
|
albumSectionLabel: this.state.filter === "book" ? "Hörbücher" : this.state.filter === "music" ? "Alben" : "Alben & Hörbücher",
|
||||||
|
categories: categoryCards,
|
||||||
|
hasCategories: categoryCards.length > 0,
|
||||||
|
categorySectionLabel: this.state.filter === "book" ? "Figuren" : this.state.filter === "music" ? "Künstler" : "Figuren & Künstler",
|
||||||
|
openCategory: (e) => {
|
||||||
|
this.playPop(440);
|
||||||
|
this.setState({ category: e.currentTarget.dataset.key, selIndex: 0 });
|
||||||
|
},
|
||||||
|
hasCategoryCrumb: this.state.category !== null && !this.state.search,
|
||||||
|
categoryCrumb: this.state.category || "",
|
||||||
|
clearCategory: () => this.setState({ category: null, selIndex: 0 }),
|
||||||
|
hasAlbumHits: albums.length > 0,
|
||||||
|
hasSongHits: songHits.length > 0,
|
||||||
|
hasNoResults: albums.length === 0 && songHits.length === 0 && cats.length === 0,
|
||||||
|
songHitLabel: `${songHits.length} Treffer`,
|
||||||
|
filteredAlbums: albumCards,
|
||||||
|
songHits: songRows,
|
||||||
|
openAlbumCard: (e) => {
|
||||||
|
this.playPop(420);
|
||||||
|
const nav = Number(e.currentTarget.dataset.navIndex);
|
||||||
|
this.setState({ openAlbumId: Number(e.currentTarget.dataset.id), selIndex: isNaN(nav) ? this.state.selIndex : nav });
|
||||||
|
},
|
||||||
|
closeAlbum: () => this.setState({ openAlbumId: null }),
|
||||||
|
stopClick: (e) => e.stopPropagation(),
|
||||||
|
playOpenAlbum: () => {
|
||||||
|
if (this.state.openAlbumId !== null) {
|
||||||
|
this.playSong(this.state.openAlbumId, 0);
|
||||||
|
this.setState({ openAlbumId: null });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
playSongHit: (e) => {
|
||||||
|
const d = e.currentTarget.dataset;
|
||||||
|
this.playSong(Number(d.album), Number(d.index));
|
||||||
|
},
|
||||||
|
hasOpenAlbum: openAlbum !== null,
|
||||||
|
openAlbumTitle: openAlbum ? openAlbum.title : "",
|
||||||
|
openAlbumArtist: openAlbum ? openAlbum.artist : "",
|
||||||
|
openAlbumMeta: openAlbum
|
||||||
|
? this.isBook(openAlbum)
|
||||||
|
? `📖 Hörbuch · ${openAlbum.songs.length} Kapitel`
|
||||||
|
: `🎵 Musik · ${openAlbum.songs.length} Songs`
|
||||||
|
: "",
|
||||||
|
openAlbumCoverStyle: openAlbum
|
||||||
|
? `width:150px;height:150px;flex:none;border-radius:${this.isBook(openAlbum) ? "10px 18px 18px 10px" : "16px"};display:flex;align-items:center;justify-content:center;${this.coverBg(openAlbum, 12)}`
|
||||||
|
: "display:none;",
|
||||||
|
openAlbumSongs: openSongs,
|
||||||
|
togglePlay: () => this.togglePlayInternal(),
|
||||||
|
prevTrack: () => this.advance(-1),
|
||||||
|
nextTrack: () => this.advance(1),
|
||||||
|
isBrowse: this.state.view === "browse",
|
||||||
|
isPlay: this.state.view === "play",
|
||||||
|
goBrowse: () => this.setState({ view: "browse" }),
|
||||||
|
|
||||||
|
bigCoverStyle: album
|
||||||
|
? `width:min(340px, 28vh);height:min(340px, 28vh);flex:none;border-radius:${this.isBook(album) ? "18px 34px 34px 18px" : "28px"};display:flex;align-items:center;justify-content:center;box-shadow:0 20px 50px oklch(10% 0.05 210 / .55);${this.coverBg(album, 22)}`
|
||||||
|
: "width:min(340px, 28vh);height:min(340px, 28vh);flex:none;border-radius:28px;background:oklch(35% 0.03 210);",
|
||||||
|
isPlaying: this.state.isPlaying,
|
||||||
|
isPaused: !this.state.isPlaying,
|
||||||
|
currentSongTitle: song ? song[0] : "Wähl ein Album!",
|
||||||
|
currentAlbumLine: album ? `${this.isBook(album) ? "📖 " : ""}${album.title} · ${album.artist}` : "Tippen oder klicken",
|
||||||
|
trackCounter: album ? `${this.isBook(album) ? "Kapitel" : "Song"} ${this.state.trackIndex + 1} von ${album.songs.length}` : "",
|
||||||
|
playerCoverStyle: album ? this.coverBg(album, 8) : "background:oklch(35% 0.03 210);",
|
||||||
|
albumRemainingLabel: album
|
||||||
|
? `Noch ${this.clock(this.remainingSecs(album))} ${this.isBook(album) ? "im Hörbuch" : "im Album"}`
|
||||||
|
: "",
|
||||||
|
trackRemainingLabel: album && album.songs[this.state.trackIndex]
|
||||||
|
? `−${this.clock(this.secs(album.songs[this.state.trackIndex][1]) * (1 - this.state.progress / 100))}`
|
||||||
|
: "",
|
||||||
|
progressStyle: `width:${this.state.progress}%;transition:width .2s linear;`,
|
||||||
|
dolphinBobAnim: this.state.isPlaying ? "dolphinBob 1.6s ease-in-out infinite" : "none",
|
||||||
|
volumeBars,
|
||||||
|
setVolume: (e) => {
|
||||||
|
this.playPop(500);
|
||||||
|
this.setState({ volume: Number(e.currentTarget.dataset.level) });
|
||||||
|
},
|
||||||
|
muteToggle: () => this.setState((s) => ({ volume: s.volume > 0 ? 0 : 60 })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
317
claude-design/Mein Zimmer.dc.html
Normal file
317
claude-design/Mein Zimmer.dc.html
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<script src="./support.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<x-dc>
|
||||||
|
<helmet>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Nunito:wght@500;700;800;900&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
@keyframes bubbleRise { 0% { transform: translateY(0) scale(1); opacity: .55; } 100% { transform: translateY(-120vh) scale(1.3); opacity: 0; } }
|
||||||
|
@keyframes dolphinFloat { 0%, 100% { transform: translateY(0) rotate(-2deg); } 50% { transform: translateY(-12px) rotate(2deg); } }
|
||||||
|
body { margin: 0; font-family: 'Nunito', system-ui, sans-serif; }
|
||||||
|
a { color: oklch(80% 0.14 302); }
|
||||||
|
a:hover { color: oklch(92% 0.10 302); }
|
||||||
|
</style>
|
||||||
|
</helmet>
|
||||||
|
|
||||||
|
<div style="position:relative;width:100%;min-height:100vh;box-sizing:border-box;overflow:hidden;background:linear-gradient(180deg, oklch(56% 0.14 305) 0%, oklch(38% 0.12 300) 45%, oklch(21% 0.08 298) 100%);">
|
||||||
|
|
||||||
|
<div style="position:absolute;left:4%;bottom:-40px;width:14px;height:14px;border-radius:50%;background:oklch(94% 0.03 302 / .5);animation:bubbleRise 9s linear infinite;"></div>
|
||||||
|
<div style="position:absolute;left:11%;bottom:-40px;width:22px;height:22px;border-radius:50%;background:oklch(94% 0.03 302 / .35);animation:bubbleRise 12s linear infinite;animation-delay:2s;"></div>
|
||||||
|
<div style="position:absolute;left:19%;bottom:-40px;width:10px;height:10px;border-radius:50%;background:oklch(94% 0.03 302 / .5);animation:bubbleRise 7s linear infinite;animation-delay:1s;"></div>
|
||||||
|
<div style="position:absolute;left:27%;bottom:-40px;width:18px;height:18px;border-radius:50%;background:oklch(94% 0.03 302 / .42);animation:bubbleRise 10.5s linear infinite;animation-delay:4s;"></div>
|
||||||
|
<div style="position:absolute;left:34%;bottom:-40px;width:8px;height:8px;border-radius:50%;background:oklch(94% 0.03 302 / .5);animation:bubbleRise 6.8s linear infinite;animation-delay:3s;"></div>
|
||||||
|
<div style="position:absolute;left:42%;bottom:-40px;width:24px;height:24px;border-radius:50%;background:oklch(94% 0.03 302 / .3);animation:bubbleRise 13.5s linear infinite;animation-delay:5.5s;"></div>
|
||||||
|
<div style="position:absolute;left:49%;bottom:-40px;width:12px;height:12px;border-radius:50%;background:oklch(94% 0.03 302 / .48);animation:bubbleRise 8.4s linear infinite;animation-delay:1.6s;"></div>
|
||||||
|
<div style="position:absolute;left:57%;bottom:-40px;width:16px;height:16px;border-radius:50%;background:oklch(94% 0.03 302 / .4);animation:bubbleRise 11.2s linear infinite;animation-delay:6.2s;"></div>
|
||||||
|
<div style="position:absolute;left:64%;bottom:-40px;width:9px;height:9px;border-radius:50%;background:oklch(94% 0.03 302 / .5);animation:bubbleRise 7.4s linear infinite;animation-delay:2.4s;"></div>
|
||||||
|
<div style="position:absolute;left:72%;bottom:-40px;width:20px;height:20px;border-radius:50%;background:oklch(94% 0.03 302 / .32);animation:bubbleRise 14s linear infinite;animation-delay:3.8s;"></div>
|
||||||
|
<div style="position:absolute;left:80%;bottom:-40px;width:11px;height:11px;border-radius:50%;background:oklch(94% 0.03 302 / .48);animation:bubbleRise 9.2s linear infinite;animation-delay:5s;"></div>
|
||||||
|
<div style="position:absolute;left:88%;bottom:-40px;width:26px;height:26px;border-radius:50%;background:oklch(94% 0.03 302 / .28);animation:bubbleRise 15s linear infinite;animation-delay:1.2s;"></div>
|
||||||
|
<div style="position:absolute;left:95%;bottom:-40px;width:13px;height:13px;border-radius:50%;background:oklch(94% 0.03 302 / .45);animation:bubbleRise 10.2s linear infinite;animation-delay:7s;"></div>
|
||||||
|
|
||||||
|
<div style="position:relative;z-index:1;padding-top:10px;">
|
||||||
|
<dc-import name="AppHeader" title="Mein Zimmer" mascot="dolphin-remote.png" link-href="./Dolphin%20Beats.dc.html" link-label="♪ Musik" hint-size="100%,116px"></dc-import>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="position:relative;z-index:1;max-width:1180px;margin:0 auto;padding:0 32px 64px;box-sizing:border-box;">
|
||||||
|
|
||||||
|
<div style="margin-top:18px;">
|
||||||
|
<div style="font-size:19px;font-weight:900;color:oklch(97% 0.01 302);text-align:center;margin-bottom:14px;">Szenen</div>
|
||||||
|
<div style="display:flex;flex-wrap:wrap;justify-content:center;gap:12px;">
|
||||||
|
<sc-for list="{{ scenes }}" as="scene" hint-placeholder-count="5">
|
||||||
|
<button onClick="{{ activateScene }}" data-id="{{ scene.id }}" style="{{ scene.style }}">
|
||||||
|
<span style="font-size:22px;">{{ scene.emoji }}</span>
|
||||||
|
<span>{{ scene.name }}</span>
|
||||||
|
</button>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:grid;grid-template-columns:repeat(auto-fit, minmax(300px, 1fr));gap:20px;margin-top:30px;">
|
||||||
|
|
||||||
|
<sc-for list="{{ lights }}" as="light" hint-placeholder-count="4">
|
||||||
|
<div style="{{ light.cardStyle }}">
|
||||||
|
<div style="display:flex;align-items:center;gap:14px;">
|
||||||
|
<div style="{{ light.iconWrapStyle }}">
|
||||||
|
<sc-if value="{{ light.isBeyond }}" hint-placeholder-val="{{ true }}">
|
||||||
|
<svg viewBox="0 0 48 48" width="34" height="34" aria-hidden="true">
|
||||||
|
<circle cx="24" cy="24" r="17" fill="none" stroke="currentColor" stroke-width="3"></circle>
|
||||||
|
<circle cx="24" cy="24" r="9" fill="currentColor" opacity=".85"></circle>
|
||||||
|
<circle cx="24" cy="24" r="22" fill="none" stroke="currentColor" stroke-width="1.5" opacity=".4"></circle>
|
||||||
|
</svg>
|
||||||
|
</sc-if>
|
||||||
|
<sc-if value="{{ light.isStrip }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<svg viewBox="0 0 48 48" width="34" height="34" aria-hidden="true">
|
||||||
|
<rect x="4" y="19" width="40" height="10" rx="5" fill="none" stroke="currentColor" stroke-width="3"></rect>
|
||||||
|
<circle cx="13" cy="24" r="2.4" fill="currentColor"></circle>
|
||||||
|
<circle cx="24" cy="24" r="2.4" fill="currentColor"></circle>
|
||||||
|
<circle cx="35" cy="24" r="2.4" fill="currentColor"></circle>
|
||||||
|
</svg>
|
||||||
|
</sc-if>
|
||||||
|
<sc-if value="{{ light.isCeiling }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<svg viewBox="0 0 48 48" width="34" height="34" aria-hidden="true">
|
||||||
|
<path d="M24 4v7" stroke="currentColor" stroke-width="3" stroke-linecap="round"></path>
|
||||||
|
<path d="M9 30 L24 11 L39 30 Z" fill="none" stroke="currentColor" stroke-width="3" stroke-linejoin="round"></path>
|
||||||
|
<path d="M16 37h16" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity=".55"></path>
|
||||||
|
<path d="M20 43h8" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity=".3"></path>
|
||||||
|
</svg>
|
||||||
|
</sc-if>
|
||||||
|
</div>
|
||||||
|
<div style="flex:1;min-width:0;font-size:19px;font-weight:900;color:oklch(20% 0.03 300);">{{ light.name }}</div>
|
||||||
|
<button onClick="{{ toggleLight }}" data-id="{{ light.id }}" style="{{ light.switchStyle }}">
|
||||||
|
<span style="{{ light.knobStyle }}"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top:16px;">
|
||||||
|
<div style="display:flex;gap:6px;">
|
||||||
|
<sc-for list="{{ light.levels }}" as="lvl" hint-placeholder-count="5">
|
||||||
|
<button onClick="{{ setLevel }}" data-id="{{ lvl.lightId }}" data-level="{{ lvl.value }}" style="{{ lvl.style }}"></button>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<sc-if value="{{ light.hasColors }}" hint-placeholder-val="{{ true }}">
|
||||||
|
<div style="margin-top:14px;">
|
||||||
|
<div style="display:flex;flex-wrap:wrap;gap:10px;">
|
||||||
|
<sc-for list="{{ light.swatches }}" as="sw" hint-placeholder-count="5">
|
||||||
|
<button onClick="{{ setColor }}" data-id="{{ sw.lightId }}" data-color="{{ sw.value }}" title="{{ sw.name }}" style="{{ sw.style }}"></button>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
</div>
|
||||||
|
</sc-for>
|
||||||
|
|
||||||
|
<div style="background:oklch(96% 0.012 300 / .55);backdrop-filter:blur(6px);border-radius:22px;padding:18px 20px 20px;box-shadow:0 8px 24px oklch(15% 0.05 300 / .35);">
|
||||||
|
<div style="display:flex;align-items:center;gap:14px;">
|
||||||
|
<div style="width:56px;height:56px;border-radius:16px;flex:none;display:flex;align-items:center;justify-content:center;background:oklch(88% 0.03 300);color:oklch(35% 0.05 300);">
|
||||||
|
<svg viewBox="0 0 48 48" width="34" height="34" aria-hidden="true">
|
||||||
|
<rect x="7" y="7" width="34" height="34" rx="4" fill="none" stroke="currentColor" stroke-width="3"></rect>
|
||||||
|
<path d="M7 16h34M7 24h34M7 32h34" stroke="currentColor" stroke-width="2.5"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div style="flex:1;min-width:0;">
|
||||||
|
<div style="font-size:19px;font-weight:900;color:oklch(20% 0.03 300);">Rollo</div>
|
||||||
|
<div style="font-size:13px;font-weight:700;color:oklch(45% 0.03 300 / .8);">{{ shutterLabel }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top:16px;display:flex;gap:18px;align-items:stretch;">
|
||||||
|
<div style="width:104px;flex:none;border-radius:12px;overflow:hidden;position:relative;background:linear-gradient(180deg, oklch(82% 0.08 220), oklch(70% 0.09 200));border:3px solid oklch(35% 0.04 300);">
|
||||||
|
<div style="position:absolute;left:0;right:0;top:0;{{ shutterFillStyle }}"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="flex:1;min-width:0;display:flex;flex-direction:column;gap:10px;">
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;">
|
||||||
|
<sc-for list="{{ shutterPresets }}" as="p" hint-placeholder-count="4">
|
||||||
|
<button onClick="{{ setShutter }}" data-value="{{ p.value }}" style="{{ p.style }}">{{ p.name }}</button>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px;">
|
||||||
|
<button onClick="{{ shutterUp }}" style="{{ upStyle }}">▲</button>
|
||||||
|
<button onClick="{{ shutterStop }}" style="{{ stopStyle }}">■</button>
|
||||||
|
<button onClick="{{ shutterDown }}" style="{{ downStyle }}">▼</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</x-dc>
|
||||||
|
<script type="text/x-dc" data-dc-script data-props="{"$preview": {"width": 1280, "height": 900}}">
|
||||||
|
const SWATCHES = [
|
||||||
|
{ name: "Warmweiß", value: "oklch(90% 0.06 85)" },
|
||||||
|
{ name: "Sonnengelb", value: "oklch(85% 0.16 95)" },
|
||||||
|
{ name: "Korallenrot", value: "oklch(65% 0.20 25)" },
|
||||||
|
{ name: "Delfinblau", value: "oklch(70% 0.15 235)" },
|
||||||
|
{ name: "Riffgrün", value: "oklch(75% 0.16 155)" },
|
||||||
|
{ name: "Quallenlila", value: "oklch(65% 0.18 310)" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const SCENES = [
|
||||||
|
{ id: "lesen", name: "Lesen", emoji: "📖", lights: [{ on: true, level: 4, color: "oklch(90% 0.06 85)" }, { on: true, level: 2, color: "oklch(90% 0.06 85)" }, { on: false, level: 2, color: "oklch(70% 0.15 235)" }], ceiling: { on: true, level: 3 }, shutter: 0 },
|
||||||
|
{ id: "spielen", name: "Spielen", emoji: "🐬", lights: [{ on: true, level: 5, color: "oklch(75% 0.16 155)" }, { on: true, level: 5, color: "oklch(70% 0.15 235)" }, { on: true, level: 4, color: "oklch(65% 0.18 310)" }], ceiling: { on: true, level: 5 }, shutter: 0 },
|
||||||
|
{ id: "party", name: "Disco", emoji: "🪩", lights: [{ on: true, level: 5, color: "oklch(65% 0.18 310)" }, { on: true, level: 5, color: "oklch(65% 0.20 25)" }, { on: true, level: 5, color: "oklch(70% 0.15 235)" }], ceiling: { on: false, level: 2 }, shutter: 85 },
|
||||||
|
{ id: "nacht", name: "Gute Nacht", emoji: "🌙", lights: [{ on: true, level: 1, color: "oklch(70% 0.15 235)" }, { on: false, level: 1, color: "oklch(70% 0.15 235)" }, { on: true, level: 1, color: "oklch(65% 0.18 310)" }], ceiling: { on: false, level: 1 }, shutter: 100 },
|
||||||
|
{ id: "aufwachen", name: "Aufwachen", emoji: "🌞", lights: [{ on: true, level: 3, color: "oklch(85% 0.16 95)" }, { on: true, level: 3, color: "oklch(85% 0.16 95)" }, { on: false, level: 2, color: "oklch(85% 0.16 95)" }], ceiling: { on: true, level: 2 }, shutter: 0 },
|
||||||
|
];
|
||||||
|
|
||||||
|
class Component extends DCLogic {
|
||||||
|
state = {
|
||||||
|
lights: [
|
||||||
|
{ id: "beyond1", kind: "beyond", name: "Hue Beyond links", on: true, level: 4, color: "oklch(70% 0.15 235)" },
|
||||||
|
{ id: "beyond2", kind: "beyond", name: "Hue Beyond rechts", on: false, level: 3, color: "oklch(75% 0.16 155)" },
|
||||||
|
{ id: "strip", kind: "strip", name: "Lichtband Bett", on: true, level: 3, color: "oklch(65% 0.18 310)" },
|
||||||
|
{ id: "ceiling", kind: "ceiling", name: "Deckenlampe", on: true, level: 5, color: null },
|
||||||
|
],
|
||||||
|
shutter: 0,
|
||||||
|
moving: null,
|
||||||
|
scene: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
if (this._mover) clearInterval(this._mover);
|
||||||
|
}
|
||||||
|
|
||||||
|
patch(id, changes) {
|
||||||
|
this.setState((s) => ({
|
||||||
|
scene: null,
|
||||||
|
lights: s.lights.map((l) => (l.id === id ? Object.assign({}, l, changes) : l)),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
move(dir) {
|
||||||
|
if (this._mover) clearInterval(this._mover);
|
||||||
|
this.setState({ moving: dir, scene: null });
|
||||||
|
this._mover = setInterval(() => {
|
||||||
|
this.setState((s) => {
|
||||||
|
const next = s.shutter + dir * 4;
|
||||||
|
if (next <= 0) { clearInterval(this._mover); this._mover = null; return { shutter: 0, moving: null }; }
|
||||||
|
if (next >= 100) { clearInterval(this._mover); this._mover = null; return { shutter: 100, moving: null }; }
|
||||||
|
return { shutter: next };
|
||||||
|
});
|
||||||
|
}, 90);
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
if (this._mover) clearInterval(this._mover);
|
||||||
|
this._mover = null;
|
||||||
|
this.setState({ moving: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
glow(color, level) {
|
||||||
|
return `0 0 0 4px ${color.replace(")", " / .28)")}, 0 10px 26px ${color.replace(")", ` / ${0.18 + level * 0.08})`)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderVals() {
|
||||||
|
const shutter = Math.round(this.state.shutter);
|
||||||
|
|
||||||
|
const lights = this.state.lights.map((l) => {
|
||||||
|
const tint = l.color || "oklch(92% 0.05 90)";
|
||||||
|
const active = l.on;
|
||||||
|
return {
|
||||||
|
id: l.id,
|
||||||
|
name: l.name,
|
||||||
|
isBeyond: l.kind === "beyond",
|
||||||
|
isStrip: l.kind === "strip",
|
||||||
|
isCeiling: l.kind === "ceiling",
|
||||||
|
hasColors: l.kind !== "ceiling",
|
||||||
|
cardStyle: `background:oklch(96% 0.012 300 / .55);backdrop-filter:blur(6px);border-radius:22px;padding:18px 20px 20px;box-shadow:0 8px 24px oklch(15% 0.05 300 / .35)${active ? `, 0 0 0 3px ${tint.replace(")", " / .55)")}` : ""};`,
|
||||||
|
iconWrapStyle: `width:56px;height:56px;border-radius:16px;flex:none;display:flex;align-items:center;justify-content:center;transition:background .15s ease;background:${active ? tint : "oklch(88% 0.02 300)"};color:${active ? "oklch(28% 0.05 300)" : "oklch(55% 0.02 300)"};${active ? `box-shadow:0 0 22px ${tint.replace(")", " / .7)")};` : ""}`,
|
||||||
|
switchStyle: `width:74px;height:42px;flex:none;border:none;cursor:pointer;border-radius:999px;padding:4px;display:flex;justify-content:${active ? "flex-end" : "flex-start"};background:${active ? "oklch(62% 0.15 302)" : "oklch(84% 0.01 300)"};transition:background .15s ease;`,
|
||||||
|
knobStyle: "width:34px;height:34px;border-radius:999px;background:#fff;box-shadow:0 2px 6px oklch(20% 0.03 300 / .4);display:block;",
|
||||||
|
levels: [1, 2, 3, 4, 5].map((v) => ({
|
||||||
|
lightId: l.id,
|
||||||
|
value: v,
|
||||||
|
style: `flex:1;height:46px;min-width:0;border:none;cursor:pointer;border-radius:12px;background:${active && l.level >= v ? tint : "oklch(88% 0.02 300)"};opacity:${active ? 1 : 0.55};`,
|
||||||
|
})),
|
||||||
|
swatches: SWATCHES.map((sw) => ({
|
||||||
|
lightId: l.id,
|
||||||
|
value: sw.value,
|
||||||
|
name: sw.name,
|
||||||
|
style: `width:46px;height:46px;border-radius:999px;cursor:pointer;background:${sw.value};border:${l.color === sw.value ? "4px solid oklch(28% 0.05 300)" : "4px solid oklch(96% 0.012 300 / .8)"};box-shadow:0 3px 8px oklch(20% 0.03 300 / .35);`,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const presetBtn = (active) =>
|
||||||
|
`border:none;cursor:pointer;font-family:inherit;font-size:14px;font-weight:800;padding:0 10px;height:48px;border-radius:14px;background:${active ? "oklch(62% 0.15 302)" : "oklch(89% 0.02 300)"};color:${active ? "#fff" : "oklch(28% 0.04 300)"};`;
|
||||||
|
|
||||||
|
const label =
|
||||||
|
shutter >= 99 ? "Ganz zu" :
|
||||||
|
shutter >= 80 ? "Fast zu" :
|
||||||
|
shutter >= 40 ? "Halb zu" :
|
||||||
|
shutter <= 1 ? "Offen" : `${shutter}% zu`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
lights,
|
||||||
|
toggleLight: (e) => {
|
||||||
|
const id = e.currentTarget.dataset.id;
|
||||||
|
const l = this.state.lights.find((x) => x.id === id);
|
||||||
|
this.patch(id, { on: !l.on });
|
||||||
|
},
|
||||||
|
setLevel: (e) => {
|
||||||
|
const d = e.currentTarget.dataset;
|
||||||
|
this.patch(d.id, { level: Number(d.level), on: true });
|
||||||
|
},
|
||||||
|
setColor: (e) => {
|
||||||
|
const d = e.currentTarget.dataset;
|
||||||
|
this.patch(d.id, { color: d.color, on: true });
|
||||||
|
},
|
||||||
|
shutterLabel: this.state.moving ? (this.state.moving > 0 ? "Fährt runter …" : "Fährt hoch …") : label,
|
||||||
|
shutterFillStyle: `height:${shutter}%;transition:height .2s linear;background:repeating-linear-gradient(180deg, oklch(70% 0.03 300) 0px, oklch(70% 0.03 300) 9px, oklch(58% 0.03 300) 9px, oklch(58% 0.03 300) 12px);box-shadow:0 4px 10px oklch(20% 0.03 300 / .4);`,
|
||||||
|
shutterPresets: [
|
||||||
|
{ name: "Offen", value: 0, style: presetBtn(shutter <= 1) },
|
||||||
|
{ name: "Halb zu", value: 50, style: presetBtn(shutter > 1 && shutter < 80) },
|
||||||
|
{ name: "Fast zu", value: 85, style: presetBtn(shutter >= 80 && shutter < 99) },
|
||||||
|
{ name: "Ganz zu", value: 100, style: presetBtn(shutter >= 99) },
|
||||||
|
],
|
||||||
|
setShutter: (e) => {
|
||||||
|
this.stop();
|
||||||
|
this.setState({ shutter: Number(e.currentTarget.dataset.value), scene: null });
|
||||||
|
},
|
||||||
|
shutterUp: () => this.move(-1),
|
||||||
|
shutterDown: () => this.move(1),
|
||||||
|
shutterStop: () => this.stop(),
|
||||||
|
upStyle: `border:none;cursor:pointer;font-family:inherit;font-size:20px;font-weight:900;height:52px;border-radius:14px;background:${this.state.moving === -1 ? "oklch(62% 0.15 302)" : "oklch(89% 0.02 300)"};color:${this.state.moving === -1 ? "#fff" : "oklch(28% 0.04 300)"};`,
|
||||||
|
downStyle: `border:none;cursor:pointer;font-family:inherit;font-size:20px;font-weight:900;height:52px;border-radius:14px;background:${this.state.moving === 1 ? "oklch(62% 0.15 302)" : "oklch(89% 0.02 300)"};color:${this.state.moving === 1 ? "#fff" : "oklch(28% 0.04 300)"};`,
|
||||||
|
stopStyle: "border:none;cursor:pointer;font-family:inherit;font-size:18px;font-weight:900;height:52px;border-radius:14px;background:oklch(65% 0.18 25);color:#fff;",
|
||||||
|
scenes: SCENES.map((sc) => ({
|
||||||
|
id: sc.id,
|
||||||
|
name: sc.name,
|
||||||
|
emoji: sc.emoji,
|
||||||
|
style: `border:none;cursor:pointer;font-family:inherit;font-size:15px;font-weight:800;padding:12px 20px;border-radius:999px;display:flex;align-items:center;gap:10px;min-height:52px;background:${
|
||||||
|
this.state.scene === sc.id ? "oklch(97% 0.01 302)" : "oklch(97% 0.01 302 / .18)"
|
||||||
|
};color:${this.state.scene === sc.id ? "oklch(26% 0.05 300)" : "oklch(97% 0.01 302)"};box-shadow:${
|
||||||
|
this.state.scene === sc.id ? "0 6px 18px oklch(15% 0.05 300 / .4)" : "none"
|
||||||
|
};`,
|
||||||
|
})),
|
||||||
|
activateScene: (e) => {
|
||||||
|
const sc = SCENES.find((x) => x.id === e.currentTarget.dataset.id);
|
||||||
|
if (!sc) return;
|
||||||
|
this.stop();
|
||||||
|
this.setState((s) => ({
|
||||||
|
scene: sc.id,
|
||||||
|
shutter: sc.shutter,
|
||||||
|
lights: s.lights.map((l, i) =>
|
||||||
|
l.kind === "ceiling"
|
||||||
|
? Object.assign({}, l, sc.ceiling)
|
||||||
|
: Object.assign({}, l, sc.lights[i] || {})
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
BIN
claude-design/dolphin-mascot.png
Normal file
BIN
claude-design/dolphin-mascot.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 697 KiB |
BIN
claude-design/dolphin-remote.png
Normal file
BIN
claude-design/dolphin-remote.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 788 KiB |
1911
claude-design/support.js
Normal file
1911
claude-design/support.js
Normal file
File diff suppressed because it is too large
Load Diff
435
claude-design/three-d-stage.js
Normal file
435
claude-design/three-d-stage.js
Normal file
@@ -0,0 +1,435 @@
|
|||||||
|
// @ds-adherence-ignore -- omelette starter scaffold (raw elements/hex/px by design)
|
||||||
|
// Copied omelette starter. Re-running copy_starter_component with this kind overwrites this file with the latest version (page content is unaffected).
|
||||||
|
/* BEGIN USAGE */
|
||||||
|
/**
|
||||||
|
* <three-d-stage> — 3D object viewer + exporter shell (three.js).
|
||||||
|
*
|
||||||
|
* The stage owns the whole scene: WebGL renderer, neutral studio lighting
|
||||||
|
* with a soft ground shadow, orbit controls (drag to orbit, wheel to zoom,
|
||||||
|
* right-drag to pan), a camera auto-framed to the object's bounds, resize
|
||||||
|
* handling, and a download toolbar that exports the current object as
|
||||||
|
* OBJ + MTL or GLB (binary glTF). FBX cannot be exported in the browser;
|
||||||
|
* GLB is the interchange format every modern 3D tool imports.
|
||||||
|
*
|
||||||
|
* three.js loads through the page's import map. Include this EXACT pinned
|
||||||
|
* map in <head>, before any module runs — versions and integrity hashes
|
||||||
|
* stay together (same map the "3D object" skill mandates):
|
||||||
|
*
|
||||||
|
* <script type="importmap">
|
||||||
|
* {
|
||||||
|
* "imports": {
|
||||||
|
* "three": "https://unpkg.com/three@0.184.0/build/three.module.js",
|
||||||
|
* "three/addons/controls/OrbitControls.js": "https://unpkg.com/three@0.184.0/examples/jsm/controls/OrbitControls.js",
|
||||||
|
* "three/addons/exporters/OBJExporter.js": "https://unpkg.com/three@0.184.0/examples/jsm/exporters/OBJExporter.js",
|
||||||
|
* "three/addons/exporters/GLTFExporter.js": "https://unpkg.com/three@0.184.0/examples/jsm/exporters/GLTFExporter.js"
|
||||||
|
* },
|
||||||
|
* "integrity": {
|
||||||
|
* "https://unpkg.com/three@0.184.0/build/three.module.js": "sha384-8FCZ1eVO6it4+pbec2aDtnTrwjWXZLJRC+MAGCIPDgsYnUrl/E0A2YlF8ioMKI/J",
|
||||||
|
* "https://unpkg.com/three@0.184.0/build/three.core.js": "sha384-dw2ooPewaEIrAgl6oFDBmmBWCE9oW9LxRGcfwZ0hLvEprzo202wXl7vCYHRlSnOT",
|
||||||
|
* "https://unpkg.com/three@0.184.0/examples/jsm/controls/OrbitControls.js": "sha384-4rziNxOBZKQ69i+w+f89KJ55TCYquwchVbByQwmaOeIOXdOU2PLDn3kOfXHwIJC9",
|
||||||
|
* "https://unpkg.com/three@0.184.0/examples/jsm/exporters/OBJExporter.js": "sha384-nbwtoZENJD3Vq+ACK0CuGQdPMuDWHkamC2KJD70EV5nfg6jQjfppKOea07YJN+N3",
|
||||||
|
* "https://unpkg.com/three@0.184.0/examples/jsm/exporters/GLTFExporter.js": "sha384-VofkvpG6HERhFCYbsUOHeNXBCqID2nfqkQqnVzE1jc/oPcz+qJ13ADdXH08hE+cQ"
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* </script>
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* <style>three-d-stage:not(:defined){visibility:hidden}</style>
|
||||||
|
* <three-d-stage name="rocket"></three-d-stage>
|
||||||
|
* <script src="three-d-stage.js"></script>
|
||||||
|
* <script type="module">
|
||||||
|
* const stage = document.querySelector('three-d-stage');
|
||||||
|
* const { THREE } = await stage.ready;
|
||||||
|
* const model = new THREE.Group();
|
||||||
|
* // …build the model out of named meshes with named materials —
|
||||||
|
* // the names become the o / usemtl entries in the exported OBJ…
|
||||||
|
* stage.setObject(model);
|
||||||
|
* </script>
|
||||||
|
*
|
||||||
|
* Attributes:
|
||||||
|
* name — export file basename (default "model")
|
||||||
|
* background — CSS color behind the scene (default a warm paper tone)
|
||||||
|
* autorotate — when present, a slow turntable until the user interacts
|
||||||
|
*
|
||||||
|
* Model in real-world meters, centered on the origin, y-up — exports
|
||||||
|
* inherit the scene's units and orientation. The stage fills its own box;
|
||||||
|
* size it with ordinary CSS (default 100vw/100vh page hero).
|
||||||
|
*
|
||||||
|
* Default setup: neutral studio lighting (hemisphere + key + fill), a
|
||||||
|
* soft ground shadow, and NO environment map — so high metalness has
|
||||||
|
* nothing to reflect and renders near-black. Cap metalness around
|
||||||
|
* 0.3–0.4 and carry a metal look with a brighter base color. The copied
|
||||||
|
* file is yours: adjust the lights, shadow, or background in _boot()
|
||||||
|
* when the object needs a different look.
|
||||||
|
*/
|
||||||
|
/* END USAGE */
|
||||||
|
|
||||||
|
(() => {
|
||||||
|
const stylesheet = `
|
||||||
|
:host {
|
||||||
|
position: relative;
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100vh;
|
||||||
|
background: var(--stage-bg, #f0eee6);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
canvas { display: block; outline: none; }
|
||||||
|
.toolbar {
|
||||||
|
position: absolute;
|
||||||
|
right: 16px;
|
||||||
|
bottom: 16px;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
|
}
|
||||||
|
.toolbar button {
|
||||||
|
appearance: none;
|
||||||
|
border: 1px solid rgba(20, 20, 19, 0.18);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.92);
|
||||||
|
color: #1a1915;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 9px 12px;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.toolbar button:hover { background: #fff; }
|
||||||
|
.toolbar button:active { transform: translateY(1px); }
|
||||||
|
.toolbar button[disabled] { opacity: 0.5; pointer-events: none; }
|
||||||
|
.note {
|
||||||
|
position: absolute;
|
||||||
|
left: 16px;
|
||||||
|
bottom: 16px;
|
||||||
|
max-width: 60%;
|
||||||
|
font: 400 12px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
|
color: rgba(26, 25, 21, 0.55);
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.err {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px;
|
||||||
|
font: 500 14px/1.6 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
|
color: #8a2f20;
|
||||||
|
text-align: center;
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
function download(blob, filename) {
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 4000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tell the host an export attempt settled — telemetry only. The host
|
||||||
|
* (HTMLViewer) verifies the source and re-reads these fields defensively
|
||||||
|
* before counting; nothing else crosses the frame boundary. Guarded so
|
||||||
|
* telemetry can never break the download path. */
|
||||||
|
function notifyExport(format, ok) {
|
||||||
|
try {
|
||||||
|
window.parent.postMessage(
|
||||||
|
{ type: 'omelette:notify-3d-export', format: format, ok: ok === true },
|
||||||
|
'*'
|
||||||
|
);
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ThreeDStage extends HTMLElement {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
const root = this.attachShadow({ mode: 'open' });
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.textContent = stylesheet;
|
||||||
|
root.appendChild(style);
|
||||||
|
this._err = document.createElement('div');
|
||||||
|
this._err.className = 'err';
|
||||||
|
root.appendChild(this._err);
|
||||||
|
const note = document.createElement('div');
|
||||||
|
note.className = 'note';
|
||||||
|
note.textContent = 'Drag to orbit · scroll to zoom · right-drag to pan';
|
||||||
|
root.appendChild(note);
|
||||||
|
this._toolbar = document.createElement('div');
|
||||||
|
this._toolbar.className = 'toolbar';
|
||||||
|
this._objBtn = document.createElement('button');
|
||||||
|
this._objBtn.type = 'button';
|
||||||
|
this._objBtn.textContent = 'Download OBJ + MTL';
|
||||||
|
this._objBtn.addEventListener('click', () => this._runExport('obj'));
|
||||||
|
this._glbBtn = document.createElement('button');
|
||||||
|
this._glbBtn.type = 'button';
|
||||||
|
this._glbBtn.textContent = 'Download GLB';
|
||||||
|
this._glbBtn.addEventListener('click', () => this._runExport('glb'));
|
||||||
|
this._toolbar.appendChild(this._objBtn);
|
||||||
|
this._toolbar.appendChild(this._glbBtn);
|
||||||
|
root.appendChild(this._toolbar);
|
||||||
|
this._setButtonsEnabled(false);
|
||||||
|
/** Resolves with { THREE } once the scene is live — build the model
|
||||||
|
* in `await stage.ready` so nothing races the library load. */
|
||||||
|
this.ready = new Promise((resolve, reject) => {
|
||||||
|
this._readyResolve = resolve;
|
||||||
|
this._readyReject = reject;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
connectedCallback() {
|
||||||
|
if (this._booted) {
|
||||||
|
// Re-attached after a removal — resume what disconnected stopped.
|
||||||
|
if (this._renderer) {
|
||||||
|
this._renderer.setAnimationLoop(this._loop);
|
||||||
|
this._ro && this._ro.observe(this);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._booted = true;
|
||||||
|
this._boot().catch((err) => {
|
||||||
|
this._err.style.display = 'flex';
|
||||||
|
this._err.textContent =
|
||||||
|
'three.js failed to load.\n' +
|
||||||
|
'Check that the pinned <script type="importmap"> from the usage ' +
|
||||||
|
'notes is in <head> before any module script.\n\n' +
|
||||||
|
String(err && err.message ? err.message : err);
|
||||||
|
this._readyReject(err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async _boot() {
|
||||||
|
const bg = this.getAttribute('background');
|
||||||
|
if (bg) this.style.setProperty('--stage-bg', bg);
|
||||||
|
const [THREE, controlsMod] = await Promise.all([
|
||||||
|
import('three'),
|
||||||
|
import('three/addons/controls/OrbitControls.js'),
|
||||||
|
]);
|
||||||
|
this._THREE = THREE;
|
||||||
|
// preserveDrawingBuffer keeps the last frame readable after
|
||||||
|
// compositing (toDataURL / drawImage) — it's what lets the
|
||||||
|
// screenshot tools capture the scene instead of a blank canvas.
|
||||||
|
const renderer = new THREE.WebGLRenderer({
|
||||||
|
antialias: true,
|
||||||
|
alpha: true,
|
||||||
|
preserveDrawingBuffer: true,
|
||||||
|
});
|
||||||
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
|
||||||
|
renderer.shadowMap.enabled = true;
|
||||||
|
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
||||||
|
this._renderer = renderer;
|
||||||
|
this.shadowRoot.insertBefore(renderer.domElement, this._err);
|
||||||
|
|
||||||
|
const scene = new THREE.Scene();
|
||||||
|
this._scene = scene;
|
||||||
|
|
||||||
|
const camera = new THREE.PerspectiveCamera(45, 1, 0.01, 500);
|
||||||
|
camera.position.set(3, 2.2, 4);
|
||||||
|
this._camera = camera;
|
||||||
|
|
||||||
|
const controls = new controlsMod.OrbitControls(camera, renderer.domElement);
|
||||||
|
controls.enableDamping = true;
|
||||||
|
controls.dampingFactor = 0.08;
|
||||||
|
this._controls = controls;
|
||||||
|
|
||||||
|
// Neutral studio: soft sky/ground wash, a shadow-casting key light,
|
||||||
|
// and a dim fill from behind so silhouettes never go black.
|
||||||
|
scene.add(new THREE.HemisphereLight(0xffffff, 0xd8d2c4, 1.0));
|
||||||
|
const key = new THREE.DirectionalLight(0xffffff, 2.2);
|
||||||
|
key.position.set(4, 7, 5);
|
||||||
|
key.castShadow = true;
|
||||||
|
key.shadow.mapSize.set(2048, 2048);
|
||||||
|
key.shadow.bias = -0.0002;
|
||||||
|
this._key = key;
|
||||||
|
scene.add(key);
|
||||||
|
const fill = new THREE.DirectionalLight(0xfff4e6, 0.5);
|
||||||
|
fill.position.set(-5, 3, -4);
|
||||||
|
scene.add(fill);
|
||||||
|
|
||||||
|
const ground = new THREE.Mesh(
|
||||||
|
new THREE.PlaneGeometry(200, 200),
|
||||||
|
new THREE.ShadowMaterial({ opacity: 0.18 })
|
||||||
|
);
|
||||||
|
ground.rotation.x = -Math.PI / 2;
|
||||||
|
ground.receiveShadow = true;
|
||||||
|
this._ground = ground;
|
||||||
|
scene.add(ground);
|
||||||
|
|
||||||
|
this._autorotate = this.hasAttribute('autorotate');
|
||||||
|
controls.autoRotate = this._autorotate;
|
||||||
|
controls.autoRotateSpeed = 1.2;
|
||||||
|
controls.addEventListener('start', () => {
|
||||||
|
controls.autoRotate = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
const fit = () => {
|
||||||
|
const w = this.clientWidth || 1;
|
||||||
|
const h = this.clientHeight || 1;
|
||||||
|
renderer.setSize(w, h);
|
||||||
|
camera.aspect = w / h;
|
||||||
|
camera.updateProjectionMatrix();
|
||||||
|
};
|
||||||
|
fit();
|
||||||
|
this._ro = new ResizeObserver(fit);
|
||||||
|
this._loop = () => {
|
||||||
|
controls.update();
|
||||||
|
renderer.render(scene, camera);
|
||||||
|
};
|
||||||
|
// Detached while three.js was fetching? Stay idle — the
|
||||||
|
// connectedCallback resume starts the loop and observer on
|
||||||
|
// reattach.
|
||||||
|
if (this.isConnected) {
|
||||||
|
this._ro.observe(this);
|
||||||
|
renderer.setAnimationLoop(this._loop);
|
||||||
|
}
|
||||||
|
|
||||||
|
this._readyResolve({ THREE });
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnectedCallback() {
|
||||||
|
// Stop rendering and observing while detached; connectedCallback
|
||||||
|
// resumes both. (The renderer itself is kept — a move within the
|
||||||
|
// document must not rebuild the scene.)
|
||||||
|
if (this._renderer) this._renderer.setAnimationLoop(null);
|
||||||
|
if (this._ro) this._ro.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Show (and own) the object. Replaces any previous object, enables
|
||||||
|
* shadows on every mesh, rests it on the ground plane, and frames
|
||||||
|
* the camera to its bounds. */
|
||||||
|
setObject(object) {
|
||||||
|
const THREE = this._THREE;
|
||||||
|
if (!THREE) throw new Error('three-d-stage: not ready — await stage.ready first');
|
||||||
|
if (this._object) this._scene.remove(this._object);
|
||||||
|
this._object = object;
|
||||||
|
object.traverse((o) => {
|
||||||
|
if (o.isMesh) {
|
||||||
|
o.castShadow = true;
|
||||||
|
o.receiveShadow = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const box = new THREE.Box3().setFromObject(object);
|
||||||
|
if (!box.isEmpty()) {
|
||||||
|
// Rest the object on the ground without moving its origin.
|
||||||
|
this._ground.position.y = box.min.y;
|
||||||
|
const sphere = box.getBoundingSphere(new THREE.Sphere());
|
||||||
|
const dist =
|
||||||
|
(sphere.radius / Math.tan((this._camera.fov * Math.PI) / 360)) * 1.35;
|
||||||
|
const dir = new THREE.Vector3(1, 0.55, 1.25).normalize();
|
||||||
|
this._camera.position
|
||||||
|
.copy(sphere.center)
|
||||||
|
.add(dir.multiplyScalar(dist));
|
||||||
|
this._camera.near = Math.max(dist / 100, 0.01);
|
||||||
|
this._camera.far = dist * 100;
|
||||||
|
this._camera.updateProjectionMatrix();
|
||||||
|
this._controls.target.copy(sphere.center);
|
||||||
|
this._controls.update();
|
||||||
|
const span = sphere.radius * 3;
|
||||||
|
this._key.shadow.camera.left = -span;
|
||||||
|
this._key.shadow.camera.right = span;
|
||||||
|
this._key.shadow.camera.top = span;
|
||||||
|
this._key.shadow.camera.bottom = -span;
|
||||||
|
this._key.shadow.camera.updateProjectionMatrix();
|
||||||
|
}
|
||||||
|
this._scene.add(object);
|
||||||
|
this._setButtonsEnabled(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
get _basename() {
|
||||||
|
return (this.getAttribute('name') || 'model').replace(/[^\w.-]+/g, '_');
|
||||||
|
}
|
||||||
|
|
||||||
|
_setButtonsEnabled(on) {
|
||||||
|
this._objBtn.disabled = !on;
|
||||||
|
this._glbBtn.disabled = !on;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every mesh and material needs a unique name for o/usemtl lines —
|
||||||
|
* fill in stable fallbacks, and return the unique material list. */
|
||||||
|
_nameParts() {
|
||||||
|
const mats = [];
|
||||||
|
const seen = new Set();
|
||||||
|
let meshI = 0;
|
||||||
|
let matI = 0;
|
||||||
|
this._object.traverse((o) => {
|
||||||
|
if (!o.isMesh) return;
|
||||||
|
if (!o.name) o.name = 'part_' + meshI;
|
||||||
|
meshI += 1;
|
||||||
|
const list = Array.isArray(o.material) ? o.material : [o.material];
|
||||||
|
for (const m of list) {
|
||||||
|
if (!m || mats.includes(m)) continue;
|
||||||
|
if (!m.name) {
|
||||||
|
m.name = 'mat_' + matI;
|
||||||
|
matI += 1;
|
||||||
|
}
|
||||||
|
while (seen.has(m.name)) {
|
||||||
|
m.name = m.name + '_' + matI;
|
||||||
|
matI += 1;
|
||||||
|
}
|
||||||
|
seen.add(m.name);
|
||||||
|
mats.push(m);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return mats;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One export attempt, reported to the host however it settles.
|
||||||
|
* Rethrows so a failure stays visible on the guest console exactly as
|
||||||
|
* before. The no-object early return is not an attempt (the toolbar is
|
||||||
|
* disabled until the model loads) and reports nothing. */
|
||||||
|
async _runExport(format) {
|
||||||
|
if (!this._object) return;
|
||||||
|
try {
|
||||||
|
await (format === 'obj' ? this._exportObj() : this._exportGlb());
|
||||||
|
notifyExport(format, true);
|
||||||
|
} catch (err) {
|
||||||
|
notifyExport(format, false);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async _exportObj() {
|
||||||
|
if (!this._object) return;
|
||||||
|
const mod = await import('three/addons/exporters/OBJExporter.js');
|
||||||
|
const mats = this._nameParts();
|
||||||
|
const base = this._basename;
|
||||||
|
const obj =
|
||||||
|
'mtllib ' + base + '.mtl\n' + new mod.OBJExporter().parse(this._object);
|
||||||
|
let mtl = '# Exported by three-d-stage\n';
|
||||||
|
for (const m of mats) {
|
||||||
|
const c = m.color || { r: 0.8, g: 0.8, b: 0.8 };
|
||||||
|
const rough = typeof m.roughness === 'number' ? m.roughness : 0.5;
|
||||||
|
const opacity = typeof m.opacity === 'number' ? m.opacity : 1;
|
||||||
|
mtl += 'newmtl ' + m.name + '\n';
|
||||||
|
mtl +=
|
||||||
|
'Kd ' + c.r.toFixed(4) + ' ' + c.g.toFixed(4) + ' ' + c.b.toFixed(4) + '\n';
|
||||||
|
mtl += 'Ks 0.2000 0.2000 0.2000\n';
|
||||||
|
mtl += 'Ns ' + Math.round((1 - rough) * 200) + '\n';
|
||||||
|
mtl += 'd ' + opacity.toFixed(4) + '\n\n';
|
||||||
|
}
|
||||||
|
download(new Blob([obj], { type: 'text/plain' }), base + '.obj');
|
||||||
|
download(new Blob([mtl], { type: 'text/plain' }), base + '.mtl');
|
||||||
|
}
|
||||||
|
|
||||||
|
async _exportGlb() {
|
||||||
|
if (!this._object) return;
|
||||||
|
const mod = await import('three/addons/exporters/GLTFExporter.js');
|
||||||
|
this._nameParts();
|
||||||
|
const base = this._basename;
|
||||||
|
const buf = await new mod.GLTFExporter().parseAsync(this._object, {
|
||||||
|
binary: true,
|
||||||
|
});
|
||||||
|
download(
|
||||||
|
new Blob([buf], { type: 'model/gltf-binary' }),
|
||||||
|
base + '.glb'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define('three-d-stage', ThreeDStage);
|
||||||
|
})();
|
||||||
141
docs/REPO_OVERVIEW.md
Normal file
141
docs/REPO_OVERVIEW.md
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
# MusicMouse — repo overview
|
||||||
|
|
||||||
|
Orientation doc for AI agents (or humans) working on this repo for the first time.
|
||||||
|
|
||||||
|
## What this is
|
||||||
|
|
||||||
|
MusicMouse is a DIY, Toniebox-style physical music player for kids, shaped like a mouse
|
||||||
|
and living on a shelf. Small 3D-printed animal figurines (fox, owl, dog, elephant,
|
||||||
|
squirrel, crocodile, rabbit, snowman, puppy — see `hardware/3dprints/figures/`) each
|
||||||
|
carry an RFID tag. Placing a figurine on the mouse starts that figure's playlist. The
|
||||||
|
mouse also has a rotary encoder and capacitive touch areas (ears/feet) for
|
||||||
|
volume/skip control, addressable RGBW LED rings with animated effects, and MQTT/Home
|
||||||
|
Assistant integration.
|
||||||
|
|
||||||
|
## Repo layout
|
||||||
|
|
||||||
|
| Path | What it is |
|
||||||
|
|---|---|
|
||||||
|
| `python-backend/` | Python host application — the main runtime. Has its own `README.md` with the architecture; start there for backend work. |
|
||||||
|
| `web/` | React + TypeScript front-end (Vite). Browse the whole library and play any of it from a browser. Built output is served by `python-backend` itself. |
|
||||||
|
| `esp-firmware/` | ESP32 firmware (C++, Arduino framework via PlatformIO). Reads the RFID reader and buttons, drives the LED strips, talks to `python-backend` over serial. |
|
||||||
|
| `hardware/` | 3D-print models for the figurines and enclosure (FreeCAD/Blender/OBJ/STL), a Fritzing electronics sketch, datasheets, and `pinout.md` (RFID reader + button-board wiring). |
|
||||||
|
| `claude-design/` | The interaction and visual specs the web front-end was built from, as standalone HTML mockups with hardcoded data: "Dolphin Beats" (the player), "Mein Zimmer" (the room page) and "Delphin Uhr" (the clock overlay). `web/` is the real implementation of all three; the mockups are kept as the reference for the keyboard model and the styling. |
|
||||||
|
| `scripts/` | `check.sh` (every check, see below) and `sync-library.sh` (seed a device's library and analysis cache). |
|
||||||
|
| `docs/` | This file. |
|
||||||
|
|
||||||
|
## How the pieces talk to each other
|
||||||
|
|
||||||
|
- **ESP32 firmware ↔ `python-backend`**: a length-prefixed binary protocol over serial.
|
||||||
|
Frames are `uint32 magic | uint8 type | uint16 size | payload`, little-endian, with
|
||||||
|
firmware log text interleaved on the same link. The Python side lives in
|
||||||
|
`python-backend/musicmouse/devices/wire.py`, the firmware side in
|
||||||
|
`esp-firmware/src/Messages.h`.
|
||||||
|
|
||||||
|
The contract is hand-duplicated in two languages. `tests/test_wire.py` parses
|
||||||
|
`Messages.h` and fails if the message ids drift, and `tests/test_effects.py` pins the
|
||||||
|
exact bytes of every effect payload — so a firmware change that breaks the host now
|
||||||
|
breaks a test instead of just the LEDs.
|
||||||
|
|
||||||
|
- **`python-backend` ↔ `web/`**: JSON over HTTP for the library and for commands, plus a
|
||||||
|
push-only websocket at `/api/ws` for state. Commands emit exactly the same *intents*
|
||||||
|
the physical buttons emit, so the web UI has no privileged path — and no way to get
|
||||||
|
out of step with a figure someone puts on the reader. See
|
||||||
|
`python-backend/README.md` for the endpoint list.
|
||||||
|
|
||||||
|
- **`python-backend` ↔ Home Assistant**: two separate paths.
|
||||||
|
- MQTT, for the mouse itself. The backend publishes three discoverable lights, a
|
||||||
|
player sensor, a volume number, transport buttons, device triggers for every button
|
||||||
|
and touch area, and a tag scanner for the RFID reader. Behaviour like "the left ear
|
||||||
|
turns the room light pink" is an HA automation, not backend code. See
|
||||||
|
`python-backend/README.md` for the trigger topics and the old colour mapping.
|
||||||
|
- HTTP, for the web front-end's room page only: `/api/ha/...` proxies state reads and
|
||||||
|
service calls to Home Assistant's REST API with the token from `general.ha`, so the
|
||||||
|
token never reaches the browser.
|
||||||
|
|
||||||
|
- **`python-backend` ↔ IR remote**: a TCP client for lircd (`general.lirc`), turned into
|
||||||
|
the same intents as every other front-end.
|
||||||
|
|
||||||
|
## Running it
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python -m musicmouse --config /media/musicmouse/config.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
On a machine with no mouse attached — real audio and a real web UI, no serial port:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python -m musicmouse --config ./config.yml --no-hardware
|
||||||
|
```
|
||||||
|
|
||||||
|
`general.serial_port: simulate` does the same thing from the config, and
|
||||||
|
`general.alsa_device: simulate` swaps in a silent player. Both warn at startup, and
|
||||||
|
both keys are required - omitting one is an error rather than an implicit simulation.
|
||||||
|
|
||||||
|
Or with no hardware *and* no audio:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python -m musicmouse --config ./config.yml --simulate
|
||||||
|
```
|
||||||
|
|
||||||
|
The simulator runs the whole app against a fake serial link and a fake player, either
|
||||||
|
interactively or from a scenario file. `python-backend/musicmouse.service` is the
|
||||||
|
systemd unit for the device.
|
||||||
|
|
||||||
|
For front-end work, run the backend (either way above) and then:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd web && npm install && npm run dev # http://localhost:5173, /api proxied to :8080
|
||||||
|
```
|
||||||
|
|
||||||
|
`npm run build` writes `web/dist`, which `general.web.static_dir` points at in
|
||||||
|
production so one process serves both the UI and the API.
|
||||||
|
|
||||||
|
## Config
|
||||||
|
|
||||||
|
`python-backend/config.yml.example` documents the schema. In short:
|
||||||
|
|
||||||
|
- `general.{library.*, serial_port, baudrate, reconnect_interval, alsa_device,
|
||||||
|
min_volume, max_volume, initial_volume, volume_increment, button_leds_brightness,
|
||||||
|
audio_extensions, podcast_episode_limit}`, plus optional sections `mqtt`, `web`, `ha`
|
||||||
|
(room page), `lirc` (IR remote), `screen` (monitor on/off), `tippen` (typing game)
|
||||||
|
and `clock` (school bus countdown).
|
||||||
|
- `figures.<name>.{id, colors, kind}` — `id` is a 5-byte hex RFID tag, unique per
|
||||||
|
figure; `colors` is exactly four (`primary, secondary, bg, accent`), each `"#rrggbb"`
|
||||||
|
or `"wNN"`; `kind` is `music` (default) or `book`.
|
||||||
|
- `remote.<digit>` — what each IR-remote number key plays. Written by the web UI.
|
||||||
|
|
||||||
|
`general.library.root` is the one path to the music. The shelves under it are fixed
|
||||||
|
names, not settings, because each has quirks the scanner knows about:
|
||||||
|
|
||||||
|
```
|
||||||
|
<root>/Figuren/<figure name>/ one folder per figurine (or album subfolders
|
||||||
|
in it, up to six, picked on the LED ring)
|
||||||
|
<root>/Musik/<Artist> - <Album>/ albums, grouped by artist
|
||||||
|
<root>/Hörbücher/<Artist> - <Album>/ audiobooks, grouped by character
|
||||||
|
<root>/Kinderpodcasts/<Show>/ shows, newest episode first
|
||||||
|
```
|
||||||
|
|
||||||
|
Config is validated with pydantic: unknown keys are errors, and every problem is
|
||||||
|
reported at once.
|
||||||
|
|
||||||
|
## Notes for agents
|
||||||
|
|
||||||
|
- There is no CI. `scripts/check.sh` runs every check the repo has - backend
|
||||||
|
`pytest`, `ruff` and `mypy --strict`, web `vitest`, `tsc` and `eslint` - and is the
|
||||||
|
gate: run it before proposing changes. A checker that cannot run (mypy once aborted
|
||||||
|
on numpy's stubs for two weeks without anyone noticing) is a failure, not a skip.
|
||||||
|
- Prefer adding a scenario in `python-backend/scenarios/` over a hand-rolled test when
|
||||||
|
the behaviour is end-to-end — those files are executed by the test suite.
|
||||||
|
- `python-backend/notebooks/` is LED-effect scratch work, not part of the app.
|
||||||
|
- The firmware has no automated tests beyond a PlatformIO `native` env for LED effects.
|
||||||
|
- The front-end has `vitest` over the pure modules (`web/src/lib/`), `tsc` and `eslint`;
|
||||||
|
there is no component-level test harness.
|
||||||
|
- The library index is cached under `general.library.cache`. It is keyed by file mtime
|
||||||
|
and size, so **a change to how the scanner derives a title, artist or series is
|
||||||
|
invisible until the cache is invalidated** — bump `_INDEX_VERSION` in
|
||||||
|
`musicmouse/library/cache.py` when you touch that logic.
|
||||||
|
- Track analysis (librosa) runs in a pool of worker *processes* - see
|
||||||
|
`musicmouse/library/workers.py`. Anything an `Analyzer` returns therefore has to be
|
||||||
|
picklable, and an analyzer that records state in its own instance (a test double
|
||||||
|
counting calls) only behaves as written with `analysis_workers=1`.
|
||||||
121
docs/agent-tasks/01-web-authentication.md
Normal file
121
docs/agent-tasks/01-web-authentication.md
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
# Task: password-protect the backend's HTTP API
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
The backend's HTTP API has no authentication and listens on `0.0.0.0`. The worst part
|
||||||
|
is the room page's Home Assistant proxy
|
||||||
|
(`python-backend/musicmouse/services/web/api.py`, `_proxy` and
|
||||||
|
`POST /api/ha/services/{domain}/{service}`): it forwards *any* service call on *any*
|
||||||
|
entity with the stored long-lived HA token. Anything on the LAN can unlock a lock,
|
||||||
|
disarm an alarm or restart Home Assistant through it. Parent mode (`/api/settings`,
|
||||||
|
`/api/remote/mapping`, `/api/timer`) is only hidden, not protected.
|
||||||
|
|
||||||
|
## What the owner decided
|
||||||
|
|
||||||
|
A browser authenticates **once** with a password, and then stays logged in for about
|
||||||
|
**a year**. The config stores only a **hash** of the password, never the password
|
||||||
|
itself. Use this design; do not substitute another one (no entity allow-list instead,
|
||||||
|
no per-endpoint tokens, no OAuth).
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### Config
|
||||||
|
|
||||||
|
- Add `general.web.password_hash` (in `WebConfig`, `musicmouse/config.py`). It is
|
||||||
|
**required** whenever the `web` section is present. Follow the repo's existing
|
||||||
|
convention for opting out of something important (see `SIMULATE` for `serial_port`
|
||||||
|
and `alsa_device`): the literal value `"disabled"` turns authentication off, logs a
|
||||||
|
warning on **every** boot, and is the only way to run without it. A missing key is a
|
||||||
|
config error. Config errors already say which key is wrong.
|
||||||
|
- Hash format: `scrypt$<n>$<r>$<p>$<salt b64>$<hash b64>` using `hashlib.scrypt` from
|
||||||
|
the standard library. No new dependency. Choose parameters that verify in well under
|
||||||
|
a second on a Raspberry Pi 4 (for example n=2**14, r=8, p=1), and measure on the
|
||||||
|
desktop to sanity-check. Verify with `hmac.compare_digest`.
|
||||||
|
- Add a CLI subcommand to set it. For example,
|
||||||
|
`python -m musicmouse --config config.yml --set-password` prompts twice with
|
||||||
|
`getpass`, hashes, and writes the key back into `config.yml` using the existing
|
||||||
|
round-trip helpers in `musicmouse/services/web/settings.py` (`load_document`,
|
||||||
|
`atomic_write`), so comments survive. Exit afterwards and start nothing else.
|
||||||
|
- Document the key and the command in `python-backend/config.yml.example`.
|
||||||
|
|
||||||
|
### Sessions
|
||||||
|
|
||||||
|
- `POST /api/auth/login` with `{"password": "..."}`. On success it sets a cookie and
|
||||||
|
returns 204; on failure it returns 401 after a fixed delay of about 1 s (cheap brute-force
|
||||||
|
damping; no lockout state needed).
|
||||||
|
- The cookie (`mm_session`): `HttpOnly`, `SameSite=Strict`, `Path=/`,
|
||||||
|
`Max-Age` = 365 days. **Not** `Secure`: the device is served over plain HTTP on the
|
||||||
|
LAN, and a `Secure` cookie would never be sent back.
|
||||||
|
- The cookie value is a stateless signed token, `<expiry unix seconds>.<HMAC-SHA256>`.
|
||||||
|
The HMAC key is derived from a persistent random secret *and* the current
|
||||||
|
`password_hash`, so **changing the password logs every browser out** without extra
|
||||||
|
bookkeeping. Keep the secret in a file under `general.library.cache` (for example
|
||||||
|
`session-secret`, 32 random bytes, created on first start with mode 0600). Deleting
|
||||||
|
the cache logs everyone out, which is acceptable; say so in the docs.
|
||||||
|
- Optional, cheap, and nice: re-issue the cookie with a fresh year on any authenticated
|
||||||
|
request when fewer than 30 days remain, so a device in daily use never expires.
|
||||||
|
- `POST /api/auth/logout` clears the cookie. `GET /api/auth` returns 204 or 401, so the
|
||||||
|
front-end can check without side effects.
|
||||||
|
|
||||||
|
### What is protected
|
||||||
|
|
||||||
|
- **Every** route under `/api`, including the websocket `/api/ws`, except
|
||||||
|
`/api/auth/*`. Implement it once, as a FastAPI dependency on the router in
|
||||||
|
`build_router` (or a small middleware), not route by route. The websocket checks the
|
||||||
|
cookie *before* `accept()` and closes with code 4401 when it is missing or invalid.
|
||||||
|
- **Not** the static front-end files: the login screen has to load.
|
||||||
|
- **No localhost exemption.** The kiosk on the device logs in once like everyone else.
|
||||||
|
An exemption by peer address breaks silently the day a reverse proxy is put in front
|
||||||
|
of it (every request then looks local).
|
||||||
|
- MQTT, the IR remote and the serial link are not HTTP and are out of scope.
|
||||||
|
- Keep the room page's proxy as it is otherwise. Do not add an entity allow-list in
|
||||||
|
this task.
|
||||||
|
|
||||||
|
### Front-end (`web/`)
|
||||||
|
|
||||||
|
- `src/api/client.ts`: a 401 from any call raises a dedicated `Unauthorized` error.
|
||||||
|
`fetchOrNullOn404` / `fetchOrNullOnError` must not swallow a 401 as "not configured".
|
||||||
|
- A login screen: one password field, German copy ("Passwort", "Anmelden", "Falsches
|
||||||
|
Passwort"), styled like the existing sheets (`.overlay` / `.sheet`). It must work with
|
||||||
|
only a keyboard (the kiosk has no pointer). Show it when the first `/api/state` or
|
||||||
|
`/api/library` call, or the websocket, reports unauthorized. On success, reload the
|
||||||
|
library, the state and the websocket. The simplest correct way is `location.reload()`.
|
||||||
|
- `src/hooks/usePlayerState.ts`: on a 4401 close, stop the reconnect loop and show the
|
||||||
|
login screen instead of retrying every 1.5 s.
|
||||||
|
- The service worker (`public/sw.js`) must not cache `/api/auth/*`. It already avoids
|
||||||
|
`/api` state; check it.
|
||||||
|
|
||||||
|
## Tests (backend)
|
||||||
|
|
||||||
|
- Every existing web test goes through the API. Give the test client fixture in
|
||||||
|
`tests/test_web.py` (and `test_remote_api.py`, `test_tippen_api.py`) a logged-in
|
||||||
|
cookie, so the existing tests stay as they are. Add a separate unauthenticated client
|
||||||
|
fixture for the new tests.
|
||||||
|
- New tests:
|
||||||
|
- Each `/api` route returns 401 without the cookie. Generate the list from the router
|
||||||
|
so a new route cannot slip through unprotected.
|
||||||
|
- The websocket is refused without the cookie.
|
||||||
|
- Login succeeds with the right password and fails with a wrong one.
|
||||||
|
- A token past its expiry is rejected.
|
||||||
|
- Changing `password_hash` invalidates existing cookies.
|
||||||
|
- `"disabled"` lets everything through and logs the warning.
|
||||||
|
- A missing key is a config error.
|
||||||
|
- The `--set-password` command writes a hash that verifies and keeps the file's
|
||||||
|
comments.
|
||||||
|
- The fixed failure delay must not slow the suite down: inject it, as the codebase
|
||||||
|
injects `Clock`.
|
||||||
|
|
||||||
|
## Docs to update
|
||||||
|
|
||||||
|
- `python-backend/README.md`: the "Parent mode" paragraph that says there is no
|
||||||
|
authentication; the route table; a short "Authentication" section.
|
||||||
|
- `web/README.md`: the "Parent mode" paragraph.
|
||||||
|
- `docs/REPO_OVERVIEW.md`: the backend ↔ web paragraph and the config summary.
|
||||||
|
- The comment block above the room-control routes in `api.py`, which justifies the open
|
||||||
|
proxy with "trusted LAN device".
|
||||||
|
|
||||||
|
## Done when
|
||||||
|
|
||||||
|
`scripts/check.sh` passes, and a manual run against `--simulate` with a hash set shows
|
||||||
|
the login screen, logs in, survives a backend restart without asking again, and asks
|
||||||
|
again after the password is changed.
|
||||||
121
docs/agent-tasks/04-one-input-policy.md
Normal file
121
docs/agent-tasks/04-one-input-policy.md
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
# Task: one behaviour per button, whatever it is pressed on
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
The backend's README promises that front-ends only translate input into intents and that
|
||||||
|
`musicmouse/reactions/` is the one place that decides what happens. Three behaviours
|
||||||
|
break that promise today, so the same request does different things depending on where
|
||||||
|
it came from:
|
||||||
|
|
||||||
|
| Behaviour | Web front-end | IR remote | Mouse buttons / knob | MQTT |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| Next/previous on a podcast | seeks ±30 s (`web/src/App.tsx`, `skipPodcast`) | skips a track | skips a track | skips a track |
|
||||||
|
| Unmute | jumps to 60 % (`UNMUTE_PERCENT` in `App.tsx`) | restores the previous volume (`LircService._toggle_mute`) | - | - |
|
||||||
|
| Volume step | J/K: 10 % (`VOLUME_STEP` in `web/src/lib/keyboard.ts`); +/- buttons: `volume_increment` | `volume_increment` | `volume_increment` × detents | absolute only |
|
||||||
|
|
||||||
|
There is also a confirmed bug. With `min_volume > 0`, the remote's mute never unmutes:
|
||||||
|
"mute" asks for volume 0, the player clamps it to `min_volume`, the next press sees
|
||||||
|
`volume > 0` and mutes again.
|
||||||
|
|
||||||
|
## What the owner decided
|
||||||
|
|
||||||
|
1. **Next/previous on a podcast episode always seeks 30 s** forward/back, from every
|
||||||
|
input: web (buttons and keys), IR remote, the mouse's buttons, MQTT.
|
||||||
|
2. **Unmute always restores the volume from before muting**, from every input.
|
||||||
|
3. **Parent mode gets three separate volume-step settings**: the web keyboard's J/K,
|
||||||
|
the mouse's rotary knob, and the IR remote. Each is configured independently.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### 1. Podcast skip lives in the backend
|
||||||
|
|
||||||
|
- In `musicmouse/reactions/playback.py`, `next_track` / `previous_track` look at the
|
||||||
|
current album (`app.album_for(app.player.playlist)`). For a podcast episode they seek
|
||||||
|
instead: `position ± PODCAST_SKIP_SECONDS` (30, a module constant), clamped to
|
||||||
|
`[0, duration]`. Take the duration from the library track when the player does not
|
||||||
|
know it yet, the way `services/web/state.py` does.
|
||||||
|
- "Is this a podcast?" is decided by **one** helper. If `docs/agent-tasks/06-podcast-kind.md`
|
||||||
|
has landed, that is `album.kind == "podcast"`. If not, add `is_podcast(album)` next to
|
||||||
|
`Album` (`musicmouse/library/models.py`) that checks the section's
|
||||||
|
`album_unit == "episode"`, and let task 06 change its body.
|
||||||
|
- `web/src/App.tsx`: the `next` / `previous` actions always call `api.next()` /
|
||||||
|
`api.previous()`. Delete `skipPodcast` and `PODCAST_SKIP_SECONDS`. Drop the optimistic
|
||||||
|
`position: 0` patch too: the client no longer knows what the backend will do. If the
|
||||||
|
≤0.5 s until the next position frame is visibly laggy, let `StateHub` push a
|
||||||
|
`position` frame right after a next/previous/seek intent has been handled. Don't
|
||||||
|
re-add client-side policy.
|
||||||
|
- The mouse's left/right buttons only act while playing (`button_pressed`); keep that.
|
||||||
|
|
||||||
|
### 2. Mute is a player state, not a volume
|
||||||
|
|
||||||
|
- Add `muted: bool` to the `Player` protocol, plus `set_muted(muted, *, source)`,
|
||||||
|
implemented in `PlayerBase`. `VlcPlayer` applies it with libVLC's
|
||||||
|
`audio_set_mute`; `FakePlayer` just records it. Muting does **not** touch `volume`,
|
||||||
|
so unmuting restores it by construction, and `min_volume` cannot interfere.
|
||||||
|
- Announce changes with a new `StateEvent` (for example `MuteChanged`) and add it to
|
||||||
|
`SNAPSHOT_EVENTS` in `services/web/hub.py`. A test fails until every `StateEvent` is
|
||||||
|
sorted into that list or `IGNORED_EVENTS`.
|
||||||
|
- A new intent, `ToggleMuteRequested`, handled by a reaction. Any volume change
|
||||||
|
(`SetVolumeRequested`, `VolumeChangeRequested`, the step intent below) also unmutes;
|
||||||
|
that is what every TV remote does.
|
||||||
|
- `POST /api/mute/toggle`. The web's Shift+M and the mute button call it. Delete
|
||||||
|
`UNMUTE_PERCENT` and `onMute`'s volume arithmetic.
|
||||||
|
- `LircService`: `KEY_MUTE` emits `ToggleMuteRequested`. Delete `_pre_mute_volume` and
|
||||||
|
`_toggle_mute`.
|
||||||
|
- The state snapshot (`PlayerStateOut`, `web/src/api/types.ts`) gets `muted`. The volume
|
||||||
|
bars show muted distinctly instead of showing volume 0.
|
||||||
|
- MQTT is out of scope. Its volume number stays absolute, and setting it unmutes via
|
||||||
|
the rule above.
|
||||||
|
|
||||||
|
### 3. Three volume steps
|
||||||
|
|
||||||
|
- Config: replace `general.volume_increment` with
|
||||||
|
`general.volume_steps: {keyboard: int, knob: int, remote: int}`, all in **device
|
||||||
|
units** like `min_volume` / `max_volume`, each `ge=1, le=100`. Defaults: `keyboard: 10`,
|
||||||
|
`knob: 5`, `remote: 5` (today's behaviour for knob and remote).
|
||||||
|
- `volume_increment` in an existing `config.yml` must fail with a message naming the
|
||||||
|
replacement ("replaced by volume_steps.knob / .remote / .keyboard"). Config
|
||||||
|
validation rejects unknown keys anyway; make the message specific rather than the
|
||||||
|
generic "unknown option".
|
||||||
|
- One intent: `VolumeStepRequested(direction: Literal[-1, 1], count: int = 1,
|
||||||
|
input: Literal["keyboard", "knob", "remote"])`. One reaction multiplies by the
|
||||||
|
configured step and calls `change_volume`.
|
||||||
|
- `rotary_turned` emits it with `input="knob"`, `count=abs(event.increment)`.
|
||||||
|
- `LircService` volume keys emit it with `input="remote"`.
|
||||||
|
- `POST /api/volume` takes `{"steps": n, "input": "keyboard" | "remote"}` instead of
|
||||||
|
bare `steps`. J/K send `input: "keyboard"`. The on-screen −/+ buttons keep taking
|
||||||
|
the remote's step, as 1254790 deliberately chose, so they send `input: "remote"`.
|
||||||
|
- `{"percent": n}` stays for the volume bars.
|
||||||
|
- Parent mode: `SettingsIn` / `SettingsOut` (`services/web/schemas.py`),
|
||||||
|
`read_settings` / `write_settings` (`services/web/settings.py`, writing the nested
|
||||||
|
mapping), and `web/src/components/ParentPanel.tsx` get three fields, labelled in
|
||||||
|
German (for example "Lautstärke-Schritt: Tastatur (J/K)", "… Drehknopf",
|
||||||
|
"… Fernbedienung"). They replace the single "rotary step". Saving applies live, like
|
||||||
|
the other settings.
|
||||||
|
- Update `python-backend/config.yml.example`, the route table in
|
||||||
|
`python-backend/README.md`, `web/README.md`'s key table, and `HelpOverlay.tsx`.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
- Reactions (`tests/test_reactions.py`, through the simulator):
|
||||||
|
- Next/previous on a podcast episode seeks ±30 s and clamps at both ends. Use a
|
||||||
|
`Kinderpodcasts` folder in the test library (see `tests/conftest.py`).
|
||||||
|
- Next/previous on music still changes track.
|
||||||
|
- Mute then unmute restores the exact volume, including with `min_volume > 0` (the
|
||||||
|
regression).
|
||||||
|
- A volume change while muted unmutes.
|
||||||
|
- Each of the three inputs moves by its own configured step.
|
||||||
|
- `tests/test_lirc.py`: the mute key and the volume keys emit the new intents.
|
||||||
|
- `tests/test_web.py`: `/api/mute/toggle`; `/api/volume` with `input`; the settings
|
||||||
|
round-trip for `volume_steps`, which keeps the file's comments.
|
||||||
|
- `tests/test_config.py`: the defaults, and the specific error for `volume_increment`.
|
||||||
|
- `web/src/lib/__tests__/keyboard.test.ts`: J/K produce the step action, not a
|
||||||
|
percentage.
|
||||||
|
|
||||||
|
## Done when
|
||||||
|
|
||||||
|
`scripts/check.sh` passes, `grep -rn "PODCAST_SKIP\|UNMUTE_PERCENT\|volume_increment"`
|
||||||
|
finds nothing in `web/src` and `python-backend/musicmouse` apart from the config error
|
||||||
|
that names the old key, and in `--simulate` a
|
||||||
|
podcast skips 30 s from the web, from `press right` in the REPL, and from a simulated
|
||||||
|
remote key.
|
||||||
75
docs/agent-tasks/05-firmware-serial-frames.md
Normal file
75
docs/agent-tasks/05-firmware-serial-frames.md
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
# Task: stop the firmware garbling its own messages to the host
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
`sendMessageToHost` (`esp-firmware/src/Messages.h`) writes one frame as four separate
|
||||||
|
`Serial.write` calls: magic, type, size, payload. It runs on two FreeRTOS tasks at once:
|
||||||
|
|
||||||
|
- the Arduino `loop()` task, for touch, rotary encoder and button events
|
||||||
|
(`esp-firmware/src/main.cpp`);
|
||||||
|
- the RC522 reader's own task (`rc522_task` in `esp-firmware/lib/nfc/rc522.c`, priority
|
||||||
|
4), which calls `tagHandler` → `sendMessageToHost(MsgRfidTokenRead{...})` directly.
|
||||||
|
|
||||||
|
When a tag read coincides with a button or touch event, the two frames interleave on
|
||||||
|
the wire. The host's decoder (`python-backend/musicmouse/devices/wire.py`,
|
||||||
|
`FrameDecoder`) resynchronises on the magic token and drops what it cannot parse. So
|
||||||
|
the symptom is an occasionally **lost figure placement/removal or button press**, with a
|
||||||
|
"Discarding bad frame from firmware" warning in the backend log.
|
||||||
|
|
||||||
|
## What the owner decided
|
||||||
|
|
||||||
|
Fix it in whatever way keeps the firmware simplest. A mutex was the first idea. **Use
|
||||||
|
the queue below instead, unless it turns out not to work**, because it is simpler:
|
||||||
|
|
||||||
|
- With a queue, every frame and every log line is written from `loop()` alone, so no
|
||||||
|
lock is needed anywhere, including around the `Serial.println` log lines that could
|
||||||
|
otherwise land in the middle of a frame.
|
||||||
|
- It is exactly the pattern `main.cpp` already uses for the rotary encoder
|
||||||
|
(`eventQueueRotaryEncoder`, drained in `handleRotaryEncoder()` with
|
||||||
|
`xQueueReceive(..., 0)`).
|
||||||
|
|
||||||
|
A mutex would need wrapping every `Serial.write` / `Serial.print` in the firmware to be
|
||||||
|
complete. That is more code and easier to get wrong later.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
In `esp-firmware/src/main.cpp`:
|
||||||
|
|
||||||
|
1. Create a FreeRTOS queue for tag reads in `setupRfidReader()`, **before**
|
||||||
|
`rc522_start`, so the callback can never run without it. Item type: the 5-byte tag id
|
||||||
|
(`MsgRfidTokenRead` itself is fine, it is a packed 5-byte struct). Depth: a handful
|
||||||
|
(4) is plenty; the reader polls every 125 ms.
|
||||||
|
2. `tagHandler` (runs on the RC522 task) only fills a `MsgRfidTokenRead`, all zeros
|
||||||
|
for "removed" exactly as now, and `xQueueSend(queue, &msg, 0)`. If the queue is full,
|
||||||
|
drop the read; the reader reports the state again on the next change. Nothing in
|
||||||
|
`tagHandler` touches `Serial` any more.
|
||||||
|
3. Add `handleRfid()`, called from `loop()` next to `handleRotaryEncoder()`. It drains
|
||||||
|
the queue with `xQueueReceive(..., 0)` and calls `sendMessageToHost` for each item.
|
||||||
|
4. Leave `Messages.h` alone. With a single writer, the four `Serial.write` calls are
|
||||||
|
fine as they are.
|
||||||
|
|
||||||
|
Do not change the wire format, message ids or the host. `python-backend/tests/test_wire.py`
|
||||||
|
parses `Messages.h` and pins the ids, and it must keep passing.
|
||||||
|
|
||||||
|
Out of scope: the touch thresholds, debouncing, anything in `lib/`.
|
||||||
|
|
||||||
|
## Checking it
|
||||||
|
|
||||||
|
There are no firmware unit tests that reach `main.cpp` (the `native` env only builds the
|
||||||
|
LED effects), and this machine may not have PlatformIO. So:
|
||||||
|
|
||||||
|
- It must build: `pio run -e esp32` in `esp-firmware/`. If PlatformIO is not
|
||||||
|
available, say so in the hand-off instead of claiming it builds.
|
||||||
|
- `pio run -e native` still builds `src/host_test.cpp`. That env excludes `main.cpp`, so
|
||||||
|
this only guards against accidental changes to shared headers.
|
||||||
|
- `scripts/check.sh backend` still passes (`test_wire.py`).
|
||||||
|
- Manual test on the device, with the backend running at `--log-level DEBUG`. Hold a
|
||||||
|
hand on an ear touch area while repeatedly placing and removing a figure for a
|
||||||
|
minute, and turn the knob while doing it. Expect every placement and removal to show
|
||||||
|
up as `RfidTokenRead`, and **no** "Discarding bad frame from firmware" warnings.
|
||||||
|
Before the fix, the same exercise should produce some.
|
||||||
|
|
||||||
|
## Done when
|
||||||
|
|
||||||
|
The firmware builds, the host tests pass, `tagHandler` contains no `Serial` call, and the
|
||||||
|
manual test shows no discarded frames.
|
||||||
89
docs/agent-tasks/06-podcast-kind.md
Normal file
89
docs/agent-tasks/06-podcast-kind.md
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
# Task: make "podcast" a kind of album
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
`AlbumKind` is `Literal["music", "book"]` (`python-backend/musicmouse/library/sections.py`),
|
||||||
|
and the `Kinderpodcasts` shelf is declared `kind="book"`. So "is this a podcast?" is
|
||||||
|
answered differently in each layer:
|
||||||
|
|
||||||
|
- web: `album.section === "Kinderpodcasts"` (`web/src/lib/search.ts`, `groupOf`), and
|
||||||
|
everything else goes through `groupOf(album) === "podcasts"` (App, PlayView,
|
||||||
|
BrowseView, MobileBrowse, keyboard.ts, remote.ts, ambience.ts, useTrackDetail);
|
||||||
|
- backend: `SECTIONS[album.section].album_unit == "episode"` (`MusicLibrary.latest_episode`,
|
||||||
|
`tippen/rewards.py`);
|
||||||
|
- typing game: reward kinds `"episode"` and `"podcast_episode"`
|
||||||
|
(`services/web/tippen_api.py`).
|
||||||
|
|
||||||
|
The shelf's folder name is therefore a de-facto API string the front-end depends on.
|
||||||
|
`todo.md` already asks for a cleaner podcast data model.
|
||||||
|
|
||||||
|
## What the owner decided
|
||||||
|
|
||||||
|
Add `"podcast"` to the album kind. Podcast-ness is then a property of the album, carried
|
||||||
|
in the index and the API, and nobody compares section names.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
- `AlbumKind = Literal["music", "book", "podcast"]`. `SECTIONS["Kinderpodcasts"]` gets
|
||||||
|
`kind="podcast"`.
|
||||||
|
- `Section.album_unit` stays. It is the scanner's structural fact (one album per file),
|
||||||
|
not a classification. Keep using it in the scanner (`scan_library`) and in
|
||||||
|
`podcast_feeds.find_feed_shows`. Every place that asks "is this album a podcast?"
|
||||||
|
switches to `album.kind == "podcast"`:
|
||||||
|
- `MusicLibrary.latest_episode`;
|
||||||
|
- `tippen/rewards.py` (`_chronological_episodes`, and the `album_unit == "folder"`
|
||||||
|
branches that really mean "not a podcast");
|
||||||
|
- `tippen_api._animation_kind`;
|
||||||
|
- the `is_podcast` helper, if task 04 added one: change its body, keep its callers.
|
||||||
|
- `scanner.scan_album` derives `series` only for `kind == "book"`. Check that episode
|
||||||
|
albums still get `series = folder.name` (they do, in `scan_episodes`) and that
|
||||||
|
nothing else branches on `"book"` meaning "book or podcast".
|
||||||
|
`grep -rn '"book"' python-backend/musicmouse` and decide per hit.
|
||||||
|
- `FigureConfig.kind` stays `Literal["music", "book"]`. A figurine holding a podcast is
|
||||||
|
not a thing. Type `Config.figure_kinds` accordingly.
|
||||||
|
- `cache._album_from_json` validates the kind: accept `"podcast"`. Bump
|
||||||
|
`_INDEX_VERSION` (with a comment line, like the existing ones), so every cached
|
||||||
|
podcast album is re-scanned with the new kind instead of being read back as
|
||||||
|
`"book"`.
|
||||||
|
- `_ANALYZED_KINDS` stays `("music",)`.
|
||||||
|
- `services/web/schemas.py`: `AlbumOut.kind` and `PlayerStateOut.kind` become the
|
||||||
|
`AlbumKind` literal instead of `str`, so the OpenAPI schema says what the values are.
|
||||||
|
|
||||||
|
### Web
|
||||||
|
|
||||||
|
- `src/api/types.ts`: `AlbumKind = "music" | "book" | "podcast"`.
|
||||||
|
- `src/lib/search.ts`, `groupOf`: map from `kind` alone (`podcast` → `"podcasts"`,
|
||||||
|
`book` → `"audiobooks"`, else `"music"`). No `section` comparison may remain
|
||||||
|
anywhere in `web/src`.
|
||||||
|
- `src/lib/covers.ts`: `isBook` and `aspectOfKind` treat `"book"` specially. Podcasts
|
||||||
|
are drawn like books today *because* they were `kind: "book"`. Keep them looking
|
||||||
|
exactly as they do now: decide explicitly whether `isBook` / `aspectOfKind` include
|
||||||
|
`"podcast"`, and check BrowseView's `mostlyBooks` / `allPodcasts` tiles and the play
|
||||||
|
view. The result must be visually unchanged; compare screenshots of the podcast
|
||||||
|
shelf, a podcast category and the podcast play view before and after.
|
||||||
|
- Anything else that reads `state.kind` or `album.kind`: grep and decide per hit.
|
||||||
|
|
||||||
|
### Leave alone
|
||||||
|
|
||||||
|
The `section` field stays in the API; it is still useful for display and debugging.
|
||||||
|
Don't rename the shelves, the folders or the `Group` names.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
- `tests/test_library.py`: a `Kinderpodcasts` episode scans as `kind == "podcast"`; a
|
||||||
|
cache written at the previous `_INDEX_VERSION` is discarded; `latest_episode` still
|
||||||
|
works.
|
||||||
|
- `tests/test_tippen_rewards.py` / `test_tippen_api.py`: episode rewards still resolve
|
||||||
|
and still animate as `podcast_episode`.
|
||||||
|
- `tests/test_web.py`: `/api/library` reports `"podcast"`.
|
||||||
|
- `web/src/lib/__tests__/search.test.ts`: `groupOf` uses `kind`; an album whose section
|
||||||
|
says `Kinderpodcasts` but whose kind is `music` is music. That proves nothing compares
|
||||||
|
the section any more.
|
||||||
|
|
||||||
|
## Done when
|
||||||
|
|
||||||
|
`scripts/check.sh` passes, `grep -rn "Kinderpodcasts" web/src` finds nothing, the
|
||||||
|
backend's "is it a podcast" checks all read `album.kind`, and the podcast UI looks the
|
||||||
|
same as before.
|
||||||
222
docs/agent-tasks/07-backend-and-frontend-cleanups.md
Normal file
222
docs/agent-tasks/07-backend-and-frontend-cleanups.md
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
# Task: eight independent cleanups
|
||||||
|
|
||||||
|
Each section below is a separate change: do them as separate commits, in any order, and
|
||||||
|
run `scripts/check.sh` after each. Each comes from a code review of commit `a55cc4f`;
|
||||||
|
the "why" says what is wrong and, where known, which commit introduced it. Do not
|
||||||
|
change behaviour beyond what a section asks for.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## A. Races in library updates and concurrent rescans (review item 7)
|
||||||
|
|
||||||
|
**Why.** `MusicLibrary.refresh()` and `MusicLibrary._publish_analysis()`
|
||||||
|
(`python-backend/musicmouse/library/__init__.py`) both do
|
||||||
|
`self._entries = await asyncio.to_thread(...)` from a snapshot taken before the await.
|
||||||
|
Whichever finishes last wins, so an analysis batch that started before a rescan
|
||||||
|
finished can overwrite the freshly scanned index (a new podcast episode disappears
|
||||||
|
until the next rescan). Both also write `index.json` from worker threads at the same
|
||||||
|
time. Rescans are not serialised either: `POST /api/library/refresh` fires an
|
||||||
|
unretained `asyncio.create_task` (`# noqa: RUF006` in `services/web/api.py`), and the
|
||||||
|
podcast poller rescans on its own (`App.rescan_library`, introduced with the poller in
|
||||||
|
57afc32).
|
||||||
|
|
||||||
|
Separately, `VlcPlayer` (`devices/player.py`) mutates `_playing`, `_index` and
|
||||||
|
`_playlist_changed` both from libVLC's callback thread (`_on_playing`, `_on_stopped`,
|
||||||
|
`_on_playlist_end`, `_on_next_item`) and from the event loop (`_poll`, the actions).
|
||||||
|
|
||||||
|
**Do.**
|
||||||
|
- One `asyncio.Lock` in `MusicLibrary` held around every read-modify-write of
|
||||||
|
`_entries` plus its `store_index`: the whole of `refresh()`, and the fold-swap-store
|
||||||
|
in `_publish_analysis()`. The analysis workers keep running while a scan holds the
|
||||||
|
lock; only publishing waits.
|
||||||
|
- `App.rescan_library` coalesces. While one rescan runs, further calls wait for the
|
||||||
|
running one plus at most one follow-up, not N parallel scans. Keep a reference to the
|
||||||
|
task the refresh endpoint starts, and drop the `noqa`.
|
||||||
|
- `VlcPlayer`'s libVLC callbacks do nothing but hop onto the loop (the bus already
|
||||||
|
knows the loop; give `PlayerBase` a small `_on_loop(fn)` using
|
||||||
|
`loop.call_soon_threadsafe`). All state mutation then happens on the loop thread.
|
||||||
|
Update the comment above the callbacks.
|
||||||
|
|
||||||
|
**Test.** A library test with a scan made artificially slow (an injected scanner or a
|
||||||
|
patched `scan_library`) and an analysis publish racing it. The new album must survive.
|
||||||
|
Two concurrent `rescan_library` calls run the scanner at most twice.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## B. Library cache invalidation gaps (review item 8)
|
||||||
|
|
||||||
|
**Why.** `scan_library` (`library/scanner.py`) reuses a cached folder album whenever
|
||||||
|
its *audio files'* names, sizes and mtimes are unchanged (`Fingerprint`). So:
|
||||||
|
1. changing a figure's `kind:` in `config.yml` has no effect until the figure's files
|
||||||
|
change: the cached album keeps the old kind;
|
||||||
|
2. a `cover.jpg` (or any of `_COVER_NAMES`) added to an album folder later is never
|
||||||
|
picked up. Podcast episodes include their sidecar covers in the fingerprint; folder
|
||||||
|
albums do not;
|
||||||
|
3. covers of albums that no longer exist (deleted folders, episodes pruned by the
|
||||||
|
podcast poller) stay in `cache/covers/` forever.
|
||||||
|
|
||||||
|
**Do.**
|
||||||
|
- Folder albums: include the existing `_COVER_NAMES` files in the fingerprint, like
|
||||||
|
`scan_episodes` does for sidecars.
|
||||||
|
- Figure albums: reuse a cached entry only if its `kind` equals the configured one.
|
||||||
|
- After a scan, delete `covers/<id>.jpg` and `covers/<id>.thumb.jpg` for ids not in
|
||||||
|
the new index. Do **not** garbage-collect `analysis/`: it is expensive, content-keyed
|
||||||
|
and documented as safe to keep.
|
||||||
|
- Bump `_INDEX_VERSION` with a comment line, because the fingerprint shape changes.
|
||||||
|
|
||||||
|
**Test.** One test per gap in `tests/test_library.py`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## C. The LED "off" animation is sent twice when a figure is removed (review item 11)
|
||||||
|
|
||||||
|
**Why.** Removing a figure makes `reactions/lighting.figure_placed_or_removed` run
|
||||||
|
`off_animation`. The player pausing then emits `PlaybackChanged(playing=False)`, and
|
||||||
|
`web_playback`, whose guard is "no figure on the reader", runs `off_animation` again.
|
||||||
|
That makes 6 LED writes instead of 3 and restarts the reverse swipe halfway through.
|
||||||
|
`web_playback` came in with the web front-end (edb6e5e).
|
||||||
|
|
||||||
|
**Do.** Make `off_animation` idempotent: skip a zone whose current effect
|
||||||
|
(`app.mouse.effect(zone)`) is already the off effect it would send, and skip the button
|
||||||
|
brightness when it is already 0. That keeps both reactions simple and covers any future
|
||||||
|
third caller.
|
||||||
|
|
||||||
|
**Test.** In `tests/test_reactions.py`: removing a figure while playing produces
|
||||||
|
exactly three `LedEffectChanged` events, one per zone.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## D. `config.yml` has no single owner (review item 13)
|
||||||
|
|
||||||
|
**Why.**
|
||||||
|
- `PUT /api/settings` (`services/web/api.py`) applies values with `setattr` on the
|
||||||
|
pydantic config objects. That skips validation, so the handler re-implements the
|
||||||
|
min/max/initial volume checks by hand.
|
||||||
|
- `PUT /api/remote/mapping` replaces `app.config.remote`.
|
||||||
|
- Both endpoints read, modify and write `config.yml` from worker threads
|
||||||
|
(`write_settings`, `write_mapping`) with no lock, so two saves at once can lose one.
|
||||||
|
- Memory is updated *before* the file is written, so a failed write leaves the running
|
||||||
|
config and the file disagreeing.
|
||||||
|
|
||||||
|
**Do.** One small `ConfigStore` (for example `musicmouse/config_store.py`) owns the path,
|
||||||
|
the in-memory `Config` and a lock. An update:
|
||||||
|
1. validates the merged values through the real pydantic models, so the
|
||||||
|
`_check_volumes` validator runs; no duplicated checks in the endpoint;
|
||||||
|
2. writes the file (round-trip, comments kept, via the existing `load_document` /
|
||||||
|
`atomic_write`);
|
||||||
|
3. only then applies the values to the live objects.
|
||||||
|
|
||||||
|
Objects that read the config live (`ScreenService` reads `auto_off_minutes`, the player's
|
||||||
|
volume limits) must keep seeing changes: either keep mutating the same objects after
|
||||||
|
validation, or have them read through the store. Pick one and say which in the
|
||||||
|
docstring. The settings and remote-mapping endpoints both go through the store. If
|
||||||
|
`docs/agent-tasks/01-web-authentication.md` has landed, its `--set-password` command
|
||||||
|
should use it too.
|
||||||
|
|
||||||
|
**Test.** Invalid settings are rejected by the model's own validator, with an
|
||||||
|
unchanged file and unchanged memory. Two concurrent saves (settings and mapping) both
|
||||||
|
end up in the file.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## E. Generate the front-end's API types (review item 15)
|
||||||
|
|
||||||
|
**Why.** `web/src/api/types.ts` (~300 lines) hand-mirrors
|
||||||
|
`python-backend/musicmouse/services/web/schemas.py`. FastAPI already describes every
|
||||||
|
route and model at `/api/openapi.json`. (A third copy lived in the Slint front-end,
|
||||||
|
which has been removed.)
|
||||||
|
|
||||||
|
**Do.**
|
||||||
|
- A backend test (for example `tests/test_openapi.py`) builds the app the way
|
||||||
|
`tests/test_web.py` does and compares `api.openapi()` with a committed
|
||||||
|
`web/src/api/openapi.json`. With `UPDATE_OPENAPI=1` it rewrites the file instead. The
|
||||||
|
test fails whenever the API changes and the snapshot was not refreshed.
|
||||||
|
- `web`: add `openapi-typescript` as a dev dependency and an `npm run gen:api` script
|
||||||
|
that generates `src/api/schema.d.ts` from that JSON. Make `npm run check` fail when
|
||||||
|
the generated file is stale: use the tool's own check mode if it has one (verify),
|
||||||
|
otherwise regenerate to a temp file and diff.
|
||||||
|
- `types.ts` keeps its exported names (`Album`, `PlayerState`, ...) as aliases of
|
||||||
|
`components["schemas"][...]`, so no component changes. Move the useful doc comments
|
||||||
|
onto the pydantic fields (`Field(description=...)`), where they reach both sides.
|
||||||
|
- The websocket messages (`ServerMessage`) are not in OpenAPI. Keep them hand-written
|
||||||
|
in `types.ts` with a comment saying so.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## F. Copy-pasted helpers (review item 16)
|
||||||
|
|
||||||
|
**Why.**
|
||||||
|
- `ScreenService.blubb` (`services/screen.py`) is a line-for-line copy of
|
||||||
|
`sound.play_wav`. `sound.py` was extracted in a55cc4f, but the screen service was
|
||||||
|
never moved onto it.
|
||||||
|
- The "did a person do this?" filter (an `IntentEvent`, not `LedEffectRequested`, not
|
||||||
|
`source == "system"`) is duplicated in `ScreenService._on_event` and
|
||||||
|
`TimerService._on_event`.
|
||||||
|
- There are five atomic-write helpers: `library/cache._write_atomic`,
|
||||||
|
`services/web/settings.atomic_write`, `tippen/progress.save_progress`,
|
||||||
|
`school_days.save_calendar` and `library/podcast_feeds._save_failed`. Three of them
|
||||||
|
name the temp file `<name>.tmp<pid>`, which collides when two threads of the same
|
||||||
|
process write the same file. That really happens: settings and remote-mapping saves
|
||||||
|
both run in `asyncio.to_thread`.
|
||||||
|
|
||||||
|
**Do.**
|
||||||
|
- `ScreenService.blubb` calls `play_wav(self.alsa_device, wav, timeout=self.config.timeout)`.
|
||||||
|
- `is_person(event: Event) -> bool` next to the event classes in `events.py`, used by
|
||||||
|
both services (keep each service's own extra exclusions).
|
||||||
|
- One `write_atomic(path, text)` (for example `musicmouse/fileio.py`) using
|
||||||
|
`tempfile.NamedTemporaryFile(dir=path.parent, delete=False)` and `os.replace`, used by
|
||||||
|
all five. Keep the temp file a dotfile where it sits in the music library, so the
|
||||||
|
scanner never picks it up. The YAML writer dumps to a string first.
|
||||||
|
|
||||||
|
**Test.** The existing tests cover behaviour. Add one for `write_atomic`, two threads
|
||||||
|
writing the same path at once, which today can fail.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## G. Inject time everywhere (review item 17)
|
||||||
|
|
||||||
|
**Why.** The backend's rule (see `musicmouse/clock.py`) is that anything that waits takes a
|
||||||
|
`Clock`, and `TimerService` takes a `wall` callable for the date. Exceptions:
|
||||||
|
- `PodcastFeedService.run` and `StateHub.run` call `asyncio.sleep` directly;
|
||||||
|
- `services/web/state.clock_state` uses `date.today()`;
|
||||||
|
- `POST /api/timer` (`services/web/api.py`) uses `datetime.now()`.
|
||||||
|
|
||||||
|
**Do.** Give `PodcastFeedService` and `StateHub` a `clock`, as the other services have,
|
||||||
|
wired in `__main__._build_services` / `WebService`. Put one `wall: Callable[[],
|
||||||
|
datetime]` on `App` (default `datetime.now`) and use it in `TimerService`,
|
||||||
|
`clock_state` and the timer endpoint, so a test can pin "today" in one place.
|
||||||
|
|
||||||
|
**Test.** The podcast service test runs on `FakeClock` instead of real sleeps; a
|
||||||
|
clock-state test pins the date through `App.wall`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## H. Break up the largest files (review item 19)
|
||||||
|
|
||||||
|
**Why.** Every feature since the rearchitecture was added to the same few files.
|
||||||
|
- `web/src/App.tsx` is 909 lines with ~40 hooks, routes pages via flags, and
|
||||||
|
defines `ToolButton`, `CornerButton`, `ConnectionDot`, `Splash` and the assign toast
|
||||||
|
inline.
|
||||||
|
- `BrowseView.tsx` is 932 lines.
|
||||||
|
- About 300 static `style={{...}}` objects sit next to five CSS files.
|
||||||
|
- `services/web/api.py` is one router for seven features: library, state, playback,
|
||||||
|
parent mode, typing game, remote, Home Assistant.
|
||||||
|
- One sign of insertion rather than placement: the comment
|
||||||
|
`/** The one place a keyboard action, a click or a tap all end up. */` in `App.tsx`
|
||||||
|
sits above `saveMapping`, but describes `run`.
|
||||||
|
|
||||||
|
**Do** (mechanical moves only, no behaviour change; one commit per bullet):
|
||||||
|
- `api.py`: one router module per feature under `services/web/routes/`, assembled in
|
||||||
|
`build_router`. URLs unchanged.
|
||||||
|
- `App.tsx`: move the four small components into `components/`, the remote-mapping
|
||||||
|
state into `hooks/useRemoteMapping.ts`, and the kiosk listeners (screen activity,
|
||||||
|
silencing a ringing timer) into `hooks/useKioskActivity.ts`. Put the misplaced
|
||||||
|
comment back above `run`.
|
||||||
|
- `BrowseView.tsx`: split by screen (root shelves, category, results), whichever seams
|
||||||
|
the file already has.
|
||||||
|
- Inline styles: move **static** style objects in `App.tsx`, `BrowseView.tsx` and
|
||||||
|
`ParentPanel.tsx` into classes in the existing CSS files. Leave dynamic ones (colours
|
||||||
|
from album data, computed sizes) inline.
|
||||||
|
|
||||||
|
Check visually in the dev server, at desktop, phone (`usePhoneLayout`) and `?pi=1`
|
||||||
|
sizes, that nothing moved.
|
||||||
20
docs/agent-tasks/README.md
Normal file
20
docs/agent-tasks/README.md
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# Agent tasks
|
||||||
|
|
||||||
|
Self-contained instructions for fixes that came out of a code review of commit `a55cc4f`.
|
||||||
|
Each file says why, what the owner decided, the design, the tests, and when it is done.
|
||||||
|
The numbers are the review's item numbers.
|
||||||
|
|
||||||
|
| File | What |
|
||||||
|
|---|---|
|
||||||
|
| `01-web-authentication.md` | Password login, once per browser for a year, in front of the whole `/api` (including the Home Assistant proxy). |
|
||||||
|
| `04-one-input-policy.md` | Podcast next/previous seeks 30 s from every input; mute restores the previous volume; three separate volume steps in parent mode. |
|
||||||
|
| `05-firmware-serial-frames.md` | Stop RFID frames interleaving with button frames on the serial link. |
|
||||||
|
| `06-podcast-kind.md` | `"podcast"` as an album kind instead of comparing the `Kinderpodcasts` folder name. |
|
||||||
|
| `07-backend-and-frontend-cleanups.md` | Eight independent cleanups: library races, cache invalidation, double LED off-animation, a config owner, generated API types, shared helpers, injected time, splitting the largest files. |
|
||||||
|
|
||||||
|
Order: `06` before `04` (04's podcast check uses 06's kind; it says how to proceed if
|
||||||
|
06 has not landed). `07 D` is easier after `01`. The rest are independent.
|
||||||
|
|
||||||
|
For every task: one commit per logical change, `scripts/check.sh` passing before each
|
||||||
|
commit, no pushing. When a task is done, delete its file in the same commit, so this
|
||||||
|
folder only ever lists open work.
|
||||||
@@ -3,5 +3,8 @@
|
|||||||
// for the documentation about the extensions.json format
|
// for the documentation about the extensions.json format
|
||||||
"recommendations": [
|
"recommendations": [
|
||||||
"platformio.platformio-ide"
|
"platformio.platformio-ide"
|
||||||
|
],
|
||||||
|
"unwantedRecommendations": [
|
||||||
|
"ms-vscode.cpptools-extension-pack"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -43,5 +43,15 @@
|
|||||||
"cinttypes": "cpp",
|
"cinttypes": "cpp",
|
||||||
"utility": "cpp",
|
"utility": "cpp",
|
||||||
"typeinfo": "cpp"
|
"typeinfo": "cpp"
|
||||||
}
|
},
|
||||||
|
"vsmqtt.brokerProfiles": [
|
||||||
|
{
|
||||||
|
"name": "homeassistant",
|
||||||
|
"host": "homeassistant",
|
||||||
|
"port": 1883,
|
||||||
|
"username": "musicmouse",
|
||||||
|
"clientId": "vsmqtt_client",
|
||||||
|
"password": "KNLEFLZF94yA6Zhj141",
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
87
esp-firmware/lib/ledtl/Esp32DriverRGBW.cpp
Normal file
87
esp-firmware/lib/ledtl/Esp32DriverRGBW.cpp
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
#ifndef PLATFORM_NATIVE
|
||||||
|
|
||||||
|
#include "drivers/Esp32DriverRGBW.h"
|
||||||
|
|
||||||
|
#include <driver/rmt_encoder.h>
|
||||||
|
#include <esp_check.h>
|
||||||
|
|
||||||
|
// Timing constants, in ns
|
||||||
|
static constexpr uint32_t RESOLUTION_HZ = 20 * 1000 * 1000; // one RMT tick = 50 ns
|
||||||
|
static constexpr uint32_t TICK_NS = 1000 * 1000 * 1000 / RESOLUTION_HZ;
|
||||||
|
static constexpr uint32_t T0H = 300;
|
||||||
|
static constexpr uint32_t T0L = 900;
|
||||||
|
static constexpr uint32_t T1H = 600;
|
||||||
|
static constexpr uint32_t T1L = 600;
|
||||||
|
|
||||||
|
static rmt_symbol_word_t symbol(uint32_t highNs, uint32_t lowNs)
|
||||||
|
{
|
||||||
|
rmt_symbol_word_t s = {};
|
||||||
|
s.duration0 = highNs / TICK_NS;
|
||||||
|
s.level0 = 1;
|
||||||
|
s.duration1 = lowNs / TICK_NS;
|
||||||
|
s.level1 = 0;
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Esp32DriverRGBW::begin(int gpio)
|
||||||
|
{
|
||||||
|
transmitting_ = false;
|
||||||
|
|
||||||
|
rmt_tx_channel_config_t channelConfig = {};
|
||||||
|
channelConfig.gpio_num = static_cast<gpio_num_t>(gpio);
|
||||||
|
channelConfig.clk_src = RMT_CLK_SRC_DEFAULT;
|
||||||
|
channelConfig.resolution_hz = RESOLUTION_HZ;
|
||||||
|
channelConfig.mem_block_symbols = 64; // one block, refilled from the interrupt
|
||||||
|
channelConfig.trans_queue_depth = 4;
|
||||||
|
ESP_ERROR_CHECK(rmt_new_tx_channel(&channelConfig, &channel_));
|
||||||
|
|
||||||
|
// Every byte goes out MSB first, one RMT symbol per bit - the strip's own byte order
|
||||||
|
// (g, r, b, w) is already how LedStripRGBW lays the data out in memory.
|
||||||
|
rmt_bytes_encoder_config_t encoderConfig = {};
|
||||||
|
encoderConfig.bit0 = symbol(T0H, T0L);
|
||||||
|
encoderConfig.bit1 = symbol(T1H, T1L);
|
||||||
|
encoderConfig.flags.msb_first = 1;
|
||||||
|
ESP_ERROR_CHECK(rmt_new_bytes_encoder(&encoderConfig, &encoder_));
|
||||||
|
|
||||||
|
ESP_ERROR_CHECK(rmt_enable(channel_));
|
||||||
|
}
|
||||||
|
|
||||||
|
void Esp32DriverRGBW::end()
|
||||||
|
{
|
||||||
|
waitForTransmissionToFinish();
|
||||||
|
ESP_ERROR_CHECK(rmt_disable(channel_));
|
||||||
|
ESP_ERROR_CHECK(rmt_del_encoder(encoder_));
|
||||||
|
ESP_ERROR_CHECK(rmt_del_channel(channel_));
|
||||||
|
encoder_ = nullptr;
|
||||||
|
channel_ = nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Esp32DriverRGBW::writeSync(const uint32_t *rgbwData, int numLeds)
|
||||||
|
{
|
||||||
|
writeAsync(rgbwData, numLeds);
|
||||||
|
waitForTransmissionToFinish();
|
||||||
|
}
|
||||||
|
|
||||||
|
void Esp32DriverRGBW::writeAsync(const uint32_t *rgbwData, int numLeds)
|
||||||
|
{
|
||||||
|
waitForTransmissionToFinish();
|
||||||
|
rmt_transmit_config_t transmitConfig = {};
|
||||||
|
transmitConfig.loop_count = 0;
|
||||||
|
ESP_ERROR_CHECK(rmt_transmit(channel_, encoder_, rgbwData, numLeds * 4, &transmitConfig));
|
||||||
|
transmitting_ = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Esp32DriverRGBW::waitForTransmissionToFinish(int waitMs)
|
||||||
|
{
|
||||||
|
if (!transmitting_)
|
||||||
|
return true;
|
||||||
|
if (rmt_tx_wait_all_done(channel_, waitMs) == ESP_OK)
|
||||||
|
{
|
||||||
|
transmitting_ = false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -4,10 +4,13 @@
|
|||||||
|
|
||||||
#include "containers/LedStripRGBW.h"
|
#include "containers/LedStripRGBW.h"
|
||||||
|
|
||||||
|
#include <driver/rmt_tx.h>
|
||||||
|
|
||||||
class Esp32DriverRGBW
|
class Esp32DriverRGBW
|
||||||
{
|
{
|
||||||
public:
|
public:
|
||||||
void begin(int gpio, int rmtChannel);
|
// The RMT channel is picked by the driver; only the pin is ours to choose.
|
||||||
|
void begin(int gpio);
|
||||||
void end();
|
void end();
|
||||||
|
|
||||||
void writeSync(const uint32_t *rgbwData, int numLeds);
|
void writeSync(const uint32_t *rgbwData, int numLeds);
|
||||||
@@ -16,8 +19,9 @@ public:
|
|||||||
bool waitForTransmissionToFinish(int waitMs = 1000);
|
bool waitForTransmissionToFinish(int waitMs = 1000);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
int rmtChannel_;
|
rmt_channel_handle_t channel_ = nullptr;
|
||||||
bool transmitting_;
|
rmt_encoder_handle_t encoder_ = nullptr;
|
||||||
|
bool transmitting_ = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
@@ -8,6 +8,8 @@ enum class EffectId
|
|||||||
RANDOM_TWO_COLOR_INTERPOLATION,
|
RANDOM_TWO_COLOR_INTERPOLATION,
|
||||||
SWIPE_AND_CHANGE, // combination of ALEXA_SWIPE and RANDOM_TWO_COLOR_INTERPOLATION
|
SWIPE_AND_CHANGE, // combination of ALEXA_SWIPE and RANDOM_TWO_COLOR_INTERPOLATION
|
||||||
REVERSE_SWIPE,
|
REVERSE_SWIPE,
|
||||||
|
STATIC_DETAILED,
|
||||||
|
SEGMENT_SELECT,
|
||||||
};
|
};
|
||||||
|
|
||||||
template <EffectId id>
|
template <EffectId id>
|
||||||
212
esp-firmware/lib/ledtl/effects/SegmentSelect.h
Normal file
212
esp-firmware/lib/ledtl/effects/SegmentSelect.h
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "effects/Common.h"
|
||||||
|
#include "helpers/ColorRGBW.h"
|
||||||
|
#include "helpers/ColorHSV.h"
|
||||||
|
#include "helpers/ColorConversions.h"
|
||||||
|
|
||||||
|
#include <cmath>
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
static constexpr int SEGMENT_SELECT_MAX_SEGMENTS = 6;
|
||||||
|
|
||||||
|
// The ring as a picker: one section per choice, the selected one bright and wobbling.
|
||||||
|
//
|
||||||
|
// The host re-sends this on every rotary detent. Each message builds a fresh effect, so
|
||||||
|
// `previous` is what makes the highlight glide over from where it was instead of
|
||||||
|
// jumping; previous == selected means no glide.
|
||||||
|
#pragma pack(push, 1)
|
||||||
|
struct EffectSegmentSelectConfig
|
||||||
|
{
|
||||||
|
uint8_t count; // number of sections, 1..SEGMENT_SELECT_MAX_SEGMENTS
|
||||||
|
uint8_t selected; // index of the highlighted section
|
||||||
|
uint8_t previous; // index the highlight glides from
|
||||||
|
uint8_t gapLeds; // dark LEDs between neighbouring sections
|
||||||
|
ColorRGBW colors[SEGMENT_SELECT_MAX_SEGMENTS];
|
||||||
|
float offsetDegrees; // where section 0 starts
|
||||||
|
int8_t direction; // +1 / -1: which way round the sections go
|
||||||
|
float dimFactor; // brightness of the sections that are not selected, 0..1
|
||||||
|
float wobbleAmplitude; // in LEDs
|
||||||
|
float wobblePeriodMs;
|
||||||
|
float slideMs; // time for the highlight to glide from `previous` to `selected`
|
||||||
|
float edgeBlend; // 0..0.5: how far a section's edges shift towards its neighbours' hue
|
||||||
|
};
|
||||||
|
#pragma pack(pop)
|
||||||
|
|
||||||
|
template <typename TLedStrip>
|
||||||
|
class EffectSegmentSelect
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
static constexpr auto NUM_LEDS = numLeds<TLedStrip>();
|
||||||
|
static constexpr int DELAY_MS = 10;
|
||||||
|
using ConfigType = EffectSegmentSelectConfig;
|
||||||
|
|
||||||
|
EffectSegmentSelect(const EffectSegmentSelectConfig &cfg, TLedStrip &ledStrip)
|
||||||
|
: config_(cfg),
|
||||||
|
ledStrip_(ledStrip)
|
||||||
|
{
|
||||||
|
if (config_.count > SEGMENT_SELECT_MAX_SEGMENTS)
|
||||||
|
config_.count = SEGMENT_SELECT_MAX_SEGMENTS;
|
||||||
|
if (config_.count > 0)
|
||||||
|
{
|
||||||
|
config_.selected %= config_.count;
|
||||||
|
config_.previous %= config_.count;
|
||||||
|
}
|
||||||
|
if (config_.direction == 0)
|
||||||
|
config_.direction = 1;
|
||||||
|
|
||||||
|
pitch_ = float(NUM_LEDS) / float(config_.count > 0 ? config_.count : 1);
|
||||||
|
halfWidth_ = std::max(0.5f, (pitch_ - float(config_.gapLeds)) / 2.0f);
|
||||||
|
offsetLeds_ = config_.offsetDegrees / 360.0f * float(NUM_LEDS);
|
||||||
|
|
||||||
|
// The glide takes the short way round, so going from the last section to the first
|
||||||
|
// moves one step forward rather than all the way back.
|
||||||
|
int delta = int(config_.selected) - int(config_.previous);
|
||||||
|
if (delta > config_.count / 2)
|
||||||
|
delta -= config_.count;
|
||||||
|
else if (delta < -(config_.count / 2))
|
||||||
|
delta += config_.count;
|
||||||
|
glideFrom_ = float(int(config_.selected) - delta);
|
||||||
|
|
||||||
|
// The dimmed background never changes, so it is computed once.
|
||||||
|
for (int led = 0; led < NUM_LEDS; ++led)
|
||||||
|
{
|
||||||
|
const float u = ringPosition(led);
|
||||||
|
const int section = std::min(int(u / pitch_), int(config_.count) - 1);
|
||||||
|
ColorRGBW color{0, 0, 0, 0};
|
||||||
|
if (config_.count > 0)
|
||||||
|
{
|
||||||
|
const float center = (float(section) + 0.5f) * pitch_;
|
||||||
|
const float coverage = clamp01(halfWidth_ + 0.5f - std::fabs(u - center));
|
||||||
|
color = sectionColor(section, (u - center) / pitch_) * (coverage * config_.dimFactor);
|
||||||
|
}
|
||||||
|
background_[led] = color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int operator()()
|
||||||
|
{
|
||||||
|
if (config_.count == 0)
|
||||||
|
{
|
||||||
|
clear(ledStrip_);
|
||||||
|
return 10000;
|
||||||
|
}
|
||||||
|
|
||||||
|
const float glide = config_.slideMs > 0 ? clamp01(elapsedMs_ / config_.slideMs) : 1.0f;
|
||||||
|
const float eased = glide * glide * (3.0f - 2.0f * glide); // smoothstep
|
||||||
|
const float index = glideFrom_ + (float(config_.selected) - glideFrom_) * eased;
|
||||||
|
|
||||||
|
float wobble = 0;
|
||||||
|
if (config_.wobblePeriodMs > 0)
|
||||||
|
wobble = config_.wobbleAmplitude * std::sin(2.0f * float(M_PI) * elapsedMs_ / config_.wobblePeriodMs);
|
||||||
|
const float center = (index + 0.5f) * pitch_ + wobble;
|
||||||
|
|
||||||
|
for (int led = 0; led < NUM_LEDS; ++led)
|
||||||
|
{
|
||||||
|
const float distance = wrappedDistance(ringPosition(led) - center);
|
||||||
|
const float coverage = clamp01(halfWidth_ + 0.5f - std::fabs(distance));
|
||||||
|
if (coverage <= 0)
|
||||||
|
{
|
||||||
|
setLedRGBW(ledStrip_, led, background_[led]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// The highlight carries the selected section's own gradient along with it, and
|
||||||
|
// changes colour from the previous section's as it glides.
|
||||||
|
const float local = distance / pitch_;
|
||||||
|
const ColorRGBW from = sectionColor(config_.previous, local);
|
||||||
|
const ColorRGBW to = sectionColor(config_.selected, local);
|
||||||
|
const ColorRGBW highlight = ColorRGBW::interpolate(from, to, eased);
|
||||||
|
setLedRGBW(ledStrip_, led, ColorRGBW::interpolate(background_[led], highlight, coverage));
|
||||||
|
}
|
||||||
|
|
||||||
|
elapsedMs_ += DELAY_MS;
|
||||||
|
return DELAY_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
static float clamp01(float x) { return x < 0 ? 0 : (x > 1 ? 1 : x); }
|
||||||
|
|
||||||
|
// An LED's position along the sections, 0..NUM_LEDS, section 0 starting at 0.
|
||||||
|
float ringPosition(int led) const
|
||||||
|
{
|
||||||
|
float u = float(config_.direction) * (float(led) + 0.5f - offsetLeds_);
|
||||||
|
u = std::fmod(u, float(NUM_LEDS));
|
||||||
|
return u < 0 ? u + float(NUM_LEDS) : u;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shortest signed distance on the ring.
|
||||||
|
static float wrappedDistance(float d)
|
||||||
|
{
|
||||||
|
const float n = float(NUM_LEDS);
|
||||||
|
d = std::fmod(d, n);
|
||||||
|
if (d > n / 2)
|
||||||
|
d -= n;
|
||||||
|
else if (d < -n / 2)
|
||||||
|
d += n;
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Colour of a section at `local`, -0.5 (its start) .. +0.5 (its end): its palette
|
||||||
|
// colour in the middle, shifting in hue towards each neighbour by up to edgeBlend.
|
||||||
|
ColorRGBW sectionColor(int section, float local) const
|
||||||
|
{
|
||||||
|
const int count = config_.count;
|
||||||
|
const int neighbour = local < 0 ? (section + count - 1) % count : (section + 1) % count;
|
||||||
|
const float amount = config_.edgeBlend * clamp01(std::fabs(local) * 2.0f);
|
||||||
|
return blendHue(config_.colors[section], config_.colors[neighbour], amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
static ColorRGBW blendHue(const ColorRGBW &c1, const ColorRGBW &c2, float f)
|
||||||
|
{
|
||||||
|
if (f <= 0)
|
||||||
|
return c1;
|
||||||
|
ColorHSV h1 = rgb2hsv(c1);
|
||||||
|
ColorHSV h2 = rgb2hsv(c2);
|
||||||
|
// Greys have no hue of their own; borrow the other's so only saturation moves.
|
||||||
|
if (h1.s <= 0)
|
||||||
|
h1.h = h2.h;
|
||||||
|
if (h2.s <= 0)
|
||||||
|
h2.h = h1.h;
|
||||||
|
float dh = h2.h - h1.h;
|
||||||
|
if (dh > 180)
|
||||||
|
dh -= 360;
|
||||||
|
else if (dh < -180)
|
||||||
|
dh += 360;
|
||||||
|
float hue = h1.h + f * dh;
|
||||||
|
if (hue < 0)
|
||||||
|
hue += 360;
|
||||||
|
else if (hue >= 360)
|
||||||
|
hue -= 360;
|
||||||
|
ColorRGBW out = hsv2rgb(ColorHSV{hue, h1.s + f * (h2.s - h1.s), h1.v + f * (h2.v - h1.v)});
|
||||||
|
out.w = uint8_t(float(c1.w) + f * (float(c2.w) - float(c1.w)));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
EffectSegmentSelectConfig config_;
|
||||||
|
TLedStrip &ledStrip_;
|
||||||
|
float pitch_ = 0;
|
||||||
|
float halfWidth_ = 0;
|
||||||
|
float offsetLeds_ = 0;
|
||||||
|
float glideFrom_ = 0;
|
||||||
|
float elapsedMs_ = 0;
|
||||||
|
ColorRGBW background_[NUM_LEDS];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Traits
|
||||||
|
template <>
|
||||||
|
struct EffectIdToConfig<EffectId::SEGMENT_SELECT>
|
||||||
|
{
|
||||||
|
using type = EffectSegmentSelectConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
template <>
|
||||||
|
struct EffectConfigToId<EffectSegmentSelectConfig>
|
||||||
|
{
|
||||||
|
static constexpr auto id = EffectId::SEGMENT_SELECT;
|
||||||
|
};
|
||||||
|
|
||||||
|
template <typename TLedStrip>
|
||||||
|
struct EffectIdToClass<EffectId::SEGMENT_SELECT, TLedStrip>
|
||||||
|
{
|
||||||
|
using type = EffectSegmentSelect<TLedStrip>;
|
||||||
|
};
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
#include "effects/Common.h"
|
#include "effects/Common.h"
|
||||||
#include "helpers/ColorRGBW.h"
|
#include "helpers/ColorRGBW.h"
|
||||||
|
|
||||||
|
#pragma pack(push, 1)
|
||||||
struct EffectStaticConfig
|
struct EffectStaticConfig
|
||||||
{
|
{
|
||||||
EffectStaticConfig(const ColorRGBW &c = ColorRGBW{0, 0, 0, 0}, uint16_t beg = 0, uint16_t en = 0)
|
EffectStaticConfig(const ColorRGBW &c = ColorRGBW{0, 0, 0, 0}, uint16_t beg = 0, uint16_t en = 0)
|
||||||
@@ -12,12 +13,14 @@ struct EffectStaticConfig
|
|||||||
uint16_t begin = 0;
|
uint16_t begin = 0;
|
||||||
uint16_t end = 0;
|
uint16_t end = 0;
|
||||||
};
|
};
|
||||||
|
#pragma pack(pop)
|
||||||
|
|
||||||
template <typename TLedStrip>
|
template <typename TLedStrip>
|
||||||
class EffectStatic
|
class EffectStatic
|
||||||
{
|
{
|
||||||
public:
|
public:
|
||||||
static constexpr auto NUM_LEDS = numLeds<TLedStrip>();
|
static constexpr auto NUM_LEDS = numLeds<TLedStrip>();
|
||||||
|
using ConfigType = EffectStaticConfig;
|
||||||
|
|
||||||
EffectStatic(const EffectStaticConfig &cfg, TLedStrip &ledStrip)
|
EffectStatic(const EffectStaticConfig &cfg, TLedStrip &ledStrip)
|
||||||
: config_(cfg),
|
: config_(cfg),
|
||||||
107
esp-firmware/lib/ledtl/effects/StaticDetailed.h
Normal file
107
esp-firmware/lib/ledtl/effects/StaticDetailed.h
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "effects/Common.h"
|
||||||
|
#include "helpers/ColorRGBW.h"
|
||||||
|
#include "helpers/ColorConversions.h"
|
||||||
|
|
||||||
|
|
||||||
|
#pragma pack(push, 1)
|
||||||
|
struct EffectStaticDetailedConfig
|
||||||
|
{
|
||||||
|
EffectStaticDetailedConfig(const ColorRGBW &c = ColorRGBW{0, 0, 0, 0}, uint16_t beg = 0, uint16_t en = 0)
|
||||||
|
: color(c), begin(beg), end(en) {}
|
||||||
|
|
||||||
|
ColorRGBW color;
|
||||||
|
uint16_t increment = 1;
|
||||||
|
float begin = 0.0f;
|
||||||
|
float end = 0.0f;
|
||||||
|
float transition_time_in_ms = 0.0f;
|
||||||
|
};
|
||||||
|
#pragma pack(pop)
|
||||||
|
|
||||||
|
template <typename TLedStrip>
|
||||||
|
class EffectStaticDetailed
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
static constexpr auto NUM_LEDS = numLeds<TLedStrip>();
|
||||||
|
static constexpr int DELAY_MS = 10;
|
||||||
|
using ConfigType = EffectStaticDetailedConfig;
|
||||||
|
|
||||||
|
EffectStaticDetailed(const EffectStaticDetailedConfig &cfg, TLedStrip &ledStrip)
|
||||||
|
: config_(cfg),
|
||||||
|
ledStrip_(ledStrip)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < NUM_LEDS; ++i)
|
||||||
|
state_[i] = getLedRGBW(ledStrip_, i);
|
||||||
|
|
||||||
|
beginIdx_ = constrain(static_cast<int>(cfg.begin * NUM_LEDS + 0.5f), 0, NUM_LEDS - 1);
|
||||||
|
endIdx_ = constrain(static_cast<int>(cfg.end * NUM_LEDS + 0.5f), 0, NUM_LEDS - 1);
|
||||||
|
while (endIdx_ < beginIdx_)
|
||||||
|
endIdx_ += NUM_LEDS;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool finished() const { return finished_; }
|
||||||
|
|
||||||
|
int operator()()
|
||||||
|
{
|
||||||
|
if (finished_)
|
||||||
|
return 1000000;
|
||||||
|
|
||||||
|
const float progress = config_.transition_time_in_ms > 0.0f ? static_cast<float>(DELAY_MS * calls_) / config_.transition_time_in_ms : 1.f;
|
||||||
|
|
||||||
|
// Finished case
|
||||||
|
if (config_.transition_time_in_ms <= 0.0f || progress >= 1.0)
|
||||||
|
{
|
||||||
|
finished_ = true;
|
||||||
|
clear(ledStrip_);
|
||||||
|
for (int i = beginIdx_; i < endIdx_; i += config_.increment)
|
||||||
|
setLedRGBW(ledStrip_, i % NUM_LEDS, config_.color);
|
||||||
|
return 10000000;
|
||||||
|
}
|
||||||
|
|
||||||
|
// In-progress case
|
||||||
|
clear(ledStrip_);
|
||||||
|
for (int i = beginIdx_; i < endIdx_; i += config_.increment)
|
||||||
|
{
|
||||||
|
const auto idx = i % NUM_LEDS;
|
||||||
|
ColorRGBW newColor = ColorRGBW::interpolate(state_[idx], config_.color, progress);
|
||||||
|
setLedRGBW(ledStrip_, idx, newColor);
|
||||||
|
}
|
||||||
|
|
||||||
|
++calls_;
|
||||||
|
return DELAY_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
static int int_interpolate(int prev, int next, float progress)
|
||||||
|
{
|
||||||
|
return static_cast<float>(prev) * (1 - progress) +
|
||||||
|
static_cast<float>(next) * progress;
|
||||||
|
}
|
||||||
|
EffectStaticDetailedConfig config_;
|
||||||
|
TLedStrip &ledStrip_;
|
||||||
|
ColorRGBW state_[NUM_LEDS];
|
||||||
|
int beginIdx_;
|
||||||
|
int endIdx_;
|
||||||
|
int calls_ = 0;
|
||||||
|
bool finished_ = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Traits
|
||||||
|
template <>
|
||||||
|
struct EffectIdToConfig<EffectId::STATIC_DETAILED>
|
||||||
|
{
|
||||||
|
using type = EffectStaticDetailedConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
template <>
|
||||||
|
struct EffectConfigToId<EffectStaticDetailedConfig>
|
||||||
|
{
|
||||||
|
static constexpr auto id = EffectId::STATIC_DETAILED;
|
||||||
|
};
|
||||||
|
|
||||||
|
template <typename TLedStrip>
|
||||||
|
struct EffectIdToClass<EffectId::STATIC_DETAILED, TLedStrip>
|
||||||
|
{
|
||||||
|
using type = EffectStaticDetailed<TLedStrip>;
|
||||||
|
};
|
||||||
6
esp-firmware/lib/ledtl/effects/swipe.py
Normal file
6
esp-firmware/lib/ledtl/effects/swipe.py
Normal file
File diff suppressed because one or more lines are too long
26
esp-firmware/lib/ledtl/helpers/ColorRGBW.h
Normal file
26
esp-firmware/lib/ledtl/helpers/ColorRGBW.h
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
struct ColorRGBW
|
||||||
|
{
|
||||||
|
uint8_t r, g, b, w;
|
||||||
|
|
||||||
|
ColorRGBW operator*(float s) const
|
||||||
|
{
|
||||||
|
return {uint8_t(s * r),
|
||||||
|
uint8_t(s * g),
|
||||||
|
uint8_t(s * b),
|
||||||
|
uint8_t(s * w)};
|
||||||
|
}
|
||||||
|
|
||||||
|
static inline ColorRGBW interpolate(const ColorRGBW &c1, const ColorRGBW &c2, float f)
|
||||||
|
{
|
||||||
|
return ColorRGBW{
|
||||||
|
static_cast<uint8_t>((1.0f - f) * static_cast<float>(c1.r) + f * static_cast<float>(c2.r)),
|
||||||
|
static_cast<uint8_t>((1.0f - f) * static_cast<float>(c1.g) + f * static_cast<float>(c2.g)),
|
||||||
|
static_cast<uint8_t>((1.0f - f) * static_cast<float>(c1.b) + f * static_cast<float>(c2.b)),
|
||||||
|
static_cast<uint8_t>((1.0f - f) * static_cast<float>(c1.w) + f * static_cast<float>(c2.w)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -220,12 +220,12 @@ esp_err_t rotary_encoder_init(rotary_encoder_info_t * info, gpio_num_t pin_a, gp
|
|||||||
info->state.direction = ROTARY_ENCODER_DIRECTION_NOT_SET;
|
info->state.direction = ROTARY_ENCODER_DIRECTION_NOT_SET;
|
||||||
|
|
||||||
// configure GPIOs
|
// configure GPIOs
|
||||||
gpio_pad_select_gpio(info->pin_a);
|
gpio_reset_pin(info->pin_a);
|
||||||
gpio_set_pull_mode(info->pin_a, GPIO_PULLUP_ONLY);
|
gpio_set_pull_mode(info->pin_a, GPIO_PULLUP_ONLY);
|
||||||
gpio_set_direction(info->pin_a, GPIO_MODE_INPUT);
|
gpio_set_direction(info->pin_a, GPIO_MODE_INPUT);
|
||||||
gpio_set_intr_type(info->pin_a, GPIO_INTR_ANYEDGE);
|
gpio_set_intr_type(info->pin_a, GPIO_INTR_ANYEDGE);
|
||||||
|
|
||||||
gpio_pad_select_gpio(info->pin_b);
|
gpio_reset_pin(info->pin_b);
|
||||||
gpio_set_pull_mode(info->pin_b, GPIO_PULLUP_ONLY);
|
gpio_set_pull_mode(info->pin_b, GPIO_PULLUP_ONLY);
|
||||||
gpio_set_direction(info->pin_b, GPIO_MODE_INPUT);
|
gpio_set_direction(info->pin_b, GPIO_MODE_INPUT);
|
||||||
gpio_set_intr_type(info->pin_b, GPIO_INTR_ANYEDGE);
|
gpio_set_intr_type(info->pin_b, GPIO_INTR_ANYEDGE);
|
||||||
@@ -13,7 +13,9 @@ data_dir = data
|
|||||||
default_envs = esp32
|
default_envs = esp32
|
||||||
|
|
||||||
[env:esp32]
|
[env:esp32]
|
||||||
platform = espressif32
|
; pioarduino's fork rather than PlatformIO's own espressif32 platform, which stays on
|
||||||
|
; Arduino core 2.x. Pinned to a release: 55.03.312-1 is Arduino core 3.3.12 on ESP-IDF 5.5.
|
||||||
|
platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.312-1/platform-espressif32.zip
|
||||||
board = esp-wrover-kit
|
board = esp-wrover-kit
|
||||||
board_upload.flash_size = "4MB"
|
board_upload.flash_size = "4MB"
|
||||||
build_flags = -DPLATFORM_ESP32
|
build_flags = -DPLATFORM_ESP32
|
||||||
@@ -21,14 +23,13 @@ framework = arduino
|
|||||||
monitor_port = /dev/ttyUSB0
|
monitor_port = /dev/ttyUSB0
|
||||||
upload_port = /dev/ttyUSB0
|
upload_port = /dev/ttyUSB0
|
||||||
monitor_speed = 115200
|
monitor_speed = 115200
|
||||||
src_filter = +<*> -<host_test.cpp>
|
build_src_filter = +<*> -<host_test.cpp>
|
||||||
lib_deps =
|
lib_deps =
|
||||||
miguelbalboa/MFRC522
|
bxparks/AceButton@1.10.1
|
||||||
bxparks/AceButton@^1.9.1
|
|
||||||
|
|
||||||
[env:native]
|
[env:native]
|
||||||
platform = native
|
platform = native@1.2.1
|
||||||
src_filter = +<*> -<main.cpp>
|
build_src_filter = +<*> -<main.cpp>
|
||||||
build_flags = -Ilib/ledtl -g -DPLATFORM_NATIVE
|
build_flags = -Ilib/ledtl -g -DPLATFORM_NATIVE
|
||||||
lib_deps =
|
lib_deps =
|
||||||
bxparks/AceButton@^1.9.1
|
bxparks/AceButton@1.10.1
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
#include "effects/ReverseSwipe.h"
|
#include "effects/ReverseSwipe.h"
|
||||||
#include "effects/RandomTwoColorInterpolation.h"
|
#include "effects/RandomTwoColorInterpolation.h"
|
||||||
#include "effects/SwipeAndChange.h"
|
#include "effects/SwipeAndChange.h"
|
||||||
|
#include "effects/SegmentSelect.h"
|
||||||
|
|
||||||
#include "Arduino.h"
|
#include "Arduino.h"
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
@@ -104,14 +105,24 @@ enum class MessageHostToFw : uint8_t
|
|||||||
LED_WHEEL_EFFECT_RANDOM_TWO_COLOR_INTERPOLATION = 3,
|
LED_WHEEL_EFFECT_RANDOM_TWO_COLOR_INTERPOLATION = 3,
|
||||||
LED_WHEEL_EFFECT_SWIPE_AND_CHANGE = 4,
|
LED_WHEEL_EFFECT_SWIPE_AND_CHANGE = 4,
|
||||||
LED_WHEEL_EFFECT_REVERSE_SWIPE = 5,
|
LED_WHEEL_EFFECT_REVERSE_SWIPE = 5,
|
||||||
|
|
||||||
MOUSE_LED_EFFECT_STATIC = 6,
|
MOUSE_LED_EFFECT_STATIC = 6,
|
||||||
MOUSE_LED_EFFECT_CIRCULAR = 7,
|
MOUSE_LED_EFFECT_CIRCULAR = 7,
|
||||||
MOUSE_LED_EFFECT_RANDOM_TWO_COLOR_INTERPOLATION = 8,
|
MOUSE_LED_EFFECT_RANDOM_TWO_COLOR_INTERPOLATION = 8,
|
||||||
MOUSE_LED_EFFECT_SWIPE_AND_CHANGE = 9,
|
MOUSE_LED_EFFECT_SWIPE_AND_CHANGE = 9,
|
||||||
MOUSE_LED_EFFECT_REVERSE_SWIPE = 10,
|
MOUSE_LED_EFFECT_REVERSE_SWIPE = 10,
|
||||||
|
|
||||||
PREV_BUTTON_LED = 20,
|
SHELF_LED_EFFECT_STATIC = 15,
|
||||||
NEXT_BUTTON_LED = 21,
|
SHELF_LED_EFFECT_CIRCULAR = 16,
|
||||||
|
SHELF_LED_EFFECT_RANDOM_TWO_COLOR_INTERPOLATION = 17,
|
||||||
|
SHELF_LED_EFFECT_SWIPE_AND_CHANGE = 18,
|
||||||
|
SHELF_LED_EFFECT_REVERSE_SWIPE = 19,
|
||||||
|
SHELF_LED_EFFECT_STATIC_DETAILED = 20,
|
||||||
|
|
||||||
|
PREV_BUTTON_LED = 21,
|
||||||
|
NEXT_BUTTON_LED = 22,
|
||||||
|
|
||||||
|
LED_WHEEL_EFFECT_SEGMENT_SELECT = 23,
|
||||||
};
|
};
|
||||||
|
|
||||||
template <>
|
template <>
|
||||||
@@ -158,8 +169,21 @@ void sendMessageToHost(const TMessage &msg)
|
|||||||
Serial.write((uint8_t *)&msg, sizeof(msg));
|
Serial.write((uint8_t *)&msg, sizeof(msg));
|
||||||
}
|
}
|
||||||
|
|
||||||
template <typename LedTask1, typename LedTask2>
|
template <typename TEffectConfig, typename TLedTask>
|
||||||
inline void handleIncomingMessagesFromHost(LedTask1 *ledTaskCircle, LedTask2 *ledTaskMouse, uint8_t ledChannelLeft, uint8_t ledChannelRight)
|
inline bool handleLedEffect(TLedTask *ledTask, MessageHostToFw msgType, MessageHostToFw incomingMsgType, uint8_t *msgBuffer)
|
||||||
|
{
|
||||||
|
if (msgType == incomingMsgType)
|
||||||
|
{
|
||||||
|
auto cfg = reinterpret_cast<TEffectConfig *>(msgBuffer);
|
||||||
|
ledTask->startEffect(*cfg);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
template <typename LedTask1, typename LedTask2, typename LedTaskShelf>
|
||||||
|
inline void handleIncomingMessagesFromHost(LedTask1 *ledTaskCircle, LedTask2 *ledTaskMouse, LedTaskShelf *ledTaskShelf, uint8_t ledPinLeft, uint8_t ledPinRight)
|
||||||
{
|
{
|
||||||
if (Serial.available() < sizeof(MAGIC_TOKEN_FW_TO_HOST) + sizeof(MessageHostToFw) + sizeof(uint16_t))
|
if (Serial.available() < sizeof(MAGIC_TOKEN_FW_TO_HOST) + sizeof(MessageHostToFw) + sizeof(uint16_t))
|
||||||
return;
|
return;
|
||||||
@@ -180,74 +204,46 @@ inline void handleIncomingMessagesFromHost(LedTask1 *ledTaskCircle, LedTask2 *le
|
|||||||
|
|
||||||
static constexpr int maxIncomingBufferSize = 1024;
|
static constexpr int maxIncomingBufferSize = 1024;
|
||||||
static uint8_t msgBuffer[maxIncomingBufferSize];
|
static uint8_t msgBuffer[maxIncomingBufferSize];
|
||||||
|
|
||||||
if (msgSize < maxIncomingBufferSize)
|
if (msgSize < maxIncomingBufferSize)
|
||||||
{
|
{
|
||||||
Serial.readBytes(msgBuffer, msgSize);
|
Serial.readBytes(msgBuffer, msgSize);
|
||||||
if (msgType == MessageHostToFw::LED_WHEEL_EFFECT_STATIC)
|
|
||||||
{
|
// clang-format off
|
||||||
auto cfg = reinterpret_cast<EffectStaticConfig *>(msgBuffer);
|
// LED Circle
|
||||||
ledTaskCircle->startEffect(*cfg);
|
if(handleLedEffect<EffectStaticConfig >(ledTaskCircle, MessageHostToFw::LED_WHEEL_EFFECT_STATIC, msgType, msgBuffer)) {}
|
||||||
}
|
else if(handleLedEffect<EffectAlexaSwipeConfig >(ledTaskCircle, MessageHostToFw::LED_WHEEL_EFFECT_ALEXA_SWIPE, msgType, msgBuffer)) {}
|
||||||
else if (msgType == MessageHostToFw::LED_WHEEL_EFFECT_ALEXA_SWIPE)
|
else if(handleLedEffect<EffectCircularConfig >(ledTaskCircle, MessageHostToFw::LED_WHEEL_EFFECT_CIRCULAR, msgType, msgBuffer)) {}
|
||||||
{
|
else if(handleLedEffect<EffectRandomTwoColorInterpolationConfig>(ledTaskCircle, MessageHostToFw::LED_WHEEL_EFFECT_RANDOM_TWO_COLOR_INTERPOLATION, msgType, msgBuffer)) {}
|
||||||
auto cfg = reinterpret_cast<EffectAlexaSwipeConfig *>(msgBuffer);
|
else if(handleLedEffect<EffectSwipeAndChangeConfig >(ledTaskCircle, MessageHostToFw::LED_WHEEL_EFFECT_SWIPE_AND_CHANGE, msgType, msgBuffer)) {}
|
||||||
ledTaskCircle->startEffect(*cfg);
|
else if(handleLedEffect<EffectReverseSwipeConfig >(ledTaskCircle, MessageHostToFw::LED_WHEEL_EFFECT_REVERSE_SWIPE, msgType, msgBuffer)) {}
|
||||||
}
|
else if(handleLedEffect<EffectSegmentSelectConfig >(ledTaskCircle, MessageHostToFw::LED_WHEEL_EFFECT_SEGMENT_SELECT, msgType, msgBuffer)) {}
|
||||||
else if (msgType == MessageHostToFw::LED_WHEEL_EFFECT_CIRCULAR)
|
|
||||||
{
|
// Mouse LEDs
|
||||||
auto cfg = reinterpret_cast<EffectCircularConfig *>(msgBuffer);
|
else if(handleLedEffect<EffectStaticConfig >(ledTaskMouse, MessageHostToFw::MOUSE_LED_EFFECT_STATIC, msgType, msgBuffer)) {}
|
||||||
ledTaskCircle->startEffect(*cfg);
|
else if(handleLedEffect<EffectCircularConfig >(ledTaskMouse, MessageHostToFw::MOUSE_LED_EFFECT_CIRCULAR, msgType, msgBuffer)) {}
|
||||||
}
|
else if(handleLedEffect<EffectRandomTwoColorInterpolationConfig>(ledTaskMouse, MessageHostToFw::MOUSE_LED_EFFECT_RANDOM_TWO_COLOR_INTERPOLATION, msgType, msgBuffer)) {}
|
||||||
else if (msgType == MessageHostToFw::LED_WHEEL_EFFECT_RANDOM_TWO_COLOR_INTERPOLATION)
|
else if(handleLedEffect<EffectSwipeAndChangeConfig >(ledTaskMouse, MessageHostToFw::MOUSE_LED_EFFECT_SWIPE_AND_CHANGE, msgType, msgBuffer)) {}
|
||||||
{
|
else if(handleLedEffect<EffectReverseSwipeConfig >(ledTaskMouse, MessageHostToFw::MOUSE_LED_EFFECT_REVERSE_SWIPE, msgType, msgBuffer)) {}
|
||||||
auto cfg = reinterpret_cast<EffectRandomTwoColorInterpolationConfig *>(msgBuffer);
|
|
||||||
ledTaskCircle->startEffect(*cfg);
|
// Shelf LEDs
|
||||||
}
|
else if (handleLedEffect<EffectStaticConfig >(ledTaskShelf, MessageHostToFw::SHELF_LED_EFFECT_STATIC, msgType, msgBuffer)) {}
|
||||||
else if (msgType == MessageHostToFw::LED_WHEEL_EFFECT_SWIPE_AND_CHANGE)
|
else if (handleLedEffect<EffectCircularConfig >(ledTaskShelf, MessageHostToFw::SHELF_LED_EFFECT_CIRCULAR, msgType, msgBuffer)) {}
|
||||||
{
|
else if (handleLedEffect<EffectRandomTwoColorInterpolationConfig>(ledTaskShelf, MessageHostToFw::SHELF_LED_EFFECT_RANDOM_TWO_COLOR_INTERPOLATION, msgType, msgBuffer)) {}
|
||||||
auto cfg = reinterpret_cast<EffectSwipeAndChangeConfig *>(msgBuffer);
|
else if (handleLedEffect<EffectSwipeAndChangeConfig >(ledTaskShelf, MessageHostToFw::SHELF_LED_EFFECT_SWIPE_AND_CHANGE, msgType, msgBuffer)) {}
|
||||||
ledTaskCircle->startEffect(*cfg);
|
else if (handleLedEffect<EffectReverseSwipeConfig >(ledTaskShelf, MessageHostToFw::SHELF_LED_EFFECT_REVERSE_SWIPE, msgType, msgBuffer)) {}
|
||||||
}
|
else if (handleLedEffect<EffectStaticDetailedConfig >(ledTaskShelf, MessageHostToFw::SHELF_LED_EFFECT_STATIC_DETAILED, msgType, msgBuffer)) {}
|
||||||
else if (msgType == MessageHostToFw::LED_WHEEL_EFFECT_REVERSE_SWIPE)
|
// clang-format on
|
||||||
{
|
|
||||||
auto cfg = reinterpret_cast<EffectReverseSwipeConfig *>(msgBuffer);
|
|
||||||
ledTaskCircle->startEffect(*cfg);
|
|
||||||
}
|
|
||||||
//
|
|
||||||
else if (msgType == MessageHostToFw::MOUSE_LED_EFFECT_STATIC)
|
|
||||||
{
|
|
||||||
auto cfg = reinterpret_cast<EffectStaticConfig *>(msgBuffer);
|
|
||||||
ledTaskMouse->startEffect(*cfg);
|
|
||||||
}
|
|
||||||
else if (msgType == MessageHostToFw::MOUSE_LED_EFFECT_CIRCULAR)
|
|
||||||
{
|
|
||||||
auto cfg = reinterpret_cast<EffectCircularConfig *>(msgBuffer);
|
|
||||||
ledTaskMouse->startEffect(*cfg);
|
|
||||||
}
|
|
||||||
else if (msgType == MessageHostToFw::MOUSE_LED_EFFECT_RANDOM_TWO_COLOR_INTERPOLATION)
|
|
||||||
{
|
|
||||||
auto cfg = reinterpret_cast<EffectRandomTwoColorInterpolationConfig *>(msgBuffer);
|
|
||||||
ledTaskMouse->startEffect(*cfg);
|
|
||||||
}
|
|
||||||
else if (msgType == MessageHostToFw::MOUSE_LED_EFFECT_SWIPE_AND_CHANGE)
|
|
||||||
{
|
|
||||||
auto cfg = reinterpret_cast<EffectSwipeAndChangeConfig *>(msgBuffer);
|
|
||||||
ledTaskMouse->startEffect(*cfg);
|
|
||||||
}
|
|
||||||
else if (msgType == MessageHostToFw::MOUSE_LED_EFFECT_REVERSE_SWIPE)
|
|
||||||
{
|
|
||||||
auto cfg = reinterpret_cast<EffectReverseSwipeConfig *>(msgBuffer);
|
|
||||||
ledTaskMouse->startEffect(*cfg);
|
|
||||||
}
|
|
||||||
else if (msgType == MessageHostToFw::PREV_BUTTON_LED)
|
else if (msgType == MessageHostToFw::PREV_BUTTON_LED)
|
||||||
{
|
{
|
||||||
float *val = reinterpret_cast<float *>(msgBuffer);
|
float *val = reinterpret_cast<float *>(msgBuffer);
|
||||||
ledcWrite(ledChannelLeft, uint32_t(255 * (*val)));
|
ledcWrite(ledPinLeft, uint32_t(255 * (*val)));
|
||||||
}
|
}
|
||||||
else if (msgType == MessageHostToFw::NEXT_BUTTON_LED)
|
else if (msgType == MessageHostToFw::NEXT_BUTTON_LED)
|
||||||
{
|
{
|
||||||
float *val = reinterpret_cast<float *>(msgBuffer);
|
float *val = reinterpret_cast<float *>(msgBuffer);
|
||||||
ledcWrite(ledChannelRight, uint32_t(255 * (*val)));
|
ledcWrite(ledPinRight, uint32_t(255 * (*val)));
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
Serial.println("Unknown message type");
|
Serial.println("Unknown message type");
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
#include "effects/Circular.h"
|
#include "effects/Circular.h"
|
||||||
#include "effects/Common.h"
|
#include "effects/Common.h"
|
||||||
|
#include "effects/SegmentSelect.h"
|
||||||
#include "drivers/Esp32DriverRGBW.h"
|
#include "drivers/Esp32DriverRGBW.h"
|
||||||
|
|
||||||
#include "freertos/FreeRTOS.h"
|
#include "freertos/FreeRTOS.h"
|
||||||
@@ -77,10 +78,12 @@ void _led_task_func(void *params)
|
|||||||
// clang-format off
|
// clang-format off
|
||||||
if (dispatchEffectId<EffectId::CIRCULAR >(id, effectFunction, ledStrip, msgBuffer, effectStorage)) {}
|
if (dispatchEffectId<EffectId::CIRCULAR >(id, effectFunction, ledStrip, msgBuffer, effectStorage)) {}
|
||||||
else if (dispatchEffectId<EffectId::STATIC >(id, effectFunction, ledStrip, msgBuffer, effectStorage)) {}
|
else if (dispatchEffectId<EffectId::STATIC >(id, effectFunction, ledStrip, msgBuffer, effectStorage)) {}
|
||||||
|
else if (dispatchEffectId<EffectId::STATIC_DETAILED >(id, effectFunction, ledStrip, msgBuffer, effectStorage)) {}
|
||||||
else if (dispatchEffectId<EffectId::ALEXA_SWIPE >(id, effectFunction, ledStrip, msgBuffer, effectStorage)) {}
|
else if (dispatchEffectId<EffectId::ALEXA_SWIPE >(id, effectFunction, ledStrip, msgBuffer, effectStorage)) {}
|
||||||
else if (dispatchEffectId<EffectId::RANDOM_TWO_COLOR_INTERPOLATION>(id, effectFunction, ledStrip, msgBuffer, effectStorage)) {}
|
else if (dispatchEffectId<EffectId::RANDOM_TWO_COLOR_INTERPOLATION>(id, effectFunction, ledStrip, msgBuffer, effectStorage)) {}
|
||||||
else if (dispatchEffectId<EffectId::SWIPE_AND_CHANGE >(id, effectFunction, ledStrip, msgBuffer, effectStorage)) {}
|
else if (dispatchEffectId<EffectId::SWIPE_AND_CHANGE >(id, effectFunction, ledStrip, msgBuffer, effectStorage)) {}
|
||||||
else if (dispatchEffectId<EffectId::REVERSE_SWIPE >(id, effectFunction, ledStrip, msgBuffer, effectStorage)) {}
|
else if (dispatchEffectId<EffectId::REVERSE_SWIPE >(id, effectFunction, ledStrip, msgBuffer, effectStorage)) {}
|
||||||
|
else if (dispatchEffectId<EffectId::SEGMENT_SELECT >(id, effectFunction, ledStrip, msgBuffer, effectStorage)) {}
|
||||||
// clang-format on
|
// clang-format on
|
||||||
|
|
||||||
timeoutMsForEffect = 0;
|
timeoutMsForEffect = 0;
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
#include "effects/AlexaSwipe.h"
|
#include "effects/AlexaSwipe.h"
|
||||||
#include "effects/ReverseSwipe.h"
|
#include "effects/ReverseSwipe.h"
|
||||||
|
#include "effects/SegmentSelect.h"
|
||||||
#include "helpers/ColorConversions.h"
|
#include "helpers/ColorConversions.h"
|
||||||
|
|
||||||
#include <iostream>
|
#include <iostream>
|
||||||
@@ -100,6 +101,29 @@ int main(int argc, char **argv)
|
|||||||
effectToFile("reverse_swipe.py", effect, strip, 200);
|
effectToFile("reverse_swipe.py", effect, strip, 200);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
EffectSegmentSelectConfig cfg{};
|
||||||
|
cfg.count = 4;
|
||||||
|
cfg.selected = 0;
|
||||||
|
cfg.previous = 3;
|
||||||
|
cfg.gapLeds = 1;
|
||||||
|
cfg.colors[0] = ColorRGBW{255, 0, 0, 0};
|
||||||
|
cfg.colors[1] = ColorRGBW{255, 160, 0, 0};
|
||||||
|
cfg.colors[2] = ColorRGBW{0, 200, 0, 0};
|
||||||
|
cfg.colors[3] = ColorRGBW{0, 60, 255, 0};
|
||||||
|
cfg.offsetDegrees = 180;
|
||||||
|
cfg.direction = 1;
|
||||||
|
cfg.dimFactor = 0.15f;
|
||||||
|
cfg.wobbleAmplitude = 1.5f;
|
||||||
|
cfg.wobblePeriodMs = 1000;
|
||||||
|
cfg.slideMs = 200;
|
||||||
|
cfg.edgeBlend = 0.25f;
|
||||||
|
LedStripRGBW<51> strip;
|
||||||
|
|
||||||
|
EffectSegmentSelect<decltype(strip)> effect(cfg, strip);
|
||||||
|
effectToFile("segment_select.py", effect, strip, 200);
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
effect.currentPosition_ = 150;
|
effect.currentPosition_ = 150;
|
||||||
|
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
#include <Arduino.h>
|
#include <Arduino.h>
|
||||||
#include "rc522.h"
|
#include "rc522.h"
|
||||||
#include "SPI.h"
|
#include "SPI.h"
|
||||||
#include <MFRC522.h>
|
|
||||||
#include "rotary_encoder.h"
|
#include "rotary_encoder.h"
|
||||||
|
|
||||||
#include "containers/LedStripRGBW.h"
|
#include "containers/LedStripRGBW.h"
|
||||||
#include "drivers/Esp32DriverRGBW.h"
|
#include "drivers/Esp32DriverRGBW.h"
|
||||||
#include "effects/Circular.h"
|
#include "effects/Circular.h"
|
||||||
#include "effects/Static.h"
|
#include "effects/Static.h"
|
||||||
|
#include "effects/StaticDetailed.h"
|
||||||
#include "effects/AlexaSwipe.h"
|
#include "effects/AlexaSwipe.h"
|
||||||
#include "effects/RandomTwoColorInterpolation.h"
|
#include "effects/RandomTwoColorInterpolation.h"
|
||||||
|
|
||||||
@@ -20,9 +20,6 @@
|
|||||||
#include "TaskLed.h"
|
#include "TaskLed.h"
|
||||||
|
|
||||||
// -------------------------------------------------- RFID Reader ----------------------------------------
|
// -------------------------------------------------- RFID Reader ----------------------------------------
|
||||||
MFRC522 rfid;
|
|
||||||
MFRC522::MIFARE_Key key;
|
|
||||||
|
|
||||||
void tagHandler(uint8_t *sn)
|
void tagHandler(uint8_t *sn)
|
||||||
{
|
{
|
||||||
if (sn != nullptr)
|
if (sn != nullptr)
|
||||||
@@ -116,11 +113,9 @@ void setupButtons()
|
|||||||
buttonRight.setEventHandler(handleButtonEvent);
|
buttonRight.setEventHandler(handleButtonEvent);
|
||||||
buttonRotary.setEventHandler(handleButtonEvent);
|
buttonRotary.setEventHandler(handleButtonEvent);
|
||||||
|
|
||||||
ledcSetup(0, PWM_FREQ, PWM_RESOLUTION);
|
// Arduino core 3 picks the LEDC channel itself; from here on the pin is the handle.
|
||||||
ledcAttachPin(BUTTON_LEFT_LED_PIN, 0);
|
ledcAttach(BUTTON_LEFT_LED_PIN, PWM_FREQ, PWM_RESOLUTION);
|
||||||
|
ledcAttach(BUTTON_RIGHT_LED_PIN, PWM_FREQ, PWM_RESOLUTION);
|
||||||
ledcSetup(1, PWM_FREQ, PWM_RESOLUTION);
|
|
||||||
ledcAttachPin(BUTTON_RIGHT_LED_PIN, 1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void handleButtons()
|
void handleButtons()
|
||||||
@@ -138,7 +133,7 @@ LedTask<decltype(ledStripCircle)> ledTaskCircle;
|
|||||||
|
|
||||||
void setupLedCircle()
|
void setupLedCircle()
|
||||||
{
|
{
|
||||||
ledDriverCircle.begin(22, 0);
|
ledDriverCircle.begin(22);
|
||||||
ledTaskCircle.begin(ledStripCircle, ledDriverCircle);
|
ledTaskCircle.begin(ledStripCircle, ledDriverCircle);
|
||||||
ledTaskCircle.startEffect(EffectStaticConfig(ColorRGBW{0, 0, 0, 0}));
|
ledTaskCircle.startEffect(EffectStaticConfig(ColorRGBW{0, 0, 0, 0}));
|
||||||
}
|
}
|
||||||
@@ -151,22 +146,22 @@ LedTask<decltype(ledStripMouse)> ledTaskMouse;
|
|||||||
|
|
||||||
void setupMouseLeds()
|
void setupMouseLeds()
|
||||||
{
|
{
|
||||||
ledDriverMouse.begin(16, 1);
|
ledDriverMouse.begin(16);
|
||||||
ledTaskMouse.begin(ledStripMouse, ledDriverMouse);
|
ledTaskMouse.begin(ledStripMouse, ledDriverMouse);
|
||||||
ledTaskMouse.startEffect(EffectStaticConfig{ColorRGBW{0, 0, 0, 0}, 0, 0});
|
ledTaskMouse.startEffect(EffectStaticConfig{ColorRGBW{0, 0, 0, 0}, 0, 0});
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------- Shelf Leds -------------------------------------------
|
// -------------------------------------------------- Shelf Leds -------------------------------------------
|
||||||
|
|
||||||
LedStripRGBW<250> ledStripShelf;
|
LedStripRGBW<252> ledStripShelf;
|
||||||
Esp32DriverRGBW ledDriverShelf;
|
Esp32DriverRGBW ledDriverShelf;
|
||||||
LedTask<decltype(ledStripShelf)> ledTaskShelf;
|
LedTask<decltype(ledStripShelf)> ledTaskShelf;
|
||||||
|
|
||||||
void setupShelfLeds()
|
void setupShelfLeds()
|
||||||
{
|
{
|
||||||
ledDriverShelf.begin(17, 2);
|
ledDriverShelf.begin(17);
|
||||||
ledTaskShelf.begin(ledStripShelf, ledDriverShelf);
|
ledTaskShelf.begin(ledStripShelf, ledDriverShelf);
|
||||||
ledTaskShelf.startEffect(EffectStaticConfig{ColorRGBW{0, 0, 30}, 0, 0});
|
ledTaskShelf.startEffect(EffectStaticConfig{ColorRGBW{0, 0, 0, 0}, 0, 0});
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------- Touch Buttons ----------------------------------------
|
// -------------------------------------------------- Touch Buttons ----------------------------------------
|
||||||
@@ -233,7 +228,7 @@ void setup()
|
|||||||
|
|
||||||
void loop()
|
void loop()
|
||||||
{
|
{
|
||||||
handleIncomingMessagesFromHost(&ledTaskCircle, &ledTaskMouse, 0, 1);
|
handleIncomingMessagesFromHost(&ledTaskCircle, &ledTaskMouse, &ledTaskShelf, BUTTON_LEFT_LED_PIN, BUTTON_RIGHT_LED_PIN);
|
||||||
handleTouchInputs();
|
handleTouchInputs();
|
||||||
handleRotaryEncoder();
|
handleRotaryEncoder();
|
||||||
handleButtons();
|
handleButtons();
|
||||||
40
esp-firmware/todo.md
Normal file
40
esp-firmware/todo.md
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
|
||||||
|
- button hintergrund beleuchtung [ok]
|
||||||
|
|
||||||
|
- playlisten
|
||||||
|
- runterladen
|
||||||
|
|
||||||
|
- befestigung im regal
|
||||||
|
- winkel
|
||||||
|
- mehrfachsteckdose
|
||||||
|
- lan kabel
|
||||||
|
|
||||||
|
- effekt kanal fuer audioeffekte
|
||||||
|
- "boing" etc runterladen
|
||||||
|
|
||||||
|
- Fernbedienung wenn empfaenger da
|
||||||
|
- HA regeln fuer standard
|
||||||
|
|
||||||
|
|
||||||
|
- ansible cleanup
|
||||||
|
- lirc
|
||||||
|
- musicmouse kanal
|
||||||
|
- musicmouse effect kanal
|
||||||
|
|
||||||
|
- home assistant anbindung
|
||||||
|
- events an HA (figur, button press, ...)
|
||||||
|
- mouse & ring leds von HA
|
||||||
|
- HA device control (led fluter, rollos)
|
||||||
|
- regal licht von HA aus
|
||||||
|
|
||||||
|
- Regal LEDs
|
||||||
|
- kabel von musikmaus
|
||||||
|
- Leisten zuschneiden
|
||||||
|
- kabel auf richtige laenge zuschneiden
|
||||||
|
- Kabel loeten
|
||||||
|
- im Arbeitszimmer testen
|
||||||
|
- Bonus: Ecken drucken
|
||||||
|
|
||||||
|
- Effekte Regal LEDs
|
||||||
|
|
||||||
|
- Musik-abhaengige Effekte
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
|
|
||||||
Reader
|
|
||||||
----------
|
|
||||||
|
|
||||||
- GND black
|
|
||||||
- RST blue 3.3V
|
|
||||||
- 3.3V red
|
|
||||||
- MISO brown 21
|
|
||||||
- SDA green 19
|
|
||||||
- SCK yellow 18
|
|
||||||
- MOSI orange 5
|
|
||||||
- IRQ green single cable not connected
|
|
||||||
|
|
||||||
|
|
||||||
Button Board:
|
|
||||||
-------------
|
|
||||||
|
|
||||||
- rot in | white 13
|
|
||||||
- btn2 led | grey 12
|
|
||||||
- btn2 in | purple 14
|
|
||||||
- rotB | blue 27
|
|
||||||
- rotA | green 26
|
|
||||||
- btn1 in | yellow 25
|
|
||||||
- btn1 led | orange 33
|
|
||||||
|
|
||||||
rot="rotary encoder"
|
|
||||||
in=button sense in
|
|
||||||
led = 5V pwm
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Firmware Planning
|
|
||||||
-----------------
|
|
||||||
|
|
||||||
- input commands:
|
|
||||||
- led: effect + parameters
|
|
||||||
- off
|
|
||||||
- single color
|
|
||||||
- multiple color HSV fade, list of colors with timings
|
|
||||||
- circular motion (already exists)
|
|
||||||
- chained events? e.g. circle two times then fade
|
|
||||||
- effects:
|
|
||||||
- welle fuer an und aus
|
|
||||||
- breathe waehrend an, oder farbgradient
|
|
||||||
-
|
|
||||||
- output infos:
|
|
||||||
- nfc read: with id
|
|
||||||
- nfc remove
|
|
||||||
- button presses, (possible also long press, double click, etc)
|
|
||||||
- rotary encoder up down + current numeric state
|
|
||||||
- on led effect end?
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
TODO
|
|
||||||
----
|
|
||||||
|
|
||||||
1) case redesign
|
|
||||||
- slightly smaller led ring (10mm -> 9mm) [ok]
|
|
||||||
- thicker top of inner ring, but cutouts for reader [ok]
|
|
||||||
- adjust reader stands position [ok]
|
|
||||||
- bottom for led ring snap-in [ok]
|
|
||||||
- bottom for inner ring [ok]
|
|
||||||
- stands for own "pcb" [ok]
|
|
||||||
- 2 cutouts for cables [ok]
|
|
||||||
- checks, compared to existing print
|
|
||||||
- same diameter, very slightly smaller
|
|
||||||
- larger overlap of LED ring
|
|
||||||
- minimal wall thickness for led ring top and side
|
|
||||||
- check total height - compare to existing
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
from typing import overload
|
|
||||||
import librosa
|
|
||||||
from numba import jit
|
|
||||||
import numpy as np
|
|
||||||
|
|
||||||
|
|
||||||
@jit(nopython=True)
|
|
||||||
def normalize_feature_sequence(X, norm='2', threshold=0.0001, v=None):
|
|
||||||
"""Normalizes the columns of a feature sequence
|
|
||||||
Notebook: C3/C3S1_FeatureNormalization.ipynb
|
|
||||||
Args:
|
|
||||||
X (np.ndarray): Feature sequence
|
|
||||||
norm (str): The norm to be applied. '1', '2', 'max' or 'z' (Default value = '2')
|
|
||||||
threshold (float): An threshold below which the vector ``v`` used instead of normalization
|
|
||||||
(Default value = 0.0001)
|
|
||||||
v (float): Used instead of normalization below ``threshold``. If None, uses unit vector for given norm
|
|
||||||
(Default value = None)
|
|
||||||
Returns:
|
|
||||||
X_norm (np.ndarray): Normalized feature sequence
|
|
||||||
"""
|
|
||||||
assert norm in ['1', '2', 'max', 'z']
|
|
||||||
|
|
||||||
K, N = X.shape
|
|
||||||
X_norm = np.zeros((K, N))
|
|
||||||
|
|
||||||
if norm == '1':
|
|
||||||
if v is None:
|
|
||||||
v = np.ones(K, dtype=np.float64) / K
|
|
||||||
for n in range(N):
|
|
||||||
s = np.sum(np.abs(X[:, n]))
|
|
||||||
if s > threshold:
|
|
||||||
X_norm[:, n] = X[:, n] / s
|
|
||||||
else:
|
|
||||||
X_norm[:, n] = v
|
|
||||||
|
|
||||||
if norm == '2':
|
|
||||||
if v is None:
|
|
||||||
v = np.ones(K, dtype=np.float64) / np.sqrt(K)
|
|
||||||
for n in range(N):
|
|
||||||
s = np.sqrt(np.sum(X[:, n]**2))
|
|
||||||
if s > threshold:
|
|
||||||
X_norm[:, n] = X[:, n] / s
|
|
||||||
else:
|
|
||||||
X_norm[:, n] = v
|
|
||||||
|
|
||||||
if norm == 'max':
|
|
||||||
if v is None:
|
|
||||||
v = np.ones(K, dtype=np.float64)
|
|
||||||
for n in range(N):
|
|
||||||
s = np.max(np.abs(X[:, n]))
|
|
||||||
if s > threshold:
|
|
||||||
X_norm[:, n] = X[:, n] / s
|
|
||||||
else:
|
|
||||||
X_norm[:, n] = v
|
|
||||||
|
|
||||||
if norm == 'z':
|
|
||||||
if v is None:
|
|
||||||
v = np.zeros(K, dtype=np.float64)
|
|
||||||
for n in range(N):
|
|
||||||
mu = np.sum(X[:, n]) / K
|
|
||||||
sigma = np.sqrt(np.sum((X[:, n] - mu)**2) / (K - 1))
|
|
||||||
if sigma > threshold:
|
|
||||||
X_norm[:, n] = (X[:, n] - mu) / sigma
|
|
||||||
else:
|
|
||||||
X_norm[:, n] = v
|
|
||||||
|
|
||||||
return X_norm
|
|
||||||
|
|
||||||
|
|
||||||
def compute_chromagram_from_filename(fn_wav,
|
|
||||||
Fs=22050,
|
|
||||||
N=4096,
|
|
||||||
H=2048,
|
|
||||||
gamma=None,
|
|
||||||
version='STFT',
|
|
||||||
norm='2'):
|
|
||||||
"""Compute chromagram for WAV file specified by filename
|
|
||||||
|
|
||||||
Notebook: C5/C5S2_ChordRec_Templates.ipynb
|
|
||||||
|
|
||||||
Args:
|
|
||||||
fn_wav (str): Filenname of WAV
|
|
||||||
Fs (scalar): Sampling rate (Default value = 22050)
|
|
||||||
N (int): Window size (Default value = 4096)
|
|
||||||
H (int): Hop size (Default value = 2048)
|
|
||||||
gamma (float): Constant for logarithmic compression (Default value = None)
|
|
||||||
version (str): Technique used for front-end decomposition ('STFT', 'IIS', 'CQT') (Default value = 'STFT')
|
|
||||||
norm (str): If not 'None', chroma vectors are normalized by norm as specified ('1', '2', 'max')
|
|
||||||
(Default value = '2')
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
X (np.ndarray): Chromagram
|
|
||||||
Fs_X (scalar): Feature reate of chromagram
|
|
||||||
x (np.ndarray): Audio signal
|
|
||||||
Fs (scalar): Sampling rate of audio signal
|
|
||||||
x_dur (float): Duration (seconds) of audio signal
|
|
||||||
"""
|
|
||||||
x, Fs = librosa.load(fn_wav, sr=Fs)
|
|
||||||
x_dur = x.shape[0] / Fs
|
|
||||||
if version == 'STFT':
|
|
||||||
# Compute chroma features with STFT
|
|
||||||
X = librosa.stft(x, n_fft=N, hop_length=H, pad_mode='constant', center=True)
|
|
||||||
if gamma is not None:
|
|
||||||
X = np.log(1 + gamma * np.abs(X)**2)
|
|
||||||
else:
|
|
||||||
X = np.abs(X)**2
|
|
||||||
X = librosa.feature.chroma_stft(S=X, sr=Fs, tuning=0, norm=None, hop_length=H, n_fft=N)
|
|
||||||
if version == 'CQT':
|
|
||||||
# Compute chroma features with CQT decomposition
|
|
||||||
X = librosa.feature.chroma_cqt(y=x, sr=Fs, hop_length=H, norm=None)
|
|
||||||
if version == 'IIR':
|
|
||||||
# Compute chroma features with filter bank (using IIR elliptic filter)
|
|
||||||
X = librosa.iirt(y=x, sr=Fs, win_length=N, hop_length=H, center=True, tuning=0.0)
|
|
||||||
if gamma is not None:
|
|
||||||
X = np.log(1.0 + gamma * X)
|
|
||||||
X = librosa.feature.chroma_cqt(C=X,
|
|
||||||
bins_per_octave=12,
|
|
||||||
n_octaves=7,
|
|
||||||
fmin=librosa.midi_to_hz(24),
|
|
||||||
norm=None)
|
|
||||||
if norm is not None:
|
|
||||||
X = normalize_feature_sequence(X, norm='2')
|
|
||||||
Fs_X = Fs / H
|
|
||||||
return X, Fs_X, x, Fs, x_dur
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
test_file = "/home/martin/Music/deemix Music/Simone Sommerland - Ki-Ka-Kinderturnen.mp3"
|
|
||||||
chroma = compute_chromagram_from_filename(test_file)
|
|
||||||
@@ -1,186 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
from enum import Enum
|
|
||||||
import struct
|
|
||||||
|
|
||||||
from led_cmds import *
|
|
||||||
|
|
||||||
MAGIC_TOKEN_HOST_TO_FW = 0x1d6379e3
|
|
||||||
MAGIC_TOKEN_FW_TO_HOST = 0x10c65631
|
|
||||||
|
|
||||||
|
|
||||||
class MessageFwToHost(Enum):
|
|
||||||
RFID_TOKEN_READ = 0
|
|
||||||
ROTARY_ENCODER = 1
|
|
||||||
TOUCH_BUTTON_PRESS = 2
|
|
||||||
TOUCH_BUTTON_RELEASE = 3
|
|
||||||
|
|
||||||
|
|
||||||
class TouchButton(Enum):
|
|
||||||
LEFT_FOOT = 0
|
|
||||||
RIGHT_FOOT = 1
|
|
||||||
LEFT_EAR = 2
|
|
||||||
RIGHT_EAR = 3
|
|
||||||
|
|
||||||
|
|
||||||
led_ring_effect_to_message_id = {
|
|
||||||
EffectStaticConfig: 0,
|
|
||||||
EffectAlexaSwipeConfig: 1,
|
|
||||||
EffectCircularConfig: 2,
|
|
||||||
EffectRandomTwoColorInterpolationConfig: 3,
|
|
||||||
EffectSwipeAndChange: 4,
|
|
||||||
EffectReverseSwipe: 5,
|
|
||||||
}
|
|
||||||
|
|
||||||
mouse_led_effect_to_message_id = {
|
|
||||||
EffectStaticConfig: 6,
|
|
||||||
EffectCircularConfig: 7,
|
|
||||||
EffectRandomTwoColorInterpolationConfig: 8,
|
|
||||||
EffectSwipeAndChange: 9,
|
|
||||||
EffectReverseSwipe: 10,
|
|
||||||
}
|
|
||||||
|
|
||||||
mouse_leds_index_ranges = {
|
|
||||||
TouchButton.RIGHT_FOOT: (0, 6),
|
|
||||||
TouchButton.LEFT_FOOT: (6, 6 + 6),
|
|
||||||
TouchButton.LEFT_EAR: (6 + 6, 6 + 6 + 16),
|
|
||||||
TouchButton.RIGHT_EAR: (6 + 6 + 16, 6 + 6 + 16 + 17),
|
|
||||||
}
|
|
||||||
|
|
||||||
PREV_BUTTON_LED_MSG = 20
|
|
||||||
NEXT_BUTTON_LED_MSG = 21
|
|
||||||
|
|
||||||
|
|
||||||
class RfidTokenRead:
|
|
||||||
def __init__(self, id: bytes):
|
|
||||||
self.id = id
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
return "RFID Token (" + " ".join(f"{v:02x}" for v in self.id) + ")"
|
|
||||||
|
|
||||||
|
|
||||||
class RotaryEncoderEvent:
|
|
||||||
def __init__(self, msg_content: bytes):
|
|
||||||
self.position, self.increment, self.direction = struct.unpack("<iiB", msg_content)
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
return f"Rotary event: pos {self.position}, incr {self.increment}, dir {self.direction}"
|
|
||||||
|
|
||||||
|
|
||||||
class TouchButtonPress:
|
|
||||||
def __init__(self, msg_content: bytes):
|
|
||||||
val = int(msg_content[0])
|
|
||||||
self.touch_button = TouchButton(val)
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
|
||||||
return "Pressed " + repr(self.touch_button)
|
|
||||||
|
|
||||||
|
|
||||||
class TouchButtonRelease:
|
|
||||||
def __init__(self, msg_content: bytes):
|
|
||||||
val = int(msg_content[0])
|
|
||||||
self.touch_button = TouchButton(val)
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
|
||||||
return "Released " + repr(self.touch_button)
|
|
||||||
|
|
||||||
|
|
||||||
class ButtonEvent:
|
|
||||||
button_name = {1: 'left', 2: 'right', 3: 'rotary'}
|
|
||||||
event_name = {
|
|
||||||
0: 'pressed',
|
|
||||||
1: 'released',
|
|
||||||
2: 'clicked',
|
|
||||||
3: 'double_clicked',
|
|
||||||
4: 'long_pressed',
|
|
||||||
5: 'repeat_pressed',
|
|
||||||
6: 'long_released'
|
|
||||||
}
|
|
||||||
|
|
||||||
def __init__(self, msg_content: bytes):
|
|
||||||
button_nr, event_nr = struct.unpack("<BB", msg_content)
|
|
||||||
self.button = self.button_name[button_nr]
|
|
||||||
self.event = self.event_name[event_nr]
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
|
||||||
return f"Button {self.button} {self.event}"
|
|
||||||
|
|
||||||
|
|
||||||
incomingMsgMap = {
|
|
||||||
0: RfidTokenRead,
|
|
||||||
1: RotaryEncoderEvent,
|
|
||||||
2: TouchButtonPress,
|
|
||||||
3: TouchButtonRelease,
|
|
||||||
4: ButtonEvent,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class MusicMouseProtocol(asyncio.Protocol):
|
|
||||||
def __init__(self):
|
|
||||||
super()
|
|
||||||
self._msg_callback = None
|
|
||||||
|
|
||||||
def register_message_callback(self, cb):
|
|
||||||
self._msg_callback = cb
|
|
||||||
|
|
||||||
def connection_made(self, transport):
|
|
||||||
self.transport = transport
|
|
||||||
self.in_buff = bytes()
|
|
||||||
|
|
||||||
def led_ring_effect(self, effect_cfg):
|
|
||||||
msg_content = effect_cfg.as_bytes()
|
|
||||||
header = struct.pack("<IBH", MAGIC_TOKEN_HOST_TO_FW,
|
|
||||||
led_ring_effect_to_message_id[type(effect_cfg)], len(msg_content))
|
|
||||||
self.transport.write(header + msg_content)
|
|
||||||
|
|
||||||
def mouse_led_effect(self, effect_cfg):
|
|
||||||
msg_content = effect_cfg.as_bytes()
|
|
||||||
header = struct.pack("<IBH", MAGIC_TOKEN_HOST_TO_FW,
|
|
||||||
mouse_led_effect_to_message_id[type(effect_cfg)], len(msg_content))
|
|
||||||
self.transport.write(header + msg_content)
|
|
||||||
|
|
||||||
def button_background_led_prev(self, val):
|
|
||||||
msg_content = struct.pack("<f", val)
|
|
||||||
header = struct.pack("<IBH", MAGIC_TOKEN_HOST_TO_FW, PREV_BUTTON_LED_MSG, len(msg_content))
|
|
||||||
self.transport.write(header + msg_content)
|
|
||||||
|
|
||||||
def button_background_led_next(self, val):
|
|
||||||
msg_content = struct.pack("<f", val)
|
|
||||||
header = struct.pack("<IBH", MAGIC_TOKEN_HOST_TO_FW, NEXT_BUTTON_LED_MSG, len(msg_content))
|
|
||||||
self.transport.write(header + msg_content)
|
|
||||||
|
|
||||||
def data_received(self, data):
|
|
||||||
self.in_buff += data
|
|
||||||
self._parse_message()
|
|
||||||
|
|
||||||
def connection_lost(self, exc):
|
|
||||||
print('port closed')
|
|
||||||
self.transport.loop.stop()
|
|
||||||
|
|
||||||
def pause_writing(self):
|
|
||||||
print('pause writing')
|
|
||||||
print(self.transport.get_write_buffer_size())
|
|
||||||
|
|
||||||
def resume_writing(self):
|
|
||||||
print(self.transport.get_write_buffer_size())
|
|
||||||
print('resume writing')
|
|
||||||
|
|
||||||
def _parse_message(self):
|
|
||||||
HEADER_SIZE = 4 + 1 + 2
|
|
||||||
if len(self.in_buff) == 0:
|
|
||||||
return
|
|
||||||
if len(self.in_buff) >= HEADER_SIZE:
|
|
||||||
token, msg_type, msg_size = struct.unpack("<IBH", self.in_buff[:HEADER_SIZE])
|
|
||||||
if token == MAGIC_TOKEN_FW_TO_HOST and len(self.in_buff) >= HEADER_SIZE + msg_size:
|
|
||||||
self._on_msg_receive(msg_type, self.in_buff[HEADER_SIZE:HEADER_SIZE + msg_size])
|
|
||||||
self.in_buff = self.in_buff[HEADER_SIZE + msg_size:]
|
|
||||||
else:
|
|
||||||
idx = self.in_buff.find("\n".encode())
|
|
||||||
if idx >= 0:
|
|
||||||
text_msg = self.in_buff[:idx]
|
|
||||||
print("LOG:", text_msg.decode())
|
|
||||||
self.in_buff = self.in_buff[idx + 1:]
|
|
||||||
|
|
||||||
def _on_msg_receive(self, msg_type, msg_payload):
|
|
||||||
parsed_msg = incomingMsgMap[msg_type](msg_payload)
|
|
||||||
if self._msg_callback is not None:
|
|
||||||
self._msg_callback(self, parsed_msg)
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
from dataclasses import dataclass
|
|
||||||
import struct
|
|
||||||
import colorsys
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ColorRGBW:
|
|
||||||
r: float
|
|
||||||
g: float
|
|
||||||
b: float
|
|
||||||
w: float
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
return f"#({self.r}, {self.g}, {self.b}, {self.w})"
|
|
||||||
|
|
||||||
def as_bytes(self) -> bytes:
|
|
||||||
assert self.is_valid(), "Trying to send invalid " + repr(self)
|
|
||||||
return struct.pack("<BBBB", int(self.r * 255), int(self.g * 255), int(self.b * 255),
|
|
||||||
int(self.w * 255))
|
|
||||||
|
|
||||||
def is_valid(self):
|
|
||||||
vals = (self.r, self.g, self.b, self.w)
|
|
||||||
return all(0 <= v <= 1 for v in vals)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ColorHSV:
|
|
||||||
h: float
|
|
||||||
s: float
|
|
||||||
v: float
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def fromRGB(rgb):
|
|
||||||
conv = colorsys.rgb_to_hsv(rgb.r, rgb.g, rgb.b)
|
|
||||||
return ColorHSV(conv[0] * 360, conv[1], conv[2])
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
return f"ColorHSV({self.h}, {self.s}, {self.v})"
|
|
||||||
|
|
||||||
def as_bytes(self) -> bytes:
|
|
||||||
return struct.pack("<fff", self.h, self.s, self.v)
|
|
||||||
|
|
||||||
def is_valid(self):
|
|
||||||
if not 0 <= self.h <= 360:
|
|
||||||
return False
|
|
||||||
if not 0 <= self.s <= 1:
|
|
||||||
return False
|
|
||||||
if not 0 <= self.v <= 2:
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class EffectStaticConfig:
|
|
||||||
color: ColorRGBW
|
|
||||||
begin: int = 0
|
|
||||||
end: int = 0
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
return f"EffectStaticConfig {str(self.color)}, beg: {self.begin}, end {self.end}"
|
|
||||||
|
|
||||||
def as_bytes(self) -> bytes:
|
|
||||||
return self.color.as_bytes() + struct.pack("<HH", self.begin, self.end)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class EffectAlexaSwipeConfig:
|
|
||||||
primary_color_width: float = 20 # in degrees
|
|
||||||
transition_width: float = 30 # in degrees
|
|
||||||
swipe_speed: float = 2 * 360 # in degrees per second
|
|
||||||
bell_curve_width_in_leds: float = 3
|
|
||||||
start_position: float = 180 # in degrees
|
|
||||||
forward: bool = True
|
|
||||||
primary_color: ColorRGBW = ColorRGBW(0, 0, 1, 0)
|
|
||||||
secondary_color: ColorRGBW = ColorRGBW(0, 200 / 255, 1, 0)
|
|
||||||
|
|
||||||
def as_bytes(self) -> bytes:
|
|
||||||
return struct.pack(
|
|
||||||
"<fffff?", self.primary_color_width, self.transition_width, self.swipe_speed,
|
|
||||||
self.bell_curve_width_in_leds, self.start_position,
|
|
||||||
self.forward) + self.primary_color.as_bytes() + self.secondary_color.as_bytes()
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
return f"EffectAlexaSwipe primary {str(self.primary_color)}, {str(self.secondary_color)}"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class EffectRandomTwoColorInterpolationConfig:
|
|
||||||
cycle_durations_ms: int = 6000
|
|
||||||
start_with_existing: bool = True
|
|
||||||
num_segments: int = 3
|
|
||||||
hue1_random: bool = False
|
|
||||||
hue2_random: bool = False
|
|
||||||
color1: ColorHSV = ColorHSV(240, 1, 1)
|
|
||||||
color2: ColorHSV = ColorHSV(192, 1, 1)
|
|
||||||
|
|
||||||
def as_bytes(self) -> bytes:
|
|
||||||
c1 = ColorHSV.fromRGB(self.color1) if isinstance(self.color1, ColorRGBW) else self.color1
|
|
||||||
c2 = ColorHSV.fromRGB(self.color2) if isinstance(self.color2, ColorRGBW) else self.color2
|
|
||||||
return struct.pack("<i?i??", self.cycle_durations_ms, self.start_with_existing,
|
|
||||||
self.num_segments, self.hue1_random,
|
|
||||||
self.hue2_random) + c1.as_bytes() + c2.as_bytes()
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
return f"RandTwoColor {str(self.color1)}, {str(self.color2)}, segments {self.num_segments}"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class EffectCircularConfig:
|
|
||||||
speed: float = 360 # in degrees per second
|
|
||||||
width: float = 180 # in degrees
|
|
||||||
color: ColorRGBW = ColorRGBW(0, 0, 1, 0)
|
|
||||||
|
|
||||||
def as_bytes(self) -> bytes:
|
|
||||||
return struct.pack("<ff", self.speed, self.width) + self.color.as_bytes()
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class EffectSwipeAndChange:
|
|
||||||
swipe: EffectAlexaSwipeConfig = EffectAlexaSwipeConfig()
|
|
||||||
change: EffectRandomTwoColorInterpolationConfig = EffectRandomTwoColorInterpolationConfig()
|
|
||||||
|
|
||||||
def as_bytes(self) -> bytes:
|
|
||||||
return self.swipe.as_bytes() + self.change.as_bytes()
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
|
||||||
return f"Swipe and Change: \n {str(self.swipe)}\n {str(self.change)}"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class EffectReverseSwipe:
|
|
||||||
swipeSpeed: float = 2 * 360
|
|
||||||
bellCurveWidthInLeds: float = 3
|
|
||||||
startPosition: float = 180
|
|
||||||
|
|
||||||
def as_bytes(self) -> bytes:
|
|
||||||
return struct.pack("<fff", self.swipeSpeed, self.bellCurveWidthInLeds, self.startPosition)
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
|
||||||
return f"Reverse swipe, speed {self.swipeSpeed}, width in leds {self.bellCurveWidthInLeds}, start position {self.startPosition}"
|
|
||||||
@@ -1,248 +0,0 @@
|
|||||||
#!/usr/bin/env python
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import sys
|
|
||||||
import serial_asyncio
|
|
||||||
from led_cmds import (ColorRGBW, ColorHSV, EffectStaticConfig,
|
|
||||||
EffectRandomTwoColorInterpolationConfig, EffectAlexaSwipeConfig,
|
|
||||||
EffectSwipeAndChange, EffectReverseSwipe)
|
|
||||||
from host_driver import MusicMouseProtocol, RfidTokenRead, RotaryEncoderEvent, ButtonEvent, TouchButton, TouchButtonPress, TouchButtonRelease, mouse_leds_index_ranges
|
|
||||||
from player import AudioPlayer
|
|
||||||
from glob import glob
|
|
||||||
from copy import deepcopy
|
|
||||||
import os
|
|
||||||
from hass_client import HomeAssistantClient
|
|
||||||
import argparse
|
|
||||||
from ruamel.yaml import YAML
|
|
||||||
import warnings
|
|
||||||
from pprint import pprint
|
|
||||||
|
|
||||||
yaml = YAML(typ='safe')
|
|
||||||
|
|
||||||
OFF_COLOR = ColorRGBW(0, 0, 0, 0)
|
|
||||||
|
|
||||||
|
|
||||||
def parse_color(color_str: str):
|
|
||||||
if isinstance(color_str, ColorRGBW):
|
|
||||||
return color_str
|
|
||||||
elif color_str.startswith("#"):
|
|
||||||
color_str = color_str.lstrip('#')
|
|
||||||
t = tuple(int(color_str[i:i + 2], 16) / 255 for i in (0, 2, 4))
|
|
||||||
return ColorRGBW(*t, 0)
|
|
||||||
elif color_str.startswith("w"):
|
|
||||||
color_str = color_str.lstrip("w")
|
|
||||||
return ColorRGBW(0, 0, 0, int(color_str, 16) / 255)
|
|
||||||
|
|
||||||
|
|
||||||
def load_config(config_path):
|
|
||||||
with open(os.path.join(config_path, "config.yml")) as cfg_file:
|
|
||||||
cfg = yaml.load(cfg_file)
|
|
||||||
for figure_name, figure_cfg in cfg["figures"].items():
|
|
||||||
figure_cfg["colors"] = [parse_color(c) for c in figure_cfg["colors"]]
|
|
||||||
if 'media_files' not in figure_cfg:
|
|
||||||
figure_cfg['media_files'] = sorted(glob(os.path.join(config_path, figure_name)))
|
|
||||||
return cfg
|
|
||||||
|
|
||||||
|
|
||||||
def hass_service(hass, domain, service, **kwargs):
|
|
||||||
asyncio.create_task(hass.call_service(domain, service, kwargs))
|
|
||||||
|
|
||||||
|
|
||||||
class MusicMouseState:
|
|
||||||
def __init__(self, protocol: MusicMouseProtocol):
|
|
||||||
self.current_figure: str = None
|
|
||||||
self.last_figure: str = None
|
|
||||||
self.current_mouse_led_effect = None
|
|
||||||
self.current_led_ring_effect = None
|
|
||||||
self.protocol: MusicMouseProtocol = protocol
|
|
||||||
self.button_led_brightness = None
|
|
||||||
|
|
||||||
def mouse_led_effect(self, effect_cfg):
|
|
||||||
self.current_mouse_led_effect = effect_cfg
|
|
||||||
self.protocol.mouse_led_effect(effect_cfg)
|
|
||||||
|
|
||||||
def led_ring_effect(self, effect_cfg):
|
|
||||||
self.current_led_ring_effect = effect_cfg
|
|
||||||
self.protocol.led_ring_effect(effect_cfg)
|
|
||||||
|
|
||||||
def button_leds(self, brightness):
|
|
||||||
assert 0 <= brightness <= 1
|
|
||||||
self.protocol.button_background_led_prev(brightness)
|
|
||||||
self.protocol.button_background_led_next(brightness)
|
|
||||||
self.button_led_brightness = brightness
|
|
||||||
|
|
||||||
def reset(self):
|
|
||||||
self.mouse_led_effect(EffectStaticConfig(OFF_COLOR))
|
|
||||||
self.led_ring_effect(EffectStaticConfig(OFF_COLOR))
|
|
||||||
|
|
||||||
def figure_placed(self, figure_state):
|
|
||||||
self.last_figure = self.current_figure
|
|
||||||
self.current_figure = figure_state
|
|
||||||
|
|
||||||
def figure_removed(self):
|
|
||||||
self.last_figure = self.current_figure
|
|
||||||
|
|
||||||
|
|
||||||
class Controller:
|
|
||||||
def __init__(self, protocol, hass, cfg):
|
|
||||||
self.cfg = cfg
|
|
||||||
self.audio_player = AudioPlayer(cfg["general"]["alsa_device"])
|
|
||||||
self.audio_player.set_volume(50)
|
|
||||||
self.mmstate = MusicMouseState(protocol)
|
|
||||||
self.protocol = protocol
|
|
||||||
self.hass = hass
|
|
||||||
|
|
||||||
vol_min = self.cfg["general"].get("min_volume", None)
|
|
||||||
vol_max = self.cfg["general"].get("max_volume", None)
|
|
||||||
self.audio_player.set_volume_limits(vol_min, vol_max)
|
|
||||||
protocol.register_message_callback(self.on_firmware_msg)
|
|
||||||
|
|
||||||
self.audio_player.on_playlist_end_callback = self._run_off_animation
|
|
||||||
self.playlists = {
|
|
||||||
fig: self.audio_player.create_playlist(fig_cfg['media_files'])
|
|
||||||
for fig, fig_cfg in cfg['figures'].items()
|
|
||||||
}
|
|
||||||
self._rfid_to_figure_name = {
|
|
||||||
bytes.fromhex(figure_cfg["id"]): figure_name
|
|
||||||
for figure_name, figure_cfg in cfg["figures"].items()
|
|
||||||
}
|
|
||||||
|
|
||||||
def handle_rfid_event(self, tagid):
|
|
||||||
if tagid == bytes.fromhex("0000000000"):
|
|
||||||
if self.audio_player.is_playing():
|
|
||||||
print("Got 000 rfid -> playing off animation")
|
|
||||||
self._run_off_animation()
|
|
||||||
self.audio_player.pause()
|
|
||||||
self.mmstate.figure_removed()
|
|
||||||
elif tagid in self._rfid_to_figure_name:
|
|
||||||
figure = self._rfid_to_figure_name[tagid]
|
|
||||||
primary_color, secondary_color, *rest = self.cfg["figures"][figure]["colors"]
|
|
||||||
self._start_animation(primary_color, secondary_color)
|
|
||||||
self.mmstate.button_leds(self.cfg["general"].get("button_leds_brightness", 0.5))
|
|
||||||
|
|
||||||
if figure in self.playlists:
|
|
||||||
self.audio_player.set_playlist(self.playlists[figure])
|
|
||||||
if self.mmstate.last_figure == figure:
|
|
||||||
self.audio_player.play()
|
|
||||||
else:
|
|
||||||
self.audio_player.play_from_start()
|
|
||||||
|
|
||||||
self.mmstate.figure_placed(figure)
|
|
||||||
else:
|
|
||||||
warnings.warn(f"Unknown figure/tag with id {tagid}")
|
|
||||||
|
|
||||||
def on_firmware_msg(self, _, message):
|
|
||||||
print("FW msg:", message)
|
|
||||||
if isinstance(message, RfidTokenRead):
|
|
||||||
self.handle_rfid_event(message.id)
|
|
||||||
elif isinstance(message, RotaryEncoderEvent):
|
|
||||||
volume_increment = self.cfg["general"].get("volume_increment", 2) * abs(
|
|
||||||
message.increment)
|
|
||||||
if message.direction == 2:
|
|
||||||
self.audio_player.change_volume(volume_increment)
|
|
||||||
elif message.direction == 1:
|
|
||||||
self.audio_player.change_volume(-volume_increment)
|
|
||||||
elif isinstance(message, ButtonEvent):
|
|
||||||
btn = message.button
|
|
||||||
if btn == "left" and message.event == "pressed" and self.audio_player.is_playing():
|
|
||||||
res = self.audio_player.previous()
|
|
||||||
print(f"Prev {res}")
|
|
||||||
elif btn == "right" and message.event == "pressed" and self.audio_player.is_playing():
|
|
||||||
res = self.audio_player.nex()
|
|
||||||
print(f"Next {res}")
|
|
||||||
elif message.button == "rotary" and message.event == "pressed":
|
|
||||||
hass_service(self.hass, "light", "toggle", entity_id="light.kinderzimmer_fluter")
|
|
||||||
elif isinstance(message, TouchButtonPress):
|
|
||||||
figure = self.mmstate.current_figure
|
|
||||||
if figure and self.audio_player.is_playing():
|
|
||||||
primary_color, secondary_color, bg, accent = self.cfg["figures"][figure]["colors"]
|
|
||||||
self.protocol.mouse_led_effect(
|
|
||||||
EffectStaticConfig(accent, *mouse_leds_index_ranges[message.touch_button]))
|
|
||||||
|
|
||||||
colors = {
|
|
||||||
TouchButton.RIGHT_FOOT: {
|
|
||||||
'rgb_color': [235, 255, 67]
|
|
||||||
},
|
|
||||||
TouchButton.LEFT_FOOT: {
|
|
||||||
'color_temp': 469
|
|
||||||
},
|
|
||||||
TouchButton.RIGHT_EAR: {
|
|
||||||
'rgb_color': [101, 49, 255]
|
|
||||||
},
|
|
||||||
TouchButton.LEFT_EAR: {
|
|
||||||
'rgb_color': [255, 74, 254]
|
|
||||||
},
|
|
||||||
}
|
|
||||||
hass_service(self.hass,
|
|
||||||
"light",
|
|
||||||
"turn_on",
|
|
||||||
entity_id="light.kinderzimmer_fluter",
|
|
||||||
**colors[message.touch_button])
|
|
||||||
|
|
||||||
elif isinstance(message, TouchButtonRelease):
|
|
||||||
figure = self.mmstate.current_figure
|
|
||||||
eff_change = EffectRandomTwoColorInterpolationConfig()
|
|
||||||
eff_static = EffectStaticConfig(ColorRGBW(0, 0, 0, 0),
|
|
||||||
*mouse_leds_index_ranges[message.touch_button])
|
|
||||||
if self.audio_player.is_playing():
|
|
||||||
primary_color, secondary_color, bg, accent = self.cfg["figures"][figure]["colors"]
|
|
||||||
eff_static.color = primary_color
|
|
||||||
self.protocol.mouse_led_effect(eff_static)
|
|
||||||
|
|
||||||
if self.audio_player.is_playing():
|
|
||||||
primary_color, secondary_color, bg, accent = self.cfg["figures"][figure]["colors"]
|
|
||||||
eff_change.color1 = primary_color
|
|
||||||
eff_change.color2 = secondary_color
|
|
||||||
eff_change.start_with_existing = True
|
|
||||||
self.protocol.mouse_led_effect(eff_change)
|
|
||||||
|
|
||||||
def _start_animation(self, primary_color, secondary_color):
|
|
||||||
ring_eff = EffectSwipeAndChange()
|
|
||||||
ring_eff.swipe.primary_color = primary_color
|
|
||||||
ring_eff.swipe.secondary_color = secondary_color
|
|
||||||
ring_eff.swipe.swipe_speed = 180
|
|
||||||
ring_eff.change.color1 = primary_color
|
|
||||||
ring_eff.change.color2 = secondary_color
|
|
||||||
self.mmstate.led_ring_effect(ring_eff)
|
|
||||||
|
|
||||||
mouse_eff = deepcopy(ring_eff)
|
|
||||||
mouse_eff.swipe.start_position = 6 / 45 * 360
|
|
||||||
mouse_eff.swipe.bell_curve_width_in_leds = 16
|
|
||||||
mouse_eff.swipe.swipe_speed = 180
|
|
||||||
self.mmstate.mouse_led_effect(mouse_eff)
|
|
||||||
|
|
||||||
def _run_off_animation(self):
|
|
||||||
print("Running off animation")
|
|
||||||
ring_eff = EffectReverseSwipe()
|
|
||||||
self.mmstate.led_ring_effect(ring_eff)
|
|
||||||
|
|
||||||
mouse_eff = EffectReverseSwipe()
|
|
||||||
mouse_eff.startPosition = 6 / 45 * 360
|
|
||||||
self.mmstate.mouse_led_effect(mouse_eff)
|
|
||||||
|
|
||||||
self.mmstate.button_leds(0)
|
|
||||||
|
|
||||||
|
|
||||||
def main(config_path):
|
|
||||||
cfg = load_config(config_path)
|
|
||||||
|
|
||||||
loop = asyncio.get_event_loop()
|
|
||||||
hass = HomeAssistantClient(cfg["general"]["hass_url"], cfg["general"]["hass_token"], loop)
|
|
||||||
|
|
||||||
coro = serial_asyncio.create_serial_connection(loop,
|
|
||||||
MusicMouseProtocol,
|
|
||||||
cfg["general"]["serial_port"],
|
|
||||||
baudrate=115200)
|
|
||||||
transport, protocol = loop.run_until_complete(coro)
|
|
||||||
controller = Controller(protocol, hass, cfg)
|
|
||||||
loop.create_task(hass.connect())
|
|
||||||
return controller, loop
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
if len(sys.argv) == 2:
|
|
||||||
controller, loop = main(config_path=sys.argv[1])
|
|
||||||
loop.run_forever()
|
|
||||||
loop.close()
|
|
||||||
else:
|
|
||||||
print("Error: run with config file path as first argument")
|
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
import vlc
|
|
||||||
|
|
||||||
all_events = (
|
|
||||||
vlc.EventType.MediaDiscovererEnded,
|
|
||||||
vlc.EventType.MediaDiscovererStarted,
|
|
||||||
vlc.EventType.MediaDurationChanged,
|
|
||||||
vlc.EventType.MediaFreed,
|
|
||||||
vlc.EventType.MediaListEndReached,
|
|
||||||
vlc.EventType.MediaListItemAdded,
|
|
||||||
vlc.EventType.MediaListItemDeleted,
|
|
||||||
vlc.EventType.MediaListPlayerNextItemSet,
|
|
||||||
vlc.EventType.MediaListPlayerPlayed,
|
|
||||||
vlc.EventType.MediaListPlayerStopped,
|
|
||||||
vlc.EventType.MediaListViewItemAdded,
|
|
||||||
vlc.EventType.MediaListViewItemDeleted,
|
|
||||||
vlc.EventType.MediaListViewWillAddItem,
|
|
||||||
vlc.EventType.MediaListViewWillDeleteItem,
|
|
||||||
vlc.EventType.MediaListWillAddItem,
|
|
||||||
vlc.EventType.MediaListWillDeleteItem,
|
|
||||||
vlc.EventType.MediaMetaChanged,
|
|
||||||
vlc.EventType.MediaParsedChanged,
|
|
||||||
vlc.EventType.MediaPlayerAudioDevice,
|
|
||||||
vlc.EventType.MediaPlayerAudioVolume,
|
|
||||||
vlc.EventType.MediaPlayerBackward,
|
|
||||||
vlc.EventType.MediaPlayerBuffering,
|
|
||||||
vlc.EventType.MediaPlayerChapterChanged,
|
|
||||||
vlc.EventType.MediaPlayerCorked,
|
|
||||||
vlc.EventType.MediaPlayerESAdded,
|
|
||||||
vlc.EventType.MediaPlayerESDeleted,
|
|
||||||
vlc.EventType.MediaPlayerESSelected,
|
|
||||||
vlc.EventType.MediaPlayerEncounteredError,
|
|
||||||
vlc.EventType.MediaPlayerEndReached,
|
|
||||||
vlc.EventType.MediaPlayerForward,
|
|
||||||
#vlc.EventType.MediaPlayerLengthChanged,
|
|
||||||
vlc.EventType.MediaPlayerMediaChanged,
|
|
||||||
vlc.EventType.MediaPlayerMuted,
|
|
||||||
vlc.EventType.MediaPlayerNothingSpecial,
|
|
||||||
vlc.EventType.MediaPlayerOpening,
|
|
||||||
vlc.EventType.MediaPlayerPausableChanged,
|
|
||||||
vlc.EventType.MediaPlayerPaused,
|
|
||||||
vlc.EventType.MediaPlayerPlaying,
|
|
||||||
#vlc.EventType.MediaPlayerPositionChanged,
|
|
||||||
vlc.EventType.MediaPlayerScrambledChanged,
|
|
||||||
vlc.EventType.MediaPlayerSeekableChanged,
|
|
||||||
vlc.EventType.MediaPlayerSnapshotTaken,
|
|
||||||
vlc.EventType.MediaPlayerStopped,
|
|
||||||
#vlc.EventType.MediaPlayerTimeChanged,
|
|
||||||
vlc.EventType.MediaPlayerTitleChanged,
|
|
||||||
vlc.EventType.MediaPlayerUncorked,
|
|
||||||
vlc.EventType.MediaPlayerUnmuted,
|
|
||||||
vlc.EventType.MediaPlayerVout,
|
|
||||||
vlc.EventType.MediaStateChanged,
|
|
||||||
vlc.EventType.MediaSubItemAdded,
|
|
||||||
vlc.EventType.MediaSubItemTreeAdded,
|
|
||||||
vlc.EventType.RendererDiscovererItemAdded,
|
|
||||||
vlc.EventType.RendererDiscovererItemDeleted,
|
|
||||||
vlc.EventType.VlmMediaAdded,
|
|
||||||
vlc.EventType.VlmMediaChanged,
|
|
||||||
vlc.EventType.VlmMediaInstanceStarted,
|
|
||||||
vlc.EventType.VlmMediaInstanceStatusEnd,
|
|
||||||
vlc.EventType.VlmMediaInstanceStatusError,
|
|
||||||
vlc.EventType.VlmMediaInstanceStatusInit,
|
|
||||||
vlc.EventType.VlmMediaInstanceStatusOpening,
|
|
||||||
vlc.EventType.VlmMediaInstanceStatusPause,
|
|
||||||
vlc.EventType.VlmMediaInstanceStatusPlaying,
|
|
||||||
vlc.EventType.VlmMediaInstanceStopped,
|
|
||||||
vlc.EventType.VlmMediaRemoved,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class AudioPlayer:
|
|
||||||
def __init__(self, alsa_device=None):
|
|
||||||
params = ["-A", "alsa", "--alsa-audio-device", alsa_device] if alsa_device else []
|
|
||||||
self.instance = vlc.Instance(*params)
|
|
||||||
self.media_list_player = self.instance.media_list_player_new()
|
|
||||||
self.media_player = self.media_list_player.get_media_player()
|
|
||||||
|
|
||||||
evm = self.media_player.event_manager()
|
|
||||||
evm.event_attach(vlc.EventType.MediaPlayerStopped, self._callback)
|
|
||||||
|
|
||||||
evm2 = self.media_list_player.event_manager()
|
|
||||||
evm2.event_attach(vlc.EventType.MediaListPlayerPlayed, self._callback)
|
|
||||||
evm2.event_attach(vlc.EventType.MediaListPlayerStopped, self._callback)
|
|
||||||
|
|
||||||
self.on_playlist_end_callback = None
|
|
||||||
|
|
||||||
self.volume_min = None
|
|
||||||
self.volume_max = None
|
|
||||||
|
|
||||||
def create_playlist(self, files):
|
|
||||||
result = vlc.MediaList()
|
|
||||||
for e in files:
|
|
||||||
result.add_media(self.instance.media_new(e))
|
|
||||||
|
|
||||||
evm = result.event_manager()
|
|
||||||
evm.event_attach(vlc.EventType.MediaListEndReached,
|
|
||||||
lambda e: print("Ml CB", str(vlc.EventType(e.type))))
|
|
||||||
evm.event_attach(vlc.EventType.MediaListItemAdded,
|
|
||||||
lambda e: print("Ml ia CB", str(vlc.EventType(e.type))))
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
def set_playlist(self, media_list):
|
|
||||||
self.media_list_player.set_media_list(media_list)
|
|
||||||
print("Setting media list of length ", media_list.count())
|
|
||||||
self.media_list_player.set_playback_mode(vlc.PlaybackMode.default)
|
|
||||||
|
|
||||||
def next(self):
|
|
||||||
return self.media_list_player.next()
|
|
||||||
|
|
||||||
def previous(self):
|
|
||||||
return self.media_list_player.previous()
|
|
||||||
|
|
||||||
def play(self):
|
|
||||||
self.media_list_player.play()
|
|
||||||
|
|
||||||
def play_from_start(self):
|
|
||||||
self.media_list_player.play_item_at_index(0)
|
|
||||||
|
|
||||||
def is_playing(self):
|
|
||||||
return self.media_list_player.is_playing()
|
|
||||||
|
|
||||||
def pause(self):
|
|
||||||
self.media_list_player.pause()
|
|
||||||
|
|
||||||
def _callback(self, event, *args, **kwargs):
|
|
||||||
eventStr = str(vlc.EventType(event.type))
|
|
||||||
print(f"Got vlc event type {event.type} {eventStr} , event {event}")
|
|
||||||
if event.type == vlc.EventType.MediaPlayerStopped:
|
|
||||||
if self.on_playlist_end_callback:
|
|
||||||
print("Calling playlist end cb")
|
|
||||||
self.on_playlist_end_callback()
|
|
||||||
#print("Callback from VLC", event, args, kwargs)
|
|
||||||
#print(event.meta_type, event.obj, event.type)
|
|
||||||
|
|
||||||
def set_volume(self, volume):
|
|
||||||
if self.volume_min and volume < self.volume_min:
|
|
||||||
volume = self.volume_min
|
|
||||||
if self.volume_max and volume > self.volume_max:
|
|
||||||
volume = self.volume_max
|
|
||||||
self.media_player.audio_set_volume(volume)
|
|
||||||
|
|
||||||
def set_volume_limits(self, vmin, vmax):
|
|
||||||
self.volume_min = vmin
|
|
||||||
self.volume_max = vmax
|
|
||||||
|
|
||||||
def change_volume(self, amount=1):
|
|
||||||
vol = self.media_player.audio_get_volume() + amount
|
|
||||||
self.set_volume(vol)
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
#ifndef PLATFORM_NATIVE
|
|
||||||
|
|
||||||
#include "drivers/Esp32DriverRGBW.h"
|
|
||||||
|
|
||||||
#include <driver/gpio.h>
|
|
||||||
#include <driver/rmt.h>
|
|
||||||
|
|
||||||
// Timing constants
|
|
||||||
static constexpr uint16_t DIVIDER = 4;
|
|
||||||
static constexpr double RMT_DURATION_NS = 12.5; // Minimum time of a single RMT duration based on clock ns
|
|
||||||
static constexpr uint32_t T0H = 300;
|
|
||||||
static constexpr uint32_t T0L = 900;
|
|
||||||
static constexpr uint32_t T1H = 600;
|
|
||||||
static constexpr uint32_t T1L = 600;
|
|
||||||
static constexpr uint32_t TRS = 80000;
|
|
||||||
static constexpr rmt_item32_t bit0Data = {uint32_t(T0H / (RMT_DURATION_NS * DIVIDER)), 1, uint32_t(T0L / (RMT_DURATION_NS * DIVIDER)), 0};
|
|
||||||
static constexpr rmt_item32_t bit1Data = {uint32_t(T1H / (RMT_DURATION_NS * DIVIDER)), 1, uint32_t(T1L / (RMT_DURATION_NS * DIVIDER)), 0};
|
|
||||||
|
|
||||||
// Function registered at the RMT driver that converts regular uint8_t values containing r,g,b,w values
|
|
||||||
// to rmt_item32_t for each bit. (8 bit -> 32 * 8 bit)
|
|
||||||
static void IRAM_ATTR uint8ToRmtAdaptor(const void *src, rmt_item32_t *dest, size_t srcSize,
|
|
||||||
size_t wantedNum, size_t *translatedSize, size_t *itemNum)
|
|
||||||
{
|
|
||||||
if (src == NULL || dest == NULL)
|
|
||||||
{
|
|
||||||
*translatedSize = 0;
|
|
||||||
*itemNum = 0;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
size_t size = 0;
|
|
||||||
size_t num = 0;
|
|
||||||
uint8_t *psrc = (uint8_t *)src;
|
|
||||||
rmt_item32_t *pdest = dest;
|
|
||||||
while (size < srcSize && num < wantedNum)
|
|
||||||
{
|
|
||||||
for (int i = 0; i < 8; i++)
|
|
||||||
{
|
|
||||||
// MSB first
|
|
||||||
const bool isBitSet = *psrc & (1 << (7 - i));
|
|
||||||
pdest->val = isBitSet ? bit1Data.val : bit0Data.val;
|
|
||||||
num++;
|
|
||||||
pdest++;
|
|
||||||
}
|
|
||||||
size++;
|
|
||||||
psrc++;
|
|
||||||
}
|
|
||||||
*translatedSize = size;
|
|
||||||
*itemNum = num;
|
|
||||||
}
|
|
||||||
|
|
||||||
void Esp32DriverRGBW::begin(int gpio, int rmtChannel)
|
|
||||||
{
|
|
||||||
rmtChannel_ = rmtChannel;
|
|
||||||
transmitting_ = false;
|
|
||||||
|
|
||||||
rmt_config_t rmt_tx;
|
|
||||||
rmt_tx.channel = static_cast<rmt_channel_t>(rmtChannel);
|
|
||||||
rmt_tx.gpio_num = static_cast<gpio_num_t>(gpio);
|
|
||||||
rmt_tx.rmt_mode = RMT_MODE_TX;
|
|
||||||
rmt_tx.mem_block_num = 1;
|
|
||||||
rmt_tx.clk_div = DIVIDER;
|
|
||||||
rmt_tx.tx_config.loop_en = false;
|
|
||||||
rmt_tx.tx_config.carrier_level = RMT_CARRIER_LEVEL_LOW;
|
|
||||||
rmt_tx.tx_config.carrier_en = false;
|
|
||||||
rmt_tx.tx_config.idle_level = RMT_IDLE_LEVEL_LOW;
|
|
||||||
rmt_tx.tx_config.idle_output_en = true;
|
|
||||||
|
|
||||||
ESP_ERROR_CHECK(rmt_config(&rmt_tx));
|
|
||||||
ESP_ERROR_CHECK(rmt_driver_install(rmt_tx.channel, 0, 0));
|
|
||||||
|
|
||||||
rmt_translator_init((rmt_channel_t)rmtChannel, uint8ToRmtAdaptor);
|
|
||||||
}
|
|
||||||
|
|
||||||
void Esp32DriverRGBW::end()
|
|
||||||
{
|
|
||||||
ESP_ERROR_CHECK(rmt_driver_uninstall((rmt_channel_t)rmtChannel_));
|
|
||||||
}
|
|
||||||
|
|
||||||
void Esp32DriverRGBW::writeSync(const uint32_t *rgbwData, int numLeds)
|
|
||||||
{
|
|
||||||
waitForTransmissionToFinish();
|
|
||||||
auto data = reinterpret_cast<const uint8_t *>(rgbwData);
|
|
||||||
ESP_ERROR_CHECK(rmt_write_sample((rmt_channel_t)rmtChannel_, data, numLeds * 4, true));
|
|
||||||
}
|
|
||||||
|
|
||||||
void Esp32DriverRGBW::writeAsync(const uint32_t *rgbwData, int numLeds)
|
|
||||||
{
|
|
||||||
waitForTransmissionToFinish();
|
|
||||||
auto data = reinterpret_cast<const uint8_t *>(rgbwData);
|
|
||||||
ESP_ERROR_CHECK(rmt_write_sample((rmt_channel_t)rmtChannel_, data, numLeds * 4, false));
|
|
||||||
transmitting_ = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool Esp32DriverRGBW::waitForTransmissionToFinish(int waitMs)
|
|
||||||
{
|
|
||||||
if (!transmitting_)
|
|
||||||
return true;
|
|
||||||
auto ret = rmt_wait_tx_done((rmt_channel_t)rmtChannel_, waitMs / portTICK_PERIOD_MS);
|
|
||||||
if (ret == ESP_OK)
|
|
||||||
{
|
|
||||||
transmitting_ = false;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
#endif
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
|
|
||||||
#include <cstdint>
|
|
||||||
|
|
||||||
struct ColorRGBW
|
|
||||||
{
|
|
||||||
uint8_t r, g, b, w;
|
|
||||||
|
|
||||||
ColorRGBW operator*(float s) const
|
|
||||||
{
|
|
||||||
return {uint8_t(s * r),
|
|
||||||
uint8_t(s * g),
|
|
||||||
uint8_t(s * b),
|
|
||||||
uint8_t(s * w)};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
|
|
||||||
#include <SPI.h>
|
|
||||||
#include <MFRC522.h>
|
|
||||||
|
|
||||||
#define RST_PIN 5 // Configurable, see typical pin layout above
|
|
||||||
#define SS_PIN 23 // Configurable, see typical pin layout above
|
|
||||||
|
|
||||||
MFRC522 mfrc522(SS_PIN, RST_PIN); // Create MFRC522 instance
|
|
||||||
|
|
||||||
//*****************************************************************************************//
|
|
||||||
void setup() {
|
|
||||||
Serial.begin(115200);
|
|
||||||
SPI.begin(22, 19, 18, 23); // Init SPI bus
|
|
||||||
mfrc522.PCD_Init(); // Init MFRC522 card
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Helper routine to dump a byte array as hex values to Serial.
|
|
||||||
*/
|
|
||||||
void PrintHex(byte *buffer, byte bufferSize) {
|
|
||||||
for (byte i = 0; i < bufferSize; i++) {
|
|
||||||
Serial.print(buffer[i] < 0x10 ? " 0" : " ");
|
|
||||||
Serial.print(buffer[i], HEX);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
//*****************************************************************************************//
|
|
||||||
|
|
||||||
void loop() {
|
|
||||||
|
|
||||||
// Look for new cards
|
|
||||||
if ( !mfrc522.PICC_IsNewCardPresent()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if ( !mfrc522.PICC_ReadCardSerial()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
//mfrc522.PICC_DumpDetailsToSerial(&(mfrc522.uid)); //dump some details about the card
|
|
||||||
|
|
||||||
Serial.print("NewCard");
|
|
||||||
PrintHex(mfrc522.uid.uidByte, mfrc522.uid.size);
|
|
||||||
Serial.println("");
|
|
||||||
|
|
||||||
// Check if Card was removed
|
|
||||||
bool cardRemoved = false;
|
|
||||||
int counter = 0;
|
|
||||||
bool current, previous;
|
|
||||||
previous = !mfrc522.PICC_IsNewCardPresent();
|
|
||||||
|
|
||||||
while(!cardRemoved){
|
|
||||||
current =!mfrc522.PICC_IsNewCardPresent();
|
|
||||||
|
|
||||||
if (current && previous) counter++;
|
|
||||||
|
|
||||||
previous = current;
|
|
||||||
cardRemoved = (counter>2);
|
|
||||||
delay(50);
|
|
||||||
}
|
|
||||||
|
|
||||||
Serial.println("Card was removed");
|
|
||||||
delay(500); //change value if you want to read cards faster
|
|
||||||
mfrc522.PICC_HaltA();
|
|
||||||
mfrc522.PCD_StopCrypto1();
|
|
||||||
}
|
|
||||||
BIN
hardware/3dprints/back-figure/music_mouse_back_figure.FCStd
Normal file
BIN
hardware/3dprints/back-figure/music_mouse_back_figure.FCStd
Normal file
Binary file not shown.
251
hardware/3dprints/back-figure/rueckseite_converted.svg
Normal file
251
hardware/3dprints/back-figure/rueckseite_converted.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 1.3 MiB |
BIN
hardware/3dprints/figures/croco.blend
Normal file
BIN
hardware/3dprints/figures/croco.blend
Normal file
Binary file not shown.
512068
hardware/3dprints/figures/croco.obj
Normal file
512068
hardware/3dprints/figures/croco.obj
Normal file
File diff suppressed because it is too large
Load Diff
BIN
hardware/3dprints/figures/croco.stl
Normal file
BIN
hardware/3dprints/figures/croco.stl
Normal file
Binary file not shown.
BIN
hardware/3dprints/figures/elephant.blend
Normal file
BIN
hardware/3dprints/figures/elephant.blend
Normal file
Binary file not shown.
133640
hardware/3dprints/figures/elephant.obj
Normal file
133640
hardware/3dprints/figures/elephant.obj
Normal file
File diff suppressed because it is too large
Load Diff
BIN
hardware/3dprints/figures/omnom.blend
Normal file
BIN
hardware/3dprints/figures/omnom.blend
Normal file
Binary file not shown.
694529
hardware/3dprints/figures/omnom.obj
Normal file
694529
hardware/3dprints/figures/omnom.obj
Normal file
File diff suppressed because it is too large
Load Diff
696208
hardware/3dprints/figures/omnom2.obj
Normal file
696208
hardware/3dprints/figures/omnom2.obj
Normal file
File diff suppressed because it is too large
Load Diff
BIN
hardware/3dprints/figures/puppy.blend
Normal file
BIN
hardware/3dprints/figures/puppy.blend
Normal file
Binary file not shown.
61345
hardware/3dprints/figures/puppy.obj
Normal file
61345
hardware/3dprints/figures/puppy.obj
Normal file
File diff suppressed because it is too large
Load Diff
101023
hardware/3dprints/figures/puppy2.obj
Normal file
101023
hardware/3dprints/figures/puppy2.obj
Normal file
File diff suppressed because it is too large
Load Diff
BIN
hardware/3dprints/figures/rabbit_ducky.blend
Normal file
BIN
hardware/3dprints/figures/rabbit_ducky.blend
Normal file
Binary file not shown.
110816
hardware/3dprints/figures/rabbit_ducky.obj
Normal file
110816
hardware/3dprints/figures/rabbit_ducky.obj
Normal file
File diff suppressed because it is too large
Load Diff
BIN
hardware/3dprints/figures/raw/Ducky.stl
Normal file
BIN
hardware/3dprints/figures/raw/Ducky.stl
Normal file
Binary file not shown.
BIN
hardware/3dprints/figures/raw/Elephant.stl
Normal file
BIN
hardware/3dprints/figures/raw/Elephant.stl
Normal file
Binary file not shown.
BIN
hardware/3dprints/figures/raw/Fox.stl
Normal file
BIN
hardware/3dprints/figures/raw/Fox.stl
Normal file
Binary file not shown.
BIN
hardware/3dprints/figures/raw/Meeple_-_Spielfigur.stl
Normal file
BIN
hardware/3dprints/figures/raw/Meeple_-_Spielfigur.stl
Normal file
Binary file not shown.
BIN
hardware/3dprints/figures/raw/croco.stl
Normal file
BIN
hardware/3dprints/figures/raw/croco.stl
Normal file
Binary file not shown.
BIN
hardware/3dprints/figures/raw/omnom_60mm.stl
Normal file
BIN
hardware/3dprints/figures/raw/omnom_60mm.stl
Normal file
Binary file not shown.
BIN
hardware/3dprints/figures/raw/owl-print.stl
Normal file
BIN
hardware/3dprints/figures/raw/owl-print.stl
Normal file
Binary file not shown.
BIN
hardware/3dprints/figures/raw/puppy.stl
Normal file
BIN
hardware/3dprints/figures/raw/puppy.stl
Normal file
Binary file not shown.
BIN
hardware/3dprints/figures/raw/snowman.stl
Normal file
BIN
hardware/3dprints/figures/raw/snowman.stl
Normal file
Binary file not shown.
BIN
hardware/3dprints/figures/raw/squirrel.stl
Normal file
BIN
hardware/3dprints/figures/raw/squirrel.stl
Normal file
Binary file not shown.
BIN
hardware/3dprints/figures/snowman.blend
Normal file
BIN
hardware/3dprints/figures/snowman.blend
Normal file
Binary file not shown.
291311
hardware/3dprints/figures/snowman.obj
Normal file
291311
hardware/3dprints/figures/snowman.obj
Normal file
File diff suppressed because it is too large
Load Diff
1548458
hardware/3dprints/figures/squirrel.obj
Normal file
1548458
hardware/3dprints/figures/squirrel.obj
Normal file
File diff suppressed because it is too large
Load Diff
BIN
hardware/3dprints/figures/squirrel.stl
Normal file
BIN
hardware/3dprints/figures/squirrel.stl
Normal file
Binary file not shown.
BIN
hardware/3dprints/musicmouse.FCStd
Normal file
BIN
hardware/3dprints/musicmouse.FCStd
Normal file
Binary file not shown.
BIN
hardware/datasheets/nfc-reader-MFRC522.pdf
Normal file
BIN
hardware/datasheets/nfc-reader-MFRC522.pdf
Normal file
Binary file not shown.
BIN
hardware/led_driver_clock.ods
Normal file
BIN
hardware/led_driver_clock.ods
Normal file
Binary file not shown.
28
hardware/pinout.md
Normal file
28
hardware/pinout.md
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
|
||||||
|
Reader
|
||||||
|
----------
|
||||||
|
|
||||||
|
- GND black
|
||||||
|
- RST blue 3.3V
|
||||||
|
- 3.3V red
|
||||||
|
- MISO brown 21
|
||||||
|
- SDA green 19
|
||||||
|
- SCK yellow 18
|
||||||
|
- MOSI orange 5
|
||||||
|
- IRQ green single cable not connected
|
||||||
|
|
||||||
|
|
||||||
|
Button Board:
|
||||||
|
-------------
|
||||||
|
|
||||||
|
- rot in | white 13
|
||||||
|
- btn2 led | grey 12
|
||||||
|
- btn2 in | purple 14
|
||||||
|
- rotB | blue 27
|
||||||
|
- rotA | green 26
|
||||||
|
- btn1 in | yellow 25
|
||||||
|
- btn1 led | orange 33
|
||||||
|
|
||||||
|
rot="rotary encoder"
|
||||||
|
in=button sense in
|
||||||
|
led = 5V pwm
|
||||||
BIN
hardware/power_consumption_leds.ods
Normal file
BIN
hardware/power_consumption_leds.ods
Normal file
Binary file not shown.
BIN
hardware/sketch.fzz
Normal file
BIN
hardware/sketch.fzz
Normal file
Binary file not shown.
BIN
musicmouse.FCStd
BIN
musicmouse.FCStd
Binary file not shown.
@@ -1,6 +0,0 @@
|
|||||||
cmake_minimum_required(VERSION 3.16)
|
|
||||||
|
|
||||||
project("pyaudioplayeralsa")
|
|
||||||
|
|
||||||
add_executable(play main.cpp src/WavFile.cpp)
|
|
||||||
target_link_libraries(play -lasound -pthread)
|
|
||||||
@@ -1,177 +0,0 @@
|
|||||||
/*
|
|
||||||
* Simple sound playback using ALSA API and libasound.
|
|
||||||
*
|
|
||||||
* Compile:
|
|
||||||
* $ cc -o play sound_playback.c -lasound
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* $ ./play <sample_rate> <channels> <seconds> < <file>
|
|
||||||
*
|
|
||||||
* Examples:
|
|
||||||
* $ ./play 44100 2 5 < /dev/urandom
|
|
||||||
* $ ./play 22050 1 8 < /path/to/file.wav
|
|
||||||
*
|
|
||||||
* Copyright (C) 2009 Alessandro Ghedini <al3xbio@gmail.com>
|
|
||||||
* --------------------------------------------------------------
|
|
||||||
* "THE BEER-WARE LICENSE" (Revision 42):
|
|
||||||
* Alessandro Ghedini wrote this file. As long as you retain this
|
|
||||||
* notice you can do whatever you want with this stuff. If we
|
|
||||||
* meet some day, and you think this stuff is worth it, you can
|
|
||||||
* buy me a beer in return.
|
|
||||||
* --------------------------------------------------------------
|
|
||||||
*/
|
|
||||||
|
|
||||||
#include <alsa/asoundlib.h>
|
|
||||||
#include <stdio.h>
|
|
||||||
|
|
||||||
#include <fstream>
|
|
||||||
|
|
||||||
#include "src/WavFile.h"
|
|
||||||
|
|
||||||
#include <future>
|
|
||||||
#include <thread>
|
|
||||||
#include <chrono>
|
|
||||||
#include <queue>
|
|
||||||
|
|
||||||
#define PCM_DEVICE "default"
|
|
||||||
|
|
||||||
std::deque<std::string> queue;
|
|
||||||
std::mutex queueMutex;
|
|
||||||
|
|
||||||
static std::string getInput()
|
|
||||||
{
|
|
||||||
|
|
||||||
std::cout << "starting input thread" << std::endl;
|
|
||||||
while (true)
|
|
||||||
{
|
|
||||||
std::string result;
|
|
||||||
std::getline(std::cin, result);
|
|
||||||
{
|
|
||||||
std::lock_guard<std::mutex> guard(queueMutex);
|
|
||||||
std::cout << "adding to queue" << std::endl;
|
|
||||||
queue.push_back(result);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
int main(int argc, char **argv)
|
|
||||||
{
|
|
||||||
std::ifstream wavFileStream("test.wav", std::ios::binary);
|
|
||||||
WavFile wav(wavFileStream);
|
|
||||||
wavFileStream.close();
|
|
||||||
std::cout << "Wav samples " << wav.size() << std::endl;
|
|
||||||
|
|
||||||
unsigned int pcm, tmp, dir;
|
|
||||||
snd_pcm_t *pcm_handle;
|
|
||||||
snd_pcm_hw_params_t *params;
|
|
||||||
snd_pcm_uframes_t frames;
|
|
||||||
char *buff;
|
|
||||||
int buff_size, loops;
|
|
||||||
|
|
||||||
/* Open the PCM device in playback mode */
|
|
||||||
if (pcm = snd_pcm_open(&pcm_handle, PCM_DEVICE,
|
|
||||||
SND_PCM_STREAM_PLAYBACK, 0) < 0)
|
|
||||||
printf("ERROR: Can't open \"%s\" PCM device. %s\n",
|
|
||||||
PCM_DEVICE, snd_strerror(pcm));
|
|
||||||
|
|
||||||
/* Allocate parameters object and fill it with default values*/
|
|
||||||
snd_pcm_hw_params_alloca(¶ms);
|
|
||||||
|
|
||||||
snd_pcm_hw_params_any(pcm_handle, params);
|
|
||||||
|
|
||||||
/* Set parameters */
|
|
||||||
if (pcm = snd_pcm_hw_params_set_access(pcm_handle, params,
|
|
||||||
SND_PCM_ACCESS_RW_INTERLEAVED) < 0)
|
|
||||||
printf("ERROR: Can't set interleaved mode. %s\n", snd_strerror(pcm));
|
|
||||||
|
|
||||||
if (pcm = snd_pcm_hw_params_set_format(pcm_handle, params,
|
|
||||||
SND_PCM_FORMAT_S16_LE) < 0)
|
|
||||||
printf("ERROR: Can't set format. %s\n", snd_strerror(pcm));
|
|
||||||
|
|
||||||
if (pcm = snd_pcm_hw_params_set_channels(pcm_handle, params, wav.channels()) < 0)
|
|
||||||
printf("ERROR: Can't set channels number. %s\n", snd_strerror(pcm));
|
|
||||||
|
|
||||||
snd_pcm_hw_params_set_buffer_size(pcm_handle, params, 2 * 2048);
|
|
||||||
|
|
||||||
unsigned int rate = wav.sampleRate();
|
|
||||||
if (pcm = snd_pcm_hw_params_set_rate_near(pcm_handle, params, &rate, 0) < 0)
|
|
||||||
printf("ERROR: Can't set rate. %s\n", snd_strerror(pcm));
|
|
||||||
|
|
||||||
/* Write parameters */
|
|
||||||
if (pcm = snd_pcm_hw_params(pcm_handle, params) < 0)
|
|
||||||
printf("ERROR: Can't set harware parameters. %s\n", snd_strerror(pcm));
|
|
||||||
|
|
||||||
/* Resume information */
|
|
||||||
printf("PCM name: '%s'\n", snd_pcm_name(pcm_handle));
|
|
||||||
|
|
||||||
printf("PCM state: %s\n", snd_pcm_state_name(snd_pcm_state(pcm_handle)));
|
|
||||||
|
|
||||||
snd_pcm_hw_params_get_channels(params, &tmp);
|
|
||||||
|
|
||||||
printf("channels: %i ", tmp);
|
|
||||||
|
|
||||||
if (tmp == 1)
|
|
||||||
printf("(mono)\n");
|
|
||||||
else if (tmp == 2)
|
|
||||||
printf("(stereo)\n");
|
|
||||||
|
|
||||||
snd_pcm_hw_params_get_rate(params, &tmp, 0);
|
|
||||||
printf("rate: %d bps\n", tmp);
|
|
||||||
|
|
||||||
/* Allocate buffer to hold single period */
|
|
||||||
snd_pcm_hw_params_get_period_size(params, &frames, 0);
|
|
||||||
|
|
||||||
buff_size = frames * wav.channels() * 2 /* 2 -> sample size */;
|
|
||||||
buff = (char *)malloc(buff_size);
|
|
||||||
std::cout << "Buffer size " << buff_size << " frames " << frames << std::endl;
|
|
||||||
|
|
||||||
snd_pcm_hw_params_get_period_time(params, &tmp, NULL);
|
|
||||||
|
|
||||||
std::thread inputThread(getInput);
|
|
||||||
bool playing = true;
|
|
||||||
|
|
||||||
size_t wavFilePosition = 0;
|
|
||||||
while (wavFilePosition < wav.size())
|
|
||||||
{
|
|
||||||
{
|
|
||||||
std::lock_guard<std::mutex> guard(queueMutex);
|
|
||||||
if (queue.size() > 0)
|
|
||||||
{
|
|
||||||
queue.pop_back();
|
|
||||||
std::cout << "Toggling" << std::endl;
|
|
||||||
//snd_pcm_drain(pcm_handle);
|
|
||||||
std::cout << "Done" << std::endl;
|
|
||||||
playing = !playing;
|
|
||||||
if (playing)
|
|
||||||
{
|
|
||||||
std::cout << "continuing at " << wavFilePosition << std::endl;
|
|
||||||
snd_pcm_pause(pcm_handle, 0);
|
|
||||||
//snd_pcm_prepare(pcm_handle);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
snd_pcm_pause(pcm_handle, 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!playing)
|
|
||||||
continue;
|
|
||||||
size_t dataToWrite = std::min(size_t(buff_size), wav.size() - wavFilePosition);
|
|
||||||
int framesToWrite = dataToWrite / wav.channels() / 2;
|
|
||||||
if (pcm = snd_pcm_writei(pcm_handle, wav[wavFilePosition], framesToWrite) == -EPIPE)
|
|
||||||
{
|
|
||||||
std::cout << "XRUN at wav position " << wavFilePosition << std::endl;
|
|
||||||
snd_pcm_prepare(pcm_handle);
|
|
||||||
}
|
|
||||||
else if (pcm < 0)
|
|
||||||
printf("ERROR. Can't write to PCM device. %s\n", snd_strerror(pcm));
|
|
||||||
|
|
||||||
wavFilePosition += dataToWrite;
|
|
||||||
}
|
|
||||||
|
|
||||||
std::cout << "done filling buffer" << std::endl;
|
|
||||||
snd_pcm_drain(pcm_handle);
|
|
||||||
snd_pcm_close(pcm_handle);
|
|
||||||
free(buff);
|
|
||||||
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
- play wav file [ok]
|
|
||||||
- read & parse wav file [ok]
|
|
||||||
- stop wave file in the middle, wait 2 secs and continue
|
|
||||||
- fade in/out
|
|
||||||
- mix second wave file on top (some effect)
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user