Compare commits
84 Commits
deploy/mus
...
17db811bba
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
8
.gitignore
vendored
8
.gitignore
vendored
@@ -1,6 +1,14 @@
|
||||
generated_3d
|
||||
venv
|
||||
.venv
|
||||
build
|
||||
*.egg-info
|
||||
*.FCStd1
|
||||
*.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.
|
||||
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 <driver/rmt_tx.h>
|
||||
|
||||
class Esp32DriverRGBW
|
||||
{
|
||||
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 writeSync(const uint32_t *rgbwData, int numLeds);
|
||||
@@ -16,8 +19,9 @@ public:
|
||||
bool waitForTransmissionToFinish(int waitMs = 1000);
|
||||
|
||||
private:
|
||||
int rmtChannel_;
|
||||
bool transmitting_;
|
||||
rmt_channel_handle_t channel_ = nullptr;
|
||||
rmt_encoder_handle_t encoder_ = nullptr;
|
||||
bool transmitting_ = false;
|
||||
};
|
||||
|
||||
#endif
|
||||
#endif
|
||||
@@ -9,6 +9,7 @@ enum class EffectId
|
||||
SWIPE_AND_CHANGE, // combination of ALEXA_SWIPE and RANDOM_TWO_COLOR_INTERPOLATION
|
||||
REVERSE_SWIPE,
|
||||
STATIC_DETAILED,
|
||||
SEGMENT_SELECT,
|
||||
};
|
||||
|
||||
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>;
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
// 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_direction(info->pin_a, GPIO_MODE_INPUT);
|
||||
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_direction(info->pin_b, GPIO_MODE_INPUT);
|
||||
gpio_set_intr_type(info->pin_b, GPIO_INTR_ANYEDGE);
|
||||
@@ -13,7 +13,9 @@ data_dir = data
|
||||
default_envs = 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_upload.flash_size = "4MB"
|
||||
build_flags = -DPLATFORM_ESP32
|
||||
@@ -21,14 +23,13 @@ framework = arduino
|
||||
monitor_port = /dev/ttyUSB0
|
||||
upload_port = /dev/ttyUSB0
|
||||
monitor_speed = 115200
|
||||
src_filter = +<*> -<host_test.cpp>
|
||||
build_src_filter = +<*> -<host_test.cpp>
|
||||
lib_deps =
|
||||
miguelbalboa/MFRC522
|
||||
bxparks/AceButton@^1.9.1
|
||||
bxparks/AceButton@1.10.1
|
||||
|
||||
[env:native]
|
||||
platform = native
|
||||
src_filter = +<*> -<main.cpp>
|
||||
platform = native@1.2.1
|
||||
build_src_filter = +<*> -<main.cpp>
|
||||
build_flags = -Ilib/ledtl -g -DPLATFORM_NATIVE
|
||||
lib_deps =
|
||||
bxparks/AceButton@^1.9.1
|
||||
bxparks/AceButton@1.10.1
|
||||
@@ -4,6 +4,7 @@
|
||||
#include "effects/ReverseSwipe.h"
|
||||
#include "effects/RandomTwoColorInterpolation.h"
|
||||
#include "effects/SwipeAndChange.h"
|
||||
#include "effects/SegmentSelect.h"
|
||||
|
||||
#include "Arduino.h"
|
||||
#include <cstdint>
|
||||
@@ -120,6 +121,8 @@ enum class MessageHostToFw : uint8_t
|
||||
|
||||
PREV_BUTTON_LED = 21,
|
||||
NEXT_BUTTON_LED = 22,
|
||||
|
||||
LED_WHEEL_EFFECT_SEGMENT_SELECT = 23,
|
||||
};
|
||||
|
||||
template <>
|
||||
@@ -180,7 +183,7 @@ inline bool handleLedEffect(TLedTask *ledTask, MessageHostToFw msgType, MessageH
|
||||
}
|
||||
|
||||
template <typename LedTask1, typename LedTask2, typename LedTaskShelf>
|
||||
inline void handleIncomingMessagesFromHost(LedTask1 *ledTaskCircle, LedTask2 *ledTaskMouse, LedTaskShelf *ledTaskShelf, uint8_t ledChannelLeft, uint8_t ledChannelRight)
|
||||
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))
|
||||
return;
|
||||
@@ -214,6 +217,7 @@ inline void handleIncomingMessagesFromHost(LedTask1 *ledTaskCircle, LedTask2 *le
|
||||
else if(handleLedEffect<EffectRandomTwoColorInterpolationConfig>(ledTaskCircle, MessageHostToFw::LED_WHEEL_EFFECT_RANDOM_TWO_COLOR_INTERPOLATION, msgType, msgBuffer)) {}
|
||||
else if(handleLedEffect<EffectSwipeAndChangeConfig >(ledTaskCircle, MessageHostToFw::LED_WHEEL_EFFECT_SWIPE_AND_CHANGE, msgType, msgBuffer)) {}
|
||||
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)) {}
|
||||
|
||||
// Mouse LEDs
|
||||
else if(handleLedEffect<EffectStaticConfig >(ledTaskMouse, MessageHostToFw::MOUSE_LED_EFFECT_STATIC, msgType, msgBuffer)) {}
|
||||
@@ -234,12 +238,12 @@ inline void handleIncomingMessagesFromHost(LedTask1 *ledTaskCircle, LedTask2 *le
|
||||
else if (msgType == MessageHostToFw::PREV_BUTTON_LED)
|
||||
{
|
||||
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)
|
||||
{
|
||||
float *val = reinterpret_cast<float *>(msgBuffer);
|
||||
ledcWrite(ledChannelRight, uint32_t(255 * (*val)));
|
||||
ledcWrite(ledPinRight, uint32_t(255 * (*val)));
|
||||
}
|
||||
else
|
||||
Serial.println("Unknown message type");
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
#include "effects/Circular.h"
|
||||
#include "effects/Common.h"
|
||||
#include "effects/SegmentSelect.h"
|
||||
#include "drivers/Esp32DriverRGBW.h"
|
||||
|
||||
#include "freertos/FreeRTOS.h"
|
||||
@@ -82,6 +83,7 @@ void _led_task_func(void *params)
|
||||
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::REVERSE_SWIPE >(id, effectFunction, ledStrip, msgBuffer, effectStorage)) {}
|
||||
else if (dispatchEffectId<EffectId::SEGMENT_SELECT >(id, effectFunction, ledStrip, msgBuffer, effectStorage)) {}
|
||||
// clang-format on
|
||||
|
||||
timeoutMsForEffect = 0;
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
#include "effects/AlexaSwipe.h"
|
||||
#include "effects/ReverseSwipe.h"
|
||||
#include "effects/SegmentSelect.h"
|
||||
#include "helpers/ColorConversions.h"
|
||||
|
||||
#include <iostream>
|
||||
@@ -100,6 +101,29 @@ int main(int argc, char **argv)
|
||||
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;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
#include <Arduino.h>
|
||||
#include "rc522.h"
|
||||
#include "SPI.h"
|
||||
#include <MFRC522.h>
|
||||
#include "rotary_encoder.h"
|
||||
|
||||
#include "containers/LedStripRGBW.h"
|
||||
@@ -21,9 +20,6 @@
|
||||
#include "TaskLed.h"
|
||||
|
||||
// -------------------------------------------------- RFID Reader ----------------------------------------
|
||||
MFRC522 rfid;
|
||||
MFRC522::MIFARE_Key key;
|
||||
|
||||
void tagHandler(uint8_t *sn)
|
||||
{
|
||||
if (sn != nullptr)
|
||||
@@ -117,11 +113,9 @@ void setupButtons()
|
||||
buttonRight.setEventHandler(handleButtonEvent);
|
||||
buttonRotary.setEventHandler(handleButtonEvent);
|
||||
|
||||
ledcSetup(0, PWM_FREQ, PWM_RESOLUTION);
|
||||
ledcAttachPin(BUTTON_LEFT_LED_PIN, 0);
|
||||
|
||||
ledcSetup(1, PWM_FREQ, PWM_RESOLUTION);
|
||||
ledcAttachPin(BUTTON_RIGHT_LED_PIN, 1);
|
||||
// Arduino core 3 picks the LEDC channel itself; from here on the pin is the handle.
|
||||
ledcAttach(BUTTON_LEFT_LED_PIN, PWM_FREQ, PWM_RESOLUTION);
|
||||
ledcAttach(BUTTON_RIGHT_LED_PIN, PWM_FREQ, PWM_RESOLUTION);
|
||||
}
|
||||
|
||||
void handleButtons()
|
||||
@@ -139,7 +133,7 @@ LedTask<decltype(ledStripCircle)> ledTaskCircle;
|
||||
|
||||
void setupLedCircle()
|
||||
{
|
||||
ledDriverCircle.begin(22, 0);
|
||||
ledDriverCircle.begin(22);
|
||||
ledTaskCircle.begin(ledStripCircle, ledDriverCircle);
|
||||
ledTaskCircle.startEffect(EffectStaticConfig(ColorRGBW{0, 0, 0, 0}));
|
||||
}
|
||||
@@ -152,7 +146,7 @@ LedTask<decltype(ledStripMouse)> ledTaskMouse;
|
||||
|
||||
void setupMouseLeds()
|
||||
{
|
||||
ledDriverMouse.begin(16, 1);
|
||||
ledDriverMouse.begin(16);
|
||||
ledTaskMouse.begin(ledStripMouse, ledDriverMouse);
|
||||
ledTaskMouse.startEffect(EffectStaticConfig{ColorRGBW{0, 0, 0, 0}, 0, 0});
|
||||
}
|
||||
@@ -165,7 +159,7 @@ LedTask<decltype(ledStripShelf)> ledTaskShelf;
|
||||
|
||||
void setupShelfLeds()
|
||||
{
|
||||
ledDriverShelf.begin(17, 2);
|
||||
ledDriverShelf.begin(17);
|
||||
ledTaskShelf.begin(ledStripShelf, ledDriverShelf);
|
||||
ledTaskShelf.startEffect(EffectStaticConfig{ColorRGBW{0, 0, 0, 0}, 0, 0});
|
||||
}
|
||||
@@ -234,7 +228,7 @@ void setup()
|
||||
|
||||
void loop()
|
||||
{
|
||||
handleIncomingMessagesFromHost(&ledTaskCircle, &ledTaskMouse, &ledTaskShelf, 0, 1);
|
||||
handleIncomingMessagesFromHost(&ledTaskCircle, &ledTaskMouse, &ledTaskShelf, BUTTON_LEFT_LED_PIN, BUTTON_RIGHT_LED_PIN);
|
||||
handleTouchInputs();
|
||||
handleRotaryEncoder();
|
||||
handleButtons();
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,434 +0,0 @@
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"<div>\n",
|
||||
"<a href=\"http://www.music-processing.de/\"><img style=\"float:left;\" src=\"../data/FMP_Teaser_Cover.png\" width=40% alt=\"FMP\"></a>\n",
|
||||
"<a href=\"https://www.audiolabs-erlangen.de\"><img src=\"../data/Logo_AudioLabs_Long.png\" width=59% style=\"float: right;\" alt=\"AudioLabs\"></a>\n",
|
||||
"</div>"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"<div>\n",
|
||||
"<a href=\"../C5/C5.html\"><img src=\"../data/C5_nav.png\" width=\"100\" style=\"float:right;\" alt=\"C5\"></a>\n",
|
||||
"<h1>Hidden Markov Model (HMM)</h1> \n",
|
||||
"</div>\n",
|
||||
"\n",
|
||||
"<br/>\n",
|
||||
"\n",
|
||||
"<p>\n",
|
||||
"Motivated by the chord recognition problem, we give in this notebook an overview of hidden Markov models (HMMs) and introduce three famous algorithmic problems related with HMMs following Section 5.3 of <a href=\"http://www.music-processing.de/\">[Müller, FMP, Springer 2015]</a>. For a detailed introduction of HMMs, we refer to the famous tutorial paper by Rabiner.\n",
|
||||
"\n",
|
||||
"<ul>\n",
|
||||
"<li><span style=\"color:black\">\n",
|
||||
"Lawrence R. Rabiner: <strong>A Tutorial on Hidden Markov Models and Selected Applications in Speech Recognition.</strong> Proceedings of the IEEE, 77 (1989), pp. 257–286. \n",
|
||||
"<br> \n",
|
||||
"<a type=\"button\" class=\"btn btn-default btn-xs\" target=\"_blank\" href=\"../data/bibtex/FMP_bibtex_Rabiner89_HMM_IEEE.txt\"> Bibtex </a>\n",
|
||||
"</span></li>\n",
|
||||
"</ul>\n",
|
||||
"</p> "
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Markov Chains\n",
|
||||
"\n",
|
||||
"Certain transitions from one chord to another are more likely than others. To capture such likelihoods, one can employ a concept called **Markov chains**. Abstracting from our chord recognition scenario, we assume that the chord types to be considered are represented by a set \n",
|
||||
"\n",
|
||||
"\\begin{equation}\n",
|
||||
" \\mathcal{A}:=\\{\\alpha_{1},\\alpha_{2},\\ldots,\\alpha_{I}\\}\n",
|
||||
"\\end{equation}\n",
|
||||
"\n",
|
||||
"of size $I\\in\\mathbb{N}$. The elements $\\alpha_{i}$ for $i\\in[1:I]$ are referred to as **states**. A progression of chords is realized by a system that can be described at any time instance $n=1,2,3,\\ldots$ as being in some state $s_{n}\\in\\mathcal{A}$. The change from one state to another is specified according to a set of probabilities associated with each state. In general, a probabilistic description of such a system can be quite complex. To simplify the model, one often makes the assumption that the probability of a change from the current state $s_{n}$ to the next state $s_{n+1}$ only depends on the current state, and not on the events that preceded it. In terms of conditional probabilities, this property is expressed by\n",
|
||||
"\n",
|
||||
"\\begin{equation}\n",
|
||||
" P[s_{n+1}=\\alpha_{j}|s_{n}=\\alpha_{i},s_{n-1}=\\alpha_{k},\\ldots]\n",
|
||||
" = P[s_{n+1}=\\alpha_{j}|s_{n}=\\alpha_{i}].\n",
|
||||
"\\end{equation}\n",
|
||||
"\n",
|
||||
"The specific kind of \"amnesia\" is called the **Markov property**. Besides this property, one also often assumes that the system is **invariant under time shifts**, which means by definition that the following coefficients become independent of the index $n$:\n",
|
||||
"\n",
|
||||
"\\begin{equation}\n",
|
||||
" a_{ij} := P[s_{n+1}=\\alpha_{j} | s_{n}=\\alpha_{i}] \\in [0,1]\n",
|
||||
"\\end{equation}\n",
|
||||
"\n",
|
||||
"for $i,j\\in[1:I]$. These coefficients are also called **state transition probabilities**. They obey the standard stochastic constraint $\\sum_{j=1}^{I} a_{ij} = 1$ and can be expressed by an $(I\\times I)$ matrix, which we denote by $A$. A system that satisfies these properties is also called a (discrete-time) **Markov chain**. The following figure illustrates these definitions. It defines a Markov chain that consists of $I=3$ states $\\alpha_{1}$, $\\alpha_{2}$, and $\\alpha_{3}$, which correspond to the major chords $\\mathbf{C}$, $\\mathbf{G}$, and $\\mathbf{F}$, respectively. In the graph representation, the states correspond to the nodes, the transitions to the edges, and the transition probabilities to the labels attached to the edges. For example, the transition probability to remain in the state $\\alpha_{1}=\\mathbf{C}$ is $a_{11}=0.8$, whereas the transition probability of changing from $\\alpha_{1}=\\mathbf{C}$ to $\\alpha_{2}=\\mathbf{G}$ is $a_{12}=0.1$.\n",
|
||||
"\n",
|
||||
"<img src=\"../data/C5/FMP_C5_F24.png\" width=\"550px\" align=\"middle\" alt=\"FMP_C5_F24\">\n",
|
||||
"\n",
|
||||
"The model expresses the probability of all possible chord changes. To compute the probability of a given chord progression, one also needs the information on how the model gets started. This information is specified\n",
|
||||
"by additional model parameters referred to as **initial state probabilities**. For a general Markov chain, these probabilities are specified by the numbers\n",
|
||||
"\n",
|
||||
"\\begin{equation}\n",
|
||||
" c_{i} := P[s_{1}=\\alpha_{i}] \\in [0,1]\n",
|
||||
"\\end{equation}\n",
|
||||
"\n",
|
||||
"for $i\\in[1:I]$. These coefficients, which sum up to one, can be expressed by a vector of length $I$ denoted by $C$."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Hidden Markov Models\n",
|
||||
"\n",
|
||||
"Based on a Markov chain, one can compute a probability for a given observation consisting of a sequence of states or chord types. In our [chord recognition scenario](../C5/C5S2_ChordRec_Templates.html), however, this is not what we need. Rather than observing a sequence of chord types, we observe a **sequence of chroma vectors** that are somehow related to the chord types. In other words, the state sequence is not directly visible, but only a fuzzier observation sequence that is generated based on the state sequence. This leads to an extension of Markov chains to a statistical model referred to as a **hidden Markov model** (HMM). The idea is to represent the relation between the observed feature vectors and the chord types (the states) using a probabilistic framework. Each state is equipped with a probability function that expresses the likelihood for a given chord type to output or emit a certain feature vector. As a result, we obtain a two-layered process consisting of a **hidden layer** and an **observable layer**. The hidden layer produces a state sequence that is not observable (\"hidden\"), but generates the observation sequence on the basis of the state-dependent probability functions.\n",
|
||||
"\n",
|
||||
"The **first layer** of an HMM is a **Markov chain** as introduced above. To define the second layer of an HMM, we need to specify a space of possible output values and a probability function for each state. In general, the output space can be any set including the real numbers, a vector space, or any kind of feature space. For example, in the case of chord recognition, this space may be modeled as the feature space $\\mathcal{F}=\\mathbb{R}^{12}$ consisting of all possible $12$-dimensional chroma vectors. For the sake of simplicity, we only consider the case of a **discrete HMM**, where the output space is assumed to be discrete and even finite. In this case, the space can be modeled as a finite set \n",
|
||||
"\n",
|
||||
"\\begin{equation}\n",
|
||||
" \\mathcal{B} = \\{\\beta_{1},\\beta_{2},\\ldots,\\beta_{K}\\} \n",
|
||||
"\\end{equation}\n",
|
||||
"\n",
|
||||
"of size $K\\in\\mathbb{N}$ consisting of distinct output elements $\\beta_{k}$, $k\\in[1:K]$, which are also referred to as **observation symbols**. An HMM associates with each state a probability function, which is also referred to as the **emission probability** or **output probability**. In the discrete case, the emission probabilities are specified by coefficients\n",
|
||||
"\n",
|
||||
"\\begin{equation}\n",
|
||||
" b_{ik}\\in[0,1]\n",
|
||||
"\\end{equation}\n",
|
||||
"\n",
|
||||
"for $i\\in[1:I]$ and $k\\in[1:K]$. Each coefficient $b_{ik}$ expresses the probability of the system to output the observation symbol $\\beta_{k}$ when in state $\\alpha_{i}$. Similarly to the state transition probabilities, the emission probabilities are required to satisfy the stochastic constraint $\\sum_{k=1}^{K} \\beta_{ik} = 1$ for $i\\in[1:I]$ (thus forming a probability distribution for each state). The coefficients can be expressed by an $(I\\times K)$ matrix, which we denote by $B$. In summary, an HMM is specified by a tuple\n",
|
||||
"\n",
|
||||
"\\begin{equation}\n",
|
||||
" \\Theta:=(\\mathcal{A},A,C,\\mathcal{B},B).\n",
|
||||
"\\end{equation}\n",
|
||||
"\n",
|
||||
"The sets $\\mathcal{A}$ and $\\mathcal{B}$ are usually considered to be fixed components of the model, while the probability values specified by $A$, $B$, and $C$ are the free parameters to be determined. This can be done explicitly by an expert based on his or her musical knowledge or by employing a learning procedure based on suitably labeled training data. Continuing the above example, the following figure illustrates a hidden Markov model, where the state-dependent emission probabilities are indicated by the labels of the dashed arrows.\n",
|
||||
"\n",
|
||||
"<img src=\"../data/C5/FMP_C5_F25.png\" width=\"400px\" align=\"middle\" alt=\"FMP_C5_F25\">"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"In the following code cell, we define the state transition probability matrix $A$ and the output probability $B$ as specified by the figure. \n",
|
||||
"\n",
|
||||
"* Here, we assume that $\\alpha_{1}=\\mathbf{C}$, $\\alpha_{2}=\\mathbf{G}$, and $\\alpha_{3}=\\mathbf{F}$. \n",
|
||||
"* Furthermore, the elements of the output space $\\mathcal{B} = \\{\\beta_{1},\\beta_{2},\\beta_{3}\\}$ represent the three chroma vectors ordered from left to right. \n",
|
||||
"* Finally, we assume that the initial state probability vector $C$ is given by the values $c_{1}=0.6$, $c_{2}=0.2$, $c_{3}=0.2$."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 1,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"import numpy as np\n",
|
||||
"from sklearn.preprocessing import normalize \n",
|
||||
"\n",
|
||||
"A = np.array([[0.8, 0.1, 0.1], \n",
|
||||
" [0.2, 0.7, 0.1], \n",
|
||||
" [0.1, 0.3, 0.6]])\n",
|
||||
"\n",
|
||||
"C = np.array([0.6, 0.2, 0.2])\n",
|
||||
"\n",
|
||||
"B = np.array([[0.7, 0.0, 0.3], \n",
|
||||
" [0.1, 0.9, 0.0], \n",
|
||||
" [0.0, 0.2, 0.8]])"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## HMM-Based Sequence Generation \n",
|
||||
"\n",
|
||||
"Once an HMM is specified by $\\Theta:=(\\mathcal{A},A,C,\\mathcal{B},B)$, it can be used for various analysis and synthesis applications. Since it is very instructive, we now discuss how to (artificially) generate, on the basis of a given HMM, an observation sequence $O=(o_{1},o_{2},\\ldots,o_{N})$ of length $N\\in\\mathbb{N}$ with $o_n\\in \\mathcal{B}$, $n\\in[1:N]$. The generation procedure is as follows:\n",
|
||||
"\n",
|
||||
"1. Set $n=1$ and choose an initial state $s_n=\\alpha_i$ for some $i\\in[1:I]$ according to the initial state distribution $C$.\n",
|
||||
"2. Generate an observation $o_n=\\beta_k$ for some $k\\in[1:K]$ according to the emission probability in state $s_n=\\alpha_i$ (specified by the $i^{\\mathrm{th}}$ row of $B$).\n",
|
||||
"3. If $n=N$ then terminate the process. Otherwise, if $n<N$, transit to the new state $s_{n+1}=\\alpha_{j}$ according to the state transition probability at state $s_n=\\alpha_i$ (specified by the $i^{\\mathrm{th}}$ row of $A$). Then increase $n$ by one and return to step 2.\n",
|
||||
"\n",
|
||||
"In the next code cell, we implement this procedure and apply it to the example HMM specified above. Note that, due to Python conventions, we start in our implementation with index `0`."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 2,
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"n = 0, S[0] = 0, O[0] = 0\n",
|
||||
"n = 1, S[1] = 0, O[1] = 0\n",
|
||||
"n = 2, S[2] = 0, O[2] = 2\n",
|
||||
"n = 3, S[3] = 0, O[3] = 0\n",
|
||||
"n = 4, S[4] = 0, O[4] = 0\n",
|
||||
"n = 5, S[5] = 0, O[5] = 0\n",
|
||||
"n = 6, S[6] = 0, O[6] = 2\n",
|
||||
"n = 7, S[7] = 0, O[7] = 0\n",
|
||||
"n = 8, S[8] = 0, O[8] = 0\n",
|
||||
"n = 9, S[9] = 0, O[9] = 0\n",
|
||||
"State sequence S: [0 0 0 0 0 0 0 0 0 0]\n",
|
||||
"Observation sequence O: [0 0 2 0 0 0 2 0 0 0]\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"def generate_sequence_hmm(N, A, C, B, details=False):\n",
|
||||
" \"\"\"Generate observation and state sequence from given HMM\n",
|
||||
"\n",
|
||||
" Notebook: C5/C5S3_HiddenMarkovModel.ipynb\n",
|
||||
"\n",
|
||||
" Args:\n",
|
||||
" N (int): Number of observations to be generated\n",
|
||||
" A (np.ndarray): State transition probability matrix of dimension I x I\n",
|
||||
" C (np.ndarray): Initial state distribution of dimension I\n",
|
||||
" B (np.ndarray): Output probability matrix of dimension I x K\n",
|
||||
" details (bool): If \"True\" then shows details (Default value = False)\n",
|
||||
"\n",
|
||||
" Returns:\n",
|
||||
" O (np.ndarray): Observation sequence of length N\n",
|
||||
" S (np.ndarray): State sequence of length N\n",
|
||||
" \"\"\"\n",
|
||||
" assert N > 0, \"N should be at least one\"\n",
|
||||
" I = A.shape[1]\n",
|
||||
" K = B.shape[1]\n",
|
||||
" assert I == A.shape[0], \"A should be an I-square matrix\"\n",
|
||||
" assert I == C.shape[0], \"Dimension of C should be I\"\n",
|
||||
" assert I == B.shape[0], \"Column-dimension of B should be I\"\n",
|
||||
"\n",
|
||||
" O = np.zeros(N, int)\n",
|
||||
" S = np.zeros(N, int)\n",
|
||||
" for n in range(N):\n",
|
||||
" if n == 0:\n",
|
||||
" i = np.random.choice(np.arange(I), p=C)\n",
|
||||
" else:\n",
|
||||
" i = np.random.choice(np.arange(I), p=A[i, :])\n",
|
||||
" k = np.random.choice(np.arange(K), p=B[i, :])\n",
|
||||
" S[n] = i\n",
|
||||
" O[n] = k\n",
|
||||
" if details:\n",
|
||||
" print('n = %d, S[%d] = %d, O[%d] = %d' % (n, n, S[n], n, O[n]))\n",
|
||||
" return O, S\n",
|
||||
"\n",
|
||||
"N = 10\n",
|
||||
"O, S = generate_sequence_hmm(N, A, C, B, details=True)\n",
|
||||
"print('State sequence S: ', S)\n",
|
||||
"print('Observation sequence O:', O)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"As a sanity check for the plausibility of our sequence generation approach, we now check if the generated sequences reflect well the probabilities of our HMM. To this end, we estimate the original transition probability matrix $A$ and the output probability matrix $B$ from a generated observation sequence $O$ and state sequence $S$.\n",
|
||||
"\n",
|
||||
"* To obtain an estimate of the entry $a_{ij}$ of $A$, we count all transitions from $n$ to $n+1$ with $S(n)=\\alpha_i$ and $S(n+1)=\\alpha_j$ and then divide this number by the total number of transitions starting with $\\alpha_i$.\n",
|
||||
"\n",
|
||||
"* Similarly, to obtain an estimate of the entry $b_{ik}$ of $B$, we count the number of occurrences $n$ with $S(n)=\\alpha_i$ and $O(n)=\\beta_k$ and divide this number by the total number of occurrences of $\\alpha_i$ in $S$.\n",
|
||||
"\n",
|
||||
"When generating longer sequences by increasing the number $N$, the resulting estimates should approach the original values in $A$ and $B$. This is demonstrated by the subsequent experiment. \n",
|
||||
"\n",
|
||||
"<div class=\"alert alert-block alert-warning\">\n",
|
||||
"Note: In practice, when estimating HMM model parameters from training data, only <strong>observation sequences</strong> are typically available, and the state sequences (that reflect the hidden generation process) are generally not known. Learning parameters only from observation sequences leads to much harder estimation problems as discussed below. \n",
|
||||
"</div> "
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 3,
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"======== Estimation results when using N = 100 ========\n",
|
||||
"A =\n",
|
||||
"[[ 0.800 0.100 0.100]\n",
|
||||
" [ 0.200 0.700 0.100]\n",
|
||||
" [ 0.100 0.300 0.600]]\n",
|
||||
"A_est =\n",
|
||||
"[[ 0.795 0.091 0.114]\n",
|
||||
" [ 0.172 0.655 0.172]\n",
|
||||
" [ 0.154 0.269 0.577]]\n",
|
||||
"B =\n",
|
||||
"[[ 0.700 0.000 0.300]\n",
|
||||
" [ 0.100 0.900 0.000]\n",
|
||||
" [ 0.000 0.200 0.800]]\n",
|
||||
"B_est =\n",
|
||||
"[[ 0.705 0.000 0.295]\n",
|
||||
" [ 0.167 0.833 0.000]\n",
|
||||
" [ 0.000 0.423 0.577]]\n",
|
||||
"======== Estimation results when using N = 10000 ========\n",
|
||||
"A =\n",
|
||||
"[[ 0.800 0.100 0.100]\n",
|
||||
" [ 0.200 0.700 0.100]\n",
|
||||
" [ 0.100 0.300 0.600]]\n",
|
||||
"A_est =\n",
|
||||
"[[ 0.799 0.097 0.104]\n",
|
||||
" [ 0.198 0.696 0.106]\n",
|
||||
" [ 0.097 0.306 0.597]]\n",
|
||||
"B =\n",
|
||||
"[[ 0.700 0.000 0.300]\n",
|
||||
" [ 0.100 0.900 0.000]\n",
|
||||
" [ 0.000 0.200 0.800]]\n",
|
||||
"B_est =\n",
|
||||
"[[ 0.708 0.000 0.292]\n",
|
||||
" [ 0.103 0.897 0.000]\n",
|
||||
" [ 0.000 0.205 0.795]]\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"def estimate_hmm_from_o_s(O, S, I, K):\n",
|
||||
" \"\"\"Estimate the state transition and output probability matrices from\n",
|
||||
" a given observation and state sequence\n",
|
||||
"\n",
|
||||
" Notebook: C5/C5S3_HiddenMarkovModel.ipynb\n",
|
||||
"\n",
|
||||
" Args:\n",
|
||||
" O (np.ndarray): Observation sequence of length N\n",
|
||||
" S (np.ndarray): State sequence of length N\n",
|
||||
" I (int): Number of states\n",
|
||||
" K (int): Number of observation symbols\n",
|
||||
"\n",
|
||||
" Returns:\n",
|
||||
" A_est (np.ndarray): State transition probability matrix of dimension I x I\n",
|
||||
" B_est (np.ndarray): Output probability matrix of dimension I x K\n",
|
||||
" \"\"\"\n",
|
||||
" # Estimate A\n",
|
||||
" A_est = np.zeros([I, I])\n",
|
||||
" N = len(S)\n",
|
||||
" for n in range(N-1):\n",
|
||||
" i = S[n]\n",
|
||||
" j = S[n+1]\n",
|
||||
" A_est[i, j] += 1\n",
|
||||
" A_est = normalize(A_est, axis=1, norm='l1')\n",
|
||||
"\n",
|
||||
" # Estimate B\n",
|
||||
" B_est = np.zeros([I, K])\n",
|
||||
" for i in range(I):\n",
|
||||
" for k in range(K):\n",
|
||||
" B_est[i, k] = np.sum(np.logical_and(S == i, O == k))\n",
|
||||
" B_est = normalize(B_est, axis=1, norm='l1')\n",
|
||||
" return A_est, B_est\n",
|
||||
"\n",
|
||||
"N = 100\n",
|
||||
"print('======== Estimation results when using N = %d ========' % N)\n",
|
||||
"O, S = generate_sequence_hmm(N, A, C, B, details=False)\n",
|
||||
"A_est, B_est = estimate_hmm_from_o_s(O, S, A.shape[1], B.shape[1])\n",
|
||||
"np.set_printoptions(formatter={'float': \"{: 7.3f}\".format})\n",
|
||||
"print('A =', A, sep='\\n')\n",
|
||||
"print('A_est =', A_est, sep='\\n')\n",
|
||||
"print('B =', B, sep='\\n')\n",
|
||||
"print('B_est =', B_est, sep='\\n')\n",
|
||||
"\n",
|
||||
"N = 10000\n",
|
||||
"print('======== Estimation results when using N = %d ========' % N)\n",
|
||||
"O, S = generate_sequence_hmm(N, A, C, B, details=False)\n",
|
||||
"A_est, B_est = estimate_hmm_from_o_s(O, S, A.shape[1], B.shape[1])\n",
|
||||
"np.set_printoptions(formatter={'float': \"{: 7.3f}\".format})\n",
|
||||
"print('A =', A, sep='\\n')\n",
|
||||
"print('A_est =', A_est, sep='\\n')\n",
|
||||
"print('B =', B, sep='\\n')\n",
|
||||
"print('B_est =', B_est, sep='\\n')"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"\n",
|
||||
"## Three Problems for HMMs\n",
|
||||
"\n",
|
||||
"We have seen how a given HMM can be used to generate an observation sequence. We will now look at three famous algorithmic problems for HMMs that concern the specification of the free model parameters and the evaluation of observation sequences. \n",
|
||||
"\n",
|
||||
"### 1. Evaluation Problem\n",
|
||||
"\n",
|
||||
"The first problem is known as **evaluation problem**. Given an HMM specified by $\\Theta=(\\mathcal{A},A,C,\\mathcal{B},B)$ and an observation sequence $O=(o_{1},o_{2},\\ldots,o_{N})$, the task is to compute the probability \n",
|
||||
"\n",
|
||||
"\\begin{equation}\n",
|
||||
" P[O|\\Theta]\n",
|
||||
"\\end{equation}\n",
|
||||
"\n",
|
||||
"of the observation sequence given the model. From a slightly different viewpoint, this probability can be regarded as a score value that expresses how well a given model matches a given observation sequence. This interpretation becomes useful in the case where one is trying to choose among several competing models. The solution would then be to choose the model which best matches the observation sequence. To compute $P[O|\\Theta]$, we first consider a fixed state sequence $S=(s_1,s_2,\\ldots,s_N)$ of length $N$ with $s_n=\\alpha_{i_n}\\in\\mathcal{A}$ for some suitable $i_n\\in[1:I]$, $n\\in[1:N]$. The probability $P[O,S|\\Theta]$ for generating the state sequence $S$ as well as the observation sequence $O$ is given by \n",
|
||||
"\n",
|
||||
"$$\n",
|
||||
"P[O,S|\\Theta] = c_{i_1}\\cdot b_{i_1k_1} \\cdot a_{i_1i_2}\\cdot b_{i_2k_2} \\cdot ...\\cdot a_{i_{N-1}i_N}\\cdot b_{i_Nk_N}\n",
|
||||
"$$\n",
|
||||
"\n",
|
||||
"Next, to obtain the overall probability $P[O|\\Theta]$, one needs to sum up all these probabilities considering all possible state sequences $S$ of length $|S|=N$:\n",
|
||||
"\n",
|
||||
"$$\n",
|
||||
"P[O|\\Theta] = \\sum_{S: |S|=N}P[O,S|\\Theta]\n",
|
||||
"= \\sum_{i_1=1}^I \\sum_{i_2=1}^I \\ldots \\sum_{i_N=1}^I\n",
|
||||
"c_{i_1}\\cdot b_{i_1k_1} \\cdot a_{i_1i_2}\\cdot b_{i_2k_2} \\cdot ...\\cdot a_{i_{N-1}i_N}\\cdot b_{i_Nk_N}\n",
|
||||
"$$\n",
|
||||
"\n",
|
||||
"This leads to $I^N$ summands, a number that is exponential in the length $N$ of the observation sequence. Therefore, in practice, this brute-force calculation is computationally infeasible even for a small $N$. The good news is that there is a more efficient way to compute $P[O|\\Theta]$ using an algorithm that is based on the dynamic programming paradigm. This procedure, which is known as [**Forward–Backward Algorithm**](https://en.wikipedia.org/wiki/Forward%E2%80%93backward_algorithm), requires a number of operations on the order of $I^2N$ (instead of $I^N$). For a detailed description of this algorithm, we refer to the article by [Rabiner](https://ieeexplore.ieee.org/document/18626).\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"### 2. Uncovering Problem\n",
|
||||
"\n",
|
||||
"The second problem is the so-called **uncovering problem**. Again, we are given an HMM specified by $\\Theta=(\\mathcal{A},A,C,\\mathcal{B},B)$ and an observation sequence $O=(o_{1},o_{2},\\ldots,o_{N})$. Instead of finding the overall probability $P[O|\\Theta]$ for $O$, where one needs to consider **all** possible state sequences, the goal of the uncovering problem is to find the **single** state sequence $S=(s_{1},s_{2},\\ldots,s_{N})$ that \"best explains\" the observation sequence. The uncovering problem stated so far is not well defined since, in general, there is not a single \"correct\" state sequence generating the observation sequence. Indeed, one needs a kind of optimization criterion that specifies what is meant when talking about a best possible explanation. There are several reasonable choices for such a criterion, and the actual choice will depend on the intended application. In the [FMP notebook on the Viterbi algorithm](../C5/C5S3_Viterbi.html), we will discuss one possible choice as well as an efficient algorithm (called **Viterbi algorithm**). This algorithm, which can be thought of as a kind of context-sensitive smoothing procedure, will apply in the [FMP notebook on HMM-based chord recognition](../C5/C5S3_ChordRec_HMM.html). \n",
|
||||
"\n",
|
||||
"\n",
|
||||
"### 3. Estimation Problem\n",
|
||||
"\n",
|
||||
"Besides the evaluation and uncovering problems, the third basic problem for HMMs is referred to as the **estimation problem**. Given an observation sequence $O$, the objective is to determine the free model parameters of $\\Theta$ (specified by by $A$, $C$, and $B$) that maximize the probability $P[O|\\Theta]$. In other words, the free model parameters are to be estimated so as to best describe the observation sequence. This is a typical instance of an **optimization problem** where a set of observation sequences serves as **training material** for adjusting or learning the HMM parameters. The estimation problem is by far the most difficult problem of HMMs. In fact, there is no known way to explicitly solve the given optimization problem. However, iterative procedures that find locally optimal solutions have been suggested. One of these procedures is known as the [**Baum–Welch Algorithm**](https://en.wikipedia.org/wiki/Baum%E2%80%93Welch_algorithm). Again, we refer to the article by [Rabiner](https://ieeexplore.ieee.org/document/18626) for more details. "
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"<div class=\"alert\" style=\"background-color:#F5F5F5; border-color:#C8C8C8\">\n",
|
||||
"<strong>Acknowledgment:</strong> This notebook was created by <a href=\"https://www.audiolabs-erlangen.de/fau/professor/mueller\">Meinard Müller</a>.\n",
|
||||
"</div> "
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"<table style=\"border:none\">\n",
|
||||
"<tr style=\"border:none\">\n",
|
||||
" <td style=\"min-width:50px; border:none\" bgcolor=\"white\"><a href=\"../C0/C0.html\"><img src=\"../data/C0_nav.png\" style=\"height:50px\" alt=\"C0\"></a></td>\n",
|
||||
" <td style=\"min-width:50px; border:none\" bgcolor=\"white\"><a href=\"../C1/C1.html\"><img src=\"../data/C1_nav.png\" style=\"height:50px\" alt=\"C1\"></a></td>\n",
|
||||
" <td style=\"min-width:50px; border:none\" bgcolor=\"white\"><a href=\"../C2/C2.html\"><img src=\"../data/C2_nav.png\" style=\"height:50px\" alt=\"C2\"></a></td>\n",
|
||||
" <td style=\"min-width:50px; border:none\" bgcolor=\"white\"><a href=\"../C3/C3.html\"><img src=\"../data/C3_nav.png\" style=\"height:50px\" alt=\"C3\"></a></td>\n",
|
||||
" <td style=\"min-width:50px; border:none\" bgcolor=\"white\"><a href=\"../C4/C4.html\"><img src=\"../data/C4_nav.png\" style=\"height:50px\" alt=\"C4\"></a></td>\n",
|
||||
" <td style=\"min-width:50px; border:none\" bgcolor=\"white\"><a href=\"../C5/C5.html\"><img src=\"../data/C5_nav.png\" style=\"height:50px\" alt=\"C5\"></a></td>\n",
|
||||
" <td style=\"min-width:50px; border:none\" bgcolor=\"white\"><a href=\"../C6/C6.html\"><img src=\"../data/C6_nav.png\" style=\"height:50px\" alt=\"C6\"></a></td>\n",
|
||||
" <td style=\"min-width:50px; border:none\" bgcolor=\"white\"><a href=\"../C7/C7.html\"><img src=\"../data/C7_nav.png\" style=\"height:50px\" alt=\"C7\"></a></td>\n",
|
||||
" <td style=\"min-width:50px; border:none\" bgcolor=\"white\"><a href=\"../C8/C8.html\"><img src=\"../data/C8_nav.png\" style=\"height:50px\" alt=\"C8\"></a></td>\n",
|
||||
"</tr>\n",
|
||||
"</table>"
|
||||
]
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"anaconda-cloud": {},
|
||||
"kernelspec": {
|
||||
"display_name": "Python 3 (ipykernel)",
|
||||
"language": "python",
|
||||
"name": "python3"
|
||||
},
|
||||
"language_info": {
|
||||
"codemirror_mode": {
|
||||
"name": "ipython",
|
||||
"version": 3
|
||||
},
|
||||
"file_extension": ".py",
|
||||
"mimetype": "text/x-python",
|
||||
"name": "python",
|
||||
"nbconvert_exporter": "python",
|
||||
"pygments_lexer": "ipython3",
|
||||
"version": "3.8.10"
|
||||
}
|
||||
},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 4
|
||||
}
|
||||
@@ -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,47 +0,0 @@
|
||||
"""Some simple tests/examples for the Home Assistant client."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import sys
|
||||
|
||||
from hass_client import HomeAssistantClient
|
||||
|
||||
LOGGER = logging.getLogger()
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
logformat = logging.Formatter(
|
||||
"%(asctime)-15s %(levelname)-5s %(name)s.%(module)s -- %(message)s")
|
||||
consolehandler = logging.StreamHandler()
|
||||
consolehandler.setFormatter(logformat)
|
||||
LOGGER.addHandler(consolehandler)
|
||||
LOGGER.setLevel(logging.DEBUG)
|
||||
|
||||
if len(sys.argv) < 3:
|
||||
LOGGER.error("usage: test.py <url> <token>")
|
||||
sys.exit()
|
||||
|
||||
url = sys.argv[1]
|
||||
token = sys.argv[2]
|
||||
loop = asyncio.get_event_loop()
|
||||
hass = HomeAssistantClient(url, token)
|
||||
|
||||
async def hass_event(event, event_details):
|
||||
"""Handle hass event callback."""
|
||||
LOGGER.info("received event %s --> %s\n", event, event_details)
|
||||
|
||||
hass.register_event_callback(hass_event)
|
||||
|
||||
async def run():
|
||||
"""Run tests."""
|
||||
await hass.async_connect()
|
||||
await asyncio.sleep(10)
|
||||
await hass.async_close()
|
||||
loop.stop()
|
||||
|
||||
try:
|
||||
loop.create_task(run())
|
||||
loop.run_forever()
|
||||
except KeyboardInterrupt:
|
||||
loop.stop()
|
||||
loop.close()
|
||||
@@ -1,198 +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,
|
||||
}
|
||||
|
||||
shelve_led_effect_to_message_id = {
|
||||
EffectStaticConfig: 15,
|
||||
EffectCircularConfig: 16,
|
||||
EffectRandomTwoColorInterpolationConfig: 17,
|
||||
EffectSwipeAndChange: 18,
|
||||
EffectReverseSwipe: 19,
|
||||
EffectStaticDetailedConfig: 20,
|
||||
}
|
||||
|
||||
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 = 21
|
||||
NEXT_BUTTON_LED_MSG = 22
|
||||
|
||||
|
||||
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_effect(self, effect_cfg, msg_dict):
|
||||
msg_content = effect_cfg.as_bytes()
|
||||
header = struct.pack("<IBH", MAGIC_TOKEN_HOST_TO_FW, msg_dict[type(effect_cfg)],
|
||||
len(msg_content))
|
||||
self.transport.write(header + msg_content)
|
||||
|
||||
def led_ring_effect(self, effect_cfg):
|
||||
self.__led_effect(effect_cfg, led_ring_effect_to_message_id)
|
||||
|
||||
def mouse_led_effect(self, effect_cfg):
|
||||
self.__led_effect(effect_cfg, mouse_led_effect_to_message_id)
|
||||
|
||||
def shelve_led_effect(self, effect_cfg):
|
||||
self.__led_effect(effect_cfg, shelve_led_effect_to_message_id)
|
||||
|
||||
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,167 +0,0 @@
|
||||
from dataclasses import dataclass, field
|
||||
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)
|
||||
|
||||
def __mul__(self, other:float):
|
||||
assert 0<= other <= 1
|
||||
return ColorRGBW(self.r * other, self.g * other, self.b * other, self.w * other)
|
||||
|
||||
def __eq__(self, other:'ColorRGBW'):
|
||||
return self.r == other.r and self.g == other.g and self.b == other.b and self.w == other.w
|
||||
|
||||
def __neq__(self, other:'ColorRGBW'):
|
||||
return not self == other
|
||||
|
||||
def without_white_channel(self, scale=1):
|
||||
args = (min(1, e + self.w) for e in (self.r, self.g, self.b) )
|
||||
return ColorRGBW(*args, 0)
|
||||
|
||||
@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 EffectStaticDetailedConfig:
|
||||
color: ColorRGBW
|
||||
increment: int = 1
|
||||
begin: float = 0.0
|
||||
end: float = 1.0
|
||||
transition_time_in_ms : float = 500
|
||||
|
||||
def __repr__(self):
|
||||
return f"EffectStaticDetailedConfig {str(self.color)}, beg: {self.begin}, end {self.end}, incr {self.increment}, transition in ms {self.transition_time_in_ms}"
|
||||
|
||||
def as_bytes(self) -> bytes:
|
||||
return self.color.as_bytes() + struct.pack("<Hfff", self.increment, self.begin, self.end, self.transition_time_in_ms)
|
||||
|
||||
@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 = field(default_factory=lambda: ColorRGBW(0, 0, 1, 0))
|
||||
secondary_color: ColorRGBW = field(default_factory=lambda: 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 = field(default_factory=lambda: ColorHSV(240, 1, 1))
|
||||
color2: ColorHSV = field(default_factory=lambda: 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 = field(default_factory=lambda: 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 = field(default_factory=lambda: EffectAlexaSwipeConfig())
|
||||
change: EffectRandomTwoColorInterpolationConfig = field(default_factory=lambda: 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,271 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
import serial_asyncio
|
||||
from led_cmds import (ColorRGBW, ColorHSV, EffectCircularConfig, 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
|
||||
from typing import Optional
|
||||
from mqtt_json import start_mqtt
|
||||
import aiohttp
|
||||
|
||||
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.active_figure: Optional[
|
||||
str] = None # None if no figure is placed on the reader, or the name of the figure
|
||||
self.last_partially_played_figure: Optional[
|
||||
str] = None # figure whose playlist wasn't played completely and was removed
|
||||
|
||||
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)
|
||||
self.protocol.shelve_led_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))
|
||||
|
||||
|
||||
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._on_playlist_end
|
||||
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()
|
||||
}
|
||||
|
||||
self.protocol.shelve_led_effect(EffectStaticConfig(ColorRGBW(0, 0, 0.1, 0)))
|
||||
shelf_eff = EffectCircularConfig()
|
||||
shelf_eff.color = ColorRGBW(0, 0, 0.4, 0)
|
||||
shelf_eff = EffectStaticConfig(ColorRGBW(0, 0, 0, 0))
|
||||
self.protocol.shelve_led_effect(shelf_eff)
|
||||
|
||||
def _on_playlist_end(self):
|
||||
if not self.audio_player.is_playing():
|
||||
self.mmstate.last_partially_played_figure = None
|
||||
self._run_off_animation()
|
||||
else:
|
||||
print("Playlist end was called, even if player remains playing?!")
|
||||
|
||||
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.last_partially_played_figure = self.mmstate.active_figure
|
||||
else:
|
||||
self.mmstate.last_partially_played_figure = None
|
||||
|
||||
self.mmstate.active_figure = None
|
||||
elif tagid in self._rfid_to_figure_name:
|
||||
newly_placed_figure = self._rfid_to_figure_name[tagid]
|
||||
primary_color, secondary_color, *rest = self.cfg["figures"][newly_placed_figure][
|
||||
"colors"]
|
||||
self._start_animation(primary_color, secondary_color)
|
||||
self.mmstate.button_leds(self.cfg["general"].get("button_leds_brightness", 0.5))
|
||||
|
||||
if newly_placed_figure in self.cfg['figures']:
|
||||
if self.mmstate.last_partially_played_figure == newly_placed_figure:
|
||||
print("Continuing playlist")
|
||||
self.audio_player.play()
|
||||
else:
|
||||
print("Restarting playlist")
|
||||
self.audio_player.set_playlist(
|
||||
self.audio_player.create_playlist(
|
||||
self.cfg['figures'][newly_placed_figure]['media_files']))
|
||||
self.audio_player.play_from_start()
|
||||
|
||||
self.mmstate.active_figure = newly_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():
|
||||
self.audio_player.previous()
|
||||
elif btn == "right" and message.event == "pressed" and self.audio_player.is_playing():
|
||||
self.audio_player.next()
|
||||
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.active_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", "light.music_mouse_regal_licht"],
|
||||
**colors[message.touch_button])
|
||||
|
||||
elif isinstance(message, TouchButtonRelease):
|
||||
figure = self.mmstate.active_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=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)
|
||||
mqtt_cfg = cfg["general"]["mqtt"]
|
||||
loop.create_task(start_mqtt(protocol, mqtt_cfg["server"], mqtt_cfg["user"], mqtt_cfg["password"] ))
|
||||
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,197 +0,0 @@
|
||||
from led_cmds import ColorRGBW, EffectStaticConfig, EffectStaticDetailedConfig, EffectCircularConfig, EffectRandomTwoColorInterpolationConfig, EffectAlexaSwipeConfig, EffectSwipeAndChange
|
||||
import asyncio
|
||||
import aiomqtt
|
||||
import json
|
||||
from copy import deepcopy
|
||||
|
||||
|
||||
class ShelveLightMqtt:
|
||||
def __init__(self, protocol, client: aiomqtt.Client):
|
||||
self._protocol = protocol
|
||||
self._mqtt_client = client
|
||||
|
||||
self._state = {
|
||||
"state": "OFF",
|
||||
"color": {
|
||||
"r": 255,
|
||||
"g": 255,
|
||||
"b": 255,
|
||||
"w": 0,
|
||||
},
|
||||
"color_mode": "rgbw",
|
||||
"brightness": 30,
|
||||
"effect": "static",
|
||||
}
|
||||
self._last_color = ColorRGBW(0.5, 0.5, 0.5, 0)
|
||||
|
||||
self._discovery_spec = self._create_discovery_msg_light()
|
||||
|
||||
async def init(self):
|
||||
"""Init method, because constructor can't be async"""
|
||||
self._protocol.shelve_led_effect(EffectStaticConfig(ColorRGBW(0, 0, 0, 0)))
|
||||
await self._send_autodiscovery_msg()
|
||||
await self._notify_mqtt_state({"state": "OFF"})
|
||||
|
||||
async def handle_light_message(self, msg):
|
||||
if msg.topic.value == self._discovery_spec['command_topic']:
|
||||
payload = msg.payload.decode()
|
||||
new_state = json.loads(payload)
|
||||
print("IN ", new_state)
|
||||
await self._update_state(new_state)
|
||||
await self._notify_mqtt_state(new_state)
|
||||
|
||||
async def _update_state(self, new_state):
|
||||
"""Merges current state with new state, updates device"""
|
||||
|
||||
# memorize last color - this is used for effects that need 2 colors
|
||||
if 'color' in new_state:
|
||||
brightness = new_state.get('brightness', self._state['brightness'])
|
||||
new_color = self._color_from_json(new_state['color'], brightness)
|
||||
current_color = self._color_from_json(self._state['color'])
|
||||
if new_color != current_color:
|
||||
self._last_color = current_color
|
||||
print("last color", self._last_color)
|
||||
|
||||
self._state.update(new_state)
|
||||
self._update_device()
|
||||
|
||||
@staticmethod
|
||||
def _color_from_json(json_color, brightness=255):
|
||||
args = ((json_color[e] / 255) * (brightness / 255) for e in ('r', 'g', 'b', 'w'))
|
||||
return ColorRGBW(*args)
|
||||
|
||||
def _update_device(self):
|
||||
s = self._state
|
||||
current_color = self._color_from_json(s['color'], brightness=s["brightness"])
|
||||
transition = s.get("transition", 0.3) * 1000
|
||||
print(f"Effect {s['effect']} Transition {transition}")
|
||||
|
||||
if s['state'] == "OFF":
|
||||
if transition > 0:
|
||||
eff = EffectStaticDetailedConfig(ColorRGBW(0,0,0,0), transition_time_in_ms=transition)
|
||||
else:
|
||||
eff = EffectStaticConfig(ColorRGBW(0, 0, 0, 0))
|
||||
elif s['effect'] == 'static':
|
||||
if transition > 0:
|
||||
eff = EffectStaticDetailedConfig(current_color, transition_time_in_ms=transition)
|
||||
else:
|
||||
eff = EffectStaticConfig(current_color)
|
||||
elif s['effect'] == 'circular':
|
||||
eff = EffectCircularConfig(speed=180, width=90, color=current_color)
|
||||
elif s['effect'] == 'wipeup':
|
||||
eff = EffectSwipeAndChange()
|
||||
eff.swipe.secondary_color = current_color
|
||||
eff.swipe.primary_color = self._last_color
|
||||
eff.swipe.bell_curve_width_in_leds = 10
|
||||
eff.swipe.transition_width = 30
|
||||
eff.swipe.start_position = 0
|
||||
eff.swipe.swipe_speed = 260
|
||||
eff.change.color1 = current_color
|
||||
eff.change.color2 = self._last_color
|
||||
elif s['effect'] == "twocolor":
|
||||
eff = EffectRandomTwoColorInterpolationConfig()
|
||||
eff.color1 = current_color
|
||||
eff.color2 = self._last_color
|
||||
eff.start_with_existing = True
|
||||
elif s['effect'] == "twocolorrandom":
|
||||
eff = EffectRandomTwoColorInterpolationConfig()
|
||||
eff.color1 = current_color
|
||||
eff.color2 = self._last_color
|
||||
eff.hue1_random = True
|
||||
eff.hue2_random = True
|
||||
eff.start_with_existing = True
|
||||
elif s['effect'] == "side_0.2":
|
||||
eff = EffectStaticDetailedConfig(current_color, begin=0.9, end=0.1, increment=1, transition_time_in_ms=transition)
|
||||
elif s['effect'] == "side_0.2_inc4":
|
||||
eff = EffectStaticDetailedConfig(current_color, begin=0.9, end=0.1, increment=4, transition_time_in_ms=transition)
|
||||
elif s['effect'] == "side_0.2_inc8":
|
||||
eff = EffectStaticDetailedConfig(current_color, begin=0.9, end=0.1, increment=8, transition_time_in_ms=transition)
|
||||
elif s['effect'] == "side_0.5":
|
||||
eff = EffectStaticDetailedConfig(current_color, begin=0.75, end=0.25, increment=1, transition_time_in_ms=transition)
|
||||
elif s['effect'] == "side_0.5_inc4":
|
||||
eff = EffectStaticDetailedConfig(current_color, begin=0.75, end=0.25, increment=4, transition_time_in_ms=transition)
|
||||
elif s['effect'] == "top_0.2":
|
||||
eff = EffectStaticDetailedConfig(current_color, begin=0.4, end=0.6, increment=1, transition_time_in_ms=transition)
|
||||
elif s['effect'] == "top_0.2_inc4":
|
||||
eff = EffectStaticDetailedConfig(current_color, begin=0.4, end=0.6, increment=4, transition_time_in_ms=transition)
|
||||
elif s['effect'] == "top_0.5":
|
||||
eff = EffectStaticDetailedConfig(current_color, begin=0.25, end=0.75, increment=1, transition_time_in_ms=transition)
|
||||
elif s['effect'] == "top_0.5_inc4":
|
||||
eff = EffectStaticDetailedConfig(current_color, begin=0.25, end=0.75, increment=4, transition_time_in_ms=transition)
|
||||
else:
|
||||
print(f"Unknown effect {s['effect']}")
|
||||
eff = EffectStaticConfig(ColorRGBW(0, 0, 0, 0))
|
||||
self._protocol.shelve_led_effect(eff)
|
||||
|
||||
@staticmethod
|
||||
def _create_discovery_msg_light(base_name="musicmouse_json",
|
||||
display_name="Music Mouse Regal Licht"):
|
||||
id = "shelve"
|
||||
return {
|
||||
'platform': 'mqtt',
|
||||
'schema': 'json',
|
||||
'name': display_name,
|
||||
'unique_id': f'{base_name}_{id}',
|
||||
'command_topic': f'{base_name}/lights_{id}/command',
|
||||
'state_topic': f'{base_name}/lights_{id}/state',
|
||||
'color_mode': True,
|
||||
'brightness': True,
|
||||
#'device': {
|
||||
# 'manufacturer': 'bauer.tech',
|
||||
# 'model': "SK6812 LED strip",
|
||||
#},
|
||||
'effect': True,
|
||||
'effect_list': ['static', 'circular', 'wipeup', 'twocolor', 'twocolorrandom',
|
||||
"side_0.2", "side_0.5", "side_0.2_inc4", "side_0.2_inc8", "side_0.5_inc4",
|
||||
"top_0.2", "top_0.5", "top_0.2_inc4", "top_0.5_inc4"],
|
||||
'supported_color_modes': ['rgbw'],
|
||||
}
|
||||
|
||||
async def _send_autodiscovery_msg(self):
|
||||
topic = f"homeassistant/light/{self._discovery_spec['unique_id']}/config"
|
||||
await self._mqtt_client.publish(topic, json.dumps(self._discovery_spec).encode(), retain=True)
|
||||
|
||||
async def _notify_mqtt_state(self, state):
|
||||
state_payload = json.dumps(self._state)
|
||||
print("OUT ", state_payload)
|
||||
await self._mqtt_client.publish(self._discovery_spec['state_topic'], state_payload.encode())
|
||||
return
|
||||
|
||||
direct_ack = False
|
||||
if direct_ack == True:
|
||||
state_payload = json.dumps(state)
|
||||
else:
|
||||
s = deepcopy(self._state)
|
||||
if s['state'] == "OFF":
|
||||
state_payload = json.dumps({"state": "OFF"})
|
||||
else:
|
||||
s['color_mode'] = "rgbw"
|
||||
state_payload = json.dumps(s)
|
||||
|
||||
print("OUT ", state_payload)
|
||||
await self._mqtt_client.publish(self._discovery_spec['state_topic'], state_payload.encode())
|
||||
|
||||
|
||||
async def start_mqtt(music_mouse_protocol, server, username, password):
|
||||
reconnect_interval = 10 # [seconds]
|
||||
while True:
|
||||
try:
|
||||
async with aiomqtt.Client(hostname=server, username=username, password=password) as client:
|
||||
shelve_light = ShelveLightMqtt(music_mouse_protocol, client)
|
||||
await shelve_light.init()
|
||||
await client.subscribe("musicmouse_json/#")
|
||||
async for message in client.messages:
|
||||
await shelve_light.handle_light_message(message)
|
||||
except aiomqtt.MqttError as error:
|
||||
print(f'Error "{error}". Reconnecting in {reconnect_interval} seconds')
|
||||
finally:
|
||||
await asyncio.sleep(reconnect_interval)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
class DummyProtocol:
|
||||
def shelve_led_effect(self, effect):
|
||||
print("EFF ", repr(effect))
|
||||
|
||||
password = ""
|
||||
asyncio.run(start_mqtt(DummyProtocol(), "homeassistant", "musicmouse", password))
|
||||
@@ -1,82 +0,0 @@
|
||||
import vlc
|
||||
|
||||
|
||||
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,5 +0,0 @@
|
||||
pyserial-asyncio==0.6
|
||||
python-vlc==3.0.20123
|
||||
hass-client==0.1.2
|
||||
ruamel.yaml==0.18.6
|
||||
aiomqtt==2.0.0
|
||||
@@ -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,13 +0,0 @@
|
||||
# Put this into /etc/systemd/system/musicmouse.service
|
||||
[Unit]
|
||||
Description=Music Mouse RFID Music Player
|
||||
After=multi-user.target
|
||||
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
Restart=always
|
||||
ExecStart=/opt/musicmouse/venv/bin/python /opt/musicmouse/espmusicmouse/host_driver/main.py /media/musicmouse/
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -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();
|
||||
}
|
||||
|
Before Width: | Height: | Size: 1.3 MiB 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/raw/croco.stl
Normal file
BIN
hardware/3dprints/figures/raw/croco.stl
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.
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
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user