Add design references, editor settings and todo
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
27
claude-design/AppHeader.dc.html
Normal file
27
claude-design/AppHeader.dc.html
Normal file
@@ -0,0 +1,27 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
<div style="display:grid;grid-template-columns:1fr auto 1fr;align-items:center;gap:16px;padding:14px 78px 8px 32px;">
|
||||
<a href="{{ linkHref }}" style="justify-self:start;text-decoration:none;background:oklch(97% 0.01 230 / .95);border-radius:999px;padding:9px 16px;font-size:14px;font-weight:800;color:oklch(30% 0.03 230);display:inline-flex;align-items:center;gap:8px;box-shadow:0 6px 18px oklch(15% 0.04 230 / .4);white-space:nowrap;">{{ linkLabel }}</a>
|
||||
|
||||
<div style="display:flex;align-items:center;gap:12px;">
|
||||
<img src="{{ mascot }}" alt="Delfin" style="width:82px;height:82px;object-fit:contain;flex:none;filter:drop-shadow(0 4px 10px oklch(15% 0.04 230 / .45));">
|
||||
<div style="font-size:44px;font-weight:900;color:oklch(97% 0.01 230);letter-spacing:.5px;white-space:nowrap;">{{ title }}</div>
|
||||
</div>
|
||||
|
||||
<div style="justify-self:end;display:flex;align-items:center;gap:8px;">
|
||||
<sc-for list="{{ actions }}" as="action" hint-placeholder-count="0">
|
||||
<button onClick="{{ action.onClick }}" style="{{ action.style }}">{{ action.label }}</button>
|
||||
</sc-for>
|
||||
</div>
|
||||
</div>
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script data-props="{"$preview": {"width": 1180, "height": 100}, "title": {"editor": "text", "default": "Musik Delphin", "tsType": "string"}, "mascot": {"editor": "text", "default": "dolphin-mascot.png", "tsType": "string"}, "linkHref": {"editor": "text", "default": "./Mein%20Zimmer.dc.html", "tsType": "string"}, "linkLabel": {"editor": "text", "default": "💡 Mein Zimmer", "tsType": "string"}, "actions": {"editor": null, "tsType": "Array<{label: string, style: string, onClick: () => void}>"}}"></script>
|
||||
</body>
|
||||
</html>
|
||||
BIN
claude-design/Dolphin Beats Music Player.zip
Normal file
BIN
claude-design/Dolphin Beats Music Player.zip
Normal file
Binary file not shown.
920
claude-design/Dolphin Beats.dc.html
Normal file
920
claude-design/Dolphin Beats.dc.html
Normal file
@@ -0,0 +1,920 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
<helmet>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Nunito:wght@500;700;800;900&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
@keyframes bubbleRise { 0% { transform: translateY(0) scale(1); opacity: .55; } 100% { transform: translateY(-120vh) scale(1.3); opacity: 0; } }
|
||||
@keyframes dolphinBob { 0%, 100% { transform: translateY(0) rotate(-2deg); } 50% { transform: translateY(-10px) rotate(2deg); } }
|
||||
@keyframes dolphinSwim {
|
||||
0% { transform: translate(-20vw, 10vh) rotate(-3deg) scaleX(1); }
|
||||
15% { transform: translate(2vw, 2vh) rotate(4deg) scaleX(1); }
|
||||
30% { transform: translate(24vw, 12vh) rotate(-3deg) scaleX(1); }
|
||||
45% { transform: translate(46vw, 2vh) rotate(4deg) scaleX(1); }
|
||||
60% { transform: translate(68vw, 12vh) rotate(-3deg) scaleX(1); }
|
||||
70% { transform: translate(84vw, 6vh) rotate(0deg) scaleX(1); }
|
||||
74% { transform: translate(84vw, 6vh) rotate(0deg) scaleX(-1); }
|
||||
85% { transform: translate(52vw, 18vh) rotate(-3deg) scaleX(-1); }
|
||||
95% { transform: translate(16vw, 6vh) rotate(3deg) scaleX(-1); }
|
||||
100% { transform: translate(-20vw, 10vh) rotate(-3deg) scaleX(-1); }
|
||||
}
|
||||
body { margin: 0; font-family: 'Nunito', system-ui, sans-serif; }
|
||||
a { color: oklch(70% 0.16 340); }
|
||||
a:hover { color: oklch(55% 0.16 340); }
|
||||
</style>
|
||||
</helmet>
|
||||
|
||||
<div style="position:relative;width:100%;height:100vh;overflow:hidden;background:linear-gradient(180deg, oklch(55% 0.07 210) 0%, oklch(38% 0.06 210) 45%, oklch(20% 0.045 210) 100%);">
|
||||
|
||||
<div style="position:absolute;left:5%;bottom:-40px;width:14px;height:14px;border-radius:50%;background:oklch(90% 0.02 210 / .5);animation:bubbleRise 9s linear infinite;animation-delay:0s;"></div>
|
||||
<div style="position:absolute;left:15%;bottom:-40px;width:22px;height:22px;border-radius:50%;background:oklch(90% 0.02 210 / .4);animation:bubbleRise 12s linear infinite;animation-delay:2s;"></div>
|
||||
<div style="position:absolute;left:28%;bottom:-40px;width:10px;height:10px;border-radius:50%;background:oklch(90% 0.02 210 / .5);animation:bubbleRise 7s linear infinite;animation-delay:1s;"></div>
|
||||
<div style="position:absolute;left:42%;bottom:-40px;width:18px;height:18px;border-radius:50%;background:oklch(90% 0.02 210 / .45);animation:bubbleRise 10s linear infinite;animation-delay:4s;"></div>
|
||||
<div style="position:absolute;left:58%;bottom:-40px;width:12px;height:12px;border-radius:50%;background:oklch(90% 0.02 210 / .5);animation:bubbleRise 8s linear infinite;animation-delay:3s;"></div>
|
||||
<div style="position:absolute;left:70%;bottom:-40px;width:26px;height:26px;border-radius:50%;background:oklch(90% 0.02 210 / .35);animation:bubbleRise 13s linear infinite;animation-delay:5s;"></div>
|
||||
<div style="position:absolute;left:82%;bottom:-40px;width:16px;height:16px;border-radius:50%;background:oklch(90% 0.02 210 / .5);animation:bubbleRise 9.5s linear infinite;animation-delay:1.5s;"></div>
|
||||
<div style="position:absolute;left:92%;bottom:-40px;width:10px;height:10px;border-radius:50%;background:oklch(90% 0.02 210 / .5);animation:bubbleRise 6.5s linear infinite;animation-delay:2.5s;"></div>
|
||||
<div style="position:absolute;left:2%;bottom:-40px;width:8px;height:8px;border-radius:50%;background:oklch(90% 0.02 210 / .45);animation:bubbleRise 7.5s linear infinite;animation-delay:3.5s;"></div>
|
||||
<div style="position:absolute;left:9%;bottom:-40px;width:18px;height:18px;border-radius:50%;background:oklch(90% 0.02 210 / .3);animation:bubbleRise 14s linear infinite;animation-delay:6s;"></div>
|
||||
<div style="position:absolute;left:21%;bottom:-40px;width:12px;height:12px;border-radius:50%;background:oklch(90% 0.02 210 / .5);animation:bubbleRise 8.5s linear infinite;animation-delay:5.5s;"></div>
|
||||
<div style="position:absolute;left:34%;bottom:-40px;width:24px;height:24px;border-radius:50%;background:oklch(90% 0.02 210 / .32);animation:bubbleRise 12.5s linear infinite;animation-delay:1.2s;"></div>
|
||||
<div style="position:absolute;left:38%;bottom:-40px;width:9px;height:9px;border-radius:50%;background:oklch(90% 0.02 210 / .5);animation:bubbleRise 6.8s linear infinite;animation-delay:4.6s;"></div>
|
||||
<div style="position:absolute;left:48%;bottom:-40px;width:14px;height:14px;border-radius:50%;background:oklch(90% 0.02 210 / .42);animation:bubbleRise 10.5s linear infinite;animation-delay:0.6s;"></div>
|
||||
<div style="position:absolute;left:53%;bottom:-40px;width:20px;height:20px;border-radius:50%;background:oklch(90% 0.02 210 / .3);animation:bubbleRise 13.5s linear infinite;animation-delay:7s;"></div>
|
||||
<div style="position:absolute;left:63%;bottom:-40px;width:8px;height:8px;border-radius:50%;background:oklch(90% 0.02 210 / .5);animation:bubbleRise 7.2s linear infinite;animation-delay:2.2s;"></div>
|
||||
<div style="position:absolute;left:66%;bottom:-40px;width:16px;height:16px;border-radius:50%;background:oklch(90% 0.02 210 / .38);animation:bubbleRise 11.5s linear infinite;animation-delay:5.2s;"></div>
|
||||
<div style="position:absolute;left:76%;bottom:-40px;width:11px;height:11px;border-radius:50%;background:oklch(90% 0.02 210 / .48);animation:bubbleRise 9.2s linear infinite;animation-delay:6.4s;"></div>
|
||||
<div style="position:absolute;left:87%;bottom:-40px;width:22px;height:22px;border-radius:50%;background:oklch(90% 0.02 210 / .28);animation:bubbleRise 15s linear infinite;animation-delay:3.2s;"></div>
|
||||
<div style="position:absolute;left:96%;bottom:-40px;width:14px;height:14px;border-radius:50%;background:oklch(90% 0.02 210 / .42);animation:bubbleRise 10.8s linear infinite;animation-delay:8s;"></div>
|
||||
<div style="position:absolute;left:45%;bottom:-40px;width:7px;height:7px;border-radius:50%;background:oklch(90% 0.02 210 / .5);animation:bubbleRise 6.2s linear infinite;animation-delay:7.6s;"></div>
|
||||
|
||||
<sc-if value="{{ isBrowse }}" hint-placeholder-val="{{ true }}">
|
||||
<div style="position:relative;z-index:1;height:100%;display:flex;flex-direction:column;">
|
||||
|
||||
<!-- header -->
|
||||
<dc-import name="AppHeader" title="Musik Delphin" mascot="dolphin-mascot.png" link-href="./Mein%20Zimmer.dc.html" link-label="💡 Mein Zimmer" hint-size="100%,116px"></dc-import>
|
||||
|
||||
<div style="display:flex;align-items:center;justify-content:center;gap:10px;padding:4px 32px 6px;">
|
||||
<button onClick="{{ setFilterAll }}" style="{{ filterAllStyle }}">Alles</button>
|
||||
<button onClick="{{ setFilterMusic }}" style="{{ filterMusicStyle }}">🎵 Musik</button>
|
||||
<button onClick="{{ setFilterBook }}" style="{{ filterBookStyle }}">📖 Hörbücher</button>
|
||||
</div>
|
||||
|
||||
<!-- search pill -->
|
||||
<sc-if value="{{ showSearchBar }}" hint-placeholder-val="{{ false }}">
|
||||
<div style="margin:2px 32px 6px;display:flex;align-items:center;justify-content:center;gap:12px;">
|
||||
<div style="background:oklch(97% 0.01 210);color:oklch(30% 0.04 210);font-weight:800;font-size:20px;padding:10px 20px;border-radius:999px;box-shadow:0 4px 14px oklch(15% 0.05 210 / .35);display:flex;align-items:center;gap:10px;">
|
||||
<span style="font-size:14px;font-weight:800;background:oklch(30% 0.04 210);color:#fff;padding:4px 10px;border-radius:999px;">{{ modeLabel }}</span>
|
||||
<span>{{ searchDisplay }}</span>
|
||||
</div>
|
||||
<div style="color:oklch(90% 0.02 210 / .85);font-weight:700;font-size:14px;">{{ resultCountLabel }}</div>
|
||||
</div>
|
||||
</sc-if>
|
||||
|
||||
<!-- results -->
|
||||
<div ref="{{ scrollerRef }}" style="flex:1;overflow:auto;min-height:0;padding:14px 32px 250px;">
|
||||
|
||||
<!-- song hits -->
|
||||
<sc-if value="{{ hasSongHits }}" hint-placeholder-val="{{ false }}">
|
||||
<div style="margin-bottom:30px;">
|
||||
<div style="display:flex;align-items:baseline;justify-content:center;gap:10px;margin-bottom:12px;">
|
||||
<div style="font-size:19px;font-weight:900;color:oklch(97% 0.01 210);">Titel</div>
|
||||
<div style="font-size:13px;font-weight:700;color:oklch(88% 0.02 210 / .7);">{{ songHitLabel }}</div>
|
||||
</div>
|
||||
<div style="display:flex;flex-direction:column;gap:8px;max-width:760px;margin:0 auto;">
|
||||
<sc-for list="{{ songHits }}" as="song" hint-placeholder-count="3">
|
||||
<div onClick="{{ playSongHit }}" data-nav-index="{{ song.navIndex }}" data-album="{{ song.albumId }}" data-index="{{ song.index }}" style="{{ song.rowStyle }}">
|
||||
<div style="{{ song.chipStyle }}"></div>
|
||||
<div style="flex:1;min-width:0;">
|
||||
<div style="font-size:16px;font-weight:800;color:oklch(97% 0.01 210);">{{ song.title }}</div>
|
||||
<div style="font-size:12px;font-weight:700;color:oklch(88% 0.02 210 / .65);">{{ song.albumLine }}</div>
|
||||
</div>
|
||||
<div style="font:700 13px ui-monospace,Menlo,monospace;color:oklch(88% 0.02 210 / .6);">{{ song.duration }}</div>
|
||||
</div>
|
||||
</sc-for>
|
||||
</div>
|
||||
</div>
|
||||
</sc-if>
|
||||
|
||||
<!-- category grid -->
|
||||
<sc-if value="{{ hasCategories }}" hint-placeholder-val="{{ true }}">
|
||||
<div>
|
||||
<div style="font-size:19px;font-weight:900;color:oklch(97% 0.01 210);margin-bottom:12px;text-align:center;">{{ categorySectionLabel }}</div>
|
||||
<div ref="{{ gridRef }}" style="display:grid;grid-template-columns:repeat(auto-fill, minmax(180px, 1fr));gap:24px;max-width:1180px;margin:0 auto;">
|
||||
<sc-for list="{{ categories }}" as="cat" hint-placeholder-count="8">
|
||||
<div onClick="{{ openCategory }}" data-nav-index="{{ cat.navIndex }}" data-key="{{ cat.key }}" style="{{ cat.cardStyle }}">
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:4px;padding:8px;aspect-ratio:1;">
|
||||
<sc-for list="{{ cat.tiles }}" as="tile" hint-placeholder-count="4">
|
||||
<div style="{{ tile.style }}"></div>
|
||||
</sc-for>
|
||||
</div>
|
||||
<div style="padding:4px 12px 14px;">
|
||||
<div style="font-size:16px;font-weight:800;color:oklch(22% 0.03 210);line-height:1.2;">{{ cat.name }}</div>
|
||||
<div style="font-size:12px;font-weight:800;color:oklch(40% 0.17 340);margin-top:5px;">{{ cat.countLabel }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</sc-for>
|
||||
</div>
|
||||
</div>
|
||||
</sc-if>
|
||||
|
||||
<!-- album grid -->
|
||||
<sc-if value="{{ hasAlbumHits }}" hint-placeholder-val="{{ false }}">
|
||||
<div>
|
||||
<sc-if value="{{ hasCategoryCrumb }}" hint-placeholder-val="{{ false }}">
|
||||
<div style="display:flex;align-items:center;justify-content:center;gap:12px;margin-bottom:14px;">
|
||||
<button onClick="{{ clearCategory }}" style="border:none;cursor:pointer;font-family:inherit;font-size:14px;font-weight:800;padding:9px 16px;border-radius:999px;background:oklch(97% 0.01 210 / .95);color:oklch(30% 0.04 210);">← Alle</button>
|
||||
<div style="font-size:22px;font-weight:900;color:oklch(97% 0.01 210);">{{ categoryCrumb }}</div>
|
||||
</div>
|
||||
</sc-if>
|
||||
<sc-if value="{{ hasSearch }}" hint-placeholder-val="{{ false }}">
|
||||
<div style="font-size:19px;font-weight:900;color:oklch(97% 0.01 210);margin-bottom:12px;text-align:center;">{{ albumSectionLabel }}</div>
|
||||
</sc-if>
|
||||
<div ref="{{ gridRef }}" style="display:grid;grid-template-columns:repeat(auto-fill, minmax(180px, 1fr));gap:24px;max-width:1180px;margin:0 auto;justify-content:center;">
|
||||
<sc-for list="{{ filteredAlbums }}" as="album" hint-placeholder-count="8">
|
||||
<div onClick="{{ openAlbumCard }}" data-nav-index="{{ album.navIndex }}" data-id="{{ album.id }}" style="{{ album.cardStyle }}">
|
||||
<div style="{{ album.coverStyle }}">
|
||||
<span style="{{ album.coverLabelStyle }}">{{ album.coverLabel }}</span>
|
||||
</div>
|
||||
<div style="{{ album.bodyStyle }}">
|
||||
<div style="font-size:16px;font-weight:800;color:oklch(22% 0.03 210);line-height:1.2;">{{ album.title }}</div>
|
||||
<div style="font-size:13px;font-weight:700;color:oklch(30% 0.03 210);margin-top:2px;">{{ album.artist }}</div>
|
||||
<div style="font-size:12px;font-weight:800;color:oklch(40% 0.17 340);margin-top:6px;">{{ album.songCountLabel }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</sc-for>
|
||||
</div>
|
||||
</div>
|
||||
</sc-if>
|
||||
|
||||
<sc-if value="{{ hasNoResults }}" hint-placeholder-val="{{ false }}">
|
||||
<div style="text-align:center;margin-top:60px;color:oklch(92% 0.02 210 / .8);">
|
||||
<img src="dolphin-mascot.png" alt="" style="width:120px;height:120px;object-fit:contain;opacity:.9;">
|
||||
<div style="font-size:22px;font-weight:800;margin-top:10px;">Nichts gefunden — probier andere Buchstaben!</div>
|
||||
</div>
|
||||
</sc-if>
|
||||
</div>
|
||||
</div>
|
||||
</sc-if>
|
||||
|
||||
<!-- play view -->
|
||||
<sc-if value="{{ isPlay }}" hint-placeholder-val="{{ false }}">
|
||||
<div style="position:relative;z-index:1;height:100%;min-height:0;overflow:auto;">
|
||||
<div style="min-height:100%;box-sizing:border-box;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:clamp(8px, 1.8vh, 26px);padding:clamp(14px, 3.5vh, 40px) 40px;">
|
||||
|
||||
<img src="dolphin-mascot.png" alt="Delfin mit Kopfhörern" style="position:absolute;top:14%;left:0;width:140px;height:140px;object-fit:contain;z-index:0;pointer-events:none;opacity:.92;animation:dolphinSwim 62s linear infinite;filter:drop-shadow(0 10px 26px oklch(10% 0.05 210 / .45));">
|
||||
|
||||
<div style="{{ bigCoverStyle }}">
|
||||
<span style="font-family:ui-monospace,Menlo,monospace;font-size:13px;color:oklch(98% 0 0 / .85);letter-spacing:1px;">ALBUM COVER</span>
|
||||
</div>
|
||||
|
||||
<div style="text-align:center;max-width:760px;position:relative;z-index:1;flex:none;">
|
||||
<div style="font-size:clamp(28px, 4.6vh, 46px);font-weight:900;color:oklch(97% 0.01 210);line-height:1.1;text-wrap:pretty;">{{ currentSongTitle }}</div>
|
||||
<div style="font-size:20px;font-weight:700;color:oklch(88% 0.02 210 / .8);margin-top:8px;">{{ currentAlbumLine }}</div>
|
||||
<div style="font-size:15px;font-weight:800;color:oklch(78% 0.14 340);margin-top:6px;">{{ trackCounter }}</div>
|
||||
</div>
|
||||
|
||||
<div style="width:min(680px, 90%);position:relative;z-index:1;">
|
||||
<div style="height:14px;border-radius:999px;background:oklch(30% 0.03 210 / .55);overflow:hidden;">
|
||||
<div style="height:100%;border-radius:999px;background:oklch(70% 0.16 340);{{ progressStyle }}"></div>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;margin-top:8px;font-size:14px;font-weight:800;color:oklch(88% 0.02 210 / .8);">
|
||||
<span style="font-family:ui-monospace,Menlo,monospace;">{{ trackRemainingLabel }}</span>
|
||||
<span>{{ albumRemainingLabel }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;align-items:center;gap:22px;position:relative;z-index:1;flex:none;">
|
||||
<button onClick="{{ prevTrack }}" style="width:clamp(50px, 9vh, 76px);height:clamp(50px, 9vh, 76px);border-radius:999px;border:none;padding:0;background:oklch(97% 0.01 210 / .16);cursor:pointer;display:flex;align-items:center;justify-content:center;"><span style="display:flex;align-items:center;gap:3px;"><span style="width:6px;height:26px;border-radius:2px;background:#fff;"></span><span style="width:22px;height:26px;background:#fff;clip-path:polygon(100% 0%, 100% 100%, 0% 50%);"></span></span></button>
|
||||
<button onClick="{{ togglePlay }}" style="width:clamp(68px, 12vh, 110px);height:clamp(68px, 12vh, 110px);border-radius:999px;border:none;padding:0;background:oklch(70% 0.16 340);cursor:pointer;display:flex;align-items:center;justify-content:center;box-shadow:0 10px 30px oklch(70% 0.16 340 / .5);"><sc-if value="{{ isPlaying }}" hint-placeholder-val="{{ false }}"><span style="display:flex;gap:9px;"><span style="width:10px;height:clamp(26px, 4.6vh, 42px);border-radius:3px;background:#fff;"></span><span style="width:10px;height:clamp(26px, 4.6vh, 42px);border-radius:3px;background:#fff;"></span></span></sc-if><sc-if value="{{ isPaused }}" hint-placeholder-val="{{ true }}"><span style="width:clamp(24px, 4.2vh, 38px);height:clamp(26px, 4.6vh, 42px);background:#fff;clip-path:polygon(6% 0%, 100% 50%, 6% 100%);"></span></sc-if></button>
|
||||
<button onClick="{{ nextTrack }}" style="width:clamp(50px, 9vh, 76px);height:clamp(50px, 9vh, 76px);border-radius:999px;border:none;padding:0;background:oklch(97% 0.01 210 / .16);cursor:pointer;display:flex;align-items:center;justify-content:center;"><span style="display:flex;align-items:center;gap:3px;"><span style="width:22px;height:26px;background:#fff;clip-path:polygon(0% 0%, 0% 100%, 100% 50%);"></span><span style="width:6px;height:26px;border-radius:2px;background:#fff;"></span></span></button>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;align-items:center;gap:12px;position:relative;z-index:1;">
|
||||
<button onClick="{{ muteToggle }}" title="Stumm" style="border:none;background:none;padding:0;cursor:pointer;font-size:24px;line-height:1;">🔊</button>
|
||||
<div style="display:flex;gap:5px;">
|
||||
<sc-for list="{{ volumeBars }}" as="bar" hint-placeholder-count="5">
|
||||
<button onClick="{{ setVolume }}" data-level="{{ bar.level }}" title="Lautstärke" style="width:18px;height:clamp(26px, 4.6vh, 44px);border-radius:5px;{{ bar.style }}"></button>
|
||||
</sc-for>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button onClick="{{ goBrowse }}" style="position:absolute;top:28px;left:32px;border:none;cursor:pointer;background:oklch(97% 0.01 210 / .95);border-radius:999px;padding:11px 20px;font-family:inherit;font-size:14px;font-weight:800;color:oklch(30% 0.04 210);display:flex;align-items:center;gap:8px;box-shadow:0 8px 24px oklch(15% 0.05 210 / .4);">
|
||||
<span style="font:800 13px ui-monospace,monospace;background:oklch(30% 0.04 210);color:#fff;padding:2px 8px;border-radius:6px;">/</span>
|
||||
Zurück zur Suche
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</sc-if>
|
||||
|
||||
<!-- album detail panel -->
|
||||
<sc-if value="{{ hasOpenAlbum }}" hint-placeholder-val="{{ false }}">
|
||||
<div onClick="{{ closeAlbum }}" style="position:absolute;inset:0;z-index:4;background:oklch(15% 0.03 210 / .6);display:flex;align-items:center;justify-content:center;padding:40px;">
|
||||
<div onClick="{{ stopClick }}" style="background:oklch(96% 0.012 210);border-radius:26px;padding:26px;width:640px;max-width:100%;max-height:100%;overflow:auto;box-shadow:0 24px 60px oklch(10% 0.04 210 / .55);">
|
||||
<div style="display:flex;gap:20px;align-items:flex-start;">
|
||||
<div style="{{ openAlbumCoverStyle }}">
|
||||
<span style="font-family:ui-monospace,Menlo,monospace;font-size:11px;color:oklch(98% 0 0 / .8);">ALBUM COVER</span>
|
||||
</div>
|
||||
<div style="flex:1;min-width:0;">
|
||||
<div style="font-size:26px;font-weight:900;color:oklch(22% 0.03 210);line-height:1.15;">{{ openAlbumTitle }}</div>
|
||||
<div style="font-size:15px;font-weight:700;color:oklch(45% 0.03 210 / .85);margin-top:2px;">{{ openAlbumArtist }}</div>
|
||||
<div style="font-size:13px;font-weight:700;color:oklch(50% 0.03 210 / .7);margin-top:2px;">{{ openAlbumMeta }}</div>
|
||||
<button onClick="{{ playOpenAlbum }}" style="margin-top:14px;border:none;cursor:pointer;background:oklch(70% 0.16 340);color:#fff;font-family:inherit;font-size:16px;font-weight:800;padding:12px 22px;border-radius:999px;display:flex;align-items:center;gap:10px;box-shadow:0 4px 14px oklch(70% 0.16 340 / .45);">
|
||||
<span style="width:14px;height:16px;background:#fff;clip-path:polygon(6% 0%, 100% 50%, 6% 100%);"></span>
|
||||
Alle Songs abspielen
|
||||
</button>
|
||||
</div>
|
||||
<button onClick="{{ closeAlbum }}" style="border:none;cursor:pointer;background:oklch(88% 0.02 210);color:oklch(30% 0.04 210);font-family:inherit;font-size:18px;font-weight:900;width:40px;height:40px;border-radius:999px;flex:none;">×</button>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;flex-direction:column;gap:6px;margin-top:22px;">
|
||||
<sc-for list="{{ openAlbumSongs }}" as="song" hint-placeholder-count="5">
|
||||
<div onClick="{{ playSongHit }}" data-album="{{ song.albumId }}" data-index="{{ song.index }}" style="{{ song.rowStyle }}">
|
||||
<div style="{{ song.numStyle }}">{{ song.number }}</div>
|
||||
<div style="flex:1;min-width:0;font-size:16px;font-weight:800;{{ song.titleStyle }}">{{ song.title }}</div>
|
||||
<div style="font:700 13px ui-monospace,Menlo,monospace;color:oklch(45% 0.03 210 / .7);">{{ song.duration }}</div>
|
||||
</div>
|
||||
</sc-for>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</sc-if>
|
||||
|
||||
<!-- shortcut help toggle + overlay -->
|
||||
<button onClick="{{ toggleHelp }}" title="Zaubertasten (F1)" style="position:absolute;top:18px;right:24px;z-index:3;width:44px;height:44px;background:oklch(97% 0.01 210 / .95);border:none;border-radius:999px;box-shadow:0 6px 18px oklch(15% 0.05 210 / .4);font-size:20px;font-weight:900;color:oklch(30% 0.04 210);cursor:pointer;display:flex;align-items:center;justify-content:center;">?</button>
|
||||
|
||||
<sc-if value="{{ showHelp }}" hint-placeholder-val="{{ false }}">
|
||||
<div onClick="{{ toggleHelp }}" style="position:absolute;inset:0;background:oklch(15% 0.03 210 / .55);z-index:5;display:flex;align-items:center;justify-content:center;">
|
||||
<div style="background:oklch(97% 0.01 210);border-radius:24px;padding:26px 30px;width:300px;box-shadow:0 20px 50px oklch(10% 0.04 210 / .5);">
|
||||
<div style="font-size:17px;font-weight:800;color:oklch(30% 0.04 210);margin-bottom:14px;letter-spacing:.3px;">⌨️ Zaubertasten</div>
|
||||
<div style="display:flex;flex-direction:column;gap:10px;">
|
||||
<div style="display:flex;align-items:center;gap:10px;">
|
||||
<div style="font:800 12px ui-monospace,monospace;background:oklch(30% 0.04 210);color:#fff;padding:5px 12px;border-radius:8px;min-width:56px;text-align:center;">LEER</div>
|
||||
<div style="font-size:14px;font-weight:700;color:oklch(35% 0.03 210);">Play / Pause</div>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:10px;">
|
||||
<div style="display:flex;gap:4px;">
|
||||
<div style="font:800 12px ui-monospace,monospace;background:oklch(30% 0.04 210);color:#fff;padding:5px 8px;border-radius:8px;">CTRL+H</div>
|
||||
<div style="font:800 12px ui-monospace,monospace;background:oklch(30% 0.04 210);color:#fff;padding:5px 8px;border-radius:8px;">CTRL+L</div>
|
||||
</div>
|
||||
<div style="font-size:14px;font-weight:700;color:oklch(35% 0.03 210);">Song zurück / vor</div>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:10px;">
|
||||
<div style="display:flex;gap:4px;">
|
||||
<div style="font:800 12px ui-monospace,monospace;background:oklch(30% 0.04 210);color:#fff;padding:5px 8px;border-radius:8px;">CTRL+J</div>
|
||||
<div style="font:800 12px ui-monospace,monospace;background:oklch(30% 0.04 210);color:#fff;padding:5px 8px;border-radius:8px;">CTRL+K</div>
|
||||
</div>
|
||||
<div style="font-size:14px;font-weight:700;color:oklch(35% 0.03 210);">Leiser / lauter</div>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:10px;">
|
||||
<div style="font:800 12px ui-monospace,monospace;background:oklch(30% 0.04 210);color:#fff;padding:5px 12px;border-radius:8px;min-width:56px;text-align:center;">A-Z</div>
|
||||
<div style="font-size:14px;font-weight:700;color:oklch(35% 0.03 210);">Album oder Hörbuch suchen</div>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:10px;">
|
||||
<div style="font:800 12px ui-monospace,monospace;background:oklch(30% 0.04 210);color:#fff;padding:5px 12px;border-radius:8px;min-width:56px;text-align:center;">?</div>
|
||||
<div style="font-size:14px;font-weight:700;color:oklch(35% 0.03 210);">Einzelne Titel suchen</div>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:10px;">
|
||||
<div style="font:800 12px ui-monospace,monospace;background:oklch(30% 0.04 210);color:#fff;padding:5px 12px;border-radius:8px;min-width:56px;text-align:center;">F1</div>
|
||||
<div style="font-size:14px;font-weight:700;color:oklch(35% 0.03 210);">Diese Hilfe</div>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:10px;">
|
||||
<div style="font:800 12px ui-monospace,monospace;background:oklch(30% 0.04 210);color:#fff;padding:5px 12px;border-radius:8px;min-width:56px;text-align:center;">TAB</div>
|
||||
<div style="font-size:14px;font-weight:700;color:oklch(35% 0.03 210);">Musik / Hörbücher / alles</div>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:10px;">
|
||||
<div style="font:800 12px ui-monospace,monospace;background:oklch(30% 0.04 210);color:#fff;padding:5px 12px;border-radius:8px;min-width:56px;text-align:center;">← ↑ ↓ →</div>
|
||||
<div style="font-size:14px;font-weight:700;color:oklch(35% 0.03 210);">Auswahl bewegen</div>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:10px;">
|
||||
<div style="font:800 12px ui-monospace,monospace;background:oklch(30% 0.04 210);color:#fff;padding:5px 12px;border-radius:8px;min-width:56px;text-align:center;">ENTER</div>
|
||||
<div style="font-size:14px;font-weight:700;color:oklch(35% 0.03 210);">Auswahl abspielen</div>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:10px;">
|
||||
<div style="font:800 12px ui-monospace,monospace;background:oklch(30% 0.04 210);color:#fff;padding:5px 12px;border-radius:8px;min-width:56px;text-align:center;">ESC</div>
|
||||
<div style="font-size:14px;font-weight:700;color:oklch(35% 0.03 210);">Schließen / Suche löschen</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top:16px;font-size:12px;font-weight:600;color:oklch(50% 0.03 210 / .8);text-align:center;">Tippe irgendwohin, um zu schließen</div>
|
||||
</div>
|
||||
</div>
|
||||
</sc-if>
|
||||
|
||||
<!-- player bar -->
|
||||
<sc-if value="{{ isBrowse }}" hint-placeholder-val="{{ true }}">
|
||||
<div style="position:absolute;left:0;right:0;bottom:0;z-index:2;background:oklch(18% 0.05 210 / .96);backdrop-filter:blur(6px);padding:16px clamp(12px, 3vw, 32px);box-sizing:border-box;display:flex;align-items:center;justify-content:center;gap:clamp(10px, 1.6vw, 22px);border-top:3px solid oklch(70% 0.16 340 / .5);">
|
||||
|
||||
<img src="dolphin-mascot.png" alt="Delfin mit Kopfhörern" style="width:clamp(56px, 7vw, 84px);height:clamp(56px, 7vw, 84px);object-fit:contain;flex:0 1 auto;min-width:0;animation:{{ dolphinBobAnim }};filter:drop-shadow(0 4px 12px oklch(10% 0.05 210 / .5));">
|
||||
|
||||
<div style="width:52px;height:52px;border-radius:12px;flex:none;{{ playerCoverStyle }}"></div>
|
||||
|
||||
<div style="flex:0 1 220px;min-width:0;overflow:hidden;">
|
||||
<div style="font-size:16px;font-weight:800;color:oklch(97% 0.01 210);">{{ currentSongTitle }}</div>
|
||||
<div style="font-size:13px;font-weight:600;color:oklch(85% 0.02 210 / .75);">{{ currentAlbumLine }}</div>
|
||||
<div style="font-size:12px;font-weight:800;color:oklch(75% 0.14 340);margin-top:1px;">{{ trackCounter }}</div>
|
||||
</div>
|
||||
|
||||
<div style="flex:1 1 auto;max-width:360px;min-width:90px;">
|
||||
<div style="height:10px;border-radius:999px;background:oklch(30% 0.03 210 / .6);overflow:hidden;">
|
||||
<div style="height:100%;border-radius:999px;background:oklch(70% 0.16 340);{{ progressStyle }}"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;align-items:center;gap:10px;">
|
||||
<button onClick="{{ prevTrack }}" style="width:48px;height:48px;border-radius:999px;border:none;padding:0;background:oklch(30% 0.04 210);color:#fff;line-height:1;cursor:pointer;display:flex;align-items:center;justify-content:center;"><span style="display:flex;align-items:center;gap:2px;"><span style="width:4px;height:16px;border-radius:1px;background:#fff;"></span><span style="width:14px;height:16px;background:#fff;clip-path:polygon(100% 0%, 100% 100%, 0% 50%);"></span></span></button>
|
||||
<button onClick="{{ togglePlay }}" style="width:60px;height:60px;border-radius:999px;border:none;padding:0;background:oklch(70% 0.16 340);color:#fff;line-height:1;cursor:pointer;display:flex;align-items:center;justify-content:center;box-shadow:0 4px 14px oklch(70% 0.16 340 / .5);"><sc-if value="{{ isPlaying }}" hint-placeholder-val="{{ false }}"><span style="display:flex;gap:5px;"><span style="width:6px;height:22px;border-radius:2px;background:#fff;"></span><span style="width:6px;height:22px;border-radius:2px;background:#fff;"></span></span></sc-if><sc-if value="{{ isPaused }}" hint-placeholder-val="{{ true }}"><span style="width:20px;height:22px;background:#fff;clip-path:polygon(6% 0%, 100% 50%, 6% 100%);"></span></sc-if></button>
|
||||
<button onClick="{{ nextTrack }}" style="width:48px;height:48px;border-radius:999px;border:none;padding:0;background:oklch(30% 0.04 210);color:#fff;line-height:1;cursor:pointer;display:flex;align-items:center;justify-content:center;"><span style="display:flex;align-items:center;gap:2px;"><span style="width:14px;height:16px;background:#fff;clip-path:polygon(0% 0%, 0% 100%, 100% 50%);"></span><span style="width:4px;height:16px;border-radius:1px;background:#fff;"></span></span></button>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;align-items:center;gap:8px;flex:0 0 auto;">
|
||||
<button onClick="{{ muteToggle }}" title="Stumm" style="border:none;background:none;padding:0;cursor:pointer;font-size:18px;line-height:1;">🔊</button>
|
||||
<div style="display:flex;gap:3px;">
|
||||
<sc-for list="{{ volumeBars }}" as="bar" hint-placeholder-count="5">
|
||||
<button onClick="{{ setVolume }}" data-level="{{ bar.level }}" title="Lautstärke" style="width:11px;height:30px;border-radius:4px;{{ bar.style }}"></button>
|
||||
</sc-for>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</sc-if>
|
||||
</div>
|
||||
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script data-props="{"$preview": {"width": 1280, "height": 800}}">
|
||||
const ALBUMS = [
|
||||
{ id: 1, title: "Ocean Adventure", artist: "The Coral Crew", hue: 180, songs: [["Set Sail", "2:41"], ["Wave Rider", "3:05"], ["Lighthouse", "2:28"], ["Salty Wind", "3:22"], ["Safe Harbour", "2:55"]] },
|
||||
{ id: 2, title: "Splash Party", artist: "Wavy Tunes", hue: 200, songs: [["Cannonball", "2:12"], ["Pool Noodle Boogie", "2:47"], ["Big Splash", "3:01"], ["Towel Dance", "2:33"]] },
|
||||
{ id: 3, title: "Deep Blue Dreams", artist: "Finn & Friends", hue: 220, songs: [["Sinking Slow", "3:48"], ["Blue Lullaby", "4:12"], ["Silent Depths", "3:30"], ["Bioluminescence", "4:02"], ["Morning Surface", "3:14"]] },
|
||||
{ id: 4, title: "Bubble Trouble", artist: "The Coral Crew", hue: 250, songs: [["Bubble Machine", "2:20"], ["Pop Pop Pop", "1:58"], ["Fizzy Water", "2:44"], ["Upside Down", "3:06"]] },
|
||||
{ id: 5, title: "Starfish Disco", artist: "Reef Riders", hue: 20, songs: [["Five Arms Up", "3:18"], ["Glitter Sand", "2:52"], ["Disco Tide", "3:40"], ["Neon Anemone", "3:02"], ["Last Dance", "4:05"]] },
|
||||
{ id: 6, title: "Whale Song", artist: "Finn & Friends", hue: 240, songs: [["Long Call", "5:12"], ["Migration", "4:36"], ["Under Ice", "3:58"], ["Deep Echo", "4:20"]] },
|
||||
{ id: 7, title: "Tidal Groove", artist: "Wavy Tunes", hue: 195, songs: [["High Tide", "3:11"], ["Low Tide", "2:49"], ["Moon Pull", "3:35"], ["Rock Pool", "2:58"], ["Undertow", "3:24"]] },
|
||||
{ id: 8, title: "Coral Reef Jam", artist: "Reef Riders", hue: 15, songs: [["Clownfish Shuffle", "2:36"], ["Parrotfish Parade", "3:08"], ["Coral Bloom", "3:44"], ["Reef Party", "2:50"]] },
|
||||
{ id: 9, title: "Sandy Shores", artist: "Beach Buddies", hue: 40, songs: [["Barefoot", "2:26"], ["Sandcastle", "3:02"], ["Seagull Song", "2:14"], ["Sunset Picnic", "3:30"], ["Beach Fire", "3:52"]] },
|
||||
{ id: 10, title: "Moonlit Lagoon", artist: "Finn & Friends", hue: 260, songs: [["Silver Water", "4:08"], ["Night Swim", "3:36"], ["Sleepy Palms", "3:12"], ["Star Reflection", "4:24"]] },
|
||||
{ id: 11, title: "Jellyfish Jive", artist: "The Coral Crew", hue: 300, songs: [["Wobble Wobble", "2:18"], ["Glow Parade", "2:56"], ["Tentacle Twist", "3:14"], ["Drifting", "3:40"], ["Sting Free", "2:44"]] },
|
||||
{ id: 12, title: "Seahorse Shuffle", artist: "Beach Buddies", hue: 30, songs: [["Tiny Trot", "2:22"], ["Seagrass Swing", "2:58"], ["Curly Tail", "3:16"], ["Slow Parade", "3:04"]] },
|
||||
{ id: 13, title: "Current Rhythm", artist: "Wavy Tunes", hue: 205, songs: [["Fast Current", "3:26"], ["Drift Beat", "3:02"], ["Whirlpool", "3:48"], ["Cross Stream", "2:52"], ["Open Water", "4:10"]] },
|
||||
{ id: 14, title: "Anchor Down", artist: "Reef Riders", hue: 10, songs: [["Chain Rattle", "2:40"], ["Harbour Lights", "3:20"], ["Old Rope", "3:04"], ["Anchor Down", "3:52"]] },
|
||||
{ id: 15, type: "book", series: "Wal Wilma", title: "Der kleine Wal Wilma", artist: "Erzählt von Lena Fisch", hue: 230, songs: [["Kapitel 1 — Wilma wacht auf", "8:12"], ["Kapitel 2 — Die große Welle", "9:04"], ["Kapitel 3 — Ein Freund im Dunkeln", "7:48"], ["Kapitel 4 — Heimweg", "8:36"]] },
|
||||
{ id: 16, type: "book", series: "Käpt'n Krabbe", title: "Käpt'n Krabbe und der Schatz", artist: "Erzählt von Jonas Meer", hue: 25, songs: [["Kapitel 1 — Die alte Karte", "10:20"], ["Kapitel 2 — Sturm vor Kap Kork", "11:02"], ["Kapitel 3 — Die Höhle", "9:44"], ["Kapitel 4 — Gold und Muscheln", "10:12"], ["Kapitel 5 — Zurück im Hafen", "8:58"]] },
|
||||
{ id: 17, type: "book", series: "Nele", title: "Nele taucht ab", artist: "Erzählt von Mira Sand", hue: 190, songs: [["Kapitel 1 — Die neue Taucherbrille", "7:30"], ["Kapitel 2 — Unten am Riff", "8:14"], ["Kapitel 3 — Das leise Lied", "9:06"], ["Kapitel 4 — Wieder oben", "6:52"]] },
|
||||
{ id: 18, type: "book", series: "Muschelbande", title: "Die Muschelbande", artist: "Erzählt von Tom Anker", hue: 300, songs: [["Kapitel 1 — Drei Freunde", "8:48"], ["Kapitel 2 — Der Plan", "9:22"], ["Kapitel 3 — Verfolgt", "10:04"], ["Kapitel 4 — Das Versteck", "8:16"], ["Kapitel 5 — Alles gut", "7:40"]] },
|
||||
{ id: 19, type: "book", series: "Riff-Geschichten", title: "Gute-Nacht-Geschichten vom Riff", artist: "Erzählt von Lena Fisch", hue: 265, songs: [["Der schläfrige Seestern", "6:40"], ["Wo die Quallen leuchten", "7:12"], ["Das müde Seepferdchen", "6:28"], ["Lied der Strömung", "8:02"]] },
|
||||
{ id: 20, type: "book", series: "Wal Wilma", title: "Wal Wilma im Eismeer", artist: "Erzählt von Lena Fisch", hue: 245, songs: [["Kapitel 1 — Kaltes Wasser", "9:10"], ["Kapitel 2 — Das Eisloch", "8:26"], ["Kapitel 3 — Der Pinguin", "9:44"], ["Kapitel 4 — Nach Hause", "8:02"]] },
|
||||
{ id: 21, type: "book", series: "Käpt'n Krabbe", title: "Käpt'n Krabbe segelt los", artist: "Erzählt von Jonas Meer", hue: 35, songs: [["Kapitel 1 — Neue Segel", "9:52"], ["Kapitel 2 — Der blinde Passagier", "10:36"], ["Kapitel 3 — Nebelbank", "9:18"], ["Kapitel 4 — Land in Sicht", "8:44"]] },
|
||||
{ id: 22, type: "book", series: "Nele", title: "Nele und der Seestern", artist: "Erzählt von Mira Sand", hue: 200, songs: [["Kapitel 1 — Fünf Arme", "7:22"], ["Kapitel 2 — Der Gezeitenteich", "8:06"], ["Kapitel 3 — Zurück ins Meer", "7:54"]] },
|
||||
];
|
||||
const TRACK_DURATION_MS = 24000;
|
||||
|
||||
class Component extends DCLogic {
|
||||
state = {
|
||||
search: "",
|
||||
mode: "albums",
|
||||
category: null,
|
||||
filter: "all",
|
||||
albumId: null,
|
||||
trackIndex: 0,
|
||||
openAlbumId: null,
|
||||
view: "browse",
|
||||
selIndex: 0,
|
||||
cols: 4,
|
||||
isPlaying: false,
|
||||
volume: 70,
|
||||
progress: 0,
|
||||
showHelp: false,
|
||||
};
|
||||
|
||||
playPop(freq) {
|
||||
try {
|
||||
if (!this._ac) this._ac = new (window.AudioContext || window.webkitAudioContext)();
|
||||
const ac = this._ac;
|
||||
const osc = ac.createOscillator();
|
||||
const gain = ac.createGain();
|
||||
osc.type = "sine";
|
||||
osc.frequency.setValueAtTime(freq, ac.currentTime);
|
||||
osc.frequency.exponentialRampToValueAtTime(freq * 1.8, ac.currentTime + 0.08);
|
||||
gain.gain.setValueAtTime(0.15, ac.currentTime);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, ac.currentTime + 0.15);
|
||||
osc.connect(gain).connect(ac.destination);
|
||||
osc.start();
|
||||
osc.stop(ac.currentTime + 0.16);
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this._onKey = (e) => this.handleKey(e);
|
||||
window.addEventListener("keydown", this._onKey);
|
||||
this._onResize = () => this.measureCols();
|
||||
window.addEventListener("resize", this._onResize);
|
||||
this.measureCols();
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
window.removeEventListener("keydown", this._onKey);
|
||||
window.removeEventListener("resize", this._onResize);
|
||||
if (this._timer) clearInterval(this._timer);
|
||||
}
|
||||
|
||||
navCounts() {
|
||||
return { songs: this.songMatches().length, albums: this.albumMatches().length + this.categoryMatches().length };
|
||||
}
|
||||
|
||||
moveSel(dx, dy) {
|
||||
const { songs, albums } = this.navCounts();
|
||||
const total = songs + albums;
|
||||
if (!total) return;
|
||||
const cols = Math.max(1, this.state.cols);
|
||||
this.setState((s) => {
|
||||
let i = Math.min(s.selIndex, total - 1);
|
||||
if (dx) i += dx;
|
||||
if (dy) {
|
||||
if (i < songs) i += dy;
|
||||
else i += dy * cols;
|
||||
}
|
||||
if (i < 0) i = 0;
|
||||
if (i > total - 1) i = total - 1;
|
||||
return { selIndex: i };
|
||||
});
|
||||
}
|
||||
|
||||
playSelection() {
|
||||
const songs = this.songMatches();
|
||||
const albums = this.albumMatches();
|
||||
const cats = this.categoryMatches();
|
||||
const i = Math.min(this.state.selIndex, songs.length + albums.length + cats.length - 1);
|
||||
if (i < 0) return;
|
||||
if (i < songs.length) return this.playSong(songs[i].album.id, songs[i].index);
|
||||
if (cats.length) {
|
||||
const c = cats[i - songs.length];
|
||||
if (c) this.setState({ category: c.key, selIndex: 0 });
|
||||
return;
|
||||
}
|
||||
const a = albums[i - songs.length];
|
||||
if (a) this.playSong(a.id, 0);
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
const sc = this._scroller;
|
||||
if (!sc) return;
|
||||
if (this._lastSel === this.state.selIndex) return;
|
||||
this._lastSel = this.state.selIndex;
|
||||
const el = sc.querySelector('[data-nav-index="' + this.state.selIndex + '"]');
|
||||
if (!el) return;
|
||||
const top = el.offsetTop - sc.offsetTop;
|
||||
const bottom = top + el.offsetHeight;
|
||||
const pad = 24;
|
||||
if (top - pad < sc.scrollTop) sc.scrollTop = Math.max(0, top - pad);
|
||||
else if (bottom + pad > sc.scrollTop + sc.clientHeight) sc.scrollTop = bottom + pad - sc.clientHeight;
|
||||
}
|
||||
|
||||
measureCols() {
|
||||
const g = this._grid;
|
||||
if (!g) return;
|
||||
const n = getComputedStyle(g).gridTemplateColumns.split(" ").filter(Boolean).length;
|
||||
if (n && n !== this.state.cols) this.setState({ cols: n });
|
||||
}
|
||||
|
||||
normalize(s) {
|
||||
return (s || "").toLowerCase().replace(/[^a-z0-9]/g, "");
|
||||
}
|
||||
|
||||
setFilter(f) {
|
||||
this.playPop(380);
|
||||
this.setState({ filter: f, selIndex: 0, view: "browse", category: null });
|
||||
}
|
||||
|
||||
cycleFilter() {
|
||||
const order = ["all", "music", "book"];
|
||||
this.setFilter(order[(order.indexOf(this.state.filter) + 1) % order.length]);
|
||||
}
|
||||
|
||||
secs(d) {
|
||||
const p = (d || "0:00").split(":");
|
||||
return Number(p[0]) * 60 + Number(p[1]);
|
||||
}
|
||||
|
||||
clock(total) {
|
||||
const t = Math.max(0, Math.round(total));
|
||||
const h = Math.floor(t / 3600);
|
||||
const m = Math.floor((t % 3600) / 60);
|
||||
const s = t % 60;
|
||||
const pad = (n) => String(n).padStart(2, "0");
|
||||
return h ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`;
|
||||
}
|
||||
|
||||
remainingSecs(album) {
|
||||
if (!album) return 0;
|
||||
const i = this.state.trackIndex;
|
||||
const cur = this.secs(album.songs[i] ? album.songs[i][1] : "0:00");
|
||||
let rest = cur * (1 - this.state.progress / 100);
|
||||
for (let k = i + 1; k < album.songs.length; k++) rest += this.secs(album.songs[k][1]);
|
||||
return rest;
|
||||
}
|
||||
|
||||
isBook(a) {
|
||||
return a.type === "book";
|
||||
}
|
||||
|
||||
categoryOf(a) {
|
||||
return this.isBook(a) ? a.series || a.artist : a.artist;
|
||||
}
|
||||
|
||||
listMode() {
|
||||
if (this.state.mode === "tracks") return "tracks";
|
||||
if (this.state.search || this.state.category) return "albums";
|
||||
return "categories";
|
||||
}
|
||||
|
||||
categoryMatches() {
|
||||
if (this.listMode() !== "categories") return [];
|
||||
const map = new Map();
|
||||
this.pool().forEach((a) => {
|
||||
const key = this.categoryOf(a);
|
||||
if (!map.has(key)) map.set(key, { key, name: key, albums: [] });
|
||||
map.get(key).albums.push(a);
|
||||
});
|
||||
return Array.from(map.values());
|
||||
}
|
||||
|
||||
navTotal() {
|
||||
return this.songMatches().length + this.albumMatches().length + this.categoryMatches().length;
|
||||
}
|
||||
|
||||
inFilter(a) {
|
||||
const f = this.state.filter;
|
||||
if (f === "book") return this.isBook(a);
|
||||
if (f === "music") return !this.isBook(a);
|
||||
return true;
|
||||
}
|
||||
|
||||
pool() {
|
||||
return ALBUMS.filter((a) => this.inFilter(a));
|
||||
}
|
||||
|
||||
albumMatches() {
|
||||
if (this.state.mode === "tracks") return [];
|
||||
const q = this.normalize(this.state.search);
|
||||
let pool = this.pool();
|
||||
if (!q && !this.state.category) return [];
|
||||
if (this.state.category) pool = pool.filter((a) => this.categoryOf(a) === this.state.category);
|
||||
if (!q) return pool;
|
||||
return pool.filter((a) => this.normalize(a.title + a.artist).includes(q));
|
||||
}
|
||||
|
||||
songMatches() {
|
||||
if (this.state.mode !== "tracks") return [];
|
||||
const q = this.normalize(this.state.search);
|
||||
const hits = [];
|
||||
this.pool().forEach((a) => {
|
||||
a.songs.forEach((s, i) => {
|
||||
if (!q || this.normalize(s[0]).includes(q)) hits.push({ album: a, index: i, title: s[0], duration: s[1] });
|
||||
});
|
||||
});
|
||||
return hits.slice(0, 40);
|
||||
}
|
||||
|
||||
playSong(albumId, index) {
|
||||
this.playPop(300);
|
||||
this.setState({ albumId, trackIndex: index, isPlaying: true, progress: 0, view: "play", openAlbumId: null });
|
||||
this.startTimer();
|
||||
}
|
||||
|
||||
startTimer() {
|
||||
if (this._timer) clearInterval(this._timer);
|
||||
this._timer = setInterval(() => {
|
||||
this.setState((s) => {
|
||||
if (!s.isPlaying) return {};
|
||||
const next = s.progress + 100 / (TRACK_DURATION_MS / 200);
|
||||
if (next >= 100) {
|
||||
this.advance(1, true);
|
||||
return {};
|
||||
}
|
||||
return { progress: next };
|
||||
});
|
||||
}, 200);
|
||||
}
|
||||
|
||||
advance(dir, silent) {
|
||||
if (!silent) this.playPop(260);
|
||||
this.setState((s) => {
|
||||
const album = ALBUMS.find((a) => a.id === s.albumId);
|
||||
if (!album) {
|
||||
const first = this.albumMatches()[0] || ALBUMS[0];
|
||||
return { albumId: first.id, trackIndex: 0, progress: 0, isPlaying: true };
|
||||
}
|
||||
const next = s.trackIndex + dir;
|
||||
if (next >= 0 && next < album.songs.length) {
|
||||
return { trackIndex: next, progress: 0, isPlaying: true };
|
||||
}
|
||||
// step to the neighbouring album and continue there
|
||||
const order = ALBUMS;
|
||||
const ai = order.findIndex((a) => a.id === album.id);
|
||||
const nextAlbum = order[(ai + (dir > 0 ? 1 : -1) + order.length) % order.length];
|
||||
return {
|
||||
albumId: nextAlbum.id,
|
||||
trackIndex: dir > 0 ? 0 : nextAlbum.songs.length - 1,
|
||||
progress: 0,
|
||||
isPlaying: true,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
handleKey(e) {
|
||||
const key = e.key;
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
const k = key.toLowerCase();
|
||||
if (k === "l") { e.preventDefault(); this.advance(1); return; }
|
||||
if (k === "h") { e.preventDefault(); this.advance(-1); return; }
|
||||
if (k === "k") { e.preventDefault(); this.setState((s) => ({ volume: Math.min(100, s.volume + 10) })); return; }
|
||||
if (k === "j") { e.preventDefault(); this.setState((s) => ({ volume: Math.max(0, s.volume - 10) })); return; }
|
||||
return;
|
||||
}
|
||||
if (key === "Tab") {
|
||||
e.preventDefault();
|
||||
this.cycleFilter();
|
||||
} else if (key === "/") {
|
||||
e.preventDefault();
|
||||
this.setState({ view: "browse" });
|
||||
} else if (key === " ") {
|
||||
e.preventDefault();
|
||||
this.togglePlayInternal();
|
||||
} else if (key === "ArrowRight" || key === "ArrowLeft" || key === "ArrowUp" || key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
const browsing = this.state.view === "browse" && this.state.openAlbumId === null && !this.state.showHelp;
|
||||
if (browsing) {
|
||||
if (key === "ArrowRight") this.moveSel(1, 0);
|
||||
else if (key === "ArrowLeft") this.moveSel(-1, 0);
|
||||
else if (key === "ArrowDown") this.moveSel(0, 1);
|
||||
else this.moveSel(0, -1);
|
||||
} else if (key === "ArrowRight") this.advance(1);
|
||||
else if (key === "ArrowLeft") this.advance(-1);
|
||||
else if (key === "ArrowUp") this.setState((s) => ({ volume: Math.min(100, s.volume + 10) }));
|
||||
else this.setState((s) => ({ volume: Math.max(0, s.volume - 10) }));
|
||||
} else if (key === "?") {
|
||||
e.preventDefault();
|
||||
this.playPop(460);
|
||||
this.setState({ mode: "tracks", search: "", selIndex: 0, view: "browse", showHelp: false });
|
||||
} else if (key === "F1") {
|
||||
e.preventDefault();
|
||||
this.setState((s) => ({ showHelp: !s.showHelp }));
|
||||
} else if (key === "Escape") {
|
||||
this.setState((s) => (s.showHelp || s.openAlbumId !== null
|
||||
? { showHelp: false, openAlbumId: null }
|
||||
: s.search
|
||||
? { search: "", selIndex: 0 }
|
||||
: s.mode === "tracks"
|
||||
? { mode: "albums", selIndex: 0 }
|
||||
: { category: null, selIndex: 0 }));
|
||||
} else if (key === "Backspace") {
|
||||
this.setState((s) => ({ search: s.search.slice(0, -1), selIndex: 0 }));
|
||||
} else if (key === "Enter") {
|
||||
if (this.state.openAlbumId !== null) {
|
||||
this.playSong(this.state.openAlbumId, 0);
|
||||
} else {
|
||||
this.playSelection();
|
||||
}
|
||||
} else if (/^[a-zA-Z0-9]$/.test(key)) {
|
||||
this.setState((s) => ({ search: s.search + key, view: "browse", selIndex: 0 }));
|
||||
}
|
||||
}
|
||||
|
||||
togglePlayInternal() {
|
||||
this.playPop(340);
|
||||
this.setState((s) => {
|
||||
if (s.albumId === null) {
|
||||
const first = this.albumMatches()[0];
|
||||
if (!first) return {};
|
||||
this.startTimer();
|
||||
return { albumId: first.id, trackIndex: 0, isPlaying: true, progress: 0 };
|
||||
}
|
||||
return { isPlaying: !s.isPlaying };
|
||||
});
|
||||
}
|
||||
|
||||
coverBg(a, w) {
|
||||
if (this.isBook(a)) {
|
||||
const h = a.hue;
|
||||
return `background:
|
||||
linear-gradient(90deg, transparent 0 95%, oklch(97% 0.02 88) 95% 97.5%, oklch(90% 0.03 88) 97.5% 100%),
|
||||
linear-gradient(90deg, transparent 0 3.5%, oklch(92% 0.04 ${h} / .45) 3.5% 4.3%, transparent 4.3% 7%, oklch(92% 0.04 ${h} / .45) 7% 7.8%, transparent 7.8%),
|
||||
linear-gradient(90deg, oklch(30% 0.06 ${h}) 0 11%, oklch(24% 0.05 ${h}) 11% 13%, transparent 13%),
|
||||
linear-gradient(155deg, oklch(64% 0.10 ${h}) 0%, oklch(48% 0.09 ${h}) 100%);`;
|
||||
}
|
||||
return this.stripes(a.hue, w);
|
||||
}
|
||||
|
||||
filterPill(value, active) {
|
||||
return `border:none;cursor:pointer;font-family:inherit;font-size:14px;font-weight:800;padding:9px 15px;border-radius:999px;white-space:nowrap;${
|
||||
active
|
||||
? "background:oklch(97% 0.01 210);color:oklch(28% 0.04 210);box-shadow:0 4px 14px oklch(15% 0.05 210 / .35);"
|
||||
: "background:oklch(97% 0.01 210 / .16);color:oklch(97% 0.01 210);"
|
||||
}`;
|
||||
}
|
||||
|
||||
stripes(hue, w) {
|
||||
return `background:repeating-linear-gradient(135deg, oklch(62% 0.12 ${hue}) 0px, oklch(62% 0.12 ${hue}) ${w}px, oklch(50% 0.12 ${hue}) ${w}px, oklch(50% 0.12 ${hue}) ${w * 2}px);`;
|
||||
}
|
||||
|
||||
renderVals() {
|
||||
const albums = this.albumMatches();
|
||||
const songHits = this.songMatches();
|
||||
const album = ALBUMS.find((a) => a.id === this.state.albumId) || null;
|
||||
const song = album ? album.songs[this.state.trackIndex] : null;
|
||||
const openAlbum = ALBUMS.find((a) => a.id === this.state.openAlbumId) || null;
|
||||
|
||||
const cats = this.categoryMatches();
|
||||
const total = songHits.length + albums.length + cats.length;
|
||||
const sel = total ? Math.min(this.state.selIndex, total - 1) : -1;
|
||||
|
||||
const categoryCards = cats.map((c, ci) => {
|
||||
const isSel = sel === songHits.length + ci;
|
||||
const books = c.albums.filter((a) => this.isBook(a)).length;
|
||||
const allBooks = books === c.albums.length;
|
||||
const tiles = c.albums.slice(0, 4).map((a) => ({
|
||||
style: `border-radius:6px;${this.coverBg(a, 7)}`,
|
||||
}));
|
||||
return {
|
||||
key: c.key,
|
||||
navIndex: songHits.length + ci,
|
||||
name: c.name,
|
||||
typeLabel: allBooks ? "📖 Hörbücher" : books ? "🎵📖 Gemischt" : "🎵 Musik",
|
||||
countLabel: `${c.albums.length} ${allBooks ? (c.albums.length === 1 ? "Hörbuch" : "Hörbücher") : c.albums.length === 1 ? "Album" : "Alben"}`,
|
||||
tiles,
|
||||
cardStyle: `background:${allBooks ? "oklch(93% 0.055 88 / .7)" : "oklch(95% 0.015 210 / .66)"};backdrop-filter:blur(6px);border-radius:${allBooks ? "6px 18px 18px 6px" : "16px"};overflow:hidden;cursor:pointer;transition:transform .12s ease;box-shadow:0 6px 18px oklch(15% 0.05 210 / .35);${
|
||||
isSel ? "outline:5px solid oklch(97% 0.01 210);outline-offset:3px;transform:translateY(-3px);" : ""
|
||||
}`,
|
||||
};
|
||||
});
|
||||
|
||||
const albumCards = albums.map((a, ai) => {
|
||||
const isSel = sel === songHits.length + cats.length + ai;
|
||||
const ring = isSel
|
||||
? "outline:5px solid oklch(97% 0.01 210);outline-offset:3px;transform:translateY(-3px);"
|
||||
: a.id === this.state.albumId
|
||||
? "outline:4px solid oklch(70% 0.16 340);"
|
||||
: "";
|
||||
const book = this.isBook(a);
|
||||
return {
|
||||
id: a.id,
|
||||
navIndex: songHits.length + cats.length + ai,
|
||||
title: a.title,
|
||||
artist: a.artist,
|
||||
typeLabel: book ? "📖 Hörbuch" : "🎵 Musik",
|
||||
coverLabel: book ? "HÖRBUCH COVER" : "ALBUM COVER",
|
||||
coverLabelStyle: book
|
||||
? "font-family:ui-monospace,Menlo,monospace;font-size:10px;color:oklch(98% 0 0 / .9);letter-spacing:.5px;text-align:center;margin-left:11%;padding:14px 10px;border:2px solid oklch(98% 0 0 / .55);border-radius:4px;"
|
||||
: "font-family:ui-monospace,Menlo,monospace;font-size:11px;color:oklch(98% 0 0 / .8);letter-spacing:.5px;",
|
||||
songCountLabel: book ? `${a.songs.length} Kapitel` : `${a.songs.length} Songs`,
|
||||
bodyStyle: `padding:10px 12px 14px;${book ? "padding-right:22px;" : ""}`,
|
||||
badgeStyle: `position:absolute;top:8px;left:8px;font-size:11px;font-weight:800;padding:4px 9px;border-radius:999px;background:${book ? "oklch(94% 0.09 88)" : "oklch(97% 0.01 210)"};color:oklch(26% 0.04 210);box-shadow:0 2px 6px oklch(15% 0.05 210 / .3);`,
|
||||
cardStyle: `background:${book ? "oklch(93% 0.055 88 / .7)" : "oklch(95% 0.015 210 / .66)"};backdrop-filter:blur(6px);border-radius:${book ? "6px 18px 18px 6px" : "16px"};overflow:hidden;cursor:pointer;transition:transform .12s ease;box-shadow:${
|
||||
book
|
||||
? "inset -7px 0 0 oklch(88% 0.06 88 / .7), inset -11px 0 0 oklch(82% 0.06 88 / .7), 0 6px 18px oklch(15% 0.05 210 / .35)"
|
||||
: "0 6px 18px oklch(15% 0.05 210 / .35)"
|
||||
};${ring}`,
|
||||
coverStyle: `width:100%;aspect-ratio:${book ? "0.82" : "1"};position:relative;display:flex;align-items:center;justify-content:center;${this.coverBg(a, 14)}`,
|
||||
};
|
||||
});
|
||||
|
||||
const songRows = songHits.map((h, hi) => ({
|
||||
albumId: h.album.id,
|
||||
navIndex: hi,
|
||||
index: h.index,
|
||||
title: h.title,
|
||||
duration: h.duration,
|
||||
albumLine: `${this.isBook(h.album) ? "📖 " : ""}${h.album.title} · ${h.album.artist}`,
|
||||
rowStyle: `display:flex;align-items:center;gap:14px;padding:10px 16px;border-radius:14px;cursor:pointer;background:oklch(97% 0.01 210 / ${album && album.id === h.album.id && this.state.trackIndex === h.index ? ".22" : ".10"});${sel === hi ? "outline:4px solid oklch(97% 0.01 210);outline-offset:2px;" : ""}`,
|
||||
chipStyle: `width:38px;height:38px;flex:none;border-radius:${this.isBook(h.album) ? "6px 12px 12px 6px" : "10px"};${this.coverBg(h.album, 7)}`,
|
||||
}));
|
||||
|
||||
const openSongs = openAlbum
|
||||
? openAlbum.songs.map((s, i) => {
|
||||
const isCurrent = album && album.id === openAlbum.id && this.state.trackIndex === i;
|
||||
return {
|
||||
albumId: openAlbum.id,
|
||||
index: i,
|
||||
number: String(i + 1),
|
||||
title: s[0],
|
||||
duration: s[1],
|
||||
rowStyle: `display:flex;align-items:center;gap:14px;padding:10px 14px;border-radius:14px;cursor:pointer;background:${isCurrent ? "oklch(70% 0.16 340 / .16)" : "oklch(30% 0.03 210 / .05)"};`,
|
||||
numStyle: `width:30px;height:30px;border-radius:999px;flex:none;display:flex;align-items:center;justify-content:center;font:800 13px ui-monospace,Menlo,monospace;background:${isCurrent ? "oklch(70% 0.16 340)" : "oklch(88% 0.02 210)"};color:${isCurrent ? "#fff" : "oklch(35% 0.03 210)"};`,
|
||||
titleStyle: `color:${isCurrent ? "oklch(45% 0.16 340)" : "oklch(24% 0.03 210)"};`,
|
||||
};
|
||||
})
|
||||
: [];
|
||||
|
||||
const volumeBars = [0, 1, 2, 3, 4].map((i) => ({
|
||||
level: (i + 1) * 20,
|
||||
style: `border:none;padding:0;cursor:pointer;background:${this.state.volume >= (i + 1) * 20 ? "oklch(70% 0.16 340)" : "oklch(88% 0.02 210 / .4)"};`,
|
||||
}));
|
||||
|
||||
const parts = [];
|
||||
if (this.state.mode === "tracks") parts.push(`${songHits.length} Titel`);
|
||||
else if (albums.length) parts.push(`${albums.length} Album${albums.length === 1 ? "" : "en"}`);
|
||||
|
||||
return {
|
||||
filterAllStyle: this.filterPill("all", this.state.filter === "all"),
|
||||
filterMusicStyle: this.filterPill("music", this.state.filter === "music"),
|
||||
filterBookStyle: this.filterPill("book", this.state.filter === "book"),
|
||||
setFilterAll: () => this.setFilter("all"),
|
||||
setFilterMusic: () => this.setFilter("music"),
|
||||
setFilterBook: () => this.setFilter("book"),
|
||||
scrollerRef: (el) => { this._scroller = el; },
|
||||
gridRef: (el) => { this._grid = el; if (el) requestAnimationFrame(() => this.measureCols()); },
|
||||
showHelp: this.state.showHelp,
|
||||
toggleHelp: () => this.setState((s) => ({ showHelp: !s.showHelp })),
|
||||
hasSearch: this.state.search.length > 0,
|
||||
showSearchBar: this.state.search.length > 0 || this.state.mode === "tracks",
|
||||
modeLabel: this.state.mode === "tracks" ? "♪ Titel" : "🔎 Alben",
|
||||
searchDisplay: this.state.search ? `"${this.state.search}"` : "tippe …",
|
||||
resultCountLabel: parts.length ? parts.join(" · ") + " gefunden" : "nichts gefunden",
|
||||
albumSectionLabel: this.state.filter === "book" ? "Hörbücher" : this.state.filter === "music" ? "Alben" : "Alben & Hörbücher",
|
||||
categories: categoryCards,
|
||||
hasCategories: categoryCards.length > 0,
|
||||
categorySectionLabel: this.state.filter === "book" ? "Figuren" : this.state.filter === "music" ? "Künstler" : "Figuren & Künstler",
|
||||
openCategory: (e) => {
|
||||
this.playPop(440);
|
||||
this.setState({ category: e.currentTarget.dataset.key, selIndex: 0 });
|
||||
},
|
||||
hasCategoryCrumb: this.state.category !== null && !this.state.search,
|
||||
categoryCrumb: this.state.category || "",
|
||||
clearCategory: () => this.setState({ category: null, selIndex: 0 }),
|
||||
hasAlbumHits: albums.length > 0,
|
||||
hasSongHits: songHits.length > 0,
|
||||
hasNoResults: albums.length === 0 && songHits.length === 0 && cats.length === 0,
|
||||
songHitLabel: `${songHits.length} Treffer`,
|
||||
filteredAlbums: albumCards,
|
||||
songHits: songRows,
|
||||
openAlbumCard: (e) => {
|
||||
this.playPop(420);
|
||||
const nav = Number(e.currentTarget.dataset.navIndex);
|
||||
this.setState({ openAlbumId: Number(e.currentTarget.dataset.id), selIndex: isNaN(nav) ? this.state.selIndex : nav });
|
||||
},
|
||||
closeAlbum: () => this.setState({ openAlbumId: null }),
|
||||
stopClick: (e) => e.stopPropagation(),
|
||||
playOpenAlbum: () => {
|
||||
if (this.state.openAlbumId !== null) {
|
||||
this.playSong(this.state.openAlbumId, 0);
|
||||
this.setState({ openAlbumId: null });
|
||||
}
|
||||
},
|
||||
playSongHit: (e) => {
|
||||
const d = e.currentTarget.dataset;
|
||||
this.playSong(Number(d.album), Number(d.index));
|
||||
},
|
||||
hasOpenAlbum: openAlbum !== null,
|
||||
openAlbumTitle: openAlbum ? openAlbum.title : "",
|
||||
openAlbumArtist: openAlbum ? openAlbum.artist : "",
|
||||
openAlbumMeta: openAlbum
|
||||
? this.isBook(openAlbum)
|
||||
? `📖 Hörbuch · ${openAlbum.songs.length} Kapitel`
|
||||
: `🎵 Musik · ${openAlbum.songs.length} Songs`
|
||||
: "",
|
||||
openAlbumCoverStyle: openAlbum
|
||||
? `width:150px;height:150px;flex:none;border-radius:${this.isBook(openAlbum) ? "10px 18px 18px 10px" : "16px"};display:flex;align-items:center;justify-content:center;${this.coverBg(openAlbum, 12)}`
|
||||
: "display:none;",
|
||||
openAlbumSongs: openSongs,
|
||||
togglePlay: () => this.togglePlayInternal(),
|
||||
prevTrack: () => this.advance(-1),
|
||||
nextTrack: () => this.advance(1),
|
||||
isBrowse: this.state.view === "browse",
|
||||
isPlay: this.state.view === "play",
|
||||
goBrowse: () => this.setState({ view: "browse" }),
|
||||
|
||||
bigCoverStyle: album
|
||||
? `width:min(340px, 28vh);height:min(340px, 28vh);flex:none;border-radius:${this.isBook(album) ? "18px 34px 34px 18px" : "28px"};display:flex;align-items:center;justify-content:center;box-shadow:0 20px 50px oklch(10% 0.05 210 / .55);${this.coverBg(album, 22)}`
|
||||
: "width:min(340px, 28vh);height:min(340px, 28vh);flex:none;border-radius:28px;background:oklch(35% 0.03 210);",
|
||||
isPlaying: this.state.isPlaying,
|
||||
isPaused: !this.state.isPlaying,
|
||||
currentSongTitle: song ? song[0] : "Wähl ein Album!",
|
||||
currentAlbumLine: album ? `${this.isBook(album) ? "📖 " : ""}${album.title} · ${album.artist}` : "Tippen oder klicken",
|
||||
trackCounter: album ? `${this.isBook(album) ? "Kapitel" : "Song"} ${this.state.trackIndex + 1} von ${album.songs.length}` : "",
|
||||
playerCoverStyle: album ? this.coverBg(album, 8) : "background:oklch(35% 0.03 210);",
|
||||
albumRemainingLabel: album
|
||||
? `Noch ${this.clock(this.remainingSecs(album))} ${this.isBook(album) ? "im Hörbuch" : "im Album"}`
|
||||
: "",
|
||||
trackRemainingLabel: album && album.songs[this.state.trackIndex]
|
||||
? `−${this.clock(this.secs(album.songs[this.state.trackIndex][1]) * (1 - this.state.progress / 100))}`
|
||||
: "",
|
||||
progressStyle: `width:${this.state.progress}%;transition:width .2s linear;`,
|
||||
dolphinBobAnim: this.state.isPlaying ? "dolphinBob 1.6s ease-in-out infinite" : "none",
|
||||
volumeBars,
|
||||
setVolume: (e) => {
|
||||
this.playPop(500);
|
||||
this.setState({ volume: Number(e.currentTarget.dataset.level) });
|
||||
},
|
||||
muteToggle: () => this.setState((s) => ({ volume: s.volume > 0 ? 0 : 60 })),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
317
claude-design/Mein Zimmer.dc.html
Normal file
317
claude-design/Mein Zimmer.dc.html
Normal file
@@ -0,0 +1,317 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
<helmet>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Nunito:wght@500;700;800;900&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
@keyframes bubbleRise { 0% { transform: translateY(0) scale(1); opacity: .55; } 100% { transform: translateY(-120vh) scale(1.3); opacity: 0; } }
|
||||
@keyframes dolphinFloat { 0%, 100% { transform: translateY(0) rotate(-2deg); } 50% { transform: translateY(-12px) rotate(2deg); } }
|
||||
body { margin: 0; font-family: 'Nunito', system-ui, sans-serif; }
|
||||
a { color: oklch(80% 0.14 302); }
|
||||
a:hover { color: oklch(92% 0.10 302); }
|
||||
</style>
|
||||
</helmet>
|
||||
|
||||
<div style="position:relative;width:100%;min-height:100vh;box-sizing:border-box;overflow:hidden;background:linear-gradient(180deg, oklch(56% 0.14 305) 0%, oklch(38% 0.12 300) 45%, oklch(21% 0.08 298) 100%);">
|
||||
|
||||
<div style="position:absolute;left:4%;bottom:-40px;width:14px;height:14px;border-radius:50%;background:oklch(94% 0.03 302 / .5);animation:bubbleRise 9s linear infinite;"></div>
|
||||
<div style="position:absolute;left:11%;bottom:-40px;width:22px;height:22px;border-radius:50%;background:oklch(94% 0.03 302 / .35);animation:bubbleRise 12s linear infinite;animation-delay:2s;"></div>
|
||||
<div style="position:absolute;left:19%;bottom:-40px;width:10px;height:10px;border-radius:50%;background:oklch(94% 0.03 302 / .5);animation:bubbleRise 7s linear infinite;animation-delay:1s;"></div>
|
||||
<div style="position:absolute;left:27%;bottom:-40px;width:18px;height:18px;border-radius:50%;background:oklch(94% 0.03 302 / .42);animation:bubbleRise 10.5s linear infinite;animation-delay:4s;"></div>
|
||||
<div style="position:absolute;left:34%;bottom:-40px;width:8px;height:8px;border-radius:50%;background:oklch(94% 0.03 302 / .5);animation:bubbleRise 6.8s linear infinite;animation-delay:3s;"></div>
|
||||
<div style="position:absolute;left:42%;bottom:-40px;width:24px;height:24px;border-radius:50%;background:oklch(94% 0.03 302 / .3);animation:bubbleRise 13.5s linear infinite;animation-delay:5.5s;"></div>
|
||||
<div style="position:absolute;left:49%;bottom:-40px;width:12px;height:12px;border-radius:50%;background:oklch(94% 0.03 302 / .48);animation:bubbleRise 8.4s linear infinite;animation-delay:1.6s;"></div>
|
||||
<div style="position:absolute;left:57%;bottom:-40px;width:16px;height:16px;border-radius:50%;background:oklch(94% 0.03 302 / .4);animation:bubbleRise 11.2s linear infinite;animation-delay:6.2s;"></div>
|
||||
<div style="position:absolute;left:64%;bottom:-40px;width:9px;height:9px;border-radius:50%;background:oklch(94% 0.03 302 / .5);animation:bubbleRise 7.4s linear infinite;animation-delay:2.4s;"></div>
|
||||
<div style="position:absolute;left:72%;bottom:-40px;width:20px;height:20px;border-radius:50%;background:oklch(94% 0.03 302 / .32);animation:bubbleRise 14s linear infinite;animation-delay:3.8s;"></div>
|
||||
<div style="position:absolute;left:80%;bottom:-40px;width:11px;height:11px;border-radius:50%;background:oklch(94% 0.03 302 / .48);animation:bubbleRise 9.2s linear infinite;animation-delay:5s;"></div>
|
||||
<div style="position:absolute;left:88%;bottom:-40px;width:26px;height:26px;border-radius:50%;background:oklch(94% 0.03 302 / .28);animation:bubbleRise 15s linear infinite;animation-delay:1.2s;"></div>
|
||||
<div style="position:absolute;left:95%;bottom:-40px;width:13px;height:13px;border-radius:50%;background:oklch(94% 0.03 302 / .45);animation:bubbleRise 10.2s linear infinite;animation-delay:7s;"></div>
|
||||
|
||||
<div style="position:relative;z-index:1;padding-top:10px;">
|
||||
<dc-import name="AppHeader" title="Mein Zimmer" mascot="dolphin-remote.png" link-href="./Dolphin%20Beats.dc.html" link-label="♪ Musik" hint-size="100%,116px"></dc-import>
|
||||
</div>
|
||||
|
||||
<div style="position:relative;z-index:1;max-width:1180px;margin:0 auto;padding:0 32px 64px;box-sizing:border-box;">
|
||||
|
||||
<div style="margin-top:18px;">
|
||||
<div style="font-size:19px;font-weight:900;color:oklch(97% 0.01 302);text-align:center;margin-bottom:14px;">Szenen</div>
|
||||
<div style="display:flex;flex-wrap:wrap;justify-content:center;gap:12px;">
|
||||
<sc-for list="{{ scenes }}" as="scene" hint-placeholder-count="5">
|
||||
<button onClick="{{ activateScene }}" data-id="{{ scene.id }}" style="{{ scene.style }}">
|
||||
<span style="font-size:22px;">{{ scene.emoji }}</span>
|
||||
<span>{{ scene.name }}</span>
|
||||
</button>
|
||||
</sc-for>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fit, minmax(300px, 1fr));gap:20px;margin-top:30px;">
|
||||
|
||||
<sc-for list="{{ lights }}" as="light" hint-placeholder-count="4">
|
||||
<div style="{{ light.cardStyle }}">
|
||||
<div style="display:flex;align-items:center;gap:14px;">
|
||||
<div style="{{ light.iconWrapStyle }}">
|
||||
<sc-if value="{{ light.isBeyond }}" hint-placeholder-val="{{ true }}">
|
||||
<svg viewBox="0 0 48 48" width="34" height="34" aria-hidden="true">
|
||||
<circle cx="24" cy="24" r="17" fill="none" stroke="currentColor" stroke-width="3"></circle>
|
||||
<circle cx="24" cy="24" r="9" fill="currentColor" opacity=".85"></circle>
|
||||
<circle cx="24" cy="24" r="22" fill="none" stroke="currentColor" stroke-width="1.5" opacity=".4"></circle>
|
||||
</svg>
|
||||
</sc-if>
|
||||
<sc-if value="{{ light.isStrip }}" hint-placeholder-val="{{ false }}">
|
||||
<svg viewBox="0 0 48 48" width="34" height="34" aria-hidden="true">
|
||||
<rect x="4" y="19" width="40" height="10" rx="5" fill="none" stroke="currentColor" stroke-width="3"></rect>
|
||||
<circle cx="13" cy="24" r="2.4" fill="currentColor"></circle>
|
||||
<circle cx="24" cy="24" r="2.4" fill="currentColor"></circle>
|
||||
<circle cx="35" cy="24" r="2.4" fill="currentColor"></circle>
|
||||
</svg>
|
||||
</sc-if>
|
||||
<sc-if value="{{ light.isCeiling }}" hint-placeholder-val="{{ false }}">
|
||||
<svg viewBox="0 0 48 48" width="34" height="34" aria-hidden="true">
|
||||
<path d="M24 4v7" stroke="currentColor" stroke-width="3" stroke-linecap="round"></path>
|
||||
<path d="M9 30 L24 11 L39 30 Z" fill="none" stroke="currentColor" stroke-width="3" stroke-linejoin="round"></path>
|
||||
<path d="M16 37h16" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity=".55"></path>
|
||||
<path d="M20 43h8" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity=".3"></path>
|
||||
</svg>
|
||||
</sc-if>
|
||||
</div>
|
||||
<div style="flex:1;min-width:0;font-size:19px;font-weight:900;color:oklch(20% 0.03 300);">{{ light.name }}</div>
|
||||
<button onClick="{{ toggleLight }}" data-id="{{ light.id }}" style="{{ light.switchStyle }}">
|
||||
<span style="{{ light.knobStyle }}"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:16px;">
|
||||
<div style="display:flex;gap:6px;">
|
||||
<sc-for list="{{ light.levels }}" as="lvl" hint-placeholder-count="5">
|
||||
<button onClick="{{ setLevel }}" data-id="{{ lvl.lightId }}" data-level="{{ lvl.value }}" style="{{ lvl.style }}"></button>
|
||||
</sc-for>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<sc-if value="{{ light.hasColors }}" hint-placeholder-val="{{ true }}">
|
||||
<div style="margin-top:14px;">
|
||||
<div style="display:flex;flex-wrap:wrap;gap:10px;">
|
||||
<sc-for list="{{ light.swatches }}" as="sw" hint-placeholder-count="5">
|
||||
<button onClick="{{ setColor }}" data-id="{{ sw.lightId }}" data-color="{{ sw.value }}" title="{{ sw.name }}" style="{{ sw.style }}"></button>
|
||||
</sc-for>
|
||||
</div>
|
||||
</div>
|
||||
</sc-if>
|
||||
</div>
|
||||
</sc-for>
|
||||
|
||||
<div style="background:oklch(96% 0.012 300 / .55);backdrop-filter:blur(6px);border-radius:22px;padding:18px 20px 20px;box-shadow:0 8px 24px oklch(15% 0.05 300 / .35);">
|
||||
<div style="display:flex;align-items:center;gap:14px;">
|
||||
<div style="width:56px;height:56px;border-radius:16px;flex:none;display:flex;align-items:center;justify-content:center;background:oklch(88% 0.03 300);color:oklch(35% 0.05 300);">
|
||||
<svg viewBox="0 0 48 48" width="34" height="34" aria-hidden="true">
|
||||
<rect x="7" y="7" width="34" height="34" rx="4" fill="none" stroke="currentColor" stroke-width="3"></rect>
|
||||
<path d="M7 16h34M7 24h34M7 32h34" stroke="currentColor" stroke-width="2.5"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div style="flex:1;min-width:0;">
|
||||
<div style="font-size:19px;font-weight:900;color:oklch(20% 0.03 300);">Rollo</div>
|
||||
<div style="font-size:13px;font-weight:700;color:oklch(45% 0.03 300 / .8);">{{ shutterLabel }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:16px;display:flex;gap:18px;align-items:stretch;">
|
||||
<div style="width:104px;flex:none;border-radius:12px;overflow:hidden;position:relative;background:linear-gradient(180deg, oklch(82% 0.08 220), oklch(70% 0.09 200));border:3px solid oklch(35% 0.04 300);">
|
||||
<div style="position:absolute;left:0;right:0;top:0;{{ shutterFillStyle }}"></div>
|
||||
</div>
|
||||
|
||||
<div style="flex:1;min-width:0;display:flex;flex-direction:column;gap:10px;">
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;">
|
||||
<sc-for list="{{ shutterPresets }}" as="p" hint-placeholder-count="4">
|
||||
<button onClick="{{ setShutter }}" data-value="{{ p.value }}" style="{{ p.style }}">{{ p.name }}</button>
|
||||
</sc-for>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px;">
|
||||
<button onClick="{{ shutterUp }}" style="{{ upStyle }}">▲</button>
|
||||
<button onClick="{{ shutterStop }}" style="{{ stopStyle }}">■</button>
|
||||
<button onClick="{{ shutterDown }}" style="{{ downStyle }}">▼</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script data-props="{"$preview": {"width": 1280, "height": 900}}">
|
||||
const SWATCHES = [
|
||||
{ name: "Warmweiß", value: "oklch(90% 0.06 85)" },
|
||||
{ name: "Sonnengelb", value: "oklch(85% 0.16 95)" },
|
||||
{ name: "Korallenrot", value: "oklch(65% 0.20 25)" },
|
||||
{ name: "Delfinblau", value: "oklch(70% 0.15 235)" },
|
||||
{ name: "Riffgrün", value: "oklch(75% 0.16 155)" },
|
||||
{ name: "Quallenlila", value: "oklch(65% 0.18 310)" },
|
||||
];
|
||||
|
||||
const SCENES = [
|
||||
{ id: "lesen", name: "Lesen", emoji: "📖", lights: [{ on: true, level: 4, color: "oklch(90% 0.06 85)" }, { on: true, level: 2, color: "oklch(90% 0.06 85)" }, { on: false, level: 2, color: "oklch(70% 0.15 235)" }], ceiling: { on: true, level: 3 }, shutter: 0 },
|
||||
{ id: "spielen", name: "Spielen", emoji: "🐬", lights: [{ on: true, level: 5, color: "oklch(75% 0.16 155)" }, { on: true, level: 5, color: "oklch(70% 0.15 235)" }, { on: true, level: 4, color: "oklch(65% 0.18 310)" }], ceiling: { on: true, level: 5 }, shutter: 0 },
|
||||
{ id: "party", name: "Disco", emoji: "🪩", lights: [{ on: true, level: 5, color: "oklch(65% 0.18 310)" }, { on: true, level: 5, color: "oklch(65% 0.20 25)" }, { on: true, level: 5, color: "oklch(70% 0.15 235)" }], ceiling: { on: false, level: 2 }, shutter: 85 },
|
||||
{ id: "nacht", name: "Gute Nacht", emoji: "🌙", lights: [{ on: true, level: 1, color: "oklch(70% 0.15 235)" }, { on: false, level: 1, color: "oklch(70% 0.15 235)" }, { on: true, level: 1, color: "oklch(65% 0.18 310)" }], ceiling: { on: false, level: 1 }, shutter: 100 },
|
||||
{ id: "aufwachen", name: "Aufwachen", emoji: "🌞", lights: [{ on: true, level: 3, color: "oklch(85% 0.16 95)" }, { on: true, level: 3, color: "oklch(85% 0.16 95)" }, { on: false, level: 2, color: "oklch(85% 0.16 95)" }], ceiling: { on: true, level: 2 }, shutter: 0 },
|
||||
];
|
||||
|
||||
class Component extends DCLogic {
|
||||
state = {
|
||||
lights: [
|
||||
{ id: "beyond1", kind: "beyond", name: "Hue Beyond links", on: true, level: 4, color: "oklch(70% 0.15 235)" },
|
||||
{ id: "beyond2", kind: "beyond", name: "Hue Beyond rechts", on: false, level: 3, color: "oklch(75% 0.16 155)" },
|
||||
{ id: "strip", kind: "strip", name: "Lichtband Bett", on: true, level: 3, color: "oklch(65% 0.18 310)" },
|
||||
{ id: "ceiling", kind: "ceiling", name: "Deckenlampe", on: true, level: 5, color: null },
|
||||
],
|
||||
shutter: 0,
|
||||
moving: null,
|
||||
scene: null,
|
||||
};
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this._mover) clearInterval(this._mover);
|
||||
}
|
||||
|
||||
patch(id, changes) {
|
||||
this.setState((s) => ({
|
||||
scene: null,
|
||||
lights: s.lights.map((l) => (l.id === id ? Object.assign({}, l, changes) : l)),
|
||||
}));
|
||||
}
|
||||
|
||||
move(dir) {
|
||||
if (this._mover) clearInterval(this._mover);
|
||||
this.setState({ moving: dir, scene: null });
|
||||
this._mover = setInterval(() => {
|
||||
this.setState((s) => {
|
||||
const next = s.shutter + dir * 4;
|
||||
if (next <= 0) { clearInterval(this._mover); this._mover = null; return { shutter: 0, moving: null }; }
|
||||
if (next >= 100) { clearInterval(this._mover); this._mover = null; return { shutter: 100, moving: null }; }
|
||||
return { shutter: next };
|
||||
});
|
||||
}, 90);
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this._mover) clearInterval(this._mover);
|
||||
this._mover = null;
|
||||
this.setState({ moving: null });
|
||||
}
|
||||
|
||||
glow(color, level) {
|
||||
return `0 0 0 4px ${color.replace(")", " / .28)")}, 0 10px 26px ${color.replace(")", ` / ${0.18 + level * 0.08})`)}`;
|
||||
}
|
||||
|
||||
renderVals() {
|
||||
const shutter = Math.round(this.state.shutter);
|
||||
|
||||
const lights = this.state.lights.map((l) => {
|
||||
const tint = l.color || "oklch(92% 0.05 90)";
|
||||
const active = l.on;
|
||||
return {
|
||||
id: l.id,
|
||||
name: l.name,
|
||||
isBeyond: l.kind === "beyond",
|
||||
isStrip: l.kind === "strip",
|
||||
isCeiling: l.kind === "ceiling",
|
||||
hasColors: l.kind !== "ceiling",
|
||||
cardStyle: `background:oklch(96% 0.012 300 / .55);backdrop-filter:blur(6px);border-radius:22px;padding:18px 20px 20px;box-shadow:0 8px 24px oklch(15% 0.05 300 / .35)${active ? `, 0 0 0 3px ${tint.replace(")", " / .55)")}` : ""};`,
|
||||
iconWrapStyle: `width:56px;height:56px;border-radius:16px;flex:none;display:flex;align-items:center;justify-content:center;transition:background .15s ease;background:${active ? tint : "oklch(88% 0.02 300)"};color:${active ? "oklch(28% 0.05 300)" : "oklch(55% 0.02 300)"};${active ? `box-shadow:0 0 22px ${tint.replace(")", " / .7)")};` : ""}`,
|
||||
switchStyle: `width:74px;height:42px;flex:none;border:none;cursor:pointer;border-radius:999px;padding:4px;display:flex;justify-content:${active ? "flex-end" : "flex-start"};background:${active ? "oklch(62% 0.15 302)" : "oklch(84% 0.01 300)"};transition:background .15s ease;`,
|
||||
knobStyle: "width:34px;height:34px;border-radius:999px;background:#fff;box-shadow:0 2px 6px oklch(20% 0.03 300 / .4);display:block;",
|
||||
levels: [1, 2, 3, 4, 5].map((v) => ({
|
||||
lightId: l.id,
|
||||
value: v,
|
||||
style: `flex:1;height:46px;min-width:0;border:none;cursor:pointer;border-radius:12px;background:${active && l.level >= v ? tint : "oklch(88% 0.02 300)"};opacity:${active ? 1 : 0.55};`,
|
||||
})),
|
||||
swatches: SWATCHES.map((sw) => ({
|
||||
lightId: l.id,
|
||||
value: sw.value,
|
||||
name: sw.name,
|
||||
style: `width:46px;height:46px;border-radius:999px;cursor:pointer;background:${sw.value};border:${l.color === sw.value ? "4px solid oklch(28% 0.05 300)" : "4px solid oklch(96% 0.012 300 / .8)"};box-shadow:0 3px 8px oklch(20% 0.03 300 / .35);`,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
const presetBtn = (active) =>
|
||||
`border:none;cursor:pointer;font-family:inherit;font-size:14px;font-weight:800;padding:0 10px;height:48px;border-radius:14px;background:${active ? "oklch(62% 0.15 302)" : "oklch(89% 0.02 300)"};color:${active ? "#fff" : "oklch(28% 0.04 300)"};`;
|
||||
|
||||
const label =
|
||||
shutter >= 99 ? "Ganz zu" :
|
||||
shutter >= 80 ? "Fast zu" :
|
||||
shutter >= 40 ? "Halb zu" :
|
||||
shutter <= 1 ? "Offen" : `${shutter}% zu`;
|
||||
|
||||
return {
|
||||
lights,
|
||||
toggleLight: (e) => {
|
||||
const id = e.currentTarget.dataset.id;
|
||||
const l = this.state.lights.find((x) => x.id === id);
|
||||
this.patch(id, { on: !l.on });
|
||||
},
|
||||
setLevel: (e) => {
|
||||
const d = e.currentTarget.dataset;
|
||||
this.patch(d.id, { level: Number(d.level), on: true });
|
||||
},
|
||||
setColor: (e) => {
|
||||
const d = e.currentTarget.dataset;
|
||||
this.patch(d.id, { color: d.color, on: true });
|
||||
},
|
||||
shutterLabel: this.state.moving ? (this.state.moving > 0 ? "Fährt runter …" : "Fährt hoch …") : label,
|
||||
shutterFillStyle: `height:${shutter}%;transition:height .2s linear;background:repeating-linear-gradient(180deg, oklch(70% 0.03 300) 0px, oklch(70% 0.03 300) 9px, oklch(58% 0.03 300) 9px, oklch(58% 0.03 300) 12px);box-shadow:0 4px 10px oklch(20% 0.03 300 / .4);`,
|
||||
shutterPresets: [
|
||||
{ name: "Offen", value: 0, style: presetBtn(shutter <= 1) },
|
||||
{ name: "Halb zu", value: 50, style: presetBtn(shutter > 1 && shutter < 80) },
|
||||
{ name: "Fast zu", value: 85, style: presetBtn(shutter >= 80 && shutter < 99) },
|
||||
{ name: "Ganz zu", value: 100, style: presetBtn(shutter >= 99) },
|
||||
],
|
||||
setShutter: (e) => {
|
||||
this.stop();
|
||||
this.setState({ shutter: Number(e.currentTarget.dataset.value), scene: null });
|
||||
},
|
||||
shutterUp: () => this.move(-1),
|
||||
shutterDown: () => this.move(1),
|
||||
shutterStop: () => this.stop(),
|
||||
upStyle: `border:none;cursor:pointer;font-family:inherit;font-size:20px;font-weight:900;height:52px;border-radius:14px;background:${this.state.moving === -1 ? "oklch(62% 0.15 302)" : "oklch(89% 0.02 300)"};color:${this.state.moving === -1 ? "#fff" : "oklch(28% 0.04 300)"};`,
|
||||
downStyle: `border:none;cursor:pointer;font-family:inherit;font-size:20px;font-weight:900;height:52px;border-radius:14px;background:${this.state.moving === 1 ? "oklch(62% 0.15 302)" : "oklch(89% 0.02 300)"};color:${this.state.moving === 1 ? "#fff" : "oklch(28% 0.04 300)"};`,
|
||||
stopStyle: "border:none;cursor:pointer;font-family:inherit;font-size:18px;font-weight:900;height:52px;border-radius:14px;background:oklch(65% 0.18 25);color:#fff;",
|
||||
scenes: SCENES.map((sc) => ({
|
||||
id: sc.id,
|
||||
name: sc.name,
|
||||
emoji: sc.emoji,
|
||||
style: `border:none;cursor:pointer;font-family:inherit;font-size:15px;font-weight:800;padding:12px 20px;border-radius:999px;display:flex;align-items:center;gap:10px;min-height:52px;background:${
|
||||
this.state.scene === sc.id ? "oklch(97% 0.01 302)" : "oklch(97% 0.01 302 / .18)"
|
||||
};color:${this.state.scene === sc.id ? "oklch(26% 0.05 300)" : "oklch(97% 0.01 302)"};box-shadow:${
|
||||
this.state.scene === sc.id ? "0 6px 18px oklch(15% 0.05 300 / .4)" : "none"
|
||||
};`,
|
||||
})),
|
||||
activateScene: (e) => {
|
||||
const sc = SCENES.find((x) => x.id === e.currentTarget.dataset.id);
|
||||
if (!sc) return;
|
||||
this.stop();
|
||||
this.setState((s) => ({
|
||||
scene: sc.id,
|
||||
shutter: sc.shutter,
|
||||
lights: s.lights.map((l, i) =>
|
||||
l.kind === "ceiling"
|
||||
? Object.assign({}, l, sc.ceiling)
|
||||
: Object.assign({}, l, sc.lights[i] || {})
|
||||
),
|
||||
}));
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
BIN
claude-design/dolphin-mascot.png
Normal file
BIN
claude-design/dolphin-mascot.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 697 KiB |
BIN
claude-design/dolphin-remote.png
Normal file
BIN
claude-design/dolphin-remote.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 788 KiB |
BIN
claude-design/pasted-1787085770763-0.png
Normal file
BIN
claude-design/pasted-1787085770763-0.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 697 KiB |
BIN
claude-design/pasted-1787122665953-0.png
Normal file
BIN
claude-design/pasted-1787122665953-0.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 788 KiB |
BIN
claude-design/pasted-1787124615900-0.png
Normal file
BIN
claude-design/pasted-1787124615900-0.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 788 KiB |
1911
claude-design/support.js
Normal file
1911
claude-design/support.js
Normal file
File diff suppressed because it is too large
Load Diff
435
claude-design/three-d-stage.js
Normal file
435
claude-design/three-d-stage.js
Normal file
@@ -0,0 +1,435 @@
|
||||
// @ds-adherence-ignore -- omelette starter scaffold (raw elements/hex/px by design)
|
||||
// Copied omelette starter. Re-running copy_starter_component with this kind overwrites this file with the latest version (page content is unaffected).
|
||||
/* BEGIN USAGE */
|
||||
/**
|
||||
* <three-d-stage> — 3D object viewer + exporter shell (three.js).
|
||||
*
|
||||
* The stage owns the whole scene: WebGL renderer, neutral studio lighting
|
||||
* with a soft ground shadow, orbit controls (drag to orbit, wheel to zoom,
|
||||
* right-drag to pan), a camera auto-framed to the object's bounds, resize
|
||||
* handling, and a download toolbar that exports the current object as
|
||||
* OBJ + MTL or GLB (binary glTF). FBX cannot be exported in the browser;
|
||||
* GLB is the interchange format every modern 3D tool imports.
|
||||
*
|
||||
* three.js loads through the page's import map. Include this EXACT pinned
|
||||
* map in <head>, before any module runs — versions and integrity hashes
|
||||
* stay together (same map the "3D object" skill mandates):
|
||||
*
|
||||
* <script type="importmap">
|
||||
* {
|
||||
* "imports": {
|
||||
* "three": "https://unpkg.com/three@0.184.0/build/three.module.js",
|
||||
* "three/addons/controls/OrbitControls.js": "https://unpkg.com/three@0.184.0/examples/jsm/controls/OrbitControls.js",
|
||||
* "three/addons/exporters/OBJExporter.js": "https://unpkg.com/three@0.184.0/examples/jsm/exporters/OBJExporter.js",
|
||||
* "three/addons/exporters/GLTFExporter.js": "https://unpkg.com/three@0.184.0/examples/jsm/exporters/GLTFExporter.js"
|
||||
* },
|
||||
* "integrity": {
|
||||
* "https://unpkg.com/three@0.184.0/build/three.module.js": "sha384-8FCZ1eVO6it4+pbec2aDtnTrwjWXZLJRC+MAGCIPDgsYnUrl/E0A2YlF8ioMKI/J",
|
||||
* "https://unpkg.com/three@0.184.0/build/three.core.js": "sha384-dw2ooPewaEIrAgl6oFDBmmBWCE9oW9LxRGcfwZ0hLvEprzo202wXl7vCYHRlSnOT",
|
||||
* "https://unpkg.com/three@0.184.0/examples/jsm/controls/OrbitControls.js": "sha384-4rziNxOBZKQ69i+w+f89KJ55TCYquwchVbByQwmaOeIOXdOU2PLDn3kOfXHwIJC9",
|
||||
* "https://unpkg.com/three@0.184.0/examples/jsm/exporters/OBJExporter.js": "sha384-nbwtoZENJD3Vq+ACK0CuGQdPMuDWHkamC2KJD70EV5nfg6jQjfppKOea07YJN+N3",
|
||||
* "https://unpkg.com/three@0.184.0/examples/jsm/exporters/GLTFExporter.js": "sha384-VofkvpG6HERhFCYbsUOHeNXBCqID2nfqkQqnVzE1jc/oPcz+qJ13ADdXH08hE+cQ"
|
||||
* }
|
||||
* }
|
||||
* </script>
|
||||
*
|
||||
* Usage:
|
||||
* <style>three-d-stage:not(:defined){visibility:hidden}</style>
|
||||
* <three-d-stage name="rocket"></three-d-stage>
|
||||
* <script src="three-d-stage.js"></script>
|
||||
* <script type="module">
|
||||
* const stage = document.querySelector('three-d-stage');
|
||||
* const { THREE } = await stage.ready;
|
||||
* const model = new THREE.Group();
|
||||
* // …build the model out of named meshes with named materials —
|
||||
* // the names become the o / usemtl entries in the exported OBJ…
|
||||
* stage.setObject(model);
|
||||
* </script>
|
||||
*
|
||||
* Attributes:
|
||||
* name — export file basename (default "model")
|
||||
* background — CSS color behind the scene (default a warm paper tone)
|
||||
* autorotate — when present, a slow turntable until the user interacts
|
||||
*
|
||||
* Model in real-world meters, centered on the origin, y-up — exports
|
||||
* inherit the scene's units and orientation. The stage fills its own box;
|
||||
* size it with ordinary CSS (default 100vw/100vh page hero).
|
||||
*
|
||||
* Default setup: neutral studio lighting (hemisphere + key + fill), a
|
||||
* soft ground shadow, and NO environment map — so high metalness has
|
||||
* nothing to reflect and renders near-black. Cap metalness around
|
||||
* 0.3–0.4 and carry a metal look with a brighter base color. The copied
|
||||
* file is yours: adjust the lights, shadow, or background in _boot()
|
||||
* when the object needs a different look.
|
||||
*/
|
||||
/* END USAGE */
|
||||
|
||||
(() => {
|
||||
const stylesheet = `
|
||||
:host {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
background: var(--stage-bg, #f0eee6);
|
||||
overflow: hidden;
|
||||
}
|
||||
canvas { display: block; outline: none; }
|
||||
.toolbar {
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
bottom: 16px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
}
|
||||
.toolbar button {
|
||||
appearance: none;
|
||||
border: 1px solid rgba(20, 20, 19, 0.18);
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
color: #1a1915;
|
||||
font-family: inherit;
|
||||
font-size: 12.5px;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
padding: 9px 12px;
|
||||
cursor: default;
|
||||
}
|
||||
.toolbar button:hover { background: #fff; }
|
||||
.toolbar button:active { transform: translateY(1px); }
|
||||
.toolbar button[disabled] { opacity: 0.5; pointer-events: none; }
|
||||
.note {
|
||||
position: absolute;
|
||||
left: 16px;
|
||||
bottom: 16px;
|
||||
max-width: 60%;
|
||||
font: 400 12px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
color: rgba(26, 25, 21, 0.55);
|
||||
user-select: none;
|
||||
}
|
||||
.err {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
font: 500 14px/1.6 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
color: #8a2f20;
|
||||
text-align: center;
|
||||
white-space: pre-line;
|
||||
}
|
||||
`;
|
||||
|
||||
function download(blob, filename) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 4000);
|
||||
}
|
||||
|
||||
/** Tell the host an export attempt settled — telemetry only. The host
|
||||
* (HTMLViewer) verifies the source and re-reads these fields defensively
|
||||
* before counting; nothing else crosses the frame boundary. Guarded so
|
||||
* telemetry can never break the download path. */
|
||||
function notifyExport(format, ok) {
|
||||
try {
|
||||
window.parent.postMessage(
|
||||
{ type: 'omelette:notify-3d-export', format: format, ok: ok === true },
|
||||
'*'
|
||||
);
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
class ThreeDStage extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
const root = this.attachShadow({ mode: 'open' });
|
||||
const style = document.createElement('style');
|
||||
style.textContent = stylesheet;
|
||||
root.appendChild(style);
|
||||
this._err = document.createElement('div');
|
||||
this._err.className = 'err';
|
||||
root.appendChild(this._err);
|
||||
const note = document.createElement('div');
|
||||
note.className = 'note';
|
||||
note.textContent = 'Drag to orbit · scroll to zoom · right-drag to pan';
|
||||
root.appendChild(note);
|
||||
this._toolbar = document.createElement('div');
|
||||
this._toolbar.className = 'toolbar';
|
||||
this._objBtn = document.createElement('button');
|
||||
this._objBtn.type = 'button';
|
||||
this._objBtn.textContent = 'Download OBJ + MTL';
|
||||
this._objBtn.addEventListener('click', () => this._runExport('obj'));
|
||||
this._glbBtn = document.createElement('button');
|
||||
this._glbBtn.type = 'button';
|
||||
this._glbBtn.textContent = 'Download GLB';
|
||||
this._glbBtn.addEventListener('click', () => this._runExport('glb'));
|
||||
this._toolbar.appendChild(this._objBtn);
|
||||
this._toolbar.appendChild(this._glbBtn);
|
||||
root.appendChild(this._toolbar);
|
||||
this._setButtonsEnabled(false);
|
||||
/** Resolves with { THREE } once the scene is live — build the model
|
||||
* in `await stage.ready` so nothing races the library load. */
|
||||
this.ready = new Promise((resolve, reject) => {
|
||||
this._readyResolve = resolve;
|
||||
this._readyReject = reject;
|
||||
});
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
if (this._booted) {
|
||||
// Re-attached after a removal — resume what disconnected stopped.
|
||||
if (this._renderer) {
|
||||
this._renderer.setAnimationLoop(this._loop);
|
||||
this._ro && this._ro.observe(this);
|
||||
}
|
||||
return;
|
||||
}
|
||||
this._booted = true;
|
||||
this._boot().catch((err) => {
|
||||
this._err.style.display = 'flex';
|
||||
this._err.textContent =
|
||||
'three.js failed to load.\n' +
|
||||
'Check that the pinned <script type="importmap"> from the usage ' +
|
||||
'notes is in <head> before any module script.\n\n' +
|
||||
String(err && err.message ? err.message : err);
|
||||
this._readyReject(err);
|
||||
});
|
||||
}
|
||||
|
||||
async _boot() {
|
||||
const bg = this.getAttribute('background');
|
||||
if (bg) this.style.setProperty('--stage-bg', bg);
|
||||
const [THREE, controlsMod] = await Promise.all([
|
||||
import('three'),
|
||||
import('three/addons/controls/OrbitControls.js'),
|
||||
]);
|
||||
this._THREE = THREE;
|
||||
// preserveDrawingBuffer keeps the last frame readable after
|
||||
// compositing (toDataURL / drawImage) — it's what lets the
|
||||
// screenshot tools capture the scene instead of a blank canvas.
|
||||
const renderer = new THREE.WebGLRenderer({
|
||||
antialias: true,
|
||||
alpha: true,
|
||||
preserveDrawingBuffer: true,
|
||||
});
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
|
||||
renderer.shadowMap.enabled = true;
|
||||
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
||||
this._renderer = renderer;
|
||||
this.shadowRoot.insertBefore(renderer.domElement, this._err);
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
this._scene = scene;
|
||||
|
||||
const camera = new THREE.PerspectiveCamera(45, 1, 0.01, 500);
|
||||
camera.position.set(3, 2.2, 4);
|
||||
this._camera = camera;
|
||||
|
||||
const controls = new controlsMod.OrbitControls(camera, renderer.domElement);
|
||||
controls.enableDamping = true;
|
||||
controls.dampingFactor = 0.08;
|
||||
this._controls = controls;
|
||||
|
||||
// Neutral studio: soft sky/ground wash, a shadow-casting key light,
|
||||
// and a dim fill from behind so silhouettes never go black.
|
||||
scene.add(new THREE.HemisphereLight(0xffffff, 0xd8d2c4, 1.0));
|
||||
const key = new THREE.DirectionalLight(0xffffff, 2.2);
|
||||
key.position.set(4, 7, 5);
|
||||
key.castShadow = true;
|
||||
key.shadow.mapSize.set(2048, 2048);
|
||||
key.shadow.bias = -0.0002;
|
||||
this._key = key;
|
||||
scene.add(key);
|
||||
const fill = new THREE.DirectionalLight(0xfff4e6, 0.5);
|
||||
fill.position.set(-5, 3, -4);
|
||||
scene.add(fill);
|
||||
|
||||
const ground = new THREE.Mesh(
|
||||
new THREE.PlaneGeometry(200, 200),
|
||||
new THREE.ShadowMaterial({ opacity: 0.18 })
|
||||
);
|
||||
ground.rotation.x = -Math.PI / 2;
|
||||
ground.receiveShadow = true;
|
||||
this._ground = ground;
|
||||
scene.add(ground);
|
||||
|
||||
this._autorotate = this.hasAttribute('autorotate');
|
||||
controls.autoRotate = this._autorotate;
|
||||
controls.autoRotateSpeed = 1.2;
|
||||
controls.addEventListener('start', () => {
|
||||
controls.autoRotate = false;
|
||||
});
|
||||
|
||||
const fit = () => {
|
||||
const w = this.clientWidth || 1;
|
||||
const h = this.clientHeight || 1;
|
||||
renderer.setSize(w, h);
|
||||
camera.aspect = w / h;
|
||||
camera.updateProjectionMatrix();
|
||||
};
|
||||
fit();
|
||||
this._ro = new ResizeObserver(fit);
|
||||
this._loop = () => {
|
||||
controls.update();
|
||||
renderer.render(scene, camera);
|
||||
};
|
||||
// Detached while three.js was fetching? Stay idle — the
|
||||
// connectedCallback resume starts the loop and observer on
|
||||
// reattach.
|
||||
if (this.isConnected) {
|
||||
this._ro.observe(this);
|
||||
renderer.setAnimationLoop(this._loop);
|
||||
}
|
||||
|
||||
this._readyResolve({ THREE });
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
// Stop rendering and observing while detached; connectedCallback
|
||||
// resumes both. (The renderer itself is kept — a move within the
|
||||
// document must not rebuild the scene.)
|
||||
if (this._renderer) this._renderer.setAnimationLoop(null);
|
||||
if (this._ro) this._ro.disconnect();
|
||||
}
|
||||
|
||||
/** Show (and own) the object. Replaces any previous object, enables
|
||||
* shadows on every mesh, rests it on the ground plane, and frames
|
||||
* the camera to its bounds. */
|
||||
setObject(object) {
|
||||
const THREE = this._THREE;
|
||||
if (!THREE) throw new Error('three-d-stage: not ready — await stage.ready first');
|
||||
if (this._object) this._scene.remove(this._object);
|
||||
this._object = object;
|
||||
object.traverse((o) => {
|
||||
if (o.isMesh) {
|
||||
o.castShadow = true;
|
||||
o.receiveShadow = true;
|
||||
}
|
||||
});
|
||||
const box = new THREE.Box3().setFromObject(object);
|
||||
if (!box.isEmpty()) {
|
||||
// Rest the object on the ground without moving its origin.
|
||||
this._ground.position.y = box.min.y;
|
||||
const sphere = box.getBoundingSphere(new THREE.Sphere());
|
||||
const dist =
|
||||
(sphere.radius / Math.tan((this._camera.fov * Math.PI) / 360)) * 1.35;
|
||||
const dir = new THREE.Vector3(1, 0.55, 1.25).normalize();
|
||||
this._camera.position
|
||||
.copy(sphere.center)
|
||||
.add(dir.multiplyScalar(dist));
|
||||
this._camera.near = Math.max(dist / 100, 0.01);
|
||||
this._camera.far = dist * 100;
|
||||
this._camera.updateProjectionMatrix();
|
||||
this._controls.target.copy(sphere.center);
|
||||
this._controls.update();
|
||||
const span = sphere.radius * 3;
|
||||
this._key.shadow.camera.left = -span;
|
||||
this._key.shadow.camera.right = span;
|
||||
this._key.shadow.camera.top = span;
|
||||
this._key.shadow.camera.bottom = -span;
|
||||
this._key.shadow.camera.updateProjectionMatrix();
|
||||
}
|
||||
this._scene.add(object);
|
||||
this._setButtonsEnabled(true);
|
||||
}
|
||||
|
||||
get _basename() {
|
||||
return (this.getAttribute('name') || 'model').replace(/[^\w.-]+/g, '_');
|
||||
}
|
||||
|
||||
_setButtonsEnabled(on) {
|
||||
this._objBtn.disabled = !on;
|
||||
this._glbBtn.disabled = !on;
|
||||
}
|
||||
|
||||
/** Every mesh and material needs a unique name for o/usemtl lines —
|
||||
* fill in stable fallbacks, and return the unique material list. */
|
||||
_nameParts() {
|
||||
const mats = [];
|
||||
const seen = new Set();
|
||||
let meshI = 0;
|
||||
let matI = 0;
|
||||
this._object.traverse((o) => {
|
||||
if (!o.isMesh) return;
|
||||
if (!o.name) o.name = 'part_' + meshI;
|
||||
meshI += 1;
|
||||
const list = Array.isArray(o.material) ? o.material : [o.material];
|
||||
for (const m of list) {
|
||||
if (!m || mats.includes(m)) continue;
|
||||
if (!m.name) {
|
||||
m.name = 'mat_' + matI;
|
||||
matI += 1;
|
||||
}
|
||||
while (seen.has(m.name)) {
|
||||
m.name = m.name + '_' + matI;
|
||||
matI += 1;
|
||||
}
|
||||
seen.add(m.name);
|
||||
mats.push(m);
|
||||
}
|
||||
});
|
||||
return mats;
|
||||
}
|
||||
|
||||
/** One export attempt, reported to the host however it settles.
|
||||
* Rethrows so a failure stays visible on the guest console exactly as
|
||||
* before. The no-object early return is not an attempt (the toolbar is
|
||||
* disabled until the model loads) and reports nothing. */
|
||||
async _runExport(format) {
|
||||
if (!this._object) return;
|
||||
try {
|
||||
await (format === 'obj' ? this._exportObj() : this._exportGlb());
|
||||
notifyExport(format, true);
|
||||
} catch (err) {
|
||||
notifyExport(format, false);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async _exportObj() {
|
||||
if (!this._object) return;
|
||||
const mod = await import('three/addons/exporters/OBJExporter.js');
|
||||
const mats = this._nameParts();
|
||||
const base = this._basename;
|
||||
const obj =
|
||||
'mtllib ' + base + '.mtl\n' + new mod.OBJExporter().parse(this._object);
|
||||
let mtl = '# Exported by three-d-stage\n';
|
||||
for (const m of mats) {
|
||||
const c = m.color || { r: 0.8, g: 0.8, b: 0.8 };
|
||||
const rough = typeof m.roughness === 'number' ? m.roughness : 0.5;
|
||||
const opacity = typeof m.opacity === 'number' ? m.opacity : 1;
|
||||
mtl += 'newmtl ' + m.name + '\n';
|
||||
mtl +=
|
||||
'Kd ' + c.r.toFixed(4) + ' ' + c.g.toFixed(4) + ' ' + c.b.toFixed(4) + '\n';
|
||||
mtl += 'Ks 0.2000 0.2000 0.2000\n';
|
||||
mtl += 'Ns ' + Math.round((1 - rough) * 200) + '\n';
|
||||
mtl += 'd ' + opacity.toFixed(4) + '\n\n';
|
||||
}
|
||||
download(new Blob([obj], { type: 'text/plain' }), base + '.obj');
|
||||
download(new Blob([mtl], { type: 'text/plain' }), base + '.mtl');
|
||||
}
|
||||
|
||||
async _exportGlb() {
|
||||
if (!this._object) return;
|
||||
const mod = await import('three/addons/exporters/GLTFExporter.js');
|
||||
this._nameParts();
|
||||
const base = this._basename;
|
||||
const buf = await new mod.GLTFExporter().parseAsync(this._object, {
|
||||
binary: true,
|
||||
});
|
||||
download(
|
||||
new Blob([buf], { type: 'model/gltf-binary' }),
|
||||
base + '.glb'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('three-d-stage', ThreeDStage);
|
||||
})();
|
||||
Reference in New Issue
Block a user