Compare commits
55 Commits
d5a4bbb7e2
...
deploy/mus
| Author | SHA1 | Date | |
|---|---|---|---|
| 683cda35cb | |||
| 2db4368fc9 | |||
| 9fd106b757 | |||
| 794126bfdb | |||
| ec9d617ad8 | |||
| 6ccb3e458f | |||
| 83d16e0058 | |||
| ebab03b700 | |||
| 3fea56d4e1 | |||
| e1c10f5408 | |||
| 0ef5a04cb9 | |||
| 3cdad1e714 | |||
| 2514ecbc35 | |||
| cc3db44c4e | |||
| a7fb56c9fe | |||
| fa3d92189c | |||
| dd14f5d901 | |||
| 274085b92e | |||
| f3337975c2 | |||
| e6f6b15cc7 | |||
| f3f7082fb8 | |||
| 36e93be79b | |||
| b6342cc117 | |||
| fd6283718c | |||
| 75b0eed080 | |||
| a69db98241 | |||
| e243862769 | |||
| b8f9f6d537 | |||
| 57ead93497 | |||
| 7f5e2733c2 | |||
| f7a5d24d8d | |||
| a5210fead2 | |||
| 498243af46 | |||
| f97de193d8 | |||
| 7e5fd5ab75 | |||
| fbf03a9847 | |||
| df89acd9a8 | |||
| 829ea89386 | |||
| a3b2c0ce2f | |||
| ba7f082f48 | |||
| 69119bb72a | |||
| 747c390303 | |||
| 57afc32f4a | |||
| 2e0e6ad199 | |||
| 8aed3b022b | |||
| a8ed350aec | |||
| edb6e5e027 | |||
| d44c24ec97 | |||
| 55bcc9448c | |||
| 1cb3387fbe | |||
| 7aa7fe4693 | |||
| bd8925a278 | |||
|
|
b2c060fcc9 | ||
|
|
11bf0505fb | ||
|
|
ef038a29a4 |
8
.gitignore
vendored
@@ -1,6 +1,14 @@
|
||||
generated_3d
|
||||
venv
|
||||
.venv
|
||||
build
|
||||
*.egg-info
|
||||
*.FCStd1
|
||||
*.blend1
|
||||
__pycache__
|
||||
.ipynb_checkpoints
|
||||
.pytest_cache
|
||||
.mypy_cache
|
||||
.ruff_cache
|
||||
.envrc
|
||||
.direnv
|
||||
|
||||
10
.vscode/settings.json
vendored
Normal file
@@ -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
@@ -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>
|
||||
BIN
claude-design/Dolphin Beats Music Player.zip
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
@@ -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
|
After Width: | Height: | Size: 697 KiB |
BIN
claude-design/dolphin-remote.png
Normal file
|
After Width: | Height: | Size: 788 KiB |
BIN
claude-design/pasted-1787085770763-0.png
Normal file
|
After Width: | Height: | Size: 697 KiB |
BIN
claude-design/pasted-1787122665953-0.png
Normal file
|
After Width: | Height: | Size: 788 KiB |
BIN
claude-design/pasted-1787124615900-0.png
Normal file
|
After Width: | Height: | Size: 788 KiB |
1911
claude-design/support.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);
|
||||
})();
|
||||
129
docs/REPO_OVERVIEW.md
Normal file
@@ -0,0 +1,129 @@
|
||||
# MusicMouse — repo overview
|
||||
|
||||
Orientation doc for AI agents (or humans) working on this repo for the first time.
|
||||
|
||||
## What this is
|
||||
|
||||
MusicMouse is a DIY, Toniebox-style physical music player for kids, shaped like a mouse
|
||||
and living on a shelf. Small 3D-printed animal figurines (fox, owl, dog, elephant,
|
||||
squirrel, crocodile, rabbit, snowman, puppy — see `hardware/3dprints/figures/`) each
|
||||
carry an RFID tag. Placing a figurine on the mouse starts that figure's playlist. The
|
||||
mouse also has a rotary encoder and capacitive touch areas (ears/feet) for
|
||||
volume/skip control, addressable RGBW LED rings with animated effects, and MQTT/Home
|
||||
Assistant integration.
|
||||
|
||||
## Repo layout
|
||||
|
||||
| Path | What it is |
|
||||
|---|---|
|
||||
| `python-backend/` | Python host application — the main runtime. Has its own `README.md` with the architecture; start there for backend work. |
|
||||
| `web/` | React + TypeScript front-end (Vite). Browse the whole library and play any of it from a browser. Built output is served by `python-backend` itself. |
|
||||
| `esp-firmware/` | ESP32 firmware (C++, Arduino framework via PlatformIO). Reads the RFID reader and buttons, drives the LED strips, talks to `python-backend` over serial. |
|
||||
| `hardware/` | 3D-print models for the figurines and enclosure (FreeCAD/Blender/OBJ/STL), a Fritzing electronics sketch, datasheets, and `pinout.md` (RFID reader + button-board wiring). |
|
||||
| `claude-design/` | The interaction and visual 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. |
|
||||
|
||||
## How the pieces talk to each other
|
||||
|
||||
- **ESP32 firmware ↔ `python-backend`**: a length-prefixed binary protocol over serial.
|
||||
Frames are `uint32 magic | uint8 type | uint16 size | payload`, little-endian, with
|
||||
firmware log text interleaved on the same link. The Python side lives in
|
||||
`python-backend/musicmouse/devices/wire.py`, the firmware side in
|
||||
`esp-firmware/src/Messages.h`.
|
||||
|
||||
The contract is hand-duplicated in two languages. `tests/test_wire.py` parses
|
||||
`Messages.h` and fails if the message ids drift, and `tests/test_effects.py` pins the
|
||||
exact bytes of every effect payload — so a firmware change that breaks the host now
|
||||
breaks a test instead of just the LEDs.
|
||||
|
||||
- **`python-backend` ↔ `web/`**: JSON over HTTP for the library and for commands, plus a
|
||||
push-only websocket at `/api/ws` for state. Commands emit exactly the same *intents*
|
||||
the physical buttons emit, so the web UI has no privileged path — and no way to get
|
||||
out of step with a figure someone puts on the reader. See
|
||||
`python-backend/README.md` for the endpoint list.
|
||||
|
||||
- **`python-backend` ↔ Home Assistant**: MQTT only. The backend publishes three
|
||||
discoverable lights, a player sensor, a volume number, transport buttons, device
|
||||
triggers for every button and touch area, and a tag scanner for the RFID reader. It
|
||||
does *not* call Home Assistant services directly any more; behaviour like "the left
|
||||
ear turns the room light pink" is an HA automation. See `python-backend/README.md`
|
||||
for the trigger topics and the old colour mapping.
|
||||
|
||||
## Running it
|
||||
|
||||
```sh
|
||||
python -m musicmouse --config /media/musicmouse/config.yml
|
||||
```
|
||||
|
||||
On a machine with no mouse attached — real audio and a real web UI, no serial port:
|
||||
|
||||
```sh
|
||||
python -m musicmouse --config ./config.yml --no-hardware
|
||||
```
|
||||
|
||||
`general.serial_port: simulate` does the same thing from the config, and
|
||||
`general.alsa_device: simulate` swaps in a silent player. Both warn at startup, and
|
||||
both keys are required - omitting one is an error rather than an implicit simulation.
|
||||
|
||||
Or with no hardware *and* no audio:
|
||||
|
||||
```sh
|
||||
python -m musicmouse --config ./config.yml --simulate
|
||||
```
|
||||
|
||||
The simulator runs the whole app against a fake serial link and a fake player, either
|
||||
interactively or from a scenario file. `python-backend/musicmouse.service` is the
|
||||
systemd unit for the device.
|
||||
|
||||
For front-end work, run the backend (either way above) and then:
|
||||
|
||||
```sh
|
||||
cd web && npm install && npm run dev # http://localhost:5173, /api proxied to :8080
|
||||
```
|
||||
|
||||
`npm run build` writes `web/dist`, which `general.web.static_dir` points at in
|
||||
production so one process serves both the UI and the API.
|
||||
|
||||
## Config
|
||||
|
||||
`python-backend/config.yml.example` documents the schema. In short:
|
||||
|
||||
- `general.{library.*, serial_port, baudrate, reconnect_interval, alsa_device,
|
||||
min_volume, max_volume, initial_volume, volume_increment, button_leds_brightness,
|
||||
audio_extensions, mqtt.*, web.*}`
|
||||
- `figures.<name>.{id, colors}` — `id` is a 5-byte hex RFID tag, unique per figure;
|
||||
`colors` is exactly four (`primary, secondary, bg, accent`), each `"#rrggbb"` or
|
||||
`"wNN"`.
|
||||
|
||||
`general.library.root` is the one path to the music. The shelves under it are fixed
|
||||
names, not settings, because each has quirks the scanner knows about:
|
||||
|
||||
```
|
||||
<root>/Figuren/<figure name>/ one folder per figurine
|
||||
<root>/Musik/<Artist> - <Album>/ albums, grouped by artist
|
||||
<root>/Hörbücher/<Artist> - <Album>/ audiobooks, grouped by character
|
||||
<root>/Kinderpodcasts/<Show>/ shows, newest episode first
|
||||
```
|
||||
|
||||
Config is validated with pydantic: unknown keys are errors, and every problem is
|
||||
reported at once.
|
||||
|
||||
## Notes for agents
|
||||
|
||||
- The backend has `pytest`, `ruff` and `mypy --strict` configured in
|
||||
`python-backend/pyproject.toml`, and no CI. Run all three before proposing changes.
|
||||
- Prefer adding a scenario in `python-backend/scenarios/` over a hand-rolled test when
|
||||
the behaviour is end-to-end — those files are executed by the test suite.
|
||||
- `python-backend/notebooks/` is university course material on chord recognition, not
|
||||
part of the app.
|
||||
- The firmware has no automated tests beyond a PlatformIO `native` env for LED effects.
|
||||
- The front-end has `vitest` over the pure modules (`web/src/lib/`) and `tsc --noEmit`;
|
||||
there is no component-level test harness. `npm run test` and `npx tsc --noEmit` are
|
||||
the two checks.
|
||||
- The library index is cached under `general.library.cache`. It is keyed by file mtime
|
||||
and size, so **a change to how the scanner derives a title, artist or series is
|
||||
invisible until the cache is invalidated** — bump `_INDEX_VERSION` in
|
||||
`musicmouse/library/cache.py` when you touch that logic.
|
||||
- Track analysis (librosa) runs in a pool of worker *processes* - see
|
||||
`musicmouse/library/workers.py`. Anything an `Analyzer` returns therefore has to be
|
||||
picklable, and an analyzer that records state in its own instance (a test double
|
||||
counting calls) only behaves as written with `analysis_workers=1`.
|
||||
@@ -36,6 +36,8 @@ public:
|
||||
|
||||
beginIdx_ = constrain(static_cast<int>(cfg.begin * NUM_LEDS + 0.5f), 0, NUM_LEDS - 1);
|
||||
endIdx_ = constrain(static_cast<int>(cfg.end * NUM_LEDS + 0.5f), 0, NUM_LEDS - 1);
|
||||
while (endIdx_ < beginIdx_)
|
||||
endIdx_ += NUM_LEDS;
|
||||
}
|
||||
|
||||
bool finished() const { return finished_; }
|
||||
@@ -45,22 +47,25 @@ public:
|
||||
if (finished_)
|
||||
return 1000000;
|
||||
|
||||
const float progress = static_cast<float>(DELAY_MS * calls_) / config_.transition_time_in_ms;
|
||||
const float progress = config_.transition_time_in_ms > 0.0f ? static_cast<float>(DELAY_MS * calls_) / config_.transition_time_in_ms : 1.f;
|
||||
|
||||
// Finished case
|
||||
if(progress > 1.0) {
|
||||
if (config_.transition_time_in_ms <= 0.0f || progress >= 1.0)
|
||||
{
|
||||
finished_ = true;
|
||||
clear(ledStrip_);
|
||||
for (int i = beginIdx_; i < endIdx_; i += config_.increment)
|
||||
setLedRGBW(ledStrip_, i % NUM_LEDS, config_.color);
|
||||
return 10000000;
|
||||
}
|
||||
|
||||
// In-progress case
|
||||
clear(ledStrip_);
|
||||
for(int i = beginIdx_; i != endIdx_; i += config_.increment) {
|
||||
ColorRGBW newColor = hsv2rgb(interpolate(rgb2hsv(state_[i]), rgb2hsv(config_.color), progress));
|
||||
newColor.w = config_.color.w * progress + state_[i].w * (1 - progress);
|
||||
setLedRGBW(ledStrip_, i, newColor);
|
||||
if(i >= NUM_LEDS)
|
||||
i = 0;
|
||||
for (int i = beginIdx_; i < endIdx_; i += config_.increment)
|
||||
{
|
||||
const auto idx = i % NUM_LEDS;
|
||||
ColorRGBW newColor = ColorRGBW::interpolate(state_[idx], config_.color, progress);
|
||||
setLedRGBW(ledStrip_, idx, newColor);
|
||||
}
|
||||
|
||||
++calls_;
|
||||
@@ -68,6 +73,11 @@ public:
|
||||
}
|
||||
|
||||
private:
|
||||
static int int_interpolate(int prev, int next, float progress)
|
||||
{
|
||||
return static_cast<float>(prev) * (1 - progress) +
|
||||
static_cast<float>(next) * progress;
|
||||
}
|
||||
EffectStaticDetailedConfig config_;
|
||||
TLedStrip &ledStrip_;
|
||||
ColorRGBW state_[NUM_LEDS];
|
||||
26
esp-firmware/lib/ledtl/helpers/ColorRGBW.h
Normal file
@@ -0,0 +1,26 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
struct ColorRGBW
|
||||
{
|
||||
uint8_t r, g, b, w;
|
||||
|
||||
ColorRGBW operator*(float s) const
|
||||
{
|
||||
return {uint8_t(s * r),
|
||||
uint8_t(s * g),
|
||||
uint8_t(s * b),
|
||||
uint8_t(s * w)};
|
||||
}
|
||||
|
||||
static inline ColorRGBW interpolate(const ColorRGBW &c1, const ColorRGBW &c2, float f)
|
||||
{
|
||||
return ColorRGBW{
|
||||
static_cast<uint8_t>((1.0f - f) * static_cast<float>(c1.r) + f * static_cast<float>(c2.r)),
|
||||
static_cast<uint8_t>((1.0f - f) * static_cast<float>(c1.g) + f * static_cast<float>(c2.g)),
|
||||
static_cast<uint8_t>((1.0f - f) * static_cast<float>(c1.b) + f * static_cast<float>(c2.b)),
|
||||
static_cast<uint8_t>((1.0f - f) * static_cast<float>(c1.w) + f * static_cast<float>(c2.w)),
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -1,74 +0,0 @@
|
||||
|
||||
Reader
|
||||
----------
|
||||
|
||||
- GND black
|
||||
- RST blue 3.3V
|
||||
- 3.3V red
|
||||
- MISO brown 21
|
||||
- SDA green 19
|
||||
- SCK yellow 18
|
||||
- MOSI orange 5
|
||||
- IRQ green single cable not connected
|
||||
|
||||
|
||||
Button Board:
|
||||
-------------
|
||||
|
||||
- rot in | white 13
|
||||
- btn2 led | grey 12
|
||||
- btn2 in | purple 14
|
||||
- rotB | blue 27
|
||||
- rotA | green 26
|
||||
- btn1 in | yellow 25
|
||||
- btn1 led | orange 33
|
||||
|
||||
rot="rotary encoder"
|
||||
in=button sense in
|
||||
led = 5V pwm
|
||||
|
||||
|
||||
|
||||
Firmware Planning
|
||||
-----------------
|
||||
|
||||
- input commands:
|
||||
- led: effect + parameters
|
||||
- off
|
||||
- single color
|
||||
- multiple color HSV fade, list of colors with timings
|
||||
- circular motion (already exists)
|
||||
- chained events? e.g. circle two times then fade
|
||||
- effects:
|
||||
- welle fuer an und aus
|
||||
- breathe waehrend an, oder farbgradient
|
||||
-
|
||||
- output infos:
|
||||
- nfc read: with id
|
||||
- nfc remove
|
||||
- button presses, (possible also long press, double click, etc)
|
||||
- rotary encoder up down + current numeric state
|
||||
- on led effect end?
|
||||
|
||||
|
||||
|
||||
|
||||
TODO
|
||||
----
|
||||
|
||||
1) case redesign
|
||||
- slightly smaller led ring (10mm -> 9mm) [ok]
|
||||
- thicker top of inner ring, but cutouts for reader [ok]
|
||||
- adjust reader stands position [ok]
|
||||
- bottom for led ring snap-in [ok]
|
||||
- bottom for inner ring [ok]
|
||||
- stands for own "pcb" [ok]
|
||||
- 2 cutouts for cables [ok]
|
||||
- checks, compared to existing print
|
||||
- same diameter, very slightly smaller
|
||||
- larger overlap of LED ring
|
||||
- minimal wall thickness for led ring top and side
|
||||
- check total height - compare to existing
|
||||
|
||||
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
"""Some simple tests/examples for the Home Assistant client."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import sys
|
||||
|
||||
from hass_client import HomeAssistantClient
|
||||
|
||||
LOGGER = logging.getLogger()
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
logformat = logging.Formatter(
|
||||
"%(asctime)-15s %(levelname)-5s %(name)s.%(module)s -- %(message)s")
|
||||
consolehandler = logging.StreamHandler()
|
||||
consolehandler.setFormatter(logformat)
|
||||
LOGGER.addHandler(consolehandler)
|
||||
LOGGER.setLevel(logging.DEBUG)
|
||||
|
||||
if len(sys.argv) < 3:
|
||||
LOGGER.error("usage: test.py <url> <token>")
|
||||
sys.exit()
|
||||
|
||||
url = sys.argv[1]
|
||||
token = sys.argv[2]
|
||||
loop = asyncio.get_event_loop()
|
||||
hass = HomeAssistantClient(url, token)
|
||||
|
||||
async def hass_event(event, event_details):
|
||||
"""Handle hass event callback."""
|
||||
LOGGER.info("received event %s --> %s\n", event, event_details)
|
||||
|
||||
hass.register_event_callback(hass_event)
|
||||
|
||||
async def run():
|
||||
"""Run tests."""
|
||||
await hass.async_connect()
|
||||
await asyncio.sleep(10)
|
||||
await hass.async_close()
|
||||
loop.stop()
|
||||
|
||||
try:
|
||||
loop.create_task(run())
|
||||
loop.run_forever()
|
||||
except KeyboardInterrupt:
|
||||
loop.stop()
|
||||
loop.close()
|
||||
@@ -1,198 +0,0 @@
|
||||
import asyncio
|
||||
from enum import Enum
|
||||
import struct
|
||||
|
||||
from led_cmds import *
|
||||
|
||||
MAGIC_TOKEN_HOST_TO_FW = 0x1d6379e3
|
||||
MAGIC_TOKEN_FW_TO_HOST = 0x10c65631
|
||||
|
||||
|
||||
class MessageFwToHost(Enum):
|
||||
RFID_TOKEN_READ = 0
|
||||
ROTARY_ENCODER = 1
|
||||
TOUCH_BUTTON_PRESS = 2
|
||||
TOUCH_BUTTON_RELEASE = 3
|
||||
|
||||
|
||||
class TouchButton(Enum):
|
||||
LEFT_FOOT = 0
|
||||
RIGHT_FOOT = 1
|
||||
LEFT_EAR = 2
|
||||
RIGHT_EAR = 3
|
||||
|
||||
|
||||
led_ring_effect_to_message_id = {
|
||||
EffectStaticConfig: 0,
|
||||
EffectAlexaSwipeConfig: 1,
|
||||
EffectCircularConfig: 2,
|
||||
EffectRandomTwoColorInterpolationConfig: 3,
|
||||
EffectSwipeAndChange: 4,
|
||||
EffectReverseSwipe: 5,
|
||||
}
|
||||
|
||||
mouse_led_effect_to_message_id = {
|
||||
EffectStaticConfig: 6,
|
||||
EffectCircularConfig: 7,
|
||||
EffectRandomTwoColorInterpolationConfig: 8,
|
||||
EffectSwipeAndChange: 9,
|
||||
EffectReverseSwipe: 10,
|
||||
}
|
||||
|
||||
shelve_led_effect_to_message_id = {
|
||||
EffectStaticConfig: 15,
|
||||
EffectCircularConfig: 16,
|
||||
EffectRandomTwoColorInterpolationConfig: 17,
|
||||
EffectSwipeAndChange: 18,
|
||||
EffectReverseSwipe: 19,
|
||||
EffectStaticDetailedConfig: 20,
|
||||
}
|
||||
|
||||
mouse_leds_index_ranges = {
|
||||
TouchButton.RIGHT_FOOT: (0, 6),
|
||||
TouchButton.LEFT_FOOT: (6, 6 + 6),
|
||||
TouchButton.LEFT_EAR: (6 + 6, 6 + 6 + 16),
|
||||
TouchButton.RIGHT_EAR: (6 + 6 + 16, 6 + 6 + 16 + 17),
|
||||
}
|
||||
|
||||
PREV_BUTTON_LED_MSG = 21
|
||||
NEXT_BUTTON_LED_MSG = 22
|
||||
|
||||
|
||||
class RfidTokenRead:
|
||||
def __init__(self, id: bytes):
|
||||
self.id = id
|
||||
|
||||
def __repr__(self):
|
||||
return "RFID Token (" + " ".join(f"{v:02x}" for v in self.id) + ")"
|
||||
|
||||
|
||||
class RotaryEncoderEvent:
|
||||
def __init__(self, msg_content: bytes):
|
||||
self.position, self.increment, self.direction = struct.unpack("<iiB", msg_content)
|
||||
|
||||
def __repr__(self):
|
||||
return f"Rotary event: pos {self.position}, incr {self.increment}, dir {self.direction}"
|
||||
|
||||
|
||||
class TouchButtonPress:
|
||||
def __init__(self, msg_content: bytes):
|
||||
val = int(msg_content[0])
|
||||
self.touch_button = TouchButton(val)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return "Pressed " + repr(self.touch_button)
|
||||
|
||||
|
||||
class TouchButtonRelease:
|
||||
def __init__(self, msg_content: bytes):
|
||||
val = int(msg_content[0])
|
||||
self.touch_button = TouchButton(val)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return "Released " + repr(self.touch_button)
|
||||
|
||||
|
||||
class ButtonEvent:
|
||||
button_name = {1: 'left', 2: 'right', 3: 'rotary'}
|
||||
event_name = {
|
||||
0: 'pressed',
|
||||
1: 'released',
|
||||
2: 'clicked',
|
||||
3: 'double_clicked',
|
||||
4: 'long_pressed',
|
||||
5: 'repeat_pressed',
|
||||
6: 'long_released'
|
||||
}
|
||||
|
||||
def __init__(self, msg_content: bytes):
|
||||
button_nr, event_nr = struct.unpack("<BB", msg_content)
|
||||
self.button = self.button_name[button_nr]
|
||||
self.event = self.event_name[event_nr]
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"Button {self.button} {self.event}"
|
||||
|
||||
|
||||
incomingMsgMap = {
|
||||
0: RfidTokenRead,
|
||||
1: RotaryEncoderEvent,
|
||||
2: TouchButtonPress,
|
||||
3: TouchButtonRelease,
|
||||
4: ButtonEvent,
|
||||
}
|
||||
|
||||
|
||||
class MusicMouseProtocol(asyncio.Protocol):
|
||||
def __init__(self):
|
||||
super()
|
||||
self._msg_callback = None
|
||||
|
||||
def register_message_callback(self, cb):
|
||||
self._msg_callback = cb
|
||||
|
||||
def connection_made(self, transport):
|
||||
self.transport = transport
|
||||
self.in_buff = bytes()
|
||||
|
||||
def __led_effect(self, effect_cfg, msg_dict):
|
||||
msg_content = effect_cfg.as_bytes()
|
||||
header = struct.pack("<IBH", MAGIC_TOKEN_HOST_TO_FW, msg_dict[type(effect_cfg)],
|
||||
len(msg_content))
|
||||
self.transport.write(header + msg_content)
|
||||
|
||||
def led_ring_effect(self, effect_cfg):
|
||||
self.__led_effect(effect_cfg, led_ring_effect_to_message_id)
|
||||
|
||||
def mouse_led_effect(self, effect_cfg):
|
||||
self.__led_effect(effect_cfg, mouse_led_effect_to_message_id)
|
||||
|
||||
def shelve_led_effect(self, effect_cfg):
|
||||
self.__led_effect(effect_cfg, shelve_led_effect_to_message_id)
|
||||
|
||||
def button_background_led_prev(self, val):
|
||||
msg_content = struct.pack("<f", val)
|
||||
header = struct.pack("<IBH", MAGIC_TOKEN_HOST_TO_FW, PREV_BUTTON_LED_MSG, len(msg_content))
|
||||
self.transport.write(header + msg_content)
|
||||
|
||||
def button_background_led_next(self, val):
|
||||
msg_content = struct.pack("<f", val)
|
||||
header = struct.pack("<IBH", MAGIC_TOKEN_HOST_TO_FW, NEXT_BUTTON_LED_MSG, len(msg_content))
|
||||
self.transport.write(header + msg_content)
|
||||
|
||||
def data_received(self, data):
|
||||
self.in_buff += data
|
||||
self._parse_message()
|
||||
|
||||
def connection_lost(self, exc):
|
||||
print('port closed')
|
||||
self.transport.loop.stop()
|
||||
|
||||
def pause_writing(self):
|
||||
print('pause writing')
|
||||
print(self.transport.get_write_buffer_size())
|
||||
|
||||
def resume_writing(self):
|
||||
print(self.transport.get_write_buffer_size())
|
||||
print('resume writing')
|
||||
|
||||
def _parse_message(self):
|
||||
HEADER_SIZE = 4 + 1 + 2
|
||||
if len(self.in_buff) == 0:
|
||||
return
|
||||
if len(self.in_buff) >= HEADER_SIZE:
|
||||
token, msg_type, msg_size = struct.unpack("<IBH", self.in_buff[:HEADER_SIZE])
|
||||
if token == MAGIC_TOKEN_FW_TO_HOST and len(self.in_buff) >= HEADER_SIZE + msg_size:
|
||||
self._on_msg_receive(msg_type, self.in_buff[HEADER_SIZE:HEADER_SIZE + msg_size])
|
||||
self.in_buff = self.in_buff[HEADER_SIZE + msg_size:]
|
||||
else:
|
||||
idx = self.in_buff.find("\n".encode())
|
||||
if idx >= 0:
|
||||
text_msg = self.in_buff[:idx]
|
||||
print("LOG:", text_msg.decode())
|
||||
self.in_buff = self.in_buff[idx + 1:]
|
||||
|
||||
def _on_msg_receive(self, msg_type, msg_payload):
|
||||
parsed_msg = incomingMsgMap[msg_type](msg_payload)
|
||||
if self._msg_callback is not None:
|
||||
self._msg_callback(self, parsed_msg)
|
||||
@@ -1,167 +0,0 @@
|
||||
from dataclasses import dataclass
|
||||
import struct
|
||||
import colorsys
|
||||
|
||||
|
||||
@dataclass
|
||||
class ColorRGBW:
|
||||
r: float
|
||||
g: float
|
||||
b: float
|
||||
w: float
|
||||
|
||||
def __repr__(self):
|
||||
return f"#({self.r}, {self.g}, {self.b}, {self.w})"
|
||||
|
||||
def as_bytes(self) -> bytes:
|
||||
assert self.is_valid(), "Trying to send invalid " + repr(self)
|
||||
return struct.pack("<BBBB", int(self.r * 255), int(self.g * 255), int(self.b * 255),
|
||||
int(self.w * 255))
|
||||
|
||||
def is_valid(self):
|
||||
vals = (self.r, self.g, self.b, self.w)
|
||||
return all(0 <= v <= 1 for v in vals)
|
||||
|
||||
def __mul__(self, other:float):
|
||||
assert 0<= other <= 1
|
||||
return ColorRGBW(self.r * other, self.g * other, self.b * other, self.w * other)
|
||||
|
||||
def __eq__(self, other:'ColorRGBW'):
|
||||
return self.r == other.r and self.g == other.g and self.b == other.b and self.w == other.w
|
||||
|
||||
def __neq__(self, other:'ColorRGBW'):
|
||||
return not self == other
|
||||
|
||||
def without_white_channel(self, scale=1):
|
||||
args = (min(1, e + self.w) for e in (self.r, self.g, self.b) )
|
||||
return ColorRGBW(*args, 0)
|
||||
|
||||
@dataclass
|
||||
class ColorHSV:
|
||||
h: float
|
||||
s: float
|
||||
v: float
|
||||
|
||||
@staticmethod
|
||||
def fromRGB(rgb):
|
||||
conv = colorsys.rgb_to_hsv(rgb.r, rgb.g, rgb.b)
|
||||
return ColorHSV(conv[0] * 360, conv[1], conv[2])
|
||||
|
||||
def __repr__(self):
|
||||
return f"ColorHSV({self.h}, {self.s}, {self.v})"
|
||||
|
||||
def as_bytes(self) -> bytes:
|
||||
return struct.pack("<fff", self.h, self.s, self.v)
|
||||
|
||||
def is_valid(self):
|
||||
if not 0 <= self.h <= 360:
|
||||
return False
|
||||
if not 0 <= self.s <= 1:
|
||||
return False
|
||||
if not 0 <= self.v <= 2:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
@dataclass
|
||||
class EffectStaticConfig:
|
||||
color: ColorRGBW
|
||||
begin: int = 0
|
||||
end: int = 0
|
||||
|
||||
def __repr__(self):
|
||||
return f"EffectStaticConfig {str(self.color)}, beg: {self.begin}, end {self.end}"
|
||||
|
||||
def as_bytes(self) -> bytes:
|
||||
return self.color.as_bytes() + struct.pack("<HH", self.begin, self.end)
|
||||
|
||||
|
||||
@dataclass
|
||||
class EffectStaticDetailedConfig:
|
||||
color: ColorRGBW
|
||||
increment: int = 1
|
||||
begin: float = 0.0
|
||||
end: float = 1.0
|
||||
transition_time_in_ms : float = 500
|
||||
|
||||
def __repr__(self):
|
||||
return f"EffectStaticDetailedConfig {str(self.color)}, beg: {self.begin}, end {self.end}, incr {self.increment}, transition in ms {self.transition_time_in_ms}"
|
||||
|
||||
def as_bytes(self) -> bytes:
|
||||
return self.color.as_bytes() + struct.pack("<Hfff", self.increment, self.begin, self.end, self.transition_time_in_ms)
|
||||
|
||||
@dataclass
|
||||
class EffectAlexaSwipeConfig:
|
||||
primary_color_width: float = 20 # in degrees
|
||||
transition_width: float = 30 # in degrees
|
||||
swipe_speed: float = 2 * 360 # in degrees per second
|
||||
bell_curve_width_in_leds: float = 3
|
||||
start_position: float = 180 # in degrees
|
||||
forward: bool = True
|
||||
primary_color: ColorRGBW = ColorRGBW(0, 0, 1, 0)
|
||||
secondary_color: ColorRGBW = ColorRGBW(0, 200 / 255, 1, 0)
|
||||
|
||||
def as_bytes(self) -> bytes:
|
||||
return struct.pack(
|
||||
"<fffff?", self.primary_color_width, self.transition_width, self.swipe_speed,
|
||||
self.bell_curve_width_in_leds, self.start_position,
|
||||
self.forward) + self.primary_color.as_bytes() + self.secondary_color.as_bytes()
|
||||
|
||||
def __repr__(self):
|
||||
return f"EffectAlexaSwipe primary {str(self.primary_color)}, {str(self.secondary_color)}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class EffectRandomTwoColorInterpolationConfig:
|
||||
cycle_durations_ms: int = 6000
|
||||
start_with_existing: bool = True
|
||||
num_segments: int = 3
|
||||
hue1_random: bool = False
|
||||
hue2_random: bool = False
|
||||
color1: ColorHSV = ColorHSV(240, 1, 1)
|
||||
color2: ColorHSV = ColorHSV(192, 1, 1)
|
||||
|
||||
def as_bytes(self) -> bytes:
|
||||
c1 = ColorHSV.fromRGB(self.color1) if isinstance(self.color1, ColorRGBW) else self.color1
|
||||
c2 = ColorHSV.fromRGB(self.color2) if isinstance(self.color2, ColorRGBW) else self.color2
|
||||
return struct.pack("<i?i??", self.cycle_durations_ms, self.start_with_existing,
|
||||
self.num_segments, self.hue1_random,
|
||||
self.hue2_random) + c1.as_bytes() + c2.as_bytes()
|
||||
|
||||
def __repr__(self):
|
||||
return f"RandTwoColor {str(self.color1)}, {str(self.color2)}, segments {self.num_segments}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class EffectCircularConfig:
|
||||
speed: float = 360 # in degrees per second
|
||||
width: float = 180 # in degrees
|
||||
color: ColorRGBW = ColorRGBW(0, 0, 1, 0)
|
||||
|
||||
def as_bytes(self) -> bytes:
|
||||
return struct.pack("<ff", self.speed, self.width) + self.color.as_bytes()
|
||||
|
||||
|
||||
@dataclass
|
||||
class EffectSwipeAndChange:
|
||||
swipe: EffectAlexaSwipeConfig = EffectAlexaSwipeConfig()
|
||||
change: EffectRandomTwoColorInterpolationConfig = EffectRandomTwoColorInterpolationConfig()
|
||||
|
||||
def as_bytes(self) -> bytes:
|
||||
return self.swipe.as_bytes() + self.change.as_bytes()
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"Swipe and Change: \n {str(self.swipe)}\n {str(self.change)}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class EffectReverseSwipe:
|
||||
swipeSpeed: float = 2 * 360
|
||||
bellCurveWidthInLeds: float = 3
|
||||
startPosition: float = 180
|
||||
|
||||
def as_bytes(self) -> bytes:
|
||||
return struct.pack("<fff", self.swipeSpeed, self.bellCurveWidthInLeds, self.startPosition)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"Reverse swipe, speed {self.swipeSpeed}, width in leds {self.bellCurveWidthInLeds}, start position {self.startPosition}"
|
||||
@@ -1,270 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
import serial_asyncio
|
||||
from led_cmds import (ColorRGBW, ColorHSV, EffectCircularConfig, EffectStaticConfig,
|
||||
EffectRandomTwoColorInterpolationConfig, EffectAlexaSwipeConfig,
|
||||
EffectSwipeAndChange, EffectReverseSwipe)
|
||||
from host_driver import MusicMouseProtocol, RfidTokenRead, RotaryEncoderEvent, ButtonEvent, TouchButton, TouchButtonPress, TouchButtonRelease, mouse_leds_index_ranges
|
||||
from player import AudioPlayer
|
||||
from glob import glob
|
||||
from copy import deepcopy
|
||||
import os
|
||||
from hass_client import HomeAssistantClient
|
||||
import argparse
|
||||
from ruamel.yaml import YAML
|
||||
import warnings
|
||||
from pprint import pprint
|
||||
from typing import Optional
|
||||
from mqtt_json import start_mqtt
|
||||
|
||||
yaml = YAML(typ='safe')
|
||||
|
||||
OFF_COLOR = ColorRGBW(0, 0, 0, 0)
|
||||
|
||||
|
||||
def parse_color(color_str: str):
|
||||
if isinstance(color_str, ColorRGBW):
|
||||
return color_str
|
||||
elif color_str.startswith("#"):
|
||||
color_str = color_str.lstrip('#')
|
||||
t = tuple(int(color_str[i:i + 2], 16) / 255 for i in (0, 2, 4))
|
||||
return ColorRGBW(*t, 0)
|
||||
elif color_str.startswith("w"):
|
||||
color_str = color_str.lstrip("w")
|
||||
return ColorRGBW(0, 0, 0, int(color_str, 16) / 255)
|
||||
|
||||
|
||||
def load_config(config_path):
|
||||
with open(os.path.join(config_path, "config.yml")) as cfg_file:
|
||||
cfg = yaml.load(cfg_file)
|
||||
for figure_name, figure_cfg in cfg["figures"].items():
|
||||
figure_cfg["colors"] = [parse_color(c) for c in figure_cfg["colors"]]
|
||||
if 'media_files' not in figure_cfg:
|
||||
figure_cfg['media_files'] = sorted(glob(os.path.join(config_path, figure_name)))
|
||||
return cfg
|
||||
|
||||
|
||||
def hass_service(hass, domain, service, **kwargs):
|
||||
asyncio.create_task(hass.call_service(domain, service, kwargs))
|
||||
|
||||
|
||||
class MusicMouseState:
|
||||
def __init__(self, protocol: MusicMouseProtocol):
|
||||
self.active_figure: Optional[
|
||||
str] = None # None if no figure is placed on the reader, or the name of the figure
|
||||
self.last_partially_played_figure: Optional[
|
||||
str] = None # figure whose playlist wasn't played completely and was removed
|
||||
|
||||
self.current_mouse_led_effect = None
|
||||
self.current_led_ring_effect = None
|
||||
self.protocol: MusicMouseProtocol = protocol
|
||||
self.button_led_brightness = None
|
||||
|
||||
def mouse_led_effect(self, effect_cfg):
|
||||
self.current_mouse_led_effect = effect_cfg
|
||||
self.protocol.mouse_led_effect(effect_cfg)
|
||||
|
||||
def led_ring_effect(self, effect_cfg):
|
||||
self.current_led_ring_effect = effect_cfg
|
||||
self.protocol.led_ring_effect(effect_cfg)
|
||||
self.protocol.shelve_led_effect(effect_cfg)
|
||||
|
||||
def button_leds(self, brightness):
|
||||
assert 0 <= brightness <= 1
|
||||
self.protocol.button_background_led_prev(brightness)
|
||||
self.protocol.button_background_led_next(brightness)
|
||||
self.button_led_brightness = brightness
|
||||
|
||||
def reset(self):
|
||||
self.mouse_led_effect(EffectStaticConfig(OFF_COLOR))
|
||||
self.led_ring_effect(EffectStaticConfig(OFF_COLOR))
|
||||
|
||||
|
||||
class Controller:
|
||||
def __init__(self, protocol, hass, cfg):
|
||||
self.cfg = cfg
|
||||
self.audio_player = AudioPlayer(cfg["general"]["alsa_device"])
|
||||
self.audio_player.set_volume(50)
|
||||
self.mmstate = MusicMouseState(protocol)
|
||||
self.protocol = protocol
|
||||
self.hass = hass
|
||||
|
||||
vol_min = self.cfg["general"].get("min_volume", None)
|
||||
vol_max = self.cfg["general"].get("max_volume", None)
|
||||
self.audio_player.set_volume_limits(vol_min, vol_max)
|
||||
protocol.register_message_callback(self.on_firmware_msg)
|
||||
|
||||
self.audio_player.on_playlist_end_callback = self._on_playlist_end
|
||||
self.playlists = {
|
||||
fig: self.audio_player.create_playlist(fig_cfg['media_files'])
|
||||
for fig, fig_cfg in cfg['figures'].items()
|
||||
}
|
||||
self._rfid_to_figure_name = {
|
||||
bytes.fromhex(figure_cfg["id"]): figure_name
|
||||
for figure_name, figure_cfg in cfg["figures"].items()
|
||||
}
|
||||
|
||||
self.protocol.shelve_led_effect(EffectStaticConfig(ColorRGBW(0, 0, 0.1, 0)))
|
||||
shelf_eff = EffectCircularConfig()
|
||||
shelf_eff.color = ColorRGBW(0, 0, 0.4, 0)
|
||||
shelf_eff = EffectStaticConfig(ColorRGBW(0, 0, 0, 0))
|
||||
self.protocol.shelve_led_effect(shelf_eff)
|
||||
|
||||
def _on_playlist_end(self):
|
||||
if not self.audio_player.is_playing():
|
||||
self.mmstate.last_partially_played_figure = None
|
||||
self._run_off_animation()
|
||||
else:
|
||||
print("Playlist end was called, even if player remains playing?!")
|
||||
|
||||
def handle_rfid_event(self, tagid):
|
||||
if tagid == bytes.fromhex("0000000000"):
|
||||
if self.audio_player.is_playing():
|
||||
print("Got 000 rfid -> playing off animation")
|
||||
self._run_off_animation()
|
||||
self.audio_player.pause()
|
||||
self.mmstate.last_partially_played_figure = self.mmstate.active_figure
|
||||
else:
|
||||
self.mmstate.last_partially_played_figure = None
|
||||
|
||||
self.mmstate.active_figure = None
|
||||
elif tagid in self._rfid_to_figure_name:
|
||||
newly_placed_figure = self._rfid_to_figure_name[tagid]
|
||||
primary_color, secondary_color, *rest = self.cfg["figures"][newly_placed_figure][
|
||||
"colors"]
|
||||
self._start_animation(primary_color, secondary_color)
|
||||
self.mmstate.button_leds(self.cfg["general"].get("button_leds_brightness", 0.5))
|
||||
|
||||
if newly_placed_figure in self.cfg['figures']:
|
||||
if self.mmstate.last_partially_played_figure == newly_placed_figure:
|
||||
print("Continuing playlist")
|
||||
self.audio_player.play()
|
||||
else:
|
||||
print("Restarting playlist")
|
||||
self.audio_player.set_playlist(
|
||||
self.audio_player.create_playlist(
|
||||
self.cfg['figures'][newly_placed_figure]['media_files']))
|
||||
self.audio_player.play_from_start()
|
||||
|
||||
self.mmstate.active_figure = newly_placed_figure
|
||||
else:
|
||||
warnings.warn(f"Unknown figure/tag with id {tagid}")
|
||||
|
||||
def on_firmware_msg(self, _, message):
|
||||
print("FW msg:", message)
|
||||
if isinstance(message, RfidTokenRead):
|
||||
self.handle_rfid_event(message.id)
|
||||
elif isinstance(message, RotaryEncoderEvent):
|
||||
volume_increment = self.cfg["general"].get("volume_increment", 2) * abs(
|
||||
message.increment)
|
||||
if message.direction == 2:
|
||||
self.audio_player.change_volume(volume_increment)
|
||||
elif message.direction == 1:
|
||||
self.audio_player.change_volume(-volume_increment)
|
||||
elif isinstance(message, ButtonEvent):
|
||||
btn = message.button
|
||||
if btn == "left" and message.event == "pressed" and self.audio_player.is_playing():
|
||||
self.audio_player.previous()
|
||||
elif btn == "right" and message.event == "pressed" and self.audio_player.is_playing():
|
||||
self.audio_player.next()
|
||||
elif message.button == "rotary" and message.event == "pressed":
|
||||
hass_service(self.hass, "light", "toggle", entity_id="light.kinderzimmer_fluter")
|
||||
elif isinstance(message, TouchButtonPress):
|
||||
figure = self.mmstate.active_figure
|
||||
if figure and self.audio_player.is_playing():
|
||||
primary_color, secondary_color, bg, accent = self.cfg["figures"][figure]["colors"]
|
||||
self.protocol.mouse_led_effect(
|
||||
EffectStaticConfig(accent, *mouse_leds_index_ranges[message.touch_button]))
|
||||
|
||||
colors = {
|
||||
TouchButton.RIGHT_FOOT: {
|
||||
'rgb_color': [235, 255, 67]
|
||||
},
|
||||
TouchButton.LEFT_FOOT: {
|
||||
'color_temp': 469
|
||||
},
|
||||
TouchButton.RIGHT_EAR: {
|
||||
'rgb_color': [101, 49, 255]
|
||||
},
|
||||
TouchButton.LEFT_EAR: {
|
||||
'rgb_color': [255, 74, 254]
|
||||
},
|
||||
}
|
||||
hass_service(
|
||||
self.hass,
|
||||
"light",
|
||||
"turn_on",
|
||||
entity_id=["light.kinderzimmer_fluter", "light.music_mouse_regal_licht"],
|
||||
**colors[message.touch_button])
|
||||
|
||||
elif isinstance(message, TouchButtonRelease):
|
||||
figure = self.mmstate.active_figure
|
||||
eff_change = EffectRandomTwoColorInterpolationConfig()
|
||||
eff_static = EffectStaticConfig(ColorRGBW(0, 0, 0, 0),
|
||||
*mouse_leds_index_ranges[message.touch_button])
|
||||
if self.audio_player.is_playing():
|
||||
primary_color, secondary_color, bg, accent = self.cfg["figures"][figure]["colors"]
|
||||
eff_static.color = primary_color
|
||||
self.protocol.mouse_led_effect(eff_static)
|
||||
|
||||
if self.audio_player.is_playing():
|
||||
primary_color, secondary_color, bg, accent = self.cfg["figures"][figure]["colors"]
|
||||
eff_change.color1 = primary_color
|
||||
eff_change.color2 = secondary_color
|
||||
eff_change.start_with_existing = True
|
||||
self.protocol.mouse_led_effect(eff_change)
|
||||
|
||||
def _start_animation(self, primary_color, secondary_color):
|
||||
ring_eff = EffectSwipeAndChange()
|
||||
ring_eff.swipe.primary_color = primary_color
|
||||
ring_eff.swipe.secondary_color = secondary_color
|
||||
ring_eff.swipe.swipe_speed = 180
|
||||
ring_eff.change.color1 = primary_color
|
||||
ring_eff.change.color2 = secondary_color
|
||||
self.mmstate.led_ring_effect(ring_eff)
|
||||
|
||||
mouse_eff = deepcopy(ring_eff)
|
||||
mouse_eff.swipe.start_position = 6 / 45 * 360
|
||||
mouse_eff.swipe.bell_curve_width_in_leds = 16
|
||||
mouse_eff.swipe.swipe_speed = 180
|
||||
self.mmstate.mouse_led_effect(mouse_eff)
|
||||
|
||||
def _run_off_animation(self):
|
||||
print("Running off animation")
|
||||
ring_eff = EffectReverseSwipe()
|
||||
self.mmstate.led_ring_effect(ring_eff)
|
||||
|
||||
mouse_eff = EffectReverseSwipe()
|
||||
mouse_eff.startPosition = 6 / 45 * 360
|
||||
self.mmstate.mouse_led_effect(mouse_eff)
|
||||
|
||||
self.mmstate.button_leds(0)
|
||||
|
||||
|
||||
def main(config_path):
|
||||
cfg = load_config(config_path)
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
hass = HomeAssistantClient(cfg["general"]["hass_url"], cfg["general"]["hass_token"], loop)
|
||||
|
||||
coro = serial_asyncio.create_serial_connection(loop,
|
||||
MusicMouseProtocol,
|
||||
cfg["general"]["serial_port"],
|
||||
baudrate=115200)
|
||||
transport, protocol = loop.run_until_complete(coro)
|
||||
controller = Controller(protocol, hass, cfg)
|
||||
mqtt_cfg = cfg["general"]["mqtt"]
|
||||
loop.create_task(start_mqtt(protocol, mqtt_cfg["server"], mqtt_cfg["user"],mqtt_cfg["password"] ))
|
||||
loop.create_task(hass.connect())
|
||||
return controller, loop
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) == 2:
|
||||
controller, loop = main(config_path=sys.argv[1])
|
||||
loop.run_forever()
|
||||
loop.close()
|
||||
else:
|
||||
print("Error: run with config file path as first argument")
|
||||
@@ -1,155 +0,0 @@
|
||||
from led_cmds import ColorRGBW, EffectStaticConfig, EffectCircularConfig, EffectRandomTwoColorInterpolationConfig, EffectAlexaSwipeConfig, EffectSwipeAndChange
|
||||
import asyncio
|
||||
import asyncio_mqtt
|
||||
import json
|
||||
|
||||
BRIGHTNESS_SCALE = 1 # power supply is a bit weak -> scale brightness down globally
|
||||
|
||||
|
||||
class ShelveLightMqtt:
|
||||
def __init__(self, protocol, client):
|
||||
self._brightness = 100
|
||||
self._protocol = protocol
|
||||
self._mqtt_client = client
|
||||
|
||||
self._brightness = 0
|
||||
self._color = ColorRGBW(0.5, 0.5, 0.5, 0)
|
||||
self._last_color = ColorRGBW(0.5, 0.5, 0.5, 0)
|
||||
self._original_color_repr = (127, 127, 127)
|
||||
self._effect = "static"
|
||||
|
||||
self._discovery_spec = self._create_discovery_msg_light()
|
||||
|
||||
async def init(self):
|
||||
self._protocol.shelve_led_effect(EffectStaticConfig(ColorRGBW(0, 0, 0, 0)))
|
||||
await self._notify_mqtt_brightness()
|
||||
await self._notify_mqtt_state()
|
||||
await self._notify_mqtt_rgb()
|
||||
await self._send_autodiscovery_msg()
|
||||
|
||||
async def _send_autodiscovery_msg(self):
|
||||
topic = f"homeassistant/light/{self._discovery_spec['unique_id']}/config"
|
||||
await self._mqtt_client.publish(topic, json.dumps(self._discovery_spec).encode())
|
||||
|
||||
async def _notify_mqtt_rgb(self):
|
||||
rgb_payload = ",".join(str(e) for e in self._original_color_repr)
|
||||
print("OUT ", "rgb", rgb_payload)
|
||||
await self._mqtt_client.publish(self._discovery_spec['rgb_state_topic'],
|
||||
rgb_payload.encode())
|
||||
|
||||
async def _notify_mqtt_state(self):
|
||||
state_payload = "ON" if self._brightness > 0 else "OFF"
|
||||
print("OUT ", "state", state_payload)
|
||||
await self._mqtt_client.publish(self._discovery_spec['state_topic'], state_payload.encode())
|
||||
|
||||
async def _notify_mqtt_brightness(self):
|
||||
brightness_payload = str(int(self._brightness * 255))
|
||||
print("OUT ", "brightness", brightness_payload)
|
||||
await self._mqtt_client.publish(self._discovery_spec['brightness_state_topic'],
|
||||
brightness_payload.encode())
|
||||
|
||||
async def _notify_mqtt_shelve_effect(self, effect):
|
||||
await self._mqtt_client.publish(self._discovery_spec['effect_state_topic'], effect.encode())
|
||||
|
||||
def _set_rgb_color(self, color: ColorRGBW):
|
||||
if self._color != self._last_color: # mqtt sends color multiple times, we want to remember last distinct color as second color for effects
|
||||
self._last_color = self._color
|
||||
self._color = color * BRIGHTNESS_SCALE * self._brightness
|
||||
|
||||
def _update_device(self):
|
||||
if self._effect == "static":
|
||||
eff = EffectStaticConfig(self._color)
|
||||
elif self._effect == "circular":
|
||||
eff = EffectCircularConfig()
|
||||
eff.color = self._color
|
||||
elif self._effect == "wipeup":
|
||||
eff = EffectSwipeAndChange()
|
||||
eff.swipe.secondary_color = self._color
|
||||
eff.swipe.primary_color = self._last_color
|
||||
eff.swipe.bell_curve_width_in_leds = 5
|
||||
eff.swipe.swipe_speed = 180
|
||||
eff.change.color1 = self._color
|
||||
eff.change.color2 = self._last_color
|
||||
elif self._effect == "twocolor":
|
||||
eff = EffectRandomTwoColorInterpolationConfig()
|
||||
eff.color1 = self._color
|
||||
eff.color2 = self._last_color
|
||||
eff.start_with_existing = True
|
||||
elif self._effect == "twocolorrandom":
|
||||
eff = EffectRandomTwoColorInterpolationConfig()
|
||||
eff.color1 = self._color
|
||||
eff.color2 = self._last_color
|
||||
eff.hue1_random = True
|
||||
eff.hue2_random = True
|
||||
eff.start_with_existing = True
|
||||
else:
|
||||
print(f"Unknown effect {self._effect}")
|
||||
eff = EffectStaticConfig(ColorRGBW(0, 0, 0, 0))
|
||||
self._protocol.shelve_led_effect(eff)
|
||||
|
||||
async def handle_light_message(self, msg):
|
||||
prefix = 'musicmouse/lights_shelve'
|
||||
if not msg.topic.startswith(prefix):
|
||||
return False
|
||||
cmd = msg.topic.split("/")[-1]
|
||||
payload = msg.payload.decode()
|
||||
|
||||
print("IN ", cmd, payload)
|
||||
if cmd == "rgb":
|
||||
r, g, b = tuple(int(i) for i in payload.split(","))
|
||||
self._original_color_repr = (r, g, b)
|
||||
self._set_rgb_color(ColorRGBW(r / 255, g / 255, b / 255, 0))
|
||||
await self._notify_mqtt_rgb()
|
||||
elif cmd == "switch":
|
||||
if payload == "ON" and self._brightness == 0:
|
||||
self._brightness = 1
|
||||
elif payload == "OFF":
|
||||
self._color = ColorRGBW(0, 0, 0, 0)
|
||||
self._brightness = 0
|
||||
self._update_device()
|
||||
await self._notify_mqtt_rgb()
|
||||
await self._notify_mqtt_brightness()
|
||||
await self._notify_mqtt_state()
|
||||
elif cmd == "brightness":
|
||||
self._brightness = int(payload) / 255
|
||||
self._set_rgb_color(self._color)
|
||||
await self._notify_mqtt_brightness()
|
||||
elif cmd == "effect":
|
||||
self._effect = payload
|
||||
|
||||
@staticmethod
|
||||
def _create_discovery_msg_light(base_name="musicmouse", display_name="Music Mouse Regal Licht"):
|
||||
id = "shelve"
|
||||
return {
|
||||
'name': display_name,
|
||||
'unique_id': f'{base_name}_{id}',
|
||||
'command_topic': f'{base_name}/lights_{id}/switch',
|
||||
'state_topic': f'{base_name}/lights_{id}/switch_state',
|
||||
'brightness_command_topic': f'{base_name}/lights_{id}/brightness',
|
||||
'brightness_state_topic': f'{base_name}/lights_{id}/brightness_state',
|
||||
'rgb_command_topic': f'{base_name}/lights_{id}/rgb',
|
||||
'rgb_state_topic': f'{base_name}/lights_{id}/rgb_state',
|
||||
'effect_command_topic': f'{base_name}/lights_{id}/effect',
|
||||
'effect_state_topic': f'{base_name}/lights_{id}/effect_state',
|
||||
'effect_list': ['static', 'circular', 'wipeup', 'twocolor', 'twocolorrandom'],
|
||||
}
|
||||
|
||||
|
||||
async def start_mqtt(music_mouse_protocol, server, username, password):
|
||||
async with asyncio_mqtt.Client(hostname=server, username=username, password=password) as client:
|
||||
shelve_light = ShelveLightMqtt(music_mouse_protocol, client)
|
||||
await shelve_light.init()
|
||||
async with client.filtered_messages("musicmouse/#") as messages:
|
||||
await client.subscribe("musicmouse/#")
|
||||
async for message in messages:
|
||||
await shelve_light.handle_light_message(message)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
class DummyProtocol:
|
||||
def shelve_led_effect(self, effect):
|
||||
print("EFF ", repr(effect))
|
||||
|
||||
password = "KNLEFLZF94yA6Zhj141"
|
||||
asyncio.run(start_mqtt(DummyProtocol(), "homeassistant", "musicmouse", password))
|
||||
@@ -1,187 +0,0 @@
|
||||
from led_cmds import ColorRGBW, EffectStaticConfig, EffectStaticDetailedConfig, EffectCircularConfig, EffectRandomTwoColorInterpolationConfig, EffectAlexaSwipeConfig, EffectSwipeAndChange
|
||||
import asyncio
|
||||
import asyncio_mqtt
|
||||
import json
|
||||
from copy import deepcopy
|
||||
|
||||
|
||||
class ShelveLightMqtt:
|
||||
def __init__(self, protocol, client: asyncio_mqtt.Client):
|
||||
self._protocol = protocol
|
||||
self._mqtt_client = client
|
||||
|
||||
self._state = {
|
||||
"state": "OFF",
|
||||
"color": {
|
||||
"r": 255,
|
||||
"g": 255,
|
||||
"b": 255,
|
||||
"w": 0,
|
||||
},
|
||||
"color_mode": "rgbw",
|
||||
"brightness": 30,
|
||||
"effect": "static",
|
||||
}
|
||||
self._last_color = ColorRGBW(0.5, 0.5, 0.5, 0)
|
||||
|
||||
self._discovery_spec = self._create_discovery_msg_light()
|
||||
|
||||
async def init(self):
|
||||
"""Init method, because constructor can't be async"""
|
||||
self._protocol.shelve_led_effect(EffectStaticConfig(ColorRGBW(0, 0, 0, 0)))
|
||||
await self._send_autodiscovery_msg()
|
||||
await self._notify_mqtt_state({"state": "OFF"})
|
||||
|
||||
async def handle_light_message(self, msg):
|
||||
if msg.topic == self._discovery_spec['command_topic']:
|
||||
payload = msg.payload.decode()
|
||||
new_state = json.loads(payload)
|
||||
print("IN ", new_state)
|
||||
await self._update_state(new_state)
|
||||
await self._notify_mqtt_state(new_state)
|
||||
|
||||
async def _update_state(self, new_state):
|
||||
"""Merges current state with new state, updates device"""
|
||||
|
||||
# memorize last color - this is used for effects that need 2 colors
|
||||
if 'color' in new_state:
|
||||
brightness = new_state.get('brightness', self._state['brightness'])
|
||||
new_color = self._color_from_json(new_state['color'], brightness)
|
||||
current_color = self._color_from_json(self._state['color'])
|
||||
if new_color != current_color:
|
||||
self._last_color = current_color
|
||||
print("last color", self._last_color)
|
||||
|
||||
self._state.update(new_state)
|
||||
self._update_device()
|
||||
|
||||
@staticmethod
|
||||
def _color_from_json(json_color, brightness=255):
|
||||
args = ((json_color[e] / 255) * (brightness / 255) for e in ('r', 'g', 'b', 'w'))
|
||||
return ColorRGBW(*args)
|
||||
|
||||
def _update_device(self):
|
||||
s = self._state
|
||||
current_color = self._color_from_json(s['color'], brightness=s["brightness"])
|
||||
transition = s.get("transition", 0.0) * 1000
|
||||
print(f"Effect {s['effect']} Transition {transition}")
|
||||
|
||||
if s['state'] == "OFF":
|
||||
if transition > 0:
|
||||
eff = EffectStaticDetailedConfig(ColorRGBW(0,0,0,0), transition_tim_in_ms=transition)
|
||||
else:
|
||||
eff = EffectStaticConfig(ColorRGBW(0, 0, 0, 0))
|
||||
elif s['effect'] == 'static':
|
||||
if transition > 0:
|
||||
eff = EffectStaticDetailedConfig(current_color, transition_time_in_ms=transition)
|
||||
else:
|
||||
eff = EffectStaticConfig(current_color)
|
||||
elif s['effect'] == 'circular':
|
||||
eff = EffectCircularConfig(speed=180, width=90, color=current_color)
|
||||
elif s['effect'] == 'wipeup':
|
||||
eff = EffectSwipeAndChange()
|
||||
eff.swipe.secondary_color = current_color
|
||||
eff.swipe.primary_color = self._last_color
|
||||
eff.swipe.bell_curve_width_in_leds = 10
|
||||
eff.swipe.transition_width = 30
|
||||
eff.swipe.start_position = 0
|
||||
eff.swipe.swipe_speed = 260
|
||||
eff.change.color1 = current_color
|
||||
eff.change.color2 = self._last_color
|
||||
elif s['effect'] == "twocolor":
|
||||
eff = EffectRandomTwoColorInterpolationConfig()
|
||||
eff.color1 = current_color
|
||||
eff.color2 = self._last_color
|
||||
eff.start_with_existing = True
|
||||
elif s['effect'] == "twocolorrandom":
|
||||
eff = EffectRandomTwoColorInterpolationConfig()
|
||||
eff.color1 = current_color
|
||||
eff.color2 = self._last_color
|
||||
eff.hue1_random = True
|
||||
eff.hue2_random = True
|
||||
eff.start_with_existing = True
|
||||
elif s['effect'] == "side_0.2":
|
||||
eff = EffectStaticDetailedConfig(current_color, begin=0.9, end=0.1, increment=1, transition_time_in_ms=transition)
|
||||
elif s['effect'] == "side_0.2_inc4":
|
||||
eff = EffectStaticDetailedConfig(current_color, begin=0.9, end=0.1, increment=4, transition_time_in_ms=transition)
|
||||
elif s['effect'] == "side_0.5":
|
||||
eff = EffectStaticDetailedConfig(current_color, begin=0.75, end=0.25, increment=1, transition_time_in_ms=transition)
|
||||
elif s['effect'] == "side_0.5_inc4":
|
||||
eff = EffectStaticDetailedConfig(current_color, begin=0.75, end=0.25, increment=4, transition_time_in_ms=transition)
|
||||
else:
|
||||
print(f"Unknown effect {s['effect']}")
|
||||
eff = EffectStaticConfig(ColorRGBW(0, 0, 0, 0))
|
||||
self._protocol.shelve_led_effect(eff)
|
||||
|
||||
@staticmethod
|
||||
def _create_discovery_msg_light(base_name="musicmouse_json",
|
||||
display_name="Music Mouse Regal Licht"):
|
||||
id = "shelve"
|
||||
return {
|
||||
'platform': 'mqtt',
|
||||
'schema': 'json',
|
||||
'name': display_name,
|
||||
'unique_id': f'{base_name}_{id}',
|
||||
'command_topic': f'{base_name}/lights_{id}/command',
|
||||
'state_topic': f'{base_name}/lights_{id}/state',
|
||||
'color_mode': True,
|
||||
'brightness': True,
|
||||
#'device': {
|
||||
# 'manufacturer': 'bauer.tech',
|
||||
# 'model': "SK6812 LED strip",
|
||||
#},
|
||||
'effect': True,
|
||||
'effect_list': ['static', 'circular', 'wipeup', 'twocolor', 'twocolorrandom',
|
||||
"side_0.2", "side_0.5", "side_0.2_inc4", "side_0.5_inc4", "top_0.2"],
|
||||
'supported_color_modes': ['rgbw'],
|
||||
}
|
||||
|
||||
async def _send_autodiscovery_msg(self):
|
||||
topic = f"homeassistant/light/{self._discovery_spec['unique_id']}/config"
|
||||
await self._mqtt_client.publish(topic, json.dumps(self._discovery_spec).encode(), retain=True)
|
||||
|
||||
async def _notify_mqtt_state(self, state):
|
||||
state_payload = json.dumps(self._state)
|
||||
print("OUT ", state_payload)
|
||||
await self._mqtt_client.publish(self._discovery_spec['state_topic'], state_payload.encode())
|
||||
return
|
||||
|
||||
direct_ack = False
|
||||
if direct_ack == True:
|
||||
state_payload = json.dumps(state)
|
||||
else:
|
||||
s = deepcopy(self._state)
|
||||
if s['state'] == "OFF":
|
||||
state_payload = json.dumps({"state": "OFF"})
|
||||
else:
|
||||
s['color_mode'] = "rgbw"
|
||||
state_payload = json.dumps(s)
|
||||
|
||||
print("OUT ", state_payload)
|
||||
await self._mqtt_client.publish(self._discovery_spec['state_topic'], state_payload.encode())
|
||||
|
||||
|
||||
async def start_mqtt(music_mouse_protocol, server, username, password):
|
||||
reconnect_interval = 10 # [seconds]
|
||||
while True:
|
||||
try:
|
||||
async with asyncio_mqtt.Client(hostname=server, username=username, password=password) as client:
|
||||
shelve_light = ShelveLightMqtt(music_mouse_protocol, client)
|
||||
await shelve_light.init()
|
||||
async with client.filtered_messages("musicmouse_json/#") as messages:
|
||||
await client.subscribe("musicmouse_json/#")
|
||||
async for message in messages:
|
||||
await shelve_light.handle_light_message(message)
|
||||
except asyncio_mqtt.MqttError as error:
|
||||
print(f'Error "{error}". Reconnecting in {reconnect_interval} seconds')
|
||||
finally:
|
||||
await asyncio.sleep(reconnect_interval)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
class DummyProtocol:
|
||||
def shelve_led_effect(self, effect):
|
||||
print("EFF ", repr(effect))
|
||||
|
||||
password = ""
|
||||
asyncio.run(start_mqtt(DummyProtocol(), "homeassistant", "musicmouse", password))
|
||||
@@ -1,82 +0,0 @@
|
||||
import vlc
|
||||
|
||||
|
||||
class AudioPlayer:
|
||||
def __init__(self, alsa_device=None):
|
||||
params = ["-A", "alsa", "--alsa-audio-device", alsa_device] if alsa_device else []
|
||||
self.instance = vlc.Instance(*params)
|
||||
self.media_list_player = self.instance.media_list_player_new()
|
||||
self.media_player = self.media_list_player.get_media_player()
|
||||
|
||||
evm = self.media_player.event_manager()
|
||||
evm.event_attach(vlc.EventType.MediaPlayerStopped, self._callback)
|
||||
|
||||
evm2 = self.media_list_player.event_manager()
|
||||
evm2.event_attach(vlc.EventType.MediaListPlayerPlayed, self._callback)
|
||||
evm2.event_attach(vlc.EventType.MediaListPlayerStopped, self._callback)
|
||||
|
||||
self.on_playlist_end_callback = None
|
||||
|
||||
self.volume_min = None
|
||||
self.volume_max = None
|
||||
|
||||
def create_playlist(self, files):
|
||||
result = vlc.MediaList()
|
||||
for e in files:
|
||||
result.add_media(self.instance.media_new(e))
|
||||
|
||||
evm = result.event_manager()
|
||||
evm.event_attach(vlc.EventType.MediaListEndReached,
|
||||
lambda e: print("Ml CB", str(vlc.EventType(e.type))))
|
||||
evm.event_attach(vlc.EventType.MediaListItemAdded,
|
||||
lambda e: print("Ml ia CB", str(vlc.EventType(e.type))))
|
||||
|
||||
return result
|
||||
|
||||
def set_playlist(self, media_list):
|
||||
self.media_list_player.set_media_list(media_list)
|
||||
print("Setting media list of length ", media_list.count())
|
||||
self.media_list_player.set_playback_mode(vlc.PlaybackMode.default)
|
||||
|
||||
def next(self):
|
||||
return self.media_list_player.next()
|
||||
|
||||
def previous(self):
|
||||
return self.media_list_player.previous()
|
||||
|
||||
def play(self):
|
||||
self.media_list_player.play()
|
||||
|
||||
def play_from_start(self):
|
||||
self.media_list_player.play_item_at_index(0)
|
||||
|
||||
def is_playing(self):
|
||||
return self.media_list_player.is_playing()
|
||||
|
||||
def pause(self):
|
||||
self.media_list_player.pause()
|
||||
|
||||
def _callback(self, event, *args, **kwargs):
|
||||
eventStr = str(vlc.EventType(event.type))
|
||||
print(f"Got vlc event type {event.type} {eventStr} , event {event}")
|
||||
if event.type == vlc.EventType.MediaPlayerStopped:
|
||||
if self.on_playlist_end_callback:
|
||||
print("Calling playlist end cb")
|
||||
self.on_playlist_end_callback()
|
||||
#print("Callback from VLC", event, args, kwargs)
|
||||
#print(event.meta_type, event.obj, event.type)
|
||||
|
||||
def set_volume(self, volume):
|
||||
if self.volume_min and volume < self.volume_min:
|
||||
volume = self.volume_min
|
||||
if self.volume_max and volume > self.volume_max:
|
||||
volume = self.volume_max
|
||||
self.media_player.audio_set_volume(volume)
|
||||
|
||||
def set_volume_limits(self, vmin, vmax):
|
||||
self.volume_min = vmin
|
||||
self.volume_max = vmax
|
||||
|
||||
def change_volume(self, amount=1):
|
||||
vol = self.media_player.audio_get_volume() + amount
|
||||
self.set_volume(vol)
|
||||
@@ -1,3 +0,0 @@
|
||||
pyserial-asyncio==0.6
|
||||
python-vlc==3.0.12118
|
||||
hass-client==0.1.2
|
||||
@@ -1,16 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
struct ColorRGBW
|
||||
{
|
||||
uint8_t r, g, b, w;
|
||||
|
||||
ColorRGBW operator*(float s) const
|
||||
{
|
||||
return {uint8_t(s * r),
|
||||
uint8_t(s * g),
|
||||
uint8_t(s * b),
|
||||
uint8_t(s * w)};
|
||||
}
|
||||
};
|
||||
@@ -1,13 +0,0 @@
|
||||
# Put this into /etc/systemd/system/musicmouse.service
|
||||
[Unit]
|
||||
Description=Music Mouse RFID Music Player
|
||||
After=multi-user.target
|
||||
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
Restart=always
|
||||
ExecStart=/opt/musicmouse/venv/bin/python /opt/musicmouse/espmusicmouse/host_driver/main.py /media/musicmouse/
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 1.3 MiB |
BIN
hardware/3dprints/figures/croco.blend
Normal file
512068
hardware/3dprints/figures/croco.obj
Normal file
BIN
hardware/3dprints/figures/croco.stl
Normal file
BIN
hardware/3dprints/figures/raw/croco.stl
Normal file
BIN
hardware/datasheets/nfc-reader-MFRC522.pdf
Normal file
28
hardware/pinout.md
Normal file
@@ -0,0 +1,28 @@
|
||||
|
||||
Reader
|
||||
----------
|
||||
|
||||
- GND black
|
||||
- RST blue 3.3V
|
||||
- 3.3V red
|
||||
- MISO brown 21
|
||||
- SDA green 19
|
||||
- SCK yellow 18
|
||||
- MOSI orange 5
|
||||
- IRQ green single cable not connected
|
||||
|
||||
|
||||
Button Board:
|
||||
-------------
|
||||
|
||||
- rot in | white 13
|
||||
- btn2 led | grey 12
|
||||
- btn2 in | purple 14
|
||||
- rotB | blue 27
|
||||
- rotA | green 26
|
||||
- btn1 in | yellow 25
|
||||
- btn1 led | orange 33
|
||||
|
||||
rot="rotary encoder"
|
||||
in=button sense in
|
||||
led = 5V pwm
|
||||
BIN
hardware/sketch.fzz
Normal file
4
python-backend/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
config.yml
|
||||
tippen-curriculum.yml
|
||||
tippen-progress.json
|
||||
/.musicmouse-cache
|
||||
241
python-backend/README.md
Normal file
@@ -0,0 +1,241 @@
|
||||
# MusicMouse backend
|
||||
|
||||
The host application: it reads RFID tags, buttons and touch areas from the ESP32
|
||||
firmware over serial, plays music through VLC, drives three LED strips, indexes the
|
||||
music collection, and exposes everything to Home Assistant over MQTT and to a browser
|
||||
over HTTP.
|
||||
|
||||
```
|
||||
ESP32 ⇄ MusicMouseDevice ─┐ ┌─► MqttService (state out, intents in)
|
||||
VLC ⇄ VlcPlayer ────────┼──► EventBus ──────────►┤
|
||||
broker ⇄ MqttService ──────┤ ▲ └─► WebService (state out, intents in)
|
||||
browser ⇄ WebService ───────┘ │
|
||||
reactions/*.py ── call actions on ──► device / player
|
||||
```
|
||||
|
||||
Three objects own the outside world, one bus carries everything, and the *reactions*
|
||||
are the only place that decides what should happen. Adding a new way to control the
|
||||
mouse means adding a service that emits the same intents - no device or reaction
|
||||
changes. The web front-end was added exactly that way.
|
||||
|
||||
## Running it
|
||||
|
||||
```sh
|
||||
pip install -e '.[dev]'
|
||||
python -m musicmouse --config /media/musicmouse/config.yml
|
||||
```
|
||||
|
||||
See `config.yml.example` for the schema and `musicmouse.service` for the systemd unit.
|
||||
Config problems are reported all at once with the path to each one; unknown keys are
|
||||
errors, not silent no-ops.
|
||||
|
||||
### On a host with no mouse attached
|
||||
|
||||
The web front-end is a complete way to drive the player, so the backend is useful on a
|
||||
machine with no serial port and no sound card worth grabbing. Two config keys say so,
|
||||
each by taking the literal value `simulate`:
|
||||
|
||||
| Setting | `simulate` gives you | Warned about as |
|
||||
|---|---|---|
|
||||
| `serial_port` | no serial link; RFID, buttons and LEDs are inert | "running without the mouse" |
|
||||
| `alsa_device` | the simulator's player: everything works, nothing is audible | "nothing will be audible" |
|
||||
|
||||
Both keys are **required**. Leaving one out is a config error, not a shortcut to
|
||||
simulation - running blind or silent has to be asked for, so a config that lost a line
|
||||
fails loudly instead of booting into something that looks like it is working. The
|
||||
warnings are repeated on every boot for the same reason.
|
||||
|
||||
`--no-hardware` forces the serial half regardless of the config, for a one-off run:
|
||||
|
||||
```sh
|
||||
python -m musicmouse --config ./config.yml --no-hardware
|
||||
```
|
||||
|
||||
Note that `alsa_device` has no "just use whatever" value. VLC's own default would seize
|
||||
whatever the desktop is playing through, which is the wrong thing to do silently - name
|
||||
a device (`"default"` is the system one) when the machine is meant to make noise.
|
||||
|
||||
### Without hardware
|
||||
|
||||
The simulator runs the entire app - real bus, real device, real reactions, real MQTT
|
||||
if configured - against a fake serial link and a fake player.
|
||||
|
||||
```sh
|
||||
python -m musicmouse --config ./config.yml --simulate
|
||||
```
|
||||
|
||||
```
|
||||
musicmouse> place fuchs
|
||||
in RfidTokenRead(04a1b2c3d4, figure='fuchs')
|
||||
rfid fuchs
|
||||
led ring: SwipeAndChange(AlexaSwipe(#(1.0, 0.4, 0.0, 0) -> ...))
|
||||
play playing
|
||||
musicmouse> press right
|
||||
play track 1: 01 - Song 1
|
||||
```
|
||||
|
||||
`help` lists the verbs. The same verbs go in a scenario file:
|
||||
|
||||
```sh
|
||||
python -m musicmouse --config ./config.yml --simulate --script scenarios/smoke.txt
|
||||
```
|
||||
|
||||
Scenario files run on a virtual clock under pytest, so `wait 1s` costs microseconds and
|
||||
every file in `scenarios/` is part of the test suite. A session reproduced by hand at
|
||||
the prompt becomes a regression test by pasting it into a `.txt` file.
|
||||
|
||||
## Layout
|
||||
|
||||
| Path | What it is |
|
||||
|---|---|
|
||||
| `musicmouse/bus.py` | One FIFO queue on one loop. Thread-safe `emit()` - which is how libVLC's callback thread stops reaching the serial transport. |
|
||||
| `musicmouse/events.py` | The vocabulary: **input** (something happened), **intent** (something was requested), **state** (something changed). |
|
||||
| `musicmouse/devices/` | `mouse.py` (firmware), `player.py` (VLC), `wire.py` (pure codec), `serial_link.py` (transport + reconnect). |
|
||||
| `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/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/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. |
|
||||
|
||||
## Checks
|
||||
|
||||
```sh
|
||||
pytest # unit + scenario tests
|
||||
ruff check .
|
||||
mypy # --strict, configured in pyproject.toml
|
||||
```
|
||||
|
||||
`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
|
||||
languages, and it had already drifted once (`BUTTON_EVENT` was missing on the Python
|
||||
side). `tests/test_effects.py` pins the exact bytes of every effect payload.
|
||||
|
||||
## LED arbitration
|
||||
|
||||
`MusicMouseDevice` is the single writer to each zone, and the most recent effect wins -
|
||||
whether it came from a figure animation or from Home Assistant. There is no priority
|
||||
scheme. Every write emits `LedEffectChanged`, and the MQTT light entities publish their
|
||||
state from that rather than echoing their own commands, so HA keeps showing the strip's
|
||||
real state when a figure animation overrides a colour it set.
|
||||
|
||||
## Home Assistant
|
||||
|
||||
The backend no longer calls Home Assistant directly (`hass-client` is gone). It
|
||||
publishes what happened; the automations live in HA.
|
||||
|
||||
MQTT device triggers are published for every button (`pressed`, `double_clicked`,
|
||||
`long_pressed`), every touch area (touched/released), and the RFID reader appears as a
|
||||
tag scanner. Topics are under `musicmouse/trigger/…` and `musicmouse/tag`.
|
||||
|
||||
### Recreating the old room-light behaviour
|
||||
|
||||
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
|
||||
trigger, action `light.toggle` on `light.kinderzimmer_fluter`.
|
||||
|
||||
**Touching a body part set a colour** on `light.kinderzimmer_fluter` and
|
||||
`light.music_mouse_regal_licht`:
|
||||
|
||||
| Touch area | Old service data |
|
||||
|---|---|
|
||||
| `right_foot` | `rgb_color: [235, 255, 67]` |
|
||||
| `left_foot` | `color_temp: 469` |
|
||||
| `right_ear` | `rgb_color: [101, 49, 255]` |
|
||||
| `left_ear` | `rgb_color: [255, 74, 254]` |
|
||||
|
||||
Trigger on the corresponding `*_touched` device trigger and call `light.turn_on` with
|
||||
that data.
|
||||
|
||||
## The library
|
||||
|
||||
`general.library.root` is the one path to the music. The shelves under it are fixed
|
||||
names rather than settings (`musicmouse/library/sections.py`), because each has quirks
|
||||
the scanner has to know about:
|
||||
|
||||
| Folder | Shown as | Why it is special |
|
||||
|---|---|---|
|
||||
| `Figuren/<figure>/` | an album per figurine | the folder name *is* the figure name from the config |
|
||||
| `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 |
|
||||
| `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 |
|
||||
|
||||
Only files whose suffix is in `audio_extensions` are read, and dotfiles are skipped, so
|
||||
a podcast downloader's `archive.json` and its half-finished `.download.tmp` never reach
|
||||
a playlist.
|
||||
|
||||
Drop a `feed.txt` into a show folder (its first line the show's RSS feed URL) and the
|
||||
backend becomes that podcast downloader itself: every six hours it checks the feed and
|
||||
saves any episode not already on disk, named `YYYYMMDD - Title.ext` like a hand-placed
|
||||
one so it sorts and scans identically. A show with no `feed.txt` is untouched, exactly
|
||||
as before - the file is the opt-in, there is no separate setting for it.
|
||||
|
||||
Each album carries three colours, pulled out of its cover art with Pillow (or
|
||||
synthesised from a hash of its id when it has none). The frontend paints cards with
|
||||
them and the LED strips run the first of them, so shelf and screen agree.
|
||||
|
||||
### The cache
|
||||
|
||||
`general.library.cache` is a directory, not a file, because its contents cost wildly
|
||||
different amounts to produce:
|
||||
|
||||
```
|
||||
index.json cheap: tags and structure. Rebuilt freely.
|
||||
covers/<album_id>.jpg medium: art extracted from an ID3 APIC frame
|
||||
analysis/<track_key>.json expensive: reserved for offline audio analysis
|
||||
```
|
||||
|
||||
Deleting the whole directory is safe; deleting it throws away analysis that is minutes
|
||||
of DSP per track, which is why anything expensive is keyed by *file content* rather than
|
||||
by album id - renaming a folder or re-sorting a section then costs nothing.
|
||||
|
||||
An entry is reused whenever its files' sizes and mtimes are unchanged. That means a
|
||||
change to how the scanner derives a title, artist or series is invisible until the cache
|
||||
is invalidated: bump `_INDEX_VERSION` in `musicmouse/library/cache.py` when you touch
|
||||
that logic.
|
||||
|
||||
**Track analysis is not implemented.** `musicmouse/library/analysis.py` fixes the shape
|
||||
of the results - scalars (`tempo`, `energy`, `valence`, `brightness`) travel inline with
|
||||
the index, and a beat grid lives in its own file and is fetched per track - so an
|
||||
analyzer can be added later without touching the scanner, the API or the frontend. It
|
||||
will go behind an optional dependency group, and because results are content-keyed
|
||||
files, they can equally well be computed on a workstation and the `analysis/` folder
|
||||
copied to the device.
|
||||
|
||||
## The web front-end
|
||||
|
||||
`web/` in the repo root, served by this backend when `general.web.static_dir` is set.
|
||||
|
||||
| Route | Purpose |
|
||||
|---|---|
|
||||
| `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. |
|
||||
| `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. |
|
||||
| `WS /api/ws` | Push only. A snapshot on connect, then a frame per change, plus the position at 2 Hz while playing. |
|
||||
| `POST /api/play` | `{album_id, track_index?}` |
|
||||
| `POST /api/resume` `/pause` `/next` `/previous` `/seek` | transport |
|
||||
| `POST /api/volume` | `{percent}` or `{delta_percent}` |
|
||||
| `GET` `PUT /api/settings` | parent mode |
|
||||
|
||||
Two decisions worth knowing:
|
||||
|
||||
**Search is not an endpoint.** The whole index goes to the browser and filtering happens
|
||||
there, which is what makes the design's type-to-search feel instant.
|
||||
|
||||
**Volume is a percentage at this boundary.** `max_volume` is a parent's business, not a
|
||||
child's, so it never crosses into the browser: `100 %` means whatever ceiling is
|
||||
configured, and the mapping lives in `services/web/settings.py` so MQTT, the rotary
|
||||
encoder and the firmware carry on in device units.
|
||||
|
||||
### Parent mode
|
||||
|
||||
`?parentMode=1` reveals a settings panel for the volume limits, the rotary step and the
|
||||
button brightness. Saving writes `config.yml` back through ruamel's round-trip loader,
|
||||
so the file keeps its comments, and a new ceiling applies to the running player rather
|
||||
than waiting for a restart.
|
||||
|
||||
This **hides** the settings; it does not protect them. There is no authentication on any
|
||||
endpoint, which matches a device on a home network - put it behind a reverse proxy if
|
||||
that is not good enough.
|
||||
184
python-backend/config.yml.example
Normal file
@@ -0,0 +1,184 @@
|
||||
# Example config for the MusicMouse backend.
|
||||
#
|
||||
# python -m musicmouse --config /media/musicmouse/config.yml
|
||||
#
|
||||
# Unknown keys are rejected rather than ignored, and every problem in the file is
|
||||
# reported at once, so a typo fails at startup with the path to the offending line.
|
||||
# Keep the real config (with credentials) off the repo - on the device only.
|
||||
|
||||
general:
|
||||
# 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:
|
||||
#
|
||||
# <root>/Figuren/<figure name>/ one folder per figurine
|
||||
# <root>/Musik/<Artist> - <Album>/ albums, grouped by artist
|
||||
# <root>/Hörbücher/<Artist> - <Album>/ audiobooks, grouped by character
|
||||
# <root>/Kinderpodcasts/<Show>/ shows, newest episode first
|
||||
#
|
||||
# A cover.jpg next to the audio is used if present, otherwise the art is pulled out
|
||||
# of the files' tags. Relative paths resolve against this file's directory.
|
||||
library:
|
||||
root: /home/martin/Music
|
||||
# Scan results, extracted cover art and track analysis. Safe to delete: the index
|
||||
# is rebuilt on the next start. Deleting it does throw away track analysis, which
|
||||
# is expensive to recompute.
|
||||
cache: .musicmouse-cache
|
||||
# How many tracks the background analyzer may work on at once, each in its own
|
||||
# worker process. Omitted means one per core bar one (capped at 8), which is what
|
||||
# turns a first-time pass over a whole library from an overnight job into a coffee
|
||||
# break on a desktop. Set it to 1 on a machine that has better things to do, or to
|
||||
# a specific number to cap how much of it analysis may take.
|
||||
# analysis_workers: 4
|
||||
|
||||
# Serial port the ESP32 firmware is on. A dropped link is retried, not fatal.
|
||||
# Required - use "simulate" to run without the mouse attached, which is a complete
|
||||
# setup on its own because the web front-end can drive the player by itself. RFID,
|
||||
# buttons and LEDs then do nothing, and startup says so every boot.
|
||||
serial_port: "/dev/ttyUSB0"
|
||||
baudrate: 115200
|
||||
reconnect_interval: 5.0
|
||||
|
||||
# ALSA output device passed to VLC, e.g. "hw:0,0", or "default" for the system
|
||||
# default output. Required - use "simulate" for a player that makes no sound, which
|
||||
# is handy when working on the web UI on a machine whose audio you would rather not
|
||||
# commandeer. Startup says so every boot.
|
||||
#
|
||||
# Both of these are required rather than optional on purpose: running blind or silent
|
||||
# has to be asked for, so a config that lost a line fails loudly instead of booting
|
||||
# into something that looks like it is working.
|
||||
alsa_device: "softvol_effects"
|
||||
|
||||
# Volume, 0..100. min/max clamp everything, including the rotary encoder.
|
||||
min_volume: 0
|
||||
max_volume: 60
|
||||
initial_volume: 40
|
||||
volume_increment: 5 # per rotary-encoder click
|
||||
|
||||
# Backlight of the prev/next buttons while a figure is playing, 0..1.
|
||||
button_leds_brightness: 0.5
|
||||
|
||||
# Which files count as music. Anything else - a podcast downloader's archive.json,
|
||||
# a half-finished .tmp - is ignored.
|
||||
audio_extensions: [".mp3", ".ogg", ".oga", ".opus", ".flac", ".wav", ".m4a", ".aac"]
|
||||
|
||||
# How many episodes of each podcast show to keep, newest first. A show that has
|
||||
# published for years grows without bound: GEOlino Spezial alone is 358 episodes and
|
||||
# 5.8 GB, which will not sit next to the rest of a library on a Pi's SD card.
|
||||
#
|
||||
# The same number caps what gets downloaded, which is what makes the folder settle.
|
||||
# Prune to the newest N but fetch everything the feed offers, and every poll would
|
||||
# re-download the episodes the last one deleted.
|
||||
#
|
||||
# Lowering this DELETES the episodes that fall outside the window on the next poll,
|
||||
# and an episode that has aged out of its feed cannot be fetched again. Use `null` to
|
||||
# keep every episode and mind the free space yourself.
|
||||
podcast_episode_limit: 50
|
||||
|
||||
# The web front-end. Omit the whole section to run without it.
|
||||
#
|
||||
# There is no authentication: this is a device on a home network. The settings panel
|
||||
# at ?parentMode=1 is hidden from the child, not protected from them - it writes back
|
||||
# to this file. Put it behind a reverse proxy if that is not good enough.
|
||||
web:
|
||||
host: "0.0.0.0"
|
||||
port: 8080
|
||||
# Built frontend to serve at /. Omit to expose only the JSON API.
|
||||
static_dir: ../web/dist
|
||||
|
||||
# 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.
|
||||
# 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.
|
||||
lirc:
|
||||
host: "musicmouse-pi.local"
|
||||
port: 2222 # this deployment's lircd listens on 2222, not its own
|
||||
# default of 8765 - see the ansible role
|
||||
remote_name: "Hauppauge" # other remotes registered with the same lircd (an LED
|
||||
# remote, say) are ignored
|
||||
reconnect_interval: 5.0
|
||||
|
||||
# Home Assistant integration. Omit the whole section to run without MQTT.
|
||||
# The backend exposes three lights, a player sensor, a volume slider, transport
|
||||
# buttons, device triggers for every button/touch area, and a tag scanner.
|
||||
mqtt:
|
||||
server: "homeassistant.local"
|
||||
port: 1883
|
||||
user: "musicmouse"
|
||||
password: "REPLACE_WITH_MQTT_PASSWORD"
|
||||
base_topic: "musicmouse"
|
||||
discovery_prefix: "homeassistant"
|
||||
device_id: "musicmouse"
|
||||
device_name: "Music Mouse"
|
||||
reconnect_interval: 10.0
|
||||
|
||||
# Room control page ("Mein Zimmer"). Omit the whole section to hide the page. This is
|
||||
# the opposite direction from mqtt above: it's musicmouse controlling Home Assistant
|
||||
# entities, not the other way round. The backend proxies every call to Home
|
||||
# Assistant's REST API with this token attached; the browser never sees it, only
|
||||
# entity ids and display names. Home Assistant's own CORS settings do not need to
|
||||
# allow musicmouse's origin for this - the browser only ever talks to musicmouse.
|
||||
ha:
|
||||
url: "http://homeassistant.local:8123"
|
||||
# A long-lived access token, created under the HA user's own profile page.
|
||||
token: "REPLACE_WITH_HA_LONG_LIVED_TOKEN"
|
||||
# Cards on the room page, in this order. "name" is optional; falls back to the
|
||||
# entity id if omitted.
|
||||
devices:
|
||||
- entity_id: cover.kinderzimmer_rollo
|
||||
name: "Rollo"
|
||||
- entity_id: light.kinderzimmer_hue_beyond_links
|
||||
name: "Hue Beyond links"
|
||||
- entity_id: light.kinderzimmer_deckenlampe
|
||||
name: "Deckenlampe"
|
||||
# Scene pill row above the cards, in this order.
|
||||
scenes:
|
||||
- entity_id: scene.kinderzimmer_lesen
|
||||
name: "Lesen"
|
||||
- entity_id: scene.kinderzimmer_gute_nacht
|
||||
name: "Gute Nacht"
|
||||
|
||||
# The typing game ("Tippen"). Omit the whole section to hide its tab in the web
|
||||
# front-end. The lesson plan is content, not device config, so it lives in its own
|
||||
# file - see tippen-curriculum.yml.example for the format, including the optional
|
||||
# `unlocks:` key that turns passing a lesson into unlocking part of the library.
|
||||
tippen:
|
||||
curriculum_file: tippen-curriculum.yml
|
||||
# Where progress (stars, unlocked lessons, streak, ...) is saved. Written by the
|
||||
# app itself - never hand-edited. Relative to this file, like curriculum_file.
|
||||
progress_file: tippen-progress.json
|
||||
|
||||
# One entry per figurine. The key is the figure name and the subfolder name.
|
||||
figures:
|
||||
fuchs:
|
||||
# RFID tag id, 5 bytes as hex. Must be unique across figures.
|
||||
id: "04a1b2c3d4"
|
||||
# Exactly four colours: primary, secondary, background, accent.
|
||||
# Either "#rrggbb" (RGB) or "wNN" (white channel only, hex).
|
||||
colors: ["#ff6600", "#ffcc00", "#331100", "wff"]
|
||||
# "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
|
||||
# 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.
|
||||
kind: music
|
||||
|
||||
eule:
|
||||
id: "04b2c3d4e5"
|
||||
colors: ["#3355ff", "#66aaff", "#001133", "#ffffff"]
|
||||
kind: book
|
||||
|
||||
# Number keys 0-9 on the IR remote, mapped to what they play. Omit the whole section,
|
||||
# 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.
|
||||
#
|
||||
# target_kind: album -> always starts from the first track (music, audiobooks).
|
||||
# target is an album id, as shown at GET /api/library.
|
||||
# target_kind: series -> always plays the newest episode of a podcast show, resolved
|
||||
# fresh on every press - never a fixed episode. target is the
|
||||
# show's folder name under Kinderpodcasts, e.g. "Wissen macht Ah".
|
||||
remote:
|
||||
"1":
|
||||
target_kind: album
|
||||
target: "3f9a0c12ab44"
|
||||
"2":
|
||||
target_kind: series
|
||||
target: "Wissen macht Ah"
|
||||
24
python-backend/musicmouse.service
Normal file
@@ -0,0 +1,24 @@
|
||||
# Put this into /etc/systemd/system/musicmouse.service
|
||||
#
|
||||
# Assumes the repo is checked out at /opt/musicmouse with a venv at /opt/musicmouse/.venv:
|
||||
# /opt/musicmouse/.venv/bin/pip install -e /opt/musicmouse/python-backend
|
||||
#
|
||||
# A dropped serial link is now handled in-process (SerialLink reconnects), so
|
||||
# Restart=always is only for genuine crashes.
|
||||
|
||||
[Unit]
|
||||
Description=Music Mouse RFID Music Player
|
||||
After=multi-user.target sound.target network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
WorkingDirectory=/opt/musicmouse/python-backend
|
||||
ExecStart=/opt/musicmouse/.venv/bin/python -m musicmouse --config /media/musicmouse/config.yml
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||