Files
musicmouse/claude-design/Dolphin Beats.dc.html
2026-09-21 11:42:33 +02:00

921 lines
57 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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="{&quot;$preview&quot;: {&quot;width&quot;: 1280, &quot;height&quot;: 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>