Compare commits
42 Commits
ec9d617ad8
...
rearchitec
| Author | SHA1 | Date | |
|---|---|---|---|
| cf38ae6fe2 | |||
| de94bd4118 | |||
| 13d14c00aa | |||
| 701e90153c | |||
| e94e39addc | |||
| 8ddfafebcb | |||
| 17db811bba | |||
| b56b41996b | |||
| 7b9e072713 | |||
| 4cb2d085ca | |||
| a737ccf108 | |||
| a0ac48ccbf | |||
| 751637d2f1 | |||
| 7a7ad9f0a7 | |||
| b791beed9c | |||
| 14b5133543 | |||
| ecb35dd5b5 | |||
| 4428357e13 | |||
| 34f2f016f9 | |||
| d465a74cde | |||
| a55cc4f67f | |||
| 30065bb9e2 | |||
| 1254790374 | |||
| c74cb09324 | |||
| 077c4e819f | |||
| 459599d2ba | |||
| 92305bece0 | |||
| 2d0caeb732 | |||
| 92644e9519 | |||
| f67f84ded3 | |||
| 34656a8517 | |||
| 1da2274076 | |||
| 2a629faf50 | |||
| 21d65cad9e | |||
| 6981890be0 | |||
| 5af5c54c62 | |||
| 0f08e1d16c | |||
| 61104d1e25 | |||
| 683cda35cb | |||
| 2db4368fc9 | |||
| 9fd106b757 | |||
| 794126bfdb |
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);
|
||||||
|
})();
|
||||||
@@ -20,7 +20,9 @@ Assistant integration.
|
|||||||
| `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. |
|
| `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. |
|
| `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). |
|
| `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 spec the web front-end was built from ("Dolphin Beats"), as a standalone HTML mockup with hardcoded data. `web/` is the real implementation; the mockup is kept as the reference for the keyboard model and the styling. It also contains a second page, "Mein Zimmer" (room lights), which is **not** implemented. |
|
| `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
|
## How the pieces talk to each other
|
||||||
|
|
||||||
@@ -41,12 +43,18 @@ Assistant integration.
|
|||||||
out of step with a figure someone puts on the reader. See
|
out of step with a figure someone puts on the reader. See
|
||||||
`python-backend/README.md` for the endpoint list.
|
`python-backend/README.md` for the endpoint list.
|
||||||
|
|
||||||
- **`python-backend` ↔ Home Assistant**: MQTT only. The backend publishes three
|
- **`python-backend` ↔ Home Assistant**: two separate paths.
|
||||||
discoverable lights, a player sensor, a volume number, transport buttons, device
|
- MQTT, for the mouse itself. The backend publishes three discoverable lights, a
|
||||||
triggers for every button and touch area, and a tag scanner for the RFID reader. It
|
player sensor, a volume number, transport buttons, device triggers for every button
|
||||||
does *not* call Home Assistant services directly any more; behaviour like "the left
|
and touch area, and a tag scanner for the RFID reader. Behaviour like "the left ear
|
||||||
ear turns the room light pink" is an HA automation. See `python-backend/README.md`
|
turns the room light pink" is an HA automation, not backend code. See
|
||||||
for the trigger topics and the old colour mapping.
|
`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
|
## Running it
|
||||||
|
|
||||||
@@ -89,16 +97,20 @@ production so one process serves both the UI and the API.
|
|||||||
|
|
||||||
- `general.{library.*, serial_port, baudrate, reconnect_interval, alsa_device,
|
- `general.{library.*, serial_port, baudrate, reconnect_interval, alsa_device,
|
||||||
min_volume, max_volume, initial_volume, volume_increment, button_leds_brightness,
|
min_volume, max_volume, initial_volume, volume_increment, button_leds_brightness,
|
||||||
audio_extensions, mqtt.*, web.*}`
|
audio_extensions, podcast_episode_limit}`, plus optional sections `mqtt`, `web`, `ha`
|
||||||
- `figures.<name>.{id, colors}` — `id` is a 5-byte hex RFID tag, unique per figure;
|
(room page), `lirc` (IR remote), `screen` (monitor on/off), `tippen` (typing game)
|
||||||
`colors` is exactly four (`primary, secondary, bg, accent`), each `"#rrggbb"` or
|
and `clock` (school bus countdown).
|
||||||
`"wNN"`.
|
- `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
|
`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:
|
names, not settings, because each has quirks the scanner knows about:
|
||||||
|
|
||||||
```
|
```
|
||||||
<root>/Figuren/<figure name>/ one folder per figurine
|
<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>/Musik/<Artist> - <Album>/ albums, grouped by artist
|
||||||
<root>/Hörbücher/<Artist> - <Album>/ audiobooks, grouped by character
|
<root>/Hörbücher/<Artist> - <Album>/ audiobooks, grouped by character
|
||||||
<root>/Kinderpodcasts/<Show>/ shows, newest episode first
|
<root>/Kinderpodcasts/<Show>/ shows, newest episode first
|
||||||
@@ -109,16 +121,16 @@ reported at once.
|
|||||||
|
|
||||||
## Notes for agents
|
## Notes for agents
|
||||||
|
|
||||||
- The backend has `pytest`, `ruff` and `mypy --strict` configured in
|
- There is no CI. `scripts/check.sh` runs every check the repo has - backend
|
||||||
`python-backend/pyproject.toml`, and no CI. Run all three before proposing changes.
|
`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
|
- 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.
|
the behaviour is end-to-end — those files are executed by the test suite.
|
||||||
- `python-backend/notebooks/` is university course material on chord recognition, not
|
- `python-backend/notebooks/` is LED-effect scratch work, not part of the app.
|
||||||
part of the app.
|
|
||||||
- The firmware has no automated tests beyond a PlatformIO `native` env for LED effects.
|
- 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/`) and `tsc --noEmit`;
|
- The front-end has `vitest` over the pure modules (`web/src/lib/`), `tsc` and `eslint`;
|
||||||
there is no component-level test harness. `npm run test` and `npx tsc --noEmit` are
|
there is no component-level test harness.
|
||||||
the two checks.
|
|
||||||
- The library index is cached under `general.library.cache`. It is keyed by file mtime
|
- 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
|
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
|
invisible until the cache is invalidated** — bump `_INDEX_VERSION` in
|
||||||
|
|||||||
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.
|
||||||
@@ -2,93 +2,72 @@
|
|||||||
|
|
||||||
#include "drivers/Esp32DriverRGBW.h"
|
#include "drivers/Esp32DriverRGBW.h"
|
||||||
|
|
||||||
#include <driver/gpio.h>
|
#include <driver/rmt_encoder.h>
|
||||||
#include <driver/rmt.h>
|
#include <esp_check.h>
|
||||||
|
|
||||||
// Timing constants
|
// Timing constants, in ns
|
||||||
static constexpr uint16_t DIVIDER = 4;
|
static constexpr uint32_t RESOLUTION_HZ = 20 * 1000 * 1000; // one RMT tick = 50 ns
|
||||||
static constexpr double RMT_DURATION_NS = 12.5; // Minimum time of a single RMT duration based on clock ns
|
static constexpr uint32_t TICK_NS = 1000 * 1000 * 1000 / RESOLUTION_HZ;
|
||||||
static constexpr uint32_t T0H = 300;
|
static constexpr uint32_t T0H = 300;
|
||||||
static constexpr uint32_t T0L = 900;
|
static constexpr uint32_t T0L = 900;
|
||||||
static constexpr uint32_t T1H = 600;
|
static constexpr uint32_t T1H = 600;
|
||||||
static constexpr uint32_t T1L = 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
|
static rmt_symbol_word_t symbol(uint32_t highNs, uint32_t lowNs)
|
||||||
// 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)
|
rmt_symbol_word_t s = {};
|
||||||
{
|
s.duration0 = highNs / TICK_NS;
|
||||||
*translatedSize = 0;
|
s.level0 = 1;
|
||||||
*itemNum = 0;
|
s.duration1 = lowNs / TICK_NS;
|
||||||
return;
|
s.level1 = 0;
|
||||||
}
|
return s;
|
||||||
|
|
||||||
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)
|
void Esp32DriverRGBW::begin(int gpio)
|
||||||
{
|
{
|
||||||
rmtChannel_ = rmtChannel;
|
|
||||||
transmitting_ = false;
|
transmitting_ = false;
|
||||||
|
|
||||||
rmt_config_t rmt_tx;
|
rmt_tx_channel_config_t channelConfig = {};
|
||||||
rmt_tx.channel = static_cast<rmt_channel_t>(rmtChannel);
|
channelConfig.gpio_num = static_cast<gpio_num_t>(gpio);
|
||||||
rmt_tx.gpio_num = static_cast<gpio_num_t>(gpio);
|
channelConfig.clk_src = RMT_CLK_SRC_DEFAULT;
|
||||||
rmt_tx.rmt_mode = RMT_MODE_TX;
|
channelConfig.resolution_hz = RESOLUTION_HZ;
|
||||||
rmt_tx.mem_block_num = 1;
|
channelConfig.mem_block_symbols = 64; // one block, refilled from the interrupt
|
||||||
rmt_tx.clk_div = DIVIDER;
|
channelConfig.trans_queue_depth = 4;
|
||||||
rmt_tx.tx_config.loop_en = false;
|
ESP_ERROR_CHECK(rmt_new_tx_channel(&channelConfig, &channel_));
|
||||||
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));
|
// Every byte goes out MSB first, one RMT symbol per bit - the strip's own byte order
|
||||||
ESP_ERROR_CHECK(rmt_driver_install(rmt_tx.channel, 0, 0));
|
// (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_));
|
||||||
|
|
||||||
rmt_translator_init((rmt_channel_t)rmtChannel, uint8ToRmtAdaptor);
|
ESP_ERROR_CHECK(rmt_enable(channel_));
|
||||||
}
|
}
|
||||||
|
|
||||||
void Esp32DriverRGBW::end()
|
void Esp32DriverRGBW::end()
|
||||||
{
|
{
|
||||||
ESP_ERROR_CHECK(rmt_driver_uninstall((rmt_channel_t)rmtChannel_));
|
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)
|
void Esp32DriverRGBW::writeSync(const uint32_t *rgbwData, int numLeds)
|
||||||
{
|
{
|
||||||
|
writeAsync(rgbwData, numLeds);
|
||||||
waitForTransmissionToFinish();
|
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)
|
void Esp32DriverRGBW::writeAsync(const uint32_t *rgbwData, int numLeds)
|
||||||
{
|
{
|
||||||
waitForTransmissionToFinish();
|
waitForTransmissionToFinish();
|
||||||
auto data = reinterpret_cast<const uint8_t *>(rgbwData);
|
rmt_transmit_config_t transmitConfig = {};
|
||||||
ESP_ERROR_CHECK(rmt_write_sample((rmt_channel_t)rmtChannel_, data, numLeds * 4, false));
|
transmitConfig.loop_count = 0;
|
||||||
|
ESP_ERROR_CHECK(rmt_transmit(channel_, encoder_, rgbwData, numLeds * 4, &transmitConfig));
|
||||||
transmitting_ = true;
|
transmitting_ = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,8 +75,7 @@ bool Esp32DriverRGBW::waitForTransmissionToFinish(int waitMs)
|
|||||||
{
|
{
|
||||||
if (!transmitting_)
|
if (!transmitting_)
|
||||||
return true;
|
return true;
|
||||||
auto ret = rmt_wait_tx_done((rmt_channel_t)rmtChannel_, waitMs / portTICK_PERIOD_MS);
|
if (rmt_tx_wait_all_done(channel_, waitMs) == ESP_OK)
|
||||||
if (ret == ESP_OK)
|
|
||||||
{
|
{
|
||||||
transmitting_ = false;
|
transmitting_ = false;
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -4,10 +4,13 @@
|
|||||||
|
|
||||||
#include "containers/LedStripRGBW.h"
|
#include "containers/LedStripRGBW.h"
|
||||||
|
|
||||||
|
#include <driver/rmt_tx.h>
|
||||||
|
|
||||||
class Esp32DriverRGBW
|
class Esp32DriverRGBW
|
||||||
{
|
{
|
||||||
public:
|
public:
|
||||||
void begin(int gpio, int rmtChannel);
|
// The RMT channel is picked by the driver; only the pin is ours to choose.
|
||||||
|
void begin(int gpio);
|
||||||
void end();
|
void end();
|
||||||
|
|
||||||
void writeSync(const uint32_t *rgbwData, int numLeds);
|
void writeSync(const uint32_t *rgbwData, int numLeds);
|
||||||
@@ -16,8 +19,9 @@ public:
|
|||||||
bool waitForTransmissionToFinish(int waitMs = 1000);
|
bool waitForTransmissionToFinish(int waitMs = 1000);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
int rmtChannel_;
|
rmt_channel_handle_t channel_ = nullptr;
|
||||||
bool transmitting_;
|
rmt_encoder_handle_t encoder_ = nullptr;
|
||||||
|
bool transmitting_ = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
@@ -9,6 +9,7 @@ enum class EffectId
|
|||||||
SWIPE_AND_CHANGE, // combination of ALEXA_SWIPE and RANDOM_TWO_COLOR_INTERPOLATION
|
SWIPE_AND_CHANGE, // combination of ALEXA_SWIPE and RANDOM_TWO_COLOR_INTERPOLATION
|
||||||
REVERSE_SWIPE,
|
REVERSE_SWIPE,
|
||||||
STATIC_DETAILED,
|
STATIC_DETAILED,
|
||||||
|
SEGMENT_SELECT,
|
||||||
};
|
};
|
||||||
|
|
||||||
template <EffectId id>
|
template <EffectId id>
|
||||||
|
|||||||
212
esp-firmware/lib/ledtl/effects/SegmentSelect.h
Normal file
212
esp-firmware/lib/ledtl/effects/SegmentSelect.h
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "effects/Common.h"
|
||||||
|
#include "helpers/ColorRGBW.h"
|
||||||
|
#include "helpers/ColorHSV.h"
|
||||||
|
#include "helpers/ColorConversions.h"
|
||||||
|
|
||||||
|
#include <cmath>
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
static constexpr int SEGMENT_SELECT_MAX_SEGMENTS = 6;
|
||||||
|
|
||||||
|
// The ring as a picker: one section per choice, the selected one bright and wobbling.
|
||||||
|
//
|
||||||
|
// The host re-sends this on every rotary detent. Each message builds a fresh effect, so
|
||||||
|
// `previous` is what makes the highlight glide over from where it was instead of
|
||||||
|
// jumping; previous == selected means no glide.
|
||||||
|
#pragma pack(push, 1)
|
||||||
|
struct EffectSegmentSelectConfig
|
||||||
|
{
|
||||||
|
uint8_t count; // number of sections, 1..SEGMENT_SELECT_MAX_SEGMENTS
|
||||||
|
uint8_t selected; // index of the highlighted section
|
||||||
|
uint8_t previous; // index the highlight glides from
|
||||||
|
uint8_t gapLeds; // dark LEDs between neighbouring sections
|
||||||
|
ColorRGBW colors[SEGMENT_SELECT_MAX_SEGMENTS];
|
||||||
|
float offsetDegrees; // where section 0 starts
|
||||||
|
int8_t direction; // +1 / -1: which way round the sections go
|
||||||
|
float dimFactor; // brightness of the sections that are not selected, 0..1
|
||||||
|
float wobbleAmplitude; // in LEDs
|
||||||
|
float wobblePeriodMs;
|
||||||
|
float slideMs; // time for the highlight to glide from `previous` to `selected`
|
||||||
|
float edgeBlend; // 0..0.5: how far a section's edges shift towards its neighbours' hue
|
||||||
|
};
|
||||||
|
#pragma pack(pop)
|
||||||
|
|
||||||
|
template <typename TLedStrip>
|
||||||
|
class EffectSegmentSelect
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
static constexpr auto NUM_LEDS = numLeds<TLedStrip>();
|
||||||
|
static constexpr int DELAY_MS = 10;
|
||||||
|
using ConfigType = EffectSegmentSelectConfig;
|
||||||
|
|
||||||
|
EffectSegmentSelect(const EffectSegmentSelectConfig &cfg, TLedStrip &ledStrip)
|
||||||
|
: config_(cfg),
|
||||||
|
ledStrip_(ledStrip)
|
||||||
|
{
|
||||||
|
if (config_.count > SEGMENT_SELECT_MAX_SEGMENTS)
|
||||||
|
config_.count = SEGMENT_SELECT_MAX_SEGMENTS;
|
||||||
|
if (config_.count > 0)
|
||||||
|
{
|
||||||
|
config_.selected %= config_.count;
|
||||||
|
config_.previous %= config_.count;
|
||||||
|
}
|
||||||
|
if (config_.direction == 0)
|
||||||
|
config_.direction = 1;
|
||||||
|
|
||||||
|
pitch_ = float(NUM_LEDS) / float(config_.count > 0 ? config_.count : 1);
|
||||||
|
halfWidth_ = std::max(0.5f, (pitch_ - float(config_.gapLeds)) / 2.0f);
|
||||||
|
offsetLeds_ = config_.offsetDegrees / 360.0f * float(NUM_LEDS);
|
||||||
|
|
||||||
|
// The glide takes the short way round, so going from the last section to the first
|
||||||
|
// moves one step forward rather than all the way back.
|
||||||
|
int delta = int(config_.selected) - int(config_.previous);
|
||||||
|
if (delta > config_.count / 2)
|
||||||
|
delta -= config_.count;
|
||||||
|
else if (delta < -(config_.count / 2))
|
||||||
|
delta += config_.count;
|
||||||
|
glideFrom_ = float(int(config_.selected) - delta);
|
||||||
|
|
||||||
|
// The dimmed background never changes, so it is computed once.
|
||||||
|
for (int led = 0; led < NUM_LEDS; ++led)
|
||||||
|
{
|
||||||
|
const float u = ringPosition(led);
|
||||||
|
const int section = std::min(int(u / pitch_), int(config_.count) - 1);
|
||||||
|
ColorRGBW color{0, 0, 0, 0};
|
||||||
|
if (config_.count > 0)
|
||||||
|
{
|
||||||
|
const float center = (float(section) + 0.5f) * pitch_;
|
||||||
|
const float coverage = clamp01(halfWidth_ + 0.5f - std::fabs(u - center));
|
||||||
|
color = sectionColor(section, (u - center) / pitch_) * (coverage * config_.dimFactor);
|
||||||
|
}
|
||||||
|
background_[led] = color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int operator()()
|
||||||
|
{
|
||||||
|
if (config_.count == 0)
|
||||||
|
{
|
||||||
|
clear(ledStrip_);
|
||||||
|
return 10000;
|
||||||
|
}
|
||||||
|
|
||||||
|
const float glide = config_.slideMs > 0 ? clamp01(elapsedMs_ / config_.slideMs) : 1.0f;
|
||||||
|
const float eased = glide * glide * (3.0f - 2.0f * glide); // smoothstep
|
||||||
|
const float index = glideFrom_ + (float(config_.selected) - glideFrom_) * eased;
|
||||||
|
|
||||||
|
float wobble = 0;
|
||||||
|
if (config_.wobblePeriodMs > 0)
|
||||||
|
wobble = config_.wobbleAmplitude * std::sin(2.0f * float(M_PI) * elapsedMs_ / config_.wobblePeriodMs);
|
||||||
|
const float center = (index + 0.5f) * pitch_ + wobble;
|
||||||
|
|
||||||
|
for (int led = 0; led < NUM_LEDS; ++led)
|
||||||
|
{
|
||||||
|
const float distance = wrappedDistance(ringPosition(led) - center);
|
||||||
|
const float coverage = clamp01(halfWidth_ + 0.5f - std::fabs(distance));
|
||||||
|
if (coverage <= 0)
|
||||||
|
{
|
||||||
|
setLedRGBW(ledStrip_, led, background_[led]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// The highlight carries the selected section's own gradient along with it, and
|
||||||
|
// changes colour from the previous section's as it glides.
|
||||||
|
const float local = distance / pitch_;
|
||||||
|
const ColorRGBW from = sectionColor(config_.previous, local);
|
||||||
|
const ColorRGBW to = sectionColor(config_.selected, local);
|
||||||
|
const ColorRGBW highlight = ColorRGBW::interpolate(from, to, eased);
|
||||||
|
setLedRGBW(ledStrip_, led, ColorRGBW::interpolate(background_[led], highlight, coverage));
|
||||||
|
}
|
||||||
|
|
||||||
|
elapsedMs_ += DELAY_MS;
|
||||||
|
return DELAY_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
static float clamp01(float x) { return x < 0 ? 0 : (x > 1 ? 1 : x); }
|
||||||
|
|
||||||
|
// An LED's position along the sections, 0..NUM_LEDS, section 0 starting at 0.
|
||||||
|
float ringPosition(int led) const
|
||||||
|
{
|
||||||
|
float u = float(config_.direction) * (float(led) + 0.5f - offsetLeds_);
|
||||||
|
u = std::fmod(u, float(NUM_LEDS));
|
||||||
|
return u < 0 ? u + float(NUM_LEDS) : u;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shortest signed distance on the ring.
|
||||||
|
static float wrappedDistance(float d)
|
||||||
|
{
|
||||||
|
const float n = float(NUM_LEDS);
|
||||||
|
d = std::fmod(d, n);
|
||||||
|
if (d > n / 2)
|
||||||
|
d -= n;
|
||||||
|
else if (d < -n / 2)
|
||||||
|
d += n;
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Colour of a section at `local`, -0.5 (its start) .. +0.5 (its end): its palette
|
||||||
|
// colour in the middle, shifting in hue towards each neighbour by up to edgeBlend.
|
||||||
|
ColorRGBW sectionColor(int section, float local) const
|
||||||
|
{
|
||||||
|
const int count = config_.count;
|
||||||
|
const int neighbour = local < 0 ? (section + count - 1) % count : (section + 1) % count;
|
||||||
|
const float amount = config_.edgeBlend * clamp01(std::fabs(local) * 2.0f);
|
||||||
|
return blendHue(config_.colors[section], config_.colors[neighbour], amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
static ColorRGBW blendHue(const ColorRGBW &c1, const ColorRGBW &c2, float f)
|
||||||
|
{
|
||||||
|
if (f <= 0)
|
||||||
|
return c1;
|
||||||
|
ColorHSV h1 = rgb2hsv(c1);
|
||||||
|
ColorHSV h2 = rgb2hsv(c2);
|
||||||
|
// Greys have no hue of their own; borrow the other's so only saturation moves.
|
||||||
|
if (h1.s <= 0)
|
||||||
|
h1.h = h2.h;
|
||||||
|
if (h2.s <= 0)
|
||||||
|
h2.h = h1.h;
|
||||||
|
float dh = h2.h - h1.h;
|
||||||
|
if (dh > 180)
|
||||||
|
dh -= 360;
|
||||||
|
else if (dh < -180)
|
||||||
|
dh += 360;
|
||||||
|
float hue = h1.h + f * dh;
|
||||||
|
if (hue < 0)
|
||||||
|
hue += 360;
|
||||||
|
else if (hue >= 360)
|
||||||
|
hue -= 360;
|
||||||
|
ColorRGBW out = hsv2rgb(ColorHSV{hue, h1.s + f * (h2.s - h1.s), h1.v + f * (h2.v - h1.v)});
|
||||||
|
out.w = uint8_t(float(c1.w) + f * (float(c2.w) - float(c1.w)));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
EffectSegmentSelectConfig config_;
|
||||||
|
TLedStrip &ledStrip_;
|
||||||
|
float pitch_ = 0;
|
||||||
|
float halfWidth_ = 0;
|
||||||
|
float offsetLeds_ = 0;
|
||||||
|
float glideFrom_ = 0;
|
||||||
|
float elapsedMs_ = 0;
|
||||||
|
ColorRGBW background_[NUM_LEDS];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Traits
|
||||||
|
template <>
|
||||||
|
struct EffectIdToConfig<EffectId::SEGMENT_SELECT>
|
||||||
|
{
|
||||||
|
using type = EffectSegmentSelectConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
template <>
|
||||||
|
struct EffectConfigToId<EffectSegmentSelectConfig>
|
||||||
|
{
|
||||||
|
static constexpr auto id = EffectId::SEGMENT_SELECT;
|
||||||
|
};
|
||||||
|
|
||||||
|
template <typename TLedStrip>
|
||||||
|
struct EffectIdToClass<EffectId::SEGMENT_SELECT, TLedStrip>
|
||||||
|
{
|
||||||
|
using type = EffectSegmentSelect<TLedStrip>;
|
||||||
|
};
|
||||||
@@ -220,12 +220,12 @@ esp_err_t rotary_encoder_init(rotary_encoder_info_t * info, gpio_num_t pin_a, gp
|
|||||||
info->state.direction = ROTARY_ENCODER_DIRECTION_NOT_SET;
|
info->state.direction = ROTARY_ENCODER_DIRECTION_NOT_SET;
|
||||||
|
|
||||||
// configure GPIOs
|
// configure GPIOs
|
||||||
gpio_pad_select_gpio(info->pin_a);
|
gpio_reset_pin(info->pin_a);
|
||||||
gpio_set_pull_mode(info->pin_a, GPIO_PULLUP_ONLY);
|
gpio_set_pull_mode(info->pin_a, GPIO_PULLUP_ONLY);
|
||||||
gpio_set_direction(info->pin_a, GPIO_MODE_INPUT);
|
gpio_set_direction(info->pin_a, GPIO_MODE_INPUT);
|
||||||
gpio_set_intr_type(info->pin_a, GPIO_INTR_ANYEDGE);
|
gpio_set_intr_type(info->pin_a, GPIO_INTR_ANYEDGE);
|
||||||
|
|
||||||
gpio_pad_select_gpio(info->pin_b);
|
gpio_reset_pin(info->pin_b);
|
||||||
gpio_set_pull_mode(info->pin_b, GPIO_PULLUP_ONLY);
|
gpio_set_pull_mode(info->pin_b, GPIO_PULLUP_ONLY);
|
||||||
gpio_set_direction(info->pin_b, GPIO_MODE_INPUT);
|
gpio_set_direction(info->pin_b, GPIO_MODE_INPUT);
|
||||||
gpio_set_intr_type(info->pin_b, GPIO_INTR_ANYEDGE);
|
gpio_set_intr_type(info->pin_b, GPIO_INTR_ANYEDGE);
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ data_dir = data
|
|||||||
default_envs = esp32
|
default_envs = esp32
|
||||||
|
|
||||||
[env:esp32]
|
[env:esp32]
|
||||||
platform = espressif32
|
; pioarduino's fork rather than PlatformIO's own espressif32 platform, which stays on
|
||||||
|
; Arduino core 2.x. Pinned to a release: 55.03.312-1 is Arduino core 3.3.12 on ESP-IDF 5.5.
|
||||||
|
platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.312-1/platform-espressif32.zip
|
||||||
board = esp-wrover-kit
|
board = esp-wrover-kit
|
||||||
board_upload.flash_size = "4MB"
|
board_upload.flash_size = "4MB"
|
||||||
build_flags = -DPLATFORM_ESP32
|
build_flags = -DPLATFORM_ESP32
|
||||||
@@ -21,14 +23,13 @@ framework = arduino
|
|||||||
monitor_port = /dev/ttyUSB0
|
monitor_port = /dev/ttyUSB0
|
||||||
upload_port = /dev/ttyUSB0
|
upload_port = /dev/ttyUSB0
|
||||||
monitor_speed = 115200
|
monitor_speed = 115200
|
||||||
src_filter = +<*> -<host_test.cpp>
|
build_src_filter = +<*> -<host_test.cpp>
|
||||||
lib_deps =
|
lib_deps =
|
||||||
miguelbalboa/MFRC522
|
bxparks/AceButton@1.10.1
|
||||||
bxparks/AceButton@^1.9.1
|
|
||||||
|
|
||||||
[env:native]
|
[env:native]
|
||||||
platform = native
|
platform = native@1.2.1
|
||||||
src_filter = +<*> -<main.cpp>
|
build_src_filter = +<*> -<main.cpp>
|
||||||
build_flags = -Ilib/ledtl -g -DPLATFORM_NATIVE
|
build_flags = -Ilib/ledtl -g -DPLATFORM_NATIVE
|
||||||
lib_deps =
|
lib_deps =
|
||||||
bxparks/AceButton@^1.9.1
|
bxparks/AceButton@1.10.1
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
#include "effects/ReverseSwipe.h"
|
#include "effects/ReverseSwipe.h"
|
||||||
#include "effects/RandomTwoColorInterpolation.h"
|
#include "effects/RandomTwoColorInterpolation.h"
|
||||||
#include "effects/SwipeAndChange.h"
|
#include "effects/SwipeAndChange.h"
|
||||||
|
#include "effects/SegmentSelect.h"
|
||||||
|
|
||||||
#include "Arduino.h"
|
#include "Arduino.h"
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
@@ -120,6 +121,8 @@ enum class MessageHostToFw : uint8_t
|
|||||||
|
|
||||||
PREV_BUTTON_LED = 21,
|
PREV_BUTTON_LED = 21,
|
||||||
NEXT_BUTTON_LED = 22,
|
NEXT_BUTTON_LED = 22,
|
||||||
|
|
||||||
|
LED_WHEEL_EFFECT_SEGMENT_SELECT = 23,
|
||||||
};
|
};
|
||||||
|
|
||||||
template <>
|
template <>
|
||||||
@@ -180,7 +183,7 @@ inline bool handleLedEffect(TLedTask *ledTask, MessageHostToFw msgType, MessageH
|
|||||||
}
|
}
|
||||||
|
|
||||||
template <typename LedTask1, typename LedTask2, typename LedTaskShelf>
|
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))
|
if (Serial.available() < sizeof(MAGIC_TOKEN_FW_TO_HOST) + sizeof(MessageHostToFw) + sizeof(uint16_t))
|
||||||
return;
|
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<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<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<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
|
// Mouse LEDs
|
||||||
else if(handleLedEffect<EffectStaticConfig >(ledTaskMouse, MessageHostToFw::MOUSE_LED_EFFECT_STATIC, msgType, msgBuffer)) {}
|
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)
|
else if (msgType == MessageHostToFw::PREV_BUTTON_LED)
|
||||||
{
|
{
|
||||||
float *val = reinterpret_cast<float *>(msgBuffer);
|
float *val = reinterpret_cast<float *>(msgBuffer);
|
||||||
ledcWrite(ledChannelLeft, uint32_t(255 * (*val)));
|
ledcWrite(ledPinLeft, uint32_t(255 * (*val)));
|
||||||
}
|
}
|
||||||
else if (msgType == MessageHostToFw::NEXT_BUTTON_LED)
|
else if (msgType == MessageHostToFw::NEXT_BUTTON_LED)
|
||||||
{
|
{
|
||||||
float *val = reinterpret_cast<float *>(msgBuffer);
|
float *val = reinterpret_cast<float *>(msgBuffer);
|
||||||
ledcWrite(ledChannelRight, uint32_t(255 * (*val)));
|
ledcWrite(ledPinRight, uint32_t(255 * (*val)));
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
Serial.println("Unknown message type");
|
Serial.println("Unknown message type");
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
#include "effects/Circular.h"
|
#include "effects/Circular.h"
|
||||||
#include "effects/Common.h"
|
#include "effects/Common.h"
|
||||||
|
#include "effects/SegmentSelect.h"
|
||||||
#include "drivers/Esp32DriverRGBW.h"
|
#include "drivers/Esp32DriverRGBW.h"
|
||||||
|
|
||||||
#include "freertos/FreeRTOS.h"
|
#include "freertos/FreeRTOS.h"
|
||||||
@@ -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::RANDOM_TWO_COLOR_INTERPOLATION>(id, effectFunction, ledStrip, msgBuffer, effectStorage)) {}
|
||||||
else if (dispatchEffectId<EffectId::SWIPE_AND_CHANGE >(id, effectFunction, ledStrip, msgBuffer, effectStorage)) {}
|
else if (dispatchEffectId<EffectId::SWIPE_AND_CHANGE >(id, effectFunction, ledStrip, msgBuffer, effectStorage)) {}
|
||||||
else if (dispatchEffectId<EffectId::REVERSE_SWIPE >(id, effectFunction, ledStrip, msgBuffer, effectStorage)) {}
|
else if (dispatchEffectId<EffectId::REVERSE_SWIPE >(id, effectFunction, ledStrip, msgBuffer, effectStorage)) {}
|
||||||
|
else if (dispatchEffectId<EffectId::SEGMENT_SELECT >(id, effectFunction, ledStrip, msgBuffer, effectStorage)) {}
|
||||||
// clang-format on
|
// clang-format on
|
||||||
|
|
||||||
timeoutMsForEffect = 0;
|
timeoutMsForEffect = 0;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
#include "effects/AlexaSwipe.h"
|
#include "effects/AlexaSwipe.h"
|
||||||
#include "effects/ReverseSwipe.h"
|
#include "effects/ReverseSwipe.h"
|
||||||
|
#include "effects/SegmentSelect.h"
|
||||||
#include "helpers/ColorConversions.h"
|
#include "helpers/ColorConversions.h"
|
||||||
|
|
||||||
#include <iostream>
|
#include <iostream>
|
||||||
@@ -100,6 +101,29 @@ int main(int argc, char **argv)
|
|||||||
effectToFile("reverse_swipe.py", effect, strip, 200);
|
effectToFile("reverse_swipe.py", effect, strip, 200);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
EffectSegmentSelectConfig cfg{};
|
||||||
|
cfg.count = 4;
|
||||||
|
cfg.selected = 0;
|
||||||
|
cfg.previous = 3;
|
||||||
|
cfg.gapLeds = 1;
|
||||||
|
cfg.colors[0] = ColorRGBW{255, 0, 0, 0};
|
||||||
|
cfg.colors[1] = ColorRGBW{255, 160, 0, 0};
|
||||||
|
cfg.colors[2] = ColorRGBW{0, 200, 0, 0};
|
||||||
|
cfg.colors[3] = ColorRGBW{0, 60, 255, 0};
|
||||||
|
cfg.offsetDegrees = 180;
|
||||||
|
cfg.direction = 1;
|
||||||
|
cfg.dimFactor = 0.15f;
|
||||||
|
cfg.wobbleAmplitude = 1.5f;
|
||||||
|
cfg.wobblePeriodMs = 1000;
|
||||||
|
cfg.slideMs = 200;
|
||||||
|
cfg.edgeBlend = 0.25f;
|
||||||
|
LedStripRGBW<51> strip;
|
||||||
|
|
||||||
|
EffectSegmentSelect<decltype(strip)> effect(cfg, strip);
|
||||||
|
effectToFile("segment_select.py", effect, strip, 200);
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
effect.currentPosition_ = 150;
|
effect.currentPosition_ = 150;
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
#include <Arduino.h>
|
#include <Arduino.h>
|
||||||
#include "rc522.h"
|
#include "rc522.h"
|
||||||
#include "SPI.h"
|
#include "SPI.h"
|
||||||
#include <MFRC522.h>
|
|
||||||
#include "rotary_encoder.h"
|
#include "rotary_encoder.h"
|
||||||
|
|
||||||
#include "containers/LedStripRGBW.h"
|
#include "containers/LedStripRGBW.h"
|
||||||
@@ -21,9 +20,6 @@
|
|||||||
#include "TaskLed.h"
|
#include "TaskLed.h"
|
||||||
|
|
||||||
// -------------------------------------------------- RFID Reader ----------------------------------------
|
// -------------------------------------------------- RFID Reader ----------------------------------------
|
||||||
MFRC522 rfid;
|
|
||||||
MFRC522::MIFARE_Key key;
|
|
||||||
|
|
||||||
void tagHandler(uint8_t *sn)
|
void tagHandler(uint8_t *sn)
|
||||||
{
|
{
|
||||||
if (sn != nullptr)
|
if (sn != nullptr)
|
||||||
@@ -117,11 +113,9 @@ void setupButtons()
|
|||||||
buttonRight.setEventHandler(handleButtonEvent);
|
buttonRight.setEventHandler(handleButtonEvent);
|
||||||
buttonRotary.setEventHandler(handleButtonEvent);
|
buttonRotary.setEventHandler(handleButtonEvent);
|
||||||
|
|
||||||
ledcSetup(0, PWM_FREQ, PWM_RESOLUTION);
|
// Arduino core 3 picks the LEDC channel itself; from here on the pin is the handle.
|
||||||
ledcAttachPin(BUTTON_LEFT_LED_PIN, 0);
|
ledcAttach(BUTTON_LEFT_LED_PIN, PWM_FREQ, PWM_RESOLUTION);
|
||||||
|
ledcAttach(BUTTON_RIGHT_LED_PIN, PWM_FREQ, PWM_RESOLUTION);
|
||||||
ledcSetup(1, PWM_FREQ, PWM_RESOLUTION);
|
|
||||||
ledcAttachPin(BUTTON_RIGHT_LED_PIN, 1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void handleButtons()
|
void handleButtons()
|
||||||
@@ -139,7 +133,7 @@ LedTask<decltype(ledStripCircle)> ledTaskCircle;
|
|||||||
|
|
||||||
void setupLedCircle()
|
void setupLedCircle()
|
||||||
{
|
{
|
||||||
ledDriverCircle.begin(22, 0);
|
ledDriverCircle.begin(22);
|
||||||
ledTaskCircle.begin(ledStripCircle, ledDriverCircle);
|
ledTaskCircle.begin(ledStripCircle, ledDriverCircle);
|
||||||
ledTaskCircle.startEffect(EffectStaticConfig(ColorRGBW{0, 0, 0, 0}));
|
ledTaskCircle.startEffect(EffectStaticConfig(ColorRGBW{0, 0, 0, 0}));
|
||||||
}
|
}
|
||||||
@@ -152,7 +146,7 @@ LedTask<decltype(ledStripMouse)> ledTaskMouse;
|
|||||||
|
|
||||||
void setupMouseLeds()
|
void setupMouseLeds()
|
||||||
{
|
{
|
||||||
ledDriverMouse.begin(16, 1);
|
ledDriverMouse.begin(16);
|
||||||
ledTaskMouse.begin(ledStripMouse, ledDriverMouse);
|
ledTaskMouse.begin(ledStripMouse, ledDriverMouse);
|
||||||
ledTaskMouse.startEffect(EffectStaticConfig{ColorRGBW{0, 0, 0, 0}, 0, 0});
|
ledTaskMouse.startEffect(EffectStaticConfig{ColorRGBW{0, 0, 0, 0}, 0, 0});
|
||||||
}
|
}
|
||||||
@@ -165,7 +159,7 @@ LedTask<decltype(ledStripShelf)> ledTaskShelf;
|
|||||||
|
|
||||||
void setupShelfLeds()
|
void setupShelfLeds()
|
||||||
{
|
{
|
||||||
ledDriverShelf.begin(17, 2);
|
ledDriverShelf.begin(17);
|
||||||
ledTaskShelf.begin(ledStripShelf, ledDriverShelf);
|
ledTaskShelf.begin(ledStripShelf, ledDriverShelf);
|
||||||
ledTaskShelf.startEffect(EffectStaticConfig{ColorRGBW{0, 0, 0, 0}, 0, 0});
|
ledTaskShelf.startEffect(EffectStaticConfig{ColorRGBW{0, 0, 0, 0}, 0, 0});
|
||||||
}
|
}
|
||||||
@@ -234,7 +228,7 @@ void setup()
|
|||||||
|
|
||||||
void loop()
|
void loop()
|
||||||
{
|
{
|
||||||
handleIncomingMessagesFromHost(&ledTaskCircle, &ledTaskMouse, &ledTaskShelf, 0, 1);
|
handleIncomingMessagesFromHost(&ledTaskCircle, &ledTaskMouse, &ledTaskShelf, BUTTON_LEFT_LED_PIN, BUTTON_RIGHT_LED_PIN);
|
||||||
handleTouchInputs();
|
handleTouchInputs();
|
||||||
handleRotaryEncoder();
|
handleRotaryEncoder();
|
||||||
handleButtons();
|
handleButtons();
|
||||||
|
|||||||
@@ -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();
|
|
||||||
}
|
|
||||||
1
python-backend/.gitignore
vendored
1
python-backend/.gitignore
vendored
@@ -1,4 +1,5 @@
|
|||||||
config.yml
|
config.yml
|
||||||
tippen-curriculum.yml
|
tippen-curriculum.yml
|
||||||
|
tippen-curriculum-dino.yml
|
||||||
tippen-progress.json
|
tippen-progress.json
|
||||||
/.musicmouse-cache
|
/.musicmouse-cache
|
||||||
|
|||||||
@@ -94,7 +94,10 @@ the prompt becomes a regression test by pasting it into a `.txt` file.
|
|||||||
| `musicmouse/reactions/` | The policy. `@on(SomeEvent)` functions that get the app and act. |
|
| `musicmouse/reactions/` | The policy. `@on(SomeEvent)` functions that get the app and act. |
|
||||||
| `musicmouse/library/` | The music collection: scanning, tags, cover art and its colours, the cache, and the seams for future track analysis. |
|
| `musicmouse/library/` | The music collection: scanning, tags, cover art and its colours, the cache, and the seams for future track analysis. |
|
||||||
| `musicmouse/services/mqtt/` | Home Assistant entities: three lights, a player sensor, a volume number, transport buttons, device triggers, a tag scanner. |
|
| `musicmouse/services/mqtt/` | Home Assistant entities: three lights, a player sensor, a volume number, transport buttons, device triggers, a tag scanner. |
|
||||||
| `musicmouse/services/web/` | The browser front-end's API: the library, a state websocket, command endpoints, and parent-mode settings. |
|
| `musicmouse/services/web/` | The browser front-end's API: the library, a state websocket, command endpoints, parent-mode settings, the typing game and the Home Assistant proxy. |
|
||||||
|
| `musicmouse/services/lirc/` | The IR remote, as a lircd TCP client. |
|
||||||
|
| `musicmouse/services/` (`screen.py`, `timers.py`, `podcasts.py`) | The monitor's on/off and idle auto-off; the parents' timer and school-day calendar; the podcast feed poller. |
|
||||||
|
| `musicmouse/tippen/` | The typing game's curriculum, progress and track unlocks. |
|
||||||
| `musicmouse/simulator/` | Fake transport and player, the driver vocabulary, the REPL and the script runner. |
|
| `musicmouse/simulator/` | Fake transport and player, the driver vocabulary, the REPL and the script runner. |
|
||||||
| `musicmouse/config.py` | Pydantic schema, validation, and human-readable error formatting. |
|
| `musicmouse/config.py` | Pydantic schema, validation, and human-readable error formatting. |
|
||||||
|
|
||||||
@@ -106,6 +109,8 @@ ruff check .
|
|||||||
mypy # --strict, configured in pyproject.toml
|
mypy # --strict, configured in pyproject.toml
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`../scripts/check.sh` runs these three plus the web front-end's checks in one go.
|
||||||
|
|
||||||
`tests/test_wire.py` parses `../esp-firmware/src/Messages.h` and fails if the Python
|
`tests/test_wire.py` parses `../esp-firmware/src/Messages.h` and fails if the Python
|
||||||
message ids drift from the firmware's - the contract is hand-duplicated in two
|
message ids drift from the firmware's - the contract is hand-duplicated in two
|
||||||
languages, and it had already drifted once (`BUTTON_EVENT` was missing on the Python
|
languages, and it had already drifted once (`BUTTON_EVENT` was missing on the Python
|
||||||
@@ -133,7 +138,9 @@ tag scanner. Topics are under `musicmouse/trigger/…` and `musicmouse/tag`.
|
|||||||
Two behaviours used to be hard-coded in `main.py` and now need automations:
|
Two behaviours used to be hard-coded in `main.py` and now need automations:
|
||||||
|
|
||||||
**Rotary press toggled the room light.** Trigger on the `rotary_pressed` device
|
**Rotary press toggled the room light.** Trigger on the `rotary_pressed` device
|
||||||
trigger, action `light.toggle` on `light.kinderzimmer_fluter`.
|
trigger, action `light.toggle` on `light.kinderzimmer_fluter`. While the LED ring's album
|
||||||
|
picker is open (a figure with several albums was just put down) the knob is the picker's:
|
||||||
|
its turns do not change the volume and its press is not published.
|
||||||
|
|
||||||
**Touching a body part set a colour** on `light.kinderzimmer_fluter` and
|
**Touching a body part set a colour** on `light.kinderzimmer_fluter` and
|
||||||
`light.music_mouse_regal_licht`:
|
`light.music_mouse_regal_licht`:
|
||||||
@@ -156,7 +163,7 @@ the scanner has to know about:
|
|||||||
|
|
||||||
| Folder | Shown as | Why it is special |
|
| Folder | Shown as | Why it is special |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `Figuren/<figure>/` | an album per figurine | the folder name *is* the figure name from the config |
|
| `Figuren/<figure>/` | an album per figurine - or, for one whose folder holds album folders instead of audio, up to six, picked on the LED ring | the folder name *is* the figure name from the config; a sub-album is titled `Fuchs - <folder>` |
|
||||||
| `Musik/<Artist> - <Album>/` | music, grouped by artist | plain ID3 |
|
| `Musik/<Artist> - <Album>/` | music, grouped by artist | plain ID3 |
|
||||||
| `Hörbücher/<Artist> - <Album>/` | audiobooks, grouped by character | `album_artist` is a credit list; only the name before the first comma groups usefully |
|
| `Hörbücher/<Artist> - <Album>/` | audiobooks, grouped by character | `album_artist` is a credit list; only the name before the first comma groups usefully |
|
||||||
| `Kinderpodcasts/<Show>/` | audiobooks, newest episode first | the tags are useless here - `artist` is the presenter list and `album` is the feed name, so the *folder* is the show |
|
| `Kinderpodcasts/<Show>/` | audiobooks, newest episode first | the tags are useless here - `artist` is the presenter list and `album` is the feed name, so the *folder* is the show |
|
||||||
@@ -182,8 +189,9 @@ different amounts to produce:
|
|||||||
|
|
||||||
```
|
```
|
||||||
index.json cheap: tags and structure. Rebuilt freely.
|
index.json cheap: tags and structure. Rebuilt freely.
|
||||||
covers/<album_id>.jpg medium: art extracted from an ID3 APIC frame
|
covers/<album_id>.jpg medium: the album's art, downscaled to 384 px
|
||||||
analysis/<track_key>.json expensive: reserved for offline audio analysis
|
covers/<album_id>.thumb.jpg medium: the same at 256 px, for cards and rows
|
||||||
|
analysis/<track_key>.json expensive: track analysis (plus .beats.json, .curve.json)
|
||||||
```
|
```
|
||||||
|
|
||||||
Deleting the whole directory is safe; deleting it throws away analysis that is minutes
|
Deleting the whole directory is safe; deleting it throws away analysis that is minutes
|
||||||
@@ -195,13 +203,16 @@ change to how the scanner derives a title, artist or series is invisible until t
|
|||||||
is invalidated: bump `_INDEX_VERSION` in `musicmouse/library/cache.py` when you touch
|
is invalidated: bump `_INDEX_VERSION` in `musicmouse/library/cache.py` when you touch
|
||||||
that logic.
|
that logic.
|
||||||
|
|
||||||
**Track analysis is not implemented.** `musicmouse/library/analysis.py` fixes the shape
|
**Track analysis** drives the web front-end's ambient background. It is optional: install
|
||||||
of the results - scalars (`tempo`, `energy`, `valence`, `brightness`) travel inline with
|
the `analysis` extra (`pip install -e '.[analysis]'`, librosa and numpy) and
|
||||||
the index, and a beat grid lives in its own file and is fetched per track - so an
|
`musicmouse/library/librosa_analyzer.py` works through the music shelf in the
|
||||||
analyzer can be added later without touching the scanner, the API or the frontend. It
|
background, in a pool of niced worker processes (`musicmouse/library/workers.py`), and
|
||||||
will go behind an optional dependency group, and because results are content-keyed
|
pauses while anything is playing. Without the extra, `build_analyzer()` returns a no-op
|
||||||
files, they can equally well be computed on a workstation and the `analysis/` folder
|
and the background stays static. Scalars (`tempo`, `energy`, `valence`, `brightness`,
|
||||||
copied to the device.
|
`pulse`) travel inline with the index; the beat grid and per-second curves live in their
|
||||||
|
own files and are fetched only for the playing track. Results are content-keyed, so they
|
||||||
|
can be computed on a workstation and the `analysis/` folder copied to the device
|
||||||
|
(`scripts/sync-library.sh` does that).
|
||||||
|
|
||||||
## The web front-end
|
## The web front-end
|
||||||
|
|
||||||
@@ -212,12 +223,19 @@ copied to the device.
|
|||||||
| `GET /api/library` | Every album with its tracks and colours. ~90 kB, sent once. |
|
| `GET /api/library` | Every album with its tracks and colours. ~90 kB, sent once. |
|
||||||
| `GET /api/albums/{id}/cover` | The cover, or 404 - the client paints the album's colours instead. |
|
| `GET /api/albums/{id}/cover` | The cover, or 404 - the client paints the album's colours instead. |
|
||||||
| `POST /api/library/refresh` | Rescan in the background; connected clients are told when it lands. |
|
| `POST /api/library/refresh` | Rescan in the background; connected clients are told when it lands. |
|
||||||
| `GET /api/state` | Snapshot: what is playing, where, how loud. |
|
| `GET /api/state` | Snapshot: what is playing, where, how loud; plus `clock` - today's school day, the bus, the parents' timer. |
|
||||||
| `WS /api/ws` | Push only. A snapshot on connect, then a frame per change, plus the position at 2 Hz while playing. |
|
| `WS /api/ws` | Push only. A snapshot on connect, then a frame per change, plus the position at 2 Hz while playing, and `clock_toggle` when the remote's Guide key asks for the clock. |
|
||||||
| `POST /api/play` | `{album_id, track_index?}` |
|
| `POST /api/play` | `{album_id, track_index?}` |
|
||||||
| `POST /api/resume` `/pause` `/next` `/previous` `/seek` | transport |
|
| `POST /api/resume` `/pause` `/next` `/previous` `/seek` | transport |
|
||||||
| `POST /api/volume` | `{percent}` or `{delta_percent}` |
|
| `POST /api/volume` | `{percent}` or `{steps}` - the IR remote's step (`volume_increment` device units) per step |
|
||||||
|
| `POST /api/remote/press/{digit}` | what the IR remote's number key does - the keyboard's digits |
|
||||||
|
| `GET` `PUT /api/remote/mapping` | number key -> album (optionally from one `track_index`) or podcast show |
|
||||||
|
| `POST /api/screen/toggle` `/screen/activity` | the kiosk's Del key; its key presses and taps, holding off the idle screen-off |
|
||||||
| `GET` `PUT /api/settings` | parent mode |
|
| `GET` `PUT /api/settings` | parent mode |
|
||||||
|
| `POST` `DELETE /api/timer` | parent mode's countdown: `{minutes}` or `{until: "HH:MM"}`; stop or silence it |
|
||||||
|
| `GET /api/tracks/{album_id}/{index}/analysis` | beat grid and mood curves of one track, or 404 |
|
||||||
|
| `GET /api/ha`, `GET /api/ha/states/{entity}`, `POST /api/ha/services/{domain}/{service}` | the room page's Home Assistant proxy; 404 without an `ha` section |
|
||||||
|
| `GET /api/tippen/curriculum` `/progress`, `PUT /api/tippen/settings`, `POST /api/tippen/runs` | the typing game; 404 without a `tippen` section |
|
||||||
|
|
||||||
Two decisions worth knowing:
|
Two decisions worth knowing:
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ general:
|
|||||||
# The music collection. One path; the shelves underneath it are fixed names, not
|
# The music collection. One path; the shelves underneath it are fixed names, not
|
||||||
# settings, because each one has its own quirks the code already knows about:
|
# settings, because each one has its own quirks the code already knows about:
|
||||||
#
|
#
|
||||||
# <root>/Figuren/<figure name>/ one folder per figurine
|
# <root>/Figuren/<figure name>/ one folder per figurine - or, for a figure
|
||||||
|
# with several albums, one subfolder each
|
||||||
# <root>/Musik/<Artist> - <Album>/ albums, grouped by artist
|
# <root>/Musik/<Artist> - <Album>/ albums, grouped by artist
|
||||||
# <root>/Hörbücher/<Artist> - <Album>/ audiobooks, grouped by character
|
# <root>/Hörbücher/<Artist> - <Album>/ audiobooks, grouped by character
|
||||||
# <root>/Kinderpodcasts/<Show>/ shows, newest episode first
|
# <root>/Kinderpodcasts/<Show>/ shows, newest episode first
|
||||||
@@ -57,6 +58,27 @@ general:
|
|||||||
# Backlight of the prev/next buttons while a figure is playing, 0..1.
|
# Backlight of the prev/next buttons while a figure is playing, 0..1.
|
||||||
button_leds_brightness: 0.5
|
button_leds_brightness: 0.5
|
||||||
|
|
||||||
|
# Loudness of the UI sounds - the screen's on/off blubb, the parents' timer, the
|
||||||
|
# album picker's clicks - in percent of how loud they are made to sit under the
|
||||||
|
# music. They follow the music's volume, but never drop below what they are at 30 %
|
||||||
|
# of it. Editable in parent mode.
|
||||||
|
effects_volume: 100
|
||||||
|
|
||||||
|
# The LED ring's album picker, for a figure with several albums (see `figures:`).
|
||||||
|
# Temporary: for tuning on the device. Every value defaults to a constant in
|
||||||
|
# musicmouse/reactions/selection.py, which is where the right ones belong once found.
|
||||||
|
# figure_selection:
|
||||||
|
# timeout: 8.0 # seconds without a turn before the picker closes
|
||||||
|
# offset_degrees: 180 # where the first album's section starts on the ring
|
||||||
|
# ring_direction: 1 # 1 or -1: which way round the sections go
|
||||||
|
# knob_direction: 1 # 1 or -1: which way the knob turns to the next album
|
||||||
|
# gap_leds: 1 # dark LEDs between sections
|
||||||
|
# dim_factor: 0.15 # brightness of the sections not selected, 0..1
|
||||||
|
# wobble_amplitude: 1.5 # how far the selected section swings, in LEDs
|
||||||
|
# wobble_period_ms: 1000 # one swing back and forth
|
||||||
|
# slide_ms: 200 # glide from one section to the next
|
||||||
|
# edge_blend: 0.25 # 0..0.5: how far section edges shift to their neighbours' hue
|
||||||
|
|
||||||
# Which files count as music. Anything else - a podcast downloader's archive.json,
|
# Which files count as music. Anything else - a podcast downloader's archive.json,
|
||||||
# a half-finished .tmp - is ignored.
|
# a half-finished .tmp - is ignored.
|
||||||
audio_extensions: [".mp3", ".ogg", ".oga", ".opus", ".flac", ".wav", ".m4a", ".aac"]
|
audio_extensions: [".mp3", ".ogg", ".oga", ".opus", ".flac", ".wav", ".m4a", ".aac"]
|
||||||
@@ -88,15 +110,42 @@ general:
|
|||||||
# IR remote control, over lircd's TCP socket (see ansible/roles/pi_lirc for how
|
# IR remote control, over lircd's TCP socket (see ansible/roles/pi_lirc for how
|
||||||
# lircd itself is set up on the Pi). Omit the whole section to run without a remote.
|
# lircd itself is set up on the Pi). Omit the whole section to run without a remote.
|
||||||
# Play/pause/stop/previous/forward/rewind/volume/mute map to normal music control;
|
# Play/pause/stop/previous/forward/rewind/volume/mute map to normal music control;
|
||||||
# number keys 0-9 play whatever the "remote:" section below assigns them.
|
# number keys 0-9 play whatever the "remote:" section below assigns them; Guide
|
||||||
|
# (KEY_GUIDE) shows the kiosk's clock. Run with --log-level DEBUG to see the name of a
|
||||||
|
# button that does nothing.
|
||||||
lirc:
|
lirc:
|
||||||
host: "musicmouse-pi.local"
|
host: "musicmouse-pi.local"
|
||||||
port: 2222 # this deployment's lircd listens on 2222, not its own
|
port: 2222 # this deployment's lircd listens on 2222, not its own
|
||||||
# default of 8765 - see the ansible role
|
# default of 8765 - see the ansible role
|
||||||
remote_name: "Hauppauge" # other remotes registered with the same lircd (an LED
|
remote_name: "Hauppauge" # other remotes registered with the same lircd (an LED
|
||||||
# remote, say) are ignored
|
# remote, say) are ignored (case-insensitively)
|
||||||
reconnect_interval: 5.0
|
reconnect_interval: 5.0
|
||||||
|
|
||||||
|
# The attached monitor, switched by the remote's power key. Omit the section on a
|
||||||
|
# device without one and the key does nothing. The command runs on every press, split
|
||||||
|
# shell-style but not through a shell; it decides itself whether that means on or off
|
||||||
|
# (ansible's pi_kiosk installs kiosk-screen for this).
|
||||||
|
#
|
||||||
|
# With an off_command the screen also switches itself off once nothing has played and
|
||||||
|
# nobody has touched the remote, the web UI or the kiosk keyboard for auto_off_minutes
|
||||||
|
# (editable in parent mode; 0 = never). The next press after that runs on_command.
|
||||||
|
# screen:
|
||||||
|
# toggle_command: "/usr/local/bin/kiosk-screen toggle"
|
||||||
|
# off_command: "/usr/local/bin/kiosk-screen off"
|
||||||
|
# on_command: "/usr/local/bin/kiosk-screen on"
|
||||||
|
# auto_off_minutes: 15
|
||||||
|
# timeout: 5.0 # seconds before a hung command is killed
|
||||||
|
|
||||||
|
# The clock overlay (Pos1 on the kiosk keyboard, Guide on the remote). Optional; these
|
||||||
|
# are the defaults. On school days a bus countdown shows for bus_lead_minutes before
|
||||||
|
# bus_time. School days skip weekends and the school and public holidays of
|
||||||
|
# `subdivision`, fetched daily from openholidaysapi.org and cached under
|
||||||
|
# library.cache - a city code such as DE-BY-AU adds that city's own holidays.
|
||||||
|
# clock:
|
||||||
|
# subdivision: "DE-BY"
|
||||||
|
# bus_time: "07:25"
|
||||||
|
# bus_lead_minutes: 60
|
||||||
|
|
||||||
# Home Assistant integration. Omit the whole section to run without MQTT.
|
# Home Assistant integration. Omit the whole section to run without MQTT.
|
||||||
# The backend exposes three lights, a player sensor, a volume slider, transport
|
# The backend exposes three lights, a player sensor, a volume slider, transport
|
||||||
# buttons, device triggers for every button/touch area, and a tag scanner.
|
# buttons, device triggers for every button/touch area, and a tag scanner.
|
||||||
@@ -148,6 +197,12 @@ general:
|
|||||||
progress_file: tippen-progress.json
|
progress_file: tippen-progress.json
|
||||||
|
|
||||||
# One entry per figurine. The key is the figure name and the subfolder name.
|
# One entry per figurine. The key is the figure name and the subfolder name.
|
||||||
|
#
|
||||||
|
# A figure folder that holds audio itself plays that. One that holds album folders
|
||||||
|
# instead can play any of them, up to six: putting the figure down plays the one it
|
||||||
|
# played last (or the first, alphabetically), and the LED ring shows one coloured
|
||||||
|
# section per album for a few seconds. Turn the knob to move between them and press it
|
||||||
|
# to play the selected one; leave it alone and the music just carries on.
|
||||||
figures:
|
figures:
|
||||||
fuchs:
|
fuchs:
|
||||||
# RFID tag id, 5 bytes as hex. Must be unique across figures.
|
# RFID tag id, 5 bytes as hex. Must be unique across figures.
|
||||||
@@ -158,7 +213,8 @@ figures:
|
|||||||
# "music" (default) or "book". Every other shelf is named after what is on it, so
|
# "music" (default) or "book". Every other shelf is named after what is on it, so
|
||||||
# its type is obvious; a figure folder is named after the figurine, so this is the
|
# its type is obvious; a figure folder is named after the figurine, so this is the
|
||||||
# one thing that has to be said out loud. The web UI draws albums square and
|
# one thing that has to be said out loud. The web UI draws albums square and
|
||||||
# audiobooks taller than wide, so getting it wrong is visible at a glance.
|
# audiobooks taller than wide, so getting it wrong is visible at a glance. It holds
|
||||||
|
# for every album of a figure that has several.
|
||||||
kind: music
|
kind: music
|
||||||
|
|
||||||
eule:
|
eule:
|
||||||
@@ -170,8 +226,10 @@ figures:
|
|||||||
# or any digit within it, for "unassigned" - a fresh install boots with none of this
|
# or any digit within it, for "unassigned" - a fresh install boots with none of this
|
||||||
# and that is not an error. Editable from the web front-end, which writes back here.
|
# and that is not an error. Editable from the web front-end, which writes back here.
|
||||||
#
|
#
|
||||||
# target_kind: album -> always starts from the first track (music, audiobooks).
|
# target_kind: album -> starts from the first track (music, audiobooks), or from
|
||||||
# target is an album id, as shown at GET /api/library.
|
# track_index (0-based) when given - one song, followed by
|
||||||
|
# the rest of the album. target is an album id, as shown
|
||||||
|
# at GET /api/library.
|
||||||
# target_kind: series -> always plays the newest episode of a podcast show, resolved
|
# target_kind: series -> always plays the newest episode of a podcast show, resolved
|
||||||
# fresh on every press - never a fixed episode. target is the
|
# fresh on every press - never a fixed episode. target is the
|
||||||
# show's folder name under Kinderpodcasts, e.g. "Wissen macht Ah".
|
# show's folder name under Kinderpodcasts, e.g. "Wissen macht Ah".
|
||||||
@@ -182,3 +240,7 @@ remote:
|
|||||||
"2":
|
"2":
|
||||||
target_kind: series
|
target_kind: series
|
||||||
target: "Wissen macht Ah"
|
target: "Wissen macht Ah"
|
||||||
|
"3":
|
||||||
|
target_kind: album
|
||||||
|
target: "3f9a0c12ab44"
|
||||||
|
track_index: 4
|
||||||
|
|||||||
@@ -39,7 +39,10 @@ from musicmouse.services.base import Service
|
|||||||
from musicmouse.services.lirc import LircService
|
from musicmouse.services.lirc import LircService
|
||||||
from musicmouse.services.mqtt import MqttService, build_entities
|
from musicmouse.services.mqtt import MqttService, build_entities
|
||||||
from musicmouse.services.podcasts import PodcastFeedService
|
from musicmouse.services.podcasts import PodcastFeedService
|
||||||
|
from musicmouse.services.screen import ScreenService
|
||||||
|
from musicmouse.services.timers import TimerService
|
||||||
from musicmouse.services.web import WebService
|
from musicmouse.services.web import WebService
|
||||||
|
from musicmouse.sound import ui_level
|
||||||
from musicmouse.tippen.curriculum import CurriculumError
|
from musicmouse.tippen.curriculum import CurriculumError
|
||||||
from musicmouse.tippen.runtime import TippenRuntime, build_tippen_runtime
|
from musicmouse.tippen.runtime import TippenRuntime, build_tippen_runtime
|
||||||
|
|
||||||
@@ -313,9 +316,6 @@ def _build_app(
|
|||||||
mouse=mouse,
|
mouse=mouse,
|
||||||
player=player,
|
player=player,
|
||||||
library=library,
|
library=library,
|
||||||
# One source of truth: the figure path and the web path must hand the player the
|
|
||||||
# same Playlist object for the same folder, because `play_figure` resumes on an
|
|
||||||
# identity check.
|
|
||||||
playlists=library.figure_playlists(),
|
playlists=library.figure_playlists(),
|
||||||
clock=clock,
|
clock=clock,
|
||||||
tippen=tippen,
|
tippen=tippen,
|
||||||
@@ -371,6 +371,36 @@ def _build_services(
|
|||||||
else:
|
else:
|
||||||
services.append(LircService(app, lirc_config, clock=clock))
|
services.append(LircService(app, lirc_config, clock=clock))
|
||||||
|
|
||||||
|
screen_config = app.config.general.screen
|
||||||
|
if screen_config is not None:
|
||||||
|
general = app.config.general
|
||||||
|
services.append(
|
||||||
|
ScreenService(
|
||||||
|
app.bus,
|
||||||
|
screen_config,
|
||||||
|
alsa_device=None if general.audio_simulated else general.alsa_device,
|
||||||
|
volume=lambda: ui_level(app.player.volume / 100, general.effects_volume / 100),
|
||||||
|
is_playing=lambda: app.player.is_playing,
|
||||||
|
clock=clock,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Unconditional, like the overlay it serves: the bus timer and the school-day badge
|
||||||
|
# need nothing configured, and the parents' timer is only started from parent mode.
|
||||||
|
general = app.config.general
|
||||||
|
services.append(
|
||||||
|
TimerService(
|
||||||
|
app.bus,
|
||||||
|
app.state,
|
||||||
|
general.clock,
|
||||||
|
cache_file=general.library.cache / "holidays.json",
|
||||||
|
client=httpx2.AsyncClient(timeout=30.0),
|
||||||
|
alsa_device=None if general.audio_simulated else general.alsa_device,
|
||||||
|
volume=lambda: ui_level(app.player.volume / 100, general.effects_volume / 100),
|
||||||
|
clock=clock,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
# Unconditional: a show only starts downloading once someone drops a `feed.txt`
|
# Unconditional: a show only starts downloading once someone drops a `feed.txt`
|
||||||
# into its folder, so there is nothing to gate here with its own config section.
|
# into its folder, so there is nothing to gate here with its own config section.
|
||||||
web_service = _service_of_type(services, WebService)
|
web_service = _service_of_type(services, WebService)
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
import math
|
||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import Awaitable, Callable
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
@@ -18,7 +20,34 @@ from musicmouse.tippen.runtime import TippenRuntime
|
|||||||
|
|
||||||
_log = logging.getLogger(__name__)
|
_log = logging.getLogger(__name__)
|
||||||
|
|
||||||
__all__ = ["App", "AppState"]
|
__all__ = ["App", "AppState", "FigureSelector", "ParentTimer"]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ParentTimer:
|
||||||
|
"""The countdown parents start from parent mode. Wall-clock Unix seconds, so every
|
||||||
|
browser can draw it against its own clock."""
|
||||||
|
|
||||||
|
started_at: float
|
||||||
|
ends_at: float
|
||||||
|
#: Ran out and is chiming; cleared after a minute or by the next thing anyone does.
|
||||||
|
ringing: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FigureSelector:
|
||||||
|
"""The LED ring offering a figure's albums to pick from. Owned by
|
||||||
|
:mod:`musicmouse.reactions.selection`."""
|
||||||
|
|
||||||
|
figure: str
|
||||||
|
choices: tuple[Playlist, ...]
|
||||||
|
#: The highlighted choice - not necessarily the one playing until it is confirmed.
|
||||||
|
selected: int
|
||||||
|
#: Clock time at which the ring gives up waiting; pushed back by every turn.
|
||||||
|
deadline: float
|
||||||
|
#: Clock time of the last tick sound, so turning fast does not stack them up.
|
||||||
|
last_tick: float = -math.inf
|
||||||
|
expiry: asyncio.Task[None] | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -29,6 +58,14 @@ class AppState:
|
|||||||
#: instead of starting over. Cleared once its playlist runs out.
|
#: instead of starting over. Cleared once its playlist runs out.
|
||||||
last_partially_played_figure: str | None = None
|
last_partially_played_figure: str | None = None
|
||||||
|
|
||||||
|
#: Figure -> album id it last played, for figures with more than one album: putting
|
||||||
|
#: it back picks that one again. Kept when another figure is placed in between -
|
||||||
|
#: only the position within the album is forgotten then.
|
||||||
|
figure_albums: dict[str, str] = field(default_factory=dict)
|
||||||
|
|
||||||
|
#: The ring's album picker, while it is open.
|
||||||
|
selector: FigureSelector | None = None
|
||||||
|
|
||||||
#: Whether the MQTT broker is currently reachable. The firmware's equivalent is
|
#: Whether the MQTT broker is currently reachable. The firmware's equivalent is
|
||||||
#: readable off the transport; a broker's is not, so it is remembered here.
|
#: readable off the transport; a broker's is not, so it is remembered here.
|
||||||
mqtt_connected: bool = False
|
mqtt_connected: bool = False
|
||||||
@@ -36,6 +73,14 @@ class AppState:
|
|||||||
#: Whether the lircd TCP link for the IR remote is currently reachable.
|
#: Whether the lircd TCP link for the IR remote is currently reachable.
|
||||||
lirc_connected: bool = False
|
lirc_connected: bool = False
|
||||||
|
|
||||||
|
#: Owned by :class:`~musicmouse.services.timers.TimerService`.
|
||||||
|
parent_timer: ParentTimer | None = None
|
||||||
|
#: Whether today is a school day; ``None`` on a weekday the holiday calendar could
|
||||||
|
#: not be fetched for (no internet, no cache yet).
|
||||||
|
school_day: bool | None = None
|
||||||
|
#: Why not, when it is not: ``"Wochenende"``, ``"Herbstferien"``, ...
|
||||||
|
school_day_reason: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class App:
|
class App:
|
||||||
@@ -44,7 +89,8 @@ class App:
|
|||||||
mouse: MusicMouseDevice
|
mouse: MusicMouseDevice
|
||||||
player: Player
|
player: Player
|
||||||
library: MusicLibrary
|
library: MusicLibrary
|
||||||
playlists: dict[str, Playlist]
|
#: Figure -> the albums it can play, in the order the ring offers them.
|
||||||
|
playlists: dict[str, tuple[Playlist, ...]]
|
||||||
clock: Clock = field(default_factory=RealClock)
|
clock: Clock = field(default_factory=RealClock)
|
||||||
state: AppState = field(default_factory=AppState)
|
state: AppState = field(default_factory=AppState)
|
||||||
#: `None` when `general.tippen` is absent - the typing game is off.
|
#: `None` when `general.tippen` is absent - the typing game is off.
|
||||||
@@ -53,11 +99,21 @@ class App:
|
|||||||
def colors(self, figure: str) -> FigureColors:
|
def colors(self, figure: str) -> FigureColors:
|
||||||
return self.config.figures[figure].colors
|
return self.config.figures[figure].colors
|
||||||
|
|
||||||
def playlist(self, figure: str) -> Playlist | None:
|
def choices(self, figure: str) -> tuple[Playlist, ...]:
|
||||||
playlist = self.playlists.get(figure)
|
"""The albums ``figure`` can play; empty, with a warning, if it has none."""
|
||||||
if playlist is None:
|
choices = self.playlists.get(figure, ())
|
||||||
|
if not choices:
|
||||||
_log.warning("No playlist for figure %r", figure)
|
_log.warning("No playlist for figure %r", figure)
|
||||||
return playlist
|
return choices
|
||||||
|
|
||||||
|
def default_choice(self, figure: str) -> int:
|
||||||
|
"""Which of the figure's albums plays when it is put down: the one it played
|
||||||
|
last, or else the first."""
|
||||||
|
remembered = self.state.figure_albums.get(figure)
|
||||||
|
for index, playlist in enumerate(self.playlists.get(figure, ())):
|
||||||
|
if playlist.album_id == remembered:
|
||||||
|
return index
|
||||||
|
return 0
|
||||||
|
|
||||||
def album_for(self, playlist: Playlist | None) -> Album | None:
|
def album_for(self, playlist: Playlist | None) -> Album | None:
|
||||||
"""The library album a playlist came from, if any."""
|
"""The library album a playlist came from, if any."""
|
||||||
|
|||||||
159
python-backend/musicmouse/blubb.py
Normal file
159
python-backend/musicmouse/blubb.py
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
"""A synthesised "blubb": two bubbles, for the screen switching on or off.
|
||||||
|
|
||||||
|
Built from samples rather than shipped as a file - the same idea as the front-end's
|
||||||
|
WebAudio blips (``web/src/lib/tippen/pop.ts``), and it lets the loudness follow the
|
||||||
|
player's volume instead of being fixed in a recording.
|
||||||
|
|
||||||
|
A bubble is a sine whose pitch rises as it decays (an air bubble's resonance climbs as
|
||||||
|
it reaches the surface). Two of them make the "blubb"; switching on the second one is
|
||||||
|
higher, switching off it is lower, so the two directions are told apart by ear.
|
||||||
|
|
||||||
|
The parents' timer has its own sound here too: a soft "ding-dong" bell
|
||||||
|
(:func:`chime_wav`), made the same way. So do the LED ring's album picker: a short
|
||||||
|
tick for every step (:func:`tick_wav`) and a rising two-note "pling" when an album is
|
||||||
|
picked (:func:`confirm_wav`).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import functools
|
||||||
|
import io
|
||||||
|
import math
|
||||||
|
import wave
|
||||||
|
from array import array
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
__all__ = ["RATE", "blubb_wav", "chime_wav", "confirm_wav", "tick_wav"]
|
||||||
|
|
||||||
|
RATE = 44100
|
||||||
|
|
||||||
|
#: (start Hz, end Hz, start s, length s) of each bubble.
|
||||||
|
_ON = ((260.0, 620.0, 0.0, 0.11), (380.0, 900.0, 0.13, 0.13))
|
||||||
|
_OFF = ((380.0, 900.0, 0.0, 0.11), (240.0, 560.0, 0.13, 0.15))
|
||||||
|
|
||||||
|
|
||||||
|
def blubb_wav(*, on: bool, gain: float) -> bytes:
|
||||||
|
"""A mono 16-bit WAV. ``gain`` is the peak, 0..1 of full scale."""
|
||||||
|
bubbles = _ON if on else _OFF
|
||||||
|
length = max(start + duration for _, _, start, duration in bubbles)
|
||||||
|
samples = [0.0] * (int(length * RATE) + 1)
|
||||||
|
for start_hz, end_hz, start, duration in bubbles:
|
||||||
|
_add_bubble(samples, start_hz, end_hz, int(start * RATE), duration)
|
||||||
|
|
||||||
|
peak = max(abs(s) for s in samples) or 1.0
|
||||||
|
scale = 32767 * max(0.0, min(1.0, gain)) / peak
|
||||||
|
return _wav(samples, scale)
|
||||||
|
|
||||||
|
|
||||||
|
#: (Hz, start s) of each bell strike: a falling major third, "ding-dong".
|
||||||
|
_CHIME = ((1318.5, 0.0), (1046.5, 0.45))
|
||||||
|
|
||||||
|
#: How long each strike rings out.
|
||||||
|
_CHIME_DECAY = 1.6
|
||||||
|
|
||||||
|
|
||||||
|
def chime_wav(*, gain: float) -> bytes:
|
||||||
|
"""A soft two-note bell for the parents' timer running out. Same format as
|
||||||
|
:func:`blubb_wav`."""
|
||||||
|
length = max(start for _, start in _CHIME) + _CHIME_DECAY
|
||||||
|
samples = [0.0] * (int(length * RATE) + 1)
|
||||||
|
for frequency, start in _CHIME:
|
||||||
|
_add_strike(samples, frequency, int(start * RATE))
|
||||||
|
peak = max(abs(s) for s in samples) or 1.0
|
||||||
|
scale = 32767 * max(0.0, min(1.0, gain)) / peak
|
||||||
|
return _wav(samples, scale)
|
||||||
|
|
||||||
|
|
||||||
|
#: (Hz, start s) of the tick's partials: a high, woody click rather than a beep.
|
||||||
|
_TICK = ((2100.0, 0.0), (3350.0, 0.0))
|
||||||
|
_TICK_LENGTH = 0.03
|
||||||
|
_TICK_DECAY = 160.0
|
||||||
|
|
||||||
|
#: (Hz, start s) of the confirm sound: a rising fifth, quick, "pling-pling".
|
||||||
|
_CONFIRM = ((880.0, 0.0), (1318.5, 0.09))
|
||||||
|
_CONFIRM_LENGTH = 0.22
|
||||||
|
_CONFIRM_DECAY = 18.0
|
||||||
|
|
||||||
|
|
||||||
|
def tick_wav(*, gain: float) -> bytes:
|
||||||
|
"""A click of a few tens of milliseconds, for every step of the album picker. Same
|
||||||
|
format as :func:`blubb_wav`."""
|
||||||
|
return _scaled(_pings(_TICK, _TICK_LENGTH, _TICK_DECAY), gain)
|
||||||
|
|
||||||
|
|
||||||
|
def confirm_wav(*, gain: float) -> bytes:
|
||||||
|
"""Two short rising notes for an album picked on the ring. Same format as
|
||||||
|
:func:`blubb_wav`."""
|
||||||
|
return _scaled(_pings(_CONFIRM, _CONFIRM_LENGTH, _CONFIRM_DECAY), gain)
|
||||||
|
|
||||||
|
|
||||||
|
@functools.cache
|
||||||
|
def _pings(
|
||||||
|
notes: tuple[tuple[float, float], ...], length: float, decay: float
|
||||||
|
) -> tuple[float, ...]:
|
||||||
|
total = max(start for _, start in notes) + length
|
||||||
|
samples = [0.0] * (int(total * RATE) + 1)
|
||||||
|
attack = int(0.002 * RATE)
|
||||||
|
count = int(length * RATE)
|
||||||
|
for frequency, start in notes:
|
||||||
|
offset = int(start * RATE)
|
||||||
|
for i in range(count):
|
||||||
|
t = i / RATE
|
||||||
|
index = offset + i
|
||||||
|
if index < len(samples):
|
||||||
|
samples[index] += (
|
||||||
|
math.sin(2 * math.pi * frequency * t)
|
||||||
|
* math.exp(-decay * t)
|
||||||
|
* min(1.0, i / attack)
|
||||||
|
)
|
||||||
|
# Cached: a tick plays on every step of the knob, from inside the event loop.
|
||||||
|
return tuple(samples)
|
||||||
|
|
||||||
|
|
||||||
|
def _scaled(samples: tuple[float, ...], gain: float) -> bytes:
|
||||||
|
peak = max(abs(s) for s in samples) or 1.0
|
||||||
|
return _wav(samples, 32767 * max(0.0, min(1.0, gain)) / peak)
|
||||||
|
|
||||||
|
|
||||||
|
def _wav(samples: Sequence[float], scale: float) -> bytes:
|
||||||
|
pcm = array("h", (round(s * scale) for s in samples))
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
with wave.open(buffer, "wb") as out:
|
||||||
|
out.setnchannels(1)
|
||||||
|
out.setsampwidth(2)
|
||||||
|
out.setframerate(RATE)
|
||||||
|
out.writeframes(pcm.tobytes())
|
||||||
|
return buffer.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def _add_bubble(
|
||||||
|
samples: list[float], start_hz: float, end_hz: float, offset: int, duration: float
|
||||||
|
) -> None:
|
||||||
|
count = int(duration * RATE)
|
||||||
|
attack = int(0.004 * RATE) # a few ms of fade-in, or the first sample clicks
|
||||||
|
phase = 0.0
|
||||||
|
for i in range(count):
|
||||||
|
t = i / count
|
||||||
|
# Exponential sweep, fast at first like the real thing.
|
||||||
|
frequency = start_hz * (end_hz / start_hz) ** (1 - (1 - t) ** 2)
|
||||||
|
phase += 2 * math.pi * frequency / RATE
|
||||||
|
envelope = min(1.0, i / attack) * math.exp(-5 * t)
|
||||||
|
index = offset + i
|
||||||
|
if index < len(samples):
|
||||||
|
samples[index] += math.sin(phase) * envelope
|
||||||
|
|
||||||
|
|
||||||
|
def _add_strike(samples: list[float], frequency: float, offset: int) -> None:
|
||||||
|
"""A bell: the fundamental plus two quieter, faster-fading overtones."""
|
||||||
|
count = int(_CHIME_DECAY * RATE)
|
||||||
|
attack = int(0.006 * RATE)
|
||||||
|
partials = ((1.0, 1.0, 3.0), (2.0, 0.35, 5.0), (3.0, 0.12, 8.0)) # ratio, level, decay
|
||||||
|
for i in range(count):
|
||||||
|
t = i / RATE
|
||||||
|
value = sum(
|
||||||
|
level * math.exp(-decay * t) * math.sin(2 * math.pi * frequency * ratio * t)
|
||||||
|
for ratio, level, decay in partials
|
||||||
|
)
|
||||||
|
index = offset + i
|
||||||
|
if index < len(samples):
|
||||||
|
samples[index] += value * min(1.0, i / attack)
|
||||||
@@ -32,11 +32,13 @@ _log = logging.getLogger(__name__)
|
|||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"SIMULATE",
|
"SIMULATE",
|
||||||
|
"ClockConfig",
|
||||||
"Config",
|
"Config",
|
||||||
"ConfigError",
|
"ConfigError",
|
||||||
"Digit",
|
"Digit",
|
||||||
"FigureColors",
|
"FigureColors",
|
||||||
"FigureConfig",
|
"FigureConfig",
|
||||||
|
"FigureSelectionConfig",
|
||||||
"GeneralConfig",
|
"GeneralConfig",
|
||||||
"HaConfig",
|
"HaConfig",
|
||||||
"HaDeviceConfig",
|
"HaDeviceConfig",
|
||||||
@@ -158,14 +160,52 @@ class LircConfig(_Strict):
|
|||||||
port: int = Field(default=2222, ge=1, le=65535)
|
port: int = Field(default=2222, ge=1, le=65535)
|
||||||
#: Only button events from this remote are acted on - other remotes registered
|
#: Only button events from this remote are acted on - other remotes registered
|
||||||
#: with the same lircd (an LED remote, say) are ignored.
|
#: with the same lircd (an LED remote, say) are ignored.
|
||||||
|
#: Compared case-insensitively: irserver lowercases what lircd capitalises.
|
||||||
remote_name: str = "Hauppauge"
|
remote_name: str = "Hauppauge"
|
||||||
reconnect_interval: float = Field(default=5.0, gt=0)
|
reconnect_interval: float = Field(default=5.0, gt=0)
|
||||||
|
|
||||||
|
|
||||||
|
class ScreenConfig(_Strict):
|
||||||
|
"""The monitor attached to the device, switched from the IR remote's power key.
|
||||||
|
|
||||||
|
Omit the whole section on a device without one: the key then does nothing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
#: Run on every press, split shell-style but not through a shell. On musicdolphin
|
||||||
|
#: this is ansible's ``kiosk-screen toggle``, which asks X whether the monitor is
|
||||||
|
#: on and flips it - the backend keeps no screen state of its own.
|
||||||
|
toggle_command: str
|
||||||
|
#: Switches the monitor off unconditionally, for the idle auto-off. Without it
|
||||||
|
#: there is no auto-off.
|
||||||
|
off_command: str | None = None
|
||||||
|
#: Switches it back on when someone is about again after an auto-off.
|
||||||
|
on_command: str | None = None
|
||||||
|
#: Minutes without music or anyone at the device before ``off_command`` runs.
|
||||||
|
#: 0 means never. Parent mode edits this.
|
||||||
|
auto_off_minutes: int = Field(default=15, ge=0)
|
||||||
|
timeout: float = Field(default=5.0, gt=0)
|
||||||
|
|
||||||
|
|
||||||
|
class ClockConfig(_Strict):
|
||||||
|
"""The clock overlay's automatic bus countdown, and whose school days it follows.
|
||||||
|
|
||||||
|
Always present: leave the section out for a Bavarian school bus at 7:25.
|
||||||
|
"""
|
||||||
|
|
||||||
|
#: ISO 3166-2 code the school and public holidays are looked up for, as the
|
||||||
|
#: OpenHolidays API knows it. A city-level code (``DE-BY-AU``) also counts that
|
||||||
|
#: city's own holidays; the state's are always included.
|
||||||
|
subdivision: str = Field(default="DE-BY", pattern=r"^[A-Z]{2}-[A-Z0-9]+(-[A-Z0-9]+)?$")
|
||||||
|
#: When the bus leaves, on school days only.
|
||||||
|
bus_time: str = Field(default="07:25", pattern=r"^([01][0-9]|2[0-3]):[0-5][0-9]$")
|
||||||
|
#: How long before ``bus_time`` the countdown appears.
|
||||||
|
bus_lead_minutes: int = Field(default=60, ge=1, le=240)
|
||||||
|
|
||||||
|
|
||||||
class LibraryConfig(_Strict):
|
class LibraryConfig(_Strict):
|
||||||
"""Where the music lives.
|
"""Where the music lives.
|
||||||
|
|
||||||
One path. The shelves underneath it - ``Figuren``, ``Musik``, ``Hoerbuecher``,
|
One path. The shelves underneath it - ``Figuren``, ``Musik``, ``Hörbücher``,
|
||||||
``Kinderpodcasts`` - are fixed names, not settings; see
|
``Kinderpodcasts`` - are fixed names, not settings; see
|
||||||
:mod:`musicmouse.library.sections`.
|
:mod:`musicmouse.library.sections`.
|
||||||
"""
|
"""
|
||||||
@@ -219,10 +259,10 @@ class HaDeviceConfig(_Strict):
|
|||||||
class HaConfig(_Strict):
|
class HaConfig(_Strict):
|
||||||
"""Home Assistant integration for the room-control page ("Mein Zimmer").
|
"""Home Assistant integration for the room-control page ("Mein Zimmer").
|
||||||
|
|
||||||
The backend never calls Home Assistant itself - it only hands the browser the
|
The browser only gets the two ordered lists. Every state read and service call goes
|
||||||
server URL, the token, and these two ordered lists. Control happens directly from
|
through the backend's ``/api/ha/...`` proxy, which attaches the token - so the token
|
||||||
the browser to Home Assistant's own REST API, so this token grants full HA control
|
never leaves the backend. The proxy itself is unauthenticated, though: anything on
|
||||||
to anything on the LAN that can reach musicmouse. See config.yml.example.
|
the LAN that can reach musicmouse can use it. See config.yml.example.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
url: str
|
url: str
|
||||||
@@ -262,6 +302,36 @@ class TippenConfig(_Strict):
|
|||||||
return _resolve_folder(path, info, must_exist=False, kind="file")
|
return _resolve_folder(path, info, must_exist=False, kind="file")
|
||||||
|
|
||||||
|
|
||||||
|
class FigureSelectionConfig(_Strict):
|
||||||
|
"""Overrides for the LED ring's album picker, for tuning it on the real device.
|
||||||
|
|
||||||
|
Temporary by design: every value defaults to a constant in
|
||||||
|
:mod:`musicmouse.reactions.selection`, which is where the ones that look right
|
||||||
|
belong once they have been found. ``None`` means "use that constant".
|
||||||
|
"""
|
||||||
|
|
||||||
|
#: Seconds without a turn before the picker closes by itself.
|
||||||
|
timeout: float | None = Field(default=None, gt=0)
|
||||||
|
#: Where on the ring the first album's section starts, in degrees from LED 0.
|
||||||
|
offset_degrees: float | None = None
|
||||||
|
#: 1 or -1: which way round the ring the sections follow each other.
|
||||||
|
ring_direction: Literal[1, -1] | None = None
|
||||||
|
#: 1 or -1: which way the knob has to turn to move to the next album.
|
||||||
|
knob_direction: Literal[1, -1] | None = None
|
||||||
|
#: Dark LEDs between neighbouring sections.
|
||||||
|
gap_leds: int | None = Field(default=None, ge=0, le=10)
|
||||||
|
#: Brightness of the sections that are not selected, 0..1.
|
||||||
|
dim_factor: float | None = Field(default=None, ge=0, le=1)
|
||||||
|
#: How far the selected section swings back and forth, in LEDs.
|
||||||
|
wobble_amplitude: float | None = Field(default=None, ge=0)
|
||||||
|
#: How long one swing back and forth takes.
|
||||||
|
wobble_period_ms: float | None = Field(default=None, gt=0)
|
||||||
|
#: How long the highlight takes to glide to the next section.
|
||||||
|
slide_ms: float | None = Field(default=None, ge=0)
|
||||||
|
#: How far a section's edges shift towards its neighbours' colour, 0..0.5.
|
||||||
|
edge_blend: float | None = Field(default=None, ge=0, le=0.5)
|
||||||
|
|
||||||
|
|
||||||
class GeneralConfig(_Strict):
|
class GeneralConfig(_Strict):
|
||||||
library: LibraryConfig
|
library: LibraryConfig
|
||||||
|
|
||||||
@@ -279,13 +349,20 @@ class GeneralConfig(_Strict):
|
|||||||
web: WebConfig | None = None
|
web: WebConfig | None = None
|
||||||
ha: HaConfig | None = None
|
ha: HaConfig | None = None
|
||||||
lirc: LircConfig | None = None
|
lirc: LircConfig | None = None
|
||||||
|
screen: ScreenConfig | None = None
|
||||||
tippen: TippenConfig | None = None
|
tippen: TippenConfig | None = None
|
||||||
|
clock: ClockConfig = Field(default_factory=ClockConfig)
|
||||||
|
|
||||||
min_volume: int = Field(default=0, ge=0, le=200)
|
min_volume: int = Field(default=0, ge=0, le=200)
|
||||||
max_volume: int = Field(default=100, ge=0, le=200)
|
max_volume: int = Field(default=100, ge=0, le=200)
|
||||||
initial_volume: int = Field(default=50, ge=0, le=200)
|
initial_volume: int = Field(default=50, ge=0, le=200)
|
||||||
volume_increment: int = Field(default=5, ge=1, le=100)
|
volume_increment: int = Field(default=5, ge=1, le=100)
|
||||||
button_leds_brightness: float = Field(default=0.5, ge=0, le=1)
|
button_leds_brightness: float = Field(default=0.5, ge=0, le=1)
|
||||||
|
#: Loudness of the UI sounds - the screen's blubb, the parents' timer, the album
|
||||||
|
#: picker's clicks - relative to the music, 0..100 %. Editable in parent mode.
|
||||||
|
effects_volume: int = Field(default=100, ge=0, le=100)
|
||||||
|
|
||||||
|
figure_selection: FigureSelectionConfig = Field(default_factory=FigureSelectionConfig)
|
||||||
|
|
||||||
audio_extensions: tuple[str, ...] = DEFAULT_AUDIO_EXTENSIONS
|
audio_extensions: tuple[str, ...] = DEFAULT_AUDIO_EXTENSIONS
|
||||||
|
|
||||||
@@ -332,8 +409,8 @@ class FigureConfig(_Strict):
|
|||||||
class RemoteSlotConfig(_Strict):
|
class RemoteSlotConfig(_Strict):
|
||||||
"""What a number key on the IR remote plays.
|
"""What a number key on the IR remote plays.
|
||||||
|
|
||||||
``"album"``: ``target`` is an ``Album.id``, always started from track 0 - a music
|
``"album"``: ``target`` is an ``Album.id`` - a music album or an audiobook, started
|
||||||
album or an audiobook. ``"series"``: ``target`` is a podcast show name (an
|
from ``track_index``. ``"series"``: ``target`` is a podcast show name (an
|
||||||
``Album.series``); resolved to that show's newest episode fresh on every press,
|
``Album.series``); resolved to that show's newest episode fresh on every press,
|
||||||
since a podcast show is not itself one playable thing in this library - each
|
since a podcast show is not itself one playable thing in this library - each
|
||||||
episode is its own album.
|
episode is its own album.
|
||||||
@@ -341,6 +418,15 @@ class RemoteSlotConfig(_Strict):
|
|||||||
|
|
||||||
target_kind: Literal["album", "series"]
|
target_kind: Literal["album", "series"]
|
||||||
target: str
|
target: str
|
||||||
|
#: ``"album"`` only: start at this track instead of the first, then carry on with
|
||||||
|
#: the rest of the album - one song assigned to a key.
|
||||||
|
track_index: int = Field(default=0, ge=0)
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _check_track_index(self) -> Self:
|
||||||
|
if self.target_kind == "series" and self.track_index:
|
||||||
|
raise ValueError("track_index only applies to album slots")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
class Config(_Strict):
|
class Config(_Strict):
|
||||||
|
|||||||
@@ -27,15 +27,17 @@ from musicmouse.devices.wire import (
|
|||||||
from musicmouse.effects import OFF, LedEffect
|
from musicmouse.effects import OFF, LedEffect
|
||||||
from musicmouse.events import (
|
from musicmouse.events import (
|
||||||
ActiveFigureChanged,
|
ActiveFigureChanged,
|
||||||
|
ButtonEvent,
|
||||||
ConnectionChanged,
|
ConnectionChanged,
|
||||||
DeviceConnected,
|
|
||||||
DeviceDisconnected,
|
|
||||||
EventSource,
|
EventSource,
|
||||||
InputEvent,
|
InputEvent,
|
||||||
LedEffectChanged,
|
LedEffectChanged,
|
||||||
RfidTokenRead,
|
RfidTokenRead,
|
||||||
|
RotaryTurned,
|
||||||
|
SelectorPressed,
|
||||||
|
SelectorTurned,
|
||||||
)
|
)
|
||||||
from musicmouse.hardware import NO_FIGURE_TAG, Button, LedZone
|
from musicmouse.hardware import NO_FIGURE_TAG, Button, ButtonAction, LedZone, RotaryDirection
|
||||||
|
|
||||||
_log = logging.getLogger(__name__)
|
_log = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -62,6 +64,7 @@ class MusicMouseDevice:
|
|||||||
self._active_figure: str | None = None
|
self._active_figure: str | None = None
|
||||||
self._button_brightness: float = 0.0
|
self._button_brightness: float = 0.0
|
||||||
self._effects: dict[LedZone, LedEffect] = {}
|
self._effects: dict[LedZone, LedEffect] = {}
|
||||||
|
self._knob_captured = False
|
||||||
|
|
||||||
# -------------------------------------------------------------------- state
|
# -------------------------------------------------------------------- state
|
||||||
|
|
||||||
@@ -81,6 +84,10 @@ class MusicMouseDevice:
|
|||||||
def effect(self, zone: LedZone) -> LedEffect | None:
|
def effect(self, zone: LedZone) -> LedEffect | None:
|
||||||
return self._effects.get(zone)
|
return self._effects.get(zone)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def knob_captured(self) -> bool:
|
||||||
|
return self._knob_captured
|
||||||
|
|
||||||
# ------------------------------------------------------------------ actions
|
# ------------------------------------------------------------------ actions
|
||||||
|
|
||||||
def set_effect(
|
def set_effect(
|
||||||
@@ -107,6 +114,17 @@ class MusicMouseDevice:
|
|||||||
self._transport.write(encode_button_brightness(button, brightness))
|
self._transport.write(encode_button_brightness(button, brightness))
|
||||||
_log.debug("Button backlights -> %.2f (%s)", brightness, origin)
|
_log.debug("Button backlights -> %.2f (%s)", brightness, origin)
|
||||||
|
|
||||||
|
def capture_knob(self, captured: bool) -> None:
|
||||||
|
"""Hand the rotary encoder to the ring's album picker, or give it back.
|
||||||
|
|
||||||
|
While captured, turns and presses come out as :class:`SelectorTurned` and
|
||||||
|
:class:`SelectorPressed` instead of :class:`RotaryTurned` and
|
||||||
|
:class:`ButtonEvent`, so the volume and Home Assistant never see them. Done here,
|
||||||
|
as the frames are decoded, rather than by each listener checking a flag: the
|
||||||
|
press that closes the picker would otherwise race whoever handles it after.
|
||||||
|
"""
|
||||||
|
self._knob_captured = captured
|
||||||
|
|
||||||
def all_leds_off(self, *, origin: EventSource = "system") -> None:
|
def all_leds_off(self, *, origin: EventSource = "system") -> None:
|
||||||
for zone in LedZone:
|
for zone in LedZone:
|
||||||
self.set_effect(zone, OFF(), origin=origin)
|
self.set_effect(zone, OFF(), origin=origin)
|
||||||
@@ -116,7 +134,6 @@ class MusicMouseDevice:
|
|||||||
|
|
||||||
def on_connected(self) -> None:
|
def on_connected(self) -> None:
|
||||||
"""Re-apply memorized state, so a reconnect is invisible from the outside."""
|
"""Re-apply memorized state, so a reconnect is invisible from the outside."""
|
||||||
self._bus.emit(DeviceConnected(port=self.port, source="device"))
|
|
||||||
self._bus.emit(ConnectionChanged(target="firmware", connected=True, source="device"))
|
self._bus.emit(ConnectionChanged(target="firmware", connected=True, source="device"))
|
||||||
|
|
||||||
for zone, effect in self._effects.items():
|
for zone, effect in self._effects.items():
|
||||||
@@ -126,8 +143,8 @@ class MusicMouseDevice:
|
|||||||
if self._effects:
|
if self._effects:
|
||||||
_log.info("Restored %d LED zone(s) after reconnect", len(self._effects))
|
_log.info("Restored %d LED zone(s) after reconnect", len(self._effects))
|
||||||
|
|
||||||
def on_disconnected(self, reason: str = "") -> None:
|
def on_disconnected(self, _reason: str = "") -> None:
|
||||||
self._bus.emit(DeviceDisconnected(port=self.port, reason=reason or None, source="device"))
|
# SerialLink already logs the reason; the event only says the link is down.
|
||||||
self._bus.emit(ConnectionChanged(target="firmware", connected=False, source="device"))
|
self._bus.emit(ConnectionChanged(target="firmware", connected=False, source="device"))
|
||||||
|
|
||||||
def feed(self, data: bytes) -> None:
|
def feed(self, data: bytes) -> None:
|
||||||
@@ -153,6 +170,20 @@ class MusicMouseDevice:
|
|||||||
def _publish(self, event: InputEvent) -> None:
|
def _publish(self, event: InputEvent) -> None:
|
||||||
if isinstance(event, RfidTokenRead):
|
if isinstance(event, RfidTokenRead):
|
||||||
self._publish_tag_read(event)
|
self._publish_tag_read(event)
|
||||||
|
elif self._knob_captured and isinstance(event, RotaryTurned):
|
||||||
|
if event.direction is not RotaryDirection.NONE:
|
||||||
|
# The first turn after boot reports no increment, but it was a detent.
|
||||||
|
steps = abs(event.increment) or 1
|
||||||
|
if event.direction is RotaryDirection.DOWN:
|
||||||
|
steps = -steps
|
||||||
|
self._bus.emit(SelectorTurned(steps=steps, source="device"))
|
||||||
|
elif (
|
||||||
|
self._knob_captured
|
||||||
|
and isinstance(event, ButtonEvent)
|
||||||
|
and event.button is Button.ROTARY
|
||||||
|
):
|
||||||
|
if event.action is ButtonAction.PRESSED:
|
||||||
|
self._bus.emit(SelectorPressed(source="device"))
|
||||||
else:
|
else:
|
||||||
self._bus.emit(event)
|
self._bus.emit(event)
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ newlines and on the magic token.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import struct
|
import struct
|
||||||
from dataclasses import dataclass, replace
|
from dataclasses import dataclass
|
||||||
from enum import IntEnum
|
from enum import IntEnum
|
||||||
from typing import TypeAlias
|
from typing import TypeAlias
|
||||||
|
|
||||||
@@ -22,6 +22,7 @@ from musicmouse.effects import (
|
|||||||
EffectCircularConfig,
|
EffectCircularConfig,
|
||||||
EffectRandomTwoColorInterpolationConfig,
|
EffectRandomTwoColorInterpolationConfig,
|
||||||
EffectReverseSwipe,
|
EffectReverseSwipe,
|
||||||
|
EffectSegmentSelect,
|
||||||
EffectStaticConfig,
|
EffectStaticConfig,
|
||||||
EffectStaticDetailedConfig,
|
EffectStaticDetailedConfig,
|
||||||
EffectSwipeAndChange,
|
EffectSwipeAndChange,
|
||||||
@@ -114,9 +115,11 @@ class MessageHostToFw(IntEnum):
|
|||||||
PREV_BUTTON_LED = 21
|
PREV_BUTTON_LED = 21
|
||||||
NEXT_BUTTON_LED = 22
|
NEXT_BUTTON_LED = 22
|
||||||
|
|
||||||
|
LED_WHEEL_EFFECT_SEGMENT_SELECT = 23
|
||||||
|
|
||||||
|
|
||||||
#: Which message id carries which effect, per zone. Note the asymmetry: only the ring
|
#: Which message id carries which effect, per zone. Note the asymmetry: only the ring
|
||||||
#: accepts AlexaSwipe on its own, and only the shelf accepts StaticDetailed.
|
#: accepts AlexaSwipe and SegmentSelect, and only the shelf accepts StaticDetailed.
|
||||||
_EFFECT_IDS: dict[LedZone, dict[type[LedEffect], MessageHostToFw]] = {
|
_EFFECT_IDS: dict[LedZone, dict[type[LedEffect], MessageHostToFw]] = {
|
||||||
LedZone.RING: {
|
LedZone.RING: {
|
||||||
EffectStaticConfig: MessageHostToFw.LED_WHEEL_EFFECT_STATIC,
|
EffectStaticConfig: MessageHostToFw.LED_WHEEL_EFFECT_STATIC,
|
||||||
@@ -127,6 +130,7 @@ _EFFECT_IDS: dict[LedZone, dict[type[LedEffect], MessageHostToFw]] = {
|
|||||||
),
|
),
|
||||||
EffectSwipeAndChange: MessageHostToFw.LED_WHEEL_EFFECT_SWIPE_AND_CHANGE,
|
EffectSwipeAndChange: MessageHostToFw.LED_WHEEL_EFFECT_SWIPE_AND_CHANGE,
|
||||||
EffectReverseSwipe: MessageHostToFw.LED_WHEEL_EFFECT_REVERSE_SWIPE,
|
EffectReverseSwipe: MessageHostToFw.LED_WHEEL_EFFECT_REVERSE_SWIPE,
|
||||||
|
EffectSegmentSelect: MessageHostToFw.LED_WHEEL_EFFECT_SEGMENT_SELECT,
|
||||||
},
|
},
|
||||||
LedZone.MOUSE: {
|
LedZone.MOUSE: {
|
||||||
EffectStaticConfig: MessageHostToFw.MOUSE_LED_EFFECT_STATIC,
|
EffectStaticConfig: MessageHostToFw.MOUSE_LED_EFFECT_STATIC,
|
||||||
@@ -341,10 +345,6 @@ class FrameDecoder:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def with_figure(event: RfidTokenRead, figure: str | None, *, known: bool) -> RfidTokenRead:
|
|
||||||
"""Attach a resolved figure name to a decoded tag read."""
|
|
||||||
return replace(event, figure=figure, known=known)
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------- host -> firmware, read back
|
# ------------------------------------------------- host -> firmware, read back
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -22,10 +22,12 @@ from musicmouse.color import ColorHSV, ColorRGBW
|
|||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"OFF",
|
"OFF",
|
||||||
|
"SEGMENT_SELECT_MAX_SEGMENTS",
|
||||||
"EffectAlexaSwipeConfig",
|
"EffectAlexaSwipeConfig",
|
||||||
"EffectCircularConfig",
|
"EffectCircularConfig",
|
||||||
"EffectRandomTwoColorInterpolationConfig",
|
"EffectRandomTwoColorInterpolationConfig",
|
||||||
"EffectReverseSwipe",
|
"EffectReverseSwipe",
|
||||||
|
"EffectSegmentSelect",
|
||||||
"EffectStaticConfig",
|
"EffectStaticConfig",
|
||||||
"EffectStaticDetailedConfig",
|
"EffectStaticDetailedConfig",
|
||||||
"EffectSwipeAndChange",
|
"EffectSwipeAndChange",
|
||||||
@@ -41,6 +43,12 @@ _ALEXA_SWIPE = struct.Struct("<fffff?")
|
|||||||
_TWO_COLOR = struct.Struct("<i?i??")
|
_TWO_COLOR = struct.Struct("<i?i??")
|
||||||
_CIRCULAR = struct.Struct("<ff")
|
_CIRCULAR = struct.Struct("<ff")
|
||||||
_REVERSE_SWIPE = struct.Struct("<fff")
|
_REVERSE_SWIPE = struct.Struct("<fff")
|
||||||
|
_SEGMENT_SELECT_HEAD = struct.Struct("<BBBB")
|
||||||
|
_SEGMENT_SELECT_TAIL = struct.Struct("<fbfffff")
|
||||||
|
|
||||||
|
#: ``SEGMENT_SELECT_MAX_SEGMENTS`` in ``SegmentSelect.h``: the colour array is this long
|
||||||
|
#: on the wire whatever ``count`` is.
|
||||||
|
SEGMENT_SELECT_MAX_SEGMENTS = 6
|
||||||
|
|
||||||
|
|
||||||
class LedEffect(Protocol):
|
class LedEffect(Protocol):
|
||||||
@@ -274,6 +282,86 @@ class EffectReverseSwipe:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class EffectSegmentSelect:
|
||||||
|
"""The ring as a picker: one section per choice, the selected one bright.
|
||||||
|
|
||||||
|
Re-sent on every rotary detent. ``previous`` is where the highlight glides from, so
|
||||||
|
the fresh effect the firmware builds per message still moves smoothly.
|
||||||
|
"""
|
||||||
|
|
||||||
|
colors: tuple[ColorRGBW, ...]
|
||||||
|
selected: int = 0
|
||||||
|
previous: int | None = None # None: same as selected, no glide
|
||||||
|
gap_leds: int = 1
|
||||||
|
offset_degrees: float = 180
|
||||||
|
direction: int = 1
|
||||||
|
dim_factor: float = 0.15
|
||||||
|
wobble_amplitude: float = 1.5 # LEDs
|
||||||
|
wobble_period_ms: float = 1000
|
||||||
|
slide_ms: float = 200
|
||||||
|
edge_blend: float = 0.25
|
||||||
|
|
||||||
|
SIZE: ClassVar[int] = (
|
||||||
|
_SEGMENT_SELECT_HEAD.size
|
||||||
|
+ SEGMENT_SELECT_MAX_SEGMENTS * _RGBW_SIZE
|
||||||
|
+ _SEGMENT_SELECT_TAIL.size
|
||||||
|
)
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if not 1 <= len(self.colors) <= SEGMENT_SELECT_MAX_SEGMENTS:
|
||||||
|
raise ValueError(
|
||||||
|
f"between 1 and {SEGMENT_SELECT_MAX_SEGMENTS} sections, got {len(self.colors)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def as_bytes(self) -> bytes:
|
||||||
|
count = len(self.colors)
|
||||||
|
previous = self.selected if self.previous is None else self.previous
|
||||||
|
padding = (ColorRGBW(0, 0, 0, 0),) * (SEGMENT_SELECT_MAX_SEGMENTS - count)
|
||||||
|
return (
|
||||||
|
_SEGMENT_SELECT_HEAD.pack(count, self.selected, previous, self.gap_leds)
|
||||||
|
+ b"".join(color.as_bytes() for color in (*self.colors, *padding))
|
||||||
|
+ _SEGMENT_SELECT_TAIL.pack(
|
||||||
|
self.offset_degrees,
|
||||||
|
self.direction,
|
||||||
|
self.dim_factor,
|
||||||
|
self.wobble_amplitude,
|
||||||
|
self.wobble_period_ms,
|
||||||
|
self.slide_ms,
|
||||||
|
self.edge_blend,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_bytes(cls, data: bytes) -> EffectSegmentSelect:
|
||||||
|
_check_size(data, cls.SIZE, cls.__name__)
|
||||||
|
count, selected, previous, gap = _SEGMENT_SELECT_HEAD.unpack_from(data)
|
||||||
|
start = _SEGMENT_SELECT_HEAD.size
|
||||||
|
colors = tuple(
|
||||||
|
ColorRGBW.from_bytes(data[start + i * _RGBW_SIZE : start + (i + 1) * _RGBW_SIZE])
|
||||||
|
for i in range(count)
|
||||||
|
)
|
||||||
|
offset, direction, dim, amplitude, period, slide, blend = _SEGMENT_SELECT_TAIL.unpack_from(
|
||||||
|
data, start + SEGMENT_SELECT_MAX_SEGMENTS * _RGBW_SIZE
|
||||||
|
)
|
||||||
|
return cls(
|
||||||
|
colors=colors,
|
||||||
|
selected=selected,
|
||||||
|
previous=None if previous == selected else previous,
|
||||||
|
gap_leds=gap,
|
||||||
|
offset_degrees=offset,
|
||||||
|
direction=direction,
|
||||||
|
dim_factor=dim,
|
||||||
|
wobble_amplitude=amplitude,
|
||||||
|
wobble_period_ms=period,
|
||||||
|
slide_ms=slide,
|
||||||
|
edge_blend=blend,
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"SegmentSelect({self.previous} -> {self.selected} of {len(self.colors)})"
|
||||||
|
|
||||||
|
|
||||||
def OFF() -> EffectStaticConfig: # noqa: N802 - reads as a constant at call sites
|
def OFF() -> EffectStaticConfig: # noqa: N802 - reads as a constant at call sites
|
||||||
"""A fresh "all LEDs off" effect."""
|
"""A fresh "all LEDs off" effect."""
|
||||||
return EffectStaticConfig(ColorRGBW(0, 0, 0, 0))
|
return EffectStaticConfig(ColorRGBW(0, 0, 0, 0))
|
||||||
|
|||||||
@@ -7,28 +7,33 @@ Three flavours, distinguished by base class:
|
|||||||
* :class:`StateEvent` - something changed.
|
* :class:`StateEvent` - something changed.
|
||||||
|
|
||||||
The intent layer is what lets several front-ends drive the same behaviour: a button
|
The intent layer is what lets several front-ends drive the same behaviour: a button
|
||||||
press, an MQTT command and a future web request all emit ``NextTrackRequested`` and a
|
press, an MQTT command, a web request and the IR remote all emit ``NextTrackRequested``
|
||||||
single reaction acts on it.
|
and a single reaction acts on it.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Literal, TypeAlias
|
from typing import TYPE_CHECKING, Literal, TypeAlias
|
||||||
|
|
||||||
from musicmouse.effects import LedEffect
|
from musicmouse.effects import LedEffect
|
||||||
from musicmouse.hardware import Button, ButtonAction, LedZone, RotaryDirection, TouchButton
|
from musicmouse.hardware import Button, ButtonAction, LedZone, RotaryDirection, TouchButton
|
||||||
from musicmouse.media import Playlist, Track
|
from musicmouse.media import Playlist, Track
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from musicmouse.config import Digit
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"ActiveFigureChanged",
|
"ActiveFigureChanged",
|
||||||
"ButtonEvent",
|
"ButtonEvent",
|
||||||
|
"CancelTimerRequested",
|
||||||
|
"ClockStateChanged",
|
||||||
"ConnectionChanged",
|
"ConnectionChanged",
|
||||||
"DeviceConnected",
|
|
||||||
"DeviceDisconnected",
|
|
||||||
"Event",
|
"Event",
|
||||||
"EventSource",
|
"EventSource",
|
||||||
|
"FigureSelectionChanged",
|
||||||
|
"FigureSelectorExpired",
|
||||||
"InputEvent",
|
"InputEvent",
|
||||||
"IntentEvent",
|
"IntentEvent",
|
||||||
"LedEffectChanged",
|
"LedEffectChanged",
|
||||||
@@ -37,6 +42,7 @@ __all__ = [
|
|||||||
"PauseRequested",
|
"PauseRequested",
|
||||||
"PlayAlbumRequested",
|
"PlayAlbumRequested",
|
||||||
"PlayFigureRequested",
|
"PlayFigureRequested",
|
||||||
|
"PlayRemoteSlotRequested",
|
||||||
"PlayRequested",
|
"PlayRequested",
|
||||||
"PlaySeriesLatestRequested",
|
"PlaySeriesLatestRequested",
|
||||||
"PlaybackChanged",
|
"PlaybackChanged",
|
||||||
@@ -44,11 +50,17 @@ __all__ = [
|
|||||||
"PreviousTrackRequested",
|
"PreviousTrackRequested",
|
||||||
"RfidTokenRead",
|
"RfidTokenRead",
|
||||||
"RotaryTurned",
|
"RotaryTurned",
|
||||||
|
"SelectorPressed",
|
||||||
|
"SelectorTurned",
|
||||||
|
"SetTimerRequested",
|
||||||
"SetVolumeRequested",
|
"SetVolumeRequested",
|
||||||
"StateEvent",
|
"StateEvent",
|
||||||
|
"ToggleClockRequested",
|
||||||
|
"ToggleScreenRequested",
|
||||||
"TouchButtonPressed",
|
"TouchButtonPressed",
|
||||||
"TouchButtonReleased",
|
"TouchButtonReleased",
|
||||||
"TrackChanged",
|
"TrackChanged",
|
||||||
|
"UserActivity",
|
||||||
"VolumeChangeRequested",
|
"VolumeChangeRequested",
|
||||||
"VolumeChanged",
|
"VolumeChanged",
|
||||||
]
|
]
|
||||||
@@ -131,22 +143,36 @@ class RotaryTurned(InputEvent):
|
|||||||
return f"RotaryTurned(pos={self.position}, incr={self.increment}, {self.direction.name})"
|
return f"RotaryTurned(pos={self.position}, incr={self.increment}, {self.direction.name})"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||||
|
class SelectorTurned(InputEvent):
|
||||||
|
"""The rotary encoder turned while the ring's album picker had it.
|
||||||
|
|
||||||
|
Not a :class:`RotaryTurned`: while the picker is open the knob is not a volume
|
||||||
|
knob, and nothing that listens for one should hear it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
#: Detents, positive for the direction that turns the volume up.
|
||||||
|
steps: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||||
|
class SelectorPressed(InputEvent):
|
||||||
|
"""The rotary encoder was pressed while the ring's album picker had it. Instead of,
|
||||||
|
not as well as, a :class:`ButtonEvent` - so it is not published to Home Assistant."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||||
|
class FigureSelectorExpired(InputEvent):
|
||||||
|
"""The ring's album picker went untouched for long enough to close by itself."""
|
||||||
|
|
||||||
|
figure: str
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||||
class PlaylistFinished(InputEvent):
|
class PlaylistFinished(InputEvent):
|
||||||
"""The player reached the end of the playlist."""
|
"""The player reached the end of the playlist."""
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
||||||
class DeviceConnected(InputEvent):
|
|
||||||
port: str
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
||||||
class DeviceDisconnected(InputEvent):
|
|
||||||
port: str
|
|
||||||
reason: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
# -------------------------------------------------------------------------- intents
|
# -------------------------------------------------------------------------- intents
|
||||||
|
|
||||||
|
|
||||||
@@ -197,6 +223,17 @@ class PlaySeriesLatestRequested(IntentEvent):
|
|||||||
series: str
|
series: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||||
|
class PlayRemoteSlotRequested(IntentEvent):
|
||||||
|
"""Play whatever the top-level ``remote:`` mapping assigns to a number key.
|
||||||
|
|
||||||
|
The IR remote's digits emit this, and so do the number keys of the web front-end's
|
||||||
|
keyboard - one reaction resolves the slot for both.
|
||||||
|
"""
|
||||||
|
|
||||||
|
digit: Digit
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||||
class SeekRequested(IntentEvent):
|
class SeekRequested(IntentEvent):
|
||||||
#: Seconds from the start of the current track.
|
#: Seconds from the start of the current track.
|
||||||
@@ -213,6 +250,36 @@ class SetVolumeRequested(IntentEvent):
|
|||||||
volume: int
|
volume: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||||
|
class ToggleScreenRequested(IntentEvent):
|
||||||
|
"""Switch the attached monitor off if it is on, on if it is off (``general.screen``)."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||||
|
class ToggleClockRequested(IntentEvent):
|
||||||
|
"""Show or hide the clock overlay. Only the kiosk acts on it - the overlay is a
|
||||||
|
front-end thing, the backend just passes the remote's key along."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||||
|
class SetTimerRequested(IntentEvent):
|
||||||
|
"""Start the parents' countdown, replacing one already running."""
|
||||||
|
|
||||||
|
#: Wall-clock end, as Unix seconds.
|
||||||
|
ends_at: float
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||||
|
class CancelTimerRequested(IntentEvent):
|
||||||
|
"""Stop the parents' countdown, or silence it if it is ringing."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||||
|
class UserActivity(IntentEvent):
|
||||||
|
"""Someone is at the device - a key press or tap in the kiosk browser. Requests
|
||||||
|
nothing by itself; it keeps the screen from switching off (``ScreenService``)."""
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||||
class LedEffectRequested(IntentEvent):
|
class LedEffectRequested(IntentEvent):
|
||||||
zone: LedZone
|
zone: LedZone
|
||||||
@@ -249,6 +316,17 @@ class ActiveFigureChanged(StateEvent):
|
|||||||
previous: str | None = None
|
previous: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||||
|
class FigureSelectionChanged(StateEvent):
|
||||||
|
"""The ring's album picker opened, moved or closed."""
|
||||||
|
|
||||||
|
figure: str
|
||||||
|
#: Index into the figure's albums of the highlighted one.
|
||||||
|
selected: int
|
||||||
|
count: int
|
||||||
|
open: bool
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||||
class LedEffectChanged(StateEvent):
|
class LedEffectChanged(StateEvent):
|
||||||
"""Emitted on *every* write to an LED zone, whatever caused it.
|
"""Emitted on *every* write to an LED zone, whatever caused it.
|
||||||
@@ -266,6 +344,12 @@ class LedEffectChanged(StateEvent):
|
|||||||
return f"LedEffectChanged({self.zone}, {self.effect}, origin={self.origin})"
|
return f"LedEffectChanged({self.zone}, {self.effect}, origin={self.origin})"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||||
|
class ClockStateChanged(StateEvent):
|
||||||
|
"""The parents' timer started, rang or stopped, or today's school day changed.
|
||||||
|
The values themselves live on :class:`~musicmouse.app.AppState`."""
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||||
class ConnectionChanged(StateEvent):
|
class ConnectionChanged(StateEvent):
|
||||||
target: Literal["firmware", "mqtt", "lirc"]
|
target: Literal["firmware", "mqtt", "lirc"]
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ _log = logging.getLogger(__name__)
|
|||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"ANALYZER_VERSION",
|
"ANALYZER_VERSION",
|
||||||
|
"MAX_FIGURE_ALBUMS",
|
||||||
"SECTIONS",
|
"SECTIONS",
|
||||||
"Album",
|
"Album",
|
||||||
"Analyzer",
|
"Analyzer",
|
||||||
@@ -50,6 +51,10 @@ __all__ = [
|
|||||||
"track_key",
|
"track_key",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
#: How many albums one figure can offer. Each is a section of the LED ring, and past six
|
||||||
|
#: they get too narrow to tell apart; the firmware's colour array is no longer either.
|
||||||
|
MAX_FIGURE_ALBUMS: Final = 6
|
||||||
|
|
||||||
#: The only kind worth spending DSP on: an audiobook chapter or a podcast episode is
|
#: The only kind worth spending DSP on: an audiobook chapter or a podcast episode is
|
||||||
#: tens of minutes of narration with no musical mood to extract, and there are far more
|
#: tens of minutes of narration with no musical mood to extract, and there are far more
|
||||||
#: of them in a typical library than there are songs.
|
#: of them in a typical library than there are songs.
|
||||||
@@ -120,17 +125,32 @@ class MusicLibrary:
|
|||||||
entry = self._entries.get(identifier) if identifier else None
|
entry = self._entries.get(identifier) if identifier else None
|
||||||
return entry[0] if entry else None
|
return entry[0] if entry else None
|
||||||
|
|
||||||
def figure_playlists(self) -> dict[str, Playlist]:
|
def figure_playlists(self) -> dict[str, tuple[Playlist, ...]]:
|
||||||
"""One playlist per figure folder, keyed by figure name.
|
"""The albums a figure can play, keyed by figure name, in folder order.
|
||||||
|
|
||||||
The figure path and the web path must hand the player the *same* object for the
|
Usually one. A figure whose folder holds album subfolders rather than audio has
|
||||||
same album: ``reactions.playback.play_figure`` resumes on an identity check.
|
one per subfolder, depth-first and alphabetical, which is the order the LED ring
|
||||||
|
offers them in - and the ring has room for no more than
|
||||||
|
:data:`MAX_FIGURE_ALBUMS`.
|
||||||
"""
|
"""
|
||||||
return {
|
by_figure: dict[str, list[Album]] = {}
|
||||||
album.figure: album.to_playlist()
|
for album, _ in self._entries.values():
|
||||||
for album, _ in self._entries.values()
|
if album.figure is not None:
|
||||||
if album.figure is not None
|
by_figure.setdefault(album.figure, []).append(album)
|
||||||
}
|
|
||||||
|
out: dict[str, tuple[Playlist, ...]] = {}
|
||||||
|
for figure, albums in by_figure.items():
|
||||||
|
albums.sort(key=lambda album: album.folder.parts)
|
||||||
|
if len(albums) > MAX_FIGURE_ALBUMS:
|
||||||
|
_log.warning(
|
||||||
|
"Figure %r has %d albums; only the first %d can be picked on the ring: %s",
|
||||||
|
figure,
|
||||||
|
len(albums),
|
||||||
|
MAX_FIGURE_ALBUMS,
|
||||||
|
", ".join(album.folder.name for album in albums[:MAX_FIGURE_ALBUMS]),
|
||||||
|
)
|
||||||
|
out[figure] = tuple(album.to_playlist() for album in albums[:MAX_FIGURE_ALBUMS])
|
||||||
|
return out
|
||||||
|
|
||||||
def latest_episode(self, series: str) -> Album | None:
|
def latest_episode(self, series: str) -> Album | None:
|
||||||
"""The newest episode-unit album of a podcast show, by filename.
|
"""The newest episode-unit album of a podcast show, by filename.
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ different amounts to produce::
|
|||||||
|
|
||||||
<cache_dir>/
|
<cache_dir>/
|
||||||
├── index.json cheap: tags and structure. Thrown away freely.
|
├── index.json cheap: tags and structure. Thrown away freely.
|
||||||
|
├── covers/<album_id>.thumb.jpg the same art at THUMB_COVER_PX, for cards and rows
|
||||||
├── covers/<album_id>.jpg medium: the album's art - out of an ID3 APIC
|
├── covers/<album_id>.jpg medium: the album's art - out of an ID3 APIC
|
||||||
│ frame, or copied from a cover.jpg in the
|
│ frame, or copied from a cover.jpg in the
|
||||||
│ folder - downscaled to MAX_COVER_PX on
|
│ folder - downscaled to MAX_COVER_PX on
|
||||||
@@ -53,9 +54,14 @@ __all__ = ["Fingerprint", "LibraryCache"]
|
|||||||
# smaller - which means a cache holding 640 px files cannot be brought to 384 px by
|
# smaller - which means a cache holding 640 px files cannot be brought to 384 px by
|
||||||
# re-reading them, only by going back to the original art. Discarding the index does
|
# re-reading them, only by going back to the original art. Discarding the index does
|
||||||
# exactly that: every album is scanned again and `store_cover` runs on the real art.
|
# exactly that: every album is scanned again and `store_cover` runs on the real art.
|
||||||
|
# 7: a figure's albums may sit in subfolders. `figure` is now the folder directly under
|
||||||
|
# Figuren rather than the album folder's own name, and such an album is titled
|
||||||
|
# "Fuchs - <folder>" - neither of which an entry cached before can know.
|
||||||
|
# The same rescan retired `shrink_stored_covers`, which re-read every cover header on
|
||||||
|
# every scan to catch pre-384 px covers and covers stored before thumbnails existed.
|
||||||
#
|
#
|
||||||
# Bumping this is cheap by design - see the module docstring.
|
# Bumping this is cheap by design - see the module docstring.
|
||||||
_INDEX_VERSION = 6
|
_INDEX_VERSION = 7
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
@@ -102,39 +108,19 @@ class LibraryCache:
|
|||||||
def cover_path(self, album_id: str) -> Path:
|
def cover_path(self, album_id: str) -> Path:
|
||||||
return self.covers / f"{album_id}.jpg"
|
return self.covers / f"{album_id}.jpg"
|
||||||
|
|
||||||
|
def thumb_path(self, album_id: str) -> Path:
|
||||||
|
return self.covers / f"{album_id}.thumb.jpg"
|
||||||
|
|
||||||
def store_cover(self, album_id: str, data: bytes) -> Path:
|
def store_cover(self, album_id: str, data: bytes) -> Path:
|
||||||
path = self.cover_path(album_id)
|
path = self.cover_path(album_id)
|
||||||
|
# The thumb comes from the original art, not from the 384 px copy: one lossy
|
||||||
|
# generation fewer, and `Image.draft` lets JPEG decode at a fraction of the size.
|
||||||
path.write_bytes(shrink_cover(data))
|
path.write_bytes(shrink_cover(data))
|
||||||
|
self.thumb_path(album_id).write_bytes(
|
||||||
|
shrink_cover(data, max_px=THUMB_COVER_PX, quality=_THUMB_JPEG_QUALITY)
|
||||||
|
)
|
||||||
return path
|
return path
|
||||||
|
|
||||||
def shrink_stored_covers(self) -> int:
|
|
||||||
"""Rewrite any already-stored cover that predates :data:`MAX_COVER_PX`.
|
|
||||||
|
|
||||||
Needed because :func:`~musicmouse.library.scanner.scan_library` reuses an album
|
|
||||||
whose fingerprint is unchanged *without* re-reading its tags, so a cover written
|
|
||||||
by an older version would otherwise never be touched again. Reading a JPEG's
|
|
||||||
dimensions only parses its header, so once every file is within the limit this
|
|
||||||
costs one small read per album and nothing else.
|
|
||||||
|
|
||||||
Returns the number of files actually rewritten.
|
|
||||||
"""
|
|
||||||
rewritten = 0
|
|
||||||
for path in sorted(self.covers.glob("*.jpg")):
|
|
||||||
try:
|
|
||||||
with Image.open(path) as image:
|
|
||||||
oversized = max(image.size) > MAX_COVER_PX
|
|
||||||
except (OSError, UnidentifiedImageError):
|
|
||||||
continue
|
|
||||||
if not oversized:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
shrunk = shrink_cover(path.read_bytes())
|
|
||||||
path.write_bytes(shrunk)
|
|
||||||
except OSError: # pragma: no cover - a cache we cannot write is not fatal
|
|
||||||
continue
|
|
||||||
rewritten += 1
|
|
||||||
return rewritten
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ analysis
|
# ------------------------------------------------------------------ analysis
|
||||||
|
|
||||||
def load_analysis(self, key: str) -> TrackAnalysis | None:
|
def load_analysis(self, key: str) -> TrackAnalysis | None:
|
||||||
@@ -289,24 +275,48 @@ MAX_COVER_PX: Final = 384
|
|||||||
#: file is a third of the size.
|
#: file is a third of the size.
|
||||||
_COVER_JPEG_QUALITY: Final = 85
|
_COVER_JPEG_QUALITY: Final = 85
|
||||||
|
|
||||||
|
#: Longest edge of the card/row thumbnail. A grid card is about 217 CSS px wide on a
|
||||||
|
#: 1080p kiosk (five columns in the 1180 px grid), so 256 covers it at devicePixelRatio 1
|
||||||
|
#: with a little to spare; only the play view asks for the full-size file. Decode cost
|
||||||
|
#: and texture upload go with pixel count, and this is 44% of the 384 px file's.
|
||||||
|
THUMB_COVER_PX: Final = 256
|
||||||
|
|
||||||
def shrink_cover(data: bytes) -> bytes:
|
#: Baseline JPEG, 4:2:0 (see :func:`shrink_cover`). libjpeg-turbo is the fastest decoder
|
||||||
"""Downscale cover art to :data:`MAX_COVER_PX` on its longest edge.
|
#: a browser on a Pi has, so JPEG stays the format; only the dimensions shrink.
|
||||||
|
_THUMB_JPEG_QUALITY: Final = 80
|
||||||
|
|
||||||
|
|
||||||
|
def shrink_cover(
|
||||||
|
data: bytes, max_px: int = MAX_COVER_PX, quality: int = _COVER_JPEG_QUALITY
|
||||||
|
) -> bytes:
|
||||||
|
"""Downscale cover art to ``max_px`` (default :data:`MAX_COVER_PX`) on its longest edge.
|
||||||
|
|
||||||
Art that is already small enough is returned untouched rather than re-encoded, so
|
Art that is already small enough is returned untouched rather than re-encoded, so
|
||||||
repeated scans never degrade it. Anything Pillow cannot read is passed through
|
repeated scans never degrade it. Anything Pillow cannot read is passed through
|
||||||
unchanged: a cover that is too big is a performance problem, a cover that is missing
|
unchanged: a cover that is too big is a performance problem, a cover that is missing
|
||||||
is a visible one.
|
is a visible one.
|
||||||
|
|
||||||
|
The output is always a baseline (non-progressive) 4:2:0 JPEG: the cheapest layout
|
||||||
|
for a browser to decode.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
with Image.open(io.BytesIO(data)) as image:
|
with Image.open(io.BytesIO(data)) as source:
|
||||||
if max(image.size) <= MAX_COVER_PX:
|
if max(source.size) <= max_px:
|
||||||
return data
|
return data
|
||||||
|
# JPEG can decode straight to 1/2, 1/4 or 1/8 size; a no-op for other formats.
|
||||||
|
source.draft("RGB", (max_px, max_px))
|
||||||
|
image = source.convert("RGB")
|
||||||
# `thumbnail` keeps the aspect ratio and never scales up.
|
# `thumbnail` keeps the aspect ratio and never scales up.
|
||||||
image = image.convert("RGB")
|
image.thumbnail((max_px, max_px), Image.Resampling.LANCZOS)
|
||||||
image.thumbnail((MAX_COVER_PX, MAX_COVER_PX), Image.Resampling.LANCZOS)
|
|
||||||
buffer = io.BytesIO()
|
buffer = io.BytesIO()
|
||||||
image.save(buffer, format="JPEG", quality=_COVER_JPEG_QUALITY, optimize=True)
|
image.save(
|
||||||
|
buffer,
|
||||||
|
format="JPEG",
|
||||||
|
quality=quality,
|
||||||
|
optimize=True,
|
||||||
|
progressive=False,
|
||||||
|
subsampling="4:2:0",
|
||||||
|
)
|
||||||
return buffer.getvalue()
|
return buffer.getvalue()
|
||||||
except (OSError, UnidentifiedImageError, ValueError):
|
except (OSError, UnidentifiedImageError, ValueError):
|
||||||
return data
|
return data
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
The frontend paints an album card with these and the LED strips run an effect in them,
|
The frontend paints an album card with these and the LED strips run an effect in them,
|
||||||
so a web-started album looks the same on the shelf as it does on the screen. Extraction
|
so a web-started album looks the same on the shelf as it does on the screen. Extraction
|
||||||
happens once per album ever - the result is cached next to the cover.
|
happens when an album is scanned; the result is stored in the cached index.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|||||||
@@ -116,6 +116,9 @@ _FFMPEG_TIMEOUT: Final = 600.0
|
|||||||
#: Where a show folder remembers episodes that failed to download, so a permanently
|
#: Where a show folder remembers episodes that failed to download, so a permanently
|
||||||
#: dead enclosure (e.g. pulled from the host's CDN) isn't retried every poll.
|
#: dead enclosure (e.g. pulled from the host's CDN) isn't retried every poll.
|
||||||
_FAILED_DOWNLOADS_FILENAME: Final = ".failed-downloads.json"
|
_FAILED_DOWNLOADS_FILENAME: Final = ".failed-downloads.json"
|
||||||
|
#: The same, for episodes on disk whose cover could not be found or fetched - so an
|
||||||
|
#: episode with no art anywhere doesn't cost a page fetch on every poll.
|
||||||
|
_MISSING_COVERS_FILENAME: Final = ".missing-covers.json"
|
||||||
#: How long a failed episode is left alone before it's given another chance - long
|
#: How long a failed episode is left alone before it's given another chance - long
|
||||||
#: enough to stop hammering a dead URL every 6 hours, short enough that a genuinely
|
#: enough to stop hammering a dead URL every 6 hours, short enough that a genuinely
|
||||||
#: transient failure (a host outage, a flaky network) still recovers on its own.
|
#: transient failure (a host outage, a flaky network) still recovers on its own.
|
||||||
@@ -379,15 +382,16 @@ def prune_show(folder: Path, keep: int | None, extensions: frozenset[str] | None
|
|||||||
return removed
|
return removed
|
||||||
|
|
||||||
|
|
||||||
def _load_failed_downloads(folder: Path) -> dict[str, datetime]:
|
def _load_failed(folder: Path, record_name: str) -> dict[str, datetime]:
|
||||||
"""Filename -> when it last failed to download, for episodes ``sync_show`` should
|
"""Filename -> when it last failed, for episodes ``sync_show`` should leave alone
|
||||||
leave alone until :data:`_RETRY_BACKOFF` has passed.
|
until :data:`_RETRY_BACKOFF` has passed. ``record_name`` picks which record: failed
|
||||||
|
downloads, or covers that could not be found.
|
||||||
|
|
||||||
A missing or corrupt record is just an empty one - nothing here is precious enough
|
A missing or corrupt record is just an empty one - nothing here is precious enough
|
||||||
to raise over, matching the module's overall rule that bad input is logged and
|
to raise over, matching the module's overall rule that bad input is logged and
|
||||||
skipped rather than fatal.
|
skipped rather than fatal.
|
||||||
"""
|
"""
|
||||||
path = folder / _FAILED_DOWNLOADS_FILENAME
|
path = folder / record_name
|
||||||
try:
|
try:
|
||||||
raw = json.loads(path.read_text(encoding="utf-8"))
|
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
@@ -402,12 +406,12 @@ def _load_failed_downloads(folder: Path) -> dict[str, datetime]:
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
def _save_failed_downloads(folder: Path, failed: dict[str, datetime]) -> None:
|
def _save_failed(folder: Path, record_name: str, failed: dict[str, datetime]) -> None:
|
||||||
path = folder / _FAILED_DOWNLOADS_FILENAME
|
path = folder / record_name
|
||||||
if not failed:
|
if not failed:
|
||||||
path.unlink(missing_ok=True)
|
path.unlink(missing_ok=True)
|
||||||
return
|
return
|
||||||
temp_path = folder / f".{_FAILED_DOWNLOADS_FILENAME}.tmp"
|
temp_path = folder / f".{record_name}.tmp"
|
||||||
payload = {filename: when.isoformat() for filename, when in failed.items()}
|
payload = {filename: when.isoformat() for filename, when in failed.items()}
|
||||||
temp_path.write_text(json.dumps(payload), encoding="utf-8")
|
temp_path.write_text(json.dumps(payload), encoding="utf-8")
|
||||||
temp_path.replace(path)
|
temp_path.replace(path)
|
||||||
@@ -529,6 +533,65 @@ async def resolve_episode_cover(client: httpx2.AsyncClient, episode: Episode) ->
|
|||||||
return _extract_og_image(response.text)
|
return _extract_og_image(response.text)
|
||||||
|
|
||||||
|
|
||||||
|
def _has_sidecar_cover(audio_path: Path) -> bool:
|
||||||
|
return any(audio_path.with_suffix(ext).is_file() for ext in _IMAGE_EXTENSIONS)
|
||||||
|
|
||||||
|
|
||||||
|
async def _sync_covers(
|
||||||
|
client: httpx2.AsyncClient, folder: Path, episodes: list[Episode], now: datetime
|
||||||
|
) -> bool:
|
||||||
|
"""Fetch the sidecar cover of every episode in ``episodes`` that is on disk without
|
||||||
|
one. Returns whether any cover was added.
|
||||||
|
|
||||||
|
Covers every episode on disk, not just the ones this pass downloaded: episodes
|
||||||
|
placed by hand, or downloaded before a cover route existed (Wissen macht Ah's
|
||||||
|
``og:image`` fallback), would otherwise never get theirs. An episode whose cover
|
||||||
|
can't be found or fetched is remembered and left alone for :data:`_RETRY_BACKOFF`,
|
||||||
|
like a failed download, so a feed with no per-episode art doesn't cost one page
|
||||||
|
fetch per episode on every poll.
|
||||||
|
"""
|
||||||
|
missing = _load_failed(folder, _MISSING_COVERS_FILENAME)
|
||||||
|
on_disk: set[str] = set()
|
||||||
|
added = False
|
||||||
|
for episode in episodes:
|
||||||
|
filename = episode_filename(
|
||||||
|
episode.published, episode.title, episode.enclosure_type, episode.enclosure_url
|
||||||
|
)
|
||||||
|
audio_path = folder / filename
|
||||||
|
if not audio_path.is_file():
|
||||||
|
continue
|
||||||
|
on_disk.add(filename)
|
||||||
|
if _has_sidecar_cover(audio_path):
|
||||||
|
missing.pop(filename, None)
|
||||||
|
continue
|
||||||
|
last_miss = missing.get(filename)
|
||||||
|
if last_miss is not None and now - last_miss < _RETRY_BACKOFF:
|
||||||
|
continue
|
||||||
|
|
||||||
|
cover_url = await resolve_episode_cover(client, episode)
|
||||||
|
if cover_url is None:
|
||||||
|
missing[filename] = now
|
||||||
|
continue
|
||||||
|
cover_filename = episode_cover_filename(episode.published, episode.title, cover_url)
|
||||||
|
try:
|
||||||
|
await download_episode_cover(client, folder, cover_url, cover_filename)
|
||||||
|
except (httpx2.HTTPError, OSError) as exc:
|
||||||
|
_log.warning(
|
||||||
|
"Could not download cover for episode %r in %s: %s",
|
||||||
|
episode.title,
|
||||||
|
folder.name,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
missing[filename] = now
|
||||||
|
continue
|
||||||
|
missing.pop(filename, None)
|
||||||
|
added = True
|
||||||
|
|
||||||
|
missing = {filename: when for filename, when in missing.items() if filename in on_disk}
|
||||||
|
_save_failed(folder, _MISSING_COVERS_FILENAME, missing)
|
||||||
|
return added
|
||||||
|
|
||||||
|
|
||||||
async def sync_show(
|
async def sync_show(
|
||||||
client: httpx2.AsyncClient,
|
client: httpx2.AsyncClient,
|
||||||
folder: Path,
|
folder: Path,
|
||||||
@@ -542,9 +605,9 @@ async def sync_show(
|
|||||||
Both halves use the same ``keep``, which is what makes the folder settle - see
|
Both halves use the same ``keep``, which is what makes the folder settle - see
|
||||||
:data:`DEFAULT_EPISODE_LIMIT`.
|
:data:`DEFAULT_EPISODE_LIMIT`.
|
||||||
|
|
||||||
Returns whether anything changed, deletions included: a pruned folder needs a
|
Returns whether anything changed, deletions and backfilled covers included: a
|
||||||
rescan just as much as a downloaded episode does, or the library keeps offering
|
pruned folder needs a rescan just as much as a downloaded episode does, or the
|
||||||
tracks whose files are gone.
|
library keeps offering tracks whose files are gone.
|
||||||
|
|
||||||
Errors - an unreachable feed, a malformed one, a single broken enclosure - are
|
Errors - an unreachable feed, a malformed one, a single broken enclosure - are
|
||||||
logged and swallowed here so one bad show never stops the others or takes down the
|
logged and swallowed here so one bad show never stops the others or takes down the
|
||||||
@@ -559,8 +622,9 @@ async def sync_show(
|
|||||||
_log.warning("Could not fetch podcast feed %s for %s: %s", feed_url, folder.name, exc)
|
_log.warning("Could not fetch podcast feed %s for %s: %s", feed_url, folder.name, exc)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
pending = missing_episodes(folder, newest_episodes(parse_feed(response.content), keep))
|
window = newest_episodes(parse_feed(response.content), keep)
|
||||||
failed = _load_failed_downloads(folder)
|
pending = missing_episodes(folder, window)
|
||||||
|
failed = _load_failed(folder, _FAILED_DOWNLOADS_FILENAME)
|
||||||
now = datetime.now(UTC)
|
now = datetime.now(UTC)
|
||||||
changed = False
|
changed = False
|
||||||
for episode, filename in pending:
|
for episode, filename in pending:
|
||||||
@@ -579,23 +643,12 @@ async def sync_show(
|
|||||||
_log.info("Downloaded new episode %r for %s", episode.title, folder.name)
|
_log.info("Downloaded new episode %r for %s", episode.title, folder.name)
|
||||||
changed = True
|
changed = True
|
||||||
|
|
||||||
cover_url = await resolve_episode_cover(client, episode)
|
|
||||||
if cover_url:
|
|
||||||
cover_filename = episode_cover_filename(episode.published, episode.title, cover_url)
|
|
||||||
if not (folder / cover_filename).exists():
|
|
||||||
try:
|
|
||||||
await download_episode_cover(client, folder, cover_url, cover_filename)
|
|
||||||
except (httpx2.HTTPError, OSError) as exc:
|
|
||||||
_log.warning(
|
|
||||||
"Could not download cover for episode %r in %s: %s",
|
|
||||||
episode.title,
|
|
||||||
folder.name,
|
|
||||||
exc,
|
|
||||||
)
|
|
||||||
|
|
||||||
pending_filenames = {filename for _episode, filename in pending}
|
pending_filenames = {filename for _episode, filename in pending}
|
||||||
failed = {filename: when for filename, when in failed.items() if filename in pending_filenames}
|
failed = {filename: when for filename, when in failed.items() if filename in pending_filenames}
|
||||||
_save_failed_downloads(folder, failed)
|
_save_failed(folder, _FAILED_DOWNLOADS_FILENAME, failed)
|
||||||
|
|
||||||
|
if await _sync_covers(client, folder, window, now):
|
||||||
|
changed = True
|
||||||
|
|
||||||
# After downloading, not before: an episode that just arrived is one of the newest
|
# After downloading, not before: an episode that just arrived is one of the newest
|
||||||
# and must be counted when deciding what falls off the end.
|
# and must be counted when deciding what falls off the end.
|
||||||
|
|||||||
@@ -135,9 +135,9 @@ def _cover_for(
|
|||||||
return cache.store_cover(identifier, art), art
|
return cache.store_cover(identifier, art), art
|
||||||
for path in paths[:3]:
|
for path in paths[:3]:
|
||||||
# Podcast feeds sometimes art only some episodes; a couple of tries is enough.
|
# Podcast feeds sometimes art only some episodes; a couple of tries is enough.
|
||||||
art = _embedded_art(path)
|
embedded = _embedded_art(path)
|
||||||
if art is not None:
|
if embedded is not None:
|
||||||
return cache.store_cover(identifier, art), art
|
return cache.store_cover(identifier, embedded), embedded
|
||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
@@ -155,9 +155,9 @@ def _cover_for_episode(
|
|||||||
if candidate.is_file():
|
if candidate.is_file():
|
||||||
art = candidate.read_bytes()
|
art = candidate.read_bytes()
|
||||||
return cache.store_cover(identifier, art), art
|
return cache.store_cover(identifier, art), art
|
||||||
art = _embedded_art(path)
|
embedded = _embedded_art(path)
|
||||||
if art is not None:
|
if embedded is not None:
|
||||||
return cache.store_cover(identifier, art), art
|
return cache.store_cover(identifier, embedded), embedded
|
||||||
for name in _COVER_NAMES:
|
for name in _COVER_NAMES:
|
||||||
candidate = folder / name
|
candidate = folder / name
|
||||||
if candidate.is_file():
|
if candidate.is_file():
|
||||||
@@ -197,7 +197,9 @@ def scan_album(
|
|||||||
albums.append(tags.get("album", ""))
|
albums.append(tags.get("album", ""))
|
||||||
artists.append(tags.get("albumartist") or tags.get("artist", ""))
|
artists.append(tags.get("albumartist") or tags.get("artist", ""))
|
||||||
|
|
||||||
figure = folder.name if section.figures else None
|
# A figure with several albums keeps them in subfolders of its own folder, so the
|
||||||
|
# figure is the folder directly under the section, however deep the album sits.
|
||||||
|
figure = folder.relative_to(root / section_name).parts[0] if section.figures else None
|
||||||
# Every other shelf is named after what is on it. A figure folder is named after the
|
# Every other shelf is named after what is on it. A figure folder is named after the
|
||||||
# figurine, so its media type has to be declared in the config.
|
# figurine, so its media type has to be declared in the config.
|
||||||
kind = (figure_kinds or {}).get(figure, "music") if figure else section.kind
|
kind = (figure_kinds or {}).get(figure, "music") if figure else section.kind
|
||||||
@@ -205,9 +207,13 @@ def scan_album(
|
|||||||
folder_artist, folder_title = _split_folder_name(folder.name)
|
folder_artist, folder_title = _split_folder_name(folder.name)
|
||||||
|
|
||||||
if section.title_from == "folder":
|
if section.title_from == "folder":
|
||||||
# A figure folder is named in lowercase ("fuchs"); it sits next to real album
|
title = folder.name
|
||||||
# titles in the browse grid, so give it a capital.
|
if figure is not None:
|
||||||
title = folder.name[:1].upper() + folder.name[1:] if section.figures else folder.name
|
# A figure folder is named in lowercase ("fuchs"); it sits next to real
|
||||||
|
# album titles in the browse grid, so give it a capital.
|
||||||
|
title = figure[:1].upper() + figure[1:]
|
||||||
|
if folder.name != figure:
|
||||||
|
title = f"{title} - {folder.name}"
|
||||||
else:
|
else:
|
||||||
title = _most_common(albums) or folder_title or folder.name
|
title = _most_common(albums) or folder_title or folder.name
|
||||||
|
|
||||||
@@ -262,7 +268,11 @@ def scan_episodes(
|
|||||||
out: dict[str, tuple[Album, Fingerprint]] = {}
|
out: dict[str, tuple[Album, Fingerprint]] = {}
|
||||||
for path in paths:
|
for path in paths:
|
||||||
identifier = album_id(root, path)
|
identifier = album_id(root, path)
|
||||||
fingerprint = Fingerprint.of([path])
|
# The sidecar cover counts too: one fetched after the episode was first scanned
|
||||||
|
# (``podcast_feeds.py`` backfills them) must invalidate the cached entry, or the
|
||||||
|
# episode keeps showing the show's shared cover.
|
||||||
|
sidecars = [path.with_suffix(ext) for ext in _SIDECAR_COVER_EXTENSIONS]
|
||||||
|
fingerprint = Fingerprint.of([path, *(p for p in sidecars if p.is_file())])
|
||||||
cached = known.get(identifier)
|
cached = known.get(identifier)
|
||||||
if cached is not None and cached[1] == fingerprint:
|
if cached is not None and cached[1] == fingerprint:
|
||||||
out[identifier] = cached
|
out[identifier] = cached
|
||||||
@@ -307,13 +317,6 @@ def scan_library(
|
|||||||
figure name to what it holds, which is the one thing the folders cannot say.
|
figure name to what it holds, which is the one thing the folders cannot say.
|
||||||
"""
|
"""
|
||||||
cache.prepare()
|
cache.prepare()
|
||||||
# Covers written before MAX_COVER_PX existed are still whatever size the tag held,
|
|
||||||
# and the reuse path below means an unchanged album never rewrites its own. One
|
|
||||||
# pass here catches them; after the first run every file is already small and this
|
|
||||||
# is 300-odd header reads.
|
|
||||||
shrunk = cache.shrink_stored_covers()
|
|
||||||
if shrunk:
|
|
||||||
_log.info("Downscaled %d oversized cover(s) in the cache", shrunk)
|
|
||||||
known = known or {}
|
known = known or {}
|
||||||
out: dict[str, tuple[Album, Fingerprint]] = {}
|
out: dict[str, tuple[Album, Fingerprint]] = {}
|
||||||
last_report = time.monotonic()
|
last_report = time.monotonic()
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"""Playlist model.
|
"""Playlist model.
|
||||||
|
|
||||||
Deliberately a real type rather than a bare ``list[str]``: it is what the browse API
|
What the player plays: a name and the files, in order. The browse API serves
|
||||||
serves, and it keeps track metadata in one place. Building one from a folder is
|
:class:`~musicmouse.library.models.Album` instead, which carries the tag metadata;
|
||||||
:mod:`musicmouse.library`'s job.
|
``Album.to_playlist()`` builds one of these from it.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ service subscribes to those events directly.
|
|||||||
from musicmouse.reactions import ( # noqa: F401 (import = register)
|
from musicmouse.reactions import ( # noqa: F401 (import = register)
|
||||||
lighting,
|
lighting,
|
||||||
playback,
|
playback,
|
||||||
|
selection,
|
||||||
status,
|
status,
|
||||||
)
|
)
|
||||||
from musicmouse.reactions.registry import Reaction, on, register_all, registered
|
from musicmouse.reactions.registry import Reaction, on, register_all, registered
|
||||||
|
|||||||
@@ -54,7 +54,10 @@ def figure_placed_or_removed(event: ActiveFigureChanged, app: App) -> None:
|
|||||||
if event.figure is None:
|
if event.figure is None:
|
||||||
off_animation(app)
|
off_animation(app)
|
||||||
else:
|
else:
|
||||||
start_animation(app, app.colors(event.figure))
|
# A figure with several albums gets the album picker on the ring instead
|
||||||
|
# (see ``reactions.selection``), which fades into this ring effect when done.
|
||||||
|
ring = len(app.playlists.get(event.figure, ())) < 2
|
||||||
|
start_animation(app, app.colors(event.figure), ring=ring)
|
||||||
app.mouse.set_button_brightness(
|
app.mouse.set_button_brightness(
|
||||||
app.config.general.button_leds_brightness, origin="device"
|
app.config.general.button_leds_brightness, origin="device"
|
||||||
)
|
)
|
||||||
@@ -106,36 +109,42 @@ def touch_released(event: TouchButtonReleased, app: App) -> None:
|
|||||||
)
|
)
|
||||||
if colors is None:
|
if colors is None:
|
||||||
return
|
return
|
||||||
|
fade_to_figure(app, LedZone.MOUSE, colors)
|
||||||
app.mouse.set_effect(
|
|
||||||
LedZone.MOUSE,
|
|
||||||
EffectRandomTwoColorInterpolationConfig(
|
|
||||||
color1=colors.primary, color2=colors.secondary, start_with_existing=True
|
|
||||||
),
|
|
||||||
origin="device",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------- animations
|
# ------------------------------------------------------------------- animations
|
||||||
|
|
||||||
|
|
||||||
def start_animation(app: App, colors: FigureColors) -> None:
|
def start_animation(app: App, colors: FigureColors, *, ring: bool = True) -> None:
|
||||||
ring = EffectSwipeAndChange()
|
swipe = EffectSwipeAndChange()
|
||||||
ring.swipe.primary_color = colors.primary
|
swipe.swipe.primary_color = colors.primary
|
||||||
ring.swipe.secondary_color = colors.secondary
|
swipe.swipe.secondary_color = colors.secondary
|
||||||
ring.swipe.swipe_speed = SWIPE_SPEED
|
swipe.swipe.swipe_speed = SWIPE_SPEED
|
||||||
ring.change.color1 = colors.primary
|
swipe.change.color1 = colors.primary
|
||||||
ring.change.color2 = colors.secondary
|
swipe.change.color2 = colors.secondary
|
||||||
|
|
||||||
app.mouse.set_effect(LedZone.RING, ring, origin="device")
|
if ring:
|
||||||
app.mouse.set_effect(LedZone.SHELF, deepcopy(ring), origin="device")
|
app.mouse.set_effect(LedZone.RING, swipe, origin="device")
|
||||||
|
app.mouse.set_effect(LedZone.SHELF, deepcopy(swipe), origin="device")
|
||||||
|
|
||||||
mouse = deepcopy(ring)
|
mouse = deepcopy(swipe)
|
||||||
mouse.swipe.start_position = MOUSE_SWIPE_START_DEGREES
|
mouse.swipe.start_position = MOUSE_SWIPE_START_DEGREES
|
||||||
mouse.swipe.bell_curve_width_in_leds = MOUSE_BELL_CURVE_WIDTH
|
mouse.swipe.bell_curve_width_in_leds = MOUSE_BELL_CURVE_WIDTH
|
||||||
app.mouse.set_effect(LedZone.MOUSE, mouse, origin="device")
|
app.mouse.set_effect(LedZone.MOUSE, mouse, origin="device")
|
||||||
|
|
||||||
|
|
||||||
|
def fade_to_figure(app: App, zone: LedZone, colors: FigureColors) -> None:
|
||||||
|
"""Fade ``zone`` from whatever it shows into the figure's resting colours - where
|
||||||
|
the start animation ends up after its swipe."""
|
||||||
|
app.mouse.set_effect(
|
||||||
|
zone,
|
||||||
|
EffectRandomTwoColorInterpolationConfig(
|
||||||
|
color1=colors.primary, color2=colors.secondary, start_with_existing=True
|
||||||
|
),
|
||||||
|
origin="device",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def web_animation(app: App, color: ColorRGBW) -> None:
|
def web_animation(app: App, color: ColorRGBW) -> None:
|
||||||
for zone in LedZone:
|
for zone in LedZone:
|
||||||
app.mouse.set_effect(
|
app.mouse.set_effect(
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"""What the mouse plays, and when.
|
"""What the mouse plays, and when.
|
||||||
|
|
||||||
Physical inputs are turned into *intents*, and the intents are what actually drive the
|
Physical inputs are turned into *intents*, and the intents are what actually drive the
|
||||||
player. That indirection is the point: an MQTT command or a future web request emits
|
player. That indirection is the point: an MQTT command, a web request or an IR remote key
|
||||||
the same intent and lands in the same handler, so there is one place per behaviour.
|
emits the same intent and lands in the same handler, so there is one place per behaviour.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -18,6 +18,7 @@ from musicmouse.events import (
|
|||||||
PlayAlbumRequested,
|
PlayAlbumRequested,
|
||||||
PlayFigureRequested,
|
PlayFigureRequested,
|
||||||
PlaylistFinished,
|
PlaylistFinished,
|
||||||
|
PlayRemoteSlotRequested,
|
||||||
PlayRequested,
|
PlayRequested,
|
||||||
PlaySeriesLatestRequested,
|
PlaySeriesLatestRequested,
|
||||||
PreviousTrackRequested,
|
PreviousTrackRequested,
|
||||||
@@ -28,6 +29,7 @@ from musicmouse.events import (
|
|||||||
)
|
)
|
||||||
from musicmouse.hardware import Button, ButtonAction, RotaryDirection
|
from musicmouse.hardware import Button, ButtonAction, RotaryDirection
|
||||||
from musicmouse.library.models import Album
|
from musicmouse.library.models import Album
|
||||||
|
from musicmouse.media import Playlist
|
||||||
from musicmouse.reactions.registry import on
|
from musicmouse.reactions.registry import on
|
||||||
|
|
||||||
_log = logging.getLogger(__name__)
|
_log = logging.getLogger(__name__)
|
||||||
@@ -62,24 +64,46 @@ def _figure_removed(previous: str | None, app: App) -> None:
|
|||||||
|
|
||||||
@on(PlayFigureRequested)
|
@on(PlayFigureRequested)
|
||||||
def play_figure(event: PlayFigureRequested, app: App) -> None:
|
def play_figure(event: PlayFigureRequested, app: App) -> None:
|
||||||
playlist = app.playlist(event.figure)
|
"""Play the figure's album - the one it played last, if it has several.
|
||||||
if playlist is None:
|
|
||||||
return
|
|
||||||
|
|
||||||
if not event.restart and app.player.playlist is playlist:
|
Another figure placed in between forgets only *where* in that album it was
|
||||||
|
(``restart``), not which album it was.
|
||||||
|
"""
|
||||||
|
choices = app.choices(event.figure)
|
||||||
|
if not choices:
|
||||||
|
return
|
||||||
|
playlist = choices[app.default_choice(event.figure)]
|
||||||
|
remember_figure_album(app, event.figure, playlist)
|
||||||
|
|
||||||
|
# Equality, not identity: a library rescan (the refresh button, or the podcast
|
||||||
|
# poller landing an episode) rebuilds every Playlist object, and an `is` check made
|
||||||
|
# the figure start over after that. Equal means the same album with the same files;
|
||||||
|
# if the files changed, starting over is the honest thing to do.
|
||||||
|
if not event.restart and app.player.playlist == playlist:
|
||||||
_log.info("Resuming %r", event.figure)
|
_log.info("Resuming %r", event.figure)
|
||||||
app.player.play()
|
app.player.play()
|
||||||
return
|
return
|
||||||
|
|
||||||
_log.info("Starting %r from the beginning", event.figure)
|
_log.info("Starting %r (%s) from the beginning", event.figure, playlist.album_id)
|
||||||
app.player.set_playlist(playlist)
|
app.player.set_playlist(playlist)
|
||||||
app.player.play_from_start()
|
app.player.play_from_start()
|
||||||
|
|
||||||
|
|
||||||
|
def remember_figure_album(app: App, figure: str, playlist: Playlist) -> None:
|
||||||
|
"""Make ``playlist`` the album ``figure`` picks next time it is put down."""
|
||||||
|
if playlist.album_id is not None:
|
||||||
|
app.state.figure_albums[figure] = playlist.album_id
|
||||||
|
|
||||||
|
|
||||||
def _start_album(app: App, album: Album, track_index: int) -> None:
|
def _start_album(app: App, album: Album, track_index: int) -> None:
|
||||||
# A figure album keeps the figure's own resume bookkeeping honest: it is the same
|
# A figure album started from anywhere becomes the album that figure picks next.
|
||||||
# Playlist object either way, because both come from the library index.
|
playlist = None
|
||||||
playlist = app.playlists.get(album.figure) if album.figure else album.to_playlist()
|
if album.figure is not None:
|
||||||
|
playlist = next(
|
||||||
|
(p for p in app.playlists.get(album.figure, ()) if p.album_id == album.id), None
|
||||||
|
)
|
||||||
|
if playlist is not None:
|
||||||
|
remember_figure_album(app, album.figure, playlist)
|
||||||
app.player.set_playlist(playlist or album.to_playlist())
|
app.player.set_playlist(playlist or album.to_playlist())
|
||||||
app.player.play_track(track_index)
|
app.player.play_track(track_index)
|
||||||
|
|
||||||
@@ -104,6 +128,27 @@ def play_series_latest(event: PlaySeriesLatestRequested, app: App) -> None:
|
|||||||
_start_album(app, album, 0)
|
_start_album(app, album, 0)
|
||||||
|
|
||||||
|
|
||||||
|
@on(PlayRemoteSlotRequested)
|
||||||
|
def play_remote_slot(event: PlayRemoteSlotRequested, app: App) -> None:
|
||||||
|
"""Play what a number key is assigned - pressed on the IR remote or a keyboard.
|
||||||
|
|
||||||
|
A slot naming one song starts the album there; the rest of the album follows, as
|
||||||
|
for any other start mid-album.
|
||||||
|
"""
|
||||||
|
slot = app.config.remote.get(event.digit)
|
||||||
|
if slot is None:
|
||||||
|
_log.debug("Number key %s is not assigned", event.digit)
|
||||||
|
return
|
||||||
|
if slot.target_kind == "series":
|
||||||
|
app.bus.emit(PlaySeriesLatestRequested(series=slot.target, source=event.source))
|
||||||
|
return
|
||||||
|
album = app.library.get(slot.target)
|
||||||
|
if album is None:
|
||||||
|
_log.warning("Number key %s: no album %r in the library", event.digit, slot.target)
|
||||||
|
return
|
||||||
|
_start_album(app, album, slot.track_index)
|
||||||
|
|
||||||
|
|
||||||
@on(PlaylistFinished)
|
@on(PlaylistFinished)
|
||||||
def playlist_finished(_event: PlaylistFinished, app: App) -> None:
|
def playlist_finished(_event: PlaylistFinished, app: App) -> None:
|
||||||
# Nothing was left half-played, so the next placement starts from the top.
|
# Nothing was left half-played, so the next placement starts from the top.
|
||||||
@@ -122,7 +167,9 @@ def button_pressed(event: ButtonEvent, app: App) -> None:
|
|||||||
elif event.button is Button.RIGHT and app.player.is_playing:
|
elif event.button is Button.RIGHT and app.player.is_playing:
|
||||||
app.bus.emit(NextTrackRequested(source="device"))
|
app.bus.emit(NextTrackRequested(source="device"))
|
||||||
# The rotary press is published to Home Assistant by the MQTT service; what it
|
# The rotary press is published to Home Assistant by the MQTT service; what it
|
||||||
# controls is an automation over there, not something this backend decides.
|
# controls is an automation over there, not something this backend decides. While
|
||||||
|
# the ring's album picker is open the press is the picker's instead, and does not
|
||||||
|
# arrive here at all (see ``MusicMouseDevice.capture_knob``).
|
||||||
|
|
||||||
|
|
||||||
@on(RotaryTurned)
|
@on(RotaryTurned)
|
||||||
|
|||||||
235
python-backend/musicmouse/reactions/selection.py
Normal file
235
python-backend/musicmouse/reactions/selection.py
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
"""Picking one of a figure's albums on the LED ring.
|
||||||
|
|
||||||
|
A figure whose folder holds several albums starts playing its default one at once, as
|
||||||
|
any figure does, and meanwhile offers all of them on the ring: one coloured section
|
||||||
|
each, the selected one bright and wobbling. The knob moves the selection one section
|
||||||
|
per detent, and pressing it plays the selected album. Left alone, the picker closes by
|
||||||
|
itself and whatever is playing carries on.
|
||||||
|
|
||||||
|
While it is open the picker has the knob to itself (``MusicMouseDevice.capture_knob``):
|
||||||
|
turning does not change the volume and pressing is not sent to Home Assistant.
|
||||||
|
|
||||||
|
The firmware only draws the ring. Which section is selected, when to give up, and what
|
||||||
|
to play are decided here, and the ring is simply sent again on every detent.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from collections.abc import Callable
|
||||||
|
from dataclasses import dataclass, replace
|
||||||
|
|
||||||
|
from musicmouse.app import App, FigureSelector
|
||||||
|
from musicmouse.blubb import confirm_wav, tick_wav
|
||||||
|
from musicmouse.color import ColorRGBW
|
||||||
|
from musicmouse.effects import EffectSegmentSelect
|
||||||
|
from musicmouse.events import (
|
||||||
|
ActiveFigureChanged,
|
||||||
|
FigureSelectionChanged,
|
||||||
|
FigureSelectorExpired,
|
||||||
|
PlayAlbumRequested,
|
||||||
|
PlaylistFinished,
|
||||||
|
SelectorPressed,
|
||||||
|
SelectorTurned,
|
||||||
|
)
|
||||||
|
from musicmouse.hardware import LedZone
|
||||||
|
from musicmouse.media import Playlist
|
||||||
|
from musicmouse.reactions.lighting import fade_to_figure
|
||||||
|
from musicmouse.reactions.registry import on
|
||||||
|
from musicmouse.sound import play_wav_soon, ui_level
|
||||||
|
|
||||||
|
_log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
#: One colour per album, by position. Neighbours are far apart in hue, so the sections
|
||||||
|
#: stay easy to tell apart even with the edge blend shifting them towards each other.
|
||||||
|
PALETTE: tuple[ColorRGBW, ...] = (
|
||||||
|
ColorRGBW(1.0, 0.0, 0.0, 0.0), # red
|
||||||
|
ColorRGBW(1.0, 0.35, 0.0, 0.0), # orange
|
||||||
|
ColorRGBW(1.0, 0.8, 0.0, 0.0), # yellow
|
||||||
|
ColorRGBW(0.0, 0.9, 0.1, 0.0), # green
|
||||||
|
ColorRGBW(0.0, 0.3, 1.0, 0.0), # blue
|
||||||
|
ColorRGBW(0.6, 0.0, 1.0, 0.0), # violet
|
||||||
|
)
|
||||||
|
|
||||||
|
#: Turning faster than this plays one tick, not a pile of overlapping ones.
|
||||||
|
TICK_INTERVAL = 0.06
|
||||||
|
#: Peak of each sound at full UI loudness, as a fraction of full scale.
|
||||||
|
TICK_GAIN = 0.25
|
||||||
|
CONFIRM_GAIN = 0.3
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class Tuning:
|
||||||
|
"""How the picker looks and behaves. Field names match
|
||||||
|
:class:`~musicmouse.config.FigureSelectionConfig`, whose values override these."""
|
||||||
|
|
||||||
|
timeout: float = 8.0 # seconds without a turn before the picker closes
|
||||||
|
#: Where the first section starts. The figure's swipe starts at LED 0 and meets
|
||||||
|
#: itself opposite, which is the front of the ring.
|
||||||
|
offset_degrees: float = 180.0
|
||||||
|
ring_direction: int = 1
|
||||||
|
knob_direction: int = 1
|
||||||
|
gap_leds: int = 1
|
||||||
|
dim_factor: float = 0.15
|
||||||
|
wobble_amplitude: float = 1.5 # LEDs
|
||||||
|
wobble_period_ms: float = 1000.0
|
||||||
|
slide_ms: float = 200.0
|
||||||
|
edge_blend: float = 0.25
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULTS = Tuning()
|
||||||
|
|
||||||
|
|
||||||
|
def tuning(app: App) -> Tuning:
|
||||||
|
""":data:`DEFAULTS`, with whatever the config's ``figure_selection`` overrides."""
|
||||||
|
overrides = app.config.general.figure_selection.model_dump(exclude_none=True)
|
||||||
|
return replace(DEFAULTS, **overrides)
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------- reactions
|
||||||
|
|
||||||
|
|
||||||
|
@on(ActiveFigureChanged)
|
||||||
|
def figure_placed_or_removed(event: ActiveFigureChanged, app: App) -> None:
|
||||||
|
# Whatever replaces the figure - nothing, or another one - sets the ring itself.
|
||||||
|
if app.state.selector is not None:
|
||||||
|
_close(app, fade=False)
|
||||||
|
if event.figure is None:
|
||||||
|
return
|
||||||
|
choices = app.playlists.get(event.figure, ())
|
||||||
|
if len(choices) > 1:
|
||||||
|
_open(app, event.figure, choices)
|
||||||
|
|
||||||
|
|
||||||
|
@on(SelectorTurned)
|
||||||
|
def selector_turned(event: SelectorTurned, app: App) -> None:
|
||||||
|
selector = app.state.selector
|
||||||
|
if selector is None:
|
||||||
|
return # a turn still in the queue when the picker closed
|
||||||
|
settings = tuning(app)
|
||||||
|
now = app.clock.now()
|
||||||
|
selector.deadline = now + settings.timeout
|
||||||
|
|
||||||
|
previous = selector.selected
|
||||||
|
selector.selected = (previous + event.steps * settings.knob_direction) % len(selector.choices)
|
||||||
|
if selector.selected == previous:
|
||||||
|
return
|
||||||
|
app.mouse.set_effect(LedZone.RING, _ring(app, selector, previous), origin="device")
|
||||||
|
if now - selector.last_tick >= TICK_INTERVAL:
|
||||||
|
selector.last_tick = now
|
||||||
|
_play_sound(app, tick_wav, TICK_GAIN)
|
||||||
|
_announce(app, selector, open_=True)
|
||||||
|
|
||||||
|
|
||||||
|
@on(SelectorPressed)
|
||||||
|
def selector_pressed(_event: SelectorPressed, app: App) -> None:
|
||||||
|
selector = app.state.selector
|
||||||
|
if selector is None:
|
||||||
|
return
|
||||||
|
playlist = selector.choices[selector.selected]
|
||||||
|
_close(app, fade=True)
|
||||||
|
_play_sound(app, confirm_wav, CONFIRM_GAIN)
|
||||||
|
# By value: a rescan rebuilds the Playlist objects (see play_figure).
|
||||||
|
if app.player.playlist == playlist:
|
||||||
|
_log.info("Album picker: keeping %s", playlist.album_id)
|
||||||
|
return
|
||||||
|
_log.info("Album picker: %r -> %s", selector.figure, playlist.album_id)
|
||||||
|
if playlist.album_id is not None:
|
||||||
|
# The same way in as the web front-end, which also remembers the album as the
|
||||||
|
# figure's for next time.
|
||||||
|
app.bus.emit(PlayAlbumRequested(album_id=playlist.album_id, source="device"))
|
||||||
|
|
||||||
|
|
||||||
|
@on(FigureSelectorExpired)
|
||||||
|
def selector_expired(event: FigureSelectorExpired, app: App) -> None:
|
||||||
|
selector = app.state.selector
|
||||||
|
# Stale if the picker it was for has closed, or a newer one is still in time.
|
||||||
|
if selector is None or selector.figure != event.figure:
|
||||||
|
return
|
||||||
|
if app.clock.now() < selector.deadline:
|
||||||
|
return
|
||||||
|
_close(app, fade=True)
|
||||||
|
|
||||||
|
|
||||||
|
@on(PlaylistFinished)
|
||||||
|
def playlist_finished(_event: PlaylistFinished, app: App) -> None:
|
||||||
|
# The off animation takes the ring; the knob goes back to being a volume knob.
|
||||||
|
if app.state.selector is not None:
|
||||||
|
_close(app, fade=False)
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------- internals
|
||||||
|
|
||||||
|
|
||||||
|
def _open(app: App, figure: str, choices: tuple[Playlist, ...]) -> None:
|
||||||
|
selector = FigureSelector(
|
||||||
|
figure=figure,
|
||||||
|
choices=choices,
|
||||||
|
selected=app.default_choice(figure),
|
||||||
|
deadline=app.clock.now() + tuning(app).timeout,
|
||||||
|
)
|
||||||
|
app.state.selector = selector
|
||||||
|
app.mouse.capture_knob(True)
|
||||||
|
app.mouse.set_effect(LedZone.RING, _ring(app, selector, None), origin="device")
|
||||||
|
selector.expiry = asyncio.create_task(_expire(app, selector), name="album-picker")
|
||||||
|
_log.info("Album picker for %r: %d albums", figure, len(choices))
|
||||||
|
_announce(app, selector, open_=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _close(app: App, *, fade: bool) -> None:
|
||||||
|
selector = app.state.selector
|
||||||
|
if selector is None:
|
||||||
|
return
|
||||||
|
app.state.selector = None
|
||||||
|
app.mouse.capture_knob(False)
|
||||||
|
if selector.expiry is not None:
|
||||||
|
selector.expiry.cancel()
|
||||||
|
if fade:
|
||||||
|
fade_to_figure(app, LedZone.RING, app.colors(selector.figure))
|
||||||
|
_announce(app, selector, open_=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def _expire(app: App, selector: FigureSelector) -> None:
|
||||||
|
# Every turn pushes the deadline back, so sleep until it stops moving.
|
||||||
|
while (remaining := selector.deadline - app.clock.now()) > 0:
|
||||||
|
await app.clock.sleep(remaining)
|
||||||
|
app.bus.emit(FigureSelectorExpired(figure=selector.figure))
|
||||||
|
|
||||||
|
|
||||||
|
def _ring(app: App, selector: FigureSelector, previous: int | None) -> EffectSegmentSelect:
|
||||||
|
settings = tuning(app)
|
||||||
|
return EffectSegmentSelect(
|
||||||
|
colors=PALETTE[: len(selector.choices)],
|
||||||
|
selected=selector.selected,
|
||||||
|
previous=previous,
|
||||||
|
gap_leds=settings.gap_leds,
|
||||||
|
offset_degrees=settings.offset_degrees,
|
||||||
|
direction=settings.ring_direction,
|
||||||
|
dim_factor=settings.dim_factor,
|
||||||
|
wobble_amplitude=settings.wobble_amplitude,
|
||||||
|
wobble_period_ms=settings.wobble_period_ms,
|
||||||
|
slide_ms=settings.slide_ms,
|
||||||
|
edge_blend=settings.edge_blend,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _announce(app: App, selector: FigureSelector, *, open_: bool) -> None:
|
||||||
|
app.bus.emit(
|
||||||
|
FigureSelectionChanged(
|
||||||
|
figure=selector.figure,
|
||||||
|
selected=selector.selected,
|
||||||
|
count=len(selector.choices),
|
||||||
|
open=open_,
|
||||||
|
source="device",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _play_sound(app: App, make: Callable[..., bytes], base_gain: float) -> None:
|
||||||
|
general = app.config.general
|
||||||
|
if general.audio_simulated:
|
||||||
|
return
|
||||||
|
gain = base_gain * ui_level(app.player.volume / 100, general.effects_volume / 100)
|
||||||
|
if gain > 0:
|
||||||
|
play_wav_soon(general.alsa_device, make(gain=gain))
|
||||||
175
python-backend/musicmouse/school_days.py
Normal file
175
python-backend/musicmouse/school_days.py
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
"""Whether today is a school day: weekends, public holidays and school holidays.
|
||||||
|
|
||||||
|
The holidays come from the OpenHolidays API (https://openholidaysapi.org), which has
|
||||||
|
both kinds per German state - Bavaria's ``DE-BY`` school holidays include the
|
||||||
|
Buß- und Bettag, which is a school day off but not a public holiday. Public holidays
|
||||||
|
carry their subdivisions, so a city-only one like Augsburg's Friedensfest
|
||||||
|
(``DE-BY-AU``) counts for Augsburg and not for the rest of Bavaria.
|
||||||
|
|
||||||
|
Fetched once a day and kept in a JSON file, so a device that loses its internet keeps
|
||||||
|
knowing the holidays for the rest of the school year.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import date, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx2
|
||||||
|
|
||||||
|
_log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"API_URL",
|
||||||
|
"Holiday",
|
||||||
|
"HolidayCalendar",
|
||||||
|
"fetch_calendar",
|
||||||
|
"load_calendar",
|
||||||
|
"parse_holidays",
|
||||||
|
"save_calendar",
|
||||||
|
"school_day",
|
||||||
|
]
|
||||||
|
|
||||||
|
API_URL = "https://openholidaysapi.org"
|
||||||
|
|
||||||
|
#: How far back and ahead each fetch reaches. Ahead covers a whole school year from
|
||||||
|
#: any date, so the cache outlives a long outage.
|
||||||
|
_FETCH_BEFORE = timedelta(days=30)
|
||||||
|
_FETCH_AHEAD = timedelta(days=400)
|
||||||
|
|
||||||
|
_WEEKEND = "Wochenende"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class Holiday:
|
||||||
|
start: date
|
||||||
|
end: date
|
||||||
|
name: str
|
||||||
|
nationwide: bool
|
||||||
|
subdivisions: tuple[str, ...]
|
||||||
|
|
||||||
|
def covers(self, day: date) -> bool:
|
||||||
|
return self.start <= day <= self.end
|
||||||
|
|
||||||
|
def applies_to(self, subdivision: str) -> bool:
|
||||||
|
"""``DE-BY-AU`` gets ``DE-BY``'s holidays as well as its own; ``DE-BY`` does
|
||||||
|
not get ``DE-BY-AU``'s."""
|
||||||
|
return self.nationwide or any(
|
||||||
|
subdivision == code or subdivision.startswith(code + "-") for code in self.subdivisions
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class HolidayCalendar:
|
||||||
|
#: The day it was fetched, to know when to fetch again.
|
||||||
|
fetched: date
|
||||||
|
subdivision: str
|
||||||
|
school: tuple[Holiday, ...]
|
||||||
|
public: tuple[Holiday, ...]
|
||||||
|
|
||||||
|
|
||||||
|
def parse_holidays(raw: list[dict[str, Any]]) -> tuple[Holiday, ...]:
|
||||||
|
"""The API's JSON list, either endpoint. Entries that do not parse are skipped."""
|
||||||
|
holidays = []
|
||||||
|
for entry in raw:
|
||||||
|
try:
|
||||||
|
names = entry.get("name") or []
|
||||||
|
name = next(
|
||||||
|
(n["text"] for n in names if n.get("language") == "DE"),
|
||||||
|
names[0]["text"] if names else "Feiertag",
|
||||||
|
)
|
||||||
|
holidays.append(
|
||||||
|
Holiday(
|
||||||
|
start=date.fromisoformat(entry["startDate"]),
|
||||||
|
end=date.fromisoformat(entry["endDate"]),
|
||||||
|
name=name,
|
||||||
|
nationwide=bool(entry.get("nationwide")),
|
||||||
|
subdivisions=tuple(s["code"] for s in entry.get("subdivisions") or []),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except (KeyError, TypeError, ValueError, IndexError):
|
||||||
|
_log.debug("Skipping a holiday entry that does not parse: %r", entry)
|
||||||
|
return tuple(holidays)
|
||||||
|
|
||||||
|
|
||||||
|
def school_day(day: date, calendar: HolidayCalendar | None) -> tuple[bool | None, str | None]:
|
||||||
|
"""``(is_school_day, why_not)``. ``None`` when there is no calendar to ask -
|
||||||
|
the weekend is still known then, a holiday is not."""
|
||||||
|
if day.weekday() >= 5:
|
||||||
|
return False, _WEEKEND
|
||||||
|
if calendar is None:
|
||||||
|
return None, None
|
||||||
|
for holiday in (*calendar.public, *calendar.school):
|
||||||
|
if holiday.covers(day) and holiday.applies_to(calendar.subdivision):
|
||||||
|
return False, holiday.name
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_calendar(
|
||||||
|
client: httpx2.AsyncClient, subdivision: str, today: date
|
||||||
|
) -> HolidayCalendar:
|
||||||
|
"""Both lists for ``subdivision``'s state. Raises on any network or HTTP error."""
|
||||||
|
# The API files school holidays under the state; city-level ones are filtered here.
|
||||||
|
state = "-".join(subdivision.split("-")[:2])
|
||||||
|
params = {
|
||||||
|
"countryIsoCode": state.split("-")[0],
|
||||||
|
"subdivisionCode": state,
|
||||||
|
"languageIsoCode": "DE",
|
||||||
|
"validFrom": (today - _FETCH_BEFORE).isoformat(),
|
||||||
|
"validTo": (today + _FETCH_AHEAD).isoformat(),
|
||||||
|
}
|
||||||
|
lists = []
|
||||||
|
for endpoint in ("SchoolHolidays", "PublicHolidays"):
|
||||||
|
response = await client.get(f"{API_URL}/{endpoint}", params=params)
|
||||||
|
response.raise_for_status()
|
||||||
|
lists.append(parse_holidays(response.json()))
|
||||||
|
school, public = lists
|
||||||
|
return HolidayCalendar(fetched=today, subdivision=subdivision, school=school, public=public)
|
||||||
|
|
||||||
|
|
||||||
|
def save_calendar(path: Path, calendar: HolidayCalendar) -> None:
|
||||||
|
def dump(holidays: tuple[Holiday, ...]) -> list[dict[str, Any]]:
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"startDate": h.start.isoformat(),
|
||||||
|
"endDate": h.end.isoformat(),
|
||||||
|
"name": [{"language": "DE", "text": h.name}],
|
||||||
|
"nationwide": h.nationwide,
|
||||||
|
"subdivisions": [{"code": code} for code in h.subdivisions],
|
||||||
|
}
|
||||||
|
for h in holidays
|
||||||
|
]
|
||||||
|
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
payload = {
|
||||||
|
"fetched": calendar.fetched.isoformat(),
|
||||||
|
"subdivision": calendar.subdivision,
|
||||||
|
"school": dump(calendar.school),
|
||||||
|
"public": dump(calendar.public),
|
||||||
|
}
|
||||||
|
temporary = path.with_suffix(".tmp")
|
||||||
|
temporary.write_text(json.dumps(payload), encoding="utf-8")
|
||||||
|
temporary.replace(path)
|
||||||
|
|
||||||
|
|
||||||
|
def load_calendar(path: Path, subdivision: str) -> HolidayCalendar | None:
|
||||||
|
"""The cached calendar, or ``None`` if there is none for this subdivision."""
|
||||||
|
try:
|
||||||
|
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
if payload["subdivision"] != subdivision:
|
||||||
|
return None
|
||||||
|
return HolidayCalendar(
|
||||||
|
fetched=date.fromisoformat(payload["fetched"]),
|
||||||
|
subdivision=subdivision,
|
||||||
|
school=parse_holidays(payload["school"]),
|
||||||
|
public=parse_holidays(payload["public"]),
|
||||||
|
)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return None
|
||||||
|
except (OSError, KeyError, TypeError, ValueError):
|
||||||
|
_log.warning("Ignoring an unreadable holiday cache at %s", path)
|
||||||
|
return None
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
"""Front-ends: things that mirror state outwards and turn requests into intents.
|
"""Front-ends: things that mirror state outwards and turn requests into intents.
|
||||||
|
|
||||||
``MqttService`` is the reference implementation. A web service would be another file
|
MQTT (Home Assistant), web (the browser), lirc (the IR remote) and the screen, timer and
|
||||||
here plus one line in the app - devices and reactions would not change.
|
podcast services each live here; adding another is a file here plus a line in
|
||||||
|
``__main__._build_services`` - devices and reactions do not change.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from musicmouse.services.base import Service
|
from musicmouse.services.base import Service
|
||||||
|
|||||||
@@ -7,6 +7,15 @@ A line looks like::
|
|||||||
that is ``<code> <repeat, hex> <button name> <remote name>``. ``repeat`` is ``00`` for
|
that is ``<code> <repeat, hex> <button name> <remote name>``. ``repeat`` is ``00`` for
|
||||||
the first press and increments while the button is held - lircd has no separate
|
the first press and increments while the button is held - lircd has no separate
|
||||||
key-up event, just repeats stopping.
|
key-up event, just repeats stopping.
|
||||||
|
|
||||||
|
irserver (the IRTrans daemon, which serves this protocol on its own port 8765) sends
|
||||||
|
the same shape but lowercases the button and remote names and always reports repeat
|
||||||
|
``00``::
|
||||||
|
|
||||||
|
b7b2000000000000 00 key_play hauppauge
|
||||||
|
|
||||||
|
Button names are therefore uppercased here, so the rest of the code sees lircd's
|
||||||
|
spelling either way.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -34,4 +43,4 @@ def parse_line(line: str) -> LircButtonEvent | None:
|
|||||||
repeat = int(repeat_hex, 16)
|
repeat = int(repeat_hex, 16)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return None
|
return None
|
||||||
return LircButtonEvent(code=code, repeat=repeat, button=button, remote=remote)
|
return LircButtonEvent(code=code, repeat=repeat, button=button.upper(), remote=remote)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
|
from dataclasses import replace
|
||||||
|
|
||||||
from musicmouse.app import App
|
from musicmouse.app import App
|
||||||
from musicmouse.clock import Clock, RealClock
|
from musicmouse.clock import Clock, RealClock
|
||||||
@@ -20,11 +21,12 @@ from musicmouse.events import (
|
|||||||
IntentEvent,
|
IntentEvent,
|
||||||
NextTrackRequested,
|
NextTrackRequested,
|
||||||
PauseRequested,
|
PauseRequested,
|
||||||
PlayAlbumRequested,
|
PlayRemoteSlotRequested,
|
||||||
PlayRequested,
|
PlayRequested,
|
||||||
PlaySeriesLatestRequested,
|
|
||||||
PreviousTrackRequested,
|
PreviousTrackRequested,
|
||||||
SetVolumeRequested,
|
SetVolumeRequested,
|
||||||
|
ToggleClockRequested,
|
||||||
|
ToggleScreenRequested,
|
||||||
VolumeChangeRequested,
|
VolumeChangeRequested,
|
||||||
)
|
)
|
||||||
from musicmouse.services.lirc.protocol import LircButtonEvent, parse_line
|
from musicmouse.services.lirc.protocol import LircButtonEvent, parse_line
|
||||||
@@ -43,12 +45,24 @@ _TRANSPORT: dict[str, Callable[[], IntentEvent]] = {
|
|||||||
"KEY_PREVIOUS": lambda: PreviousTrackRequested(source="lirc"),
|
"KEY_PREVIOUS": lambda: PreviousTrackRequested(source="lirc"),
|
||||||
"KEY_REWIND": lambda: PreviousTrackRequested(source="lirc"),
|
"KEY_REWIND": lambda: PreviousTrackRequested(source="lirc"),
|
||||||
"KEY_FORWARD": lambda: NextTrackRequested(source="lirc"),
|
"KEY_FORWARD": lambda: NextTrackRequested(source="lirc"),
|
||||||
|
# Does nothing unless general.screen is configured - see ScreenService.
|
||||||
|
"KEY_POWER": lambda: ToggleScreenRequested(source="lirc"),
|
||||||
|
# "Guide": what time is it. Shows the kiosk's clock overlay. KEY_GUIDE is what
|
||||||
|
# irserver's hauppauge.rem calls it (the ansible repo); lircd's own Hauppauge
|
||||||
|
# config says KEY_EPG.
|
||||||
|
"KEY_GUIDE": lambda: ToggleClockRequested(source="lirc"),
|
||||||
}
|
}
|
||||||
|
|
||||||
#: Acted on every repeat, for a continuous ramp while held - same feel as the rotary
|
#: Acted on every repeat, for a continuous ramp while held - same feel as the rotary
|
||||||
#: encoder (see ``reactions.playback.rotary_turned``).
|
#: encoder (see ``reactions.playback.rotary_turned``).
|
||||||
_VOLUME: dict[str, int] = {"KEY_VOLUMEUP": 1, "KEY_VOLUMEDOWN": -1}
|
_VOLUME: dict[str, int] = {"KEY_VOLUMEUP": 1, "KEY_VOLUMEDOWN": -1}
|
||||||
|
|
||||||
|
#: irserver reports every line of a held button as repeat ``00``, so a long-ish press
|
||||||
|
#: would act several times. A line of the same button arriving within this many seconds
|
||||||
|
#: of the previous one is counted as a repeat instead. The Hauppauge remote resends
|
||||||
|
#: about every 0.11s while held; lircd numbers repeats itself and is unaffected.
|
||||||
|
_REPEAT_GAP = 0.25
|
||||||
|
|
||||||
_DIGITS: dict[str, Digit] = {
|
_DIGITS: dict[str, Digit] = {
|
||||||
"BTN_0": "0",
|
"BTN_0": "0",
|
||||||
"BTN_1": "1",
|
"BTN_1": "1",
|
||||||
@@ -73,6 +87,9 @@ class LircService:
|
|||||||
#: Volume to restore on the next mute press, remembered the way the frontend's
|
#: Volume to restore on the next mute press, remembered the way the frontend's
|
||||||
#: own mute toggle does (App.tsx) rather than via any new Player API.
|
#: own mute toggle does (App.tsx) rather than via any new Player API.
|
||||||
self._pre_mute_volume: int | None = None
|
self._pre_mute_volume: int | None = None
|
||||||
|
#: The last line acted on, for counting repeats irserver does not count.
|
||||||
|
self._last: LircButtonEvent | None = None
|
||||||
|
self._last_at = 0.0
|
||||||
|
|
||||||
async def run(self) -> None:
|
async def run(self) -> None:
|
||||||
"""Connect, read lines until the link drops, wait, repeat. Runs until cancelled."""
|
"""Connect, read lines until the link drops, wait, repeat. Runs until cancelled."""
|
||||||
@@ -99,13 +116,27 @@ class LircService:
|
|||||||
if not raw:
|
if not raw:
|
||||||
return
|
return
|
||||||
event = parse_line(raw.decode(errors="replace"))
|
event = parse_line(raw.decode(errors="replace"))
|
||||||
if event is None or event.remote != self.config.remote_name:
|
if event is None or event.remote.casefold() != self.config.remote_name.casefold():
|
||||||
continue
|
continue
|
||||||
self._handle(event)
|
self._handle(self._count_repeat(event))
|
||||||
finally:
|
finally:
|
||||||
writer.close()
|
writer.close()
|
||||||
self.app.bus.emit(ConnectionChanged(target="lirc", connected=False, source="lirc"))
|
self.app.bus.emit(ConnectionChanged(target="lirc", connected=False, source="lirc"))
|
||||||
|
|
||||||
|
def _count_repeat(self, event: LircButtonEvent) -> LircButtonEvent:
|
||||||
|
now = self._clock.now()
|
||||||
|
last, last_at = self._last, self._last_at
|
||||||
|
self._last_at = now
|
||||||
|
if (
|
||||||
|
event.repeat == 0
|
||||||
|
and last is not None
|
||||||
|
and last.button == event.button
|
||||||
|
and now - last_at < _REPEAT_GAP
|
||||||
|
):
|
||||||
|
event = replace(event, repeat=last.repeat + 1)
|
||||||
|
self._last = event
|
||||||
|
return event
|
||||||
|
|
||||||
def _handle(self, event: LircButtonEvent) -> None:
|
def _handle(self, event: LircButtonEvent) -> None:
|
||||||
if event.button in _TRANSPORT:
|
if event.button in _TRANSPORT:
|
||||||
if event.repeat == 0:
|
if event.repeat == 0:
|
||||||
@@ -116,8 +147,13 @@ class LircService:
|
|||||||
elif event.button == "KEY_MUTE":
|
elif event.button == "KEY_MUTE":
|
||||||
if event.repeat == 0:
|
if event.repeat == 0:
|
||||||
self._toggle_mute()
|
self._toggle_mute()
|
||||||
elif (digit := _DIGITS.get(event.button)) is not None and event.repeat == 0:
|
elif (digit := _DIGITS.get(event.button)) is not None:
|
||||||
self._play_digit(digit)
|
if event.repeat == 0:
|
||||||
|
self._play_digit(digit)
|
||||||
|
elif event.repeat == 0:
|
||||||
|
# irserver's names do not always match lircd's; this is how to find out
|
||||||
|
# what a new button is called on the device.
|
||||||
|
_log.debug("Unmapped IR button %s", event.button)
|
||||||
|
|
||||||
def _toggle_mute(self) -> None:
|
def _toggle_mute(self) -> None:
|
||||||
player = self.app.player
|
player = self.app.player
|
||||||
@@ -129,12 +165,4 @@ class LircService:
|
|||||||
self.app.bus.emit(SetVolumeRequested(volume=restore, source="lirc"))
|
self.app.bus.emit(SetVolumeRequested(volume=restore, source="lirc"))
|
||||||
|
|
||||||
def _play_digit(self, digit: Digit) -> None:
|
def _play_digit(self, digit: Digit) -> None:
|
||||||
slot = self.app.config.remote.get(digit)
|
self.app.bus.emit(PlayRemoteSlotRequested(digit=digit, source="lirc"))
|
||||||
if slot is None:
|
|
||||||
return
|
|
||||||
if slot.target_kind == "album":
|
|
||||||
self.app.bus.emit(
|
|
||||||
PlayAlbumRequested(album_id=slot.target, track_index=0, source="lirc")
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
self.app.bus.emit(PlaySeriesLatestRequested(series=slot.target, source="lirc"))
|
|
||||||
|
|||||||
204
python-backend/musicmouse/services/screen.py
Normal file
204
python-backend/musicmouse/services/screen.py
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
"""The attached monitor: runs ``general.screen.toggle_command`` on each toggle request,
|
||||||
|
and switches it off by itself when nothing is going on.
|
||||||
|
|
||||||
|
A service rather than a reaction because the command is a subprocess: the bus awaits
|
||||||
|
async handlers, so running it there would hold every other event up behind ``xset``.
|
||||||
|
Here each press gets its own task, and a hung or failing command is only logged.
|
||||||
|
|
||||||
|
Each switch is answered with a "blubb" (:mod:`musicmouse.blubb`) on the music's own
|
||||||
|
ALSA device, as loud as UI sounds are (:func:`musicmouse.sound.ui_level`). The
|
||||||
|
command's last word of output says which way it went - ``on`` or ``off``, as
|
||||||
|
``kiosk-screen`` prints it; anything else gets no sound.
|
||||||
|
|
||||||
|
With an ``off_command`` it also switches the monitor off by itself once nothing has
|
||||||
|
played and nobody has done anything for ``auto_off_minutes`` - silently, since whoever
|
||||||
|
it would tell may be asleep. "Doing anything" is any intent a person could have caused:
|
||||||
|
a remote key, a web command, a key press in the kiosk browser (:class:`UserActivity`).
|
||||||
|
The first such intent after an auto-off runs ``on_command``, so the monitor wakes up
|
||||||
|
for the remote too, not only for the keyboard X already wakes it for.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import shlex
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
|
from musicmouse.blubb import blubb_wav
|
||||||
|
from musicmouse.bus import EventBus
|
||||||
|
from musicmouse.clock import Clock, RealClock
|
||||||
|
from musicmouse.config import ScreenConfig
|
||||||
|
from musicmouse.events import (
|
||||||
|
Event,
|
||||||
|
IntentEvent,
|
||||||
|
LedEffectRequested,
|
||||||
|
PlaybackChanged,
|
||||||
|
ToggleScreenRequested,
|
||||||
|
)
|
||||||
|
|
||||||
|
_log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
__all__ = ["ScreenService"]
|
||||||
|
|
||||||
|
#: The blubb's peak at full player volume, as a fraction of full scale. The music is
|
||||||
|
#: mastered near full scale; a notification sound sits well under it.
|
||||||
|
_BLUBB_GAIN = 0.35
|
||||||
|
|
||||||
|
#: How often the idle auto-off looks at the clock. Coarse on purpose: nobody notices
|
||||||
|
#: whether a screen went dark after 15 minutes or 15 and a half.
|
||||||
|
_IDLE_CHECK_INTERVAL = 30.0
|
||||||
|
|
||||||
|
|
||||||
|
class ScreenService:
|
||||||
|
name = "screen"
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
bus: EventBus,
|
||||||
|
config: ScreenConfig,
|
||||||
|
*,
|
||||||
|
alsa_device: str | None,
|
||||||
|
volume: Callable[[], float],
|
||||||
|
is_playing: Callable[[], bool] = lambda: False,
|
||||||
|
clock: Clock | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.bus = bus
|
||||||
|
#: Read afresh on every check: parent mode changes ``auto_off_minutes`` on this
|
||||||
|
#: very object.
|
||||||
|
self.config = config
|
||||||
|
#: Where the blubb plays, or ``None`` for no sound (simulated audio).
|
||||||
|
self.alsa_device = alsa_device
|
||||||
|
#: How loud UI sounds are right now, 0..1 (see :func:`musicmouse.sound.ui_level`),
|
||||||
|
#: read at the moment the sound plays.
|
||||||
|
self.volume = volume
|
||||||
|
self.is_playing = is_playing
|
||||||
|
self._clock = clock or RealClock()
|
||||||
|
self._last_activity = self._clock.now()
|
||||||
|
#: The monitor was last switched by the idle auto-off, not by a person.
|
||||||
|
self._auto_off = False
|
||||||
|
self._running: asyncio.Task[None] | None = None
|
||||||
|
|
||||||
|
async def run(self) -> None:
|
||||||
|
unsubscribe_toggle = self.bus.subscribe(ToggleScreenRequested, self._on_toggle)
|
||||||
|
unsubscribe_all = self.bus.subscribe_all(self._on_event)
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
await self._clock.sleep(_IDLE_CHECK_INTERVAL)
|
||||||
|
self.check_idle()
|
||||||
|
finally:
|
||||||
|
unsubscribe_toggle()
|
||||||
|
unsubscribe_all()
|
||||||
|
if self._running is not None:
|
||||||
|
self._running.cancel()
|
||||||
|
|
||||||
|
def _on_toggle(self, _event: ToggleScreenRequested) -> None:
|
||||||
|
# Whichever way the press goes, a person decided it; nothing to wake from.
|
||||||
|
self._auto_off = False
|
||||||
|
self._start(self.config.toggle_command, sound=True)
|
||||||
|
|
||||||
|
def _on_event(self, event: Event) -> None:
|
||||||
|
if isinstance(event, PlaybackChanged):
|
||||||
|
self._last_activity = self._clock.now()
|
||||||
|
return
|
||||||
|
# LED intents arrive from Home Assistant automations as well as people, and
|
||||||
|
# "system" intents are the backend talking to itself.
|
||||||
|
if (
|
||||||
|
not isinstance(event, IntentEvent)
|
||||||
|
or isinstance(event, LedEffectRequested)
|
||||||
|
or event.source == "system"
|
||||||
|
):
|
||||||
|
return
|
||||||
|
self._last_activity = self._clock.now()
|
||||||
|
if self._auto_off and not isinstance(event, ToggleScreenRequested):
|
||||||
|
self._auto_off = False
|
||||||
|
if self.config.on_command:
|
||||||
|
self._start(self.config.on_command, sound=True)
|
||||||
|
|
||||||
|
def check_idle(self) -> None:
|
||||||
|
"""Switch the monitor off if it has been idle long enough. Called every
|
||||||
|
:data:`_IDLE_CHECK_INTERVAL` by :meth:`run`."""
|
||||||
|
now = self._clock.now()
|
||||||
|
if self.is_playing():
|
||||||
|
# The countdown starts when the music stops, not when it started.
|
||||||
|
self._last_activity = now
|
||||||
|
return
|
||||||
|
minutes = self.config.auto_off_minutes
|
||||||
|
if self._auto_off or not minutes or not self.config.off_command:
|
||||||
|
return
|
||||||
|
if now - self._last_activity < minutes * 60:
|
||||||
|
return
|
||||||
|
if self._start(self.config.off_command, sound=False):
|
||||||
|
_log.info("Nothing played for %d minutes; switching the screen off", minutes)
|
||||||
|
self._auto_off = True
|
||||||
|
|
||||||
|
def _start(self, command: str, *, sound: bool) -> bool:
|
||||||
|
# A second switch while the first command is still running would read the
|
||||||
|
# monitor's state before the first one changed it, and undo it.
|
||||||
|
if self._running is not None and not self._running.done():
|
||||||
|
_log.debug("Screen command still running; ignoring this one")
|
||||||
|
return False
|
||||||
|
self._running = asyncio.create_task(self._run(command, sound=sound), name="screen")
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def _run(self, command: str, *, sound: bool) -> None:
|
||||||
|
argv = shlex.split(command)
|
||||||
|
try:
|
||||||
|
process = await asyncio.create_subprocess_exec(
|
||||||
|
*argv, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT
|
||||||
|
)
|
||||||
|
except OSError as exc:
|
||||||
|
_log.warning("Screen command %r could not start: %s", command, exc)
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
output, _ = await asyncio.wait_for(process.communicate(), self.config.timeout)
|
||||||
|
except TimeoutError:
|
||||||
|
process.kill()
|
||||||
|
await process.wait()
|
||||||
|
_log.warning(
|
||||||
|
"Screen command %r took longer than %gs; killed", command, self.config.timeout
|
||||||
|
)
|
||||||
|
return
|
||||||
|
text = output.decode(errors="replace").strip()
|
||||||
|
if process.returncode != 0:
|
||||||
|
_log.warning(
|
||||||
|
"Screen command %r failed (exit %d): %s", command, process.returncode, text
|
||||||
|
)
|
||||||
|
return
|
||||||
|
_log.info("Screen switched%s", f": {text}" if text else "")
|
||||||
|
state = text.split()[-1] if text else ""
|
||||||
|
if sound and state in ("on", "off"):
|
||||||
|
await self.blubb(on=state == "on")
|
||||||
|
|
||||||
|
async def blubb(self, *, on: bool) -> None:
|
||||||
|
gain = _BLUBB_GAIN * max(0.0, min(1.0, self.volume()))
|
||||||
|
if self.alsa_device is None or gain <= 0:
|
||||||
|
return
|
||||||
|
wav = await asyncio.to_thread(blubb_wav, on=on, gain=gain)
|
||||||
|
try:
|
||||||
|
process = await asyncio.create_subprocess_exec(
|
||||||
|
"aplay",
|
||||||
|
"-q",
|
||||||
|
"-D",
|
||||||
|
self.alsa_device,
|
||||||
|
"-",
|
||||||
|
stdin=asyncio.subprocess.PIPE,
|
||||||
|
stdout=asyncio.subprocess.DEVNULL,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
)
|
||||||
|
except OSError as exc:
|
||||||
|
_log.warning("Could not play the screen sound: %s", exc)
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
_, error = await asyncio.wait_for(process.communicate(wav), self.config.timeout)
|
||||||
|
except TimeoutError:
|
||||||
|
process.kill()
|
||||||
|
await process.wait()
|
||||||
|
_log.warning("Screen sound took longer than %gs; killed", self.config.timeout)
|
||||||
|
return
|
||||||
|
if process.returncode != 0:
|
||||||
|
_log.warning(
|
||||||
|
"Screen sound failed (exit %d): %s",
|
||||||
|
process.returncode,
|
||||||
|
error.decode(errors="replace").strip(),
|
||||||
|
)
|
||||||
226
python-backend/musicmouse/services/timers.py
Normal file
226
python-backend/musicmouse/services/timers.py
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
"""The clock overlay's backend half: the parents' countdown and today's school day.
|
||||||
|
|
||||||
|
The overlay itself lives in the kiosk browser. What has to live here is what several
|
||||||
|
screens share (a timer set from a parent's phone shows on the kiosk), what has to keep
|
||||||
|
going with the screen off (the chime), and what needs the internet (the holidays).
|
||||||
|
|
||||||
|
The countdown runs on the injected :class:`~musicmouse.clock.Clock` like everything
|
||||||
|
else that waits; wall time comes from ``wall`` so a test can pin the date.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from collections.abc import Callable
|
||||||
|
from datetime import date, datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx2
|
||||||
|
|
||||||
|
from musicmouse.app import AppState, ParentTimer
|
||||||
|
from musicmouse.blubb import chime_wav
|
||||||
|
from musicmouse.bus import EventBus
|
||||||
|
from musicmouse.clock import Clock, RealClock
|
||||||
|
from musicmouse.config import ClockConfig
|
||||||
|
from musicmouse.events import (
|
||||||
|
CancelTimerRequested,
|
||||||
|
ClockStateChanged,
|
||||||
|
Event,
|
||||||
|
IntentEvent,
|
||||||
|
LedEffectRequested,
|
||||||
|
SetTimerRequested,
|
||||||
|
)
|
||||||
|
from musicmouse.school_days import (
|
||||||
|
HolidayCalendar,
|
||||||
|
fetch_calendar,
|
||||||
|
load_calendar,
|
||||||
|
save_calendar,
|
||||||
|
school_day,
|
||||||
|
)
|
||||||
|
from musicmouse.sound import play_wav
|
||||||
|
|
||||||
|
_log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
__all__ = ["RING_SECONDS", "TimerService", "timer_end"]
|
||||||
|
|
||||||
|
#: How long a timer that ran out keeps showing 00:00 before it clears by itself.
|
||||||
|
RING_SECONDS = 60.0
|
||||||
|
|
||||||
|
#: The chime's peak at full player volume, as a fraction of full scale - soft.
|
||||||
|
_CHIME_GAIN = 0.3
|
||||||
|
|
||||||
|
#: After a failed holiday fetch, try again this much later rather than at midnight.
|
||||||
|
_RETRY_SECONDS = 3600.0
|
||||||
|
#: Wake this long after midnight, so the new day has surely begun.
|
||||||
|
_AFTER_MIDNIGHT = 5.0
|
||||||
|
|
||||||
|
|
||||||
|
def timer_end(now: datetime, *, minutes: int | None = None, until: str | None = None) -> datetime:
|
||||||
|
"""When a timer started at ``now`` ends: ``minutes`` from now, or at the next
|
||||||
|
``until`` ("HH:MM") - tomorrow if that time has already passed today."""
|
||||||
|
if (minutes is None) == (until is None):
|
||||||
|
raise ValueError("give exactly one of minutes and until")
|
||||||
|
if minutes is not None:
|
||||||
|
return now + timedelta(minutes=minutes)
|
||||||
|
assert until is not None
|
||||||
|
hour, minute = (int(part) for part in until.split(":"))
|
||||||
|
end = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
|
||||||
|
return end if end > now else end + timedelta(days=1)
|
||||||
|
|
||||||
|
|
||||||
|
class TimerService:
|
||||||
|
name = "timers"
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
bus: EventBus,
|
||||||
|
state: AppState,
|
||||||
|
config: ClockConfig,
|
||||||
|
*,
|
||||||
|
cache_file: Path,
|
||||||
|
client: httpx2.AsyncClient,
|
||||||
|
alsa_device: str | None,
|
||||||
|
volume: Callable[[], float],
|
||||||
|
clock: Clock | None = None,
|
||||||
|
wall: Callable[[], datetime] = datetime.now,
|
||||||
|
) -> None:
|
||||||
|
self.bus = bus
|
||||||
|
self.state = state
|
||||||
|
self.config = config
|
||||||
|
self.cache_file = cache_file
|
||||||
|
self.client = client
|
||||||
|
#: Where the chime plays, or ``None`` for no sound (simulated audio).
|
||||||
|
self.alsa_device = alsa_device
|
||||||
|
#: How loud UI sounds are right now, 0..1 (see :func:`musicmouse.sound.ui_level`),
|
||||||
|
#: read when the chime plays.
|
||||||
|
self.volume = volume
|
||||||
|
self._clock = clock or RealClock()
|
||||||
|
self._wall = wall
|
||||||
|
self._calendar: HolidayCalendar | None = None
|
||||||
|
self._countdown: asyncio.Task[None] | None = None
|
||||||
|
self._chime_task: asyncio.Task[None] | None = None
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ lifecycle
|
||||||
|
|
||||||
|
async def run(self) -> None:
|
||||||
|
unsubscribe = [
|
||||||
|
self.bus.subscribe(SetTimerRequested, self._on_set),
|
||||||
|
self.bus.subscribe(CancelTimerRequested, self._on_cancel),
|
||||||
|
self.bus.subscribe_all(self._on_event),
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
fetched = await self.refresh_school_day()
|
||||||
|
await self._clock.sleep(self._seconds_to_midnight() if fetched else _RETRY_SECONDS)
|
||||||
|
finally:
|
||||||
|
for undo in unsubscribe:
|
||||||
|
undo()
|
||||||
|
if self._countdown is not None:
|
||||||
|
self._countdown.cancel()
|
||||||
|
await self.client.aclose()
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------- school day
|
||||||
|
|
||||||
|
async def refresh_school_day(self) -> bool:
|
||||||
|
"""Work out today's school day, fetching the holidays if the cached ones are
|
||||||
|
not from today. ``False`` if a fetch was due and failed."""
|
||||||
|
today = self._wall().date()
|
||||||
|
ok = True
|
||||||
|
if self._calendar is None:
|
||||||
|
self._calendar = load_calendar(self.cache_file, self.config.subdivision)
|
||||||
|
if self._calendar is None or self._calendar.fetched < today:
|
||||||
|
try:
|
||||||
|
self._calendar = await fetch_calendar(self.client, self.config.subdivision, today)
|
||||||
|
save_calendar(self.cache_file, self._calendar)
|
||||||
|
except Exception as exc:
|
||||||
|
ok = False
|
||||||
|
_log.warning(
|
||||||
|
"Could not fetch the school holidays (%s); %s",
|
||||||
|
exc,
|
||||||
|
"using the cached ones" if self._calendar else "only weekends are known",
|
||||||
|
)
|
||||||
|
self._set_school_day(today)
|
||||||
|
return ok
|
||||||
|
|
||||||
|
def _set_school_day(self, today: date) -> None:
|
||||||
|
is_school_day, reason = school_day(today, self._calendar)
|
||||||
|
if (is_school_day, reason) == (self.state.school_day, self.state.school_day_reason):
|
||||||
|
return
|
||||||
|
self.state.school_day = is_school_day
|
||||||
|
self.state.school_day_reason = reason
|
||||||
|
_log.info(
|
||||||
|
"Today is %s",
|
||||||
|
"a school day" if is_school_day else f"no school day ({reason or 'unknown'})",
|
||||||
|
)
|
||||||
|
self.bus.emit(ClockStateChanged())
|
||||||
|
|
||||||
|
def _seconds_to_midnight(self) -> float:
|
||||||
|
now = self._wall()
|
||||||
|
midnight = datetime.combine(now.date() + timedelta(days=1), datetime.min.time())
|
||||||
|
return (midnight - now).total_seconds() + _AFTER_MIDNIGHT
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- the timer
|
||||||
|
|
||||||
|
def _on_set(self, event: SetTimerRequested) -> None:
|
||||||
|
self._stop_countdown()
|
||||||
|
now = self._wall().timestamp()
|
||||||
|
self.state.parent_timer = ParentTimer(started_at=now, ends_at=event.ends_at)
|
||||||
|
self.bus.emit(ClockStateChanged())
|
||||||
|
self._countdown = asyncio.create_task(
|
||||||
|
self._run_countdown(event.ends_at - now), name="parent-timer"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _on_cancel(self, _event: CancelTimerRequested) -> None:
|
||||||
|
self._stop_countdown()
|
||||||
|
self._clear()
|
||||||
|
|
||||||
|
def _on_event(self, event: Event) -> None:
|
||||||
|
"""Anything a person does silences a ringing timer - the same notion of "a
|
||||||
|
person" as the screen's idle auto-off: intents, minus Home Assistant's LED
|
||||||
|
writes and the backend talking to itself."""
|
||||||
|
timer = self.state.parent_timer
|
||||||
|
if (
|
||||||
|
timer is None
|
||||||
|
or not timer.ringing
|
||||||
|
or not isinstance(event, IntentEvent)
|
||||||
|
or isinstance(event, (LedEffectRequested, SetTimerRequested, CancelTimerRequested))
|
||||||
|
or event.source == "system"
|
||||||
|
):
|
||||||
|
return
|
||||||
|
self._stop_countdown()
|
||||||
|
self._clear()
|
||||||
|
|
||||||
|
async def _run_countdown(self, seconds: float) -> None:
|
||||||
|
await self._clock.sleep(max(0.0, seconds))
|
||||||
|
timer = self.state.parent_timer
|
||||||
|
if timer is None:
|
||||||
|
return
|
||||||
|
self.state.parent_timer = ParentTimer(
|
||||||
|
started_at=timer.started_at, ends_at=timer.ends_at, ringing=True
|
||||||
|
)
|
||||||
|
_log.info("The parents' timer ran out")
|
||||||
|
self.bus.emit(ClockStateChanged())
|
||||||
|
# Its own task: silencing the timer must not cut the chime off mid-note.
|
||||||
|
self._chime_task = asyncio.create_task(self._chime(), name="parent-timer-chime")
|
||||||
|
await self._clock.sleep(RING_SECONDS)
|
||||||
|
self._countdown = None
|
||||||
|
self._clear()
|
||||||
|
|
||||||
|
async def _chime(self) -> None:
|
||||||
|
if self.alsa_device is None:
|
||||||
|
return
|
||||||
|
gain = _CHIME_GAIN * max(0.0, min(1.0, self.volume()))
|
||||||
|
wav = await asyncio.to_thread(chime_wav, gain=gain)
|
||||||
|
await play_wav(self.alsa_device, wav)
|
||||||
|
|
||||||
|
def _stop_countdown(self) -> None:
|
||||||
|
if self._countdown is not None:
|
||||||
|
self._countdown.cancel()
|
||||||
|
self._countdown = None
|
||||||
|
|
||||||
|
def _clear(self) -> None:
|
||||||
|
if self.state.parent_timer is None:
|
||||||
|
return
|
||||||
|
self.state.parent_timer = None
|
||||||
|
self.bus.emit(ClockStateChanged())
|
||||||
@@ -12,6 +12,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -22,15 +23,22 @@ from fastapi.responses import FileResponse
|
|||||||
from musicmouse.app import App
|
from musicmouse.app import App
|
||||||
from musicmouse.config import Digit, HaConfig, RemoteSlotConfig
|
from musicmouse.config import Digit, HaConfig, RemoteSlotConfig
|
||||||
from musicmouse.events import (
|
from musicmouse.events import (
|
||||||
|
CancelTimerRequested,
|
||||||
IntentEvent,
|
IntentEvent,
|
||||||
NextTrackRequested,
|
NextTrackRequested,
|
||||||
PauseRequested,
|
PauseRequested,
|
||||||
PlayAlbumRequested,
|
PlayAlbumRequested,
|
||||||
|
PlayRemoteSlotRequested,
|
||||||
PlayRequested,
|
PlayRequested,
|
||||||
PreviousTrackRequested,
|
PreviousTrackRequested,
|
||||||
SeekRequested,
|
SeekRequested,
|
||||||
|
SetTimerRequested,
|
||||||
SetVolumeRequested,
|
SetVolumeRequested,
|
||||||
|
ToggleScreenRequested,
|
||||||
|
UserActivity,
|
||||||
|
VolumeChangeRequested,
|
||||||
)
|
)
|
||||||
|
from musicmouse.services.timers import timer_end
|
||||||
from musicmouse.services.web.hub import StateHub
|
from musicmouse.services.web.hub import StateHub
|
||||||
from musicmouse.services.web.remote_settings import read_mapping, write_mapping
|
from musicmouse.services.web.remote_settings import read_mapping, write_mapping
|
||||||
from musicmouse.services.web.schemas import (
|
from musicmouse.services.web.schemas import (
|
||||||
@@ -38,7 +46,6 @@ from musicmouse.services.web.schemas import (
|
|||||||
HaConfigOut,
|
HaConfigOut,
|
||||||
HaDeviceOut,
|
HaDeviceOut,
|
||||||
LibraryOut,
|
LibraryOut,
|
||||||
LircConfigOut,
|
|
||||||
PlayerStateOut,
|
PlayerStateOut,
|
||||||
PlayIn,
|
PlayIn,
|
||||||
RemoteMappingIn,
|
RemoteMappingIn,
|
||||||
@@ -46,6 +53,7 @@ from musicmouse.services.web.schemas import (
|
|||||||
SeekIn,
|
SeekIn,
|
||||||
SettingsIn,
|
SettingsIn,
|
||||||
SettingsOut,
|
SettingsOut,
|
||||||
|
TimerIn,
|
||||||
TippenCurriculumOut,
|
TippenCurriculumOut,
|
||||||
TippenProgressOut,
|
TippenProgressOut,
|
||||||
TippenRunIn,
|
TippenRunIn,
|
||||||
@@ -59,7 +67,6 @@ from musicmouse.services.web.schemas import (
|
|||||||
from musicmouse.services.web.settings import (
|
from musicmouse.services.web.settings import (
|
||||||
read_settings,
|
read_settings,
|
||||||
to_device_volume,
|
to_device_volume,
|
||||||
to_percent,
|
|
||||||
write_settings,
|
write_settings,
|
||||||
)
|
)
|
||||||
from musicmouse.services.web.state import snapshot
|
from musicmouse.services.web.state import snapshot
|
||||||
@@ -97,27 +104,32 @@ def build_router(
|
|||||||
)
|
)
|
||||||
|
|
||||||
@router.get("/albums/{album_id}/cover")
|
@router.get("/albums/{album_id}/cover")
|
||||||
def get_cover(album_id: str) -> FileResponse:
|
def get_cover(album_id: str, size: str = "full", v: str | None = None) -> FileResponse:
|
||||||
album = app.library.get(album_id)
|
album = app.library.get(album_id)
|
||||||
if album is None or album.cover is None:
|
if album is None or album.cover is None:
|
||||||
# Not an error: the client paints the album's own colours instead.
|
# Not an error: the client paints the album's own colours instead.
|
||||||
raise HTTPException(status_code=404, detail="no cover")
|
raise HTTPException(status_code=404, detail="no cover")
|
||||||
|
path = album.cover
|
||||||
|
if size == "thumb":
|
||||||
|
thumb = path.with_name(f"{path.stem}.thumb.jpg")
|
||||||
|
if thumb.is_file():
|
||||||
|
path = thumb
|
||||||
return FileResponse(
|
return FileResponse(
|
||||||
album.cover,
|
path,
|
||||||
# This used to send `public, max-age=604800`, on the reasoning that a cover
|
# A URL carrying `v` (the file's mtime, from `AlbumOut.cover_v`) names one
|
||||||
# is "content-addressed by album id". It is not: the id addresses *which
|
# exact set of bytes: reprocessing the art changes the library payload, so
|
||||||
# album* the art belongs to, and the bytes behind that URL change whenever
|
# the client asks for a new URL. Those can be cached forever, and the
|
||||||
# the art is reprocessed. When `MAX_COVER_PX` arrived and every cover was
|
# browser then never asks again - no conditional request per card.
|
||||||
# rewritten smaller, every browser that had loaded the page in the previous
|
|
||||||
# week went on decoding the old 3000px file out of its own disk cache, and
|
|
||||||
# a trace was the only way to see it.
|
|
||||||
#
|
#
|
||||||
# `no-cache` does not mean "do not store", it means "revalidate before
|
# Without `v` the id alone is not enough (the bytes behind it change whenever
|
||||||
# reusing" - so the browser keeps the file and usually gets a 304. The cost
|
# the art is reprocessed - when `MAX_COVER_PX` arrived, browsers went on
|
||||||
# is one conditional request per cover, and it is not on the paint path at
|
# decoding old 3000px files out of their cache), so `no-cache` makes the
|
||||||
# all: the service worker answers covers from its own cache first and does
|
# browser revalidate: it keeps the file and usually gets a 304.
|
||||||
# the revalidation behind the page (see web/public/sw.js).
|
headers={
|
||||||
headers={"Cache-Control": "no-cache"},
|
"Cache-Control": (
|
||||||
|
"public, max-age=31536000, immutable" if v else "no-cache"
|
||||||
|
)
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@router.get("/tracks/{album_id}/{index}/analysis")
|
@router.get("/tracks/{album_id}/{index}/analysis")
|
||||||
@@ -184,6 +196,30 @@ def build_router(
|
|||||||
def previous_track() -> Response:
|
def previous_track() -> Response:
|
||||||
return emit(PreviousTrackRequested(source="web"))
|
return emit(PreviousTrackRequested(source="web"))
|
||||||
|
|
||||||
|
@router.post("/screen/toggle", status_code=204)
|
||||||
|
def toggle_screen() -> Response:
|
||||||
|
# The device's own monitor - only the kiosk page on the device calls this (its
|
||||||
|
# Del key). Without a screen section in the config nothing listens and it is a
|
||||||
|
# no-op, like the remote's power key.
|
||||||
|
return emit(ToggleScreenRequested(source="web"))
|
||||||
|
|
||||||
|
@router.post("/screen/activity", status_code=204)
|
||||||
|
def screen_activity() -> Response:
|
||||||
|
# The kiosk page on the device reports key presses and taps here, so the
|
||||||
|
# screen's idle auto-off does not blank it under a child who is browsing.
|
||||||
|
return emit(UserActivity(source="web"))
|
||||||
|
|
||||||
|
@router.post("/timer", status_code=204)
|
||||||
|
def set_timer(body: TimerIn) -> Response:
|
||||||
|
# The parents' countdown. The new state, ringing and all, arrives over the
|
||||||
|
# websocket like everything else.
|
||||||
|
end = timer_end(datetime.now(), minutes=body.minutes, until=body.until)
|
||||||
|
return emit(SetTimerRequested(ends_at=end.timestamp(), source="web"))
|
||||||
|
|
||||||
|
@router.delete("/timer", status_code=204)
|
||||||
|
def cancel_timer() -> Response:
|
||||||
|
return emit(CancelTimerRequested(source="web"))
|
||||||
|
|
||||||
@router.post("/seek", status_code=204)
|
@router.post("/seek", status_code=204)
|
||||||
def seek(body: SeekIn) -> Response:
|
def seek(body: SeekIn) -> Response:
|
||||||
return emit(SeekRequested(position=body.position, source="web"))
|
return emit(SeekRequested(position=body.position, source="web"))
|
||||||
@@ -191,16 +227,15 @@ def build_router(
|
|||||||
@router.post("/volume", status_code=204)
|
@router.post("/volume", status_code=204)
|
||||||
def set_volume(body: VolumeIn) -> Response:
|
def set_volume(body: VolumeIn) -> Response:
|
||||||
general = app.config.general
|
general = app.config.general
|
||||||
if body.percent is not None:
|
if body.steps is not None:
|
||||||
target = body.percent
|
# The IR remote's own step, in device units - see LircService._handle.
|
||||||
elif body.delta_percent is not None:
|
return emit(
|
||||||
target = to_percent(app.player.volume, general) + body.delta_percent
|
VolumeChangeRequested(delta=general.volume_increment * body.steps, source="web")
|
||||||
else:
|
|
||||||
raise HTTPException(status_code=422, detail="percent or delta_percent required")
|
|
||||||
return emit(
|
|
||||||
SetVolumeRequested(
|
|
||||||
volume=to_device_volume(max(0, min(100, target)), general), source="web"
|
|
||||||
)
|
)
|
||||||
|
if body.percent is None:
|
||||||
|
raise HTTPException(status_code=422, detail="percent or steps required")
|
||||||
|
return emit(
|
||||||
|
SetVolumeRequested(volume=to_device_volume(body.percent, general), source="web")
|
||||||
)
|
)
|
||||||
|
|
||||||
# ------------------------------------------------------------- parent mode
|
# ------------------------------------------------------------- parent mode
|
||||||
@@ -223,8 +258,13 @@ def build_router(
|
|||||||
general = app.config.general
|
general = app.config.general
|
||||||
# Applied live as well as saved: a parent lowering the ceiling expects the next
|
# Applied live as well as saved: a parent lowering the ceiling expects the next
|
||||||
# song to be quieter, not the next boot.
|
# song to be quieter, not the next boot.
|
||||||
for key, value in body.model_dump().items():
|
values = body.model_dump()
|
||||||
|
auto_off = values.pop("screen_auto_off_minutes")
|
||||||
|
for key, value in values.items():
|
||||||
setattr(general, key, value)
|
setattr(general, key, value)
|
||||||
|
if auto_off is not None and general.screen is not None:
|
||||||
|
# The same object ScreenService reads on every tick.
|
||||||
|
general.screen.auto_off_minutes = auto_off
|
||||||
app.player.set_volume_limits(general.min_volume, general.max_volume)
|
app.player.set_volume_limits(general.min_volume, general.max_volume)
|
||||||
await asyncio.to_thread(write_settings, config_path, body)
|
await asyncio.to_thread(write_settings, config_path, body)
|
||||||
await hub.broadcast_state()
|
await hub.broadcast_state()
|
||||||
@@ -271,11 +311,11 @@ def build_router(
|
|||||||
|
|
||||||
# -------------------------------------------------------------- IR remote
|
# -------------------------------------------------------------- IR remote
|
||||||
|
|
||||||
@router.get("/lirc")
|
@router.post("/remote/press/{digit}", status_code=204)
|
||||||
def get_lirc_config() -> LircConfigOut:
|
def press_remote(digit: Digit) -> Response:
|
||||||
if app.config.general.lirc is None:
|
# A keyboard's number keys, doing what the IR remote's do. An unassigned key
|
||||||
raise HTTPException(status_code=404, detail="lirc not configured")
|
# is not an error - the remote's own press of it is silent too.
|
||||||
return LircConfigOut(connected=app.state.lirc_connected)
|
return emit(PlayRemoteSlotRequested(digit=digit, source="web"))
|
||||||
|
|
||||||
@router.get("/remote/mapping")
|
@router.get("/remote/mapping")
|
||||||
def get_remote_mapping() -> RemoteMappingOut:
|
def get_remote_mapping() -> RemoteMappingOut:
|
||||||
@@ -295,7 +335,19 @@ def build_router(
|
|||||||
status_code=422,
|
status_code=422,
|
||||||
detail=f"key {digit}: no such {slot.target_kind} {slot.target!r}",
|
detail=f"key {digit}: no such {slot.target_kind} {slot.target!r}",
|
||||||
)
|
)
|
||||||
resolved[digit] = RemoteSlotConfig(target_kind=slot.target_kind, target=slot.target)
|
if slot.target_kind == "series" and slot.track_index:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail=f"key {digit}: a podcast show has no fixed song to start at",
|
||||||
|
)
|
||||||
|
if slot.track_index >= len(found.tracks):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail=f"key {digit}: {slot.target!r} has no track {slot.track_index}",
|
||||||
|
)
|
||||||
|
resolved[digit] = RemoteSlotConfig(
|
||||||
|
target_kind=slot.target_kind, target=slot.target, track_index=slot.track_index
|
||||||
|
)
|
||||||
|
|
||||||
app.config.remote = resolved
|
app.config.remote = resolved
|
||||||
await asyncio.to_thread(write_mapping, config_path, resolved)
|
await asyncio.to_thread(write_mapping, config_path, resolved)
|
||||||
@@ -324,6 +376,10 @@ def build_router(
|
|||||||
method, f"{ha.url}{path}", headers={"Authorization": f"Bearer {ha.token}"}, **kwargs
|
method, f"{ha.url}{path}", headers={"Authorization": f"Bearer {ha.token}"}, **kwargs
|
||||||
)
|
)
|
||||||
except httpx2.HTTPError as exc:
|
except httpx2.HTTPError as exc:
|
||||||
|
# httpx logs a request only once it has a response, so without this a call
|
||||||
|
# that times out - a Homematic command the device never acknowledges can
|
||||||
|
# keep Home Assistant from answering - leaves no trace at all.
|
||||||
|
_log.warning("Home Assistant %s %s failed: %r", method, path, exc)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=502, detail=f"Home Assistant unreachable: {exc}"
|
status_code=502, detail=f"Home Assistant unreachable: {exc}"
|
||||||
) from exc
|
) from exc
|
||||||
|
|||||||
@@ -22,7 +22,19 @@ from typing import Any
|
|||||||
from fastapi import WebSocket
|
from fastapi import WebSocket
|
||||||
|
|
||||||
from musicmouse.app import App
|
from musicmouse.app import App
|
||||||
from musicmouse.events import Event, StateEvent
|
from musicmouse.events import (
|
||||||
|
ActiveFigureChanged,
|
||||||
|
ClockStateChanged,
|
||||||
|
ConnectionChanged,
|
||||||
|
Event,
|
||||||
|
FigureSelectionChanged,
|
||||||
|
LedEffectChanged,
|
||||||
|
PlaybackChanged,
|
||||||
|
StateEvent,
|
||||||
|
ToggleClockRequested,
|
||||||
|
TrackChanged,
|
||||||
|
VolumeChanged,
|
||||||
|
)
|
||||||
from musicmouse.services.web.state import snapshot
|
from musicmouse.services.web.state import snapshot
|
||||||
|
|
||||||
_log = logging.getLogger(__name__)
|
_log = logging.getLogger(__name__)
|
||||||
@@ -33,22 +45,45 @@ __all__ = ["StateHub"]
|
|||||||
#: enough to leave running.
|
#: enough to leave running.
|
||||||
POSITION_INTERVAL = 0.5
|
POSITION_INTERVAL = 0.5
|
||||||
|
|
||||||
|
#: The state events that change something in :func:`~musicmouse.services.web.state.snapshot`
|
||||||
|
#: - each one costs every open tab a full snapshot. LED writes are the reason this is a
|
||||||
|
#: list rather than "every StateEvent": a figure placed and removed is 9 LED writes,
|
||||||
|
#: and the snapshot has no LED state in it.
|
||||||
|
SNAPSHOT_EVENTS: tuple[type[StateEvent], ...] = (
|
||||||
|
PlaybackChanged,
|
||||||
|
TrackChanged,
|
||||||
|
VolumeChanged,
|
||||||
|
ActiveFigureChanged,
|
||||||
|
ConnectionChanged,
|
||||||
|
ClockStateChanged,
|
||||||
|
)
|
||||||
|
|
||||||
|
#: State events deliberately not pushed to the browser. Together with
|
||||||
|
#: :data:`SNAPSHOT_EVENTS` this covers every :class:`StateEvent`; a test holds that, so
|
||||||
|
#: a new state event has to be sorted into one or the other.
|
||||||
|
#: The ring's album picker is shown on the LED ring, not in the browser.
|
||||||
|
IGNORED_EVENTS: tuple[type[StateEvent], ...] = (LedEffectChanged, FigureSelectionChanged)
|
||||||
|
|
||||||
|
|
||||||
class StateHub:
|
class StateHub:
|
||||||
def __init__(self, app: App) -> None:
|
def __init__(self, app: App) -> None:
|
||||||
self.app = app
|
self.app = app
|
||||||
self._clients: set[WebSocket] = set()
|
self._clients: set[WebSocket] = set()
|
||||||
self._unsubscribe: Any = None
|
self._unsubscribe: list[Any] = []
|
||||||
|
|
||||||
# ------------------------------------------------------------------- lifecycle
|
# ------------------------------------------------------------------- lifecycle
|
||||||
|
|
||||||
def start(self) -> None:
|
def start(self) -> None:
|
||||||
self._unsubscribe = self.app.bus.subscribe(StateEvent, self._on_state)
|
self._unsubscribe = [
|
||||||
|
*(self.app.bus.subscribe(kind, self._on_state) for kind in SNAPSHOT_EVENTS),
|
||||||
|
# The clock overlay is the kiosk's own; the remote's key only asks for it.
|
||||||
|
self.app.bus.subscribe(ToggleClockRequested, self._on_clock_toggle),
|
||||||
|
]
|
||||||
|
|
||||||
def stop(self) -> None:
|
def stop(self) -> None:
|
||||||
if self._unsubscribe is not None:
|
for unsubscribe in self._unsubscribe:
|
||||||
self._unsubscribe()
|
unsubscribe()
|
||||||
self._unsubscribe = None
|
self._unsubscribe = []
|
||||||
|
|
||||||
async def run(self) -> None:
|
async def run(self) -> None:
|
||||||
"""Push the playback position while anything is playing."""
|
"""Push the playback position while anything is playing."""
|
||||||
@@ -102,3 +137,6 @@ class StateHub:
|
|||||||
|
|
||||||
async def _on_state(self, _event: Event) -> None:
|
async def _on_state(self, _event: Event) -> None:
|
||||||
await self.broadcast_state()
|
await self.broadcast_state()
|
||||||
|
|
||||||
|
async def _on_clock_toggle(self, _event: ToggleClockRequested) -> None:
|
||||||
|
await self.broadcast({"type": "clock_toggle"})
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ def read_mapping(remote: dict[Digit, RemoteSlotConfig], library: MusicLibrary) -
|
|||||||
target_kind=slot.target_kind,
|
target_kind=slot.target_kind,
|
||||||
target=slot.target,
|
target=slot.target,
|
||||||
resolved_album_id=_resolve(slot, library),
|
resolved_album_id=_resolve(slot, library),
|
||||||
|
track_index=slot.track_index,
|
||||||
)
|
)
|
||||||
for digit, slot in sorted(remote.items())
|
for digit, slot in sorted(remote.items())
|
||||||
]
|
]
|
||||||
@@ -40,9 +41,12 @@ def read_mapping(remote: dict[Digit, RemoteSlotConfig], library: MusicLibrary) -
|
|||||||
|
|
||||||
def write_mapping(path: Path, mapping: dict[Digit, RemoteSlotConfig]) -> None:
|
def write_mapping(path: Path, mapping: dict[Digit, RemoteSlotConfig]) -> None:
|
||||||
document = load_document(path)
|
document = load_document(path)
|
||||||
remote = {
|
remote: dict[str, dict[str, str | int]] = {}
|
||||||
digit: {"target_kind": slot.target_kind, "target": slot.target}
|
for digit, slot in mapping.items():
|
||||||
for digit, slot in mapping.items()
|
entry: dict[str, str | int] = {"target_kind": slot.target_kind, "target": slot.target}
|
||||||
}
|
# Only written when it says something, so a whole-album slot reads as before.
|
||||||
|
if slot.track_index:
|
||||||
|
entry["track_index"] = slot.track_index
|
||||||
|
remote[digit] = entry
|
||||||
document["remote"] = remote
|
document["remote"] = remote
|
||||||
atomic_write(path, document)
|
atomic_write(path, document)
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ configured ceiling is a parent's business, not a child's, so it never crosses th
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Literal
|
from typing import Literal, Self
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field, model_validator
|
||||||
|
|
||||||
from musicmouse.config import Digit
|
from musicmouse.config import Digit
|
||||||
from musicmouse.library import Album
|
from musicmouse.library import Album
|
||||||
@@ -21,10 +21,11 @@ from musicmouse.tippen.rewards import AlbumLock, TrackLock, UnlockHint
|
|||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"AlbumOut",
|
"AlbumOut",
|
||||||
|
"BusOut",
|
||||||
|
"ClockStateOut",
|
||||||
"HaConfigOut",
|
"HaConfigOut",
|
||||||
"HaDeviceOut",
|
"HaDeviceOut",
|
||||||
"LibraryOut",
|
"LibraryOut",
|
||||||
"LircConfigOut",
|
|
||||||
"PlayIn",
|
"PlayIn",
|
||||||
"PlayerStateOut",
|
"PlayerStateOut",
|
||||||
"RemoteMappingIn",
|
"RemoteMappingIn",
|
||||||
@@ -34,6 +35,8 @@ __all__ = [
|
|||||||
"SeekIn",
|
"SeekIn",
|
||||||
"SettingsIn",
|
"SettingsIn",
|
||||||
"SettingsOut",
|
"SettingsOut",
|
||||||
|
"TimerIn",
|
||||||
|
"TimerOut",
|
||||||
"TippenCurriculumOut",
|
"TippenCurriculumOut",
|
||||||
"TippenGhostStrokeOut",
|
"TippenGhostStrokeOut",
|
||||||
"TippenKeyStatOut",
|
"TippenKeyStatOut",
|
||||||
@@ -120,6 +123,15 @@ class TrackOut(BaseModel):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _cover_version(album: Album) -> int:
|
||||||
|
if album.cover is None:
|
||||||
|
return 0
|
||||||
|
try:
|
||||||
|
return album.cover.stat().st_mtime_ns // 1_000_000
|
||||||
|
except OSError:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
class AlbumOut(BaseModel):
|
class AlbumOut(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
section: str
|
section: str
|
||||||
@@ -131,6 +143,9 @@ class AlbumOut(BaseModel):
|
|||||||
category: str
|
category: str
|
||||||
colors: list[str]
|
colors: list[str]
|
||||||
has_cover: bool
|
has_cover: bool
|
||||||
|
#: Cache-busting version of the cover (its mtime in ms; 0 with no cover). Goes in the
|
||||||
|
#: cover URL's `v` so the browser may cache the image forever.
|
||||||
|
cover_v: int = 0
|
||||||
duration: float
|
duration: float
|
||||||
tracks: list[TrackOut]
|
tracks: list[TrackOut]
|
||||||
#: Every track is still locked - the browse view shows a question mark instead of
|
#: Every track is still locked - the browse view shows a question mark instead of
|
||||||
@@ -151,6 +166,7 @@ class AlbumOut(BaseModel):
|
|||||||
category=album.category,
|
category=album.category,
|
||||||
colors=list(album.colors),
|
colors=list(album.colors),
|
||||||
has_cover=album.cover is not None,
|
has_cover=album.cover is not None,
|
||||||
|
cover_v=_cover_version(album),
|
||||||
duration=album.duration,
|
duration=album.duration,
|
||||||
locked=lock.locked if lock is not None else False,
|
locked=lock.locked if lock is not None else False,
|
||||||
tracks=[
|
tracks=[
|
||||||
@@ -189,6 +205,33 @@ class ConnectionOut(BaseModel):
|
|||||||
lirc: bool
|
lirc: bool
|
||||||
|
|
||||||
|
|
||||||
|
class BusOut(BaseModel):
|
||||||
|
#: "HH:MM", when the bus leaves.
|
||||||
|
time: str
|
||||||
|
#: How long before ``time`` its countdown shows.
|
||||||
|
lead_minutes: int
|
||||||
|
|
||||||
|
|
||||||
|
class TimerOut(BaseModel):
|
||||||
|
#: Wall-clock Unix seconds.
|
||||||
|
started_at: float
|
||||||
|
ends_at: float
|
||||||
|
ringing: bool
|
||||||
|
|
||||||
|
|
||||||
|
class ClockStateOut(BaseModel):
|
||||||
|
"""What the clock overlay shows besides the time."""
|
||||||
|
|
||||||
|
#: ``None`` when the holiday calendar could not be fetched; weekends are known
|
||||||
|
#: regardless.
|
||||||
|
school_day: bool | None
|
||||||
|
#: Why today is no school day: "Wochenende", "Herbstferien", ...
|
||||||
|
school_day_reason: str | None
|
||||||
|
#: Today's bus, or ``None`` when there is no school today.
|
||||||
|
bus: BusOut | None
|
||||||
|
parent_timer: TimerOut | None
|
||||||
|
|
||||||
|
|
||||||
class PlayerStateOut(BaseModel):
|
class PlayerStateOut(BaseModel):
|
||||||
playing: bool
|
playing: bool
|
||||||
album_id: str | None
|
album_id: str | None
|
||||||
@@ -204,6 +247,7 @@ class PlayerStateOut(BaseModel):
|
|||||||
volume: int
|
volume: int
|
||||||
active_figure: str | None
|
active_figure: str | None
|
||||||
connected: ConnectionOut
|
connected: ConnectionOut
|
||||||
|
clock: ClockStateOut
|
||||||
|
|
||||||
|
|
||||||
class PlayIn(BaseModel):
|
class PlayIn(BaseModel):
|
||||||
@@ -216,10 +260,28 @@ class SeekIn(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class VolumeIn(BaseModel):
|
class VolumeIn(BaseModel):
|
||||||
"""Either an absolute percentage or a relative step; exactly one of the two."""
|
"""An absolute percentage or a number of steps; exactly one of the two.
|
||||||
|
|
||||||
|
``steps`` moves by ``volume_increment`` device units per step - the same step the
|
||||||
|
IR remote's volume keys and the rotary encoder take - rather than by a percentage.
|
||||||
|
"""
|
||||||
|
|
||||||
percent: int | None = Field(default=None, ge=0, le=100)
|
percent: int | None = Field(default=None, ge=0, le=100)
|
||||||
delta_percent: int | None = Field(default=None, ge=-100, le=100)
|
steps: int | None = Field(default=None, ge=-10, le=10)
|
||||||
|
|
||||||
|
|
||||||
|
class TimerIn(BaseModel):
|
||||||
|
"""Minutes from now, or a time of day ("HH:MM", tomorrow if already past); exactly
|
||||||
|
one of the two."""
|
||||||
|
|
||||||
|
minutes: int | None = Field(default=None, ge=1, le=12 * 60)
|
||||||
|
until: str | None = Field(default=None, pattern=r"^([01][0-9]|2[0-3]):[0-5][0-9]$")
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _exactly_one(self) -> Self:
|
||||||
|
if (self.minutes is None) == (self.until is None):
|
||||||
|
raise ValueError("give exactly one of minutes and until")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
class SettingsOut(BaseModel):
|
class SettingsOut(BaseModel):
|
||||||
@@ -228,6 +290,10 @@ class SettingsOut(BaseModel):
|
|||||||
initial_volume: int
|
initial_volume: int
|
||||||
volume_increment: int
|
volume_increment: int
|
||||||
button_leds_brightness: float
|
button_leds_brightness: float
|
||||||
|
effects_volume: int
|
||||||
|
#: ``general.screen.auto_off_minutes``, or ``None`` when there is no screen to
|
||||||
|
#: switch off (no ``screen`` section, or one without an ``off_command``).
|
||||||
|
screen_auto_off_minutes: int | None
|
||||||
|
|
||||||
|
|
||||||
class SettingsIn(BaseModel):
|
class SettingsIn(BaseModel):
|
||||||
@@ -236,6 +302,10 @@ class SettingsIn(BaseModel):
|
|||||||
initial_volume: int = Field(ge=0, le=200)
|
initial_volume: int = Field(ge=0, le=200)
|
||||||
volume_increment: int = Field(ge=1, le=100)
|
volume_increment: int = Field(ge=1, le=100)
|
||||||
button_leds_brightness: float = Field(ge=0, le=1)
|
button_leds_brightness: float = Field(ge=0, le=1)
|
||||||
|
#: UI sounds relative to the music, in percent.
|
||||||
|
effects_volume: int = Field(ge=0, le=100)
|
||||||
|
#: Ignored without a screen section. ``None`` leaves it as it is.
|
||||||
|
screen_auto_off_minutes: int | None = Field(default=None, ge=0, le=720)
|
||||||
|
|
||||||
|
|
||||||
class HaDeviceOut(BaseModel):
|
class HaDeviceOut(BaseModel):
|
||||||
@@ -252,13 +322,6 @@ class HaConfigOut(BaseModel):
|
|||||||
scenes: list[HaDeviceOut]
|
scenes: list[HaDeviceOut]
|
||||||
|
|
||||||
|
|
||||||
class LircConfigOut(BaseModel):
|
|
||||||
"""Presence-only, like ``HaConfigOut``: there is nothing secret in a host/port,
|
|
||||||
but the frontend only needs to know whether the remote is set up and connected."""
|
|
||||||
|
|
||||||
connected: bool
|
|
||||||
|
|
||||||
|
|
||||||
class RemoteSlotOut(BaseModel):
|
class RemoteSlotOut(BaseModel):
|
||||||
digit: str
|
digit: str
|
||||||
target_kind: Literal["album", "series"]
|
target_kind: Literal["album", "series"]
|
||||||
@@ -268,6 +331,8 @@ class RemoteSlotOut(BaseModel):
|
|||||||
#: resolves (a moved/deleted album, an unknown show), so the frontend can show a
|
#: resolves (a moved/deleted album, an unknown show), so the frontend can show a
|
||||||
#: broken-assignment state instead of silently dropping it.
|
#: broken-assignment state instead of silently dropping it.
|
||||||
resolved_album_id: str | None
|
resolved_album_id: str | None
|
||||||
|
#: "album" slots only: the song the key starts at; 0 for the whole album.
|
||||||
|
track_index: int = 0
|
||||||
|
|
||||||
|
|
||||||
class RemoteMappingOut(BaseModel):
|
class RemoteMappingOut(BaseModel):
|
||||||
@@ -277,6 +342,7 @@ class RemoteMappingOut(BaseModel):
|
|||||||
class RemoteSlotIn(BaseModel):
|
class RemoteSlotIn(BaseModel):
|
||||||
target_kind: Literal["album", "series"]
|
target_kind: Literal["album", "series"]
|
||||||
target: str
|
target: str
|
||||||
|
track_index: int = Field(default=0, ge=0)
|
||||||
|
|
||||||
|
|
||||||
class RemoteMappingIn(BaseModel):
|
class RemoteMappingIn(BaseModel):
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import httpx2
|
|||||||
import uvicorn
|
import uvicorn
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from starlette.responses import Response
|
||||||
|
|
||||||
from musicmouse.app import App
|
from musicmouse.app import App
|
||||||
from musicmouse.config import WebConfig
|
from musicmouse.config import WebConfig
|
||||||
@@ -28,6 +29,18 @@ _log = logging.getLogger(__name__)
|
|||||||
__all__ = ["WebService", "build_app"]
|
__all__ = ["WebService", "build_app"]
|
||||||
|
|
||||||
|
|
||||||
|
class _WebFiles(StaticFiles):
|
||||||
|
"""Vite writes ``assets/<name>-<content hash>.<ext>``: a URL there names one exact
|
||||||
|
set of bytes, so the browser may keep it forever and never revalidate. Everything
|
||||||
|
else (``index.html``, ``sw.js``) has a stable name and keeps the default behaviour."""
|
||||||
|
|
||||||
|
def file_response(self, full_path, stat_result, scope, status_code=200) -> Response: # type: ignore[no-untyped-def]
|
||||||
|
response = super().file_response(full_path, stat_result, scope, status_code)
|
||||||
|
if scope.get("path", "").startswith("/assets/"):
|
||||||
|
response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
def build_app(
|
def build_app(
|
||||||
app: App,
|
app: App,
|
||||||
config: WebConfig,
|
config: WebConfig,
|
||||||
@@ -50,7 +63,7 @@ def build_app(
|
|||||||
if config.static_dir.is_dir():
|
if config.static_dir.is_dir():
|
||||||
# Mounted last and at the root so every /api route still wins; html=True
|
# Mounted last and at the root so every /api route still wins; html=True
|
||||||
# falls back to index.html, which is what a client-side router needs.
|
# falls back to index.html, which is what a client-side router needs.
|
||||||
api.mount("/", StaticFiles(directory=config.static_dir, html=True), name="web")
|
api.mount("/", _WebFiles(directory=config.static_dir, html=True), name="web")
|
||||||
else:
|
else:
|
||||||
_log.warning(
|
_log.warning(
|
||||||
"web.static_dir %s does not exist; serving the API only "
|
"web.static_dir %s does not exist; serving the API only "
|
||||||
|
|||||||
@@ -48,12 +48,17 @@ def to_percent(volume: int, general: GeneralConfig) -> int:
|
|||||||
|
|
||||||
|
|
||||||
def read_settings(general: GeneralConfig) -> SettingsOut:
|
def read_settings(general: GeneralConfig) -> SettingsOut:
|
||||||
|
screen = general.screen
|
||||||
return SettingsOut(
|
return SettingsOut(
|
||||||
min_volume=general.min_volume,
|
min_volume=general.min_volume,
|
||||||
max_volume=general.max_volume,
|
max_volume=general.max_volume,
|
||||||
initial_volume=general.initial_volume,
|
initial_volume=general.initial_volume,
|
||||||
volume_increment=general.volume_increment,
|
volume_increment=general.volume_increment,
|
||||||
button_leds_brightness=general.button_leds_brightness,
|
button_leds_brightness=general.button_leds_brightness,
|
||||||
|
effects_volume=general.effects_volume,
|
||||||
|
screen_auto_off_minutes=(
|
||||||
|
screen.auto_off_minutes if screen is not None and screen.off_command else None
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -91,6 +96,11 @@ def write_settings(path: Path, settings: SettingsIn) -> None:
|
|||||||
"""Patch the settings into ``config.yml`` in place."""
|
"""Patch the settings into ``config.yml`` in place."""
|
||||||
document = load_document(path)
|
document = load_document(path)
|
||||||
general = document["general"]
|
general = document["general"]
|
||||||
for key, value in settings.model_dump().items():
|
values = settings.model_dump()
|
||||||
|
# The one setting that lives in a subsection rather than flat under `general`.
|
||||||
|
auto_off = values.pop("screen_auto_off_minutes")
|
||||||
|
for key, value in values.items():
|
||||||
general[key] = value
|
general[key] = value
|
||||||
|
if auto_off is not None and general.get("screen") is not None:
|
||||||
|
general["screen"]["auto_off_minutes"] = auto_off
|
||||||
atomic_write(path, document)
|
atomic_write(path, document)
|
||||||
|
|||||||
@@ -7,8 +7,16 @@ read ``mouse.active_figure``, do not keep a second copy of it that can drift.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
from musicmouse.app import App
|
from musicmouse.app import App
|
||||||
from musicmouse.services.web.schemas import ConnectionOut, PlayerStateOut
|
from musicmouse.services.web.schemas import (
|
||||||
|
BusOut,
|
||||||
|
ClockStateOut,
|
||||||
|
ConnectionOut,
|
||||||
|
PlayerStateOut,
|
||||||
|
TimerOut,
|
||||||
|
)
|
||||||
from musicmouse.services.web.settings import to_percent
|
from musicmouse.services.web.settings import to_percent
|
||||||
|
|
||||||
__all__ = ["snapshot"]
|
__all__ = ["snapshot"]
|
||||||
@@ -45,4 +53,27 @@ def snapshot(app: App) -> PlayerStateOut:
|
|||||||
mqtt=app.state.mqtt_connected,
|
mqtt=app.state.mqtt_connected,
|
||||||
lirc=app.state.lirc_connected,
|
lirc=app.state.lirc_connected,
|
||||||
),
|
),
|
||||||
|
clock=clock_state(app),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def clock_state(app: App, today: date | None = None) -> ClockStateOut:
|
||||||
|
state = app.state
|
||||||
|
config = app.config.general.clock
|
||||||
|
# Without a holiday calendar a weekday counts as a school day: a bus countdown on
|
||||||
|
# a holiday is a smaller surprise than none on the first day back.
|
||||||
|
today = today or date.today()
|
||||||
|
school = state.school_day if state.school_day is not None else today.weekday() < 5
|
||||||
|
timer = state.parent_timer
|
||||||
|
return ClockStateOut(
|
||||||
|
school_day=state.school_day,
|
||||||
|
school_day_reason=state.school_day_reason,
|
||||||
|
bus=BusOut(time=config.bus_time, lead_minutes=config.bus_lead_minutes)
|
||||||
|
if school
|
||||||
|
else None,
|
||||||
|
parent_timer=TimerOut(
|
||||||
|
started_at=timer.started_at, ends_at=timer.ends_at, ringing=timer.ringing
|
||||||
|
)
|
||||||
|
if timer
|
||||||
|
else None,
|
||||||
)
|
)
|
||||||
|
|||||||
73
python-backend/musicmouse/sound.py
Normal file
73
python-backend/musicmouse/sound.py
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
"""Playing a short synthesised sound on the music's own ALSA device.
|
||||||
|
|
||||||
|
``aplay`` in a subprocess, fed the WAV on stdin: no audio library to load, and a hung
|
||||||
|
or missing ``aplay`` costs a log line, never the event loop.
|
||||||
|
|
||||||
|
Every UI sound - the screen's blubb, the parents' timer, the album picker's clicks -
|
||||||
|
is as loud as :func:`ui_level` says, times its own base gain.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
|
||||||
|
_log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
__all__ = ["MIN_PLAYER_VOLUME", "play_wav", "play_wav_soon", "ui_level"]
|
||||||
|
|
||||||
|
#: UI sounds are never quieter than at this player volume (0..1): a muted player must
|
||||||
|
#: not mute the timer, nor leave turning the album picker silent.
|
||||||
|
MIN_PLAYER_VOLUME = 0.3
|
||||||
|
|
||||||
|
#: Sounds started with :func:`play_wav_soon`, kept so they are not collected mid-play.
|
||||||
|
_playing: set[asyncio.Task[None]] = set()
|
||||||
|
|
||||||
|
|
||||||
|
def ui_level(player_volume: float, effects_volume: float) -> float:
|
||||||
|
"""How loud a UI sound plays, 0..1, before its own base gain.
|
||||||
|
|
||||||
|
``player_volume`` is the music's (0..1), floored at :data:`MIN_PLAYER_VOLUME`;
|
||||||
|
``effects_volume`` is the parent-mode setting (0..1) for sounds relative to music.
|
||||||
|
"""
|
||||||
|
player = max(MIN_PLAYER_VOLUME, min(1.0, player_volume))
|
||||||
|
return player * max(0.0, min(1.0, effects_volume))
|
||||||
|
|
||||||
|
|
||||||
|
def play_wav_soon(alsa_device: str, wav: bytes) -> None:
|
||||||
|
"""Start playing ``wav`` without waiting for it - for callers on the event bus,
|
||||||
|
which must not stall every other event for the length of a sound."""
|
||||||
|
task = asyncio.create_task(play_wav(alsa_device, wav), name="ui-sound")
|
||||||
|
_playing.add(task)
|
||||||
|
task.add_done_callback(_playing.discard)
|
||||||
|
|
||||||
|
|
||||||
|
async def play_wav(alsa_device: str, wav: bytes, *, timeout: float = 10.0) -> None:
|
||||||
|
"""Play ``wav`` to the end. Failures are logged, not raised."""
|
||||||
|
try:
|
||||||
|
process = await asyncio.create_subprocess_exec(
|
||||||
|
"aplay",
|
||||||
|
"-q",
|
||||||
|
"-D",
|
||||||
|
alsa_device,
|
||||||
|
"-",
|
||||||
|
stdin=asyncio.subprocess.PIPE,
|
||||||
|
stdout=asyncio.subprocess.DEVNULL,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
)
|
||||||
|
except OSError as exc:
|
||||||
|
_log.warning("Could not play a sound: %s", exc)
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
_, error = await asyncio.wait_for(process.communicate(wav), timeout)
|
||||||
|
except TimeoutError:
|
||||||
|
process.kill()
|
||||||
|
await process.wait()
|
||||||
|
_log.warning("A sound took longer than %gs; killed", timeout)
|
||||||
|
return
|
||||||
|
if process.returncode != 0:
|
||||||
|
_log.warning(
|
||||||
|
"Playing a sound failed (exit %d): %s",
|
||||||
|
process.returncode,
|
||||||
|
error.decode(errors="replace").strip(),
|
||||||
|
)
|
||||||
@@ -5,8 +5,8 @@ Same convention as everywhere else this codebase persists something outside
|
|||||||
missing or corrupt file is just a fresh start, never a crash: nothing here is precious
|
missing or corrupt file is just a fresh start, never a crash: nothing here is precious
|
||||||
enough to raise over.
|
enough to raise over.
|
||||||
|
|
||||||
``record_run`` is a near-verbatim port of tippen's own (client-side, until now)
|
``record_run`` is a port of the ``recordRun`` the standalone typing app had client-side
|
||||||
``recordRun`` in ``tippen/src/lib/progress.ts`` - grading a keystroke-by-keystroke run
|
before it was merged into the web front-end - grading a keystroke-by-keystroke run
|
||||||
into stars/points/an animal stays entirely client-side (see ``TippenRunIn`` in
|
into stars/points/an animal stays entirely client-side (see ``TippenRunIn`` in
|
||||||
``musicmouse.services.web.schemas``); this only owns what happens to progress once a
|
``musicmouse.services.web.schemas``); this only owns what happens to progress once a
|
||||||
graded result arrives.
|
graded result arrives.
|
||||||
|
|||||||
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,14 +1,10 @@
|
|||||||
# Notebooks
|
# Notebooks
|
||||||
|
|
||||||
Exploratory material, **not part of the running backend** and not imported by it.
|
Scratch work, **not part of the running backend** and not imported by it.
|
||||||
|
|
||||||
`audio_analysis.py` and the three `C5S*` notebooks are chroma/chord-recognition course
|
`effect_debug.ipynb` is for tinkering with LED effects. It needs `numpy` and `jupyter`,
|
||||||
work (they still reference stale absolute paths). `effect_debug.ipynb` is scratch work
|
which are deliberately not in the backend's dependencies:
|
||||||
for tinkering with LED effects.
|
|
||||||
|
|
||||||
They need `librosa`, `numba` and `numpy`, which are deliberately not in the backend's
|
|
||||||
dependencies:
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
pip install librosa numba numpy jupyter
|
pip install numpy jupyter
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,128 +0,0 @@
|
|||||||
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)
|
|
||||||
@@ -55,7 +55,7 @@ filterwarnings = ["error"]
|
|||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
line-length = 100
|
line-length = 100
|
||||||
target-version = "py311"
|
target-version = "py311"
|
||||||
# Course material and scratch work, not part of the backend. See notebooks/README.md.
|
# Scratch work, not part of the backend. See notebooks/README.md.
|
||||||
extend-exclude = ["notebooks"]
|
extend-exclude = ["notebooks"]
|
||||||
|
|
||||||
[tool.ruff.lint]
|
[tool.ruff.lint]
|
||||||
@@ -77,3 +77,12 @@ warn_unreachable = true
|
|||||||
# checkout must still type-check clean, so it needs the same treatment as librosa.
|
# checkout must still type-check clean, so it needs the same treatment as librosa.
|
||||||
module = ["vlc", "serial_asyncio", "ruamel.*", "librosa.*", "numpy", "feedparser"]
|
module = ["vlc", "serial_asyncio", "ruamel.*", "librosa.*", "numpy", "feedparser"]
|
||||||
ignore_missing_imports = true
|
ignore_missing_imports = true
|
||||||
|
|
||||||
|
[[tool.mypy.overrides]]
|
||||||
|
# Recent numpy ships stubs written in 3.12+ syntax (`type X = ...`). Checking against
|
||||||
|
# the device's 3.11 makes mypy refuse to parse them and abort before checking anything
|
||||||
|
# at all - which is what happened in a dev venv that has the analysis extra. Treat the
|
||||||
|
# analysis stack as untyped instead: it is only touched by librosa_analyzer.py.
|
||||||
|
module = ["numpy", "numpy.*", "librosa", "librosa.*"]
|
||||||
|
follow_imports = "skip"
|
||||||
|
follow_imports_for_stubs = true
|
||||||
|
|||||||
@@ -96,6 +96,32 @@ def test_remote_mapping_rejects_an_unknown_target_kind(config_dir: Path) -> None
|
|||||||
assert "remote" in message
|
assert "remote" in message
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_remote_slot_can_name_a_song(config_dir: Path) -> None:
|
||||||
|
data = copy.deepcopy(VALID_CONFIG)
|
||||||
|
data["remote"] = {
|
||||||
|
"1": {"target_kind": "album", "target": "abc123", "track_index": 4},
|
||||||
|
"2": {"target_kind": "album", "target": "abc123"},
|
||||||
|
}
|
||||||
|
config = load_config(write_config(config_dir, data))
|
||||||
|
assert config.remote["1"].track_index == 4
|
||||||
|
assert config.remote["2"].track_index == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_series_slot_cannot_name_a_song(config_dir: Path) -> None:
|
||||||
|
data = copy.deepcopy(VALID_CONFIG)
|
||||||
|
data["remote"] = {"7": {"target_kind": "series", "target": "Wissen", "track_index": 1}}
|
||||||
|
message = _error(config_dir, data)
|
||||||
|
assert "track_index" in message
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_screen_switches_off_after_15_minutes_by_default(config_dir: Path) -> None:
|
||||||
|
data = _config(screen={"toggle_command": "kiosk-screen toggle"})
|
||||||
|
screen = load_config(write_config(config_dir, data)).general.screen
|
||||||
|
assert screen is not None
|
||||||
|
assert screen.auto_off_minutes == 15
|
||||||
|
assert screen.off_command is None # ...but only once there is a way to do it
|
||||||
|
|
||||||
|
|
||||||
def test_ha_device_and_scene_name_is_optional(config_dir: Path) -> None:
|
def test_ha_device_and_scene_name_is_optional(config_dir: Path) -> None:
|
||||||
data = _config(
|
data = _config(
|
||||||
ha={
|
ha={
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from musicmouse.effects import (
|
|||||||
EffectCircularConfig,
|
EffectCircularConfig,
|
||||||
EffectRandomTwoColorInterpolationConfig,
|
EffectRandomTwoColorInterpolationConfig,
|
||||||
EffectReverseSwipe,
|
EffectReverseSwipe,
|
||||||
|
EffectSegmentSelect,
|
||||||
EffectStaticConfig,
|
EffectStaticConfig,
|
||||||
EffectStaticDetailedConfig,
|
EffectStaticDetailedConfig,
|
||||||
EffectSwipeAndChange,
|
EffectSwipeAndChange,
|
||||||
@@ -135,7 +136,54 @@ def test_swipe_and_change_is_the_two_payloads_concatenated() -> None:
|
|||||||
(EffectRandomTwoColorInterpolationConfig(), 35),
|
(EffectRandomTwoColorInterpolationConfig(), 35),
|
||||||
(EffectReverseSwipe(), 12),
|
(EffectReverseSwipe(), 12),
|
||||||
(EffectSwipeAndChange(), 64),
|
(EffectSwipeAndChange(), 64),
|
||||||
|
(EffectSegmentSelect(colors=(ColorRGBW(0, 0, 0, 0),)), 53),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_payload_sizes(effect: LedEffect, size: int) -> None:
|
def test_payload_sizes(effect: LedEffect, size: int) -> None:
|
||||||
assert len(effect.as_bytes()) == size
|
assert len(effect.as_bytes()) == size
|
||||||
|
|
||||||
|
|
||||||
|
def test_segment_select() -> None:
|
||||||
|
effect = EffectSegmentSelect(
|
||||||
|
colors=(ColorRGBW(1, 0, 0, 0), ColorRGBW(0, 0, 1, 0)),
|
||||||
|
selected=1,
|
||||||
|
previous=0,
|
||||||
|
gap_leds=2,
|
||||||
|
offset_degrees=180.0,
|
||||||
|
direction=-1,
|
||||||
|
dim_factor=0.5,
|
||||||
|
wobble_amplitude=1.5,
|
||||||
|
wobble_period_ms=1000.0,
|
||||||
|
slide_ms=200.0,
|
||||||
|
edge_blend=0.25,
|
||||||
|
)
|
||||||
|
assert effect.as_bytes().hex() == (
|
||||||
|
"02" "01" "00" "02" # count, selected, previous, gap
|
||||||
|
"ff000000" "0000ff00" + "00000000" * 4 # always six colours on the wire
|
||||||
|
+ "00003443" # offset 180.0
|
||||||
|
"ff" # direction -1
|
||||||
|
"0000003f" # dim 0.5
|
||||||
|
"0000c03f" # wobble 1.5
|
||||||
|
"00007a44" # period 1000.0
|
||||||
|
"00004843" # slide 200.0
|
||||||
|
"0000803e" # edge blend 0.25
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_segment_select_without_previous_does_not_glide() -> None:
|
||||||
|
effect = EffectSegmentSelect(colors=(ColorRGBW(1, 0, 0, 0),) * 3, selected=2)
|
||||||
|
assert effect.as_bytes()[:3] == bytes([3, 2, 2])
|
||||||
|
|
||||||
|
|
||||||
|
def test_segment_select_round_trips() -> None:
|
||||||
|
# Every float exact in float32, as the firmware has them.
|
||||||
|
effect = EffectSegmentSelect(
|
||||||
|
colors=(ColorRGBW(1, 0, 0, 0), ColorRGBW(0, 1, 0, 0)), previous=1, dim_factor=0.25
|
||||||
|
)
|
||||||
|
assert EffectSegmentSelect.from_bytes(effect.as_bytes()) == effect
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("count", [0, 7])
|
||||||
|
def test_segment_select_takes_one_to_six_sections(count: int) -> None:
|
||||||
|
with pytest.raises(ValueError, match="between 1 and 6"):
|
||||||
|
EffectSegmentSelect(colors=(ColorRGBW(1, 0, 0, 0),) * count)
|
||||||
|
|||||||
@@ -15,7 +15,14 @@ from pathlib import Path
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from musicmouse.config import DEFAULT_AUDIO_EXTENSIONS, load_config
|
from musicmouse.config import DEFAULT_AUDIO_EXTENSIONS, load_config
|
||||||
from musicmouse.library import Album, Analyzer, MusicLibrary, default_worker_count
|
from musicmouse.effects import SEGMENT_SELECT_MAX_SEGMENTS
|
||||||
|
from musicmouse.library import (
|
||||||
|
MAX_FIGURE_ALBUMS,
|
||||||
|
Album,
|
||||||
|
Analyzer,
|
||||||
|
MusicLibrary,
|
||||||
|
default_worker_count,
|
||||||
|
)
|
||||||
from musicmouse.library.analysis import ANALYZER_VERSION, BeatGrid, TrackAnalysis, TrackCurves
|
from musicmouse.library.analysis import ANALYZER_VERSION, BeatGrid, TrackAnalysis, TrackCurves
|
||||||
from musicmouse.library.cache import LibraryCache
|
from musicmouse.library.cache import LibraryCache
|
||||||
from musicmouse.library.colors import colors_from_id
|
from musicmouse.library.colors import colors_from_id
|
||||||
@@ -92,15 +99,72 @@ async def test_figure_playlists_are_keyed_by_figure_name(config_dir: Path) -> No
|
|||||||
playlists = library.figure_playlists()
|
playlists = library.figure_playlists()
|
||||||
|
|
||||||
assert set(playlists) == {"fuchs", "eule"}
|
assert set(playlists) == {"fuchs", "eule"}
|
||||||
assert len(playlists["fuchs"]) == 3
|
(fuchs,) = playlists["fuchs"]
|
||||||
assert [track.path.name for track in playlists["fuchs"].tracks] == [
|
assert len(fuchs) == 3
|
||||||
|
assert [track.path.name for track in fuchs.tracks] == [
|
||||||
"00 - track.mp3",
|
"00 - track.mp3",
|
||||||
"01 - track.mp3",
|
"01 - track.mp3",
|
||||||
"02 - track.mp3",
|
"02 - track.mp3",
|
||||||
]
|
]
|
||||||
# The playlist carries the album it came from, which is how a front-end answers
|
# The playlist carries the album it came from, which is how a front-end answers
|
||||||
# "what is playing?" without keeping its own copy of that state.
|
# "what is playing?" without keeping its own copy of that state.
|
||||||
assert playlists["fuchs"].album_id == album_named(library, "Fuchs").id
|
assert fuchs.album_id == album_named(library, "Fuchs").id
|
||||||
|
|
||||||
|
|
||||||
|
def _figure_with_albums(config_dir: Path, figure: str, *folders: str) -> None:
|
||||||
|
"""Replace a figure's audio with one album per folder (relative to the figure)."""
|
||||||
|
figure_folder = config_dir / "music" / "Figuren" / figure
|
||||||
|
for track in figure_folder.glob("*.mp3"):
|
||||||
|
track.unlink()
|
||||||
|
for folder in folders:
|
||||||
|
write_track(figure_folder / folder / "01 - track.mp3", title=f"In {folder}")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_figure_with_album_subfolders_offers_each_in_folder_order(
|
||||||
|
config_dir: Path,
|
||||||
|
) -> None:
|
||||||
|
# Depth-first and alphabetical, however deep an album sits.
|
||||||
|
_figure_with_albums(
|
||||||
|
config_dir, "fuchs", "B Lieder", "A Geschichten/Teil 2", "A Geschichten/Teil 1"
|
||||||
|
)
|
||||||
|
library = await build(config_dir)
|
||||||
|
|
||||||
|
choices = library.figure_playlists()["fuchs"]
|
||||||
|
|
||||||
|
albums = [library.get(playlist.album_id) for playlist in choices]
|
||||||
|
assert [album.title if album else None for album in albums] == [
|
||||||
|
"Fuchs - Teil 1",
|
||||||
|
"Fuchs - Teil 2",
|
||||||
|
"Fuchs - B Lieder",
|
||||||
|
]
|
||||||
|
assert all(album is not None and album.figure == "fuchs" for album in albums)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_figure_album_subfolder_takes_the_figures_kind(config_dir: Path) -> None:
|
||||||
|
_figure_with_albums(config_dir, "eule", "Folge 1", "Folge 2")
|
||||||
|
library = await build(config_dir)
|
||||||
|
|
||||||
|
albums = [library.get(p.album_id) for p in library.figure_playlists()["eule"]]
|
||||||
|
assert len(albums) == 2
|
||||||
|
assert {album.kind if album else None for album in albums} == {"book"}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_figure_offers_no_more_albums_than_the_ring_has_room_for(
|
||||||
|
config_dir: Path, caplog: pytest.LogCaptureFixture
|
||||||
|
) -> None:
|
||||||
|
_figure_with_albums(config_dir, "fuchs", *(f"Album {n}" for n in range(MAX_FIGURE_ALBUMS + 2)))
|
||||||
|
library = await build(config_dir)
|
||||||
|
|
||||||
|
choices = library.figure_playlists()["fuchs"]
|
||||||
|
|
||||||
|
assert len(choices) == MAX_FIGURE_ALBUMS
|
||||||
|
assert "only the first 6" in caplog.text
|
||||||
|
# Browsing still shows every one of them.
|
||||||
|
assert sum(album.figure == "fuchs" for album in library.albums) == MAX_FIGURE_ALBUMS + 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_album_cap_is_the_rings_section_count() -> None:
|
||||||
|
assert MAX_FIGURE_ALBUMS == SEGMENT_SELECT_MAX_SEGMENTS
|
||||||
|
|
||||||
|
|
||||||
async def test_music_takes_its_title_and_artist_from_the_tags(config_dir: Path) -> None:
|
async def test_music_takes_its_title_and_artist_from_the_tags(config_dir: Path) -> None:
|
||||||
@@ -284,6 +348,25 @@ async def test_an_episodes_sidecar_cover_wins_over_the_shows_shared_one(config_d
|
|||||||
assert red < 60 and blue > red, "falls back to the show's shared cover"
|
assert red < 60 and blue > red, "falls back to the show's shared cover"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_sidecar_cover_added_later_replaces_the_cached_one(config_dir: Path) -> None:
|
||||||
|
"""`podcast_feeds.py` backfills covers for episodes already on disk - the episode's
|
||||||
|
audio is untouched, so only the sidecar can tell the cache its entry is stale."""
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
podcast = config_dir / "music" / "Kinderpodcasts" / "Wissen macht Ah"
|
||||||
|
Image.new("RGB", (32, 32), (10, 20, 30)).save(podcast / "cover.jpg")
|
||||||
|
first = album_named(await build(config_dir), "Alt")
|
||||||
|
|
||||||
|
alt_audio = first.tracks[0].path
|
||||||
|
Image.new("RGB", (32, 32), (200, 40, 30)).save(alt_audio.with_suffix(".jpg"))
|
||||||
|
second = album_named(await build(config_dir), "Alt")
|
||||||
|
|
||||||
|
assert second.cover is not None
|
||||||
|
with Image.open(second.cover) as image:
|
||||||
|
red, green, _blue = image.convert("RGB").getpixel((0, 0)) # type: ignore[misc]
|
||||||
|
assert red > 150 and green < 90, "the new sidecar should replace the shared cover"
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- cache
|
# --------------------------------------------------------------------------- cache
|
||||||
|
|
||||||
|
|
||||||
@@ -820,24 +903,16 @@ class TestCoverDownscaling:
|
|||||||
# The untouched original still comes back, because colour extraction wants it.
|
# The untouched original still comes back, because colour extraction wants it.
|
||||||
assert art == (folder / "cover.jpg").read_bytes()
|
assert art == (folder / "cover.jpg").read_bytes()
|
||||||
|
|
||||||
def test_covers_written_by_an_older_version_are_migrated(self, tmp_path: Path) -> None:
|
def test_store_cover_also_writes_a_smaller_baseline_thumbnail(self, tmp_path: Path) -> None:
|
||||||
"""The scanner reuses an unchanged album without re-reading its tags, so a
|
|
||||||
cover stored before the limit existed would otherwise never be rewritten."""
|
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
from musicmouse.library.cache import MAX_COVER_PX, LibraryCache
|
from musicmouse.library.cache import THUMB_COVER_PX, LibraryCache
|
||||||
|
|
||||||
cache = LibraryCache(tmp_path)
|
cache = LibraryCache(tmp_path)
|
||||||
cache.prepare()
|
cache.prepare()
|
||||||
stale = cache.cover_path("old")
|
cache.store_cover("abc123", self._jpeg((2400, 1800)))
|
||||||
stale.write_bytes(self._jpeg((3000, 3000)))
|
with Image.open(cache.thumb_path("abc123")) as thumb:
|
||||||
fresh = cache.cover_path("new")
|
assert max(thumb.size) == THUMB_COVER_PX
|
||||||
fresh.write_bytes(self._jpeg((320, 320)))
|
assert thumb.format == "JPEG"
|
||||||
fresh_before = fresh.read_bytes()
|
assert "progressive" not in thumb.info
|
||||||
|
assert abs(thumb.width / thumb.height - 2400 / 1800) < 0.02
|
||||||
assert cache.shrink_stored_covers() == 1
|
|
||||||
with Image.open(stale) as image:
|
|
||||||
assert max(image.size) == MAX_COVER_PX
|
|
||||||
assert fresh.read_bytes() == fresh_before
|
|
||||||
# Second pass has nothing left to do - the cheap steady state.
|
|
||||||
assert cache.shrink_stored_covers() == 0
|
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ from musicmouse.events import (
|
|||||||
PlayRequested,
|
PlayRequested,
|
||||||
PlaySeriesLatestRequested,
|
PlaySeriesLatestRequested,
|
||||||
PreviousTrackRequested,
|
PreviousTrackRequested,
|
||||||
|
ToggleClockRequested,
|
||||||
|
ToggleScreenRequested,
|
||||||
VolumeChangeRequested,
|
VolumeChangeRequested,
|
||||||
)
|
)
|
||||||
from musicmouse.services.lirc import LircService
|
from musicmouse.services.lirc import LircService
|
||||||
@@ -60,6 +62,13 @@ def test_surrounding_whitespace_is_ignored() -> None:
|
|||||||
assert parse_line(" 0000000000001781 00 BTN_1 Hauppauge \n") is not None
|
assert parse_line(" 0000000000001781 00 BTN_1 Hauppauge \n") is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_irservers_lowercase_button_names_are_uppercased() -> None:
|
||||||
|
# Verbatim from irserver's lircd-compatible port on musicdolphin.
|
||||||
|
event = parse_line("b7b2000000000000 00 key_play hauppauge\n")
|
||||||
|
assert event is not None
|
||||||
|
assert event.button == "KEY_PLAY"
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- service
|
# --------------------------------------------------------------------------- service
|
||||||
|
|
||||||
|
|
||||||
@@ -195,6 +204,26 @@ async def test_events_from_another_remote_are_ignored(
|
|||||||
assert not any(isinstance(event, IntentEvent) for event in seen)
|
assert not any(isinstance(event, IntentEvent) for event in seen)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_irservers_lowercase_lines_are_acted_on(
|
||||||
|
lircd: tuple[ScriptedLircd, asyncio.base_events.Server], sim: Simulation
|
||||||
|
) -> None:
|
||||||
|
station, server = lircd
|
||||||
|
port = server.sockets[0].getsockname()[1]
|
||||||
|
seen: list[Event] = []
|
||||||
|
sim.bus.subscribe_all(seen.append)
|
||||||
|
|
||||||
|
# The config still says "Hauppauge" (the default); irserver says "hauppauge".
|
||||||
|
task = await _run_service(sim, _lirc_config(port))
|
||||||
|
try:
|
||||||
|
await station.wait_connected()
|
||||||
|
await station.send("b7b2000000000000 00 key_play hauppauge")
|
||||||
|
await sim.bus.drain()
|
||||||
|
finally:
|
||||||
|
task.cancel()
|
||||||
|
|
||||||
|
assert [type(event) for event in seen if isinstance(event, IntentEvent)] == [PlayRequested]
|
||||||
|
|
||||||
|
|
||||||
async def test_transport_buttons_only_act_on_the_first_press(
|
async def test_transport_buttons_only_act_on_the_first_press(
|
||||||
lircd: tuple[ScriptedLircd, asyncio.base_events.Server], sim: Simulation
|
lircd: tuple[ScriptedLircd, asyncio.base_events.Server], sim: Simulation
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -238,6 +267,76 @@ async def test_volume_buttons_act_on_every_repeat(
|
|||||||
assert all(event.delta < 0 for event in seen)
|
assert all(event.delta < 0 for event in seen)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_held_button_from_irserver_acts_once(
|
||||||
|
lircd: tuple[ScriptedLircd, asyncio.base_events.Server], sim: Simulation
|
||||||
|
) -> None:
|
||||||
|
# irserver sends every line of a held button as repeat 00.
|
||||||
|
station, server = lircd
|
||||||
|
port = server.sockets[0].getsockname()[1]
|
||||||
|
seen: list[Event] = []
|
||||||
|
sim.bus.subscribe(NextTrackRequested, seen.append)
|
||||||
|
sim.bus.subscribe(VolumeChangeRequested, seen.append)
|
||||||
|
assert isinstance(sim.clock, FakeClock)
|
||||||
|
|
||||||
|
task = await _run_service(sim, _lirc_config(port))
|
||||||
|
try:
|
||||||
|
await station.wait_connected()
|
||||||
|
for _ in range(3):
|
||||||
|
await station.send("b7b0000000000000 00 key_forward hauppauge")
|
||||||
|
await sim.clock.advance(0.11)
|
||||||
|
await sim.clock.advance(1.0)
|
||||||
|
await station.send("b7b0000000000000 00 key_forward hauppauge")
|
||||||
|
# Volume still ramps for as long as it is held.
|
||||||
|
for _ in range(3):
|
||||||
|
await station.send("b720000000000000 00 key_volumeup hauppauge")
|
||||||
|
await sim.clock.advance(0.11)
|
||||||
|
await sim.bus.drain()
|
||||||
|
finally:
|
||||||
|
task.cancel()
|
||||||
|
|
||||||
|
assert [type(event) for event in seen] == [NextTrackRequested] * 2 + [VolumeChangeRequested] * 3
|
||||||
|
|
||||||
|
|
||||||
|
async def test_the_power_button_toggles_the_screen(
|
||||||
|
lircd: tuple[ScriptedLircd, asyncio.base_events.Server], sim: Simulation
|
||||||
|
) -> None:
|
||||||
|
station, server = lircd
|
||||||
|
port = server.sockets[0].getsockname()[1]
|
||||||
|
seen: list[Event] = []
|
||||||
|
sim.bus.subscribe(ToggleScreenRequested, seen.append)
|
||||||
|
|
||||||
|
task = await _run_service(sim, _lirc_config(port))
|
||||||
|
try:
|
||||||
|
await station.wait_connected()
|
||||||
|
await station.send("b7d0000000000000 00 key_power hauppauge")
|
||||||
|
await station.send("b7d0000000000000 00 key_power hauppauge") # still held
|
||||||
|
await sim.bus.drain()
|
||||||
|
finally:
|
||||||
|
task.cancel()
|
||||||
|
|
||||||
|
assert len(seen) == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_the_guide_key_asks_for_the_clock(
|
||||||
|
lircd: tuple[ScriptedLircd, asyncio.base_events.Server], sim: Simulation
|
||||||
|
) -> None:
|
||||||
|
station, server = lircd
|
||||||
|
port = server.sockets[0].getsockname()[1]
|
||||||
|
seen: list[Event] = []
|
||||||
|
sim.bus.subscribe(ToggleClockRequested, seen.append)
|
||||||
|
|
||||||
|
task = await _run_service(sim, _lirc_config(port))
|
||||||
|
try:
|
||||||
|
await station.wait_connected()
|
||||||
|
await station.send("b7c0000000000000 00 key_guide hauppauge")
|
||||||
|
await station.send("b7c0000000000000 00 key_guide hauppauge") # still held
|
||||||
|
await sim.bus.drain()
|
||||||
|
finally:
|
||||||
|
task.cancel()
|
||||||
|
|
||||||
|
assert [event.source for event in seen] == ["lirc"]
|
||||||
|
|
||||||
|
|
||||||
async def test_mute_toggles_and_restores(
|
async def test_mute_toggles_and_restores(
|
||||||
lircd: tuple[ScriptedLircd, asyncio.base_events.Server], sim: Simulation
|
lircd: tuple[ScriptedLircd, asyncio.base_events.Server], sim: Simulation
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -254,6 +353,9 @@ async def test_mute_toggles_and_restores(
|
|||||||
await sim.bus.drain()
|
await sim.bus.drain()
|
||||||
assert sim.player.volume == 0
|
assert sim.player.volume == 0
|
||||||
|
|
||||||
|
# A second press, not the first one held - see the repeat counting below.
|
||||||
|
assert isinstance(sim.clock, FakeClock)
|
||||||
|
await sim.clock.advance(1.0)
|
||||||
await station.send("0 00 KEY_MUTE Hauppauge")
|
await station.send("0 00 KEY_MUTE Hauppauge")
|
||||||
await sim.bus.drain()
|
await sim.bus.drain()
|
||||||
assert sim.player.volume == 37
|
assert sim.player.volume == 37
|
||||||
@@ -302,6 +404,29 @@ async def test_an_album_digit_plays_from_the_beginning(
|
|||||||
assert sim.player.track_index == 0
|
assert sim.player.track_index == 0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_song_digit_plays_from_that_song(
|
||||||
|
lircd: tuple[ScriptedLircd, asyncio.base_events.Server], sim: Simulation
|
||||||
|
) -> None:
|
||||||
|
station, server = lircd
|
||||||
|
port = server.sockets[0].getsockname()[1]
|
||||||
|
album = next(a for a in sim.app.library.albums if a.title == "Kinderparty Lieder")
|
||||||
|
sim.app.config.remote = {
|
||||||
|
"3": RemoteSlotConfig(target_kind="album", target=album.id, track_index=1)
|
||||||
|
}
|
||||||
|
|
||||||
|
task = await _run_service(sim, _lirc_config(port))
|
||||||
|
try:
|
||||||
|
await station.wait_connected()
|
||||||
|
await station.send("0 00 BTN_3 Hauppauge")
|
||||||
|
await sim.bus.drain()
|
||||||
|
finally:
|
||||||
|
task.cancel()
|
||||||
|
|
||||||
|
assert sim.player.playlist is not None
|
||||||
|
assert sim.player.playlist.album_id == album.id
|
||||||
|
assert sim.player.track_index == 1
|
||||||
|
|
||||||
|
|
||||||
async def test_a_series_digit_plays_the_latest_episode(
|
async def test_a_series_digit_plays_the_latest_episode(
|
||||||
lircd: tuple[ScriptedLircd, asyncio.base_events.Server], sim: Simulation
|
lircd: tuple[ScriptedLircd, asyncio.base_events.Server], sim: Simulation
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from musicmouse.effects import (
|
|||||||
OFF,
|
OFF,
|
||||||
EffectAlexaSwipeConfig,
|
EffectAlexaSwipeConfig,
|
||||||
EffectCircularConfig,
|
EffectCircularConfig,
|
||||||
|
EffectSegmentSelect,
|
||||||
EffectStaticConfig,
|
EffectStaticConfig,
|
||||||
)
|
)
|
||||||
from musicmouse.events import (
|
from musicmouse.events import (
|
||||||
@@ -23,9 +24,12 @@ from musicmouse.events import (
|
|||||||
Event,
|
Event,
|
||||||
LedEffectChanged,
|
LedEffectChanged,
|
||||||
RfidTokenRead,
|
RfidTokenRead,
|
||||||
|
RotaryTurned,
|
||||||
|
SelectorPressed,
|
||||||
|
SelectorTurned,
|
||||||
TouchButtonPressed,
|
TouchButtonPressed,
|
||||||
)
|
)
|
||||||
from musicmouse.hardware import Button, ButtonAction, LedZone, TouchButton
|
from musicmouse.hardware import Button, ButtonAction, LedZone, RotaryDirection, TouchButton
|
||||||
from musicmouse.simulator import FakeTransport
|
from musicmouse.simulator import FakeTransport
|
||||||
|
|
||||||
FUCHS = bytes.fromhex("04a1b2c3d4")
|
FUCHS = bytes.fromhex("04a1b2c3d4")
|
||||||
@@ -82,6 +86,56 @@ async def test_button_press_reaches_the_bus(
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _turn(increment: int, direction: RotaryDirection) -> bytes:
|
||||||
|
return encode_input_event(RotaryTurned(position=0, increment=increment, direction=direction))
|
||||||
|
|
||||||
|
|
||||||
|
def _knob(action: ButtonAction) -> bytes:
|
||||||
|
return encode_input_event(ButtonEvent(button=Button.ROTARY, action=action))
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_captured_knob_turns_the_selector_not_the_volume(
|
||||||
|
bus: EventBus, device: MusicMouseDevice, seen: list[Event]
|
||||||
|
) -> None:
|
||||||
|
device.capture_knob(True)
|
||||||
|
device.feed(_turn(2, RotaryDirection.UP))
|
||||||
|
device.feed(_turn(1, RotaryDirection.DOWN))
|
||||||
|
# The first turn after boot reports no increment, but it was still a detent.
|
||||||
|
device.feed(_turn(0, RotaryDirection.UP))
|
||||||
|
await bus.drain()
|
||||||
|
|
||||||
|
assert [e.steps for e in only(seen, SelectorTurned)] == [2, -1, 1]
|
||||||
|
assert not only(seen, RotaryTurned)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_captured_knob_press_is_the_selectors_alone(
|
||||||
|
bus: EventBus, device: MusicMouseDevice, seen: list[Event]
|
||||||
|
) -> None:
|
||||||
|
device.capture_knob(True)
|
||||||
|
for action in (ButtonAction.PRESSED, ButtonAction.RELEASED):
|
||||||
|
device.feed(_knob(action))
|
||||||
|
# The other buttons are not captured.
|
||||||
|
device.feed(encode_input_event(ButtonEvent(button=Button.LEFT, action=ButtonAction.PRESSED)))
|
||||||
|
await bus.drain()
|
||||||
|
|
||||||
|
assert len(only(seen, SelectorPressed)) == 1
|
||||||
|
assert [e.button for e in only(seen, ButtonEvent)] == [Button.LEFT]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_released_knob_is_a_volume_knob_again(
|
||||||
|
bus: EventBus, device: MusicMouseDevice, seen: list[Event]
|
||||||
|
) -> None:
|
||||||
|
device.capture_knob(True)
|
||||||
|
device.capture_knob(False)
|
||||||
|
device.feed(_turn(1, RotaryDirection.UP))
|
||||||
|
device.feed(_knob(ButtonAction.PRESSED))
|
||||||
|
await bus.drain()
|
||||||
|
|
||||||
|
assert len(only(seen, RotaryTurned)) == 1
|
||||||
|
assert len(only(seen, ButtonEvent)) == 1
|
||||||
|
assert not only(seen, SelectorTurned) and not only(seen, SelectorPressed)
|
||||||
|
|
||||||
|
|
||||||
async def test_touch_press_reaches_the_bus(
|
async def test_touch_press_reaches_the_bus(
|
||||||
bus: EventBus, device: MusicMouseDevice, seen: list[Event]
|
bus: EventBus, device: MusicMouseDevice, seen: list[Event]
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -255,6 +309,19 @@ async def test_an_effect_a_zone_does_not_support_is_reported_not_sent(
|
|||||||
assert "cannot be sent to the mouse LEDs" in caplog.text
|
assert "cannot be sent to the mouse LEDs" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
async def test_only_the_ring_can_be_a_selector(
|
||||||
|
bus: EventBus, device: MusicMouseDevice, transport: FakeTransport
|
||||||
|
) -> None:
|
||||||
|
effect = EffectSegmentSelect(colors=(ColorRGBW(1, 0, 0, 0), ColorRGBW(0, 1, 0, 0)))
|
||||||
|
device.set_effect(LedZone.RING, effect)
|
||||||
|
device.set_effect(LedZone.SHELF, effect)
|
||||||
|
await bus.drain()
|
||||||
|
|
||||||
|
sent = transport.effect(LedZone.RING)
|
||||||
|
assert sent is not None and sent.as_bytes() == effect.as_bytes()
|
||||||
|
assert transport.effect(LedZone.SHELF) is None
|
||||||
|
|
||||||
|
|
||||||
async def test_button_brightness_sets_both_backlights(
|
async def test_button_brightness_sets_both_backlights(
|
||||||
device: MusicMouseDevice, transport: FakeTransport
|
device: MusicMouseDevice, transport: FakeTransport
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
@@ -411,6 +411,83 @@ async def test_sync_show_survives_a_failed_cover_download(tmp_path: Path) -> Non
|
|||||||
assert not (folder / "20250101 - Unique Cover.jpg").exists()
|
assert not (folder / "20250101 - Unique Cover.jpg").exists()
|
||||||
|
|
||||||
|
|
||||||
|
_FEED_WITH_LINKS = b"""<?xml version="1.0"?>
|
||||||
|
<rss version="2.0">
|
||||||
|
<channel>
|
||||||
|
<title>Link Show</title>
|
||||||
|
<link>http://example.com/show</link>
|
||||||
|
<item>
|
||||||
|
<title>Old One</title>
|
||||||
|
<link>http://example.com/episodes/old</link>
|
||||||
|
<pubDate>Wed, 01 Jan 2025 08:00:00 GMT</pubDate>
|
||||||
|
<enclosure url="http://example.com/ep1.mp3" length="100" type="audio/mpeg" />
|
||||||
|
<guid>ep1</guid>
|
||||||
|
</item>
|
||||||
|
</channel>
|
||||||
|
</rss>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sync_show_backfills_the_cover_of_an_episode_already_on_disk(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
# Placed by hand (or downloaded before covers were fetched): the audio is there,
|
||||||
|
# so nothing is downloaded - but its cover still has to be fetched.
|
||||||
|
folder = tmp_path / "show"
|
||||||
|
folder.mkdir()
|
||||||
|
(folder / "20250101 - Old One.mp3").write_bytes(b"audio-bytes")
|
||||||
|
requests: list[str] = []
|
||||||
|
|
||||||
|
def handler(request: httpx2.Request) -> httpx2.Response:
|
||||||
|
url = str(request.url)
|
||||||
|
requests.append(url)
|
||||||
|
if url == "http://example.com/feed.xml":
|
||||||
|
return httpx2.Response(200, content=_FEED_WITH_LINKS)
|
||||||
|
if url == "http://example.com/episodes/old":
|
||||||
|
html = '<meta property="og:image" content="http://example.com/old.png">'
|
||||||
|
return httpx2.Response(200, text=html)
|
||||||
|
if url == "http://example.com/old.png":
|
||||||
|
return httpx2.Response(200, content=b"cover-bytes")
|
||||||
|
return httpx2.Response(500)
|
||||||
|
|
||||||
|
async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as client:
|
||||||
|
changed = await sync_show(client, folder, "http://example.com/feed.xml")
|
||||||
|
requests.clear()
|
||||||
|
changed_again = await sync_show(client, folder, "http://example.com/feed.xml")
|
||||||
|
|
||||||
|
assert changed is True
|
||||||
|
assert (folder / "20250101 - Old One.png").read_bytes() == b"cover-bytes"
|
||||||
|
# Once the sidecar exists, a later poll doesn't go looking for it again.
|
||||||
|
assert changed_again is False
|
||||||
|
assert requests == ["http://example.com/feed.xml"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sync_show_does_not_look_for_a_missing_cover_again_within_the_backoff(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
folder = tmp_path / "show"
|
||||||
|
folder.mkdir()
|
||||||
|
(folder / "20250101 - Old One.mp3").write_bytes(b"audio-bytes")
|
||||||
|
page_requests: list[str] = []
|
||||||
|
|
||||||
|
def handler(request: httpx2.Request) -> httpx2.Response:
|
||||||
|
url = str(request.url)
|
||||||
|
if url == "http://example.com/feed.xml":
|
||||||
|
return httpx2.Response(200, content=_FEED_WITH_LINKS)
|
||||||
|
page_requests.append(url)
|
||||||
|
return httpx2.Response(200, text="<html>no og:image here</html>")
|
||||||
|
|
||||||
|
async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as client:
|
||||||
|
changed = await sync_show(client, folder, "http://example.com/feed.xml")
|
||||||
|
await sync_show(client, folder, "http://example.com/feed.xml")
|
||||||
|
|
||||||
|
assert changed is False
|
||||||
|
assert page_requests == ["http://example.com/episodes/old"]
|
||||||
|
assert (folder / podcast_feeds._MISSING_COVERS_FILENAME).exists()
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------- video shows
|
# ---------------------------------------------------------------------- video shows
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -126,6 +126,22 @@ async def test_putting_the_same_figure_back_resumes_where_it_left_off(
|
|||||||
mouse.check("track", "1")
|
mouse.check("track", "1")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_resuming_survives_a_library_rescan(
|
||||||
|
mouse: SimulatorDriver, sim: Simulation
|
||||||
|
) -> None:
|
||||||
|
"""A rescan rebuilds every Playlist object; resuming must not depend on identity."""
|
||||||
|
await mouse.place("fuchs")
|
||||||
|
await mouse.wait(TRACK_SECONDS + 1)
|
||||||
|
mouse.check("track", "1")
|
||||||
|
|
||||||
|
await mouse.remove()
|
||||||
|
await sim.app.rescan_library()
|
||||||
|
await mouse.place("fuchs")
|
||||||
|
|
||||||
|
mouse.check("playing", "true")
|
||||||
|
mouse.check("track", "1")
|
||||||
|
|
||||||
|
|
||||||
async def test_a_different_figure_starts_from_the_top(mouse: SimulatorDriver) -> None:
|
async def test_a_different_figure_starts_from_the_top(mouse: SimulatorDriver) -> None:
|
||||||
await mouse.place("fuchs")
|
await mouse.place("fuchs")
|
||||||
await mouse.wait(TRACK_SECONDS + 1)
|
await mouse.wait(TRACK_SECONDS + 1)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from pathlib import Path
|
|||||||
import httpx2
|
import httpx2
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from musicmouse.config import WebConfig, load_config
|
from musicmouse.config import RemoteSlotConfig, WebConfig, load_config
|
||||||
from musicmouse.services.web.service import build_app
|
from musicmouse.services.web.service import build_app
|
||||||
from musicmouse.simulator.harness import Simulation, build_simulation
|
from musicmouse.simulator.harness import Simulation, build_simulation
|
||||||
from tests.conftest import VALID_CONFIG, write_config
|
from tests.conftest import VALID_CONFIG, write_config
|
||||||
@@ -65,13 +65,6 @@ async def album_id(sim: Simulation, title: str) -> str:
|
|||||||
return next(a.id for a in sim.app.library.albums if a.title == title)
|
return next(a.id for a in sim.app.library.albums if a.title == title)
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------ lirc
|
|
||||||
|
|
||||||
|
|
||||||
async def test_lirc_is_a_404_when_unconfigured(client: httpx2.AsyncClient) -> None:
|
|
||||||
assert (await client.get("/api/lirc")).status_code == 404
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- reading
|
# --------------------------------------------------------------------------- reading
|
||||||
|
|
||||||
|
|
||||||
@@ -93,13 +86,93 @@ async def test_saving_an_album_slot_round_trips(
|
|||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
slots = response.json()["slots"]
|
slots = response.json()["slots"]
|
||||||
assert slots == [
|
assert slots == [
|
||||||
{"digit": "3", "target_kind": "album", "target": target, "resolved_album_id": target}
|
{
|
||||||
|
"digit": "3",
|
||||||
|
"target_kind": "album",
|
||||||
|
"target": target,
|
||||||
|
"resolved_album_id": target,
|
||||||
|
"track_index": 0,
|
||||||
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
text = (config_dir / "config.yml").read_text(encoding="utf-8")
|
text = (config_dir / "config.yml").read_text(encoding="utf-8")
|
||||||
assert "# The MusicMouse config." in text # comments survive
|
assert "# The MusicMouse config." in text # comments survive
|
||||||
reloaded = load_config(config_dir / "config.yml")
|
reloaded = load_config(config_dir / "config.yml")
|
||||||
assert reloaded.remote["3"].target == target
|
assert reloaded.remote["3"].target == target
|
||||||
|
assert "track_index" not in text # a whole-album slot reads as it always did
|
||||||
|
|
||||||
|
|
||||||
|
async def test_saving_a_song_slot_round_trips(
|
||||||
|
client: httpx2.AsyncClient, sim: Simulation, config_dir: Path
|
||||||
|
) -> None:
|
||||||
|
target = await album_id(sim, "Kinderparty Lieder")
|
||||||
|
response = await client.put(
|
||||||
|
"/api/remote/mapping",
|
||||||
|
json={"slots": {"4": {"target_kind": "album", "target": target, "track_index": 1}}},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["slots"][0]["track_index"] == 1
|
||||||
|
assert (await client.get("/api/remote/mapping")).json()["slots"][0]["track_index"] == 1
|
||||||
|
assert sim.app.config.remote["4"].track_index == 1
|
||||||
|
|
||||||
|
reloaded = load_config(config_dir / "config.yml")
|
||||||
|
assert reloaded.remote["4"].track_index == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_song_past_the_end_of_the_album_is_rejected(
|
||||||
|
client: httpx2.AsyncClient, sim: Simulation
|
||||||
|
) -> None:
|
||||||
|
target = await album_id(sim, "Kinderparty Lieder")
|
||||||
|
response = await client.put(
|
||||||
|
"/api/remote/mapping",
|
||||||
|
json={"slots": {"4": {"target_kind": "album", "target": target, "track_index": 2}}},
|
||||||
|
)
|
||||||
|
assert response.status_code == 422
|
||||||
|
assert "no track 2" in response.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_song_of_a_series_is_rejected(client: httpx2.AsyncClient) -> None:
|
||||||
|
response = await client.put(
|
||||||
|
"/api/remote/mapping",
|
||||||
|
json={
|
||||||
|
"slots": {
|
||||||
|
"7": {"target_kind": "series", "target": "Wissen macht Ah", "track_index": 1}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- pressing
|
||||||
|
|
||||||
|
|
||||||
|
async def test_pressing_a_key_plays_its_song_and_then_the_rest(
|
||||||
|
client: httpx2.AsyncClient, sim: Simulation
|
||||||
|
) -> None:
|
||||||
|
target = await album_id(sim, "Kinderparty Lieder")
|
||||||
|
sim.app.config.remote = {
|
||||||
|
"4": RemoteSlotConfig(target_kind="album", target=target, track_index=1)
|
||||||
|
}
|
||||||
|
response = await client.post("/api/remote/press/4")
|
||||||
|
assert response.status_code == 204
|
||||||
|
await sim.bus.drain()
|
||||||
|
|
||||||
|
assert sim.player.playlist is not None
|
||||||
|
assert sim.player.playlist.album_id == target
|
||||||
|
assert len(sim.player.playlist) == 2 # the whole album, not just the one song
|
||||||
|
assert sim.player.track_index == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_pressing_an_unassigned_key_does_nothing(
|
||||||
|
client: httpx2.AsyncClient, sim: Simulation
|
||||||
|
) -> None:
|
||||||
|
assert (await client.post("/api/remote/press/5")).status_code == 204
|
||||||
|
await sim.bus.drain()
|
||||||
|
assert sim.player.playlist is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_pressing_a_non_digit_is_rejected(client: httpx2.AsyncClient) -> None:
|
||||||
|
assert (await client.post("/api/remote/press/x")).status_code == 422
|
||||||
|
|
||||||
|
|
||||||
async def test_saving_a_series_slot_resolves_to_the_latest_episode(
|
async def test_saving_a_series_slot_resolves_to_the_latest_episode(
|
||||||
|
|||||||
141
python-backend/tests/test_school_days.py
Normal file
141
python-backend/tests/test_school_days.py
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
"""Which days are school days, from the OpenHolidays API's two lists."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx2
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from musicmouse.school_days import (
|
||||||
|
HolidayCalendar,
|
||||||
|
fetch_calendar,
|
||||||
|
load_calendar,
|
||||||
|
parse_holidays,
|
||||||
|
save_calendar,
|
||||||
|
school_day,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _entry(
|
||||||
|
start: str, end: str, name: str, *codes: str, nationwide: bool = False
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""One entry in the API's shape (trimmed to the fields that are read)."""
|
||||||
|
return {
|
||||||
|
"startDate": start,
|
||||||
|
"endDate": end,
|
||||||
|
"name": [{"language": "DE", "text": name}],
|
||||||
|
"nationwide": nationwide,
|
||||||
|
"subdivisions": [{"code": code, "shortName": code[3:]} for code in codes],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#: Verbatim dates from the API for Bavaria, 2026/27.
|
||||||
|
SCHOOL = [
|
||||||
|
_entry("2026-11-02", "2026-11-06", "Herbstferien", "DE-BY"),
|
||||||
|
_entry("2026-11-18", "2026-11-18", "Buß- und Bettag", "DE-BY"),
|
||||||
|
_entry("2026-12-24", "2027-01-08", "Weihnachtsferien", "DE-BY"),
|
||||||
|
]
|
||||||
|
PUBLIC = [
|
||||||
|
_entry("2026-10-03", "2026-10-03", "Tag der Deutschen Einheit", nationwide=True),
|
||||||
|
_entry("2027-05-27", "2027-05-27", "Fronleichnam", "DE-HE", "DE-BW", "DE-BY"),
|
||||||
|
_entry("2027-08-09", "2027-08-09", "Friedensfest", "DE-BY-AU"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _calendar(subdivision: str = "DE-BY") -> HolidayCalendar:
|
||||||
|
return HolidayCalendar(
|
||||||
|
fetched=date(2026, 10, 1),
|
||||||
|
subdivision=subdivision,
|
||||||
|
school=parse_holidays(SCHOOL),
|
||||||
|
public=parse_holidays(PUBLIC),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("day", "expected"),
|
||||||
|
[
|
||||||
|
(date(2026, 10, 5), (True, None)), # a plain Monday
|
||||||
|
(date(2026, 10, 10), (False, "Wochenende")),
|
||||||
|
(date(2026, 10, 11), (False, "Wochenende")),
|
||||||
|
(date(2026, 11, 4), (False, "Herbstferien")),
|
||||||
|
(date(2026, 11, 9), (True, None)), # the Monday after
|
||||||
|
(date(2026, 11, 18), (False, "Buß- und Bettag")),
|
||||||
|
(date(2027, 1, 7), (False, "Weihnachtsferien")), # spans the new year
|
||||||
|
(date(2027, 5, 27), (False, "Fronleichnam")),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_school_days_in_bavaria(day: date, expected: tuple[bool, str | None]) -> None:
|
||||||
|
assert school_day(day, _calendar()) == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_nationwide_holiday_on_a_weekend_is_still_the_weekend() -> None:
|
||||||
|
assert school_day(date(2026, 10, 3), _calendar()) == (False, "Wochenende")
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_city_holiday_counts_only_for_that_city() -> None:
|
||||||
|
friedensfest = date(2027, 8, 9) # a Monday; pretend it is not in the summer holidays
|
||||||
|
assert school_day(friedensfest, _calendar("DE-BY")) == (True, None)
|
||||||
|
assert school_day(friedensfest, _calendar("DE-BY-AU")) == (False, "Friedensfest")
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_city_also_gets_its_states_holidays() -> None:
|
||||||
|
assert school_day(date(2026, 11, 4), _calendar("DE-BY-AU")) == (False, "Herbstferien")
|
||||||
|
|
||||||
|
|
||||||
|
def test_without_a_calendar_only_the_weekend_is_known() -> None:
|
||||||
|
assert school_day(date(2026, 10, 5), None) == (None, None)
|
||||||
|
assert school_day(date(2026, 10, 10), None) == (False, "Wochenende")
|
||||||
|
|
||||||
|
|
||||||
|
def test_entries_that_do_not_parse_are_skipped() -> None:
|
||||||
|
raw = [{"startDate": "nope"}, {"name": []}, SCHOOL[0]]
|
||||||
|
assert [holiday.name for holiday in parse_holidays(raw)] == ["Herbstferien"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_cache_round_trips(tmp_path: Path) -> None:
|
||||||
|
path = tmp_path / "cache" / "holidays.json"
|
||||||
|
save_calendar(path, _calendar())
|
||||||
|
assert load_calendar(path, "DE-BY") == _calendar()
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_cache_for_another_subdivision_is_not_used(tmp_path: Path) -> None:
|
||||||
|
path = tmp_path / "holidays.json"
|
||||||
|
save_calendar(path, _calendar())
|
||||||
|
assert load_calendar(path, "DE-BW") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_broken_or_missing_cache_is_no_cache(tmp_path: Path) -> None:
|
||||||
|
path = tmp_path / "holidays.json"
|
||||||
|
assert load_calendar(path, "DE-BY") is None
|
||||||
|
path.write_text("{not json")
|
||||||
|
assert load_calendar(path, "DE-BY") is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_fetching_asks_for_the_state_and_both_lists() -> None:
|
||||||
|
requests: list[httpx2.Request] = []
|
||||||
|
|
||||||
|
def handler(request: httpx2.Request) -> httpx2.Response:
|
||||||
|
requests.append(request)
|
||||||
|
body = SCHOOL if request.url.path == "/SchoolHolidays" else PUBLIC
|
||||||
|
return httpx2.Response(200, json=body)
|
||||||
|
|
||||||
|
async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as client:
|
||||||
|
calendar = await fetch_calendar(client, "DE-BY-AU", date(2026, 10, 1))
|
||||||
|
|
||||||
|
assert [r.url.path for r in requests] == ["/SchoolHolidays", "/PublicHolidays"]
|
||||||
|
assert all(r.url.params["subdivisionCode"] == "DE-BY" for r in requests)
|
||||||
|
assert all(r.url.params["countryIsoCode"] == "DE" for r in requests)
|
||||||
|
assert calendar.subdivision == "DE-BY-AU"
|
||||||
|
assert calendar.fetched == date(2026, 10, 1)
|
||||||
|
assert len(calendar.school) == 3
|
||||||
|
assert len(calendar.public) == 3
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_failed_fetch_raises() -> None:
|
||||||
|
transport = httpx2.MockTransport(lambda _: httpx2.Response(503))
|
||||||
|
async with httpx2.AsyncClient(transport=transport) as client:
|
||||||
|
with pytest.raises(httpx2.HTTPStatusError):
|
||||||
|
await fetch_calendar(client, "DE-BY", date(2026, 10, 1))
|
||||||
350
python-backend/tests/test_screen.py
Normal file
350
python-backend/tests/test_screen.py
Normal file
@@ -0,0 +1,350 @@
|
|||||||
|
"""The screen service: runs the configured command per toggle, and never takes the bus down."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
import shlex
|
||||||
|
import sys
|
||||||
|
import wave
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from musicmouse.blubb import RATE, blubb_wav
|
||||||
|
from musicmouse.bus import EventBus
|
||||||
|
from musicmouse.clock import FakeClock
|
||||||
|
from musicmouse.color import ColorRGBW
|
||||||
|
from musicmouse.config import ScreenConfig
|
||||||
|
from musicmouse.effects import EffectStaticConfig
|
||||||
|
from musicmouse.events import (
|
||||||
|
LedEffectRequested,
|
||||||
|
PlaybackChanged,
|
||||||
|
PlayRemoteSlotRequested,
|
||||||
|
ToggleScreenRequested,
|
||||||
|
UserActivity,
|
||||||
|
)
|
||||||
|
from musicmouse.hardware import LedZone
|
||||||
|
from musicmouse.services.screen import ScreenService
|
||||||
|
|
||||||
|
|
||||||
|
def _python(code: str) -> str:
|
||||||
|
return f"{shlex.quote(sys.executable)} -c {shlex.quote(code)}"
|
||||||
|
|
||||||
|
|
||||||
|
def _appending(log: Path, *, then: str = "") -> str:
|
||||||
|
"""A command that appends a line to ``log`` each time it runs."""
|
||||||
|
return _python(f"open({str(log)!r}, 'a').write('ran\\n'); {then}")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def bus() -> AsyncIterator[EventBus]:
|
||||||
|
async with EventBus() as bus:
|
||||||
|
yield bus
|
||||||
|
|
||||||
|
|
||||||
|
async def _settle(service: ScreenService) -> None:
|
||||||
|
while service._running is not None and not service._running.done():
|
||||||
|
await asyncio.sleep(0.01)
|
||||||
|
|
||||||
|
|
||||||
|
async def _started(
|
||||||
|
bus: EventBus,
|
||||||
|
config: ScreenConfig,
|
||||||
|
*,
|
||||||
|
alsa_device: str | None = None,
|
||||||
|
volume: float = 0.5,
|
||||||
|
) -> tuple[ScreenService, asyncio.Task[None]]:
|
||||||
|
service = ScreenService(bus, config, alsa_device=alsa_device, volume=lambda: volume)
|
||||||
|
task = asyncio.create_task(service.run())
|
||||||
|
await asyncio.sleep(0) # let it subscribe
|
||||||
|
return service, task
|
||||||
|
|
||||||
|
|
||||||
|
async def test_each_press_runs_the_command(bus: EventBus, tmp_path: Path) -> None:
|
||||||
|
log = tmp_path / "ran"
|
||||||
|
service, task = await _started(bus, ScreenConfig(toggle_command=_appending(log)))
|
||||||
|
try:
|
||||||
|
for _ in range(2):
|
||||||
|
await bus.emit_and_wait(ToggleScreenRequested(source="lirc"))
|
||||||
|
await _settle(service)
|
||||||
|
finally:
|
||||||
|
task.cancel()
|
||||||
|
|
||||||
|
assert log.read_text() == "ran\nran\n"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_press_while_the_command_runs_is_ignored(bus: EventBus, tmp_path: Path) -> None:
|
||||||
|
log = tmp_path / "ran"
|
||||||
|
command = _appending(log, then="import time; time.sleep(0.3)")
|
||||||
|
service, task = await _started(bus, ScreenConfig(toggle_command=command))
|
||||||
|
try:
|
||||||
|
await bus.emit_and_wait(ToggleScreenRequested(source="lirc"))
|
||||||
|
await bus.emit_and_wait(ToggleScreenRequested(source="lirc"))
|
||||||
|
await _settle(service)
|
||||||
|
finally:
|
||||||
|
task.cancel()
|
||||||
|
|
||||||
|
assert log.read_text() == "ran\n"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_failing_command_is_logged(bus: EventBus, caplog: pytest.LogCaptureFixture) -> None:
|
||||||
|
command = _python("import sys; print('no display'); sys.exit(3)")
|
||||||
|
service, task = await _started(bus, ScreenConfig(toggle_command=command))
|
||||||
|
try:
|
||||||
|
await bus.emit_and_wait(ToggleScreenRequested(source="lirc"))
|
||||||
|
await _settle(service)
|
||||||
|
finally:
|
||||||
|
task.cancel()
|
||||||
|
|
||||||
|
assert "exit 3" in caplog.text
|
||||||
|
assert "no display" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_missing_command_is_logged(
|
||||||
|
bus: EventBus, caplog: pytest.LogCaptureFixture, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
service, task = await _started(
|
||||||
|
bus, ScreenConfig(toggle_command=str(tmp_path / "no-such-command"))
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await bus.emit_and_wait(ToggleScreenRequested(source="lirc"))
|
||||||
|
await _settle(service)
|
||||||
|
finally:
|
||||||
|
task.cancel()
|
||||||
|
|
||||||
|
assert "could not start" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_hung_command_is_killed(bus: EventBus, caplog: pytest.LogCaptureFixture) -> None:
|
||||||
|
command = _python("import time; time.sleep(30)")
|
||||||
|
service, task = await _started(bus, ScreenConfig(toggle_command=command, timeout=0.2))
|
||||||
|
try:
|
||||||
|
await bus.emit_and_wait(ToggleScreenRequested(source="lirc"))
|
||||||
|
await asyncio.wait_for(_settle(service), 5)
|
||||||
|
finally:
|
||||||
|
task.cancel()
|
||||||
|
|
||||||
|
assert "killed" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------ the blubb
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_blubb_is_a_short_mono_wav() -> None:
|
||||||
|
with wave.open(io.BytesIO(blubb_wav(on=True, gain=0.5))) as wav:
|
||||||
|
assert (wav.getnchannels(), wav.getsampwidth(), wav.getframerate()) == (1, 2, RATE)
|
||||||
|
assert 0.1 < wav.getnframes() / RATE < 0.5
|
||||||
|
|
||||||
|
|
||||||
|
def test_on_and_off_sound_different() -> None:
|
||||||
|
assert blubb_wav(on=True, gain=0.5) != blubb_wav(on=False, gain=0.5)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def fake_aplay(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||||
|
"""An ``aplay`` on PATH that records its arguments and what it was fed."""
|
||||||
|
bin_dir = tmp_path / "bin"
|
||||||
|
bin_dir.mkdir()
|
||||||
|
record = tmp_path / "aplay"
|
||||||
|
script = bin_dir / "aplay"
|
||||||
|
script.write_text(
|
||||||
|
f'#!/bin/sh\necho "$@" > {shlex.quote(str(record))}.args\n'
|
||||||
|
f"cat > {shlex.quote(str(record))}.wav\n"
|
||||||
|
)
|
||||||
|
script.chmod(0o755)
|
||||||
|
monkeypatch.setenv("PATH", f"{bin_dir}:{os.environ['PATH']}")
|
||||||
|
return record
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_switch_plays_the_blubb_its_way(bus: EventBus, fake_aplay: Path) -> None:
|
||||||
|
service, task = await _started(
|
||||||
|
bus, ScreenConfig(toggle_command=_python("print('off')")), alsa_device="softvol_effects"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await bus.emit_and_wait(ToggleScreenRequested(source="lirc"))
|
||||||
|
await _settle(service)
|
||||||
|
finally:
|
||||||
|
task.cancel()
|
||||||
|
|
||||||
|
assert Path(f"{fake_aplay}.args").read_text().split() == ["-q", "-D", "softvol_effects", "-"]
|
||||||
|
assert Path(f"{fake_aplay}.wav").read_bytes() == blubb_wav(on=False, gain=0.35 * 0.5)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("output", "alsa_device", "volume"),
|
||||||
|
[
|
||||||
|
("print('on')", None, 0.5), # simulated audio
|
||||||
|
("print('on')", "softvol_effects", 0.0), # muted
|
||||||
|
("print('done')", "softvol_effects", 0.5), # no direction to go by
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_no_blubb(
|
||||||
|
bus: EventBus, fake_aplay: Path, output: str, alsa_device: str | None, volume: float
|
||||||
|
) -> None:
|
||||||
|
service, task = await _started(
|
||||||
|
bus, ScreenConfig(toggle_command=_python(output)), alsa_device=alsa_device, volume=volume
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await bus.emit_and_wait(ToggleScreenRequested(source="lirc"))
|
||||||
|
await _settle(service)
|
||||||
|
finally:
|
||||||
|
task.cancel()
|
||||||
|
|
||||||
|
assert not Path(f"{fake_aplay}.wav").exists()
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------- idle auto-off
|
||||||
|
|
||||||
|
|
||||||
|
class _Idle:
|
||||||
|
"""A started service with a fake clock and an ``off`` and ``on`` command that log."""
|
||||||
|
|
||||||
|
def __init__(self, bus: EventBus, tmp_path: Path, **config: object) -> None:
|
||||||
|
self.bus = bus
|
||||||
|
self.off_log = tmp_path / "off"
|
||||||
|
self.on_log = tmp_path / "on"
|
||||||
|
self.playing = False
|
||||||
|
self.clock = FakeClock(idle=bus.drain)
|
||||||
|
settings: dict[str, object] = {
|
||||||
|
"toggle_command": _python("print('toggled')"),
|
||||||
|
"off_command": _appending(self.off_log, then="print('off')"),
|
||||||
|
"on_command": _appending(self.on_log, then="print('on')"),
|
||||||
|
"auto_off_minutes": 15,
|
||||||
|
}
|
||||||
|
settings.update(config)
|
||||||
|
self.service = ScreenService(
|
||||||
|
bus,
|
||||||
|
ScreenConfig.model_validate(settings),
|
||||||
|
alsa_device=None,
|
||||||
|
volume=lambda: 0.5,
|
||||||
|
is_playing=lambda: self.playing,
|
||||||
|
clock=self.clock,
|
||||||
|
)
|
||||||
|
self.task: asyncio.Task[None] | None = None
|
||||||
|
|
||||||
|
async def __aenter__(self) -> _Idle:
|
||||||
|
self.task = asyncio.create_task(self.service.run())
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *_: object) -> None:
|
||||||
|
assert self.task is not None
|
||||||
|
self.task.cancel()
|
||||||
|
|
||||||
|
async def wait(self, minutes: float) -> None:
|
||||||
|
await self.clock.advance(minutes * 60)
|
||||||
|
await _settle(self.service)
|
||||||
|
|
||||||
|
def offs(self) -> int:
|
||||||
|
return len(self.off_log.read_text().splitlines()) if self.off_log.exists() else 0
|
||||||
|
|
||||||
|
def ons(self) -> int:
|
||||||
|
return len(self.on_log.read_text().splitlines()) if self.on_log.exists() else 0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_the_screen_goes_off_after_the_idle_time(bus: EventBus, tmp_path: Path) -> None:
|
||||||
|
async with _Idle(bus, tmp_path) as idle:
|
||||||
|
await idle.wait(14)
|
||||||
|
assert idle.offs() == 0
|
||||||
|
await idle.wait(1)
|
||||||
|
assert idle.offs() == 1
|
||||||
|
await idle.wait(60)
|
||||||
|
assert idle.offs() == 1 # once, not every half minute after
|
||||||
|
|
||||||
|
|
||||||
|
async def test_not_while_music_plays(bus: EventBus, tmp_path: Path) -> None:
|
||||||
|
async with _Idle(bus, tmp_path) as idle:
|
||||||
|
idle.playing = True
|
||||||
|
await idle.wait(60)
|
||||||
|
assert idle.offs() == 0
|
||||||
|
|
||||||
|
# The countdown starts when the music stops.
|
||||||
|
idle.playing = False
|
||||||
|
await bus.emit_and_wait(PlaybackChanged(playing=False, source="player"))
|
||||||
|
await idle.wait(14)
|
||||||
|
assert idle.offs() == 0
|
||||||
|
await idle.wait(1)
|
||||||
|
assert idle.offs() == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"event",
|
||||||
|
[UserActivity(source="web"), PlayRemoteSlotRequested(digit="3", source="lirc")],
|
||||||
|
)
|
||||||
|
async def test_activity_restarts_the_countdown(
|
||||||
|
bus: EventBus, tmp_path: Path, event: UserActivity | PlayRemoteSlotRequested
|
||||||
|
) -> None:
|
||||||
|
async with _Idle(bus, tmp_path) as idle:
|
||||||
|
await idle.wait(10)
|
||||||
|
await bus.emit_and_wait(event)
|
||||||
|
await idle.wait(10)
|
||||||
|
assert idle.offs() == 0
|
||||||
|
await idle.wait(5)
|
||||||
|
assert idle.offs() == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_automations_do_not_count_as_activity(bus: EventBus, tmp_path: Path) -> None:
|
||||||
|
async with _Idle(bus, tmp_path) as idle:
|
||||||
|
await idle.wait(10)
|
||||||
|
effect = EffectStaticConfig(ColorRGBW(0, 0, 0, 0))
|
||||||
|
automation = LedEffectRequested(zone=LedZone.MOUSE, effect=effect, source="mqtt")
|
||||||
|
await bus.emit_and_wait(automation)
|
||||||
|
await idle.wait(5)
|
||||||
|
assert idle.offs() == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_someone_coming_back_switches_it_on(bus: EventBus, tmp_path: Path) -> None:
|
||||||
|
async with _Idle(bus, tmp_path) as idle:
|
||||||
|
await idle.wait(15)
|
||||||
|
assert idle.offs() == 1
|
||||||
|
|
||||||
|
await bus.emit_and_wait(UserActivity(source="web"))
|
||||||
|
await _settle(idle.service)
|
||||||
|
await bus.emit_and_wait(UserActivity(source="web"))
|
||||||
|
await _settle(idle.service)
|
||||||
|
assert idle.ons() == 1 # only the first wakes it
|
||||||
|
|
||||||
|
await idle.wait(15)
|
||||||
|
assert idle.offs() == 2 # and the countdown runs again
|
||||||
|
|
||||||
|
|
||||||
|
async def test_the_power_key_after_an_auto_off_is_left_to_the_toggle(
|
||||||
|
bus: EventBus, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
async with _Idle(bus, tmp_path) as idle:
|
||||||
|
await idle.wait(15)
|
||||||
|
await bus.emit_and_wait(ToggleScreenRequested(source="lirc"))
|
||||||
|
await _settle(idle.service)
|
||||||
|
await bus.emit_and_wait(UserActivity(source="web"))
|
||||||
|
await _settle(idle.service)
|
||||||
|
assert idle.ons() == 0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_zero_minutes_means_never(bus: EventBus, tmp_path: Path) -> None:
|
||||||
|
async with _Idle(bus, tmp_path, auto_off_minutes=0) as idle:
|
||||||
|
await idle.wait(600)
|
||||||
|
assert idle.offs() == 0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_no_off_command_means_never(bus: EventBus, tmp_path: Path) -> None:
|
||||||
|
async with _Idle(bus, tmp_path, off_command=None) as idle:
|
||||||
|
await idle.wait(600)
|
||||||
|
assert idle.offs() == 0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_new_idle_time_applies_at_once(bus: EventBus, tmp_path: Path) -> None:
|
||||||
|
async with _Idle(bus, tmp_path) as idle:
|
||||||
|
idle.service.config.auto_off_minutes = 5 # what parent mode does
|
||||||
|
await idle.wait(5)
|
||||||
|
assert idle.offs() == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_the_auto_off_is_silent(bus: EventBus, tmp_path: Path, fake_aplay: Path) -> None:
|
||||||
|
async with _Idle(bus, tmp_path) as idle:
|
||||||
|
idle.service.alsa_device = "softvol_effects"
|
||||||
|
await idle.wait(15)
|
||||||
|
assert idle.offs() == 1
|
||||||
|
assert not Path(f"{fake_aplay}.wav").exists()
|
||||||
371
python-backend/tests/test_selection.py
Normal file
371
python-backend/tests/test_selection.py
Normal file
@@ -0,0 +1,371 @@
|
|||||||
|
"""Picking one of a figure's albums on the LED ring, driven through the simulator."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
from copy import deepcopy
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, TypeVar
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from musicmouse.blubb import confirm_wav, tick_wav
|
||||||
|
from musicmouse.config import load_config
|
||||||
|
from musicmouse.effects import (
|
||||||
|
EffectRandomTwoColorInterpolationConfig,
|
||||||
|
EffectReverseSwipe,
|
||||||
|
EffectSegmentSelect,
|
||||||
|
EffectSwipeAndChange,
|
||||||
|
)
|
||||||
|
from musicmouse.events import ButtonEvent, Event, FigureSelectionChanged, RotaryTurned
|
||||||
|
from musicmouse.hardware import LedZone
|
||||||
|
from musicmouse.reactions import selection
|
||||||
|
from musicmouse.reactions.selection import CONFIRM_GAIN, DEFAULTS, PALETTE, TICK_GAIN
|
||||||
|
from musicmouse.simulator.driver import SimulatorDriver
|
||||||
|
from musicmouse.simulator.harness import Simulation, build_simulation
|
||||||
|
from musicmouse.sound import ui_level
|
||||||
|
from tests.conftest import VALID_CONFIG, write_config, write_track
|
||||||
|
|
||||||
|
TRACK_SECONDS = 10.0
|
||||||
|
TIMEOUT = DEFAULTS.timeout
|
||||||
|
|
||||||
|
T = TypeVar("T", bound=Event)
|
||||||
|
|
||||||
|
|
||||||
|
def _albums(config_dir: Path) -> None:
|
||||||
|
"""Give the fox three albums of two tracks each; the owl keeps its one."""
|
||||||
|
fuchs = config_dir / "music" / "Figuren" / "fuchs"
|
||||||
|
for track in fuchs.glob("*.mp3"):
|
||||||
|
track.unlink()
|
||||||
|
for folder in ("A", "B", "C"):
|
||||||
|
for index in range(2):
|
||||||
|
write_track(fuchs / folder / f"{index:02d}.mp3", title=f"{folder}{index}")
|
||||||
|
|
||||||
|
|
||||||
|
async def _simulation(config_dir: Path, config: dict[str, Any]) -> Simulation:
|
||||||
|
_albums(config_dir)
|
||||||
|
return await build_simulation(
|
||||||
|
load_config(write_config(config_dir, config)), track_duration=TRACK_SECONDS
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def sim(config_dir: Path) -> AsyncIterator[Simulation]:
|
||||||
|
simulation = await _simulation(config_dir, VALID_CONFIG)
|
||||||
|
try:
|
||||||
|
yield simulation
|
||||||
|
finally:
|
||||||
|
await simulation.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mouse(sim: Simulation) -> SimulatorDriver:
|
||||||
|
return sim.driver
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def seen(sim: Simulation) -> list[Event]:
|
||||||
|
events: list[Event] = []
|
||||||
|
sim.bus.subscribe_all(events.append)
|
||||||
|
return events
|
||||||
|
|
||||||
|
|
||||||
|
def only(events: list[Event], event_type: type[T]) -> list[T]:
|
||||||
|
return [e for e in events if isinstance(e, event_type)]
|
||||||
|
|
||||||
|
|
||||||
|
def ring(sim: Simulation) -> object:
|
||||||
|
return sim.app.mouse.effect(LedZone.RING)
|
||||||
|
|
||||||
|
|
||||||
|
def picker(sim: Simulation) -> EffectSegmentSelect:
|
||||||
|
effect = ring(sim)
|
||||||
|
assert isinstance(effect, EffectSegmentSelect)
|
||||||
|
return effect
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------- opening
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_figure_with_several_albums_plays_the_first_and_offers_all(
|
||||||
|
mouse: SimulatorDriver, sim: Simulation
|
||||||
|
) -> None:
|
||||||
|
await mouse.place("fuchs")
|
||||||
|
|
||||||
|
mouse.check("playing", "true")
|
||||||
|
mouse.check("album", "Fuchs - A")
|
||||||
|
effect = picker(sim)
|
||||||
|
assert effect.colors == PALETTE[:3]
|
||||||
|
assert effect.selected == 0
|
||||||
|
# Only the ring is the picker; the body and shelf run the usual start animation.
|
||||||
|
assert isinstance(sim.app.mouse.effect(LedZone.MOUSE), EffectSwipeAndChange)
|
||||||
|
assert isinstance(sim.app.mouse.effect(LedZone.SHELF), EffectSwipeAndChange)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_figure_with_one_album_gets_no_picker(
|
||||||
|
mouse: SimulatorDriver, sim: Simulation
|
||||||
|
) -> None:
|
||||||
|
await mouse.place("eule")
|
||||||
|
|
||||||
|
assert isinstance(ring(sim), EffectSwipeAndChange)
|
||||||
|
assert sim.app.state.selector is None
|
||||||
|
assert not sim.app.mouse.knob_captured
|
||||||
|
|
||||||
|
|
||||||
|
async def test_the_config_can_override_how_the_picker_looks(config_dir: Path) -> None:
|
||||||
|
config = deepcopy(VALID_CONFIG)
|
||||||
|
config["general"]["figure_selection"] = {"offset_degrees": 90, "wobble_amplitude": 3}
|
||||||
|
sim = await _simulation(config_dir, config)
|
||||||
|
try:
|
||||||
|
await sim.driver.place("fuchs")
|
||||||
|
effect = picker(sim)
|
||||||
|
assert effect.offset_degrees == 90
|
||||||
|
assert effect.wobble_amplitude == 3
|
||||||
|
assert effect.slide_ms == DEFAULTS.slide_ms
|
||||||
|
finally:
|
||||||
|
await sim.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- knob
|
||||||
|
|
||||||
|
|
||||||
|
async def test_turning_moves_the_selection_instead_of_the_volume(
|
||||||
|
mouse: SimulatorDriver, sim: Simulation, seen: list[Event]
|
||||||
|
) -> None:
|
||||||
|
await mouse.place("fuchs")
|
||||||
|
volume = sim.player.volume
|
||||||
|
|
||||||
|
await mouse.turn(1)
|
||||||
|
|
||||||
|
effect = picker(sim)
|
||||||
|
assert (effect.previous, effect.selected) == (0, 1)
|
||||||
|
assert sim.player.volume == volume
|
||||||
|
assert not only(seen, RotaryTurned)
|
||||||
|
assert only(seen, FigureSelectionChanged)[-1].selected == 1
|
||||||
|
# Turning only moves the highlight; what plays changes on a press.
|
||||||
|
mouse.check("album", "Fuchs - A")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_turning_wraps_round_the_ring(mouse: SimulatorDriver, sim: Simulation) -> None:
|
||||||
|
await mouse.place("fuchs")
|
||||||
|
|
||||||
|
await mouse.turn(-1)
|
||||||
|
assert picker(sim).selected == 2
|
||||||
|
await mouse.turn(2)
|
||||||
|
assert picker(sim).selected == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_pressing_plays_the_selected_album_and_gives_the_knob_back(
|
||||||
|
mouse: SimulatorDriver, sim: Simulation, seen: list[Event]
|
||||||
|
) -> None:
|
||||||
|
await mouse.place("fuchs")
|
||||||
|
await mouse.turn(2)
|
||||||
|
|
||||||
|
await mouse.press("rotary")
|
||||||
|
|
||||||
|
mouse.check("album", "Fuchs - C")
|
||||||
|
mouse.check("track", "0")
|
||||||
|
mouse.check("playing", "true")
|
||||||
|
effect = ring(sim)
|
||||||
|
assert isinstance(effect, EffectRandomTwoColorInterpolationConfig)
|
||||||
|
assert effect.start_with_existing # a fade from the sections, not a jump
|
||||||
|
# The press was the picker's: Home Assistant never hears of it.
|
||||||
|
assert not only(seen, ButtonEvent)
|
||||||
|
assert only(seen, FigureSelectionChanged)[-1].open is False
|
||||||
|
|
||||||
|
volume = sim.player.volume
|
||||||
|
await mouse.turn(-1)
|
||||||
|
assert sim.player.volume < volume
|
||||||
|
|
||||||
|
|
||||||
|
async def test_pressing_on_the_album_already_playing_does_not_restart_it(
|
||||||
|
mouse: SimulatorDriver, sim: Simulation
|
||||||
|
) -> None:
|
||||||
|
await mouse.place("fuchs")
|
||||||
|
await mouse.wait(3)
|
||||||
|
|
||||||
|
await mouse.press("rotary")
|
||||||
|
|
||||||
|
mouse.check("album", "Fuchs - A")
|
||||||
|
assert sim.player.position == pytest.approx(3.0)
|
||||||
|
assert sim.app.state.selector is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_presses_reach_home_assistant_again_once_the_picker_is_closed(
|
||||||
|
mouse: SimulatorDriver, seen: list[Event]
|
||||||
|
) -> None:
|
||||||
|
await mouse.place("fuchs")
|
||||||
|
await mouse.press("rotary")
|
||||||
|
await mouse.press("rotary")
|
||||||
|
|
||||||
|
assert len(only(seen, ButtonEvent)) == 1
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------ timeout
|
||||||
|
|
||||||
|
|
||||||
|
async def test_left_alone_the_picker_closes_and_the_music_carries_on(
|
||||||
|
mouse: SimulatorDriver, sim: Simulation
|
||||||
|
) -> None:
|
||||||
|
await mouse.place("fuchs")
|
||||||
|
await mouse.turn(1)
|
||||||
|
|
||||||
|
await mouse.wait(TIMEOUT + 0.1)
|
||||||
|
|
||||||
|
assert sim.app.state.selector is None
|
||||||
|
assert not sim.app.mouse.knob_captured
|
||||||
|
assert isinstance(ring(sim), EffectRandomTwoColorInterpolationConfig)
|
||||||
|
mouse.check("album", "Fuchs - A")
|
||||||
|
mouse.check("playing", "true")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_every_turn_starts_the_timeout_over(mouse: SimulatorDriver, sim: Simulation) -> None:
|
||||||
|
await mouse.place("fuchs")
|
||||||
|
await mouse.wait(TIMEOUT - 2)
|
||||||
|
await mouse.turn(1)
|
||||||
|
|
||||||
|
await mouse.wait(TIMEOUT - 1)
|
||||||
|
assert sim.app.state.selector is not None
|
||||||
|
|
||||||
|
await mouse.wait(1.1)
|
||||||
|
assert sim.app.state.selector is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_lifting_the_figure_closes_the_picker(
|
||||||
|
mouse: SimulatorDriver, sim: Simulation
|
||||||
|
) -> None:
|
||||||
|
await mouse.place("fuchs")
|
||||||
|
await mouse.remove()
|
||||||
|
|
||||||
|
assert sim.app.state.selector is None
|
||||||
|
assert not sim.app.mouse.knob_captured
|
||||||
|
assert isinstance(ring(sim), EffectReverseSwipe)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------- memory
|
||||||
|
|
||||||
|
|
||||||
|
async def test_putting_the_figure_back_resumes_the_picked_album(
|
||||||
|
mouse: SimulatorDriver, sim: Simulation
|
||||||
|
) -> None:
|
||||||
|
await mouse.place("fuchs")
|
||||||
|
await mouse.turn(1)
|
||||||
|
await mouse.press("rotary")
|
||||||
|
await mouse.wait(3)
|
||||||
|
await mouse.remove()
|
||||||
|
|
||||||
|
await mouse.place("fuchs")
|
||||||
|
|
||||||
|
mouse.check("album", "Fuchs - B")
|
||||||
|
assert sim.player.position == pytest.approx(3.0)
|
||||||
|
assert picker(sim).selected == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_another_figure_in_between_keeps_the_album_but_not_the_place(
|
||||||
|
mouse: SimulatorDriver, sim: Simulation
|
||||||
|
) -> None:
|
||||||
|
await mouse.place("fuchs")
|
||||||
|
await mouse.turn(1)
|
||||||
|
await mouse.press("rotary")
|
||||||
|
await mouse.wait(TRACK_SECONDS + 3)
|
||||||
|
await mouse.remove()
|
||||||
|
await mouse.place("eule")
|
||||||
|
await mouse.remove()
|
||||||
|
|
||||||
|
await mouse.place("fuchs")
|
||||||
|
|
||||||
|
mouse.check("album", "Fuchs - B")
|
||||||
|
mouse.check("track", "0")
|
||||||
|
assert sim.player.position == pytest.approx(0.0)
|
||||||
|
assert picker(sim).selected == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_an_album_played_to_the_end_is_still_the_one_picked_next_time(
|
||||||
|
mouse: SimulatorDriver,
|
||||||
|
) -> None:
|
||||||
|
await mouse.place("fuchs")
|
||||||
|
await mouse.turn(1)
|
||||||
|
await mouse.press("rotary")
|
||||||
|
await mouse.wait(2 * TRACK_SECONDS + 1)
|
||||||
|
mouse.check("playing", "false")
|
||||||
|
await mouse.remove()
|
||||||
|
|
||||||
|
await mouse.place("fuchs")
|
||||||
|
|
||||||
|
mouse.check("album", "Fuchs - B")
|
||||||
|
mouse.check("track", "0")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_an_album_started_from_the_web_becomes_the_figures_pick(
|
||||||
|
mouse: SimulatorDriver, sim: Simulation
|
||||||
|
) -> None:
|
||||||
|
await mouse.play_album("Fuchs - C")
|
||||||
|
await mouse.wait(2)
|
||||||
|
|
||||||
|
await mouse.place("fuchs")
|
||||||
|
|
||||||
|
mouse.check("album", "Fuchs - C")
|
||||||
|
assert picker(sim).selected == 2
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------- sound
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sounds(monkeypatch: pytest.MonkeyPatch) -> list[tuple[str, bytes]]:
|
||||||
|
played: list[tuple[str, bytes]] = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
selection, "play_wav_soon", lambda device, wav: played.append((device, wav))
|
||||||
|
)
|
||||||
|
return played
|
||||||
|
|
||||||
|
|
||||||
|
async def _audible(config_dir: Path, effects_volume: int = 100) -> Simulation:
|
||||||
|
config = deepcopy(VALID_CONFIG)
|
||||||
|
config["general"]["alsa_device"] = "softvol_effects"
|
||||||
|
config["general"]["effects_volume"] = effects_volume
|
||||||
|
return await _simulation(config_dir, config)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_each_step_ticks_but_fast_turns_do_not_pile_up(
|
||||||
|
config_dir: Path, sounds: list[tuple[str, bytes]]
|
||||||
|
) -> None:
|
||||||
|
sim = await _audible(config_dir)
|
||||||
|
try:
|
||||||
|
await sim.driver.place("fuchs")
|
||||||
|
await sim.driver.turn(1)
|
||||||
|
await sim.driver.turn(1) # no time passed: one tick for both
|
||||||
|
await sim.driver.wait(0.1)
|
||||||
|
await sim.driver.turn(1)
|
||||||
|
|
||||||
|
level = ui_level(sim.player.volume / 100, 1.0)
|
||||||
|
assert sounds == [("softvol_effects", tick_wav(gain=TICK_GAIN * level))] * 2
|
||||||
|
finally:
|
||||||
|
await sim.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_picking_an_album_plays_the_confirm_sound(
|
||||||
|
config_dir: Path, sounds: list[tuple[str, bytes]]
|
||||||
|
) -> None:
|
||||||
|
sim = await _audible(config_dir, effects_volume=50)
|
||||||
|
try:
|
||||||
|
await sim.driver.place("fuchs")
|
||||||
|
await sim.driver.press("rotary")
|
||||||
|
|
||||||
|
level = ui_level(sim.player.volume / 100, 0.5)
|
||||||
|
assert sounds == [("softvol_effects", confirm_wav(gain=CONFIRM_GAIN * level))]
|
||||||
|
finally:
|
||||||
|
await sim.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_no_picker_sounds_with_effects_turned_off(
|
||||||
|
config_dir: Path, sounds: list[tuple[str, bytes]]
|
||||||
|
) -> None:
|
||||||
|
sim = await _audible(config_dir, effects_volume=0)
|
||||||
|
try:
|
||||||
|
await sim.driver.place("fuchs")
|
||||||
|
await sim.driver.turn(1)
|
||||||
|
await sim.driver.press("rotary")
|
||||||
|
assert sounds == []
|
||||||
|
finally:
|
||||||
|
await sim.aclose()
|
||||||
@@ -8,7 +8,7 @@ from pathlib import Path
|
|||||||
import httpx2
|
import httpx2
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from musicmouse.config import WebConfig, load_config
|
from musicmouse.config import ScreenConfig, WebConfig, load_config
|
||||||
from musicmouse.services.web.service import build_app
|
from musicmouse.services.web.service import build_app
|
||||||
from musicmouse.services.web.settings import to_device_volume, to_percent
|
from musicmouse.services.web.settings import to_device_volume, to_percent
|
||||||
from musicmouse.simulator.harness import Simulation, build_simulation
|
from musicmouse.simulator.harness import Simulation, build_simulation
|
||||||
@@ -20,6 +20,7 @@ SETTINGS = {
|
|||||||
"initial_volume": 40,
|
"initial_volume": 40,
|
||||||
"volume_increment": 5,
|
"volume_increment": 5,
|
||||||
"button_leds_brightness": 0.5,
|
"button_leds_brightness": 0.5,
|
||||||
|
"effects_volume": 100,
|
||||||
}
|
}
|
||||||
|
|
||||||
#: A config file with comments, to prove saving does not flatten them.
|
#: A config file with comments, to prove saving does not flatten them.
|
||||||
@@ -99,8 +100,46 @@ def test_a_degenerate_range_reads_as_full(config_dir: Path) -> None:
|
|||||||
|
|
||||||
async def test_settings_expose_only_the_editable_subset(client: httpx2.AsyncClient) -> None:
|
async def test_settings_expose_only_the_editable_subset(client: httpx2.AsyncClient) -> None:
|
||||||
body = (await client.get("/api/settings")).json()
|
body = (await client.get("/api/settings")).json()
|
||||||
assert set(body) == set(SETTINGS)
|
assert set(body) == {*SETTINGS, "screen_auto_off_minutes"}
|
||||||
assert body["max_volume"] == 60
|
assert body["max_volume"] == 60
|
||||||
|
assert body["screen_auto_off_minutes"] is None # no screen to switch off
|
||||||
|
|
||||||
|
|
||||||
|
async def test_the_screen_auto_off_is_read_and_saved_into_its_section(
|
||||||
|
client: httpx2.AsyncClient, sim: Simulation, config_dir: Path
|
||||||
|
) -> None:
|
||||||
|
path = config_dir / "config.yml"
|
||||||
|
path.write_text(
|
||||||
|
COMMENTED.replace(
|
||||||
|
"\nfigures:",
|
||||||
|
" screen:\n toggle_command: kiosk-screen toggle\n"
|
||||||
|
" off_command: kiosk-screen off\n\nfigures:",
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
sim.app.config.general.screen = ScreenConfig(
|
||||||
|
toggle_command="kiosk-screen toggle", off_command="kiosk-screen off"
|
||||||
|
)
|
||||||
|
assert (await client.get("/api/settings")).json()["screen_auto_off_minutes"] == 15
|
||||||
|
|
||||||
|
response = await client.put("/api/settings", json={**SETTINGS, "screen_auto_off_minutes": 30})
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["screen_auto_off_minutes"] == 30
|
||||||
|
assert sim.app.config.general.screen.auto_off_minutes == 30 # live, for ScreenService
|
||||||
|
|
||||||
|
reloaded = load_config(path)
|
||||||
|
assert reloaded.general.screen is not None
|
||||||
|
assert reloaded.general.screen.auto_off_minutes == 30
|
||||||
|
assert "screen_auto_off_minutes" not in path.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_the_screen_auto_off_without_a_screen_is_ignored(
|
||||||
|
client: httpx2.AsyncClient, config_dir: Path
|
||||||
|
) -> None:
|
||||||
|
response = await client.put("/api/settings", json={**SETTINGS, "screen_auto_off_minutes": 30})
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["screen_auto_off_minutes"] is None
|
||||||
|
assert "screen" not in (config_dir / "config.yml").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
# -------------------------------------------------------------------------- writing
|
# -------------------------------------------------------------------------- writing
|
||||||
|
|||||||
47
python-backend/tests/test_sound.py
Normal file
47
python-backend/tests/test_sound.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
"""How loud UI sounds are, and the album picker's two sounds."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
import wave
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from musicmouse.blubb import RATE, confirm_wav, tick_wav
|
||||||
|
from musicmouse.sound import MIN_PLAYER_VOLUME, ui_level
|
||||||
|
|
||||||
|
|
||||||
|
def test_ui_sounds_follow_the_music_volume() -> None:
|
||||||
|
assert ui_level(0.8, 1.0) == pytest.approx(0.8)
|
||||||
|
assert ui_level(1.0, 0.5) == pytest.approx(0.5)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ui_sounds_never_drop_below_the_minimum_player_volume() -> None:
|
||||||
|
# A muted player must not mute the timer, nor make the album picker silent.
|
||||||
|
assert ui_level(0.0, 1.0) == pytest.approx(MIN_PLAYER_VOLUME)
|
||||||
|
assert ui_level(0.0, 0.5) == pytest.approx(MIN_PLAYER_VOLUME * 0.5)
|
||||||
|
|
||||||
|
|
||||||
|
def test_parents_can_still_turn_ui_sounds_off() -> None:
|
||||||
|
assert ui_level(0.8, 0.0) == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(("make", "longest"), [(tick_wav, 0.05), (confirm_wav, 0.4)])
|
||||||
|
def test_the_picker_sounds_are_short_mono_wavs(make: Callable[..., bytes], longest: float) -> None:
|
||||||
|
with wave.open(io.BytesIO(make(gain=0.5))) as wav:
|
||||||
|
assert (wav.getnchannels(), wav.getsampwidth(), wav.getframerate()) == (1, 2, RATE)
|
||||||
|
assert 0 < wav.getnframes() / RATE < longest
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_gain_sets_the_peak() -> None:
|
||||||
|
def peak(data: bytes) -> int:
|
||||||
|
with wave.open(io.BytesIO(data)) as wav:
|
||||||
|
frames = wav.readframes(wav.getnframes())
|
||||||
|
return max(
|
||||||
|
abs(int.from_bytes(frames[i : i + 2], "little", signed=True))
|
||||||
|
for i in range(0, len(frames), 2)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert peak(tick_wav(gain=1.0)) == pytest.approx(32767, abs=1)
|
||||||
|
assert peak(tick_wav(gain=0.25)) == pytest.approx(32767 / 4, abs=1)
|
||||||
292
python-backend/tests/test_timers.py
Normal file
292
python-backend/tests/test_timers.py
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
"""The parents' timer and today's school day, on virtual time and a pinned date."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import io
|
||||||
|
import wave
|
||||||
|
from array import array
|
||||||
|
from collections.abc import AsyncIterator, Callable
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx2
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from musicmouse.app import AppState
|
||||||
|
from musicmouse.blubb import RATE, chime_wav
|
||||||
|
from musicmouse.bus import EventBus
|
||||||
|
from musicmouse.clock import FakeClock
|
||||||
|
from musicmouse.color import ColorRGBW
|
||||||
|
from musicmouse.config import ClockConfig
|
||||||
|
from musicmouse.effects import EffectStaticConfig
|
||||||
|
from musicmouse.events import (
|
||||||
|
CancelTimerRequested,
|
||||||
|
ClockStateChanged,
|
||||||
|
LedEffectRequested,
|
||||||
|
NextTrackRequested,
|
||||||
|
SetTimerRequested,
|
||||||
|
UserActivity,
|
||||||
|
)
|
||||||
|
from musicmouse.hardware import LedZone
|
||||||
|
from musicmouse.services.timers import RING_SECONDS, TimerService, timer_end
|
||||||
|
from tests.test_school_days import PUBLIC, SCHOOL
|
||||||
|
|
||||||
|
#: A Monday in the Herbstferien.
|
||||||
|
HOLIDAY = datetime(2026, 11, 2, 18, 0)
|
||||||
|
#: A plain school Monday.
|
||||||
|
SCHOOL_DAY = datetime(2026, 10, 5, 6, 0)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- chime
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_chime_is_a_short_wav_at_the_requested_level() -> None:
|
||||||
|
wav = chime_wav(gain=0.3)
|
||||||
|
with wave.open(io.BytesIO(wav)) as audio:
|
||||||
|
assert audio.getnchannels() == 1
|
||||||
|
assert audio.getframerate() == RATE
|
||||||
|
frames = audio.readframes(audio.getnframes())
|
||||||
|
assert 1.0 < audio.getnframes() / RATE < 3.0
|
||||||
|
peak = max(abs(sample) for sample in array("h", frames))
|
||||||
|
assert peak == round(32767 * 0.3)
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------- timer_end
|
||||||
|
|
||||||
|
|
||||||
|
def test_minutes_count_from_now() -> None:
|
||||||
|
assert timer_end(datetime(2026, 10, 5, 18, 50), minutes=15) == datetime(2026, 10, 5, 19, 5)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_time_later_today_is_today() -> None:
|
||||||
|
now = datetime(2026, 10, 5, 18, 50, 30)
|
||||||
|
assert timer_end(now, until="19:30") == datetime(2026, 10, 5, 19, 30)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_time_already_past_is_tomorrow() -> None:
|
||||||
|
now = datetime(2026, 10, 5, 23, 40)
|
||||||
|
assert timer_end(now, until="00:10") == datetime(2026, 10, 6, 0, 10)
|
||||||
|
assert timer_end(now, until="23:40") == datetime(2026, 10, 6, 23, 40)
|
||||||
|
|
||||||
|
|
||||||
|
def test_exactly_one_of_minutes_and_until() -> None:
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
timer_end(HOLIDAY)
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
timer_end(HOLIDAY, minutes=5, until="19:00")
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------- service
|
||||||
|
|
||||||
|
|
||||||
|
class Harness:
|
||||||
|
def __init__(self, bus: EventBus, clock: FakeClock, service: TimerService) -> None:
|
||||||
|
self.bus = bus
|
||||||
|
self.clock = clock
|
||||||
|
self.service = service
|
||||||
|
self.state = service.state
|
||||||
|
self.changes = 0
|
||||||
|
bus.subscribe(ClockStateChanged, self._count)
|
||||||
|
|
||||||
|
def _count(self, _event: ClockStateChanged) -> None:
|
||||||
|
self.changes += 1
|
||||||
|
|
||||||
|
def now(self) -> float:
|
||||||
|
return self.service._wall().timestamp()
|
||||||
|
|
||||||
|
|
||||||
|
def _holiday_api(status: int = 200) -> Callable[[httpx2.Request], httpx2.Response]:
|
||||||
|
def handler(request: httpx2.Request) -> httpx2.Response:
|
||||||
|
if status != 200:
|
||||||
|
return httpx2.Response(status)
|
||||||
|
return httpx2.Response(200, json=SCHOOL if "School" in request.url.path else PUBLIC)
|
||||||
|
|
||||||
|
return handler
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def start() -> datetime:
|
||||||
|
return HOLIDAY
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def api_status() -> int:
|
||||||
|
return 200
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def harness(tmp_path: Path, start: datetime, api_status: int) -> AsyncIterator[Harness]:
|
||||||
|
async with EventBus() as bus:
|
||||||
|
clock = FakeClock(idle=bus.drain)
|
||||||
|
service = TimerService(
|
||||||
|
bus,
|
||||||
|
AppState(),
|
||||||
|
ClockConfig(),
|
||||||
|
cache_file=tmp_path / "holidays.json",
|
||||||
|
client=httpx2.AsyncClient(transport=httpx2.MockTransport(_holiday_api(api_status))),
|
||||||
|
alsa_device=None,
|
||||||
|
volume=lambda: 0.5,
|
||||||
|
clock=clock,
|
||||||
|
wall=lambda: start + timedelta(seconds=clock.now()),
|
||||||
|
)
|
||||||
|
task = asyncio.create_task(service.run())
|
||||||
|
await clock.advance(0)
|
||||||
|
try:
|
||||||
|
yield Harness(bus, clock, service)
|
||||||
|
finally:
|
||||||
|
task.cancel()
|
||||||
|
with pytest.raises(asyncio.CancelledError):
|
||||||
|
await task
|
||||||
|
|
||||||
|
|
||||||
|
async def _set(h: Harness, minutes: float) -> None:
|
||||||
|
await h.bus.emit_and_wait(SetTimerRequested(ends_at=h.now() + minutes * 60, source="web"))
|
||||||
|
await h.clock.advance(0)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_timer_counts_down_then_rings(harness: Harness) -> None:
|
||||||
|
await _set(harness, 5)
|
||||||
|
timer = harness.state.parent_timer
|
||||||
|
assert timer is not None and not timer.ringing
|
||||||
|
assert timer.ends_at - timer.started_at == 300
|
||||||
|
|
||||||
|
await harness.clock.advance(299)
|
||||||
|
assert not harness.state.parent_timer.ringing # type: ignore[union-attr]
|
||||||
|
await harness.clock.advance(1)
|
||||||
|
assert harness.state.parent_timer.ringing # type: ignore[union-attr]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_ringing_timer_clears_itself_after_a_minute(harness: Harness) -> None:
|
||||||
|
await _set(harness, 1)
|
||||||
|
await harness.clock.advance(60)
|
||||||
|
assert harness.state.parent_timer is not None
|
||||||
|
|
||||||
|
await harness.clock.advance(RING_SECONDS - 1)
|
||||||
|
assert harness.state.parent_timer is not None
|
||||||
|
await harness.clock.advance(1)
|
||||||
|
assert harness.state.parent_timer is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_anything_a_person_does_silences_it(harness: Harness) -> None:
|
||||||
|
await _set(harness, 1)
|
||||||
|
await harness.clock.advance(60)
|
||||||
|
|
||||||
|
await harness.bus.emit_and_wait(NextTrackRequested(source="lirc"))
|
||||||
|
assert harness.state.parent_timer is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_key_press_in_the_kiosk_silences_it(harness: Harness) -> None:
|
||||||
|
await _set(harness, 1)
|
||||||
|
await harness.clock.advance(60)
|
||||||
|
|
||||||
|
await harness.bus.emit_and_wait(UserActivity(source="web"))
|
||||||
|
assert harness.state.parent_timer is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_the_backend_and_home_assistant_do_not_silence_it(harness: Harness) -> None:
|
||||||
|
await _set(harness, 1)
|
||||||
|
await harness.clock.advance(60)
|
||||||
|
|
||||||
|
await harness.bus.emit_and_wait(NextTrackRequested(source="system"))
|
||||||
|
effect = EffectStaticConfig(color=ColorRGBW(1, 0, 0, 0))
|
||||||
|
await harness.bus.emit_and_wait(
|
||||||
|
LedEffectRequested(zone=LedZone.MOUSE, effect=effect, source="mqtt")
|
||||||
|
)
|
||||||
|
assert harness.state.parent_timer is not None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_input_while_counting_down_does_not_stop_it(harness: Harness) -> None:
|
||||||
|
await _set(harness, 5)
|
||||||
|
await harness.bus.emit_and_wait(NextTrackRequested(source="lirc"))
|
||||||
|
assert harness.state.parent_timer is not None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_cancel_stops_a_running_timer(harness: Harness) -> None:
|
||||||
|
await _set(harness, 5)
|
||||||
|
await harness.bus.emit_and_wait(CancelTimerRequested(source="web"))
|
||||||
|
assert harness.state.parent_timer is None
|
||||||
|
|
||||||
|
await harness.clock.advance(600) # and it does not come back to ring
|
||||||
|
assert harness.state.parent_timer is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_new_timer_replaces_the_old_one(harness: Harness) -> None:
|
||||||
|
await _set(harness, 5)
|
||||||
|
await _set(harness, 30)
|
||||||
|
|
||||||
|
await harness.clock.advance(300)
|
||||||
|
timer = harness.state.parent_timer
|
||||||
|
assert timer is not None and not timer.ringing
|
||||||
|
|
||||||
|
|
||||||
|
async def test_every_step_is_announced(harness: Harness) -> None:
|
||||||
|
before = harness.changes
|
||||||
|
await _set(harness, 1) # set
|
||||||
|
await harness.clock.advance(60) # ringing
|
||||||
|
await harness.clock.advance(RING_SECONDS) # cleared
|
||||||
|
assert harness.changes - before == 3
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------- school day
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_holiday_is_no_school_day(harness: Harness) -> None:
|
||||||
|
assert harness.state.school_day is False
|
||||||
|
assert harness.state.school_day_reason == "Herbstferien"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("start", [SCHOOL_DAY])
|
||||||
|
async def test_a_plain_monday_is_a_school_day(harness: Harness) -> None:
|
||||||
|
assert harness.state.school_day is True
|
||||||
|
assert harness.state.school_day_reason is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_the_holidays_are_cached(harness: Harness) -> None:
|
||||||
|
assert harness.service.cache_file.exists()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_the_day_changes_at_midnight(harness: Harness) -> None:
|
||||||
|
# Friday 2026-11-06 is the last day of the Herbstferien; Saturday is the weekend.
|
||||||
|
await harness.clock.advance(4 * 86400)
|
||||||
|
assert harness.state.school_day_reason == "Herbstferien"
|
||||||
|
await harness.clock.advance(86400)
|
||||||
|
assert harness.state.school_day_reason == "Wochenende"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(("start", "api_status"), [(SCHOOL_DAY, 503)])
|
||||||
|
async def test_offline_without_a_cache_the_weekday_is_unknown(harness: Harness) -> None:
|
||||||
|
assert harness.state.school_day is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("api_status", [503])
|
||||||
|
async def test_offline_with_a_cache_the_cache_is_used(
|
||||||
|
tmp_path: Path, start: datetime, api_status: int
|
||||||
|
) -> None:
|
||||||
|
from musicmouse.school_days import HolidayCalendar, parse_holidays, save_calendar
|
||||||
|
|
||||||
|
cache = tmp_path / "holidays.json"
|
||||||
|
save_calendar(
|
||||||
|
cache,
|
||||||
|
HolidayCalendar(
|
||||||
|
fetched=start.date() - timedelta(days=3),
|
||||||
|
subdivision="DE-BY",
|
||||||
|
school=parse_holidays(SCHOOL),
|
||||||
|
public=parse_holidays(PUBLIC),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
async with EventBus() as bus:
|
||||||
|
service = TimerService(
|
||||||
|
bus,
|
||||||
|
AppState(),
|
||||||
|
ClockConfig(),
|
||||||
|
cache_file=cache,
|
||||||
|
client=httpx2.AsyncClient(transport=httpx2.MockTransport(_holiday_api(api_status))),
|
||||||
|
alsa_device=None,
|
||||||
|
volume=lambda: 0.5,
|
||||||
|
wall=lambda: start,
|
||||||
|
)
|
||||||
|
assert await service.refresh_school_day() is False
|
||||||
|
await service.client.aclose()
|
||||||
|
|
||||||
|
assert service.state.school_day_reason == "Herbstferien"
|
||||||
@@ -9,7 +9,9 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import contextlib
|
import contextlib
|
||||||
import json
|
import json
|
||||||
|
import time
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TypeAlias
|
from typing import TypeAlias
|
||||||
|
|
||||||
@@ -17,9 +19,22 @@ import httpx2
|
|||||||
import pytest
|
import pytest
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
|
|
||||||
|
from musicmouse.app import ParentTimer
|
||||||
from musicmouse.config import WebConfig, load_config
|
from musicmouse.config import WebConfig, load_config
|
||||||
|
from musicmouse.effects import OFF
|
||||||
|
from musicmouse.events import (
|
||||||
|
CancelTimerRequested,
|
||||||
|
ClockStateChanged,
|
||||||
|
LedEffectChanged,
|
||||||
|
SetTimerRequested,
|
||||||
|
StateEvent,
|
||||||
|
ToggleClockRequested,
|
||||||
|
ToggleScreenRequested,
|
||||||
|
)
|
||||||
|
from musicmouse.hardware import LedZone
|
||||||
from musicmouse.library.analysis import BeatGrid, TrackCurves
|
from musicmouse.library.analysis import BeatGrid, TrackCurves
|
||||||
from musicmouse.library.models import track_key
|
from musicmouse.library.models import track_key
|
||||||
|
from musicmouse.services.web.hub import IGNORED_EVENTS, SNAPSHOT_EVENTS
|
||||||
from musicmouse.services.web.service import build_app
|
from musicmouse.services.web.service import build_app
|
||||||
from musicmouse.simulator.harness import Simulation, build_simulation
|
from musicmouse.simulator.harness import Simulation, build_simulation
|
||||||
from tests.conftest import VALID_CONFIG, write_config
|
from tests.conftest import VALID_CONFIG, write_config
|
||||||
@@ -90,6 +105,7 @@ async def test_library_lists_every_album_with_its_tracks(client: Client) -> None
|
|||||||
async def test_a_missing_cover_is_a_404_not_an_error(client: Client) -> None:
|
async def test_a_missing_cover_is_a_404_not_an_error(client: Client) -> None:
|
||||||
album = await album_by_title(client, "Fuchs")
|
album = await album_by_title(client, "Fuchs")
|
||||||
assert album["has_cover"] is False
|
assert album["has_cover"] is False
|
||||||
|
assert album["cover_v"] == 0
|
||||||
assert (await client.get(f"/api/albums/{album['id']}/cover")).status_code == 404
|
assert (await client.get(f"/api/albums/{album['id']}/cover")).status_code == 404
|
||||||
|
|
||||||
|
|
||||||
@@ -188,15 +204,15 @@ async def test_play_can_start_at_a_track(client: Client, sim: Simulation) -> Non
|
|||||||
assert (await client.get("/api/state")).json()["track_index"] == 2
|
assert (await client.get("/api/state")).json()["track_index"] == 2
|
||||||
|
|
||||||
|
|
||||||
async def test_playing_a_figure_album_reuses_the_figure_playlist(
|
async def test_playing_a_figure_album_plays_the_figure_playlist(
|
||||||
client: Client, sim: Simulation
|
client: Client, sim: Simulation
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Identity matters: ``play_figure`` resumes on an ``is`` check."""
|
"""Equal to the figure's own playlist, so placing the figure afterwards resumes it."""
|
||||||
album = await album_by_title(client, "Fuchs")
|
album = await album_by_title(client, "Fuchs")
|
||||||
await client.post("/api/play", json={"album_id": album["id"]})
|
await client.post("/api/play", json={"album_id": album["id"]})
|
||||||
await sim.bus.drain()
|
await sim.bus.drain()
|
||||||
|
|
||||||
assert sim.player.playlist is sim.app.playlists["fuchs"]
|
assert sim.player.playlist == sim.app.playlists["fuchs"][0]
|
||||||
|
|
||||||
|
|
||||||
async def test_playing_an_unknown_album_is_a_404(client: Client) -> None:
|
async def test_playing_an_unknown_album_is_a_404(client: Client) -> None:
|
||||||
@@ -226,6 +242,17 @@ async def test_transport_commands_reach_the_player(client: Client, sim: Simulati
|
|||||||
assert sim.player.is_playing
|
assert sim.player.is_playing
|
||||||
|
|
||||||
|
|
||||||
|
async def test_the_screen_toggle_is_an_intent(client: Client, sim: Simulation) -> None:
|
||||||
|
seen: list[ToggleScreenRequested] = []
|
||||||
|
sim.bus.subscribe(ToggleScreenRequested, seen.append)
|
||||||
|
|
||||||
|
response = await client.post("/api/screen/toggle")
|
||||||
|
await sim.bus.drain()
|
||||||
|
|
||||||
|
assert response.status_code == 204
|
||||||
|
assert [event.source for event in seen] == ["web"]
|
||||||
|
|
||||||
|
|
||||||
async def test_seek_moves_the_position(client: Client, sim: Simulation) -> None:
|
async def test_seek_moves_the_position(client: Client, sim: Simulation) -> None:
|
||||||
album = await album_by_title(client, "Fuchs")
|
album = await album_by_title(client, "Fuchs")
|
||||||
await client.post("/api/play", json={"album_id": album["id"]})
|
await client.post("/api/play", json={"album_id": album["id"]})
|
||||||
@@ -244,9 +271,7 @@ async def test_seeking_backwards_is_rejected(client: Client) -> None:
|
|||||||
# ------------------------------------------------------------------------- volume
|
# ------------------------------------------------------------------------- volume
|
||||||
|
|
||||||
|
|
||||||
async def test_full_volume_means_the_configured_ceiling(
|
async def test_full_volume_means_the_configured_ceiling(client: Client, sim: Simulation) -> None:
|
||||||
client: Client, sim: Simulation
|
|
||||||
) -> None:
|
|
||||||
"""The child sees 0..100; the config's max of 60 never crosses the API boundary."""
|
"""The child sees 0..100; the config's max of 60 never crosses the API boundary."""
|
||||||
response = await client.post("/api/volume", json={"percent": 100})
|
response = await client.post("/api/volume", json={"percent": 100})
|
||||||
assert response.status_code == 204
|
assert response.status_code == 204
|
||||||
@@ -256,33 +281,27 @@ async def test_full_volume_means_the_configured_ceiling(
|
|||||||
assert (await client.get("/api/state")).json()["volume"] == 100
|
assert (await client.get("/api/state")).json()["volume"] == 100
|
||||||
|
|
||||||
|
|
||||||
async def test_volume_scales_across_the_allowed_range(
|
async def test_volume_scales_across_the_allowed_range(client: Client, sim: Simulation) -> None:
|
||||||
client: Client, sim: Simulation
|
|
||||||
) -> None:
|
|
||||||
await client.post("/api/volume", json={"percent": 50})
|
await client.post("/api/volume", json={"percent": 50})
|
||||||
await sim.bus.drain()
|
await sim.bus.drain()
|
||||||
assert sim.player.volume == 30
|
assert sim.player.volume == 30
|
||||||
assert (await client.get("/api/state")).json()["volume"] == 50
|
assert (await client.get("/api/state")).json()["volume"] == 50
|
||||||
|
|
||||||
|
|
||||||
async def test_volume_steps_are_relative_to_the_percentage(
|
async def test_remote_steps_move_by_the_volume_increment(client: Client, sim: Simulation) -> None:
|
||||||
client: Client, sim: Simulation
|
"""The +/- buttons take the IR remote's step, in device units, not a percentage."""
|
||||||
) -> None:
|
|
||||||
await client.post("/api/volume", json={"percent": 50})
|
await client.post("/api/volume", json={"percent": 50})
|
||||||
await sim.bus.drain()
|
await sim.bus.drain()
|
||||||
await client.post("/api/volume", json={"delta_percent": 10})
|
before = sim.player.volume
|
||||||
await sim.bus.drain()
|
step = sim.app.config.general.volume_increment
|
||||||
|
|
||||||
assert (await client.get("/api/state")).json()["volume"] == 60
|
assert (await client.post("/api/volume", json={"steps": 1})).status_code == 204
|
||||||
assert sim.player.volume == 36
|
|
||||||
|
|
||||||
|
|
||||||
async def test_volume_steps_clamp_at_the_ends(client: Client, sim: Simulation) -> None:
|
|
||||||
await client.post("/api/volume", json={"percent": 95})
|
|
||||||
await sim.bus.drain()
|
await sim.bus.drain()
|
||||||
await client.post("/api/volume", json={"delta_percent": 20})
|
assert sim.player.volume == before + step
|
||||||
|
|
||||||
|
await client.post("/api/volume", json={"steps": -2})
|
||||||
await sim.bus.drain()
|
await sim.bus.drain()
|
||||||
assert (await client.get("/api/state")).json()["volume"] == 100
|
assert sim.player.volume == before - step
|
||||||
|
|
||||||
|
|
||||||
async def test_the_device_volume_range_is_never_exposed(client: Client) -> None:
|
async def test_the_device_volume_range_is_never_exposed(client: Client) -> None:
|
||||||
@@ -291,10 +310,80 @@ async def test_the_device_volume_range_is_never_exposed(client: Client) -> None:
|
|||||||
assert "volume_max" not in state
|
assert "volume_max" not in state
|
||||||
|
|
||||||
|
|
||||||
async def test_volume_needs_one_of_the_two_fields(client: Client) -> None:
|
async def test_volume_needs_one_of_the_fields(client: Client) -> None:
|
||||||
assert (await client.post("/api/volume", json={})).status_code == 422
|
assert (await client.post("/api/volume", json={})).status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------- clock
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_timer_in_minutes_is_an_intent(client: Client, sim: Simulation) -> None:
|
||||||
|
seen: list[SetTimerRequested] = []
|
||||||
|
sim.bus.subscribe(SetTimerRequested, seen.append)
|
||||||
|
|
||||||
|
before = time.time()
|
||||||
|
response = await client.post("/api/timer", json={"minutes": 15})
|
||||||
|
await sim.bus.drain()
|
||||||
|
|
||||||
|
assert response.status_code == 204
|
||||||
|
assert len(seen) == 1
|
||||||
|
assert before + 900 <= seen[0].ends_at <= time.time() + 900
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_timer_until_a_time_is_an_intent(client: Client, sim: Simulation) -> None:
|
||||||
|
seen: list[SetTimerRequested] = []
|
||||||
|
sim.bus.subscribe(SetTimerRequested, seen.append)
|
||||||
|
|
||||||
|
response = await client.post("/api/timer", json={"until": "19:30"})
|
||||||
|
await sim.bus.drain()
|
||||||
|
|
||||||
|
assert response.status_code == 204
|
||||||
|
end = datetime.fromtimestamp(seen[0].ends_at)
|
||||||
|
assert (end.hour, end.minute) == (19, 30)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"body", [{}, {"minutes": 5, "until": "19:30"}, {"minutes": 0}, {"until": "25:00"}]
|
||||||
|
)
|
||||||
|
async def test_a_timer_needs_exactly_one_valid_field(client: Client, body: dict) -> None:
|
||||||
|
assert (await client.post("/api/timer", json=body)).status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
async def test_cancelling_the_timer_is_an_intent(client: Client, sim: Simulation) -> None:
|
||||||
|
seen: list[CancelTimerRequested] = []
|
||||||
|
sim.bus.subscribe(CancelTimerRequested, seen.append)
|
||||||
|
|
||||||
|
response = await client.delete("/api/timer")
|
||||||
|
await sim.bus.drain()
|
||||||
|
|
||||||
|
assert response.status_code == 204
|
||||||
|
assert len(seen) == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_the_state_carries_the_clock(client: Client, sim: Simulation) -> None:
|
||||||
|
sim.app.state.school_day = True
|
||||||
|
sim.app.state.parent_timer = ParentTimer(started_at=100.0, ends_at=400.0)
|
||||||
|
|
||||||
|
clock = (await client.get("/api/state")).json()["clock"]
|
||||||
|
|
||||||
|
assert clock == {
|
||||||
|
"school_day": True,
|
||||||
|
"school_day_reason": None,
|
||||||
|
"bus": {"time": "07:25", "lead_minutes": 60},
|
||||||
|
"parent_timer": {"started_at": 100.0, "ends_at": 400.0, "ringing": False},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_no_bus_without_school(client: Client, sim: Simulation) -> None:
|
||||||
|
sim.app.state.school_day = False
|
||||||
|
sim.app.state.school_day_reason = "Herbstferien"
|
||||||
|
|
||||||
|
clock = (await client.get("/api/state")).json()["clock"]
|
||||||
|
|
||||||
|
assert clock["bus"] is None
|
||||||
|
assert clock["school_day_reason"] == "Herbstferien"
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------- websocket
|
# ---------------------------------------------------------------------- websocket
|
||||||
|
|
||||||
|
|
||||||
@@ -324,9 +413,10 @@ async def test_a_new_client_is_sent_a_snapshot_before_any_deltas(api: FastAPI) -
|
|||||||
|
|
||||||
|
|
||||||
async def test_a_state_change_reaches_every_client(api: FastAPI, sim: Simulation) -> None:
|
async def test_a_state_change_reaches_every_client(api: FastAPI, sim: Simulation) -> None:
|
||||||
async with websocket_connect(api, "/api/ws") as first, websocket_connect(
|
async with (
|
||||||
api, "/api/ws"
|
websocket_connect(api, "/api/ws") as first,
|
||||||
) as second:
|
websocket_connect(api, "/api/ws") as second,
|
||||||
|
):
|
||||||
await first.next_json()
|
await first.next_json()
|
||||||
await second.next_json()
|
await second.next_json()
|
||||||
|
|
||||||
@@ -339,6 +429,30 @@ async def test_a_state_change_reaches_every_client(api: FastAPI, sim: Simulation
|
|||||||
assert message["state"]["active_figure"] == "fuchs"
|
assert message["state"]["active_figure"] == "fuchs"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_led_writes_are_not_pushed_to_the_clients(api: FastAPI, sim: Simulation) -> None:
|
||||||
|
"""The snapshot has no LED state, so an LED write is no reason to send one."""
|
||||||
|
async with websocket_connect(api, "/api/ws") as socket:
|
||||||
|
await socket.next_json()
|
||||||
|
|
||||||
|
await sim.bus.emit_and_wait(
|
||||||
|
LedEffectChanged(zone=LedZone.RING, effect=OFF(), origin="mqtt")
|
||||||
|
)
|
||||||
|
|
||||||
|
assert socket.from_app.empty()
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_state_event_is_either_pushed_or_deliberately_ignored() -> None:
|
||||||
|
"""A new StateEvent has to be sorted into SNAPSHOT_EVENTS or IGNORED_EVENTS."""
|
||||||
|
|
||||||
|
def subclasses(kind: type) -> set[type]:
|
||||||
|
found = set(kind.__subclasses__())
|
||||||
|
for sub in list(found):
|
||||||
|
found |= subclasses(sub)
|
||||||
|
return found
|
||||||
|
|
||||||
|
assert subclasses(StateEvent) == set(SNAPSHOT_EVENTS) | set(IGNORED_EVENTS)
|
||||||
|
|
||||||
|
|
||||||
async def test_switching_albums_while_playing_reaches_every_client(
|
async def test_switching_albums_while_playing_reaches_every_client(
|
||||||
api: FastAPI, client: Client, sim: Simulation
|
api: FastAPI, client: Client, sim: Simulation
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -359,6 +473,26 @@ async def test_switching_albums_while_playing_reaches_every_client(
|
|||||||
assert (await last_state(socket))["album_title"] == "Eule"
|
assert (await last_state(socket))["album_title"] == "Eule"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_clock_change_reaches_the_clients(api: FastAPI, sim: Simulation) -> None:
|
||||||
|
async with websocket_connect(api, "/api/ws") as socket:
|
||||||
|
await socket.next_json()
|
||||||
|
|
||||||
|
sim.app.state.parent_timer = ParentTimer(started_at=1.0, ends_at=2.0, ringing=True)
|
||||||
|
await sim.bus.emit_and_wait(ClockStateChanged())
|
||||||
|
|
||||||
|
state = await last_state(socket)
|
||||||
|
assert state["clock"]["parent_timer"]["ringing"] is True
|
||||||
|
|
||||||
|
|
||||||
|
async def test_the_remotes_clock_key_reaches_the_clients(api: FastAPI, sim: Simulation) -> None:
|
||||||
|
async with websocket_connect(api, "/api/ws") as socket:
|
||||||
|
await socket.next_json()
|
||||||
|
|
||||||
|
await sim.bus.emit_and_wait(ToggleClockRequested(source="lirc"))
|
||||||
|
|
||||||
|
assert await socket.next_json() == {"type": "clock_toggle"}
|
||||||
|
|
||||||
|
|
||||||
async def test_a_refresh_tells_the_clients_to_reload_the_library(
|
async def test_a_refresh_tells_the_clients_to_reload_the_library(
|
||||||
api: FastAPI, client: Client, sim: Simulation
|
api: FastAPI, client: Client, sim: Simulation
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
44
scripts/check.sh
Executable file
44
scripts/check.sh
Executable file
@@ -0,0 +1,44 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Every check this repo has, in one command. There is no CI, so this is the gate:
|
||||||
|
# run it before committing, and treat a checker that cannot run as a failure.
|
||||||
|
#
|
||||||
|
# scripts/check.sh backend and web
|
||||||
|
# scripts/check.sh backend just one half
|
||||||
|
# scripts/check.sh web
|
||||||
|
#
|
||||||
|
# PYTHON picks the interpreter (default: python-backend/.venv/bin/python, else python3).
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
repo="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
what="${1:-all}"
|
||||||
|
|
||||||
|
backend() {
|
||||||
|
cd "$repo/python-backend"
|
||||||
|
local python="${PYTHON:-}"
|
||||||
|
if [[ -z "$python" ]]; then
|
||||||
|
if [[ -x .venv/bin/python ]]; then python=.venv/bin/python; else python=python3; fi
|
||||||
|
fi
|
||||||
|
echo "== backend: pytest"
|
||||||
|
"$python" -m pytest -q
|
||||||
|
echo "== backend: ruff"
|
||||||
|
"$python" -m ruff check .
|
||||||
|
echo "== backend: mypy"
|
||||||
|
"$python" -m mypy
|
||||||
|
}
|
||||||
|
|
||||||
|
web() {
|
||||||
|
cd "$repo/web"
|
||||||
|
echo "== web: vitest"
|
||||||
|
npm run --silent test
|
||||||
|
echo "== web: tsc + eslint"
|
||||||
|
npm run --silent check
|
||||||
|
}
|
||||||
|
|
||||||
|
case "$what" in
|
||||||
|
all) backend; web ;;
|
||||||
|
backend) backend ;;
|
||||||
|
web) web ;;
|
||||||
|
*) echo "usage: $0 [all|backend|web]" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
echo "== all checks passed"
|
||||||
@@ -224,14 +224,17 @@ if [[ $DRY_RUN -eq 1 ]]; then
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
ssh "$SSH_TARGET" "
|
# Through `sh -s` rather than as an ssh command string: that string is run by the
|
||||||
echo 'library: '\$(du -sh '$DEST' 2>/dev/null | cut -f1)
|
# remote user's login shell, and on the devices root's is fish, which rejects
|
||||||
echo 'free: '\$(df -Ph '$DEST' | awk 'NR==2 {print \$4}')
|
# `if ...; then`. The heredoc expands $DEST here; the escaped \$ ones run remotely.
|
||||||
if [ -d '${DEST}/.musicmouse-cache' ]; then
|
ssh "$SSH_TARGET" sh -s <<EOF
|
||||||
echo 'covers: '\$(find '${DEST}/.musicmouse-cache/covers' -type f 2>/dev/null | wc -l)' files'
|
echo "library: \$(du -sh '$DEST' 2>/dev/null | cut -f1)"
|
||||||
echo 'analysis: '\$(find '${DEST}/.musicmouse-cache/analysis' -type f 2>/dev/null | wc -l)' files'
|
echo "free: \$(df -Ph '$DEST' | awk 'NR==2 {print \$4}')"
|
||||||
fi
|
if [ -d '${DEST}/.musicmouse-cache' ]; then
|
||||||
"
|
echo "covers: \$(find '${DEST}/.musicmouse-cache/covers' -type f 2>/dev/null | wc -l) files"
|
||||||
|
echo "analysis: \$(find '${DEST}/.musicmouse-cache/analysis' -type f 2>/dev/null | wc -l) files"
|
||||||
|
fi
|
||||||
|
EOF
|
||||||
|
|
||||||
cat <<EOF
|
cat <<EOF
|
||||||
|
|
||||||
|
|||||||
1
slint-frontend/.gitignore
vendored
1
slint-frontend/.gitignore
vendored
@@ -1 +0,0 @@
|
|||||||
target
|
|
||||||
5908
slint-frontend/Cargo.lock
generated
5908
slint-frontend/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,65 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "musicmouse-slint"
|
|
||||||
version = "0.1.0"
|
|
||||||
edition = "2021"
|
|
||||||
description = "Native Slint front-end for the MusicMouse player"
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
# default-features off so the renderer is an explicit choice below, never an
|
|
||||||
# accident: on a Pi 5 (Mesa V3D) the default FemtoVG renderer draws every runtime
|
|
||||||
# loaded Image as solid black - see slint-ui/slint#11785 - which for this app means
|
|
||||||
# every album cover. The `skia` feature is the device's way out.
|
|
||||||
slint = { version = "1.15", default-features = false, features = [
|
|
||||||
"compat-1-2",
|
|
||||||
"std",
|
|
||||||
] }
|
|
||||||
|
|
||||||
serde = { version = "1", features = ["derive"] }
|
|
||||||
serde_json = "1"
|
|
||||||
# Blocking HTTP and blocking websockets, each on its own thread, rather than an async
|
|
||||||
# runtime. The whole client makes one library request, one websocket connection and a
|
|
||||||
# trickle of covers; tokio would be more machinery than the job has work.
|
|
||||||
ureq = { version = "2.12", default-features = false, features = ["json", "gzip"] }
|
|
||||||
tungstenite = { version = "0.24", default-features = false, features = ["handshake"] }
|
|
||||||
image = { version = "0.25", default-features = false, features = ["jpeg", "png"] }
|
|
||||||
dirs = "5"
|
|
||||||
# Only for `normalize()`: folding "Grüffelo" to "gruffelo" needs a real NFD pass, which
|
|
||||||
# is what the web client gets from String.prototype.normalize for free.
|
|
||||||
unicode-normalization = "0.1"
|
|
||||||
|
|
||||||
[build-dependencies]
|
|
||||||
slint-build = "1.15"
|
|
||||||
|
|
||||||
[features]
|
|
||||||
default = ["desktop"]
|
|
||||||
|
|
||||||
# Development on a normal desktop session. FemtoVG is fine on x86/Mesa and builds in
|
|
||||||
# seconds, which is what makes it the right default *here* and the wrong one on the Pi.
|
|
||||||
# `renderer-software` rides along so `SLINT_BACKEND=winit-software` works without a
|
|
||||||
# rebuild: it is how the UI can be rendered and screenshotted on a machine with no GPU
|
|
||||||
# (or no session), and it is the fallback the Pi 5 FemtoVG bug leaves standing.
|
|
||||||
desktop = ["slint/backend-winit", "slint/renderer-femtovg", "slint/renderer-software"]
|
|
||||||
|
|
||||||
# Same window, Skia instead - the combination the device runs, so visual checks here
|
|
||||||
# match what ships. Costs a long first build while rust-skia bootstraps.
|
|
||||||
skia = ["slint/backend-winit", "slint/renderer-skia"]
|
|
||||||
|
|
||||||
# The device: straight onto KMS/DRM with no X, no Wayland and no compositor, input
|
|
||||||
# via libinput. (`backend-linuxkms-noseat` is deprecated upstream in favour of this.)
|
|
||||||
#
|
|
||||||
# Build it with `--no-default-features --features kiosk`: cargo features are additive,
|
|
||||||
# so leaving `desktop` enabled links winit and linuxkms into the same binary.
|
|
||||||
kiosk = ["slint/backend-linuxkms-libinput", "slint/renderer-skia"]
|
|
||||||
|
|
||||||
[profile.release]
|
|
||||||
opt-level = 3
|
|
||||||
lto = "thin"
|
|
||||||
codegen-units = 1
|
|
||||||
panic = "abort"
|
|
||||||
|
|
||||||
# The UI is laid out and hit-tested in debug builds too; an unoptimised image resize
|
|
||||||
# on a 343-album first run is otherwise the slowest thing in the app by far.
|
|
||||||
[profile.dev.package.image]
|
|
||||||
opt-level = 3
|
|
||||||
[profile.dev.package.slint]
|
|
||||||
opt-level = 2
|
|
||||||
@@ -1,168 +0,0 @@
|
|||||||
# Musik Delfin — the Slint front-end
|
|
||||||
|
|
||||||
A native front-end for the music player, next to `../web/`. Same backend, same layout,
|
|
||||||
same German copy; no browser. It exists because the React UI is sluggish on the Pi 5
|
|
||||||
kiosk, and the things making it sluggish are browser-shaped: a compositor deciding what
|
|
||||||
gets its own layer, a `requestAnimationFrame` loop that repaints the viewport, and a
|
|
||||||
JPEG decode per album card per cold start.
|
|
||||||
|
|
||||||
Only the music player is here. The typing game (`Tippen`), the room-lights page, parent
|
|
||||||
mode and the IR-remote assignment are all still web-only.
|
|
||||||
|
|
||||||
## Running it
|
|
||||||
|
|
||||||
Start the backend, then:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
nix-shell # cargo, the GL/X11/Wayland libs, and Nunito
|
|
||||||
cargo run # windowed, FemtoVG
|
|
||||||
cargo run -- --server http://musicdolphin:8080
|
|
||||||
```
|
|
||||||
|
|
||||||
Checks:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
cargo test && cargo clippy --all-targets && cargo fmt --check
|
|
||||||
```
|
|
||||||
|
|
||||||
`tools/headless-shots.sh` renders the UI and screenshots it inside a nested headless
|
|
||||||
compositor, for checking a layout change on a machine with no display (or a locked one).
|
|
||||||
A Slint window needs a real compositor - under bare Xvfb nothing maps at all.
|
|
||||||
|
|
||||||
## Build profiles
|
|
||||||
|
|
||||||
| | Backend | Renderer | For |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `cargo run` | winit | FemtoVG | development here |
|
|
||||||
| `cargo run --features skia` | winit | Skia | checking what the device will show |
|
|
||||||
| `cargo build --release --no-default-features --features kiosk` | linuxkms + libinput | Skia | the Pi |
|
|
||||||
|
|
||||||
**The device must not use FemtoVG.** On a Pi 5 (Mesa V3D), FemtoVG draws every
|
|
||||||
runtime-loaded `Image` as solid black - [slint#11785][bug], open as of this writing -
|
|
||||||
which for this app means every album cover. Skia and the software renderer are both
|
|
||||||
unaffected. FemtoVG stays the default *here* because it builds in seconds and is fine on
|
|
||||||
desktop Mesa; `kiosk` is the profile that ships.
|
|
||||||
|
|
||||||
`--no-default-features` is not optional on that last one: the features are additive, so
|
|
||||||
leaving `desktop` on would link winit *and* linuxkms into the same binary.
|
|
||||||
|
|
||||||
`kiosk` takes the framebuffer directly through KMS/DRM: no X, no Wayland, no compositor.
|
|
||||||
(`backend-linuxkms-noseat` is deprecated upstream in favour of the libinput variant used
|
|
||||||
here.) `SLINT_BACKEND=winit-software` forces the software renderer without a rebuild.
|
|
||||||
|
|
||||||
[bug]: https://github.com/slint-ui/slint/issues/11785
|
|
||||||
|
|
||||||
## Where the speed comes from
|
|
||||||
|
|
||||||
Four things, in rough order of how much they matter on the device:
|
|
||||||
|
|
||||||
**Covers are downscaled once, ever.** The backend serves one size - 640 px on the long
|
|
||||||
edge - and has no `?size=`. A grid card is 180 px. `src/covers.rs` fetches each cover
|
|
||||||
once on a worker thread, resizes it to the tier that was asked for, and writes a JPEG
|
|
||||||
thumbnail to `~/.cache/musicmouse-slint/covers/<album-id>@<px>.jpg`. Every later start
|
|
||||||
reads that instead. There are two tiers and only two, which is what makes the cache
|
|
||||||
worth having: a per-widget pixel size would give each album a dozen near-identical files
|
|
||||||
and a fresh decode for each.
|
|
||||||
|
|
||||||
The cache is dropped wholesale when the websocket announces a rescan
|
|
||||||
(`{"type": "library"}`), because that is the one moment the backend rewrites the art
|
|
||||||
behind an unchanged cover URL. That is the bug the web client had to learn about the
|
|
||||||
hard way - see the comment at `python-backend/musicmouse/services/web/api.py:106`.
|
|
||||||
|
|
||||||
**The grid is virtualized.** Rust hands the UI a `[GridRow]` - the album list
|
|
||||||
pre-chunked into rows - and the view puts those in a `ListView`, which only instantiates
|
|
||||||
the rows on screen. A flat list of 660 cards gives it no rows to skip, hence the
|
|
||||||
chunking. This is the counterpart of `content-visibility: auto` on `.grid > .card` in
|
|
||||||
the web UI, which exists for exactly the same measurement: 340 live cards saturated the
|
|
||||||
Pi's renderer so completely that the main thread stopped scheduling anything.
|
|
||||||
|
|
||||||
**The progress bar animates instead of ticking.** Position arrives twice a second. The
|
|
||||||
web client interpolates it onto animation frames with a JS clock and a render budget;
|
|
||||||
here the fill is a Slint `animate width` between the pushed values, so interpolation
|
|
||||||
costs a property evaluation per frame on the render side and there is no clock at all.
|
|
||||||
It is suppressed for the one frame a track changes on, so a new track jumps rather than
|
|
||||||
sliding across two unrelated positions.
|
|
||||||
|
|
||||||
**Nothing on screen animates by itself.** The web UI's per-track ambient canvas is not
|
|
||||||
here. Halving its resolution and its frame rate took it from 53.5% of a core to 25.0%
|
|
||||||
and 25% was still too much: a loop repainting the viewport is a floor you cannot get
|
|
||||||
under while it runs at all. The stage keeps its gradient, which is what `?pi=1` already
|
|
||||||
does on the device this replaces.
|
|
||||||
|
|
||||||
## What is deliberately different from the web UI
|
|
||||||
|
|
||||||
**No glass blur.** Slint has no `backdrop-filter`, so the panels use a flat translucent
|
|
||||||
fill - exactly the web app's own `data-blur="off"` fallback. Some depth is genuinely
|
|
||||||
lost. Faking it by compositing a pre-blurred copy would reintroduce the per-frame cost
|
|
||||||
that dropping the blur was meant to avoid.
|
|
||||||
|
|
||||||
Worth knowing if you ever port the blur back: on the Pi, `backdrop-filter` measured five
|
|
||||||
times *better* than no blur (25% of a core against 118%), because it is what promotes
|
|
||||||
each panel to its own compositing layer. That is a browser-compositor artifact and does
|
|
||||||
not transfer here - but it is why the web UI keeps the panel blur under `?pi=1`.
|
|
||||||
|
|
||||||
**No dolphin.** The mascot and its swim/bob loops are not ported.
|
|
||||||
|
|
||||||
**Layout** is Slint's, not a transcription of the CSS: fixed row heights instead of
|
|
||||||
`clamp()` and `vh`, a 1080p-shaped fixed measure for the track list rather than
|
|
||||||
`max-width`. The colours are the same - `ui/theme.slint` carries the web UI's oklch
|
|
||||||
tokens converted to sRGB, with the oklch source kept beside each one, because oklch is
|
|
||||||
the form the design is actually maintained in.
|
|
||||||
|
|
||||||
## How it is put together
|
|
||||||
|
|
||||||
The split that matters is the web UI's, unchanged: **what is playing** comes from the
|
|
||||||
backend, **what you are looking at** lives here. The buttons on the mouse, a figurine on
|
|
||||||
the reader and Home Assistant all move playback too, so this follows it rather than
|
|
||||||
assuming it is in charge.
|
|
||||||
|
|
||||||
Three worker threads and no async runtime - the whole client makes one library request,
|
|
||||||
one websocket connection and a trickle of covers.
|
|
||||||
|
|
||||||
| Path | What it is |
|
|
||||||
|---|---|
|
|
||||||
| `src/api.rs` | The wire types and the HTTP calls. Mirrors `services/web/schemas.py`. |
|
|
||||||
| `src/ws.rs` | The state websocket, with reconnect-and-reseed. |
|
|
||||||
| `src/library.rs` | The search index and the browse hierarchy. Port of `web/src/lib/search.ts`. |
|
|
||||||
| `src/keymap.rs` | The keyboard model, as a pure function. Port of `web/src/lib/keyboard.ts`. |
|
|
||||||
| `src/covers.rs` | The thumbnail cache, and the generated stand-in art. |
|
|
||||||
| `src/oklch.rs`, `src/format.rs` | Colour conversion; durations. |
|
|
||||||
| `src/main.rs` | Wiring: worker channels in, Slint models out. |
|
|
||||||
| `ui/*.slint` | Layout only. Nothing here formats a number or picks a word. |
|
|
||||||
|
|
||||||
`src/keymap.rs` and `src/library.rs` are ports of modules that were already pure
|
|
||||||
functions with their own tests, and they keep them: 43 tests, no window required.
|
|
||||||
|
|
||||||
## Keyboard
|
|
||||||
|
|
||||||
The same map as the web UI.
|
|
||||||
|
|
||||||
| Key | |
|
|
||||||
|---|---|
|
|
||||||
| `SPACE` | play / pause (types a space mid-query) |
|
|
||||||
| `SHIFT+H` / `SHIFT+L` | previous / next |
|
|
||||||
| `SHIFT+J` / `SHIFT+K` | quieter / louder |
|
|
||||||
| `SHIFT+M` | mute |
|
|
||||||
| `SHIFT+←` / `SHIFT+→` | seek 15 s |
|
|
||||||
| `A-Z`, `0-9` | search albums as you type |
|
|
||||||
| `?` | search individual tracks |
|
|
||||||
| `TAB` | cycle Musik / Hörbücher / Podcasts |
|
|
||||||
| `← ↑ ↓ →` | move the selection while browsing; transport and volume while playing |
|
|
||||||
| `CTRL+H/J/K/L` | the same, without leaving the home row |
|
|
||||||
| `CTRL+D` / `CTRL+U` | half a page |
|
|
||||||
| `ENTER` | play the selection |
|
|
||||||
| `SHIFT+ENTER` | open the track list |
|
|
||||||
| `ESC` | back out one layer at a time |
|
|
||||||
| `/` | back to browsing |
|
|
||||||
|
|
||||||
Everything is clickable too.
|
|
||||||
|
|
||||||
The special keys are named in `ui/app.slint`, where the `Key.*` constants live, and
|
|
||||||
reach Rust as `"escape"`, `"left"`, `"enter"` and so on - so `src/keymap.rs` is testable
|
|
||||||
without a window and without hardcoding keysym values.
|
|
||||||
|
|
||||||
## Fonts
|
|
||||||
|
|
||||||
Nunito, via `SLINT_DEFAULT_FONT`, which `shell.nix` sets. Slint cannot read the woff2
|
|
||||||
the web app self-hosts, so this wants the TTF; a deployment can drop one at
|
|
||||||
`fonts/Nunito.ttf` instead. With neither, everything still renders in the system sans.
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
fn main() {
|
|
||||||
slint_build::compile("ui/app.slint").expect("compiling ui/app.slint");
|
|
||||||
}
|
|
||||||
Binary file not shown.
@@ -1,9 +0,0 @@
|
|||||||
Nunito, the face the design is drawn in, as a variable TTF.
|
|
||||||
|
|
||||||
`web/public/fonts/` has the same family as woff2, which is right for a browser and
|
|
||||||
useless here: Slint cannot read woff2. This is the upstream variable TTF (weights
|
|
||||||
200-1000), which `SLINT_DEFAULT_FONT` is pointed at - by `shell.nix` for development,
|
|
||||||
and by the launcher `roles/pi_slint_frontend` installs on the device.
|
|
||||||
|
|
||||||
Nunito is licensed under the SIL Open Font License 1.1:
|
|
||||||
https://github.com/googlefonts/nunito
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
# Dev shell for the Slint front-end.
|
|
||||||
#
|
|
||||||
# NixOS has no rustup-installed toolchain here, and Slint's renderers link against
|
|
||||||
# system GL/X11/Wayland libraries that a plain `cargo build` cannot find on its own -
|
|
||||||
# hence LD_LIBRARY_PATH below rather than just a list of buildInputs.
|
|
||||||
{ pkgs ? import <nixpkgs> { } }:
|
|
||||||
|
|
||||||
let
|
|
||||||
# Everything the winit backend dlopen()s at runtime. Missing one of these shows up
|
|
||||||
# as a panic inside winit, not as a link error, so they are easy to misdiagnose.
|
|
||||||
runtimeLibs = with pkgs; [
|
|
||||||
libGL
|
|
||||||
libxkbcommon
|
|
||||||
wayland
|
|
||||||
xorg.libX11
|
|
||||||
xorg.libXcursor
|
|
||||||
xorg.libXi
|
|
||||||
xorg.libXrandr
|
|
||||||
fontconfig
|
|
||||||
freetype
|
|
||||||
];
|
|
||||||
in
|
|
||||||
pkgs.mkShell {
|
|
||||||
nativeBuildInputs = with pkgs; [
|
|
||||||
cargo
|
|
||||||
rustc
|
|
||||||
rustfmt
|
|
||||||
clippy
|
|
||||||
pkg-config
|
|
||||||
# Only needed when building with --features skia: rust-skia compiles its own
|
|
||||||
# bindings shim and wants a C++ toolchain plus python for the GN build.
|
|
||||||
clang
|
|
||||||
python3
|
|
||||||
];
|
|
||||||
|
|
||||||
# Nunito is the face the design is drawn in. `SLINT_DEFAULT_FONT` takes one file and
|
|
||||||
# makes it the primary font. This points at the copy vendored in `fonts/` rather than
|
|
||||||
# at nixpkgs, so development and the device render from the identical file.
|
|
||||||
SLINT_DEFAULT_FONT = toString ./fonts/Nunito.ttf;
|
|
||||||
|
|
||||||
buildInputs = runtimeLibs ++ (with pkgs; [
|
|
||||||
fontconfig
|
|
||||||
# libinput/libdrm/libseat are the linuxkms (kiosk) backend's dependencies. They
|
|
||||||
# cost nothing to have present on a desktop build.
|
|
||||||
libinput
|
|
||||||
libdrm
|
|
||||||
seatd
|
|
||||||
udev
|
|
||||||
]);
|
|
||||||
|
|
||||||
LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath runtimeLibs;
|
|
||||||
|
|
||||||
# rust-skia downloads a prebuilt libskia rather than building it, when it can.
|
|
||||||
SKIA_BINARIES_URL_TEMPLATE = null;
|
|
||||||
|
|
||||||
shellHook = ''
|
|
||||||
echo "musicmouse slint-frontend — cargo $(cargo --version | cut -d' ' -f2)"
|
|
||||||
echo " cargo run # windowed, femtovg"
|
|
||||||
echo " cargo run --features skia # windowed, skia (what the Pi uses)"
|
|
||||||
echo " cargo build --features kiosk # linuxkms + skia, for the device"
|
|
||||||
'';
|
|
||||||
}
|
|
||||||
@@ -1,312 +0,0 @@
|
|||||||
//! The shapes the backend serves, and the calls that fetch them.
|
|
||||||
//!
|
|
||||||
//! Mirrors `python-backend/musicmouse/services/web/schemas.py`, by way of
|
|
||||||
//! `web/src/api/types.ts` - the three are hand-kept in step. Only the music player's
|
|
||||||
//! share of the API is here; settings, the IR remote, Home Assistant and the typing
|
|
||||||
//! game are all reachable and all deliberately absent.
|
|
||||||
//!
|
|
||||||
//! Every field the backend can omit is modelled with `#[serde(default)]` rather than a
|
|
||||||
//! required `Option`, so a backend that grows a field does not break this client and
|
|
||||||
//! one that loses a field fails at the one call site that cared.
|
|
||||||
|
|
||||||
use serde::Deserialize;
|
|
||||||
|
|
||||||
/// Default for a development run; `--server` overrides it.
|
|
||||||
pub const DEFAULT_BASE_URL: &str = "http://127.0.0.1:8080";
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
|
|
||||||
#[serde(rename_all = "lowercase")]
|
|
||||||
pub enum AlbumKind {
|
|
||||||
#[default]
|
|
||||||
Music,
|
|
||||||
Book,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AlbumKind {
|
|
||||||
/// Shape is how you tell the two apart without reading anything: albums square,
|
|
||||||
/// audiobooks taller than wide, everywhere they appear. Nothing else in this app
|
|
||||||
/// may set a cover's aspect ratio. Ported from `web/src/lib/covers.ts`.
|
|
||||||
pub fn aspect(self) -> f32 {
|
|
||||||
match self {
|
|
||||||
Self::Music => 1.0,
|
|
||||||
Self::Book => 0.82,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
|
||||||
pub struct Track {
|
|
||||||
pub title: String,
|
|
||||||
/// Seconds, read from the file's tags at scan time. `0.0` when it carries none.
|
|
||||||
#[serde(default)]
|
|
||||||
pub duration: f64,
|
|
||||||
/// A typing-reward track not yet earned. This front-end does not host the typing
|
|
||||||
/// game, but it still has to not show what the game is withholding.
|
|
||||||
#[serde(default)]
|
|
||||||
pub locked: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
// `series`, and the parts of `PlayerState` the UI does not read, are kept because these
|
|
||||||
// structs are the record of what the backend serves - dropping a field would make this
|
|
||||||
// file a list of what this front-end happens to use today, which is a much less useful
|
|
||||||
// thing to read next to `schemas.py`.
|
|
||||||
#[allow(dead_code)]
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
|
||||||
pub struct Album {
|
|
||||||
pub id: String,
|
|
||||||
/// One of the four fixed shelf folder names: `Figuren`, `Musik`, `Hörbücher`,
|
|
||||||
/// `Kinderpodcasts`. What `Group::of` reads to spot a podcast.
|
|
||||||
pub section: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub kind: AlbumKind,
|
|
||||||
pub title: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub artist: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub series: Option<String>,
|
|
||||||
/// The figurine whose folder this is, when it has one.
|
|
||||||
#[serde(default)]
|
|
||||||
pub figure: Option<String>,
|
|
||||||
/// Series for audiobooks and podcasts, artist for music. What browsing groups by.
|
|
||||||
#[serde(default)]
|
|
||||||
pub category: String,
|
|
||||||
/// Exactly three `#rrggbb`: primary, secondary, accent, pulled out of the cover art
|
|
||||||
/// or synthesised from the id. The generated stand-in cover is painted from these.
|
|
||||||
#[serde(default)]
|
|
||||||
pub colors: Vec<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub has_cover: bool,
|
|
||||||
#[serde(default)]
|
|
||||||
pub duration: f64,
|
|
||||||
#[serde(default)]
|
|
||||||
pub tracks: Vec<Track>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub locked: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Album {
|
|
||||||
pub fn is_book(&self) -> bool {
|
|
||||||
self.kind == AlbumKind::Book
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `"Album · Artist"`, unless a podcast makes those the same string.
|
|
||||||
pub fn line(&self) -> String {
|
|
||||||
let prefix = if self.is_book() { "📖 " } else { "" };
|
|
||||||
if !self.artist.is_empty() && self.artist != self.title {
|
|
||||||
format!("{prefix}{} · {}", self.title, self.artist)
|
|
||||||
} else {
|
|
||||||
format!("{prefix}{}", self.title)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `colors` padded out to the three the painter wants, with the same fallbacks the
|
|
||||||
/// web client uses, so a library entry missing them looks the same in both.
|
|
||||||
pub fn palette(&self) -> [&str; 3] {
|
|
||||||
[
|
|
||||||
self.colors.first().map_or("#4a6fa5", String::as_str),
|
|
||||||
self.colors.get(1).map_or("#6a8fc5", String::as_str),
|
|
||||||
self.colors.get(2).map_or("#a5804a", String::as_str),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
|
||||||
struct LibraryOut {
|
|
||||||
albums: Vec<Album>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
#[derive(Debug, Clone, Default, Deserialize)]
|
|
||||||
pub struct Connection {
|
|
||||||
#[serde(default)]
|
|
||||||
pub firmware: bool,
|
|
||||||
#[serde(default)]
|
|
||||||
pub mqtt: bool,
|
|
||||||
#[serde(default)]
|
|
||||||
pub lirc: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// What is playing. Owned entirely by the backend: the buttons on the mouse, a figurine
|
|
||||||
/// on the reader and Home Assistant all move it too, so this client follows it rather
|
|
||||||
/// than assuming it is in charge.
|
|
||||||
#[allow(dead_code)] // see the note above `Album`
|
|
||||||
#[derive(Debug, Clone, Default, Deserialize)]
|
|
||||||
pub struct PlayerState {
|
|
||||||
#[serde(default)]
|
|
||||||
pub playing: bool,
|
|
||||||
#[serde(default)]
|
|
||||||
pub album_id: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub album_title: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub artist: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub kind: Option<AlbumKind>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub track_index: i32,
|
|
||||||
#[serde(default)]
|
|
||||||
pub track_title: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub track_count: i32,
|
|
||||||
#[serde(default)]
|
|
||||||
pub position: f64,
|
|
||||||
#[serde(default)]
|
|
||||||
pub duration: f64,
|
|
||||||
/// Percent, 0..100. The device's configured range never leaves the backend.
|
|
||||||
#[serde(default)]
|
|
||||||
pub volume: i32,
|
|
||||||
#[serde(default)]
|
|
||||||
pub active_figure: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub connected: Connection,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The push-only websocket's frames. `library` carries no payload - it is a bare "your
|
|
||||||
/// index is stale, fetch it again" after a rescan.
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
|
||||||
#[serde(tag = "type", rename_all = "lowercase")]
|
|
||||||
pub enum ServerMessage {
|
|
||||||
State {
|
|
||||||
state: PlayerState,
|
|
||||||
},
|
|
||||||
Position {
|
|
||||||
position: f64,
|
|
||||||
#[serde(default)]
|
|
||||||
duration: f64,
|
|
||||||
},
|
|
||||||
Library,
|
|
||||||
/// A frame this build does not know. Ignored rather than fatal, so the backend can
|
|
||||||
/// grow a message type without this client falling over on it.
|
|
||||||
#[serde(other)]
|
|
||||||
Unknown,
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------------- client --
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct Client {
|
|
||||||
base: String,
|
|
||||||
agent: ureq::Agent,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Enough of an error type to put a cause on screen. Nothing here is recoverable in a
|
|
||||||
/// way the caller could branch on - the UI either has a library or says it cannot reach
|
|
||||||
/// the mouse - so the variants are not worth splitting.
|
|
||||||
pub type Error = String;
|
|
||||||
|
|
||||||
impl Client {
|
|
||||||
pub fn new(base_url: &str) -> Self {
|
|
||||||
Self {
|
|
||||||
base: base_url.trim_end_matches('/').to_string(),
|
|
||||||
agent: ureq::AgentBuilder::new()
|
|
||||||
.timeout_connect(std::time::Duration::from_secs(4))
|
|
||||||
.timeout_read(std::time::Duration::from_secs(30))
|
|
||||||
.build(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `http://host:port` -> `ws://host:port/api/ws`. The device serves plain HTTP, but
|
|
||||||
/// deriving the scheme rather than hardcoding it keeps a TLS proxy in front working.
|
|
||||||
pub fn websocket_url(&self) -> String {
|
|
||||||
let ws = if let Some(rest) = self.base.strip_prefix("https://") {
|
|
||||||
format!("wss://{rest}")
|
|
||||||
} else if let Some(rest) = self.base.strip_prefix("http://") {
|
|
||||||
format!("ws://{rest}")
|
|
||||||
} else {
|
|
||||||
format!("ws://{}", self.base)
|
|
||||||
};
|
|
||||||
format!("{ws}/api/ws")
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn cover_url(&self, album_id: &str) -> String {
|
|
||||||
format!("{}/api/albums/{album_id}/cover", self.base)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn library(&self) -> Result<Vec<Album>, Error> {
|
|
||||||
let out: LibraryOut = self
|
|
||||||
.agent
|
|
||||||
.get(&format!("{}/api/library", self.base))
|
|
||||||
.call()
|
|
||||||
.map_err(|e| format!("GET /api/library: {e}"))?
|
|
||||||
.into_json()
|
|
||||||
.map_err(|e| format!("GET /api/library: bad JSON: {e}"))?;
|
|
||||||
Ok(out.albums)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn state(&self) -> Result<PlayerState, Error> {
|
|
||||||
self.agent
|
|
||||||
.get(&format!("{}/api/state", self.base))
|
|
||||||
.call()
|
|
||||||
.map_err(|e| format!("GET /api/state: {e}"))?
|
|
||||||
.into_json()
|
|
||||||
.map_err(|e| format!("GET /api/state: bad JSON: {e}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Raw cover bytes, or `Ok(None)` for a 404 - which is a normal answer, not a
|
|
||||||
/// failure: `has_cover` says so in advance and the client paints the album's own
|
|
||||||
/// colours instead.
|
|
||||||
pub fn cover(&self, album_id: &str) -> Result<Option<Vec<u8>>, Error> {
|
|
||||||
let response = match self.agent.get(&self.cover_url(album_id)).call() {
|
|
||||||
Ok(response) => response,
|
|
||||||
Err(ureq::Error::Status(404, _)) => return Ok(None),
|
|
||||||
Err(e) => return Err(format!("GET cover {album_id}: {e}")),
|
|
||||||
};
|
|
||||||
let mut bytes = Vec::new();
|
|
||||||
std::io::Read::read_to_end(&mut response.into_reader(), &mut bytes)
|
|
||||||
.map_err(|e| format!("GET cover {album_id}: {e}"))?;
|
|
||||||
Ok(Some(bytes))
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------- commands --
|
|
||||||
//
|
|
||||||
// Every one of these emits the same *intent* the physical buttons emit, so this UI
|
|
||||||
// has no privileged path and no way to get out of step with a figure someone puts
|
|
||||||
// on the reader. They all answer 204 with no body - nothing to parse.
|
|
||||||
|
|
||||||
fn command(&self, path: &str, body: serde_json::Value) -> Result<(), Error> {
|
|
||||||
let request = self.agent.post(&format!("{}/api{path}", self.base));
|
|
||||||
let result = if body.is_null() {
|
|
||||||
request.call()
|
|
||||||
} else {
|
|
||||||
request.send_json(body)
|
|
||||||
};
|
|
||||||
result.map(|_| ()).map_err(|e| format!("POST {path}: {e}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn play(&self, album_id: &str, track_index: i32) -> Result<(), Error> {
|
|
||||||
self.command(
|
|
||||||
"/play",
|
|
||||||
serde_json::json!({ "album_id": album_id, "track_index": track_index.max(0) }),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn resume(&self) -> Result<(), Error> {
|
|
||||||
self.command("/resume", serde_json::Value::Null)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn pause(&self) -> Result<(), Error> {
|
|
||||||
self.command("/pause", serde_json::Value::Null)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn next(&self) -> Result<(), Error> {
|
|
||||||
self.command("/next", serde_json::Value::Null)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn previous(&self) -> Result<(), Error> {
|
|
||||||
self.command("/previous", serde_json::Value::Null)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn seek(&self, position: f64) -> Result<(), Error> {
|
|
||||||
self.command(
|
|
||||||
"/seek",
|
|
||||||
serde_json::json!({ "position": position.max(0.0) }),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn volume(&self, percent: i32) -> Result<(), Error> {
|
|
||||||
self.command(
|
|
||||||
"/volume",
|
|
||||||
serde_json::json!({ "percent": percent.clamp(0, 100) }),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,406 +0,0 @@
|
|||||||
//! Album art: fetched once, downscaled once, then reused forever.
|
|
||||||
//!
|
|
||||||
//! The backend serves one size and only one - `MAX_COVER_PX = 640` on the longest edge,
|
|
||||||
//! JPEG, at `/api/albums/{id}/cover`, with no `?size=` to ask for less. A browse grid
|
|
||||||
//! draws that at 192 px and the play view at 384, so painting the served file directly
|
|
||||||
//! means decoding a 640x640 JPEG for every visible card on every cold start. At 343
|
|
||||||
//! albums on a Pi that is the whole of "the UI feels slow".
|
|
||||||
//!
|
|
||||||
//! So this keeps a thumbnail cache on disk, keyed by album id *and* the size that was
|
|
||||||
//! asked for, and the decode-and-resize cost is paid once ever rather than once per
|
|
||||||
//! launch. A miss goes to the network on a worker thread; the UI never blocks on one.
|
|
||||||
//!
|
|
||||||
//! **Invalidation.** The bytes behind a cover URL do change while the URL does not -
|
|
||||||
//! the id addresses which album the art belongs to, not which art. Rather than pay a
|
|
||||||
//! conditional request per cover per start, this cache is cleared wholesale when the
|
|
||||||
//! websocket announces a rescan (`{"type": "library"}`), which is the one moment the
|
|
||||||
//! backend reprocesses cover art. See the long comment at
|
|
||||||
//! `python-backend/musicmouse/services/web/api.py:106` for how the web client learned
|
|
||||||
//! this the hard way.
|
|
||||||
|
|
||||||
use std::collections::{HashMap, HashSet};
|
|
||||||
use std::path::PathBuf;
|
|
||||||
use std::sync::mpsc::{Receiver, Sender};
|
|
||||||
|
|
||||||
use image::imageops::FilterType;
|
|
||||||
use image::{Rgba, RgbaImage};
|
|
||||||
|
|
||||||
use crate::api::{AlbumKind, Client};
|
|
||||||
use crate::library::Group;
|
|
||||||
use crate::oklch::{mix, oklch_to_rgb, parse_hex};
|
|
||||||
|
|
||||||
/// The two sizes anything on screen actually asks for. Keeping it to two is what makes
|
|
||||||
/// the disk cache worth having: a per-widget pixel size would give every album a dozen
|
|
||||||
/// near-identical files and a fresh decode for each.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
|
||||||
pub enum Tier {
|
|
||||||
/// Grid cards, shelf tiles, track rows and the player bar's 56 px thumbnail, which
|
|
||||||
/// scales down from this rather than earning a tier of its own.
|
|
||||||
Thumb,
|
|
||||||
/// The play view's cover, and the album sheet's.
|
|
||||||
Hero,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Tier {
|
|
||||||
pub fn px(self) -> u32 {
|
|
||||||
match self {
|
|
||||||
Self::Thumb => 192,
|
|
||||||
Self::Hero => 384,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
|
||||||
pub struct Key {
|
|
||||||
pub album_id: String,
|
|
||||||
pub tier: Tier,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Everything the worker needs about an album, flattened so it can cross a thread
|
|
||||||
/// boundary without the `Rc<Album>` the UI side holds.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct Request {
|
|
||||||
pub album_id: String,
|
|
||||||
pub tier: Tier,
|
|
||||||
pub has_cover: bool,
|
|
||||||
pub is_book: bool,
|
|
||||||
pub locked: bool,
|
|
||||||
pub colors: [String; 3],
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A finished bitmap on its way back to the UI thread. Raw RGBA rather than a
|
|
||||||
/// `slint::Image`, because the image types are built on the UI thread by whoever
|
|
||||||
/// receives this.
|
|
||||||
pub struct Ready {
|
|
||||||
pub key: Key,
|
|
||||||
pub width: u32,
|
|
||||||
pub height: u32,
|
|
||||||
pub rgba: Vec<u8>,
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------ generated art --
|
|
||||||
|
|
||||||
/// Diagonal two-tone stripes: the stand-in for a music cover, and the thing that shows
|
|
||||||
/// through underneath one that has not arrived yet.
|
|
||||||
fn stripes(width: u32, height: u32, primary: [u8; 3], secondary: [u8; 3]) -> RgbaImage {
|
|
||||||
// `max(6, size / 13)` in `web/src/lib/covers.ts` - the stripe scales with the art so
|
|
||||||
// a 56 px thumbnail does not turn into a grey smear of forty bands.
|
|
||||||
let band = (width / 13).max(6) as f32;
|
|
||||||
RgbaImage::from_fn(width, height, |x, y| {
|
|
||||||
// 135deg in CSS points the gradient line at the bottom-right corner, so the
|
|
||||||
// position along it is the projection of (x, y) onto (1, 1)/sqrt(2).
|
|
||||||
let along = (x + y) as f32 * std::f32::consts::FRAC_1_SQRT_2;
|
|
||||||
let c = if ((along / band) as u32).is_multiple_of(2) {
|
|
||||||
primary
|
|
||||||
} else {
|
|
||||||
secondary
|
|
||||||
};
|
|
||||||
Rgba([c[0], c[1], c[2], 255])
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A book: page edges down the right, a darker board down the left, over a diagonal
|
|
||||||
/// wash. Four layers in CSS, four passes here, in the same order.
|
|
||||||
fn spine(width: u32, height: u32, primary: [u8; 3], secondary: [u8; 3]) -> RgbaImage {
|
|
||||||
// The audiobook shelf's own hue - warm amber, not a duller yellow-brown - so a
|
|
||||||
// generated spine and the shelf it sits on are the same family by construction.
|
|
||||||
let hue = Group::Audiobooks.hue();
|
|
||||||
let page_light = oklch_to_rgb(0.97, 0.02, hue);
|
|
||||||
let page_dark = oklch_to_rgb(0.90, 0.03, hue);
|
|
||||||
|
|
||||||
RgbaImage::from_fn(width, height, |x, y| {
|
|
||||||
let fx = x as f32 / width.max(1) as f32;
|
|
||||||
let fy = y as f32 / height.max(1) as f32;
|
|
||||||
|
|
||||||
// Layer 4 (bottom): linear-gradient(155deg, primary -> secondary).
|
|
||||||
let t = ((fx * 0.42 + fy * 0.91) / 1.33).clamp(0.0, 1.0);
|
|
||||||
let mut c = mix(primary, secondary, t);
|
|
||||||
|
|
||||||
// Layer 3: the board down the left edge.
|
|
||||||
if fx < 0.11 {
|
|
||||||
c = secondary;
|
|
||||||
} else if fx < 0.13 {
|
|
||||||
c = primary;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Layer 2: two thin highlight rules over the board.
|
|
||||||
if (0.035..0.043).contains(&fx) || (0.070..0.078).contains(&fx) {
|
|
||||||
c = mix(c, [255, 255, 255], 0.35);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Layer 1 (top): the page block on the right.
|
|
||||||
if fx >= 0.975 {
|
|
||||||
c = page_dark;
|
|
||||||
} else if fx >= 0.95 {
|
|
||||||
c = page_light;
|
|
||||||
}
|
|
||||||
|
|
||||||
Rgba([c[0], c[1], c[2], 255])
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// What an album looks like before - or instead of - its real art.
|
|
||||||
pub fn generated(request: &Request) -> RgbaImage {
|
|
||||||
let primary = parse_hex(&request.colors[0], [0x4a, 0x6f, 0xa5]);
|
|
||||||
let secondary = parse_hex(&request.colors[1], [0x6a, 0x8f, 0xc5]);
|
|
||||||
let px = request.tier.px();
|
|
||||||
let (width, height) = dimensions(px, request.is_book);
|
|
||||||
if request.is_book {
|
|
||||||
spine(width, height, primary, secondary)
|
|
||||||
} else {
|
|
||||||
stripes(width, height, primary, secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Shape comes from the media type and from nowhere else: albums square, audiobooks
|
|
||||||
/// taller than wide. `px` fixes the longest edge either way.
|
|
||||||
fn dimensions(px: u32, is_book: bool) -> (u32, u32) {
|
|
||||||
let kind = if is_book {
|
|
||||||
AlbumKind::Book
|
|
||||||
} else {
|
|
||||||
AlbumKind::Music
|
|
||||||
};
|
|
||||||
(((px as f32) * kind.aspect()).round() as u32, px)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------------- worker --
|
|
||||||
|
|
||||||
pub struct Worker {
|
|
||||||
client: Client,
|
|
||||||
dir: Option<PathBuf>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Worker {
|
|
||||||
pub fn new(client: Client) -> Self {
|
|
||||||
let dir = dirs::cache_dir().map(|d| d.join("musicmouse-slint").join("covers"));
|
|
||||||
if let Some(dir) = &dir {
|
|
||||||
if let Err(e) = std::fs::create_dir_all(dir) {
|
|
||||||
// Not fatal: without a disk cache every cover is simply fetched and
|
|
||||||
// resized again next launch, which is the old behaviour, not a failure.
|
|
||||||
eprintln!("cover cache unavailable at {}: {e}", dir.display());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Self { client, dir }
|
|
||||||
}
|
|
||||||
|
|
||||||
fn path(&self, key: &Key) -> Option<PathBuf> {
|
|
||||||
self.dir
|
|
||||||
.as_ref()
|
|
||||||
.map(|d| d.join(format!("{}@{}.jpg", key.album_id, key.tier.px())))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Drop every stored thumbnail. Called when the backend says it has rescanned, and
|
|
||||||
/// therefore possibly rewritten the art behind these ids.
|
|
||||||
pub fn clear_disk(&self) {
|
|
||||||
let Some(dir) = &self.dir else { return };
|
|
||||||
if let Ok(entries) = std::fs::read_dir(dir) {
|
|
||||||
for entry in entries.flatten() {
|
|
||||||
let _ = std::fs::remove_file(entry.path());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render(&self, request: &Request) -> RgbaImage {
|
|
||||||
let px = request.tier.px();
|
|
||||||
let (want_w, want_h) = dimensions(px, request.is_book);
|
|
||||||
|
|
||||||
// A locked album is one the typing game is withholding. It has real art, and
|
|
||||||
// showing it would give away the reward, so it never gets fetched at all.
|
|
||||||
if request.locked || !request.has_cover {
|
|
||||||
return generated(request);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(path) = self.path(&Key {
|
|
||||||
album_id: request.album_id.clone(),
|
|
||||||
tier: request.tier,
|
|
||||||
}) {
|
|
||||||
if let Ok(image) = image::open(&path) {
|
|
||||||
return image.to_rgba8();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let bytes = match self.client.cover(&request.album_id) {
|
|
||||||
Ok(Some(bytes)) => bytes,
|
|
||||||
// 404 is a normal answer - `has_cover` can be stale by a rescan - and a
|
|
||||||
// network error should show the album, not a hole.
|
|
||||||
Ok(None) | Err(_) => return generated(request),
|
|
||||||
};
|
|
||||||
let Ok(decoded) = image::load_from_memory(&bytes) else {
|
|
||||||
return generated(request);
|
|
||||||
};
|
|
||||||
|
|
||||||
// `resize_to_fill` rather than `resize`: a real cover is square but a book's
|
|
||||||
// slot is not, and letterboxing one inside the other would break the shelf
|
|
||||||
// metaphor that the whole browse screen is built on.
|
|
||||||
let thumb = decoded.resize_to_fill(want_w, want_h, FilterType::CatmullRom);
|
|
||||||
|
|
||||||
if let Some(path) = self.path(&Key {
|
|
||||||
album_id: request.album_id.clone(),
|
|
||||||
tier: request.tier,
|
|
||||||
}) {
|
|
||||||
// Storing JPEG, not PNG: these are photographs, so JPEG is both smaller on
|
|
||||||
// disk and faster to decode on the next start, which is the whole point.
|
|
||||||
let _ = thumb
|
|
||||||
.to_rgb8()
|
|
||||||
.save_with_format(&path, image::ImageFormat::Jpeg);
|
|
||||||
}
|
|
||||||
thumb.to_rgba8()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Runs the fetch/decode/resize loop off the UI thread, calling `deliver` for each
|
|
||||||
/// finished bitmap. `deliver` is expected to hop back onto the Slint event loop.
|
|
||||||
pub fn spawn(
|
|
||||||
client: Client,
|
|
||||||
jobs: Receiver<Job>,
|
|
||||||
deliver: impl Fn(Ready) + Send + 'static,
|
|
||||||
) -> std::thread::JoinHandle<()> {
|
|
||||||
std::thread::Builder::new()
|
|
||||||
.name("covers".into())
|
|
||||||
.spawn(move || {
|
|
||||||
let worker = Worker::new(client);
|
|
||||||
// A cover asked for while it is already in flight - a card scrolled past
|
|
||||||
// twice, a grid rebuilt on a keystroke - must not become a second fetch.
|
|
||||||
let mut done: HashSet<Key> = HashSet::new();
|
|
||||||
for job in jobs {
|
|
||||||
match job {
|
|
||||||
Job::Clear => {
|
|
||||||
worker.clear_disk();
|
|
||||||
done.clear();
|
|
||||||
}
|
|
||||||
Job::Fetch(request) => {
|
|
||||||
let key = Key {
|
|
||||||
album_id: request.album_id.clone(),
|
|
||||||
tier: request.tier,
|
|
||||||
};
|
|
||||||
if !done.insert(key.clone()) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let image = worker.render(&request);
|
|
||||||
deliver(Ready {
|
|
||||||
key,
|
|
||||||
width: image.width(),
|
|
||||||
height: image.height(),
|
|
||||||
rgba: image.into_raw(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.expect("spawning the cover worker")
|
|
||||||
}
|
|
||||||
|
|
||||||
pub enum Job {
|
|
||||||
Fetch(Request),
|
|
||||||
Clear,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The UI thread's half: what is already decoded, and what has been asked for.
|
|
||||||
pub struct Store {
|
|
||||||
jobs: Sender<Job>,
|
|
||||||
ready: HashMap<Key, slint::Image>,
|
|
||||||
requested: HashSet<Key>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Store {
|
|
||||||
pub fn new(jobs: Sender<Job>) -> Self {
|
|
||||||
Self {
|
|
||||||
jobs,
|
|
||||||
ready: HashMap::new(),
|
|
||||||
requested: HashSet::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn insert(&mut self, ready: Ready) {
|
|
||||||
let mut buffer =
|
|
||||||
slint::SharedPixelBuffer::<slint::Rgba8Pixel>::new(ready.width, ready.height);
|
|
||||||
buffer.make_mut_bytes().copy_from_slice(&ready.rgba);
|
|
||||||
self.ready
|
|
||||||
.insert(ready.key, slint::Image::from_rgba8(buffer));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The cover for an album if it is decoded, and a fetch queued if it is not. Callers
|
|
||||||
/// paint the album's own colours in the meantime, so this returning `None` is a
|
|
||||||
/// normal frame rather than a missing one.
|
|
||||||
pub fn get(&mut self, request: Request) -> Option<slint::Image> {
|
|
||||||
let key = Key {
|
|
||||||
album_id: request.album_id.clone(),
|
|
||||||
tier: request.tier,
|
|
||||||
};
|
|
||||||
if let Some(image) = self.ready.get(&key) {
|
|
||||||
return Some(image.clone());
|
|
||||||
}
|
|
||||||
if self.requested.insert(key) {
|
|
||||||
let _ = self.jobs.send(Job::Fetch(request));
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn clear(&mut self) {
|
|
||||||
self.ready.clear();
|
|
||||||
self.requested.clear();
|
|
||||||
let _ = self.jobs.send(Job::Clear);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
fn request(is_book: bool, tier: Tier) -> Request {
|
|
||||||
Request {
|
|
||||||
album_id: "abc".into(),
|
|
||||||
tier,
|
|
||||||
has_cover: false,
|
|
||||||
is_book,
|
|
||||||
locked: false,
|
|
||||||
colors: ["#4a6fa5".into(), "#6a8fc5".into(), "#a5804a".into()],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn shape_follows_the_media_type() {
|
|
||||||
assert_eq!(dimensions(192, false), (192, 192));
|
|
||||||
assert_eq!(dimensions(192, true), (157, 192));
|
|
||||||
assert_eq!(dimensions(384, true), (315, 384));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn generated_art_fills_the_tier_it_was_asked_for() {
|
|
||||||
let music = generated(&request(false, Tier::Thumb));
|
|
||||||
assert_eq!((music.width(), music.height()), (192, 192));
|
|
||||||
let book = generated(&request(true, Tier::Hero));
|
|
||||||
assert_eq!((book.width(), book.height()), (315, 384));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn stripes_actually_alternate() {
|
|
||||||
let art = generated(&request(false, Tier::Thumb));
|
|
||||||
let colours: HashSet<[u8; 3]> = (0..192)
|
|
||||||
.map(|x| {
|
|
||||||
let p = art.get_pixel(x, 0).0;
|
|
||||||
[p[0], p[1], p[2]]
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
assert_eq!(colours.len(), 2, "expected exactly the two stripe colours");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_book_has_bright_page_edges_on_its_right() {
|
|
||||||
let art = generated(&request(true, Tier::Hero));
|
|
||||||
let (w, h) = (art.width(), art.height());
|
|
||||||
let edge = art.get_pixel(w - 2, h / 2).0;
|
|
||||||
let middle = art.get_pixel(w / 2, h / 2).0;
|
|
||||||
let brightness = |p: [u8; 4]| p[0] as u32 + p[1] as u32 + p[2] as u32;
|
|
||||||
assert!(
|
|
||||||
brightness(edge) > brightness(middle),
|
|
||||||
"page edges should be lighter than the cover"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn every_generated_pixel_is_opaque() {
|
|
||||||
let art = generated(&request(true, Tier::Thumb));
|
|
||||||
assert!(art.pixels().all(|p| p.0[3] == 255));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
//! Durations, as the screen says them. Ported from `web/src/lib/format.ts`.
|
|
||||||
|
|
||||||
/// `m:ss`, or `h:mm:ss` once there is an hour to show. Negative and non-finite inputs
|
|
||||||
/// collapse to `0:00` rather than rendering `-1:-3`, because both reach here honestly:
|
|
||||||
/// a position can briefly exceed a tag-derived duration, and a track with no duration
|
|
||||||
/// tag at all arrives as `0.0`.
|
|
||||||
pub fn clock(seconds: f64) -> String {
|
|
||||||
if !seconds.is_finite() || seconds <= 0.0 {
|
|
||||||
return "0:00".into();
|
|
||||||
}
|
|
||||||
let total = seconds.round() as u64;
|
|
||||||
let (hours, minutes, secs) = (total / 3600, (total % 3600) / 60, total % 60);
|
|
||||||
if hours > 0 {
|
|
||||||
format!("{hours}:{minutes:02}:{secs:02}")
|
|
||||||
} else {
|
|
||||||
format!("{minutes}:{secs:02}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// How much of the album is left: the rest of this track plus every track after it.
|
|
||||||
pub fn remaining_in_album(durations: &[f64], track_index: usize, position: f64) -> f64 {
|
|
||||||
let current = durations.get(track_index).copied().unwrap_or(0.0);
|
|
||||||
let rest: f64 = durations.iter().skip(track_index + 1).sum();
|
|
||||||
(current - position).max(0.0) + rest
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn clock_formats_minutes_and_hours() {
|
|
||||||
assert_eq!(clock(0.0), "0:00");
|
|
||||||
assert_eq!(clock(9.0), "0:09");
|
|
||||||
assert_eq!(clock(61.0), "1:01");
|
|
||||||
assert_eq!(clock(599.0), "9:59");
|
|
||||||
assert_eq!(clock(3600.0), "1:00:00");
|
|
||||||
assert_eq!(clock(12130.0), "3:22:10");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn clock_survives_the_inputs_a_real_player_produces() {
|
|
||||||
assert_eq!(clock(-4.0), "0:00");
|
|
||||||
assert_eq!(clock(f64::NAN), "0:00");
|
|
||||||
assert_eq!(clock(f64::INFINITY), "0:00");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn remaining_counts_this_track_and_the_ones_after_it() {
|
|
||||||
let durations = [100.0, 200.0, 300.0];
|
|
||||||
assert_eq!(remaining_in_album(&durations, 0, 0.0), 600.0);
|
|
||||||
assert_eq!(remaining_in_album(&durations, 1, 50.0), 450.0);
|
|
||||||
assert_eq!(remaining_in_album(&durations, 2, 300.0), 0.0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_position_past_the_tagged_duration_does_not_go_negative() {
|
|
||||||
assert_eq!(remaining_in_album(&[100.0, 50.0], 0, 130.0), 50.0);
|
|
||||||
assert_eq!(remaining_in_album(&[], 7, 10.0), 0.0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,833 +0,0 @@
|
|||||||
//! What you are looking at, and what a keypress does to it.
|
|
||||||
//!
|
|
||||||
//! Ported from `web/src/lib/keyboard.ts`, which is already a pure function from a
|
|
||||||
//! keypress to a list of actions - the single most portable thing in that codebase, and
|
|
||||||
//! tested there without a DOM for the same reason it is tested here without a window.
|
|
||||||
//!
|
|
||||||
//! The split this rests on: **what is playing** comes from the backend, **what you are
|
|
||||||
//! looking at** lives here. A real player has other front-ends - the buttons on the
|
|
||||||
//! mouse, a figurine on the reader, Home Assistant - so this UI follows playback rather
|
|
||||||
//! than owning it, and owns only the browse state.
|
|
||||||
//!
|
|
||||||
//! Not ported: the typing game, the room-lights page, the in-app help overlay and the
|
|
||||||
//! IR-remote assign mode, none of which this front-end hosts.
|
|
||||||
|
|
||||||
use crate::library::{Group, Results, GROUPS};
|
|
||||||
|
|
||||||
pub const VOLUME_STEP: i32 = 10;
|
|
||||||
pub const SEEK_STEP: f64 = 15.0;
|
|
||||||
/// How far Ctrl+d/Ctrl+u jump - vim's half-page scroll, applied to rows of cards.
|
|
||||||
pub const PAGE_ROWS: i32 = 3;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
|
||||||
pub struct UiState {
|
|
||||||
pub search: String,
|
|
||||||
pub tracks_mode: bool,
|
|
||||||
/// `None` is the bare root screen (three shelves); otherwise which one is open.
|
|
||||||
pub group: Option<Group>,
|
|
||||||
pub category: Option<String>,
|
|
||||||
pub sel_index: usize,
|
|
||||||
/// Which of the three root shelves is focused. Only meaningful at the bare root,
|
|
||||||
/// where `sel_index` doubles as "which tile in that row".
|
|
||||||
pub shelf_row: usize,
|
|
||||||
pub play_view: bool,
|
|
||||||
pub open_album: Option<String>,
|
|
||||||
/// Which track is highlighted in the open album's list.
|
|
||||||
pub sheet_index: usize,
|
|
||||||
/// Grid columns, recomputed from the window width - `move_selection` needs it to
|
|
||||||
/// know what one row down means.
|
|
||||||
pub cols: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for UiState {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
search: String::new(),
|
|
||||||
tracks_mode: false,
|
|
||||||
group: None,
|
|
||||||
category: None,
|
|
||||||
sel_index: 0,
|
|
||||||
shelf_row: 0,
|
|
||||||
play_view: false,
|
|
||||||
open_album: None,
|
|
||||||
sheet_index: 0,
|
|
||||||
cols: 4,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl UiState {
|
|
||||||
/// The true root: nothing chosen yet, drawn as three shelves rather than a list.
|
|
||||||
pub fn is_root_shelf(&self) -> bool {
|
|
||||||
self.group.is_none()
|
|
||||||
&& self.search.is_empty()
|
|
||||||
&& !self.tracks_mode
|
|
||||||
&& self.category.is_none()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn browsing(&self) -> bool {
|
|
||||||
!self.play_view && self.open_album.is_none()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One step of "back": search, then track-search mode, then group and category
|
|
||||||
/// together - the order ESC and the corner button both use.
|
|
||||||
///
|
|
||||||
/// `group` and `category` clear as one step because a root shelf tile sets both at
|
|
||||||
/// once, jumping straight to one category's albums; undoing that jump should be one
|
|
||||||
/// step too, however the category was reached.
|
|
||||||
pub fn browse_back(&mut self) {
|
|
||||||
self.sel_index = 0;
|
|
||||||
if !self.search.is_empty() {
|
|
||||||
self.search.clear();
|
|
||||||
} else if self.tracks_mode {
|
|
||||||
self.tracks_mode = false;
|
|
||||||
} else {
|
|
||||||
self.group = None;
|
|
||||||
self.category = None;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether anything is left to back out of - what the corner button's presence is.
|
|
||||||
pub fn can_go_back(&self) -> bool {
|
|
||||||
self.play_view
|
|
||||||
|| self.open_album.is_some()
|
|
||||||
|| !self.search.is_empty()
|
|
||||||
|| self.tracks_mode
|
|
||||||
|| self.group.is_some()
|
|
||||||
|| self.category.is_some()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// What a keypress asks the *player* to do. Everything else a key can do is a change to
|
|
||||||
/// [`UiState`], applied in place.
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
|
||||||
pub enum Action {
|
|
||||||
Play {
|
|
||||||
album_id: String,
|
|
||||||
track_index: usize,
|
|
||||||
},
|
|
||||||
Toggle,
|
|
||||||
Next,
|
|
||||||
Previous,
|
|
||||||
/// Percentage points, positive or negative.
|
|
||||||
Volume(i32),
|
|
||||||
/// Seconds, positive or negative.
|
|
||||||
Seek(f64),
|
|
||||||
Mute,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Where the flat selection index is pointing. Songs, then categories, then albums.
|
|
||||||
enum Selection {
|
|
||||||
Song {
|
|
||||||
album_id: String,
|
|
||||||
track_index: usize,
|
|
||||||
},
|
|
||||||
Category(String),
|
|
||||||
Album(String),
|
|
||||||
None,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn selection_at(results: &Results, sel_index: usize) -> Selection {
|
|
||||||
let total = results.total();
|
|
||||||
if total == 0 {
|
|
||||||
return Selection::None;
|
|
||||||
}
|
|
||||||
let index = sel_index.min(total - 1);
|
|
||||||
if let Some(hit) = results.songs.get(index) {
|
|
||||||
return Selection::Song {
|
|
||||||
album_id: hit.album.id.clone(),
|
|
||||||
track_index: hit.index,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
let after_songs = index - results.songs.len();
|
|
||||||
if let Some(category) = results.categories.get(after_songs) {
|
|
||||||
return Selection::Category(category.key.clone());
|
|
||||||
}
|
|
||||||
match results.albums.get(after_songs - results.categories.len()) {
|
|
||||||
Some(album) => Selection::Album(album.id.clone()),
|
|
||||||
None => Selection::None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The album behind the selection, when "show me the tracks" makes sense for it: an
|
|
||||||
/// album tile directly, or - so you can reach the rest of it - the album behind a song
|
|
||||||
/// hit. `None` for a category tile, and for a podcast episode, which is a single track
|
|
||||||
/// with nothing else to show.
|
|
||||||
pub fn selection_album(results: &Results, sel_index: usize) -> Option<String> {
|
|
||||||
let total = results.total();
|
|
||||||
if total == 0 {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let index = sel_index.min(total - 1);
|
|
||||||
let album = if let Some(hit) = results.songs.get(index) {
|
|
||||||
Some(&hit.album)
|
|
||||||
} else {
|
|
||||||
let after_songs = index - results.songs.len();
|
|
||||||
if after_songs < results.categories.len() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
results.albums.get(after_songs - results.categories.len())
|
|
||||||
}?;
|
|
||||||
(Group::of(album) != Group::Podcasts).then(|| album.id.clone())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The bare root is a genuine two-axis layout: three rows, each a differently-long row
|
|
||||||
/// of tiles. `shelf_lengths` is how long each row actually is, so a vertical move can
|
|
||||||
/// clamp the column to the row it lands on rather than guessing.
|
|
||||||
fn move_root_shelf(state: &mut UiState, dx: i32, dy: i32, shelf_lengths: &[usize; 3]) {
|
|
||||||
if dy != 0 {
|
|
||||||
state.shelf_row = (state.shelf_row as i32 + dy).clamp(0, GROUPS.len() as i32 - 1) as usize;
|
|
||||||
state.sel_index = state
|
|
||||||
.sel_index
|
|
||||||
.min(shelf_lengths[state.shelf_row].saturating_sub(1));
|
|
||||||
}
|
|
||||||
if dx != 0 {
|
|
||||||
let last = shelf_lengths[state.shelf_row].saturating_sub(1) as i32;
|
|
||||||
state.sel_index = (state.sel_index as i32 + dx).clamp(0, last.max(0)) as usize;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn move_selection(state: &mut UiState, results: &Results, dx: i32, dy: i32) {
|
|
||||||
let total = results.total();
|
|
||||||
if total == 0 {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let cols = state.cols.max(1) as i32;
|
|
||||||
let mut index = state.sel_index.min(total - 1) as i32;
|
|
||||||
index += dx;
|
|
||||||
if dy != 0 {
|
|
||||||
// Song hits are a single-column list; everything below them is a grid.
|
|
||||||
index += if (index as usize) < results.songs.len() {
|
|
||||||
dy
|
|
||||||
} else {
|
|
||||||
dy * cols
|
|
||||||
};
|
|
||||||
}
|
|
||||||
state.sel_index = index.clamp(0, total as i32 - 1) as usize;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The keys that mean the same thing from anywhere, matching the muscle memory of every
|
|
||||||
/// other vim-ish media app. Keyed on the exact shifted letter, so this intercepts only
|
|
||||||
/// these five - every other Shift+letter still reaches the search box. Search is
|
|
||||||
/// case-insensitive, so nothing searchable is lost.
|
|
||||||
fn shift_media(key: &str) -> Option<Action> {
|
|
||||||
match key.to_ascii_uppercase().as_str() {
|
|
||||||
"H" => Some(Action::Previous),
|
|
||||||
"L" => Some(Action::Next),
|
|
||||||
"K" => Some(Action::Volume(VOLUME_STEP)),
|
|
||||||
"J" => Some(Action::Volume(-VOLUME_STEP)),
|
|
||||||
"M" => Some(Action::Mute),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A plain character key's default: type it into the search box. Letters and digits
|
|
||||||
/// only - `char::is_alphanumeric` rather than `[a-z0-9]`, so the umlauts a German title
|
|
||||||
/// needs are searchable too.
|
|
||||||
fn type_into_search(state: &mut UiState, key: &str) -> bool {
|
|
||||||
let mut chars = key.chars();
|
|
||||||
match (chars.next(), chars.next()) {
|
|
||||||
(Some(c), None) if c.is_alphanumeric() => {
|
|
||||||
state.search.push(c);
|
|
||||||
state.play_view = false;
|
|
||||||
state.sel_index = 0;
|
|
||||||
true
|
|
||||||
}
|
|
||||||
_ => false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Translate one keypress: mutate `state`, and return whatever the player should be
|
|
||||||
/// asked to do.
|
|
||||||
///
|
|
||||||
/// `key` is already named by the UI layer - `"escape"`, `"left"`, `"enter"`, … - or is
|
|
||||||
/// the literal character typed. `shelf_lengths` and `sheet_tracks` are the two counts
|
|
||||||
/// that `results` does not carry but the clamping needs.
|
|
||||||
pub fn handle_key(
|
|
||||||
key: &str,
|
|
||||||
ctrl: bool,
|
|
||||||
shift: bool,
|
|
||||||
state: &mut UiState,
|
|
||||||
results: &Results,
|
|
||||||
shelf_lengths: &[usize; 3],
|
|
||||||
sheet_tracks: usize,
|
|
||||||
) -> Vec<Action> {
|
|
||||||
// Reachable from anywhere, including from the play view and with an album sheet open.
|
|
||||||
if shift && !ctrl {
|
|
||||||
if let Some(action) = shift_media(key) {
|
|
||||||
return vec![action];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if key == "space" {
|
|
||||||
// Except mid-search, where a space is punctuation the query needs, as in
|
|
||||||
// "geolino azte".
|
|
||||||
if state.browsing() && !state.search.is_empty() {
|
|
||||||
state.search.push(' ');
|
|
||||||
state.sel_index = 0;
|
|
||||||
return vec![];
|
|
||||||
}
|
|
||||||
return vec![Action::Toggle];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ctrl+hjkl moves the highlight in whichever list is on screen, the same job the
|
|
||||||
// arrows do but without leaving the home row. Ctrl+d/u are vim's half-page jump.
|
|
||||||
if ctrl {
|
|
||||||
if state.open_album.is_some() {
|
|
||||||
match key.to_ascii_lowercase().as_str() {
|
|
||||||
"j" => {
|
|
||||||
state.sheet_index = (state.sheet_index + 1).min(sheet_tracks.saturating_sub(1))
|
|
||||||
}
|
|
||||||
"k" => state.sheet_index = state.sheet_index.saturating_sub(1),
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
return vec![];
|
|
||||||
}
|
|
||||||
if state.play_view {
|
|
||||||
return vec![];
|
|
||||||
}
|
|
||||||
let lower = key.to_ascii_lowercase();
|
|
||||||
if state.is_root_shelf() {
|
|
||||||
match lower.as_str() {
|
|
||||||
"h" => move_root_shelf(state, -1, 0, shelf_lengths),
|
|
||||||
"l" => move_root_shelf(state, 1, 0, shelf_lengths),
|
|
||||||
"j" | "d" => move_root_shelf(state, 0, 1, shelf_lengths),
|
|
||||||
"k" | "u" => move_root_shelf(state, 0, -1, shelf_lengths),
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
return vec![];
|
|
||||||
}
|
|
||||||
match lower.as_str() {
|
|
||||||
"h" => move_selection(state, results, -1, 0),
|
|
||||||
"l" => move_selection(state, results, 1, 0),
|
|
||||||
"j" => move_selection(state, results, 0, 1),
|
|
||||||
"k" => move_selection(state, results, 0, -1),
|
|
||||||
"d" => move_selection(state, results, 0, PAGE_ROWS),
|
|
||||||
"u" => move_selection(state, results, 0, -PAGE_ROWS),
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
return vec![];
|
|
||||||
}
|
|
||||||
|
|
||||||
let browsing = state.browsing();
|
|
||||||
|
|
||||||
match key {
|
|
||||||
"tab" => {
|
|
||||||
let current = state.group.map_or(-1, |g| g.index() as i32);
|
|
||||||
state.group = Some(Group::from_index(
|
|
||||||
((current + 1) % GROUPS.len() as i32) as usize,
|
|
||||||
));
|
|
||||||
state.category = None;
|
|
||||||
state.sel_index = 0;
|
|
||||||
state.play_view = false;
|
|
||||||
vec![]
|
|
||||||
}
|
|
||||||
"/" => {
|
|
||||||
state.play_view = false;
|
|
||||||
vec![]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Arrows browse while browsing and drive the player while playing - the one
|
|
||||||
// pair of keys that changes meaning with the screen, because on the play view
|
|
||||||
// there is no selection for them to move.
|
|
||||||
"right" => {
|
|
||||||
if shift {
|
|
||||||
return vec![Action::Seek(SEEK_STEP)];
|
|
||||||
}
|
|
||||||
if !browsing {
|
|
||||||
return vec![Action::Next];
|
|
||||||
}
|
|
||||||
if state.is_root_shelf() {
|
|
||||||
move_root_shelf(state, 1, 0, shelf_lengths);
|
|
||||||
} else {
|
|
||||||
move_selection(state, results, 1, 0);
|
|
||||||
}
|
|
||||||
vec![]
|
|
||||||
}
|
|
||||||
"left" => {
|
|
||||||
if shift {
|
|
||||||
return vec![Action::Seek(-SEEK_STEP)];
|
|
||||||
}
|
|
||||||
if !browsing {
|
|
||||||
return vec![Action::Previous];
|
|
||||||
}
|
|
||||||
if state.is_root_shelf() {
|
|
||||||
move_root_shelf(state, -1, 0, shelf_lengths);
|
|
||||||
} else {
|
|
||||||
move_selection(state, results, -1, 0);
|
|
||||||
}
|
|
||||||
vec![]
|
|
||||||
}
|
|
||||||
"down" => {
|
|
||||||
if !browsing {
|
|
||||||
return vec![Action::Volume(-VOLUME_STEP)];
|
|
||||||
}
|
|
||||||
if state.is_root_shelf() {
|
|
||||||
move_root_shelf(state, 0, 1, shelf_lengths);
|
|
||||||
} else {
|
|
||||||
move_selection(state, results, 0, 1);
|
|
||||||
}
|
|
||||||
vec![]
|
|
||||||
}
|
|
||||||
"up" => {
|
|
||||||
if !browsing {
|
|
||||||
return vec![Action::Volume(VOLUME_STEP)];
|
|
||||||
}
|
|
||||||
if state.is_root_shelf() {
|
|
||||||
move_root_shelf(state, 0, -1, shelf_lengths);
|
|
||||||
} else {
|
|
||||||
move_selection(state, results, 0, -1);
|
|
||||||
}
|
|
||||||
vec![]
|
|
||||||
}
|
|
||||||
|
|
||||||
"?" => {
|
|
||||||
state.tracks_mode = true;
|
|
||||||
state.search.clear();
|
|
||||||
state.sel_index = 0;
|
|
||||||
state.play_view = false;
|
|
||||||
vec![]
|
|
||||||
}
|
|
||||||
"escape" => {
|
|
||||||
// Peel one layer at a time rather than dumping you back at the top.
|
|
||||||
if state.open_album.is_some() {
|
|
||||||
state.open_album = None;
|
|
||||||
} else if state.play_view {
|
|
||||||
state.play_view = false;
|
|
||||||
} else {
|
|
||||||
state.browse_back();
|
|
||||||
}
|
|
||||||
vec![]
|
|
||||||
}
|
|
||||||
"backspace" => {
|
|
||||||
if !state.search.is_empty() {
|
|
||||||
state.search.pop();
|
|
||||||
state.sel_index = 0;
|
|
||||||
}
|
|
||||||
vec![]
|
|
||||||
}
|
|
||||||
"enter" => {
|
|
||||||
if let Some(album_id) = state.open_album.clone() {
|
|
||||||
return vec![Action::Play {
|
|
||||||
album_id,
|
|
||||||
track_index: state.sheet_index,
|
|
||||||
}];
|
|
||||||
}
|
|
||||||
if browsing && state.is_root_shelf() {
|
|
||||||
let row = state.shelf_row.min(GROUPS.len() - 1);
|
|
||||||
state.group = Some(Group::from_index(row));
|
|
||||||
// A shelf row with no tiles still opens its group; there is simply no
|
|
||||||
// category to pre-select.
|
|
||||||
state.category = None;
|
|
||||||
state.sel_index = 0;
|
|
||||||
return vec![];
|
|
||||||
}
|
|
||||||
if shift {
|
|
||||||
if let Some(album_id) = selection_album(results, state.sel_index) {
|
|
||||||
state.open_album = Some(album_id);
|
|
||||||
state.sheet_index = 0;
|
|
||||||
return vec![];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
match selection_at(results, state.sel_index) {
|
|
||||||
Selection::Song {
|
|
||||||
album_id,
|
|
||||||
track_index,
|
|
||||||
} => {
|
|
||||||
vec![Action::Play {
|
|
||||||
album_id,
|
|
||||||
track_index,
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
Selection::Album(album_id) => vec![Action::Play {
|
|
||||||
album_id,
|
|
||||||
track_index: 0,
|
|
||||||
}],
|
|
||||||
Selection::Category(key) => {
|
|
||||||
state.category = Some(key);
|
|
||||||
state.sel_index = 0;
|
|
||||||
vec![]
|
|
||||||
}
|
|
||||||
Selection::None => vec![],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"f1" => vec![],
|
|
||||||
_ => {
|
|
||||||
type_into_search(state, key);
|
|
||||||
vec![]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use crate::library::{Category, Library, SongHit};
|
|
||||||
|
|
||||||
fn empty() -> Results {
|
|
||||||
Results::default()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn press(key: &str, state: &mut UiState) -> Vec<Action> {
|
|
||||||
handle_key(key, false, false, state, &empty(), &[0, 0, 0], 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn typing_a_letter_searches_and_leaves_the_play_view() {
|
|
||||||
let mut state = UiState {
|
|
||||||
play_view: true,
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
press("k", &mut state);
|
|
||||||
press("i", &mut state);
|
|
||||||
assert_eq!(state.search, "ki");
|
|
||||||
assert!(!state.play_view);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn umlauts_are_searchable_like_any_other_letter() {
|
|
||||||
let mut state = UiState::default();
|
|
||||||
press("ö", &mut state);
|
|
||||||
assert_eq!(state.search, "ö");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn space_toggles_but_types_mid_search() {
|
|
||||||
let mut state = UiState::default();
|
|
||||||
assert_eq!(press("space", &mut state), vec![Action::Toggle]);
|
|
||||||
|
|
||||||
state.search = "geolino".into();
|
|
||||||
assert!(press("space", &mut state).is_empty());
|
|
||||||
assert_eq!(state.search, "geolino ");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn space_toggles_from_the_play_view_even_with_a_query_behind_it() {
|
|
||||||
let mut state = UiState {
|
|
||||||
search: "conni".into(),
|
|
||||||
play_view: true,
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
assert_eq!(press("space", &mut state), vec![Action::Toggle]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn shift_media_works_from_every_screen() {
|
|
||||||
for play_view in [false, true] {
|
|
||||||
let mut state = UiState {
|
|
||||||
play_view,
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
let got = handle_key("H", false, true, &mut state, &empty(), &[0, 0, 0], 0);
|
|
||||||
assert_eq!(got, vec![Action::Previous]);
|
|
||||||
let got = handle_key("K", false, true, &mut state, &empty(), &[0, 0, 0], 0);
|
|
||||||
assert_eq!(got, vec![Action::Volume(VOLUME_STEP)]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn other_shifted_letters_still_reach_the_search_box() {
|
|
||||||
let mut state = UiState::default();
|
|
||||||
handle_key("C", false, true, &mut state, &empty(), &[0, 0, 0], 0);
|
|
||||||
assert_eq!(state.search, "C");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn arrows_browse_while_browsing_and_drive_the_player_while_playing() {
|
|
||||||
let mut state = UiState {
|
|
||||||
group: Some(Group::Music),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
assert!(press("right", &mut state).is_empty());
|
|
||||||
|
|
||||||
state.play_view = true;
|
|
||||||
assert_eq!(press("right", &mut state), vec![Action::Next]);
|
|
||||||
assert_eq!(press("up", &mut state), vec![Action::Volume(VOLUME_STEP)]);
|
|
||||||
assert_eq!(
|
|
||||||
press("down", &mut state),
|
|
||||||
vec![Action::Volume(-VOLUME_STEP)]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn shift_arrows_seek_from_either_screen() {
|
|
||||||
let mut state = UiState::default();
|
|
||||||
let got = handle_key("right", false, true, &mut state, &empty(), &[0, 0, 0], 0);
|
|
||||||
assert_eq!(got, vec![Action::Seek(SEEK_STEP)]);
|
|
||||||
let got = handle_key("left", false, true, &mut state, &empty(), &[0, 0, 0], 0);
|
|
||||||
assert_eq!(got, vec![Action::Seek(-SEEK_STEP)]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn the_root_shelf_is_two_axis_and_clamps_to_the_row_it_lands_on() {
|
|
||||||
let mut state = UiState::default();
|
|
||||||
let lengths = [5, 2, 0];
|
|
||||||
|
|
||||||
handle_key("right", false, false, &mut state, &empty(), &lengths, 0);
|
|
||||||
handle_key("right", false, false, &mut state, &empty(), &lengths, 0);
|
|
||||||
handle_key("right", false, false, &mut state, &empty(), &lengths, 0);
|
|
||||||
assert_eq!((state.shelf_row, state.sel_index), (0, 3));
|
|
||||||
|
|
||||||
// Row 1 only has two tiles, so the column has to come back with it.
|
|
||||||
handle_key("down", false, false, &mut state, &empty(), &lengths, 0);
|
|
||||||
assert_eq!((state.shelf_row, state.sel_index), (1, 1));
|
|
||||||
|
|
||||||
// Row 2 has none at all.
|
|
||||||
handle_key("down", false, false, &mut state, &empty(), &lengths, 0);
|
|
||||||
assert_eq!((state.shelf_row, state.sel_index), (2, 0));
|
|
||||||
|
|
||||||
// And it does not walk off the bottom.
|
|
||||||
handle_key("down", false, false, &mut state, &empty(), &lengths, 0);
|
|
||||||
assert_eq!(state.shelf_row, 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn enter_at_the_root_opens_the_focused_shelfs_group() {
|
|
||||||
let mut state = UiState {
|
|
||||||
shelf_row: 1,
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
assert!(press("enter", &mut state).is_empty());
|
|
||||||
assert_eq!(state.group, Some(Group::Audiobooks));
|
|
||||||
assert_eq!(state.sel_index, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn escape_peels_one_layer_at_a_time() {
|
|
||||||
let mut state = UiState {
|
|
||||||
group: Some(Group::Music),
|
|
||||||
category: Some("Rolf".into()),
|
|
||||||
search: "abc".into(),
|
|
||||||
tracks_mode: true,
|
|
||||||
play_view: true,
|
|
||||||
open_album: Some("x".into()),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
press("escape", &mut state);
|
|
||||||
assert!(state.open_album.is_none(), "the sheet closes first");
|
|
||||||
press("escape", &mut state);
|
|
||||||
assert!(!state.play_view, "then the play view");
|
|
||||||
press("escape", &mut state);
|
|
||||||
assert_eq!(state.search, "", "then the query");
|
|
||||||
press("escape", &mut state);
|
|
||||||
assert!(!state.tracks_mode, "then track-search mode");
|
|
||||||
press("escape", &mut state);
|
|
||||||
assert_eq!(
|
|
||||||
(state.group, state.category.clone()),
|
|
||||||
(None, None),
|
|
||||||
"then both at once"
|
|
||||||
);
|
|
||||||
assert!(!state.can_go_back());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn tab_cycles_the_three_groups_and_wraps() {
|
|
||||||
let mut state = UiState::default();
|
|
||||||
for expected in [
|
|
||||||
Group::Music,
|
|
||||||
Group::Audiobooks,
|
|
||||||
Group::Podcasts,
|
|
||||||
Group::Music,
|
|
||||||
] {
|
|
||||||
press("tab", &mut state);
|
|
||||||
assert_eq!(state.group, Some(expected));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn backspace_only_bites_when_there_is_a_query() {
|
|
||||||
let mut state = UiState {
|
|
||||||
search: "ab".into(),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
press("backspace", &mut state);
|
|
||||||
assert_eq!(state.search, "a");
|
|
||||||
press("backspace", &mut state);
|
|
||||||
press("backspace", &mut state);
|
|
||||||
assert_eq!(state.search, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn question_mark_switches_to_track_search_and_clears_the_query() {
|
|
||||||
let mut state = UiState {
|
|
||||||
search: "conni".into(),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
press("?", &mut state);
|
|
||||||
assert!(state.tracks_mode);
|
|
||||||
assert_eq!(state.search, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- selection over a real results set ----
|
|
||||||
|
|
||||||
fn results_with(songs: usize, categories: usize, albums: usize) -> Results {
|
|
||||||
let library = Library::build(
|
|
||||||
(0..albums.max(1))
|
|
||||||
.map(|i| {
|
|
||||||
serde_json::from_value(serde_json::json!({
|
|
||||||
"id": format!("al{i}"),
|
|
||||||
"section": "Musik",
|
|
||||||
"kind": "music",
|
|
||||||
"title": format!("Album {i}"),
|
|
||||||
"artist": "A",
|
|
||||||
"category": "A",
|
|
||||||
"tracks": [{ "title": "T", "duration": 10.0 }],
|
|
||||||
}))
|
|
||||||
.unwrap()
|
|
||||||
})
|
|
||||||
.collect(),
|
|
||||||
);
|
|
||||||
Results {
|
|
||||||
songs: (0..songs)
|
|
||||||
.map(|i| SongHit {
|
|
||||||
album: std::rc::Rc::clone(&library.albums[0]),
|
|
||||||
index: i,
|
|
||||||
title: format!("S{i}"),
|
|
||||||
duration: 10.0,
|
|
||||||
})
|
|
||||||
.collect(),
|
|
||||||
categories: (0..categories)
|
|
||||||
.map(|i| Category {
|
|
||||||
key: format!("C{i}"),
|
|
||||||
albums: vec![],
|
|
||||||
})
|
|
||||||
.collect(),
|
|
||||||
albums: library
|
|
||||||
.albums
|
|
||||||
.iter()
|
|
||||||
.take(albums)
|
|
||||||
.map(std::rc::Rc::clone)
|
|
||||||
.collect(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn the_selection_runs_flat_across_songs_then_categories_then_albums() {
|
|
||||||
let results = results_with(2, 2, 3);
|
|
||||||
assert_eq!(results.total(), 7);
|
|
||||||
|
|
||||||
let mut state = UiState {
|
|
||||||
group: Some(Group::Music),
|
|
||||||
cols: 3,
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
// Inside the song list a vertical move is one row, not one grid row.
|
|
||||||
handle_key("down", false, false, &mut state, &results, &[0, 0, 0], 0);
|
|
||||||
assert_eq!(state.sel_index, 1);
|
|
||||||
|
|
||||||
// Past the songs it jumps a whole grid row.
|
|
||||||
state.sel_index = 2;
|
|
||||||
handle_key("down", false, false, &mut state, &results, &[0, 0, 0], 0);
|
|
||||||
assert_eq!(state.sel_index, 5);
|
|
||||||
|
|
||||||
// And it never runs off the end.
|
|
||||||
handle_key("down", false, false, &mut state, &results, &[0, 0, 0], 0);
|
|
||||||
assert_eq!(state.sel_index, 6);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn enter_plays_a_song_hit_at_its_own_track_index() {
|
|
||||||
let results = results_with(2, 0, 0);
|
|
||||||
let mut state = UiState {
|
|
||||||
group: Some(Group::Music),
|
|
||||||
sel_index: 1,
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
let got = handle_key("enter", false, false, &mut state, &results, &[0, 0, 0], 0);
|
|
||||||
assert_eq!(
|
|
||||||
got,
|
|
||||||
vec![Action::Play {
|
|
||||||
album_id: "al0".into(),
|
|
||||||
track_index: 1
|
|
||||||
}]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn enter_on_a_category_drills_in_rather_than_playing() {
|
|
||||||
let results = results_with(0, 2, 0);
|
|
||||||
let mut state = UiState {
|
|
||||||
group: Some(Group::Music),
|
|
||||||
sel_index: 1,
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
assert!(handle_key("enter", false, false, &mut state, &results, &[0, 0, 0], 0).is_empty());
|
|
||||||
assert_eq!(state.category.as_deref(), Some("C1"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn enter_on_an_album_plays_it_from_the_top() {
|
|
||||||
let results = results_with(0, 0, 3);
|
|
||||||
let mut state = UiState {
|
|
||||||
group: Some(Group::Music),
|
|
||||||
sel_index: 2,
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
let got = handle_key("enter", false, false, &mut state, &results, &[0, 0, 0], 0);
|
|
||||||
assert_eq!(
|
|
||||||
got,
|
|
||||||
vec![Action::Play {
|
|
||||||
album_id: "al2".into(),
|
|
||||||
track_index: 0
|
|
||||||
}]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn shift_enter_opens_the_track_list_instead() {
|
|
||||||
let results = results_with(0, 0, 2);
|
|
||||||
let mut state = UiState {
|
|
||||||
group: Some(Group::Music),
|
|
||||||
sel_index: 1,
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
assert!(handle_key("enter", false, true, &mut state, &results, &[0, 0, 0], 0).is_empty());
|
|
||||||
assert_eq!(state.open_album.as_deref(), Some("al1"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn shift_enter_on_a_category_falls_through_to_drilling_in() {
|
|
||||||
let results = results_with(0, 1, 0);
|
|
||||||
let mut state = UiState {
|
|
||||||
group: Some(Group::Music),
|
|
||||||
sel_index: 0,
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
handle_key("enter", false, true, &mut state, &results, &[0, 0, 0], 0);
|
|
||||||
assert!(state.open_album.is_none());
|
|
||||||
assert_eq!(state.category.as_deref(), Some("C0"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn ctrl_jk_walks_the_open_track_list_and_clamps() {
|
|
||||||
let mut state = UiState {
|
|
||||||
open_album: Some("al0".into()),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
handle_key("j", true, false, &mut state, &empty(), &[0, 0, 0], 3);
|
|
||||||
handle_key("j", true, false, &mut state, &empty(), &[0, 0, 0], 3);
|
|
||||||
handle_key("j", true, false, &mut state, &empty(), &[0, 0, 0], 3);
|
|
||||||
assert_eq!(state.sheet_index, 2);
|
|
||||||
handle_key("k", true, false, &mut state, &empty(), &[0, 0, 0], 3);
|
|
||||||
assert_eq!(state.sheet_index, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn enter_with_the_sheet_open_plays_the_highlighted_track() {
|
|
||||||
let mut state = UiState {
|
|
||||||
open_album: Some("al0".into()),
|
|
||||||
sheet_index: 2,
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
let got = handle_key("enter", false, false, &mut state, &empty(), &[0, 0, 0], 4);
|
|
||||||
assert_eq!(
|
|
||||||
got,
|
|
||||||
vec![Action::Play {
|
|
||||||
album_id: "al0".into(),
|
|
||||||
track_index: 2
|
|
||||||
}]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,504 +0,0 @@
|
|||||||
//! Browsing and searching the library, entirely on the client.
|
|
||||||
//!
|
|
||||||
//! The whole index arrives in one response, so type-to-search has no round trip - which
|
|
||||||
//! is the point of a keyboard-first UI. Ported from `web/src/lib/search.ts`, including
|
|
||||||
//! the reason that module is shaped the way it is:
|
|
||||||
//!
|
|
||||||
//! The cost that matters is per keystroke, not per library load. A real collection is
|
|
||||||
//! 343 albums and 4969 tracks, and the naive version re-derives its search keys from
|
|
||||||
//! scratch on every one of them for every letter typed - `normalize` runs a lowercase,
|
|
||||||
//! a Unicode NFD decomposition and two filtering passes, so track search would mean
|
|
||||||
//! five thousand NFD normalizations before a single comparison. That is work whose
|
|
||||||
//! answer cannot change until the library does, so it happens once, in
|
|
||||||
//! [`Library::build`], and a keystroke becomes `str::contains` over strings that
|
|
||||||
//! already exist.
|
|
||||||
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::rc::Rc;
|
|
||||||
|
|
||||||
use unicode_normalization::UnicodeNormalization;
|
|
||||||
|
|
||||||
use crate::api::Album;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
|
||||||
pub enum Group {
|
|
||||||
Music,
|
|
||||||
Audiobooks,
|
|
||||||
Podcasts,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub const GROUPS: [Group; 3] = [Group::Music, Group::Audiobooks, Group::Podcasts];
|
|
||||||
|
|
||||||
impl Group {
|
|
||||||
/// Which of the three shelves an album belongs on. A podcast is exactly a
|
|
||||||
/// `Kinderpodcasts`-section album; everything else buckets by `kind`, so a Figuren
|
|
||||||
/// album joins whichever of music/audiobooks matches what it actually holds.
|
|
||||||
pub fn of(album: &Album) -> Self {
|
|
||||||
if album.section == "Kinderpodcasts" {
|
|
||||||
Self::Podcasts
|
|
||||||
} else if album.is_book() {
|
|
||||||
Self::Audiobooks
|
|
||||||
} else {
|
|
||||||
Self::Music
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn index(self) -> usize {
|
|
||||||
match self {
|
|
||||||
Self::Music => 0,
|
|
||||||
Self::Audiobooks => 1,
|
|
||||||
Self::Podcasts => 2,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn from_index(index: usize) -> Self {
|
|
||||||
GROUPS[index.min(2)]
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn label(self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
Self::Music => "Musik",
|
|
||||||
Self::Audiobooks => "Hörbücher",
|
|
||||||
Self::Podcasts => "Podcasts",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn icon(self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
Self::Music => "🎵",
|
|
||||||
Self::Audiobooks => "📖",
|
|
||||||
Self::Podcasts => "🎙️",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Heading over the category tiles inside this group.
|
|
||||||
pub fn category_label(self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
Self::Music => "Künstler",
|
|
||||||
Self::Audiobooks => "Figuren",
|
|
||||||
Self::Podcasts => "Sendungen",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Heading over the album grid inside this group.
|
|
||||||
pub fn section_label(self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
Self::Music => "Alben",
|
|
||||||
Self::Audiobooks => "Hörbücher",
|
|
||||||
Self::Podcasts => "Episoden",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Per-group hue in oklch degrees, shared with the generated cover art so a shelf
|
|
||||||
/// already hints at the mood its albums play into.
|
|
||||||
pub fn hue(self) -> f32 {
|
|
||||||
match self {
|
|
||||||
Self::Music => 210.0, // today's sea - the baseline hue
|
|
||||||
Self::Audiobooks => 55.0, // warm amber
|
|
||||||
Self::Podcasts => 300.0, // the one otherwise-unused family
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Diacritics and punctuation are noise when a child is hunting for letters.
|
|
||||||
pub fn normalize(value: &str) -> String {
|
|
||||||
value
|
|
||||||
.to_lowercase()
|
|
||||||
.nfd()
|
|
||||||
// Combining marks, i.e. what NFD just split off the base letters.
|
|
||||||
.filter(|c| !matches!(*c as u32, 0x0300..=0x036F))
|
|
||||||
.filter(|c| c.is_ascii_alphanumeric())
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Words of a query, normalized individually. A space-separated query like "conni rad"
|
|
||||||
/// should find "Conni lernt Rad fahren" even though "rad" is not right after "conni",
|
|
||||||
/// so requiring the whole phrase to be one contiguous substring is too strict.
|
|
||||||
fn search_words(value: &str) -> Vec<String> {
|
|
||||||
value
|
|
||||||
.split_whitespace()
|
|
||||||
.map(normalize)
|
|
||||||
.filter(|w| !w.is_empty())
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Every word has to show up somewhere in the haystack, in any order.
|
|
||||||
fn matches_words(haystack: &str, words: &[String]) -> bool {
|
|
||||||
words.iter().all(|word| haystack.contains(word.as_str()))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct AlbumEntry {
|
|
||||||
pub album: Rc<Album>,
|
|
||||||
/// `normalize(title + artist)`, as a naive `album_matches` would recompute per key.
|
|
||||||
haystack: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct TrackEntry {
|
|
||||||
pub album: Rc<Album>,
|
|
||||||
/// Index within `album.tracks`, which is what playback wants.
|
|
||||||
pub index: usize,
|
|
||||||
pub title: String,
|
|
||||||
pub duration: f64,
|
|
||||||
haystack: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct Category {
|
|
||||||
pub key: String,
|
|
||||||
pub albums: Vec<Rc<Album>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct SongHit {
|
|
||||||
pub album: Rc<Album>,
|
|
||||||
pub index: usize,
|
|
||||||
pub title: String,
|
|
||||||
pub duration: f64,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Everything a search needs, derived once per library payload.
|
|
||||||
#[derive(Default)]
|
|
||||||
pub struct Library {
|
|
||||||
pub albums: Vec<Rc<Album>>,
|
|
||||||
entries: Vec<AlbumEntry>,
|
|
||||||
by_group: [Vec<usize>; 3],
|
|
||||||
/// Each group's categories, already grouped and sorted.
|
|
||||||
categories_by_group: [Vec<Category>; 3],
|
|
||||||
/// Unlocked tracks only, album order then track order. A locked track shows as a
|
|
||||||
/// question mark wherever it appears, so it has no title to match on and is left
|
|
||||||
/// out of the index entirely rather than skipped at match time.
|
|
||||||
tracks: Vec<TrackEntry>,
|
|
||||||
tracks_by_group: [Vec<usize>; 3],
|
|
||||||
by_id: HashMap<String, Rc<Album>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Library {
|
|
||||||
pub fn build(albums: Vec<Album>) -> Self {
|
|
||||||
let mut library = Self::default();
|
|
||||||
// One map per group rather than one overall: two groups may legitimately hold
|
|
||||||
// the same category name, and merging them would put an artist's albums on
|
|
||||||
// the audiobook shelf.
|
|
||||||
let mut category_maps: [HashMap<String, Vec<Rc<Album>>>; 3] = Default::default();
|
|
||||||
|
|
||||||
for album in albums {
|
|
||||||
let album = Rc::new(album);
|
|
||||||
let group = Group::of(&album);
|
|
||||||
|
|
||||||
library.by_id.insert(album.id.clone(), Rc::clone(&album));
|
|
||||||
library.albums.push(Rc::clone(&album));
|
|
||||||
|
|
||||||
library.by_group[group.index()].push(library.entries.len());
|
|
||||||
library.entries.push(AlbumEntry {
|
|
||||||
haystack: normalize(&format!("{}{}", album.title, album.artist)),
|
|
||||||
album: Rc::clone(&album),
|
|
||||||
});
|
|
||||||
|
|
||||||
category_maps[group.index()]
|
|
||||||
.entry(album.category.clone())
|
|
||||||
.or_default()
|
|
||||||
.push(Rc::clone(&album));
|
|
||||||
|
|
||||||
for (index, track) in album.tracks.iter().enumerate() {
|
|
||||||
if track.locked {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
library.tracks_by_group[group.index()].push(library.tracks.len());
|
|
||||||
library.tracks.push(TrackEntry {
|
|
||||||
haystack: normalize(&track.title),
|
|
||||||
album: Rc::clone(&album),
|
|
||||||
index,
|
|
||||||
title: track.title.clone(),
|
|
||||||
duration: track.duration,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (slot, map) in library.categories_by_group.iter_mut().zip(category_maps) {
|
|
||||||
let mut categories: Vec<Category> = map
|
|
||||||
.into_iter()
|
|
||||||
.map(|(key, albums)| Category { key, albums })
|
|
||||||
.collect();
|
|
||||||
// `localeCompare(_, "de")` in the original. A full collator is more than
|
|
||||||
// this earns; normalizing first folds the umlauts, which is the whole of
|
|
||||||
// what German ordering asks for here.
|
|
||||||
categories.sort_by_key(|c| normalize(&c.key));
|
|
||||||
*slot = categories;
|
|
||||||
}
|
|
||||||
|
|
||||||
library
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get(&self, album_id: &str) -> Option<&Rc<Album>> {
|
|
||||||
self.by_id.get(album_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn categories(&self, group: Group) -> &[Category] {
|
|
||||||
&self.categories_by_group[group.index()]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --------------------------------------------------------------------- queries --
|
|
||||||
|
|
||||||
/// `group: None` means unrestricted - typing at the root searches every group at once.
|
|
||||||
pub struct Query<'a> {
|
|
||||||
pub search: &'a str,
|
|
||||||
pub tracks_mode: bool,
|
|
||||||
pub group: Option<Group>,
|
|
||||||
pub category: Option<&'a str>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Which of the three lists the browse view is showing.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum ListMode {
|
|
||||||
Tracks,
|
|
||||||
Albums,
|
|
||||||
Categories,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Capped, because an empty query over 900 podcast episodes is not a useful screen.
|
|
||||||
pub const MAX_SONG_HITS: usize = 40;
|
|
||||||
|
|
||||||
/// Selection indices run flat across songs, then categories, then albums, so one pair
|
|
||||||
/// of arrow keys walks the whole page.
|
|
||||||
#[derive(Default)]
|
|
||||||
pub struct Results {
|
|
||||||
pub songs: Vec<SongHit>,
|
|
||||||
pub categories: Vec<Category>,
|
|
||||||
pub albums: Vec<Rc<Album>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Results {
|
|
||||||
pub fn total(&self) -> usize {
|
|
||||||
self.songs.len() + self.categories.len() + self.albums.len()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Library {
|
|
||||||
pub fn list_mode(&self, query: &Query) -> ListMode {
|
|
||||||
if query.tracks_mode {
|
|
||||||
ListMode::Tracks
|
|
||||||
} else if !query.search.is_empty() || query.category.is_some() {
|
|
||||||
ListMode::Albums
|
|
||||||
} else {
|
|
||||||
ListMode::Categories
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn album_pool(&self, query: &Query) -> Vec<usize> {
|
|
||||||
match query.group {
|
|
||||||
None => (0..self.entries.len()).collect(),
|
|
||||||
Some(group) => self.by_group[group.index()].clone(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn category_matches(&self, query: &Query) -> Vec<Category> {
|
|
||||||
// `group: None` is the bare root screen, rendered as three shelves instead of a
|
|
||||||
// flat category list - each shelf asks again with its own group filled in.
|
|
||||||
match (self.list_mode(query), query.group) {
|
|
||||||
(ListMode::Categories, Some(group)) => self.categories(group).to_vec(),
|
|
||||||
_ => Vec::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn album_matches(&self, query: &Query) -> Vec<Rc<Album>> {
|
|
||||||
if query.tracks_mode {
|
|
||||||
return Vec::new();
|
|
||||||
}
|
|
||||||
let words = search_words(query.search);
|
|
||||||
if words.is_empty() && query.category.is_none() {
|
|
||||||
return Vec::new();
|
|
||||||
}
|
|
||||||
self.album_pool(query)
|
|
||||||
.into_iter()
|
|
||||||
.map(|i| &self.entries[i])
|
|
||||||
.filter(|entry| match query.category {
|
|
||||||
Some(category) => entry.album.category == category,
|
|
||||||
None => true,
|
|
||||||
})
|
|
||||||
.filter(|entry| words.is_empty() || matches_words(&entry.haystack, &words))
|
|
||||||
.map(|entry| Rc::clone(&entry.album))
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn song_matches(&self, query: &Query) -> Vec<SongHit> {
|
|
||||||
if !query.tracks_mode {
|
|
||||||
return Vec::new();
|
|
||||||
}
|
|
||||||
let words = search_words(query.search);
|
|
||||||
let pool: &[usize] = match query.group {
|
|
||||||
None => return self.take_song_hits(self.tracks.iter(), &words),
|
|
||||||
Some(group) => &self.tracks_by_group[group.index()],
|
|
||||||
};
|
|
||||||
self.take_song_hits(pool.iter().map(|&i| &self.tracks[i]), &words)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Stopping at the cap rather than filling every hit and slicing is what keeps an
|
|
||||||
/// empty query in tracks mode from walking all 4969 tracks to show 40 rows.
|
|
||||||
fn take_song_hits<'a>(
|
|
||||||
&self,
|
|
||||||
pool: impl Iterator<Item = &'a TrackEntry>,
|
|
||||||
words: &[String],
|
|
||||||
) -> Vec<SongHit> {
|
|
||||||
pool.filter(|track| words.is_empty() || matches_words(&track.haystack, words))
|
|
||||||
.take(MAX_SONG_HITS)
|
|
||||||
.map(|track| SongHit {
|
|
||||||
album: Rc::clone(&track.album),
|
|
||||||
index: track.index,
|
|
||||||
title: track.title.clone(),
|
|
||||||
duration: track.duration,
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn results(&self, query: &Query) -> Results {
|
|
||||||
Results {
|
|
||||||
songs: self.song_matches(query),
|
|
||||||
categories: self.category_matches(query),
|
|
||||||
albums: self.album_matches(query),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
fn album(id: &str, title: &str, artist: &str, section: &str, tracks: &[&str]) -> Album {
|
|
||||||
serde_json::from_value(serde_json::json!({
|
|
||||||
"id": id,
|
|
||||||
"section": section,
|
|
||||||
"kind": if section == "Musik" { "music" } else { "book" },
|
|
||||||
"title": title,
|
|
||||||
"artist": artist,
|
|
||||||
"category": artist,
|
|
||||||
"tracks": tracks.iter().map(|t| serde_json::json!({ "title": t, "duration": 60.0 })).collect::<Vec<_>>(),
|
|
||||||
}))
|
|
||||||
.unwrap()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn fixture() -> Library {
|
|
||||||
Library::build(vec![
|
|
||||||
album(
|
|
||||||
"a",
|
|
||||||
"Grüffelo",
|
|
||||||
"Julia Donaldson",
|
|
||||||
"Hörbücher",
|
|
||||||
&["Kapitel 1", "Kapitel 2"],
|
|
||||||
),
|
|
||||||
album("b", "Bibi Blocksberg", "Bibi", "Hörbücher", &["Hexerei"]),
|
|
||||||
album(
|
|
||||||
"c",
|
|
||||||
"Rote Lieder",
|
|
||||||
"Rolf Zuckowski",
|
|
||||||
"Musik",
|
|
||||||
&["Wie schön"],
|
|
||||||
),
|
|
||||||
album(
|
|
||||||
"d",
|
|
||||||
"Maus Folge 3",
|
|
||||||
"Sendung mit der Maus",
|
|
||||||
"Kinderpodcasts",
|
|
||||||
&["Folge 3"],
|
|
||||||
),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn normalize_folds_umlauts_and_punctuation() {
|
|
||||||
assert_eq!(normalize("Hörbücher!"), "horbucher");
|
|
||||||
assert_eq!(normalize("Grüffelo"), "gruffelo");
|
|
||||||
assert_eq!(normalize("A-B C"), "abc");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn groups_split_podcasts_out_of_books() {
|
|
||||||
let library = fixture();
|
|
||||||
assert_eq!(Group::of(library.get("d").unwrap()), Group::Podcasts);
|
|
||||||
assert_eq!(Group::of(library.get("a").unwrap()), Group::Audiobooks);
|
|
||||||
assert_eq!(Group::of(library.get("c").unwrap()), Group::Music);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn album_search_is_word_order_independent() {
|
|
||||||
let library = fixture();
|
|
||||||
let hits = library.album_matches(&Query {
|
|
||||||
search: "donaldson gruffelo",
|
|
||||||
tracks_mode: false,
|
|
||||||
group: None,
|
|
||||||
category: None,
|
|
||||||
});
|
|
||||||
assert_eq!(hits.len(), 1);
|
|
||||||
assert_eq!(hits[0].id, "a");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn an_empty_query_lists_no_albums_but_does_list_categories() {
|
|
||||||
let library = fixture();
|
|
||||||
let query = Query {
|
|
||||||
search: "",
|
|
||||||
tracks_mode: false,
|
|
||||||
group: Some(Group::Audiobooks),
|
|
||||||
category: None,
|
|
||||||
};
|
|
||||||
assert!(library.album_matches(&query).is_empty());
|
|
||||||
assert_eq!(library.category_matches(&query).len(), 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn the_root_screen_has_no_flat_category_list_of_its_own() {
|
|
||||||
let library = fixture();
|
|
||||||
let query = Query {
|
|
||||||
search: "",
|
|
||||||
tracks_mode: false,
|
|
||||||
group: None,
|
|
||||||
category: None,
|
|
||||||
};
|
|
||||||
assert!(library.category_matches(&query).is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_category_lists_its_albums_without_a_search() {
|
|
||||||
let library = fixture();
|
|
||||||
let hits = library.album_matches(&Query {
|
|
||||||
search: "",
|
|
||||||
tracks_mode: false,
|
|
||||||
group: Some(Group::Audiobooks),
|
|
||||||
category: Some("Bibi"),
|
|
||||||
});
|
|
||||||
assert_eq!(hits.len(), 1);
|
|
||||||
assert_eq!(hits[0].id, "b");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn track_search_is_capped_and_group_scoped() {
|
|
||||||
let library = fixture();
|
|
||||||
let hits = library.song_matches(&Query {
|
|
||||||
search: "kapitel",
|
|
||||||
tracks_mode: true,
|
|
||||||
group: None,
|
|
||||||
category: None,
|
|
||||||
});
|
|
||||||
assert_eq!(hits.len(), 2);
|
|
||||||
assert!(hits.len() <= MAX_SONG_HITS);
|
|
||||||
|
|
||||||
let scoped = library.song_matches(&Query {
|
|
||||||
search: "",
|
|
||||||
tracks_mode: true,
|
|
||||||
group: Some(Group::Music),
|
|
||||||
category: None,
|
|
||||||
});
|
|
||||||
assert_eq!(scoped.len(), 1);
|
|
||||||
assert_eq!(scoped[0].title, "Wie schön");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn categories_sort_by_folded_key() {
|
|
||||||
let library = fixture();
|
|
||||||
let keys: Vec<&str> = library
|
|
||||||
.categories(Group::Audiobooks)
|
|
||||||
.iter()
|
|
||||||
.map(|c| c.key.as_str())
|
|
||||||
.collect();
|
|
||||||
assert_eq!(keys, ["Bibi", "Julia Donaldson"]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,999 +0,0 @@
|
|||||||
//! A native front-end for the MusicMouse player.
|
|
||||||
//!
|
|
||||||
//! The same split the web front-end is built on: **what is playing** comes from the
|
|
||||||
//! backend over a push-only websocket, **what you are looking at** lives here. The
|
|
||||||
//! device has other front-ends - the buttons on the mouse, a figurine on the reader,
|
|
||||||
//! Home Assistant - so this follows playback rather than owning it.
|
|
||||||
//!
|
|
||||||
//! Three threads and no async runtime. The UI thread does layout and nothing else; a
|
|
||||||
//! websocket thread reads state; a cover thread fetches and downscales album art; a
|
|
||||||
//! command thread makes the HTTP calls, because a blocking POST on the UI thread is
|
|
||||||
//! exactly the stall this front-end exists to remove.
|
|
||||||
|
|
||||||
mod api;
|
|
||||||
mod covers;
|
|
||||||
mod format;
|
|
||||||
mod keymap;
|
|
||||||
mod library;
|
|
||||||
mod oklch;
|
|
||||||
mod ws;
|
|
||||||
|
|
||||||
use std::cell::RefCell;
|
|
||||||
use std::collections::VecDeque;
|
|
||||||
use std::rc::Rc;
|
|
||||||
use std::sync::mpsc::{channel, Sender};
|
|
||||||
use std::sync::{Arc, Mutex};
|
|
||||||
|
|
||||||
use slint::{ModelRc, SharedString, VecModel};
|
|
||||||
|
|
||||||
use api::{Album, Client, PlayerState};
|
|
||||||
|
|
||||||
use covers::Tier;
|
|
||||||
use keymap::{Action, UiState};
|
|
||||||
use library::{Group, Library, Query, Results, GROUPS};
|
|
||||||
|
|
||||||
slint::include_modules!();
|
|
||||||
|
|
||||||
// The application state lives on the UI thread and is reached from the event-loop
|
|
||||||
// closures the worker relay posts. See `pump_workers`.
|
|
||||||
thread_local! {
|
|
||||||
static APP: RefCell<Option<Rc<RefCell<App>>>> = const { RefCell::new(None) };
|
|
||||||
}
|
|
||||||
|
|
||||||
// --------------------------------------------------------------------- commands --
|
|
||||||
|
|
||||||
/// Anything that talks to the backend, run off the UI thread. They all answer 204 with
|
|
||||||
/// no body, so nothing comes back: the authoritative answer arrives as a pushed state
|
|
||||||
/// frame, which is also what makes the optimistic updates below safe.
|
|
||||||
enum Command {
|
|
||||||
Play {
|
|
||||||
album_id: String,
|
|
||||||
track_index: usize,
|
|
||||||
},
|
|
||||||
Resume,
|
|
||||||
Pause,
|
|
||||||
Next,
|
|
||||||
Previous,
|
|
||||||
Seek(f64),
|
|
||||||
Volume(i32),
|
|
||||||
ReloadLibrary,
|
|
||||||
}
|
|
||||||
|
|
||||||
enum FromWorker {
|
|
||||||
Library(Result<Vec<Album>, String>),
|
|
||||||
Cover(covers::Ready),
|
|
||||||
Ws(WsEvent),
|
|
||||||
}
|
|
||||||
|
|
||||||
enum WsEvent {
|
|
||||||
Connected,
|
|
||||||
Disconnected,
|
|
||||||
State(Box<PlayerState>),
|
|
||||||
Position { position: f64, duration: f64 },
|
|
||||||
LibraryChanged,
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------------ state --
|
|
||||||
|
|
||||||
struct App {
|
|
||||||
commands: Sender<Command>,
|
|
||||||
ui: UiState,
|
|
||||||
player: PlayerState,
|
|
||||||
library: Library,
|
|
||||||
covers: covers::Store,
|
|
||||||
online: bool,
|
|
||||||
loaded: bool,
|
|
||||||
error: Option<String>,
|
|
||||||
/// Changes whenever the track itself changes, so the progress bar can stop
|
|
||||||
/// animating across two unrelated positions instead of sliding between them.
|
|
||||||
track_key: String,
|
|
||||||
/// False for exactly one render: the frame a track changes on, and the frame a seek
|
|
||||||
/// lands on.
|
|
||||||
smooth: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl App {
|
|
||||||
fn send(&self, command: Command) {
|
|
||||||
let _ = self.commands.send(command);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn query(&self) -> Query<'_> {
|
|
||||||
Query {
|
|
||||||
search: &self.ui.search,
|
|
||||||
tracks_mode: self.ui.tracks_mode,
|
|
||||||
group: self.ui.group,
|
|
||||||
category: self.ui.category.as_deref(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn current_album(&self) -> Option<&Rc<Album>> {
|
|
||||||
self.player
|
|
||||||
.album_id
|
|
||||||
.as_deref()
|
|
||||||
.and_then(|id| self.library.get(id))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// How long each of the three root shelves is, for the keyboard's column clamping.
|
|
||||||
fn shelf_lengths(&self) -> [usize; 3] {
|
|
||||||
[
|
|
||||||
self.library.categories(Group::Music).len(),
|
|
||||||
self.library.categories(Group::Audiobooks).len(),
|
|
||||||
self.library.categories(Group::Podcasts).len(),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
fn apply(&mut self, actions: Vec<Action>) {
|
|
||||||
for action in actions {
|
|
||||||
match action {
|
|
||||||
Action::Play {
|
|
||||||
album_id,
|
|
||||||
track_index,
|
|
||||||
} => {
|
|
||||||
// Starting something switches to the full-screen view and closes
|
|
||||||
// the track list behind it, as the web front-end does: what you
|
|
||||||
// just chose becomes the thing on screen. ESC and `/` come back.
|
|
||||||
self.ui.play_view = true;
|
|
||||||
self.ui.open_album = None;
|
|
||||||
self.send(Command::Play {
|
|
||||||
album_id,
|
|
||||||
track_index,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Action::Toggle => {
|
|
||||||
// Optimistic: flip it here and let the pushed frame confirm. Safe
|
|
||||||
// precisely because state only ever arrives pushed, never polled,
|
|
||||||
// so there is no poll in flight to race with.
|
|
||||||
self.player.playing = !self.player.playing;
|
|
||||||
self.send(if self.player.playing {
|
|
||||||
Command::Resume
|
|
||||||
} else {
|
|
||||||
Command::Pause
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Action::Next => self.send(Command::Next),
|
|
||||||
Action::Previous => self.send(Command::Previous),
|
|
||||||
Action::Volume(delta) => {
|
|
||||||
self.player.volume = (self.player.volume + delta).clamp(0, 100);
|
|
||||||
self.send(Command::Volume(self.player.volume));
|
|
||||||
}
|
|
||||||
Action::Seek(delta) => {
|
|
||||||
let target = (self.player.position + delta).clamp(0.0, self.player.duration);
|
|
||||||
self.player.position = target;
|
|
||||||
self.smooth = false;
|
|
||||||
self.send(Command::Seek(target));
|
|
||||||
}
|
|
||||||
Action::Mute => {
|
|
||||||
// Unmuting restores a sensible level rather than whatever it was:
|
|
||||||
// the level before a mute is not recorded anywhere the device
|
|
||||||
// agrees on, and coming back at 8% reads as still broken.
|
|
||||||
let target = if self.player.volume == 0 { 60 } else { 0 };
|
|
||||||
self.player.volume = target;
|
|
||||||
self.send(Command::Volume(target));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------ presentation --
|
|
||||||
|
|
||||||
fn cover_of(store: &mut covers::Store, album: &Album, tier: Tier) -> slint::Image {
|
|
||||||
let palette = album.palette();
|
|
||||||
store
|
|
||||||
.get(covers::Request {
|
|
||||||
album_id: album.id.clone(),
|
|
||||||
tier,
|
|
||||||
has_cover: album.has_cover,
|
|
||||||
is_book: album.is_book(),
|
|
||||||
locked: album.locked,
|
|
||||||
colors: [palette[0].into(), palette[1].into(), palette[2].into()],
|
|
||||||
})
|
|
||||||
.unwrap_or_default()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// "12 Songs · 45:03 · 🧸" - the line under an album's title.
|
|
||||||
fn album_meta(album: &Album) -> String {
|
|
||||||
let unit = if album.is_book() {
|
|
||||||
"Kapitel"
|
|
||||||
} else if album.tracks.len() == 1 {
|
|
||||||
"Song"
|
|
||||||
} else {
|
|
||||||
"Songs"
|
|
||||||
};
|
|
||||||
let mut meta = format!(
|
|
||||||
"{} {unit} · {}",
|
|
||||||
album.tracks.len(),
|
|
||||||
format::clock(album.duration)
|
|
||||||
);
|
|
||||||
if album.figure.is_some() {
|
|
||||||
meta.push_str(" · 🧸");
|
|
||||||
}
|
|
||||||
meta
|
|
||||||
}
|
|
||||||
|
|
||||||
/// "7 Hörbücher" - the line under a category tile.
|
|
||||||
fn category_meta(group: Group, count: usize) -> String {
|
|
||||||
let unit = match (group, count) {
|
|
||||||
(Group::Music, 1) => "Album",
|
|
||||||
(Group::Music, _) => "Alben",
|
|
||||||
(Group::Audiobooks, _) => "Hörbücher",
|
|
||||||
(Group::Podcasts, 1) => "Episode",
|
|
||||||
(Group::Podcasts, _) => "Episoden",
|
|
||||||
};
|
|
||||||
format!("{count} {unit}")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn album_tile(store: &mut covers::Store, album: &Album, current: bool, nav: usize) -> AlbumTile {
|
|
||||||
AlbumTile {
|
|
||||||
id: album.id.as_str().into(),
|
|
||||||
title: if album.locked {
|
|
||||||
"❓ Geheimnis".into()
|
|
||||||
} else {
|
|
||||||
album.title.as_str().into()
|
|
||||||
},
|
|
||||||
// A podcast episode's artist is its show, which the title already says.
|
|
||||||
artist: if album.artist == album.title {
|
|
||||||
SharedString::new()
|
|
||||||
} else {
|
|
||||||
album.artist.as_str().into()
|
|
||||||
},
|
|
||||||
meta: album_meta(album).into(),
|
|
||||||
cover: cover_of(store, album, Tier::Thumb),
|
|
||||||
is_book: album.is_book(),
|
|
||||||
locked: album.locked,
|
|
||||||
current,
|
|
||||||
nav: nav as i32,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn category_tile(
|
|
||||||
store: &mut covers::Store,
|
|
||||||
group: Group,
|
|
||||||
category: &library::Category,
|
|
||||||
nav: usize,
|
|
||||||
) -> CategoryTile {
|
|
||||||
// Up to four covers in a 2x2 contact sheet, which is how a category reads as "a
|
|
||||||
// stack of these" without a label saying so.
|
|
||||||
let mut covers_out = [
|
|
||||||
slint::Image::default(),
|
|
||||||
slint::Image::default(),
|
|
||||||
slint::Image::default(),
|
|
||||||
slint::Image::default(),
|
|
||||||
];
|
|
||||||
for (slot, album) in covers_out.iter_mut().zip(category.albums.iter()) {
|
|
||||||
*slot = cover_of(store, album, Tier::Thumb);
|
|
||||||
}
|
|
||||||
let [c1, c2, c3, c4] = covers_out;
|
|
||||||
CategoryTile {
|
|
||||||
key: category.key.as_str().into(),
|
|
||||||
count_label: category_meta(group, category.albums.len()).into(),
|
|
||||||
cover1: c1,
|
|
||||||
cover2: c2,
|
|
||||||
cover3: c3,
|
|
||||||
cover4: c4,
|
|
||||||
cover_count: category.albums.len().min(4) as i32,
|
|
||||||
is_book: category.albums.first().is_some_and(|a| a.is_book()),
|
|
||||||
nav: nav as i32,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn track_row(
|
|
||||||
store: &mut covers::Store,
|
|
||||||
album: &Album,
|
|
||||||
index: usize,
|
|
||||||
title: &str,
|
|
||||||
duration: f64,
|
|
||||||
current: bool,
|
|
||||||
nav: usize,
|
|
||||||
) -> TrackRow {
|
|
||||||
TrackRow {
|
|
||||||
title: title.into(),
|
|
||||||
subtitle: album.line().into(),
|
|
||||||
duration: format::clock(duration).into(),
|
|
||||||
cover: cover_of(store, album, Tier::Thumb),
|
|
||||||
is_book: album.is_book(),
|
|
||||||
current,
|
|
||||||
locked: false,
|
|
||||||
album_id: album.id.as_str().into(),
|
|
||||||
index: index as i32,
|
|
||||||
nav: nav as i32,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn now_playing(app: &mut App) -> NowPlaying {
|
|
||||||
let album = app.current_album().map(Rc::clone);
|
|
||||||
let state = &app.player;
|
|
||||||
let is_book = album.as_ref().is_some_and(|a| a.is_book());
|
|
||||||
let is_podcast = album
|
|
||||||
.as_ref()
|
|
||||||
.is_some_and(|a| Group::of(a) == Group::Podcasts);
|
|
||||||
|
|
||||||
let (cover, hero) = match &album {
|
|
||||||
Some(album) => (
|
|
||||||
cover_of(&mut app.covers, album, Tier::Thumb),
|
|
||||||
cover_of(&mut app.covers, album, Tier::Hero),
|
|
||||||
),
|
|
||||||
None => (slint::Image::default(), slint::Image::default()),
|
|
||||||
};
|
|
||||||
|
|
||||||
let remaining_label = match &album {
|
|
||||||
// "Noch ... im Hörbuch" means nothing for a podcast, where an episode is the
|
|
||||||
// whole of what was chosen.
|
|
||||||
Some(album) if !is_podcast => {
|
|
||||||
let durations: Vec<f64> = album.tracks.iter().map(|t| t.duration).collect();
|
|
||||||
let left = format::remaining_in_album(
|
|
||||||
&durations,
|
|
||||||
state.track_index.max(0) as usize,
|
|
||||||
state.position,
|
|
||||||
);
|
|
||||||
let what = if is_book { "im Hörbuch" } else { "im Album" };
|
|
||||||
format!("Noch {} {what}", format::clock(left))
|
|
||||||
}
|
|
||||||
_ => String::new(),
|
|
||||||
};
|
|
||||||
|
|
||||||
NowPlaying {
|
|
||||||
title: state
|
|
||||||
.track_title
|
|
||||||
.clone()
|
|
||||||
.unwrap_or_else(|| "Wähl ein Album!".into())
|
|
||||||
.into(),
|
|
||||||
subtitle: album
|
|
||||||
.as_ref()
|
|
||||||
.map_or_else(|| "Tippen oder klicken".to_string(), |a| a.line())
|
|
||||||
.into(),
|
|
||||||
counter: format!(
|
|
||||||
"{} {} von {}",
|
|
||||||
if is_book { "Kapitel" } else { "Song" },
|
|
||||||
state.track_index + 1,
|
|
||||||
state.track_count.max(1)
|
|
||||||
)
|
|
||||||
.into(),
|
|
||||||
cover,
|
|
||||||
hero_cover: hero,
|
|
||||||
is_book,
|
|
||||||
has_album: album.is_some(),
|
|
||||||
clock_label: if state.duration > 0.0 {
|
|
||||||
format!("−{}", format::clock(state.duration - state.position)).into()
|
|
||||||
} else {
|
|
||||||
SharedString::new()
|
|
||||||
},
|
|
||||||
remaining_label: remaining_label.into(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------- rendering --
|
|
||||||
|
|
||||||
fn render(app: &mut App, window: &AppWindow) {
|
|
||||||
window.set_loading(!app.loaded);
|
|
||||||
window.set_splash_text(
|
|
||||||
match &app.error {
|
|
||||||
Some(error) => format!("Der Delfin ist nicht erreichbar …\n{error}"),
|
|
||||||
None => "Einen Moment …".to_string(),
|
|
||||||
}
|
|
||||||
.into(),
|
|
||||||
);
|
|
||||||
window.set_online(app.online);
|
|
||||||
window.set_status_label(
|
|
||||||
if !app.online {
|
|
||||||
"Keine Verbindung".into()
|
|
||||||
} else {
|
|
||||||
match &app.player.active_figure {
|
|
||||||
Some(figure) => format!("🧸 {figure}"),
|
|
||||||
None => String::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.into(),
|
|
||||||
);
|
|
||||||
|
|
||||||
// How many columns fit, from the width the window is actually giving the grid. The
|
|
||||||
// keyboard needs the same number to know what "one row down" means.
|
|
||||||
let gap = 24.0_f32;
|
|
||||||
let card = 180.0_f32;
|
|
||||||
let cols = (((window.get_grid_width() + gap) / (card + gap)).floor() as usize).max(1);
|
|
||||||
app.ui.cols = cols;
|
|
||||||
|
|
||||||
window.set_play_view(app.ui.play_view);
|
|
||||||
window.set_can_go_back(app.ui.can_go_back());
|
|
||||||
window.set_playing(app.player.playing);
|
|
||||||
window.set_volume(app.player.volume);
|
|
||||||
window.set_smooth(app.smooth);
|
|
||||||
window.set_progress(if app.player.duration > 0.0 {
|
|
||||||
(app.player.position / app.player.duration).clamp(0.0, 1.0) as f32
|
|
||||||
} else {
|
|
||||||
0.0
|
|
||||||
});
|
|
||||||
|
|
||||||
let now = now_playing(app);
|
|
||||||
let has_album = now.has_album;
|
|
||||||
window.set_now(now);
|
|
||||||
window.set_has_bar(has_album && !app.ui.play_view);
|
|
||||||
|
|
||||||
let root_screen = app.ui.is_root_shelf();
|
|
||||||
window.set_root_screen(root_screen);
|
|
||||||
window.set_shelf_row(app.ui.shelf_row as i32);
|
|
||||||
window.set_sel_index(app.ui.sel_index as i32);
|
|
||||||
|
|
||||||
if root_screen {
|
|
||||||
render_shelves(app, window);
|
|
||||||
} else {
|
|
||||||
render_results(app, window, cols);
|
|
||||||
}
|
|
||||||
|
|
||||||
render_sheet(app, window);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render_shelves(app: &mut App, window: &AppWindow) {
|
|
||||||
let mut shelves = Vec::with_capacity(GROUPS.len());
|
|
||||||
for group in GROUPS {
|
|
||||||
let categories: Vec<library::Category> = app.library.categories(group).to_vec();
|
|
||||||
let tiles: Vec<CategoryTile> = categories
|
|
||||||
.iter()
|
|
||||||
.enumerate()
|
|
||||||
.map(|(i, category)| category_tile(&mut app.covers, group, category, i))
|
|
||||||
.collect();
|
|
||||||
shelves.push(Shelf {
|
|
||||||
label: group.label().into(),
|
|
||||||
icon: group.icon().into(),
|
|
||||||
tiles: ModelRc::new(VecModel::from(tiles)),
|
|
||||||
empty_label: "Noch nichts hier".into(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
window.set_shelves(ModelRc::new(VecModel::from(shelves)));
|
|
||||||
// The shelves replace the flat result lists rather than sitting beside them.
|
|
||||||
window.set_songs(ModelRc::new(VecModel::from(Vec::<TrackRow>::new())));
|
|
||||||
window.set_grid(ModelRc::new(VecModel::from(Vec::<GridRow>::new())));
|
|
||||||
window.set_category_grid(ModelRc::new(VecModel::from(Vec::<CategoryRow>::new())));
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render_results(app: &mut App, window: &AppWindow, cols: usize) {
|
|
||||||
let results: Results = app.library.results(&app.query());
|
|
||||||
let total = results.total();
|
|
||||||
let current_album = app.player.album_id.clone();
|
|
||||||
let current_track = app.player.track_index.max(0) as usize;
|
|
||||||
|
|
||||||
// Selection indices run flat across songs, then categories, then albums.
|
|
||||||
let mut nav = 0usize;
|
|
||||||
|
|
||||||
let songs: Vec<TrackRow> = results
|
|
||||||
.songs
|
|
||||||
.iter()
|
|
||||||
.map(|hit| {
|
|
||||||
let row = track_row(
|
|
||||||
&mut app.covers,
|
|
||||||
&hit.album,
|
|
||||||
hit.index,
|
|
||||||
&hit.title,
|
|
||||||
hit.duration,
|
|
||||||
current_album.as_deref() == Some(hit.album.id.as_str())
|
|
||||||
&& current_track == hit.index,
|
|
||||||
nav,
|
|
||||||
);
|
|
||||||
nav += 1;
|
|
||||||
row
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let group = app.ui.group.unwrap_or(Group::Music);
|
|
||||||
let categories: Vec<CategoryTile> = results
|
|
||||||
.categories
|
|
||||||
.iter()
|
|
||||||
.map(|category| {
|
|
||||||
let tile = category_tile(&mut app.covers, group, category, nav);
|
|
||||||
nav += 1;
|
|
||||||
tile
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
// Chunked like the album grid, and for the same reason: the view puts each row in
|
|
||||||
// a virtualizing list rather than one ever-widening horizontal layout.
|
|
||||||
let category_grid: Vec<CategoryRow> = categories
|
|
||||||
.chunks(cols)
|
|
||||||
.map(|chunk| CategoryRow {
|
|
||||||
tiles: ModelRc::new(VecModel::from(chunk.to_vec())),
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let tiles: Vec<AlbumTile> = results
|
|
||||||
.albums
|
|
||||||
.iter()
|
|
||||||
.map(|album| {
|
|
||||||
let tile = album_tile(
|
|
||||||
&mut app.covers,
|
|
||||||
album,
|
|
||||||
current_album.as_deref() == Some(album.id.as_str()),
|
|
||||||
nav,
|
|
||||||
);
|
|
||||||
nav += 1;
|
|
||||||
tile
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
// Pre-chunked into rows: a ListView virtualizes its model, and a flat list of 343
|
|
||||||
// cards has no rows for it to skip. The web grid reaches the same place with
|
|
||||||
// `content-visibility: auto`.
|
|
||||||
let first_album_nav = songs.len() + categories.len();
|
|
||||||
let grid: Vec<GridRow> = tiles
|
|
||||||
.chunks(cols)
|
|
||||||
.map(|chunk| GridRow {
|
|
||||||
tiles: ModelRc::new(VecModel::from(chunk.to_vec())),
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
// Which grid row the selection is in, so the scroller can follow it without
|
|
||||||
// measuring anything - every row is the same height by construction.
|
|
||||||
let sel_row = app
|
|
||||||
.ui
|
|
||||||
.sel_index
|
|
||||||
.checked_sub(first_album_nav)
|
|
||||||
.map_or(0, |within| within / cols.max(1));
|
|
||||||
|
|
||||||
let count_label = if app.ui.tracks_mode {
|
|
||||||
format!("{} Titel gefunden", results.songs.len())
|
|
||||||
} else if results.albums.is_empty() {
|
|
||||||
"nichts gefunden".to_string()
|
|
||||||
} else if results.albums.len() == 1 {
|
|
||||||
"1 Album gefunden".to_string()
|
|
||||||
} else {
|
|
||||||
format!("{} Alben gefunden", results.albums.len())
|
|
||||||
};
|
|
||||||
|
|
||||||
window.set_group_label(
|
|
||||||
app.ui
|
|
||||||
.group
|
|
||||||
.map_or(SharedString::new(), |g| g.label().into()),
|
|
||||||
);
|
|
||||||
window.set_category_heading(group.category_label().into());
|
|
||||||
window.set_album_heading(
|
|
||||||
match app.ui.category.as_deref() {
|
|
||||||
Some(category) => category.to_string(),
|
|
||||||
None => group.section_label().to_string(),
|
|
||||||
}
|
|
||||||
.into(),
|
|
||||||
);
|
|
||||||
window.set_show_search(!app.ui.search.is_empty() || app.ui.tracks_mode);
|
|
||||||
window.set_search_text(
|
|
||||||
if app.ui.search.is_empty() {
|
|
||||||
"tippe …".to_string()
|
|
||||||
} else {
|
|
||||||
format!("\"{}\"", app.ui.search)
|
|
||||||
}
|
|
||||||
.into(),
|
|
||||||
);
|
|
||||||
window.set_mode_label(if app.ui.tracks_mode {
|
|
||||||
"♪ Titel".into()
|
|
||||||
} else {
|
|
||||||
"🔎 Alben".into()
|
|
||||||
});
|
|
||||||
window.set_count_label(count_label.into());
|
|
||||||
window.set_no_results(total == 0);
|
|
||||||
window.set_sel_row(sel_row as i32);
|
|
||||||
|
|
||||||
window.set_songs(ModelRc::new(VecModel::from(songs)));
|
|
||||||
window.set_category_grid(ModelRc::new(VecModel::from(category_grid)));
|
|
||||||
window.set_grid(ModelRc::new(VecModel::from(grid)));
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render_sheet(app: &mut App, window: &AppWindow) {
|
|
||||||
let Some(album) = app
|
|
||||||
.ui
|
|
||||||
.open_album
|
|
||||||
.clone()
|
|
||||||
.and_then(|id| app.library.get(&id).map(Rc::clone))
|
|
||||||
else {
|
|
||||||
window.set_show_sheet(false);
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
let playing_here = app.player.album_id.as_deref() == Some(album.id.as_str());
|
|
||||||
let current = app.player.track_index.max(0) as usize;
|
|
||||||
let rows: Vec<TrackRow> = album
|
|
||||||
.tracks
|
|
||||||
.iter()
|
|
||||||
.enumerate()
|
|
||||||
.map(|(i, track)| {
|
|
||||||
let title = if track.locked {
|
|
||||||
"❓".to_string()
|
|
||||||
} else {
|
|
||||||
track.title.clone()
|
|
||||||
};
|
|
||||||
track_row(
|
|
||||||
&mut app.covers,
|
|
||||||
&album,
|
|
||||||
i,
|
|
||||||
&title,
|
|
||||||
track.duration,
|
|
||||||
playing_here && current == i,
|
|
||||||
i,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let kind = if album.is_book() {
|
|
||||||
"📖 Hörbuch"
|
|
||||||
} else {
|
|
||||||
"🎵 Album"
|
|
||||||
};
|
|
||||||
let mut meta = format!("{kind} · {}", album_meta(&album));
|
|
||||||
if let Some(figure) = &album.figure {
|
|
||||||
meta.push_str(&format!(" · {figure}"));
|
|
||||||
}
|
|
||||||
|
|
||||||
window.set_show_sheet(true);
|
|
||||||
window.set_sheet_title(album.title.as_str().into());
|
|
||||||
window.set_sheet_subtitle(album.artist.as_str().into());
|
|
||||||
window.set_sheet_meta(meta.into());
|
|
||||||
window.set_sheet_cover(cover_of(&mut app.covers, &album, Tier::Hero));
|
|
||||||
window.set_sheet_is_book(album.is_book());
|
|
||||||
window.set_sheet_index(app.ui.sheet_index.min(album.tracks.len().saturating_sub(1)) as i32);
|
|
||||||
window.set_sheet_tracks(ModelRc::new(VecModel::from(rows)));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ----------------------------------------------------------------------- wiring --
|
|
||||||
|
|
||||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
||||||
let base = std::env::args()
|
|
||||||
.skip_while(|a| a != "--server")
|
|
||||||
.nth(1)
|
|
||||||
.unwrap_or_else(|| api::DEFAULT_BASE_URL.to_string());
|
|
||||||
println!("musicmouse-slint → {base}");
|
|
||||||
|
|
||||||
// Nunito is the face the design is drawn in, vendored as a TTF in `fonts/` because
|
|
||||||
// Slint cannot read the woff2 the web front-end self-hosts. Slint takes one file as
|
|
||||||
// the primary font through `SLINT_DEFAULT_FONT`; the dev shell and the device's
|
|
||||||
// launcher both set it, and this is the fallback for running the binary directly.
|
|
||||||
// None of them finding it just means the system sans - a missing font should change
|
|
||||||
// how this looks, not whether it runs.
|
|
||||||
if std::env::var_os("SLINT_DEFAULT_FONT").is_none() {
|
|
||||||
let beside_exe = std::env::current_exe()
|
|
||||||
.ok()
|
|
||||||
// target/release/<bin> -> the crate root
|
|
||||||
.and_then(|exe| Some(exe.parent()?.parent()?.parent()?.join("fonts/Nunito.ttf")));
|
|
||||||
for candidate in [
|
|
||||||
Some(std::path::PathBuf::from("fonts/Nunito.ttf")),
|
|
||||||
beside_exe,
|
|
||||||
]
|
|
||||||
.into_iter()
|
|
||||||
.flatten()
|
|
||||||
{
|
|
||||||
if candidate.exists() {
|
|
||||||
std::env::set_var("SLINT_DEFAULT_FONT", candidate);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let window = AppWindow::new()?;
|
|
||||||
let client = Client::new(&base);
|
|
||||||
|
|
||||||
let (to_ui, from_workers) = channel::<FromWorker>();
|
|
||||||
let (cover_jobs, cover_rx) = channel::<covers::Job>();
|
|
||||||
let (commands, command_rx) = channel::<Command>();
|
|
||||||
|
|
||||||
// Covers: fetch, downscale, cache to disk. Never on the UI thread.
|
|
||||||
{
|
|
||||||
let to_ui = to_ui.clone();
|
|
||||||
covers::spawn(client.clone(), cover_rx, move |ready| {
|
|
||||||
let _ = to_ui.send(FromWorker::Cover(ready));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Commands, and the library fetch that seeds everything.
|
|
||||||
{
|
|
||||||
let client = client.clone();
|
|
||||||
let to_ui = to_ui.clone();
|
|
||||||
std::thread::Builder::new()
|
|
||||||
.name("commands".into())
|
|
||||||
.spawn(move || {
|
|
||||||
let _ = to_ui.send(FromWorker::Library(client.library()));
|
|
||||||
for command in command_rx {
|
|
||||||
let result = match command {
|
|
||||||
Command::Play {
|
|
||||||
album_id,
|
|
||||||
track_index,
|
|
||||||
} => client.play(&album_id, track_index as i32),
|
|
||||||
Command::Resume => client.resume(),
|
|
||||||
Command::Pause => client.pause(),
|
|
||||||
Command::Next => client.next(),
|
|
||||||
Command::Previous => client.previous(),
|
|
||||||
Command::Seek(position) => client.seek(position),
|
|
||||||
Command::Volume(percent) => client.volume(percent),
|
|
||||||
Command::ReloadLibrary => {
|
|
||||||
let _ = to_ui.send(FromWorker::Library(client.library()));
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if let Err(error) = result {
|
|
||||||
// A failed command is not fatal: the next pushed frame says
|
|
||||||
// what actually happened, which is the only truth anyway.
|
|
||||||
eprintln!("{error}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})?;
|
|
||||||
}
|
|
||||||
|
|
||||||
// State.
|
|
||||||
{
|
|
||||||
let to_ui = to_ui.clone();
|
|
||||||
let (ws_tx, ws_rx) = channel();
|
|
||||||
ws::spawn(client.clone(), ws_tx);
|
|
||||||
std::thread::Builder::new()
|
|
||||||
.name("ws-relay".into())
|
|
||||||
.spawn(move || {
|
|
||||||
for update in ws_rx {
|
|
||||||
let event = match update {
|
|
||||||
ws::Update::Connected => WsEvent::Connected,
|
|
||||||
ws::Update::Disconnected => WsEvent::Disconnected,
|
|
||||||
ws::Update::State(state) => WsEvent::State(state),
|
|
||||||
ws::Update::Position { position, duration } => {
|
|
||||||
WsEvent::Position { position, duration }
|
|
||||||
}
|
|
||||||
ws::Update::LibraryChanged => WsEvent::LibraryChanged,
|
|
||||||
};
|
|
||||||
if to_ui.send(FromWorker::Ws(event)).is_err() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})?;
|
|
||||||
}
|
|
||||||
|
|
||||||
let app = Rc::new(RefCell::new(App {
|
|
||||||
commands,
|
|
||||||
ui: UiState::default(),
|
|
||||||
player: PlayerState::default(),
|
|
||||||
library: Library::default(),
|
|
||||||
covers: covers::Store::new(cover_jobs),
|
|
||||||
online: false,
|
|
||||||
loaded: false,
|
|
||||||
error: None,
|
|
||||||
track_key: String::new(),
|
|
||||||
smooth: true,
|
|
||||||
}));
|
|
||||||
|
|
||||||
install_callbacks(&app, &window);
|
|
||||||
pump_workers(&app, &window, from_workers);
|
|
||||||
|
|
||||||
render(&mut app.borrow_mut(), &window);
|
|
||||||
window.run()?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Bridge the worker channels onto the Slint event loop.
|
|
||||||
///
|
|
||||||
/// `invoke_from_event_loop` takes a `Send` closure, and the application state is
|
|
||||||
/// `Rc<RefCell<..>>` that never leaves the UI thread - so the state stays in a
|
|
||||||
/// thread-local here and only the *messages* cross, which are `Send` by construction.
|
|
||||||
///
|
|
||||||
/// The queue is drained rather than handled one at a time, and that is not incidental:
|
|
||||||
/// a cold start delivers a few hundred covers in a burst, and one re-render per cover
|
|
||||||
/// would rebuild every model a few hundred times. Coalescing collapses each burst into
|
|
||||||
/// a single render.
|
|
||||||
fn pump_workers(
|
|
||||||
app: &Rc<RefCell<App>>,
|
|
||||||
window: &AppWindow,
|
|
||||||
from_workers: std::sync::mpsc::Receiver<FromWorker>,
|
|
||||||
) {
|
|
||||||
APP.with(|slot| *slot.borrow_mut() = Some(Rc::clone(app)));
|
|
||||||
|
|
||||||
let queue: Arc<Mutex<VecDeque<FromWorker>>> = Arc::default();
|
|
||||||
let weak = window.as_weak();
|
|
||||||
let producer = Arc::clone(&queue);
|
|
||||||
|
|
||||||
std::thread::Builder::new()
|
|
||||||
.name("relay".into())
|
|
||||||
.spawn(move || {
|
|
||||||
for message in from_workers {
|
|
||||||
match producer.lock() {
|
|
||||||
Ok(mut queue) => queue.push_back(message),
|
|
||||||
// Only reachable if a UI-thread panic poisoned the lock, in which
|
|
||||||
// case there is no UI left to deliver to.
|
|
||||||
Err(_) => return,
|
|
||||||
}
|
|
||||||
let drain = Arc::clone(&producer);
|
|
||||||
let posted = weak.upgrade_in_event_loop(move |window| {
|
|
||||||
let messages: Vec<FromWorker> = match drain.lock() {
|
|
||||||
Ok(mut queue) => queue.drain(..).collect(),
|
|
||||||
Err(_) => return,
|
|
||||||
};
|
|
||||||
if messages.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
APP.with(|slot| {
|
|
||||||
let Some(app) = slot.borrow().clone() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
{
|
|
||||||
let mut app = app.borrow_mut();
|
|
||||||
for message in messages {
|
|
||||||
handle_worker_message(&mut app, message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
render(&mut app.borrow_mut(), &window);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
if posted.is_err() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.expect("spawning the worker relay");
|
|
||||||
}
|
|
||||||
|
|
||||||
fn handle_worker_message(app: &mut App, message: FromWorker) {
|
|
||||||
match message {
|
|
||||||
FromWorker::Library(Ok(albums)) => {
|
|
||||||
app.library = Library::build(albums);
|
|
||||||
app.loaded = true;
|
|
||||||
app.error = None;
|
|
||||||
}
|
|
||||||
FromWorker::Library(Err(error)) => {
|
|
||||||
app.error = Some(error);
|
|
||||||
}
|
|
||||||
FromWorker::Cover(ready) => app.covers.insert(ready),
|
|
||||||
FromWorker::Ws(WsEvent::Connected) => app.online = true,
|
|
||||||
FromWorker::Ws(WsEvent::Disconnected) => app.online = false,
|
|
||||||
FromWorker::Ws(WsEvent::State(state)) => {
|
|
||||||
let key = format!("{:?}:{}", state.album_id, state.track_index);
|
|
||||||
// A new track means the bar must jump, not slide: animating across two
|
|
||||||
// unrelated positions is what makes a seek look like it drifts backwards.
|
|
||||||
app.smooth = key == app.track_key;
|
|
||||||
app.track_key = key;
|
|
||||||
app.player = *state;
|
|
||||||
}
|
|
||||||
FromWorker::Ws(WsEvent::Position { position, duration }) => {
|
|
||||||
app.player.position = position;
|
|
||||||
if duration > 0.0 {
|
|
||||||
app.player.duration = duration;
|
|
||||||
}
|
|
||||||
app.smooth = true;
|
|
||||||
}
|
|
||||||
FromWorker::Ws(WsEvent::LibraryChanged) => {
|
|
||||||
// A rescan is the one moment the backend rewrites the art behind an
|
|
||||||
// unchanged cover URL, so the thumbnails go with it.
|
|
||||||
app.covers.clear();
|
|
||||||
app.send(Command::ReloadLibrary);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn install_callbacks(app: &Rc<RefCell<App>>, window: &AppWindow) {
|
|
||||||
// Every callback does the same three things: mutate state, maybe queue a command,
|
|
||||||
// re-render. `bind!` is that shape written once, so no individual handler has to
|
|
||||||
// remember the third.
|
|
||||||
macro_rules! bind {
|
|
||||||
($setter:ident, |$a:ident $(, $arg:ident : $ty:ty)*| $body:block) => {
|
|
||||||
let weak = window.as_weak();
|
|
||||||
let handle = Rc::clone(app);
|
|
||||||
window.$setter(move |$($arg: $ty),*| {
|
|
||||||
let Some(window) = weak.upgrade() else { return };
|
|
||||||
{
|
|
||||||
let mut $a = handle.borrow_mut();
|
|
||||||
$body
|
|
||||||
}
|
|
||||||
render(&mut handle.borrow_mut(), &window);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
bind!(on_key, |a, key: SharedString, ctrl: bool, shift: bool| {
|
|
||||||
let results = a.library.results(&a.query());
|
|
||||||
let shelves = a.shelf_lengths();
|
|
||||||
let sheet_tracks =
|
|
||||||
a.ui.open_album
|
|
||||||
.as_deref()
|
|
||||||
.and_then(|id| a.library.get(id))
|
|
||||||
.map_or(0, |album| album.tracks.len());
|
|
||||||
let mut ui = a.ui.clone();
|
|
||||||
let actions = keymap::handle_key(
|
|
||||||
key.as_str(),
|
|
||||||
ctrl,
|
|
||||||
shift,
|
|
||||||
&mut ui,
|
|
||||||
&results,
|
|
||||||
&shelves,
|
|
||||||
sheet_tracks,
|
|
||||||
);
|
|
||||||
a.ui = ui;
|
|
||||||
a.apply(actions);
|
|
||||||
});
|
|
||||||
|
|
||||||
bind!(on_toggle, |a| {
|
|
||||||
a.apply(vec![Action::Toggle]);
|
|
||||||
});
|
|
||||||
bind!(on_next, |a| {
|
|
||||||
a.apply(vec![Action::Next]);
|
|
||||||
});
|
|
||||||
bind!(on_previous, |a| {
|
|
||||||
a.apply(vec![Action::Previous]);
|
|
||||||
});
|
|
||||||
bind!(on_mute, |a| {
|
|
||||||
a.apply(vec![Action::Mute]);
|
|
||||||
});
|
|
||||||
|
|
||||||
bind!(on_seek, |a, fraction: f32| {
|
|
||||||
let target = (fraction as f64 * a.player.duration).clamp(0.0, a.player.duration);
|
|
||||||
a.player.position = target;
|
|
||||||
a.smooth = false;
|
|
||||||
a.send(Command::Seek(target));
|
|
||||||
});
|
|
||||||
|
|
||||||
bind!(on_set_volume, |a, percent: i32| {
|
|
||||||
a.player.volume = percent.clamp(0, 100);
|
|
||||||
a.send(Command::Volume(a.player.volume));
|
|
||||||
});
|
|
||||||
|
|
||||||
bind!(on_go_back, |a| {
|
|
||||||
if a.ui.open_album.is_some() {
|
|
||||||
a.ui.open_album = None;
|
|
||||||
} else if a.ui.play_view {
|
|
||||||
a.ui.play_view = false;
|
|
||||||
} else {
|
|
||||||
a.ui.browse_back();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
bind!(on_enter_group, |a, shelf: i32| {
|
|
||||||
a.ui.group = Some(Group::from_index(shelf.max(0) as usize));
|
|
||||||
a.ui.category = None;
|
|
||||||
a.ui.sel_index = 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
bind!(on_enter_category, |a, shelf: i32, key: SharedString| {
|
|
||||||
a.ui.group = Some(Group::from_index(shelf.max(0) as usize));
|
|
||||||
a.ui.category = Some(key.to_string());
|
|
||||||
a.ui.sel_index = 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
bind!(on_open_category, |a, key: SharedString| {
|
|
||||||
a.ui.category = Some(key.to_string());
|
|
||||||
a.ui.sel_index = 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
bind!(on_open_album, |a, id: SharedString| {
|
|
||||||
// A podcast episode is a single track with nothing to choose between, so the
|
|
||||||
// cover plays it rather than opening a list of one.
|
|
||||||
match a.library.get(id.as_str()).map(Rc::clone) {
|
|
||||||
Some(album) if Group::of(&album) == Group::Podcasts => {
|
|
||||||
a.apply(vec![Action::Play {
|
|
||||||
album_id: album.id.clone(),
|
|
||||||
track_index: 0,
|
|
||||||
}]);
|
|
||||||
}
|
|
||||||
Some(album) => {
|
|
||||||
a.ui.open_album = Some(album.id.clone());
|
|
||||||
a.ui.sheet_index = 0;
|
|
||||||
}
|
|
||||||
None => {}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
bind!(on_play_album, |a, id: SharedString| {
|
|
||||||
a.apply(vec![Action::Play {
|
|
||||||
album_id: id.to_string(),
|
|
||||||
track_index: 0,
|
|
||||||
}]);
|
|
||||||
});
|
|
||||||
|
|
||||||
bind!(on_play_song, |a, id: SharedString, index: i32| {
|
|
||||||
a.apply(vec![Action::Play {
|
|
||||||
album_id: id.to_string(),
|
|
||||||
track_index: index.max(0) as usize,
|
|
||||||
}]);
|
|
||||||
});
|
|
||||||
|
|
||||||
bind!(on_open_play_view, |a| {
|
|
||||||
a.ui.play_view = true;
|
|
||||||
});
|
|
||||||
bind!(on_close_sheet, |a| {
|
|
||||||
a.ui.open_album = None;
|
|
||||||
});
|
|
||||||
|
|
||||||
bind!(on_play_sheet_track, |a, index: i32| {
|
|
||||||
if let Some(album_id) = a.ui.open_album.clone() {
|
|
||||||
a.apply(vec![Action::Play {
|
|
||||||
album_id,
|
|
||||||
track_index: index.max(0) as usize,
|
|
||||||
}]);
|
|
||||||
a.ui.open_album = None;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user