4 Commits

Author SHA1 Message Date
683cda35cb Add design references, editor settings and todo
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 11:42:33 +02:00
2db4368fc9 Web frontend: perf panel, cover warmup, incremental browse rendering
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 11:42:33 +02:00
9fd106b757 Backend: cover cache and web API changes
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 11:42:33 +02:00
794126bfdb Port "Mein Zimmer" - the Home Assistant room panel
The second of the web front-end's three pages. Lights get a toggle, five brightness
steps and six colours; shutters get four presets, up/stop/down and a window that
reflects the position Home Assistant reports. Its own violet hue rather than the
player's teal, so the two pages read as siblings rather than one bleeding into the
other - the same reason room.css has its own tokens.

The backend keeps Home Assistant's URL and token and relays the calls, so this client
learns only which entities exist, exactly as the browser does. A 404 from GET /api/ha
means Home Assistant is not configured, which is a normal answer: the tab simply does
not appear.

Polled, not subscribed, and only while the page is on screen. HA's own auth-and-
subscribe websocket is more machinery than a room panel needs, and a lamp's state is
not worth a request every 2.5 seconds when nobody is looking at it - this device has a
music player to stay out of the way of. A tap patches the entity locally and records
when; a poll that was already in flight when the patch landed is dropped for that
entity rather than putting the lamp back to "off" until the next one catches up. That
reconciliation is the one non-obvious thing in useHomeAssistant.ts and it is carried
over for the same reason.

Nothing on a shutter card animates locally. A real cover reports its own movement and
position, and a client-side guess at where it will end up is precisely what would
fight with that - the design mockup animated it because it had no backend to ask.

Two additions beyond a straight port, both because a kiosk has no pointer:

CTRL+TAB cycles the pages. The web's tab rail is pointer-only, which sits badly with a
front-end whose README claims every screen is reachable from the keyboard. Plain TAB
was already the group cycle, and CTRL+TAB is the idiom for the outer one everywhere
else.

--page opens directly on a page, which a kiosk may well want and which is the only way
in with neither keyboard nor pointer.

Verified against the real Home Assistant: the shutter reported open and the lamp off,
and both drew that way. Nothing was switched - those entities are hardware.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 01:02:18 +02:00
52 changed files with 6429 additions and 305 deletions

10
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,10 @@
{
"files.associations": {
"*.tcc": "cpp",
"deque": "cpp",
"string": "cpp",
"unordered_map": "cpp",
"vector": "cpp",
"system_error": "cpp"
}
}

View 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="{&quot;$preview&quot;: {&quot;width&quot;: 1180, &quot;height&quot;: 100}, &quot;title&quot;: {&quot;editor&quot;: &quot;text&quot;, &quot;default&quot;: &quot;Musik Delphin&quot;, &quot;tsType&quot;: &quot;string&quot;}, &quot;mascot&quot;: {&quot;editor&quot;: &quot;text&quot;, &quot;default&quot;: &quot;dolphin-mascot.png&quot;, &quot;tsType&quot;: &quot;string&quot;}, &quot;linkHref&quot;: {&quot;editor&quot;: &quot;text&quot;, &quot;default&quot;: &quot;./Mein%20Zimmer.dc.html&quot;, &quot;tsType&quot;: &quot;string&quot;}, &quot;linkLabel&quot;: {&quot;editor&quot;: &quot;text&quot;, &quot;default&quot;: &quot;💡 Mein Zimmer&quot;, &quot;tsType&quot;: &quot;string&quot;}, &quot;actions&quot;: {&quot;editor&quot;: null, &quot;tsType&quot;: &quot;Array&lt;{label: string, style: string, onClick: () =&gt; void}&gt;&quot;}}"></script>
</body>
</html>

Binary file not shown.

View 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="{&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>

View 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="{&quot;$preview&quot;: {&quot;width&quot;: 1280, &quot;height&quot;: 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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 697 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 788 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 697 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 788 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 788 KiB

1911
claude-design/support.js Normal file

File diff suppressed because it is too large Load Diff

View 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);
})();

View File

@@ -5,6 +5,7 @@ different amounts to produce::
<cache_dir>/ <cache_dir>/
├── index.json cheap: tags and structure. Thrown away freely. ├── index.json cheap: tags and structure. Thrown away freely.
├── covers/<album_id>.thumb.jpg the same art at THUMB_COVER_PX, for cards and rows
├── covers/<album_id>.jpg medium: the album's art - out of an ID3 APIC ├── covers/<album_id>.jpg medium: the album's art - out of an ID3 APIC
│ frame, or copied from a cover.jpg in the │ frame, or copied from a cover.jpg in the
│ folder - downscaled to MAX_COVER_PX on │ folder - downscaled to MAX_COVER_PX on
@@ -102,11 +103,32 @@ class LibraryCache:
def cover_path(self, album_id: str) -> Path: def cover_path(self, album_id: str) -> Path:
return self.covers / f"{album_id}.jpg" return self.covers / f"{album_id}.jpg"
def thumb_path(self, album_id: str) -> Path:
return self.covers / f"{album_id}.thumb.jpg"
def store_cover(self, album_id: str, data: bytes) -> Path: def store_cover(self, album_id: str, data: bytes) -> Path:
path = self.cover_path(album_id) path = self.cover_path(album_id)
# The thumb comes from the original art, not from the 384 px copy: one lossy
# generation fewer, and `Image.draft` lets JPEG decode at a fraction of the size.
path.write_bytes(shrink_cover(data)) path.write_bytes(shrink_cover(data))
self.thumb_path(album_id).write_bytes(
shrink_cover(data, max_px=THUMB_COVER_PX, quality=_THUMB_JPEG_QUALITY)
)
return path return path
def _refresh_thumb(self, cover: Path) -> bool:
"""Make ``<id>.thumb.jpg`` exist and be newer than the cover it came from."""
thumb = cover.with_name(f"{cover.stem}.thumb.jpg")
try:
if thumb.exists() and thumb.stat().st_mtime_ns >= cover.stat().st_mtime_ns:
return False
thumb.write_bytes(
shrink_cover(cover.read_bytes(), max_px=THUMB_COVER_PX, quality=_THUMB_JPEG_QUALITY)
)
except OSError: # pragma: no cover - a cache we cannot write is not fatal
return False
return True
def shrink_stored_covers(self) -> int: def shrink_stored_covers(self) -> int:
"""Rewrite any already-stored cover that predates :data:`MAX_COVER_PX`. """Rewrite any already-stored cover that predates :data:`MAX_COVER_PX`.
@@ -116,23 +138,27 @@ class LibraryCache:
dimensions only parses its header, so once every file is within the limit this dimensions only parses its header, so once every file is within the limit this
costs one small read per album and nothing else. costs one small read per album and nothing else.
Returns the number of files actually rewritten. Also backfills any missing (or stale) thumbnail. Returns the number of full-size
files actually rewritten.
""" """
rewritten = 0 rewritten = 0
for path in sorted(self.covers.glob("*.jpg")): for path in sorted(self.covers.glob("*.jpg")):
if path.name.endswith(".thumb.jpg"):
continue
try: try:
with Image.open(path) as image: with Image.open(path) as image:
oversized = max(image.size) > MAX_COVER_PX oversized = max(image.size) > MAX_COVER_PX
except (OSError, UnidentifiedImageError): except (OSError, UnidentifiedImageError):
continue continue
if not oversized: if oversized:
continue
try: try:
shrunk = shrink_cover(path.read_bytes()) shrunk = shrink_cover(path.read_bytes())
path.write_bytes(shrunk) path.write_bytes(shrunk)
except OSError: # pragma: no cover - a cache we cannot write is not fatal except OSError: # pragma: no cover - a cache we cannot write is not fatal
continue continue
rewritten += 1 rewritten += 1
# Covers stored before thumbnails existed get theirs here, without a rescan.
self._refresh_thumb(path)
return rewritten return rewritten
# ------------------------------------------------------------------ analysis # ------------------------------------------------------------------ analysis
@@ -289,24 +315,48 @@ MAX_COVER_PX: Final = 384
#: file is a third of the size. #: file is a third of the size.
_COVER_JPEG_QUALITY: Final = 85 _COVER_JPEG_QUALITY: Final = 85
#: Longest edge of the card/row thumbnail. A grid card is about 217 CSS px wide on a
#: 1080p kiosk (five columns in the 1180 px grid), so 256 covers it at devicePixelRatio 1
#: with a little to spare; only the play view asks for the full-size file. Decode cost
#: and texture upload go with pixel count, and this is 44% of the 384 px file's.
THUMB_COVER_PX: Final = 256
def shrink_cover(data: bytes) -> bytes: #: Baseline JPEG, 4:2:0 (see :func:`shrink_cover`). libjpeg-turbo is the fastest decoder
"""Downscale cover art to :data:`MAX_COVER_PX` on its longest edge. #: a browser on a Pi has, so JPEG stays the format; only the dimensions shrink.
_THUMB_JPEG_QUALITY: Final = 80
def shrink_cover(
data: bytes, max_px: int = MAX_COVER_PX, quality: int = _COVER_JPEG_QUALITY
) -> bytes:
"""Downscale cover art to ``max_px`` (default :data:`MAX_COVER_PX`) on its longest edge.
Art that is already small enough is returned untouched rather than re-encoded, so Art that is already small enough is returned untouched rather than re-encoded, so
repeated scans never degrade it. Anything Pillow cannot read is passed through repeated scans never degrade it. Anything Pillow cannot read is passed through
unchanged: a cover that is too big is a performance problem, a cover that is missing unchanged: a cover that is too big is a performance problem, a cover that is missing
is a visible one. is a visible one.
The output is always a baseline (non-progressive) 4:2:0 JPEG: the cheapest layout
for a browser to decode.
""" """
try: try:
with Image.open(io.BytesIO(data)) as image: with Image.open(io.BytesIO(data)) as image:
if max(image.size) <= MAX_COVER_PX: if max(image.size) <= max_px:
return data return data
# `thumbnail` keeps the aspect ratio and never scales up. # JPEG can decode straight to 1/2, 1/4 or 1/8 size; a no-op for other formats.
image.draft("RGB", (max_px, max_px))
image = image.convert("RGB") image = image.convert("RGB")
image.thumbnail((MAX_COVER_PX, MAX_COVER_PX), Image.Resampling.LANCZOS) # `thumbnail` keeps the aspect ratio and never scales up.
image.thumbnail((max_px, max_px), Image.Resampling.LANCZOS)
buffer = io.BytesIO() buffer = io.BytesIO()
image.save(buffer, format="JPEG", quality=_COVER_JPEG_QUALITY, optimize=True) image.save(
buffer,
format="JPEG",
quality=quality,
optimize=True,
progressive=False,
subsampling="4:2:0",
)
return buffer.getvalue() return buffer.getvalue()
except (OSError, UnidentifiedImageError, ValueError): except (OSError, UnidentifiedImageError, ValueError):
return data return data

View File

@@ -97,27 +97,32 @@ def build_router(
) )
@router.get("/albums/{album_id}/cover") @router.get("/albums/{album_id}/cover")
def get_cover(album_id: str) -> FileResponse: def get_cover(album_id: str, size: str = "full", v: str | None = None) -> FileResponse:
album = app.library.get(album_id) album = app.library.get(album_id)
if album is None or album.cover is None: if album is None or album.cover is None:
# Not an error: the client paints the album's own colours instead. # Not an error: the client paints the album's own colours instead.
raise HTTPException(status_code=404, detail="no cover") raise HTTPException(status_code=404, detail="no cover")
path = album.cover
if size == "thumb":
thumb = path.with_name(f"{path.stem}.thumb.jpg")
if thumb.is_file():
path = thumb
return FileResponse( return FileResponse(
album.cover, path,
# This used to send `public, max-age=604800`, on the reasoning that a cover # A URL carrying `v` (the file's mtime, from `AlbumOut.cover_v`) names one
# is "content-addressed by album id". It is not: the id addresses *which # exact set of bytes: reprocessing the art changes the library payload, so
# album* the art belongs to, and the bytes behind that URL change whenever # the client asks for a new URL. Those can be cached forever, and the
# the art is reprocessed. When `MAX_COVER_PX` arrived and every cover was # browser then never asks again - no conditional request per card.
# rewritten smaller, every browser that had loaded the page in the previous
# week went on decoding the old 3000px file out of its own disk cache, and
# a trace was the only way to see it.
# #
# `no-cache` does not mean "do not store", it means "revalidate before # Without `v` the id alone is not enough (the bytes behind it change whenever
# reusing" - so the browser keeps the file and usually gets a 304. The cost # the art is reprocessed - when `MAX_COVER_PX` arrived, browsers went on
# is one conditional request per cover, and it is not on the paint path at # decoding old 3000px files out of their cache), so `no-cache` makes the
# all: the service worker answers covers from its own cache first and does # browser revalidate: it keeps the file and usually gets a 304.
# the revalidation behind the page (see web/public/sw.js). headers={
headers={"Cache-Control": "no-cache"}, "Cache-Control": (
"public, max-age=31536000, immutable" if v else "no-cache"
)
},
) )
@router.get("/tracks/{album_id}/{index}/analysis") @router.get("/tracks/{album_id}/{index}/analysis")

View File

@@ -120,6 +120,15 @@ class TrackOut(BaseModel):
) )
def _cover_version(album: Album) -> int:
if album.cover is None:
return 0
try:
return album.cover.stat().st_mtime_ns // 1_000_000
except OSError:
return 0
class AlbumOut(BaseModel): class AlbumOut(BaseModel):
id: str id: str
section: str section: str
@@ -131,6 +140,9 @@ class AlbumOut(BaseModel):
category: str category: str
colors: list[str] colors: list[str]
has_cover: bool has_cover: bool
#: Cache-busting version of the cover (its mtime in ms; 0 with no cover). Goes in the
#: cover URL's `v` so the browser may cache the image forever.
cover_v: int = 0
duration: float duration: float
tracks: list[TrackOut] tracks: list[TrackOut]
#: Every track is still locked - the browse view shows a question mark instead of #: Every track is still locked - the browse view shows a question mark instead of
@@ -151,6 +163,7 @@ class AlbumOut(BaseModel):
category=album.category, category=album.category,
colors=list(album.colors), colors=list(album.colors),
has_cover=album.cover is not None, has_cover=album.cover is not None,
cover_v=_cover_version(album),
duration=album.duration, duration=album.duration,
locked=lock.locked if lock is not None else False, locked=lock.locked if lock is not None else False,
tracks=[ tracks=[

View File

@@ -17,6 +17,7 @@ import httpx2
import uvicorn import uvicorn
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from starlette.responses import Response
from musicmouse.app import App from musicmouse.app import App
from musicmouse.config import WebConfig from musicmouse.config import WebConfig
@@ -28,6 +29,18 @@ _log = logging.getLogger(__name__)
__all__ = ["WebService", "build_app"] __all__ = ["WebService", "build_app"]
class _WebFiles(StaticFiles):
"""Vite writes ``assets/<name>-<content hash>.<ext>``: a URL there names one exact
set of bytes, so the browser may keep it forever and never revalidate. Everything
else (``index.html``, ``sw.js``) has a stable name and keeps the default behaviour."""
def file_response(self, full_path, stat_result, scope, status_code=200) -> Response: # type: ignore[no-untyped-def]
response = super().file_response(full_path, stat_result, scope, status_code)
if scope.get("path", "").startswith("/assets/"):
response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
return response
def build_app( def build_app(
app: App, app: App,
config: WebConfig, config: WebConfig,
@@ -50,7 +63,7 @@ def build_app(
if config.static_dir.is_dir(): if config.static_dir.is_dir():
# Mounted last and at the root so every /api route still wins; html=True # Mounted last and at the root so every /api route still wins; html=True
# falls back to index.html, which is what a client-side router needs. # falls back to index.html, which is what a client-side router needs.
api.mount("/", StaticFiles(directory=config.static_dir, html=True), name="web") api.mount("/", _WebFiles(directory=config.static_dir, html=True), name="web")
else: else:
_log.warning( _log.warning(
"web.static_dir %s does not exist; serving the API only " "web.static_dir %s does not exist; serving the API only "

View File

@@ -820,6 +820,41 @@ class TestCoverDownscaling:
# The untouched original still comes back, because colour extraction wants it. # The untouched original still comes back, because colour extraction wants it.
assert art == (folder / "cover.jpg").read_bytes() assert art == (folder / "cover.jpg").read_bytes()
def test_store_cover_also_writes_a_smaller_baseline_thumbnail(self, tmp_path: Path) -> None:
from PIL import Image
from musicmouse.library.cache import THUMB_COVER_PX, LibraryCache
cache = LibraryCache(tmp_path)
cache.prepare()
cache.store_cover("abc123", self._jpeg((2400, 1800)))
with Image.open(cache.thumb_path("abc123")) as thumb:
assert max(thumb.size) == THUMB_COVER_PX
assert thumb.format == "JPEG"
assert "progressive" not in thumb.info
assert abs(thumb.width / thumb.height - 2400 / 1800) < 0.02
def test_thumbnails_are_backfilled_for_covers_stored_without_one(
self, tmp_path: Path
) -> None:
from PIL import Image
from musicmouse.library.cache import THUMB_COVER_PX, LibraryCache
cache = LibraryCache(tmp_path)
cache.prepare()
cache.cover_path("old").write_bytes(self._jpeg((384, 384)))
assert not cache.thumb_path("old").exists()
assert cache.shrink_stored_covers() == 0 # nothing oversized...
with Image.open(cache.thumb_path("old")) as thumb: # ...but the thumb appeared
assert max(thumb.size) == THUMB_COVER_PX
# Steady state: the thumbnail is not regenerated, and is not mistaken for a cover.
before = cache.thumb_path("old").stat().st_mtime_ns
assert cache.shrink_stored_covers() == 0
assert cache.thumb_path("old").stat().st_mtime_ns == before
assert not cache.thumb_path("old.thumb").exists()
def test_covers_written_by_an_older_version_are_migrated(self, tmp_path: Path) -> None: def test_covers_written_by_an_older_version_are_migrated(self, tmp_path: Path) -> None:
"""The scanner reuses an unchanged album without re-reading its tags, so a """The scanner reuses an unchanged album without re-reading its tags, so a
cover stored before the limit existed would otherwise never be rewritten.""" cover stored before the limit existed would otherwise never be rewritten."""

View File

@@ -90,6 +90,7 @@ async def test_library_lists_every_album_with_its_tracks(client: Client) -> None
async def test_a_missing_cover_is_a_404_not_an_error(client: Client) -> None: async def test_a_missing_cover_is_a_404_not_an_error(client: Client) -> None:
album = await album_by_title(client, "Fuchs") album = await album_by_title(client, "Fuchs")
assert album["has_cover"] is False assert album["has_cover"] is False
assert album["cover_v"] == 0
assert (await client.get(f"/api/albums/{album['id']}/cover")).status_code == 404 assert (await client.get(f"/api/albums/{album['id']}/cover")).status_code == 404

View File

@@ -6,8 +6,8 @@ kiosk, and the things making it sluggish are browser-shaped: a compositor decidi
gets its own layer, a `requestAnimationFrame` loop that repaints the viewport, and a gets its own layer, a `requestAnimationFrame` loop that repaints the viewport, and a
JPEG decode per album card per cold start. JPEG decode per album card per cold start.
Only the music player is here. The typing game (`Tippen`), the room-lights page, parent The music player and the room panel ("Mein Zimmer"). The typing game (`Tippen`), parent
mode and the IR-remote assignment are all still web-only. mode and the IR-remote assignment are still web-only.
## Running it ## Running it
@@ -17,6 +17,7 @@ Start the backend, then:
nix-shell # cargo, the GL/X11/Wayland libs, and Nunito nix-shell # cargo, the GL/X11/Wayland libs, and Nunito
cargo run # windowed, FemtoVG cargo run # windowed, FemtoVG
cargo run -- --server http://musicdolphin:8080 cargo run -- --server http://musicdolphin:8080
cargo run -- --page room # open on the room panel rather than the player
``` ```
Checks: Checks:
@@ -126,6 +127,7 @@ one websocket connection and a trickle of covers.
| `src/library.rs` | The search index and the browse hierarchy. Port of `web/src/lib/search.ts`. | | `src/library.rs` | The search index and the browse hierarchy. Port of `web/src/lib/search.ts`. |
| `src/keymap.rs` | The keyboard model, as a pure function. Port of `web/src/lib/keyboard.ts`. | | `src/keymap.rs` | The keyboard model, as a pure function. Port of `web/src/lib/keyboard.ts`. |
| `src/covers.rs` | The thumbnail cache, and the generated stand-in art. | | `src/covers.rs` | The thumbnail cache, and the generated stand-in art. |
| `src/ha.rs` | Home Assistant through the backend's proxy, and the shutter/colour conversions. Port of `web/src/hooks/useHomeAssistant.ts` and `lib/shutter.ts`. |
| `src/oklch.rs`, `src/format.rs` | Colour conversion; durations. | | `src/oklch.rs`, `src/format.rs` | Colour conversion; durations. |
| `src/main.rs` | Wiring: worker channels in, Slint models out. | | `src/main.rs` | Wiring: worker channels in, Slint models out. |
| `ui/*.slint` | Layout only. Nothing here formats a number or picks a word. | | `ui/*.slint` | Layout only. Nothing here formats a number or picks a word. |
@@ -154,9 +156,31 @@ The same map as the web UI.
| `SHIFT+ENTER` | open the track list | | `SHIFT+ENTER` | open the track list |
| `ESC` | back out one layer at a time | | `ESC` | back out one layer at a time |
| `/` | back to browsing | | `/` | back to browsing |
| `CTRL+TAB` | cycle Musik / Mein Zimmer / Tippen |
Everything is clickable too. Everything is clickable too.
`CTRL+TAB` is the one addition to the web front-end's map. Its tab rail is reachable
only with a pointer, which sits badly with a device whose stated design is that every
screen can be reached from the keyboard. Plain `TAB` was already the group cycle.
## Mein Zimmer
The Home Assistant room panel, in its own violet rather than the player's teal so the
two read as siblings. Lights get a toggle, five brightness steps and six colours;
shutters get four presets, up/stop/down and a window that reflects the reported
position. Nothing is animated locally - a real cover reports its own movement, and
guessing where it will end up is what would fight with that.
The whole page is a poll, not a subscription, and only while it is on screen: Home
Assistant's own auth-and-subscribe websocket is more machinery than this needs, and a
lamp is not worth a request every 2.5 s when nobody is looking. A tap patches the
entity locally and remembers when, so the poll already in flight cannot undo it - the
same reconciliation `useHomeAssistant.ts` documents, and for the same reason.
The tab appears only when the backend answers `GET /api/ha` with a config; a 404 there
means Home Assistant is not set up, which is a normal answer rather than an error.
The special keys are named in `ui/app.slint`, where the `Key.*` constants live, and The special keys are named in `ui/app.slint`, where the `Key.*` constants live, and
reach Rust as `"escape"`, `"left"`, `"enter"` and so on - so `src/keymap.rs` is testable reach Rust as `"escape"`, `"left"`, `"enter"` and so on - so `src/keymap.rs` is testable
without a window and without hardcoding keysym values. without a window and without hardcoding keysym values.

View File

@@ -222,6 +222,28 @@ impl Client {
format!("{}/api/albums/{album_id}/cover", self.base) format!("{}/api/albums/{album_id}/cover", self.base)
} }
/// A GET that decodes JSON, with the status code kept in the error text so a
/// caller that cares about 404 - several do, because "not configured" is a normal
/// answer here - can tell it from a connection failure.
pub(crate) fn get_json<T: serde::de::DeserializeOwned>(&self, path: &str) -> Result<T, Error> {
self.agent
.get(&format!("{}/api{path}", self.base))
.call()
.map_err(|e| format!("GET {path}: {e}"))?
.into_json()
.map_err(|e| format!("GET {path}: bad JSON: {e}"))
}
/// A POST whose response body, if any, is not worth reading. Home Assistant's
/// relayed replies are the only ones with a body, and nothing here reads it.
pub(crate) fn post_json(&self, path: &str, body: serde_json::Value) -> Result<(), Error> {
self.agent
.post(&format!("{}/api{path}", self.base))
.send_json(body)
.map(|_| ())
.map_err(|e| format!("POST {path}: {e}"))
}
pub fn library(&self) -> Result<Vec<Album>, Error> { pub fn library(&self) -> Result<Vec<Album>, Error> {
let out: LibraryOut = self let out: LibraryOut = self
.agent .agent

319
slint-frontend/src/ha.rs Normal file
View File

@@ -0,0 +1,319 @@
//! Home Assistant, through the backend's proxy.
//!
//! Ported from `web/src/hooks/useHomeAssistant.ts`. The browser never learns Home
//! Assistant's URL or token and neither does this: the backend keeps both and relays
//! `GET /api/ha/states/{id}` and `POST /api/ha/services/{domain}/{service}`, so all
//! this client knows is which entities exist.
//!
//! A short poll rather than a subscription. HA's own auth/subscribe websocket is more
//! machinery than a room panel needs, and the panel is only on screen while someone is
//! looking at it.
use std::collections::HashMap;
use std::time::{Duration, Instant};
use serde::Deserialize;
use crate::api::{Client, Error};
pub const POLL_INTERVAL: Duration = Duration::from_millis(2500);
#[derive(Debug, Clone, Deserialize)]
pub struct Device {
pub entity_id: String,
#[serde(default)]
pub name: Option<String>,
}
impl Device {
pub fn label(&self) -> &str {
self.name.as_deref().unwrap_or(&self.entity_id)
}
pub fn domain(&self) -> &str {
self.entity_id.split('.').next().unwrap_or("")
}
}
/// Presence-only, like the LIRC config - there is nothing secret in a list of entity
/// ids. `None` from `GET /api/ha` means Home Assistant is not configured at all, which
/// is a normal answer and hides the tab rather than being an error.
#[derive(Debug, Clone, Default, Deserialize)]
pub struct Config {
#[serde(default)]
pub devices: Vec<Device>,
#[serde(default)]
pub scenes: Vec<Device>,
}
/// One entity's `state`/`attributes`, relayed byte-for-byte from Home Assistant.
#[derive(Debug, Clone, Deserialize)]
pub struct EntityState {
#[serde(default)]
pub state: String,
#[serde(default)]
pub attributes: serde_json::Map<String, serde_json::Value>,
}
impl EntityState {
pub fn is_on(&self) -> bool {
self.state == "on"
}
fn number(&self, key: &str) -> Option<f64> {
self.attributes.get(key)?.as_f64()
}
/// 0..255 as Home Assistant reports it.
pub fn brightness(&self) -> f64 {
self.number("brightness").unwrap_or(0.0)
}
/// Percent *open*, which is HA's convention. The room UI thinks in percent closed -
/// see `closed_percent`.
pub fn cover_position(&self) -> Option<f64> {
self.number("current_position")
}
pub fn supported_features(&self) -> u64 {
self.number("supported_features").unwrap_or(0.0) as u64
}
pub fn rgb(&self) -> [u8; 3] {
let Some(values) = self.attributes.get("rgb_color").and_then(|v| v.as_array()) else {
// The warm default the web card falls back to, so an on-but-uncoloured lamp
// is not drawn as if it were off.
return [255, 214, 140];
};
let mut out = [255u8, 214, 140];
for (slot, value) in out.iter_mut().zip(values) {
if let Some(number) = value.as_f64() {
*slot = number.clamp(0.0, 255.0) as u8;
}
}
out
}
/// Whether this light can be given a colour, as opposed to only a brightness. A
/// `brightness_pct` sent to a plain on/off bulb is harmlessly ignored by HA, so
/// only the colour row is worth branching on.
pub fn is_color(&self) -> bool {
const COLOR_MODES: [&str; 5] = ["hs", "rgb", "rgbw", "rgbww", "xy"];
self.attributes
.get("supported_color_modes")
.and_then(|v| v.as_array())
.is_some_and(|modes| {
modes
.iter()
.filter_map(|m| m.as_str())
.any(|m| COLOR_MODES.contains(&m))
})
}
}
// --------------------------------------------------------------------- shutters --
/// Home Assistant's `CoverEntityFeature.SET_POSITION`.
pub const SUPPORT_SET_POSITION: u64 = 4;
/// The four buttons on a shutter card, in percent *closed*.
pub const SHUTTER_PRESETS: [(&str, f64); 4] = [
("Offen", 0.0),
("Halb zu", 50.0),
("Fast zu", 85.0),
("Ganz zu", 100.0),
];
/// HA reports percent *open*; the UI, following the design, thinks in percent *closed*.
/// The inversion lives in one place because it is easy to get backwards.
pub fn closed_percent(position: f64) -> f64 {
100.0 - position
}
pub fn position_from_closed(closed: f64) -> f64 {
100.0 - closed
}
pub fn closed_label(closed: f64) -> String {
if closed <= 1.0 {
"Offen".into()
} else if closed >= 99.0 {
"Ganz zu".into()
} else if closed >= 80.0 {
"Fast zu".into()
} else if closed >= 40.0 {
"Halb zu".into()
} else {
format!("{}% zu", closed.round())
}
}
// ---------------------------------------------------------------------- colours --
/// The six colours the room page offers, with the oklch each was authored as. Kept as
/// literals rather than converted at runtime for the same reason `ui/theme.slint` does:
/// the oklch is the form the design is maintained in, and the sRGB is what gets drawn.
pub const SWATCHES: [(&str, [u8; 3]); 6] = [
("Warmweiß", [0xf0, 0xdc, 0xb1]), // oklch(90% 0.06 85)
("Sonnengelb", [0xef, 0xcc, 0x36]), // oklch(85% 0.16 95)
("Korallenrot", [0xf1, 0x4d, 0x4c]), // oklch(65% 0.20 25)
("Delfinblau", [0x00, 0xab, 0xed]), // oklch(70% 0.15 235)
("Riffgrün", [0x42, 0xcb, 0x80]), // oklch(75% 0.16 155)
("Quallenlila", [0xb1, 0x6a, 0xe0]), // oklch(65% 0.18 310)
];
// ----------------------------------------------------------------------- client --
impl Client {
/// `Ok(None)` when Home Assistant is not configured - a 404 here is a normal
/// answer, and the room tab simply does not appear.
pub fn ha_config(&self) -> Result<Option<Config>, Error> {
match self.get_json::<Config>("/ha") {
Ok(config) => Ok(Some(config)),
Err(error) if error.contains("404") => Ok(None),
Err(error) => Err(error),
}
}
pub fn ha_state(&self, entity_id: &str) -> Result<EntityState, Error> {
self.get_json(&format!("/ha/states/{entity_id}"))
}
pub fn ha_call(
&self,
domain: &str,
service: &str,
body: serde_json::Value,
) -> Result<(), Error> {
self.post_json(&format!("/ha/services/{domain}/{service}"), body)
}
}
/// What the poll thread sends back.
pub struct Snapshot {
pub states: HashMap<String, EntityState>,
/// When the request that produced this went out. The UI drops any entity whose
/// optimistic patch is newer - see the note in `Room::apply`.
pub started_at: Instant,
}
pub enum Job {
/// Poll these entities once, now.
Poll(Vec<String>),
Call {
domain: String,
service: String,
body: serde_json::Value,
},
}
/// One worker for both polling and commands, so a command can never overtake the poll
/// that was meant to confirm it.
pub fn spawn(
client: Client,
jobs: std::sync::mpsc::Receiver<Job>,
deliver: impl Fn(Snapshot) + Send + 'static,
) -> std::thread::JoinHandle<()> {
std::thread::Builder::new()
.name("ha".into())
.spawn(move || {
for job in jobs {
match job {
Job::Call {
domain,
service,
body,
} => {
if let Err(error) = client.ha_call(&domain, &service, body) {
// The next poll corrects whatever the optimistic update
// guessed, so a failed call is worth a line and nothing more.
eprintln!("{domain}.{service}: {error}");
}
}
Job::Poll(entity_ids) => {
let started_at = Instant::now();
let mut states = HashMap::new();
for entity_id in entity_ids {
if let Ok(state) = client.ha_state(&entity_id) {
states.insert(entity_id, state);
}
}
deliver(Snapshot { states, started_at });
}
}
}
})
.expect("spawning the home assistant worker")
}
#[cfg(test)]
mod tests {
use super::*;
fn state(json: serde_json::Value) -> EntityState {
serde_json::from_value(json).unwrap()
}
#[test]
fn closed_percent_inverts_home_assistants_convention() {
assert_eq!(closed_percent(100.0), 0.0); // fully open
assert_eq!(closed_percent(0.0), 100.0); // fully closed
assert_eq!(position_from_closed(85.0), 15.0);
}
#[test]
fn closed_label_names_the_presets_and_falls_back_to_a_number() {
assert_eq!(closed_label(0.0), "Offen");
assert_eq!(closed_label(100.0), "Ganz zu");
assert_eq!(closed_label(85.0), "Fast zu");
assert_eq!(closed_label(50.0), "Halb zu");
assert_eq!(closed_label(20.0), "20% zu");
}
#[test]
fn a_colour_light_is_told_apart_by_its_modes() {
assert!(state(serde_json::json!({
"state": "on",
"attributes": { "supported_color_modes": ["hs", "color_temp"] }
}))
.is_color());
assert!(!state(serde_json::json!({
"state": "on",
"attributes": { "supported_color_modes": ["brightness"] }
}))
.is_color());
assert!(!state(serde_json::json!({ "state": "on", "attributes": {} })).is_color());
}
#[test]
fn rgb_falls_back_to_a_warm_white_rather_than_black() {
assert_eq!(
state(serde_json::json!({"state":"on","attributes":{}})).rgb(),
[255, 214, 140]
);
assert_eq!(
state(serde_json::json!({
"state": "on", "attributes": { "rgb_color": [10, 20, 30] }
}))
.rgb(),
[10, 20, 30]
);
}
#[test]
fn a_missing_attribute_reads_as_zero_rather_than_failing() {
let s = state(serde_json::json!({ "state": "off", "attributes": {} }));
assert_eq!(s.brightness(), 0.0);
assert_eq!(s.cover_position(), None);
assert_eq!(s.supported_features(), 0);
assert!(!s.is_on());
}
#[test]
fn a_device_knows_its_domain_and_its_label() {
let device: Device =
serde_json::from_value(serde_json::json!({ "entity_id": "light.kitchen" })).unwrap();
assert_eq!(device.domain(), "light");
assert_eq!(device.label(), "light.kitchen");
}
}

View File

@@ -19,8 +19,39 @@ pub const SEEK_STEP: f64 = 15.0;
/// How far Ctrl+d/Ctrl+u jump - vim's half-page scroll, applied to rows of cards. /// How far Ctrl+d/Ctrl+u jump - vim's half-page scroll, applied to rows of cards.
pub const PAGE_ROWS: i32 = 3; pub const PAGE_ROWS: i32 = 3;
/// Which top-level page is showing. Orthogonal to everything below it, so switching
/// pages and coming back leaves the music side exactly as it was.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Page {
Music,
Room,
Typing,
}
impl Page {
pub fn icon(self) -> &'static str {
match self {
// A plain Unicode symbol rather than the emoji: that glyph carries its own
// muted colour on some platforms and ignores `color`, which makes it
// illegible on the rail's own background.
Self::Music => "♪",
Self::Room => "💡",
Self::Typing => "⌨️",
}
}
pub fn label(self) -> &'static str {
match self {
Self::Music => "Musik",
Self::Room => "Mein Zimmer",
Self::Typing => "Tippen",
}
}
}
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub struct UiState { pub struct UiState {
pub page: Page,
pub search: String, pub search: String,
pub tracks_mode: bool, pub tracks_mode: bool,
/// `None` is the bare root screen (three shelves); otherwise which one is open. /// `None` is the bare root screen (three shelves); otherwise which one is open.
@@ -42,6 +73,7 @@ pub struct UiState {
impl Default for UiState { impl Default for UiState {
fn default() -> Self { fn default() -> Self {
Self { Self {
page: Page::Music,
search: String::new(), search: String::new(),
tracks_mode: false, tracks_mode: false,
group: None, group: None,
@@ -89,7 +121,8 @@ impl UiState {
/// Whether anything is left to back out of - what the corner button's presence is. /// Whether anything is left to back out of - what the corner button's presence is.
pub fn can_go_back(&self) -> bool { pub fn can_go_back(&self) -> bool {
self.play_view self.page != Page::Music
|| self.play_view
|| self.open_album.is_some() || self.open_album.is_some()
|| !self.search.is_empty() || !self.search.is_empty()
|| self.tracks_mode || self.tracks_mode
@@ -270,6 +303,20 @@ pub fn handle_key(
return vec![Action::Toggle]; return vec![Action::Toggle];
} }
// Ctrl+Tab cycles the pages. This is an addition to the web front-end, where the
// tab rail is reachable only with a pointer - which sits badly with a device whose
// stated design is that every screen can be reached from the keyboard. Plain Tab
// is already taken by the group cycle, and Ctrl+Tab is the idiom for the outer
// one everywhere else.
if ctrl && key == "tab" {
state.page = match state.page {
Page::Music => Page::Room,
Page::Room => Page::Typing,
Page::Typing => Page::Music,
};
return vec![];
}
// Ctrl+hjkl moves the highlight in whichever list is on screen, the same job the // Ctrl+hjkl moves the highlight in whichever list is on screen, the same job the
// arrows do but without leaving the home row. Ctrl+d/u are vim's half-page jump. // arrows do but without leaving the home row. Ctrl+d/u are vim's half-page jump.
if ctrl { if ctrl {
@@ -389,8 +436,12 @@ pub fn handle_key(
vec![] vec![]
} }
"escape" => { "escape" => {
// Peel one layer at a time rather than dumping you back at the top. // Peel one layer at a time rather than dumping you back at the top. The
if state.open_album.is_some() { // page is the outermost layer: ESC on the room or typing page comes back
// to the player, and only then starts unwinding the browse hierarchy.
if state.page != Page::Music {
state.page = Page::Music;
} else if state.open_album.is_some() {
state.open_album = None; state.open_album = None;
} else if state.play_view { } else if state.play_view {
state.play_view = false; state.play_view = false;
@@ -621,6 +672,37 @@ mod tests {
assert!(!state.can_go_back()); assert!(!state.can_go_back());
} }
#[test]
fn ctrl_tab_cycles_the_pages_and_plain_tab_still_cycles_groups() {
let mut state = UiState::default();
for expected in [Page::Room, Page::Typing, Page::Music] {
handle_key("tab", true, false, &mut state, &empty(), &[0, 0, 0], 0);
assert_eq!(state.page, expected);
}
// And the outer cycle has not eaten the inner one.
press("tab", &mut state);
assert_eq!(state.group, Some(Group::Music));
assert_eq!(state.page, Page::Music);
}
#[test]
fn escape_comes_back_from_another_page_before_it_touches_the_browse_state() {
let mut state = UiState {
page: Page::Room,
group: Some(Group::Music),
..Default::default()
};
press("escape", &mut state);
assert_eq!(state.page, Page::Music);
assert_eq!(
state.group,
Some(Group::Music),
"the player was left as it was"
);
press("escape", &mut state);
assert_eq!(state.group, None);
}
#[test] #[test]
fn tab_cycles_the_three_groups_and_wraps() { fn tab_cycles_the_three_groups_and_wraps() {
let mut state = UiState::default(); let mut state = UiState::default();

View File

@@ -13,23 +13,25 @@
mod api; mod api;
mod covers; mod covers;
mod format; mod format;
mod ha;
mod keymap; mod keymap;
mod library; mod library;
mod oklch; mod oklch;
mod ws; mod ws;
use std::cell::RefCell; use std::cell::RefCell;
use std::collections::VecDeque; use std::collections::{HashMap, VecDeque};
use std::rc::Rc; use std::rc::Rc;
use std::sync::mpsc::{channel, Sender}; use std::sync::mpsc::{channel, Sender};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::time::Instant;
use slint::{ModelRc, SharedString, VecModel}; use slint::{ModelRc, SharedString, VecModel};
use api::{Album, Client, PlayerState}; use api::{Album, Client, PlayerState};
use covers::Tier; use covers::Tier;
use keymap::{Action, UiState}; use keymap::{Action, Page, UiState};
use library::{Group, Library, Query, Results, GROUPS}; use library::{Group, Library, Query, Results, GROUPS};
slint::include_modules!(); slint::include_modules!();
@@ -63,6 +65,9 @@ enum FromWorker {
Library(Result<Vec<Album>, String>), Library(Result<Vec<Album>, String>),
Cover(covers::Ready), Cover(covers::Ready),
Ws(WsEvent), Ws(WsEvent),
/// `None` means Home Assistant is not configured, which hides the room tab.
HaConfig(Option<ha::Config>),
HaStates(ha::Snapshot),
} }
enum WsEvent { enum WsEvent {
@@ -90,6 +95,20 @@ struct App {
/// False for exactly one render: the frame a track changes on, and the frame a seek /// False for exactly one render: the frame a track changes on, and the frame a seek
/// lands on. /// lands on.
smooth: bool, smooth: bool,
// ------------------------------------------------------------ Mein Zimmer --
ha: Sender<ha::Job>,
ha_config: Option<ha::Config>,
ha_states: HashMap<String, ha::EntityState>,
/// When each entity last got an optimistic patch. A poll that was already in
/// flight when the patch landed resolves with pre-click data; without this, that
/// stale answer puts a lamp back to "off" until the next poll catches up, which
/// is what made the web page feel laggy despite having optimistic updates.
ha_patched_at: HashMap<String, Instant>,
ha_loaded: bool,
/// Home Assistant scenes have no "currently active" state of their own, so this is
/// purely local: set by activating one, cleared by any manual device change.
active_scene: Option<String>,
} }
impl App { impl App {
@@ -122,6 +141,41 @@ impl App {
] ]
} }
fn ha_send(&self, job: ha::Job) {
let _ = self.ha.send(job);
}
/// Patch one entity locally so a tap feels instant, and remember when, so the poll
/// already in flight cannot undo it.
fn ha_optimistic(&mut self, entity_id: &str, patch: impl FnOnce(&mut ha::EntityState)) {
self.ha_patched_at
.insert(entity_id.to_string(), Instant::now());
if let Some(state) = self.ha_states.get_mut(entity_id) {
patch(state);
}
self.active_scene = None;
}
fn ha_call(&self, domain: &str, service: &str, body: serde_json::Value) {
self.ha_send(ha::Job::Call {
domain: domain.into(),
service: service.into(),
body,
});
}
fn ha_entity_ids(&self) -> Vec<String> {
let Some(config) = &self.ha_config else {
return Vec::new();
};
config
.devices
.iter()
.chain(config.scenes.iter())
.map(|d| d.entity_id.clone())
.collect()
}
fn apply(&mut self, actions: Vec<Action>) { fn apply(&mut self, actions: Vec<Action>) {
for action in actions { for action in actions {
match action { match action {
@@ -409,6 +463,35 @@ fn render(app: &mut App, window: &AppWindow) {
window.set_now(now); window.set_now(now);
window.set_has_bar(has_album && !app.ui.play_view); window.set_has_bar(has_album && !app.ui.play_view);
// The room tab only exists when Home Assistant is configured - the backend
// answering 404 for /api/ha is how that is known.
let tabs: Vec<Tab> = [Page::Music, Page::Room, Page::Typing]
.into_iter()
.filter(|page| *page != Page::Room || app.ha_config.is_some())
.map(|page| Tab {
id: match page {
Page::Music => "music",
Page::Room => "room",
Page::Typing => "typing",
}
.into(),
icon: page.icon().into(),
label: page.label().into(),
})
.collect();
window.set_tabs(ModelRc::new(VecModel::from(tabs)));
window.set_page(
match app.ui.page {
Page::Music => "music",
Page::Room => "room",
Page::Typing => "typing",
}
.into(),
);
render_room(app, window);
let root_screen = app.ui.is_root_shelf(); let root_screen = app.ui.is_root_shelf();
window.set_root_screen(root_screen); window.set_root_screen(root_screen);
window.set_shelf_row(app.ui.shelf_row as i32); window.set_shelf_row(app.ui.shelf_row as i32);
@@ -573,6 +656,94 @@ fn render_results(app: &mut App, window: &AppWindow, cols: usize) {
window.set_grid(ModelRc::new(VecModel::from(grid))); window.set_grid(ModelRc::new(VecModel::from(grid)));
} }
/// Build the room page's models. One `RoomCard` list in Home Assistant's own config
/// order, because that order is the only layout information the config carries.
fn render_room(app: &mut App, window: &AppWindow) {
let Some(config) = app.ha_config.clone() else {
return;
};
window.set_room_loading(!app.ha_loaded);
let mut cards = Vec::with_capacity(config.devices.len());
for device in &config.devices {
let state = app.ha_states.get(&device.entity_id);
let mut card = RoomCard {
entity_id: device.entity_id.as_str().into(),
name: device.label().into(),
..Default::default()
};
match device.domain() {
"light" => {
let on = state.is_some_and(|s| s.is_on());
let rgb = state.map_or([255, 214, 140], |s| s.rgb());
card.kind = "light".into();
card.on = on;
card.is_color = state.is_some_and(|s| s.is_color());
// 0..5, matching the five buttons - HA reports 0..255.
card.level = state
.map_or(0.0, |s| (s.brightness() / 255.0 * 5.0).round())
.clamp(0.0, 5.0) as i32;
card.tint = slint::Color::from_rgb_u8(rgb[0], rgb[1], rgb[2]);
card.swatch = ha::SWATCHES
.iter()
.position(|(_, swatch)| *swatch == rgb)
.map_or(-1, |i| i as i32);
}
"cover" => {
let position = state.and_then(|s| s.cover_position());
let features = state.map_or(0, |s| s.supported_features());
let closed = ha::closed_percent(position.unwrap_or(0.0));
let moving = match state.map(|s| s.state.as_str()) {
Some("opening") => "up",
Some("closing") => "down",
_ => "",
};
card.kind = "shutter".into();
card.closed_percent = closed as f32;
card.moving = moving.into();
card.status = match moving {
"down" => "Fährt runter …".to_string(),
"up" => "Fährt hoch …".to_string(),
_ => ha::closed_label(closed),
}
.into();
card.supports_position =
position.is_some() || features & ha::SUPPORT_SET_POSITION != 0;
let label = ha::closed_label(closed);
card.preset = ha::SHUTTER_PRESETS
.iter()
.position(|(name, _)| *name == label)
.map_or(-1, |i| i as i32);
}
// Not a domain this page knows how to draw - skip rather than guess.
_ => continue,
}
cards.push(card);
}
let scenes: Vec<SceneTile> = config
.scenes
.iter()
.map(|scene| SceneTile {
entity_id: scene.entity_id.as_str().into(),
name: scene.label().into(),
active: app.active_scene.as_deref() == Some(scene.entity_id.as_str()),
})
.collect();
// Three across at 1080p, which is what the web grid's `minmax(300px, 1fr)` inside
// a 1180px column comes to on the same screen.
let columns = ((window.get_grid_width() / 400.0).floor() as usize).clamp(1, 3);
let rows: Vec<RoomRow> = cards
.chunks(columns)
.map(|chunk| RoomRow {
cards: ModelRc::new(VecModel::from(chunk.to_vec())),
})
.collect();
window.set_room_rows(ModelRc::new(VecModel::from(rows)));
window.set_room_scenes(ModelRc::new(VecModel::from(scenes)));
}
fn render_sheet(app: &mut App, window: &AppWindow) { fn render_sheet(app: &mut App, window: &AppWindow) {
let Some(album) = app let Some(album) = app
.ui .ui
@@ -631,10 +802,16 @@ fn render_sheet(app: &mut App, window: &AppWindow) {
// ----------------------------------------------------------------------- wiring -- // ----------------------------------------------------------------------- wiring --
fn main() -> Result<(), Box<dyn std::error::Error>> { fn main() -> Result<(), Box<dyn std::error::Error>> {
let base = std::env::args() let arg = |name: &str| -> Option<String> { std::env::args().skip_while(|a| a != name).nth(1) };
.skip_while(|a| a != "--server") let base = arg("--server").unwrap_or_else(|| api::DEFAULT_BASE_URL.to_string());
.nth(1) // Which page to open on. A kiosk may well want to come up on the room panel rather
.unwrap_or_else(|| api::DEFAULT_BASE_URL.to_string()); // than the player, and it is the only way to reach a page without a keyboard or a
// pointer.
let page = match arg("--page").as_deref() {
Some("room") => Page::Room,
Some("typing") => Page::Typing,
_ => Page::Music,
};
println!("musicmouse-slint → {base}"); println!("musicmouse-slint → {base}");
// Nunito is the face the design is drawn in, vendored as a TTF in `fonts/` because // Nunito is the face the design is drawn in, vendored as a TTF in `fonts/` because
@@ -668,6 +845,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let (to_ui, from_workers) = channel::<FromWorker>(); let (to_ui, from_workers) = channel::<FromWorker>();
let (cover_jobs, cover_rx) = channel::<covers::Job>(); let (cover_jobs, cover_rx) = channel::<covers::Job>();
let (commands, command_rx) = channel::<Command>(); let (commands, command_rx) = channel::<Command>();
let (ha_jobs, ha_rx) = channel::<ha::Job>();
// Covers: fetch, downscale, cache to disk. Never on the UI thread. // Covers: fetch, downscale, cache to disk. Never on the UI thread.
{ {
@@ -711,6 +889,24 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
})?; })?;
} }
// Home Assistant, behind the backend's proxy. One worker for both the poll and
// the commands, so a command can never overtake the poll meant to confirm it.
{
let states_tx = to_ui.clone();
ha::spawn(client.clone(), ha_rx, move |snapshot| {
let _ = states_tx.send(FromWorker::HaStates(snapshot));
});
let client = client.clone();
let to_ui = to_ui.clone();
std::thread::Builder::new()
.name("ha-config".into())
.spawn(move || {
// A 404 here means Home Assistant is not configured, which is a normal
// answer: the tab simply does not appear.
let _ = to_ui.send(FromWorker::HaConfig(client.ha_config().ok().flatten()));
})?;
}
// State. // State.
{ {
let to_ui = to_ui.clone(); let to_ui = to_ui.clone();
@@ -738,10 +934,19 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let app = Rc::new(RefCell::new(App { let app = Rc::new(RefCell::new(App {
commands, commands,
ui: UiState::default(), ui: UiState {
page,
..UiState::default()
},
player: PlayerState::default(), player: PlayerState::default(),
library: Library::default(), library: Library::default(),
covers: covers::Store::new(cover_jobs), covers: covers::Store::new(cover_jobs),
ha: ha_jobs,
ha_config: None,
ha_states: HashMap::new(),
ha_patched_at: HashMap::new(),
ha_loaded: false,
active_scene: None,
online: false, online: false,
loaded: false, loaded: false,
error: None, error: None,
@@ -749,6 +954,45 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
smooth: true, smooth: true,
})); }));
// The room page's poll. Only runs while that page is showing: a lamp's state is
// not worth a request every 2.5 s when nobody is looking at it, and this device
// has a music player to get out of the way of.
let poll = slint::Timer::default();
{
let handle = Rc::clone(&app);
let weak = window.as_weak();
poll.start(slint::TimerMode::Repeated, ha::POLL_INTERVAL, move || {
let Some(window) = weak.upgrade() else { return };
let entity_ids = {
let app = handle.borrow();
if app.ui.page != Page::Room || app.ha_config.is_none() {
return;
}
app.ha_entity_ids()
};
handle.borrow().ha_send(ha::Job::Poll(entity_ids));
let _ = &window;
});
}
// The static parts of the room page - the six colours and the four shutter
// presets - never change, so they are set once rather than rebuilt per render.
window.set_room_swatches(ModelRc::new(VecModel::from(
ha::SWATCHES
.iter()
.map(|(name, rgb)| Swatch {
name: (*name).into(),
tint: slint::Color::from_rgb_u8(rgb[0], rgb[1], rgb[2]),
})
.collect::<Vec<_>>(),
)));
window.set_shutter_presets(ModelRc::new(VecModel::from(
ha::SHUTTER_PRESETS
.iter()
.map(|(label, _)| SharedString::from(*label))
.collect::<Vec<_>>(),
)));
install_callbacks(&app, &window); install_callbacks(&app, &window);
pump_workers(&app, &window, from_workers); pump_workers(&app, &window, from_workers);
@@ -846,6 +1090,23 @@ fn handle_worker_message(app: &mut App, message: FromWorker) {
} }
app.smooth = true; app.smooth = true;
} }
FromWorker::HaConfig(config) => {
app.ha_config = config;
if app.ha_config.is_some() {
app.ha_send(ha::Job::Poll(app.ha_entity_ids()));
}
}
FromWorker::HaStates(snapshot) => {
for (entity_id, state) in snapshot.states {
// Drop any entity whose optimistic patch is newer than the request
// that produced this - see `ha_patched_at`.
let patched = app.ha_patched_at.get(&entity_id).copied();
if patched.is_none_or(|at| snapshot.started_at >= at) {
app.ha_states.insert(entity_id, state);
}
}
app.ha_loaded = true;
}
FromWorker::Ws(WsEvent::LibraryChanged) => { FromWorker::Ws(WsEvent::LibraryChanged) => {
// A rescan is the one moment the backend rewrites the art behind an // A rescan is the one moment the backend rewrites the art behind an
// unchanged cover URL, so the thumbnails go with it. // unchanged cover URL, so the thumbnails go with it.
@@ -987,6 +1248,109 @@ fn install_callbacks(app: &Rc<RefCell<App>>, window: &AppWindow) {
a.ui.open_album = None; a.ui.open_album = None;
}); });
bind!(on_select_page, |a, page: SharedString| {
a.ui.page = match page.as_str() {
"room" => Page::Room,
"typing" => Page::Typing,
_ => Page::Music,
};
// Ask for fresh state the moment the page comes up, rather than making the
// first thing you see be up to one poll interval stale.
if a.ui.page == Page::Room && a.ha_config.is_some() {
a.ha_send(ha::Job::Poll(a.ha_entity_ids()));
}
});
bind!(on_toggle_light, |a, id: SharedString| {
let on = a.ha_states.get(id.as_str()).is_some_and(|s| s.is_on());
a.ha_optimistic(id.as_str(), |state| {
state.state = if on { "off".into() } else { "on".into() };
});
a.ha_call(
"light",
if on { "turn_off" } else { "turn_on" },
serde_json::json!({ "entity_id": id.as_str() }),
);
});
bind!(on_set_light_level, |a, id: SharedString, level: i32| {
let percent = level.clamp(1, 5) * 20;
a.ha_optimistic(id.as_str(), |state| {
state.state = "on".into();
state.attributes.insert(
"brightness".into(),
serde_json::json!((percent as f64 / 100.0 * 255.0).round()),
);
});
a.ha_call(
"light",
"turn_on",
serde_json::json!({ "entity_id": id.as_str(), "brightness_pct": percent }),
);
});
bind!(on_set_light_swatch, |a, id: SharedString, index: i32| {
let Some((_, rgb)) = ha::SWATCHES.get(index.max(0) as usize) else {
return;
};
let rgb = *rgb;
a.ha_optimistic(id.as_str(), |state| {
state.state = "on".into();
state.attributes.insert(
"rgb_color".into(),
serde_json::json!([rgb[0], rgb[1], rgb[2]]),
);
});
a.ha_call(
"light",
"turn_on",
serde_json::json!({ "entity_id": id.as_str(), "rgb_color": [rgb[0], rgb[1], rgb[2]] }),
);
});
bind!(on_set_shutter_preset, |a, id: SharedString, index: i32| {
let Some((_, closed)) = ha::SHUTTER_PRESETS.get(index.max(0) as usize) else {
return;
};
let position = ha::position_from_closed(*closed);
a.ha_optimistic(id.as_str(), |state| {
state
.attributes
.insert("current_position".into(), serde_json::json!(position));
});
a.ha_call(
"cover",
"set_cover_position",
serde_json::json!({ "entity_id": id.as_str(), "position": position }),
);
});
bind!(on_shutter, |a, id: SharedString, what: SharedString| {
let service = match what.as_str() {
"open" => "open_cover",
"close" => "close_cover",
_ => "stop_cover",
};
// No optimistic position here: a real cover reports its own movement, and
// guessing where it will end up is exactly what would fight with that.
a.active_scene = None;
a.ha_patched_at.remove(id.as_str());
a.ha_call(
"cover",
service,
serde_json::json!({ "entity_id": id.as_str() }),
);
});
bind!(on_activate_scene, |a, id: SharedString| {
a.active_scene = Some(id.to_string());
a.ha_call(
"scene",
"turn_on",
serde_json::json!({ "entity_id": id.as_str() }),
);
});
bind!(on_play_sheet_track, |a, index: i32| { bind!(on_play_sheet_track, |a, index: i32| {
if let Some(album_id) = a.ui.open_album.clone() { if let Some(album_id) = a.ui.open_album.clone() {
a.apply(vec![Action::Play { a.apply(vec![Action::Play {

View File

@@ -2,9 +2,10 @@ import { Theme } from "theme.slint";
import { RoundButton } from "widgets.slint"; import { RoundButton } from "widgets.slint";
import { ShelfView, ResultsView } from "browse.slint"; import { ShelfView, ResultsView } from "browse.slint";
import { PlayerBar, PlayView, AlbumSheet } from "player.slint"; import { PlayerBar, PlayView, AlbumSheet } from "player.slint";
import { AlbumTile, GridRow, CategoryTile, CategoryRow, Shelf, TrackRow, NowPlaying } from "models.slint"; import { RoomView } from "room.slint";
import { AlbumTile, GridRow, CategoryTile, CategoryRow, Shelf, TrackRow, NowPlaying, RoomCard, RoomRow, SceneTile, Swatch, Tab } from "models.slint";
export { AlbumTile, GridRow, CategoryTile, CategoryRow, Shelf, TrackRow, NowPlaying, Theme } export { AlbumTile, GridRow, CategoryTile, CategoryRow, Shelf, TrackRow, NowPlaying, RoomCard, RoomRow, SceneTile, Swatch, Tab, Theme }
export component AppWindow inherits Window { export component AppWindow inherits Window {
title: "Musik Delfin"; title: "Musik Delfin";
@@ -20,6 +21,17 @@ export component AppWindow inherits Window {
Theme.sea-deep 100%); Theme.sea-deep 100%);
// ------------------------------------------------------------------- inputs -- // ------------------------------------------------------------------- inputs --
// "music" | "room" | "typing" - named rather than an enum so Rust owns the
// ordering and the labels, as it does for every other string on screen.
in property <string> page: "music";
in property <[Tab]> tabs;
in property <[RoomRow]> room-rows;
in property <[SceneTile]> room-scenes;
in property <[Swatch]> room-swatches;
in property <[string]> shutter-presets;
in property <bool> room-loading: true;
in property <bool> loading: true; in property <bool> loading: true;
in property <string> splash-text; in property <string> splash-text;
in property <string> status-label; in property <string> status-label;
@@ -87,6 +99,13 @@ export component AppWindow inherits Window {
callback open-play-view(); callback open-play-view();
callback close-sheet(); callback close-sheet();
callback play-sheet-track(int); callback play-sheet-track(int);
callback select-page(string);
callback toggle-light(string);
callback set-light-level(string, int);
callback set-light-swatch(string, int);
callback set-shutter-preset(string, int);
callback shutter(string, string);
callback activate-scene(string);
forward-focus: keys; forward-focus: keys;
@@ -111,7 +130,7 @@ export component AppWindow inherits Window {
VerticalLayout { VerticalLayout {
// ------------------------------------------------------------ header -- // ------------------------------------------------------------ header --
if !root.play-view: HorizontalLayout { if root.page == "music" && !root.play-view: HorizontalLayout {
alignment: center; alignment: center;
spacing: 16px; spacing: 16px;
padding-top: 18px; padding-top: 18px;
@@ -146,7 +165,7 @@ export component AppWindow inherits Window {
} }
// -------------------------------------------------------------- body -- // -------------------------------------------------------------- body --
if !root.play-view && root.root-screen: ShelfView { if root.page == "music" && !root.play-view && root.root-screen: ShelfView {
shelves: root.shelves; shelves: root.shelves;
shelf-row: root.shelf-row; shelf-row: root.shelf-row;
sel-index: root.sel-index; sel-index: root.sel-index;
@@ -154,7 +173,7 @@ export component AppWindow inherits Window {
enter-category(s, key) => { root.enter-category(s, key); } enter-category(s, key) => { root.enter-category(s, key); }
} }
if !root.play-view && !root.root-screen: ResultsView { if root.page == "music" && !root.play-view && !root.root-screen: ResultsView {
group-label: root.group-label; group-label: root.group-label;
category-heading: root.category-heading; category-heading: root.category-heading;
album-heading: root.album-heading; album-heading: root.album-heading;
@@ -174,7 +193,39 @@ export component AppWindow inherits Window {
play-album(id) => { root.play-album(id); } play-album(id) => { root.play-album(id); }
} }
if root.play-view: PlayView { if root.page == "room": RoomView {
rows: root.room-rows;
scenes: root.room-scenes;
swatches: root.room-swatches;
presets: root.shutter-presets;
loading: root.room-loading;
toggle-light(id) => { root.toggle-light(id); }
set-level(id, n) => { root.set-light-level(id, n); }
set-swatch(id, i) => { root.set-light-swatch(id, i); }
set-preset(id, i) => { root.set-shutter-preset(id, i); }
shutter(id, what) => { root.shutter(id, what); }
activate-scene(id) => { root.activate-scene(id); }
}
if root.page == "typing": VerticalLayout {
alignment: center;
Text {
text: "Tippen";
font-size: 34px;
font-weight: 900;
color: Theme.paper;
horizontal-alignment: center;
}
Text {
text: "Noch nicht portiert.";
font-size: Theme.text-heading;
font-weight: 700;
color: Theme.text-dim;
horizontal-alignment: center;
}
}
if root.page == "music" && root.play-view: PlayView {
now: root.now; now: root.now;
playing: root.playing; playing: root.playing;
progress: root.progress; progress: root.progress;
@@ -196,7 +247,7 @@ export component AppWindow inherits Window {
// rather than inside the component: a plain element takes its *preferred* width // rather than inside the component: a plain element takes its *preferred* width
// from its content, not its parent's width, so a bar that only declared a height // from its content, not its parent's width, so a bar that only declared a height
// left gaps for the grid behind it to show through. // left gaps for the grid behind it to show through.
if !root.play-view && root.has-bar: PlayerBar { if root.page == "music" && !root.play-view && root.has-bar: PlayerBar {
x: 0; x: 0;
y: parent.height - self.height; y: parent.height - self.height;
width: parent.width; width: parent.width;
@@ -223,6 +274,41 @@ export component AppWindow inherits Window {
clicked => { root.go-back(); } clicked => { root.go-back(); }
} }
// The vertical tab rail. Right edge, vertically centred - the one fixed control
// that is in the same place on every page.
tab-rail := Rectangle {
x: parent.width - self.width - 16px;
y: (parent.height - self.height) / 2;
width: 60px;
height: rail.preferred-height;
border-radius: 30px;
background: Theme.paper.with-alpha(0.22);
border-width: 1px;
border-color: Theme.paper.with-alpha(0.3);
rail := VerticalLayout {
padding: 8px;
spacing: 6px;
for tab[i] in root.tabs: Rectangle {
width: 44px;
height: 44px;
border-radius: 22px;
background: root.page == tab.id ? Theme.accent : transparent;
animate background { duration: 150ms; }
Text {
text: tab.icon;
font-size: 20px;
color: root.page == tab.id ? white : Theme.paper;
vertical-alignment: center;
horizontal-alignment: center;
}
TouchArea {
clicked => { root.select-page(tab.id); }
}
}
}
}
if root.show-sheet: AlbumSheet { if root.show-sheet: AlbumSheet {
width: 100%; width: 100%;
height: 100%; height: 100%;

View File

@@ -89,3 +89,63 @@ export struct NowPlaying {
// "Noch 38:20 im Album" - empty for a podcast, where it means nothing. // "Noch 38:20 im Album" - empty for a podcast, where it means nothing.
remaining-label: string, remaining-label: string,
} }
// ------------------------------------------------------------- Mein Zimmer --
// One device card on the room page. Lights and shutters share a struct rather than
// having one each, because the grid renders them in the order Home Assistant's config
// lists them and two models would lose that order. `kind` is what the view branches on.
export struct RoomCard {
entity-id: string,
name: string,
// "light" | "shutter"
kind: string,
on: bool,
// --- light ---
is-color: bool,
// 0..5, matching the five brightness buttons.
level: int,
tint: color,
// Which of the six swatches the light is currently set to, or -1.
swatch: int,
// --- shutter ---
// Percent *closed*: 0 is fully open. Home Assistant reports the opposite; the
// inversion happens once, in Rust.
closed-percent: float,
// "Fährt runter …", or the preset name the position matches.
status: string,
// "" | "up" | "down"
moving: string,
supports-position: bool,
// Which of the four presets the position matches, or -1.
preset: int,
}
// The room grid, pre-chunked into rows for the same reason the album grid is: the
// view lays rows out, and how many fit is a function of the window width, which Rust
// is the one that knows.
export struct RoomRow {
cards: [RoomCard],
}
export struct SceneTile {
entity-id: string,
name: string,
active: bool,
}
// One of the six colours a colour-capable light can be set to.
export struct Swatch {
name: string,
tint: color,
}
// One entry in the vertical tab rail. Built in Rust like every other string on
// screen, so the page order, the icons and the German labels live in one place.
export struct Tab {
id: string,
icon: string,
label: string,
}

View File

@@ -0,0 +1,460 @@
// "Mein Zimmer" - the Home Assistant room panel.
//
// Its own violet hue rather than the player's teal, so the two pages read as siblings.
// Lights and shutters are drawn from one `RoomCard` model in Home Assistant's own
// config order; `kind` is what each card branches on.
import { Theme } from "theme.slint";
import { RoomCard, RoomRow, SceneTile, Swatch } from "models.slint";
component ToggleSwitch inherits Rectangle {
in property <bool> on;
callback clicked();
width: 74px;
height: 42px;
border-radius: 21px;
background: root.on ? Theme.room-accent : Theme.room-toggle-off;
animate background { duration: 140ms; }
Rectangle {
x: root.on ? parent.width - self.width - 4px : 4px;
y: 4px;
width: 34px;
height: 34px;
border-radius: 17px;
background: white;
drop-shadow-color: #0f051d66;
drop-shadow-blur: 6px;
drop-shadow-offset-y: 2px;
animate x { duration: 140ms; easing: ease-out; }
}
TouchArea {
clicked => { root.clicked(); }
}
}
// A flat labelled button - the shutter presets and the up/stop/down row.
component RoomButton inherits Rectangle {
in property <string> label;
in property <bool> active;
in property <brush> active-fill: Theme.room-accent;
in property <length> text-size: 14px;
callback clicked();
border-radius: 14px;
background: root.active ? root.active-fill : (touch.pressed ? Theme.room-off-bg : Theme.room-btn);
animate background { duration: 120ms; }
Text {
text: root.label;
font-size: root.text-size;
font-weight: 800;
color: root.active ? white : Theme.room-btn-fg;
vertical-alignment: center;
horizontal-alignment: center;
}
touch := TouchArea {
clicked => { root.clicked(); }
}
}
component RoomCardFrame inherits Rectangle {
in property <bool> glow;
in property <color> glow-tint;
background: Theme.room-card.with-alpha(0.55);
border-radius: 22px;
// Standing in for the web card's `0 0 0 3px color-mix(...)` ring: a lit lamp's own
// colour around its card is how the page reads at a glance from across the room.
border-width: root.glow ? 3px : 0px;
border-color: root.glow-tint;
drop-shadow-color: #0f051d59;
drop-shadow-blur: 24px;
drop-shadow-offset-y: 8px;
}
component LightCard inherits RoomCardFrame {
in property <RoomCard> card;
in property <[Swatch]> swatches;
callback toggle();
callback set-level(int);
callback set-swatch(int);
glow: root.card.on;
glow-tint: root.card.tint;
VerticalLayout {
padding-left: 20px;
padding-right: 20px;
padding-top: 18px;
padding-bottom: 20px;
spacing: 14px;
HorizontalLayout {
spacing: 14px;
Rectangle {
width: 56px;
height: 56px;
border-radius: 16px;
background: root.card.on ? root.card.tint : Theme.room-off-bg;
drop-shadow-color: root.card.on ? root.card.tint : transparent;
drop-shadow-blur: 22px;
// A pendant for a colour lamp, a ceiling fitting for a plain one -
// drawn rather than iconographic, because there is no icon font here.
Rectangle {
width: 34px;
height: 34px;
x: 11px;
y: 11px;
if root.card.is-color: Path {
width: 100%;
height: 100%;
viewbox-width: 48;
viewbox-height: 48;
stroke: root.card.on ? Theme.room-btn-fg : Theme.room-off-fg;
stroke-width: 3px;
commands: "M 24 7 A 17 17 0 1 1 23.9 7 Z";
}
if !root.card.is-color: Path {
width: 100%;
height: 100%;
viewbox-width: 48;
viewbox-height: 48;
stroke: root.card.on ? Theme.room-btn-fg : Theme.room-off-fg;
stroke-width: 3px;
commands: "M 9 30 L 24 11 L 39 30 Z M 24 4 L 24 11";
}
}
}
VerticalLayout {
alignment: center;
horizontal-stretch: 1;
Text {
text: root.card.name;
font-size: 19px;
font-weight: 900;
color: Theme.room-ink;
overflow: elide;
}
}
VerticalLayout {
alignment: center;
ToggleSwitch {
on: root.card.on;
clicked => { root.toggle(); }
}
}
}
// Five steps rather than a slider: a child aiming at a bar hits a level.
HorizontalLayout {
spacing: 6px;
height: 46px;
opacity: root.card.on ? 1.0 : 0.55;
for step[i] in [1, 2, 3, 4, 5]: Rectangle {
border-radius: 12px;
background: root.card.on && root.card.level >= step
? root.card.tint
: Theme.room-off-bg;
animate background { duration: 120ms; }
TouchArea {
clicked => { root.set-level(step); }
}
}
}
if root.card.is-color: HorizontalLayout {
spacing: 10px;
height: 46px;
alignment: start;
for swatch[i] in root.swatches: Rectangle {
width: 46px;
height: 46px;
border-radius: 23px;
background: swatch.tint;
border-width: 4px;
border-color: root.card.on && root.card.swatch == i
? Theme.room-btn-fg
: Theme.room-card.with-alpha(0.8);
drop-shadow-color: #0f051d59;
drop-shadow-blur: 8px;
drop-shadow-offset-y: 3px;
TouchArea {
clicked => { root.set-swatch(i); }
}
}
}
}
}
component ShutterCard inherits RoomCardFrame {
in property <RoomCard> card;
in property <[string]> presets;
callback set-preset(int);
callback open();
callback stop();
callback close();
VerticalLayout {
padding-left: 20px;
padding-right: 20px;
padding-top: 18px;
padding-bottom: 20px;
spacing: 16px;
HorizontalLayout {
spacing: 14px;
alignment: start;
Rectangle {
width: 56px;
height: 56px;
border-radius: 16px;
background: Theme.room-icon-bg;
Path {
x: 11px;
y: 11px;
width: 34px;
height: 34px;
viewbox-width: 48;
viewbox-height: 48;
stroke: Theme.room-icon-fg;
stroke-width: 3px;
commands: "M 7 7 L 41 7 L 41 41 L 7 41 Z M 7 16 L 41 16 M 7 24 L 41 24 M 7 32 L 41 32";
}
}
VerticalLayout {
alignment: center;
Text {
text: root.card.name;
font-size: 19px;
font-weight: 900;
color: Theme.room-ink;
overflow: elide;
}
Text {
text: root.card.status;
font-size: 13px;
font-weight: 700;
color: Theme.room-sub.with-alpha(0.8);
}
}
}
HorizontalLayout {
spacing: 18px;
// The window itself: sky behind, slats drawn down from the top to however
// closed the shutter is. Real covers report their own position, so this
// only ever reflects Home Assistant - no client-side movement animation.
Rectangle {
width: 104px;
height: 140px;
border-radius: 12px;
border-width: 3px;
border-color: Theme.room-icon-fg;
clip: true;
background: @linear-gradient(180deg, Theme.room-window-a 0%, Theme.room-window-b 100%);
Rectangle {
x: 0;
y: 0;
width: 100%;
height: parent.height * clamp(root.card.closed-percent / 100, 0.0, 1.0);
clip: true;
animate height { duration: 200ms; easing: linear; }
// The slats, as a fixed ladder clipped by the parent's height.
for slat[i] in [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]: Rectangle {
y: i * 12px;
width: 100%;
height: 12px;
background: Theme.room-slat-a;
Rectangle {
y: 9px;
width: 100%;
height: 3px;
background: Theme.room-slat-b;
}
}
}
}
VerticalLayout {
spacing: 10px;
horizontal-stretch: 1;
if root.card.supports-position: VerticalLayout {
spacing: 8px;
for row[r] in [0, 1]: HorizontalLayout {
spacing: 8px;
height: 48px;
for col[c] in [0, 1]: RoomButton {
label: root.presets[r * 2 + c];
active: root.card.preset == r * 2 + c;
clicked => { root.set-preset(r * 2 + c); }
}
}
}
HorizontalLayout {
spacing: 8px;
height: 52px;
RoomButton {
label: "▲";
text-size: 20px;
active: root.card.moving == "up";
clicked => { root.open(); }
}
RoomButton {
label: "■";
text-size: 18px;
active: true;
active-fill: Theme.room-stop;
clicked => { root.stop(); }
}
RoomButton {
label: "▼";
text-size: 20px;
active: root.card.moving == "down";
clicked => { root.close(); }
}
}
}
}
}
}
export component RoomView inherits Rectangle {
in property <[RoomRow]> rows;
in property <[SceneTile]> scenes;
in property <[Swatch]> swatches;
in property <[string]> presets;
in property <bool> loading;
callback toggle-light(string);
callback set-level(string, int);
callback set-swatch(string, int);
callback set-preset(string, int);
callback shutter(string, string); // entity, "open" | "stop" | "close"
callback activate-scene(string);
background: @linear-gradient(180deg,
Theme.room-top 0%,
Theme.room-mid 45%,
Theme.room-deep 100%);
VerticalLayout {
padding-top: 18px;
spacing: 6px;
Text {
text: "Mein Zimmer";
font-size: 34px;
font-weight: 900;
color: Theme.room-paper;
horizontal-alignment: center;
}
if root.loading: VerticalLayout {
alignment: center;
Text {
text: "Einen Moment …";
font-size: 22px;
font-weight: 800;
color: Theme.room-paper;
horizontal-alignment: center;
}
}
if !root.loading: Flickable {
content-height: body.preferred-height;
content-width: self.width;
body := VerticalLayout {
padding-left: 32px;
padding-right: Theme.rail-clearance;
padding-top: 14px;
padding-bottom: 64px;
spacing: 30px;
alignment: start;
if root.scenes.length > 0: VerticalLayout {
spacing: 14px;
Text {
text: "Szenen";
font-size: 19px;
font-weight: 900;
color: Theme.room-paper;
horizontal-alignment: center;
}
HorizontalLayout {
alignment: center;
spacing: 12px;
for scene in root.scenes: Rectangle {
height: 52px;
width: pill.preferred-width;
border-radius: 26px;
background: scene.active
? Theme.room-paper
: Theme.room-paper.with-alpha(0.18);
animate background { duration: 140ms; }
pill := HorizontalLayout {
padding-left: 20px;
padding-right: 20px;
spacing: 8px;
VerticalLayout {
alignment: center;
Text {
text: "✨";
font-size: 22px;
}
}
VerticalLayout {
alignment: center;
Text {
text: scene.name;
font-size: 15px;
font-weight: 800;
color: scene.active ? Theme.room-btn-fg : Theme.room-paper;
}
}
}
TouchArea {
clicked => { root.activate-scene(scene.entity-id); }
}
}
}
}
// Both kinds share one cell width, so the grid stays a grid whatever
// mix of devices Home Assistant reports.
for row in root.rows: HorizontalLayout {
alignment: center;
spacing: 20px;
for card in row.cards: Rectangle {
width: Theme.room-card-width;
height: card.kind == "shutter" ? 268px : 246px;
if card.kind == "light": LightCard {
width: 100%;
card: card;
swatches: root.swatches;
toggle => { root.toggle-light(card.entity-id); }
set-level(n) => { root.set-level(card.entity-id, n); }
set-swatch(i) => { root.set-swatch(card.entity-id, i); }
}
if card.kind == "shutter": ShutterCard {
width: 100%;
card: card;
presets: root.presets;
set-preset(i) => { root.set-preset(card.entity-id, i); }
open => { root.shutter(card.entity-id, "open"); }
stop => { root.shutter(card.entity-id, "stop"); }
close => { root.shutter(card.entity-id, "close"); }
}
}
}
}
}
}
}

View File

@@ -48,6 +48,32 @@ export global Theme {
out property <color> row-idle: #f7fcfd1a; out property <color> row-idle: #f7fcfd1a;
out property <color> row-active: #f7fcfd38; out property <color> row-active: #f7fcfd38;
// --------------------------------------------------- Mein Zimmer (violet) --
// Its own hue (~298-310) rather than the player's teal (~210), so the two pages
// read as siblings rather than one bleeding into the other. Same oklch-converted-
// once treatment as above.
out property <color> room-ink: #181322; // oklch(20% 0.03 300)
out property <color> room-paper: #f6f4fb; // oklch(97% 0.01 302)
out property <color> room-accent: #986dd0; // oklch(62% 0.15 302)
out property <color> room-card: #f3f0f9; // oklch(96% 0.012 300)
out property <color> room-top: #885cb5; // oklch(56% 0.14 305)
out property <color> room-mid: #4d2f77; // oklch(38% 0.12 300)
out property <color> room-deep: #1e0c37; // oklch(21% 0.08 298)
out property <color> room-icon-bg: #dad3e9; // oklch(88% 0.03 300)
out property <color> room-icon-fg: #3e3451; // oklch(35% 0.05 300)
out property <color> room-off-bg: #d9d5e3; // oklch(88% 0.02 300)
out property <color> room-off-fg: #736f7c; // oklch(55% 0.02 300)
out property <color> room-btn: #dcd8e6; // oklch(89% 0.02 300)
out property <color> room-btn-fg: #2c243a; // oklch(28% 0.04 300)
out property <color> room-stop: #e85854; // oklch(65% 0.18 25)
out property <color> room-toggle-off: #cbc9d0;
out property <color> room-sub: #585264; // oklch(45% 0.03 300)
out property <color> room-slat-a: #a19baf; // oklch(70% 0.03 300)
out property <color> room-slat-b: #7d778a; // oklch(58% 0.03 300)
out property <color> room-window-a: #87d1e8; // oklch(82% 0.08 220)
out property <color> room-window-b: #50afb4; // oklch(70% 0.09 200)
out property <length> room-card-width: 380px;
// ------------------------------------------------------------------- metrics -- // ------------------------------------------------------------------- metrics --
out property <length> radius-panel: 22px; out property <length> radius-panel: 22px;
out property <length> radius-card: 16px; out property <length> radius-card: 16px;

21
todo.md Normal file
View File

@@ -0,0 +1,21 @@
Podcast issues:
- cover art for podcasts:
- use the coverart embedded in the actual mp3
- use generic image or cover.jpg inside the podcast folder
- download a cover.jpg for the podcasts already in the library right now
- podcasts grouping right now is not optimal. right now the hierarchy is for example "Sendung mit der Maus" => "Sendung mit der Maus" => "actual tracks". I'd rather like: "Sendung mit der Maus" => all tracks shown as separate cards so that their cover art is shown. The normal track list view (textbased) should be replaced by this.
- when playing a podcast, the timer "Noch ... im Hörbuch" doesn't make sense. Hide it in case of podcasts. also stop playing after one podcast episode. For albums and real audiobooks stop after the audiobook or album played fully.
think about a datamodel for podcasts that is elegant, fits with what we have and then check back with me before implementing.
UI issues:
- setting the volume reacts very slowly right now. investigate. fix only if it is possible without massive workarounds.
plan a fix and check with me first.
- the seek bar is behaving weird: when I click on a position it moves there, but then it moves sligtly backwards (probably after a backend state update). Find the cause and check the fix with me before implementing.
when skipping to the next song it also moves slowly to the beginning
- in the overview create category sections (3 rows) for music, audiobooks, podcasts.
the rows should scroll horizontally. for each row there is a search button that searches only this group

View File

@@ -7,9 +7,13 @@
*/ */
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Suspense, lazy, useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from "react";
import { api } from "./api/client"; import { api } from "./api/client";
import { PERF_ENABLED, PERF } from "./lib/perfSettings";
import { PerfPanel } from "./components/PerfPanel";
import { useCoverWarmup } from "./hooks/useCoverWarmup";
import { useEvent } from "./hooks/useEvent";
import type { Album, HaConfig, RemoteMapping, RemoteSlotInput } from "./api/types"; import type { Album, HaConfig, RemoteMapping, RemoteSlotInput } from "./api/types";
import { AlbumModal } from "./components/AlbumModal"; import { AlbumModal } from "./components/AlbumModal";
import { Ambience, type AmbienceDebugSnapshot } from "./components/Ambience"; import { Ambience, type AmbienceDebugSnapshot } from "./components/Ambience";
@@ -21,22 +25,27 @@ import { ParentPanel } from "./components/ParentPanel";
import { PlayerBar } from "./components/PlayerBar"; import { PlayerBar } from "./components/PlayerBar";
import { PlayView } from "./components/PlayView"; import { PlayView } from "./components/PlayView";
import { RemoteAssignPopup } from "./components/RemoteAssignPopup"; import { RemoteAssignPopup } from "./components/RemoteAssignPopup";
import { RoomView } from "./components/RoomView";
import { TabRail } from "./components/TabRail"; import { TabRail } from "./components/TabRail";
import { TippenApp } from "./components/TippenApp";
import { useGridColumns } from "./hooks/useGridColumns"; import { useGridColumns } from "./hooks/useGridColumns";
import { useLibrary } from "./hooks/useLibrary"; import { useLibrary } from "./hooks/useLibrary";
import { usePlayerState } from "./hooks/usePlayerState"; import { usePlayerState } from "./hooks/usePlayerState";
import { DEFAULT_MANUAL_CONTROL, DEFAULT_TUNABLES } from "./lib/ambienceTunables"; import { DEFAULT_MANUAL_CONTROL, DEFAULT_TUNABLES } from "./lib/ambienceTunables";
import type { AmbienceTunables, ManualControl } from "./lib/ambienceTunables"; import type { AmbienceTunables, ManualControl } from "./lib/ambienceTunables";
import type { Action, UiState } from "./lib/keyboard"; import type { Action, UiState } from "./lib/keyboard";
import { browseBackActions, GROUP_ORDER, handleKey, initialUiState, isRootShelf } from "./lib/keyboard"; import { browseBackActions, GROUP_ORDER, handleKey, initialUiState, isRootShelf, toggleModeActions } from "./lib/keyboard";
import { playPop } from "./lib/pop"; import { playPop } from "./lib/pop";
import { targetForAlbum } from "./lib/remote"; import { targetForAlbum } from "./lib/remote";
import type { Group, Results, SongHit } from "./lib/search"; import type { Group, Results, SongHit } from "./lib/search";
import { buildSearchIndex, categoryMatches, groupOf, results as computeResults } from "./lib/search"; import { buildSearchIndex, categoryMatches, groupOf, results as computeResults } from "./lib/search";
import { SHOW_AMBIENCE, SHOW_CARD_BLUR, SHOW_GLASS_BLUR } from "./lib/theme"; import { SHOW_AMBIENCE, SHOW_CARD_BLUR, SHOW_GLASS_BLUR } from "./lib/theme";
// The room page and the typing game are each a tab away from the player and together a
// large share of the bundle; the kiosk parses and compiles only what it opens.
const RoomView = lazy(() => import("./components/RoomView").then((m) => ({ default: m.RoomView })));
const TippenApp = lazy(() =>
import("./components/TippenApp").then((m) => ({ default: m.TippenApp })),
);
/** How long an armed "A" waits for the digit that completes the shortcut. */ /** How long an armed "A" waits for the digit that completes the shortcut. */
const ASSIGN_PENDING_TIMEOUT_MS = 4000; const ASSIGN_PENDING_TIMEOUT_MS = 4000;
@@ -58,6 +67,9 @@ export function App() {
const [parentMode, setParentMode] = useState( const [parentMode, setParentMode] = useState(
() => new URLSearchParams(location.search).get("parentMode") === "1", () => new URLSearchParams(location.search).get("parentMode") === "1",
); );
const [perfOpen, setPerfOpen] = useState(
() => PERF_ENABLED && new URLSearchParams(location.search).get("perf") === "1",
);
const [debugDynamicUI] = useState( const [debugDynamicUI] = useState(
() => new URLSearchParams(location.search).get("debugDynamicUI") === "1", () => new URLSearchParams(location.search).get("debugDynamicUI") === "1",
); );
@@ -97,16 +109,24 @@ export function App() {
// only reloads when the backend says a rescan finished, so this is genuinely rare. // only reloads when the backend says a rescan finished, so this is genuinely rare.
const index = useMemo(() => buildSearchIndex(library.albums), [library.albums]); const index = useMemo(() => buildSearchIndex(library.albums), [library.albums]);
// Kiosk only: fetch and decode every card thumbnail up front, in idle time.
useCoverWarmup(library.albums, PERF.coverWarmup);
// Typing updates `ui.search` at once and the search box with it; the results follow a
// deferred copy, so a render for an older query is dropped when the next letter lands
// instead of being finished first.
const deferredSearch = useDeferredValue(ui.search);
const results: Results = useMemo( const results: Results = useMemo(
() => () =>
computeResults({ computeResults({
index, index,
search: ui.search, search: deferredSearch,
mode: ui.mode, mode: ui.mode,
group: ui.group, group: ui.group,
category: ui.category, category: ui.category,
}), }),
[index, ui.search, ui.mode, ui.group, ui.category], [index, deferredSearch, ui.mode, ui.group, ui.category],
); );
// The bare root's three shelves, by category key only: the keyboard handler needs // The bare root's three shelves, by category key only: the keyboard handler needs
@@ -351,17 +371,17 @@ export function App() {
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [assignStatus]); }, [assignStatus]);
const onEnterGroup = (group: Group, category: string | null) => { const onEnterGroup = useEvent((group: Group, category: string | null) => {
playPop(category ? 440 : 380); playPop(category ? 440 : 380);
setUi((previous) => ({ ...previous, group, category, search: "", selIndex: 0 })); setUi((previous) => ({ ...previous, group, category, search: "", searchOpen: false, selIndex: 0 }));
}; });
const onCategory = (key: string | null) => { const onCategory = useEvent((key: string | null) => {
if (key) playPop(440); if (key) playPop(440);
setUi((previous) => ({ ...previous, category: key, selIndex: 0 })); setUi((previous) => ({ ...previous, category: key, selIndex: 0 }));
}; });
const onOpenAlbum = (album: Album, navIndex: number) => { const onOpenAlbum = useEvent((album: Album, navIndex: number) => {
// A podcast episode is a single track behaving like an audiobook of one chapter - // A podcast episode is a single track behaving like an audiobook of one chapter -
// there's nothing a metadata popup would add, so it just starts playing. // there's nothing a metadata popup would add, so it just starts playing.
if (groupOf(album) === "podcasts") { if (groupOf(album) === "podcasts") {
@@ -371,14 +391,14 @@ export function App() {
} }
playPop(420); playPop(420);
setUi((previous) => ({ ...previous, openAlbumId: album.id, selIndex: navIndex, modalTrackIndex: 0 })); setUi((previous) => ({ ...previous, openAlbumId: album.id, selIndex: navIndex, modalTrackIndex: 0 }));
}; });
/** Clicking the title text (as opposed to the cover) starts the album right away - /** Clicking the title text (as opposed to the cover) starts the album right away -
* the same thing ENTER does from the keyboard - instead of opening the track list. */ * the same thing ENTER does from the keyboard - instead of opening the track list. */
const onPlayAlbum = (album: Album, navIndex: number) => { const onPlayAlbum = useEvent((album: Album, navIndex: number) => {
setUi((previous) => ({ ...previous, selIndex: navIndex })); setUi((previous) => ({ ...previous, selIndex: navIndex }));
play(album.id, 0); play(album.id, 0);
}; });
const onOpenCurrentAlbum = () => { const onOpenCurrentAlbum = () => {
if (!currentAlbum) return; if (!currentAlbum) return;
@@ -392,7 +412,7 @@ export function App() {
})); }));
}; };
const onPlaySong = (hit: SongHit) => play(hit.album.id, hit.index); const onPlaySong = useEvent((hit: SongHit) => play(hit.album.id, hit.index));
const onNext = useCallback(() => run([{ type: "next" }]), [run]); const onNext = useCallback(() => run([{ type: "next" }]), [run]);
const onPrevious = useCallback(() => run([{ type: "previous" }]), [run]); const onPrevious = useCallback(() => run([{ type: "previous" }]), [run]);
@@ -443,7 +463,11 @@ export function App() {
group={ui.group} group={ui.group}
index={index} index={index}
mode={ui.mode} mode={ui.mode}
search={ui.search} search={deferredSearch}
typed={ui.search}
searchOpen={ui.searchOpen}
onToggleMode={() => run(toggleModeActions(ui))}
columns={columns}
category={ui.category} category={ui.category}
selIndex={ui.selIndex} selIndex={ui.selIndex}
shelfRow={ui.shelfRow} shelfRow={ui.shelfRow}
@@ -506,11 +530,13 @@ export function App() {
</div> </div>
)} )}
<Suspense fallback={null}>
{ui.page === "room" && haConfig && <RoomView config={haConfig} />} {ui.page === "room" && haConfig && <RoomView config={haConfig} />}
{ui.page === "typing" && ( {ui.page === "typing" && (
<TippenApp onExit={() => setUi((previous) => ({ ...previous, page: "music" }))} /> <TippenApp onExit={() => setUi((previous) => ({ ...previous, page: "music" }))} />
)} )}
</Suspense>
{openAlbum && state && ( {openAlbum && state && (
<AlbumModal <AlbumModal
@@ -559,6 +585,30 @@ export function App() {
{parentMode && <ParentPanel onClose={() => setParentMode(false)} />} {parentMode && <ParentPanel onClose={() => setParentMode(false)} />}
{PERF_ENABLED && !perfOpen && (
<button
onClick={() => setPerfOpen(true)}
aria-label="Leistungseinstellungen"
style={{
position: "absolute",
left: 8,
bottom: 8,
zIndex: 5,
width: 32,
height: 32,
border: "none",
borderRadius: 999,
cursor: "pointer",
background: "oklch(15% 0.03 210 / .35)",
color: "#fff",
opacity: 0.5,
}}
>
⚡
</button>
)}
{PERF_ENABLED && perfOpen && <PerfPanel onClose={() => setPerfOpen(false)} />}
{(!state || library.loading) && <Splash error={library.error} />} {(!state || library.loading) && <Splash error={library.error} />}
{debugDynamicUI && debugSnapshot && ( {debugDynamicUI && debugSnapshot && (

View File

@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { coverUrl } from "../client";
describe("coverUrl", () => {
it("is the plain full-size URL with no options", () => {
expect(coverUrl("abc")).toBe("/api/albums/abc/cover");
});
it("asks for the thumbnail and carries the cache-busting version", () => {
expect(coverUrl("abc", { size: "thumb", version: 42 })).toBe(
"/api/albums/abc/cover?size=thumb&v=42",
);
});
it("leaves the version off when there is none, so the server revalidates", () => {
expect(coverUrl("abc", { size: "thumb" })).toBe("/api/albums/abc/cover?size=thumb");
expect(coverUrl("abc", { version: 0 })).toBe("/api/albums/abc/cover");
});
});

View File

@@ -122,4 +122,20 @@ export const api = {
request<TippenRunResult>("/tippen/runs", { method: "POST", body: JSON.stringify(body) }), request<TippenRunResult>("/tippen/runs", { method: "POST", body: JSON.stringify(body) }),
}; };
export const coverUrl = (albumId: string) => `/api/albums/${albumId}/cover`; /** Cover URL. `thumb` is the small file for cards and rows; only the play view needs
* `full`. With a `version` (`Album.cover_v`) the URL names one exact set of bytes and the
* server marks it immutable, so the browser never asks for it twice; without one it
* revalidates on every use. */
export function coverUrl(
albumId: string,
{ size = "full", version }: { size?: "thumb" | "full"; version?: number } = {},
): string {
const params: string[] = [];
if (size === "thumb") params.push("size=thumb");
if (version) params.push(`v=${version}`);
return `/api/albums/${albumId}/cover${params.length ? `?${params.join("&")}` : ""}`;
}
/** Largest `Cover` `size` (CSS px) that is served the thumbnail; the play view's 340 gets
* the full file. Keep in step with `THUMB_COVER_PX` in the backend's `library/cache.py`. */
export const THUMB_MAX_SIZE = 220;

View File

@@ -67,6 +67,8 @@ export interface Album {
/** Three `#rrggbb`, taken from the cover art or synthesised: primary, secondary, accent. */ /** Three `#rrggbb`, taken from the cover art or synthesised: primary, secondary, accent. */
colors: string[]; colors: string[];
has_cover: boolean; has_cover: boolean;
/** Cache-busting version of the cover file; put it in the cover URL's `v`. */
cover_v?: number;
duration: number; duration: number;
tracks: Track[]; tracks: Track[];
/** Every track is still locked - show a question mark instead of cover art. */ /** Every track is still locked - show a question mark instead of cover art. */

View File

@@ -5,7 +5,7 @@
* flat across songs, then categories, then albums so one pair of arrow keys walks * flat across songs, then categories, then albums so one pair of arrow keys walks
* the whole page. */ * the whole page. */
import { useEffect, useMemo, useRef } from "react"; import { memo, useEffect, useMemo, useRef } from "react";
import type { MouseEvent as ReactMouseEvent, ReactNode } from "react"; import type { MouseEvent as ReactMouseEvent, ReactNode } from "react";
import type { Album } from "../api/types"; import type { Album } from "../api/types";
@@ -19,7 +19,7 @@ import {
} from "../lib/covers"; } from "../lib/covers";
import { clock } from "../lib/format"; import { clock } from "../lib/format";
import type { Category, Group, Results, SearchIndex, SongHit } from "../lib/search"; import type { Category, Group, Results, SearchIndex, SongHit } from "../lib/search";
import { categoryMatches, groupOf } from "../lib/search"; import { categoryMatches, effectiveSearch, groupOf } from "../lib/search";
import { import {
ANIMATE_VIEW_TRANSITIONS, ANIMATE_VIEW_TRANSITIONS,
glassTint, glassTint,
@@ -32,6 +32,8 @@ import {
SHOW_ROW_TITLES, SHOW_ROW_TITLES,
TAB_RAIL_CLEARANCE, TAB_RAIL_CLEARANCE,
} from "../lib/theme"; } from "../lib/theme";
import { useIncrementalCount } from "../hooks/useIncrementalCount";
import { PERF } from "../lib/perfSettings";
import { Cover } from "./Cover"; import { Cover } from "./Cover";
interface Props { interface Props {
@@ -39,7 +41,17 @@ interface Props {
group: Group | null; group: Group | null;
index: SearchIndex; index: SearchIndex;
mode: "albums" | "tracks"; mode: "albums" | "tracks";
/** The query the results below were computed for. Trails `typed` while a render for
* a newer one is still in flight. */
search: string; search: string;
/** What is in the search box right now - never behind. */
typed: string;
/** `/` was pressed, so the box shows while still empty. */
searchOpen: boolean;
/** The pill in the search box: album search <-> title search. */
onToggleMode: () => void;
/** Columns in the album grid, so the first paint can be exactly three rows. */
columns: number;
category: string | null; category: string | null;
selIndex: number; selIndex: number;
/** Which of the three root shelves is focused - only meaningful at the bare root, /** Which of the three root shelves is focused - only meaningful at the bare root,
@@ -73,12 +85,16 @@ const GROUP_SECTION_LABEL: Record<Group, string> = {
podcasts: "Episoden", podcasts: "Episoden",
}; };
export function BrowseView({ function BrowseViewImpl({
results, results,
group, group,
index, index,
mode, mode,
search, search: rawSearch,
typed,
searchOpen,
onToggleMode,
columns,
category, category,
selIndex, selIndex,
shelfRow, shelfRow,
@@ -93,7 +109,12 @@ export function BrowseView({
const scroller = useRef<HTMLDivElement | null>(null); const scroller = useRef<HTMLDivElement | null>(null);
const { songs, categories, albums: albumResults, total } = results; const { songs, categories, albums: albumResults, total } = results;
const isRootShelf = group === null && mode !== "tracks" && !search; // One letter is not a query yet - see `effectiveSearch`.
const search = effectiveSearch(rawSearch);
// Gone as soon as the box appears - `typed` and `searchOpen` are never behind, so the
// shelves do not linger for the one letter that is not a query yet.
const isRootShelf = group === null && mode !== "tracks" && !typed && !searchOpen;
// The root shelf has no flat `results` of its own (each row computes its own // The root shelf has no flat `results` of its own (each row computes its own
// categories independently) - it's a genuine two-axis layout instead: `shelfRow` // categories independently) - it's a genuine two-axis layout instead: `shelfRow`
@@ -103,11 +124,7 @@ export function BrowseView({
// needs the row half of that. // needs the row half of that.
const focusedRow = Math.max(0, Math.min(GROUPS.length - 1, shelfRow)); const focusedRow = Math.max(0, Math.min(GROUPS.length - 1, shelfRow));
const focusedCol = Math.max(0, selIndex); const focusedCol = Math.max(0, selIndex);
const selected = isRootShelf const selected = isRootShelf ? focusedRow : total ? Math.min(selIndex, total - 1) : -1;
? focusedRow
: total
? Math.min(selIndex, total - 1)
: -1;
const shelves = useMemo( const shelves = useMemo(
() => () =>
@@ -138,14 +155,49 @@ export function BrowseView({
} }
}, [selected]); }, [selected]);
// Long lists render a screenful and grow as the user nears the end. The three lists
// share one selection index (songs, then categories, then albums), hence the offsets.
const rowKey = `${group}:${category}:${mode}:${search}`;
const gridInitial = Math.max(1, columns) * 3;
const songWindow = useIncrementalCount(songs.length, 20, 20, rowKey, scroller, selected);
const categoryWindow = useIncrementalCount(
categories.length,
gridInitial,
gridInitial,
rowKey,
scroller,
selected - songs.length,
);
const albumWindow = useIncrementalCount(
albumResults.length,
gridInitial,
gridInitial,
rowKey,
scroller,
selected - songs.length - categories.length,
);
if (isRootShelf) { if (isRootShelf) {
return ( return (
<>
<SearchBox
typed={typed}
mode={mode}
open={searchOpen}
onToggleMode={onToggleMode}
note=""
/>
<div <div
ref={scroller} ref={scroller}
className={ANIMATE_VIEW_TRANSITIONS ? "view-enter" : undefined} className={ANIMATE_VIEW_TRANSITIONS ? "view-enter" : undefined}
// Right padding reserves room for the tab rail's own glass bar (see // Right padding reserves room for the tab rail's own glass bar (see
// TAB_RAIL_CLEARANCE) so a full-width shelf row never renders under it. // TAB_RAIL_CLEARANCE) so a full-width shelf row never renders under it.
style={{ flex: 1, overflow: "auto", minHeight: 0, padding: `14px ${TAB_RAIL_CLEARANCE}px 250px 32px` }} style={{
flex: 1,
overflow: "auto",
minHeight: 0,
padding: `14px ${TAB_RAIL_CLEARANCE}px 250px 32px`,
}}
> >
{shelves.map(({ group: shelfGroup, categories: shelfCategories }, index) => ( {shelves.map(({ group: shelfGroup, categories: shelfCategories }, index) => (
<div <div
@@ -225,16 +277,24 @@ export function BrowseView({
</div> </div>
))} ))}
</div> </div>
</>
); );
} }
const showSearchBar = search.length > 0 || mode === "tracks";
const countLabel = const countLabel =
mode === "tracks" typed.trim() && !search
? "noch ein Buchstabe …"
: mode === "tracks"
? songs.length
? `${songs.length} Titel gefunden` ? `${songs.length} Titel gefunden`
: search
? "nichts gefunden"
: ""
: albumResults.length : albumResults.length
? `${albumResults.length} Album${albumResults.length === 1 ? "" : "en"} gefunden` ? `${albumResults.length} Album${albumResults.length === 1 ? "" : "en"} gefunden`
: "nichts gefunden"; : search
? "nichts gefunden"
: "";
return ( return (
<> <>
@@ -253,50 +313,13 @@ export function BrowseView({
</div> </div>
)} )}
{showSearchBar && ( <SearchBox
<div typed={typed}
style={{ mode={mode}
margin: "2px 32px 6px", open={searchOpen}
display: "flex", onToggleMode={onToggleMode}
alignItems: "center", note={countLabel}
justifyContent: "center", />
gap: 12,
flex: "none",
}}
>
<div
style={{
background: "var(--paper)",
color: "var(--ink)",
fontWeight: 800,
fontSize: 20,
padding: "10px 20px",
borderRadius: 999,
boxShadow: "0 4px 14px var(--shadow)",
display: "flex",
alignItems: "center",
gap: 10,
}}
>
<span
style={{
fontSize: 14,
fontWeight: 800,
background: "var(--ink)",
color: "#fff",
padding: "4px 10px",
borderRadius: 999,
}}
>
{mode === "tracks" ? "♪ Titel" : "🔎 Alben"}
</span>
<span>{search ? `"${search}"` : "tippe …"}</span>
</div>
<div style={{ color: "oklch(90% 0.02 210 / .85)", fontWeight: 700, fontSize: 14 }}>
{countLabel}
</div>
</div>
)}
<div <div
ref={scroller} ref={scroller}
@@ -304,7 +327,12 @@ export function BrowseView({
className={ANIMATE_VIEW_TRANSITIONS ? "view-enter" : undefined} className={ANIMATE_VIEW_TRANSITIONS ? "view-enter" : undefined}
// Right padding reserves room for the tab rail's own glass bar (see // Right padding reserves room for the tab rail's own glass bar (see
// TAB_RAIL_CLEARANCE) so a full-width row/grid never renders under it. // TAB_RAIL_CLEARANCE) so a full-width row/grid never renders under it.
style={{ flex: 1, overflow: "auto", minHeight: 0, padding: `14px ${TAB_RAIL_CLEARANCE}px 250px 32px` }} style={{
flex: 1,
overflow: "auto",
minHeight: 0,
padding: `14px ${TAB_RAIL_CLEARANCE}px 250px 32px`,
}}
> >
{songs.length > 0 && ( {songs.length > 0 && (
<div style={{ marginBottom: 30 }}> <div style={{ marginBottom: 30 }}>
@@ -320,7 +348,7 @@ export function BrowseView({
margin: "0 auto", margin: "0 auto",
}} }}
> >
{songs.map((hit, index) => ( {songs.slice(0, songWindow.visible).map((hit, index) => (
<button <button
key={`${hit.album.id}-${hit.index}`} key={`${hit.album.id}-${hit.index}`}
data-nav-index={index} data-nav-index={index}
@@ -336,8 +364,7 @@ export function BrowseView({
textAlign: "left", textAlign: "left",
font: "inherit", font: "inherit",
...rowTint(GROUP_HUE[groupOf(hit.album)], currentAlbumId === hit.album.id), ...rowTint(GROUP_HUE[groupOf(hit.album)], currentAlbumId === hit.album.id),
outline: outline: selected === index ? "4px solid var(--paper)" : undefined,
selected === index ? "4px solid var(--paper)" : undefined,
outlineOffset: 2, outlineOffset: 2,
}} }}
> >
@@ -350,7 +377,13 @@ export function BrowseView({
/> />
</div> </div>
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 16, fontWeight: 800, color: "var(--paper)" }}> <div
style={{
fontSize: 16,
fontWeight: 800,
color: "var(--paper)",
}}
>
{hit.title} {hit.title}
</div> </div>
<div <div
@@ -377,6 +410,9 @@ export function BrowseView({
</button> </button>
))} ))}
</div> </div>
{songWindow.visible < songs.length && (
<div ref={songWindow.sentinelRef} aria-hidden style={{ height: 1 }} />
)}
</div> </div>
)} )}
@@ -384,7 +420,7 @@ export function BrowseView({
<div> <div>
<SectionTitle centered>{GROUP_CATEGORY_LABEL[group]}</SectionTitle> <SectionTitle centered>{GROUP_CATEGORY_LABEL[group]}</SectionTitle>
<div className="grid" ref={gridRef}> <div className="grid" ref={gridRef}>
{categories.map((entry, index) => { {categories.slice(0, categoryWindow.visible).map((entry, index) => {
const navIndex = songs.length + index; const navIndex = songs.length + index;
return ( return (
<CategoryTile <CategoryTile
@@ -397,6 +433,9 @@ export function BrowseView({
); );
})} })}
</div> </div>
{categoryWindow.visible < categories.length && (
<div ref={categoryWindow.sentinelRef} aria-hidden style={{ height: 1 }} />
)}
</div> </div>
)} )}
@@ -419,7 +458,7 @@ export function BrowseView({
<SectionTitle centered>{GROUP_SECTION_LABEL[group]}</SectionTitle> <SectionTitle centered>{GROUP_SECTION_LABEL[group]}</SectionTitle>
)} )}
<div className="grid" ref={gridRef}> <div className="grid" ref={gridRef}>
{albumResults.map((album, index) => { {albumResults.slice(0, albumWindow.visible).map((album, index) => {
const navIndex = songs.length + categories.length + index; const navIndex = songs.length + categories.length + index;
const podcast = groupOf(album) === "podcasts"; const podcast = groupOf(album) === "podcasts";
// An audiobook's artist is almost always the same as the character/ // An audiobook's artist is almost always the same as the character/
@@ -468,7 +507,11 @@ export function BrowseView({
<button <button
onClick={() => !album.locked && onPlayAlbum(album, navIndex)} onClick={() => !album.locked && onPlayAlbum(album, navIndex)}
disabled={album.locked} disabled={album.locked}
aria-label={album.locked ? `${album.title}: noch nicht freigeschaltet` : `${album.title} abspielen`} aria-label={
album.locked
? `${album.title}: noch nicht freigeschaltet`
: `${album.title} abspielen`
}
style={{ style={{
display: "block", display: "block",
width: "100%", width: "100%",
@@ -547,7 +590,9 @@ export function BrowseView({
marginTop: 6, marginTop: 6,
}} }}
> >
{podcast ? clock(album.duration) : unitLabel(album, album.tracks.length)} {podcast
? clock(album.duration)
: unitLabel(album, album.tracks.length)}
{album.figure && " · 🧸"} {album.figure && " · 🧸"}
</div> </div>
</> </>
@@ -557,20 +602,34 @@ export function BrowseView({
); );
})} })}
</div> </div>
{albumWindow.visible < albumResults.length && (
<div ref={albumWindow.sentinelRef} aria-hidden style={{ height: 1 }} />
)}
</div> </div>
)} )}
{total === 0 && ( {total === 0 && (
<div <div
style={{ textAlign: "center", marginTop: 60, color: "oklch(92% 0.02 210 / .8)" }} style={{
textAlign: "center",
marginTop: 60,
color: "oklch(92% 0.02 210 / .8)",
}}
> >
<img <img
src="/dolphin-mascot.png" src="/dolphin-mascot.png"
alt="" alt=""
style={{ width: 120, height: 120, objectFit: "contain", opacity: 0.9 }} style={{
width: 120,
height: 120,
objectFit: "contain",
opacity: 0.9,
}}
/> />
<div style={{ fontSize: 22, fontWeight: 800, marginTop: 10 }}> <div style={{ fontSize: 22, fontWeight: 800, marginTop: 10 }}>
Nichts gefunden — probier andere Buchstaben! {search
? "Nichts gefunden — probier andere Buchstaben!"
: "Tippe mindestens zwei Buchstaben …"}
</div> </div>
</div> </div>
)} )}
@@ -648,11 +707,10 @@ function ShelfRow({
element.classList.remove("dragging"); element.classList.remove("dragging");
if (dragged) { if (dragged) {
// This was a pan, not a click - swallow the click a tile would otherwise get. // This was a pan, not a click - swallow the click a tile would otherwise get.
window.addEventListener( window.addEventListener("click", (clickEvent) => clickEvent.stopPropagation(), {
"click", capture: true,
(clickEvent) => clickEvent.stopPropagation(), once: true,
{ capture: true, once: true }, });
);
} }
}; };
window.addEventListener("mousemove", onMove); window.addEventListener("mousemove", onMove);
@@ -682,6 +740,7 @@ function CategoryTile({
}) { }) {
const books = entry.albums.filter(isBook).length; const books = entry.albums.filter(isBook).length;
const allBooks = books === entry.albums.length; const allBooks = books === entry.albums.length;
const allPodcasts = entry.albums.every((a) => groupOf(a) === "podcasts");
const mostlyBooks = books * 2 > entry.albums.length; const mostlyBooks = books * 2 > entry.albums.length;
const shown = entry.albums.slice(0, 4); const shown = entry.albums.slice(0, 4);
return ( return (
@@ -695,7 +754,8 @@ function CategoryTile({
? `oklch(92% 0.09 ${GROUP_HUE.audiobooks} / .7)` ? `oklch(92% 0.09 ${GROUP_HUE.audiobooks} / .7)`
: `oklch(95% 0.015 ${GROUP_HUE.music} / .66)`, : `oklch(95% 0.015 ${GROUP_HUE.music} / .66)`,
borderRadius: allBooks ? "6px 18px 18px 6px" : "16px", borderRadius: allBooks ? "6px 18px 18px 6px" : "16px",
boxShadow: "0 6px 18px var(--shadow)", // One blurred shadow per tile: dropped under `?pi=1`, like `cardShadow`.
boxShadow: PERF.tileShadows ? "0 6px 18px var(--shadow)" : "none",
}} }}
> >
<div <div
@@ -758,7 +818,11 @@ function CategoryTile({
}} }}
> >
{entry.albums.length}{" "} {entry.albums.length}{" "}
{allBooks {allPodcasts
? entry.albums.length === 1
? "Episode"
: "Episoden"
: allBooks
? entry.albums.length === 1 ? entry.albums.length === 1
? "Hörbuch" ? "Hörbuch"
: "Hörbücher" : "Hörbücher"
@@ -771,13 +835,7 @@ function CategoryTile({
); );
} }
function SectionTitle({ function SectionTitle({ children, centered }: { children: ReactNode; centered?: boolean }) {
children,
centered,
}: {
children: ReactNode;
centered?: boolean;
}) {
return ( return (
<div <div
style={{ style={{
@@ -799,8 +857,63 @@ function SectionTitle({
function Muted({ children }: { children: ReactNode }) { function Muted({ children }: { children: ReactNode }) {
return ( return (
<span style={{ fontSize: 13, fontWeight: 700, color: "oklch(88% 0.02 210 / .7)" }}> <span
style={{
fontSize: 13,
fontWeight: 700,
color: "oklch(88% 0.02 210 / .7)",
}}
>
{children} {children}
</span> </span>
); );
} }
/** The search field, shown once there is something in it so it is obvious where typing goes. It only
* displays: keys are caught by the window handler in App, which is what lets the whole
* UI be driven from the keyboard without focusing anything first. */
function SearchBox({
typed,
mode,
open,
onToggleMode,
note,
}: {
typed: string;
mode: "albums" | "tracks";
open: boolean;
onToggleMode: () => void;
note: string;
}) {
// Nothing to show until a letter is typed or `/` opens it - and in tracks mode the
// box is also how the screen says which mode it is in.
if (!typed && !open && mode !== "tracks") return null;
return (
<div className="searchbox-row">
<div className="searchbox" data-empty={typed ? undefined : true}>
{/* mousedown is swallowed so the button never takes focus: a focused button
would also "click" on the Space or Enter the window handler already used. */}
<button
type="button"
className="searchbox-mode"
onMouseDown={(event) => event.preventDefault()}
onClick={onToggleMode}
title="Suchmodus wechseln ( / )"
>
{mode === "tracks" ? "♪ Titel" : "🔎 Alben"} ⇄
</button>
<span className="searchbox-text">
{!typed && <span className="searchbox-caret" aria-hidden />}
{typed || <span className="searchbox-placeholder">Tippe zum Suchen …</span>}
{typed && <span className="searchbox-caret" aria-hidden />}
</span>
</div>
{note && <div className="searchbox-note">{note}</div>}
</div>
);
}
/** Memoised: `App` re-renders twice a second during playback (position frames), and
* every prop here is either a primitive, a memoised value or a stable handler, so the
* card grid - up to ~340 cards - stays put unless something it shows has changed. */
export const BrowseView = memo(BrowseViewImpl);

View File

@@ -9,9 +9,9 @@
import type { CSSProperties } from "react"; import type { CSSProperties } from "react";
import { coverUrl } from "../api/client"; import { THUMB_MAX_SIZE, coverUrl } from "../api/client";
import type { Album } from "../api/types"; import type { Album } from "../api/types";
import { aspectOf, coverBackground, isBook } from "../lib/covers"; import { aspectOf, coverBackground, coverUnderlay, isBook } from "../lib/covers";
interface Props { interface Props {
album: Album; album: Album;
@@ -47,7 +47,11 @@ export function Cover({ album, size, fit = "width", radius, className, style, la
flex: "none", flex: "none",
borderRadius: radius ?? defaultRadius, borderRadius: radius ?? defaultRadius,
overflow: "hidden", overflow: "hidden",
background: coverBackground(album, Math.max(6, Math.round(size / 13))), // Real art is opaque: paint the cheap flat underlay, not the stripes/spine.
background:
album.has_cover && !locked
? coverUnderlay(album)
: coverBackground(album, Math.max(6, Math.round(size / 13))),
...box, ...box,
...style, ...style,
}} }}
@@ -90,9 +94,13 @@ export function Cover({ album, size, fit = "width", radius, className, style, la
)} )}
{album.has_cover && ( {album.has_cover && (
<img <img
src={coverUrl(album.id)} src={coverUrl(album.id, {
size: size <= THUMB_MAX_SIZE ? "thumb" : "full",
version: album.cover_v,
})}
alt="" alt=""
loading="lazy" loading="lazy"
decoding="async"
style={{ style={{
position: "absolute", position: "absolute",
inset: 0, inset: 0,

View File

@@ -0,0 +1,158 @@
/** Per-knob performance settings, shown under `?pi=1`. Stored in this browser's
* localStorage (see lib/perfSettings.ts) and applied by a reload, because the knobs are
* read once at module load. Nothing changes until "Speichern". */
import { useState } from "react";
import {
FULL,
PERF,
PI,
resetPerfSettings,
savePerfSettings,
type PerfSettings,
} from "../lib/perfSettings";
type BoolKey = { [K in keyof PerfSettings]: PerfSettings[K] extends boolean ? K : never }[keyof PerfSettings];
type NumKey = { [K in keyof PerfSettings]: PerfSettings[K] extends number ? K : never }[keyof PerfSettings];
const TOGGLES: [BoolKey, string, string][] = [
["ambience", "Animierter Hintergrund", "Canvas im Abspielbildschirm (größter CPU-Verbraucher)"],
["cardBlur", "Weichzeichner auf Karten", "Backdrop-Blur je Album-Karte und Titelzeile"],
["viewTransitions", "Übergänge", "Ein-/Ausblenden beim Öffnen von Gruppen und Alben"],
["decorativeAnimations", "Deko-Animationen", "Blasen im Zimmer, schwimmender Delfin"],
["cardShadows", "Schatten auf Karten", "Schlagschatten unter den Album-Karten"],
["tileShadows", "Schatten auf Kacheln", "Schlagschatten unter den Kacheln im Raster"],
["petAnimations", "Tippen: Tiere bewegen sich", "Aus: Tiere stehen still"],
["tippenBubbles", "Tippen: Blasen", "Aufsteigende Blasen im Hintergrund"],
["tippenViewTransitions", "Tippen: Übergänge", "Ein-/Ausblenden der Bildschirme"],
["serviceWorker", "Service Worker", "Aus: vorhandener Worker wird entfernt"],
["coverWarmup", "Cover vorladen", "Cover im Hintergrund dekodieren"],
];
const NUMBERS: [NumKey, string, string, number, number, number][] = [
["playbackClockFps", "Fortschrittsbalken: Updates/s", "0 = jeder Frame", 0, 60, 1],
["ambienceScale", "Hintergrund: Auflösung", "Faktor der Canvas-Größe", 0.1, 1, 0.05],
["ambienceMaxPx", "Hintergrund: max. Pixel", "Obergrenze je Achse", 256, 4096, 64],
["ambienceMaxFps", "Hintergrund: max. FPS", "0 = unbegrenzt", 0, 60, 1],
["ambienceMaxBubbles", "Hintergrund: max. Blasen", "0 = unbegrenzt", 0, 500, 5],
];
const pill = (background: string, color: string) =>
({
border: "none",
cursor: "pointer",
background,
color,
fontSize: 15,
fontWeight: 800,
padding: "11px 20px",
borderRadius: 999,
}) as const;
export function PerfPanel({ onClose }: { onClose: () => void }) {
const [draft, setDraft] = useState<PerfSettings>({ ...PERF });
const save = () => {
savePerfSettings(draft);
location.reload();
};
const reset = () => {
resetPerfSettings();
location.reload();
};
return (
<div className="overlay" style={{ zIndex: 7, background: "oklch(15% 0.03 210 / .75)" }}>
<div className="sheet" style={{ padding: 28, width: 480, maxHeight: "100%", overflow: "auto" }}>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<div style={{ fontSize: 20, fontWeight: 900, color: "var(--ink)", flex: 1 }}>
⚡ Leistung
</div>
<button
onClick={onClose}
aria-label="Schließen"
style={{
...pill("oklch(88% 0.02 210)", "var(--ink)"),
fontSize: 18,
fontWeight: 900,
width: 36,
height: 36,
padding: 0,
}}
>
×
</button>
</div>
<div style={{ display: "flex", gap: 10, marginTop: 16 }}>
<button onClick={() => setDraft({ ...PI })} style={pill("oklch(88% 0.02 210)", "var(--ink)")}>
Pi-Vorgabe
</button>
<button onClick={() => setDraft({ ...FULL })} style={pill("oklch(88% 0.02 210)", "var(--ink)")}>
Alles an
</button>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 12, marginTop: 18 }}>
{TOGGLES.map(([key, label, hint]) => (
<label key={key} style={{ display: "flex", alignItems: "center", gap: 12 }}>
<input
type="checkbox"
checked={draft[key]}
onChange={(event) => setDraft({ ...draft, [key]: event.target.checked })}
style={{ width: 22, height: 22 }}
/>
<span style={{ display: "flex", flexDirection: "column", gap: 2 }}>
<span style={{ fontSize: 14, fontWeight: 800, color: "var(--ink)" }}>{label}</span>
<span style={{ fontSize: 12, fontWeight: 600, color: "oklch(50% 0.03 210)" }}>
{hint}
</span>
</span>
</label>
))}
{NUMBERS.map(([key, label, hint, min, max, step]) => (
<label key={key} style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<span style={{ fontSize: 14, fontWeight: 800, color: "var(--ink)" }}>{label}</span>
<span style={{ fontSize: 12, fontWeight: 600, color: "oklch(50% 0.03 210)" }}>
{hint}
</span>
<input
type="number"
min={min}
max={max}
step={step}
value={draft[key]}
onChange={(event) => {
const value = event.target.valueAsNumber;
if (Number.isFinite(value)) setDraft({ ...draft, [key]: value });
}}
style={{
padding: "8px 12px",
borderRadius: 10,
border: "2px solid oklch(85% 0.02 210)",
fontSize: 16,
fontWeight: 700,
}}
/>
</label>
))}
<div style={{ display: "flex", gap: 10, marginTop: 6 }}>
<button onClick={save} style={pill("var(--accent)", "#fff")}>
Speichern &amp; neu laden
</button>
<button onClick={reset} style={pill("oklch(88% 0.02 210)", "var(--ink)")}>
Zurücksetzen
</button>
</div>
<div style={{ fontSize: 12, fontWeight: 600, color: "oklch(50% 0.03 210)", lineHeight: 1.5 }}>
Wird nur in diesem Browser gespeichert und gilt nur mit <code>?pi=1</code>.
</div>
</div>
</div>
</div>
);
}

View File

@@ -14,7 +14,7 @@
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { LOW_POWER } from "../../lib/lowPower"; import { PERF } from "../../lib/perfSettings";
import { creatureById, createSwimmer, pose, stepSwimmer } from "../../lib/tippen/aquarium"; import { creatureById, createSwimmer, pose, stepSwimmer } from "../../lib/tippen/aquarium";
import type { CreatureId, Swimmer } from "../../lib/tippen/aquarium"; import type { CreatureId, Swimmer } from "../../lib/tippen/aquarium";
@@ -42,7 +42,7 @@ export function AquariumCreatures({ creatures, opacity }: Props) {
// takes the same door: on that device the point is that nothing runs a frame loop, // takes the same door: on that device the point is that nothing runs a frame loop,
// and the pets are the reward - they should be *there*, they just need not swim. // and the pets are the reward - they should be *there*, they just need not swim.
const reducedMotion = const reducedMotion =
LOW_POWER || window.matchMedia("(prefers-reduced-motion: reduce)").matches; !PERF.petAnimations || window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let frame = 0; let frame = 0;
let lastTime = performance.now(); let lastTime = performance.now();

View File

@@ -9,7 +9,7 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import type { CreatureId } from "../../lib/tippen/aquarium"; import type { CreatureId } from "../../lib/tippen/aquarium";
import { LOW_POWER } from "../../lib/lowPower"; import { PERF } from "../../lib/perfSettings";
import { SHOW_AQUARIUM_CREATURES, SHOW_BUBBLES, SHOW_GLASS_BLUR } from "../../lib/tippen/theme"; import { SHOW_AQUARIUM_CREATURES, SHOW_BUBBLES, SHOW_GLASS_BLUR } from "../../lib/tippen/theme";
import { AquariumCreatures } from "./AquariumCreatures"; import { AquariumCreatures } from "./AquariumCreatures";
import { Bubbles } from "./Bubbles"; import { Bubbles } from "./Bubbles";
@@ -27,7 +27,7 @@ export function Stage({ children, creatures, dimmed }: Props) {
<div <div
className="tp-stage" className="tp-stage"
data-blur={SHOW_GLASS_BLUR ? "on" : "off"} data-blur={SHOW_GLASS_BLUR ? "on" : "off"}
data-anim={LOW_POWER ? "off" : "on"} data-anim={PERF.petAnimations ? "on" : "off"}
> >
{SHOW_AQUARIUM_CREATURES && <AquariumCreatures creatures={creatures} opacity={dimmed ? 0.25 : 1} />} {SHOW_AQUARIUM_CREATURES && <AquariumCreatures creatures={creatures} opacity={dimmed ? 0.25 : 1} />}
{SHOW_BUBBLES && <Bubbles />} {SHOW_BUBBLES && <Bubbles />}

View File

@@ -0,0 +1,55 @@
/** Pull every card thumbnail into the browser's caches once, while the kiosk is idle.
*
* Cover URLs carry a version and are served `immutable`, so once a thumbnail has been
* fetched the browser never asks for it again - but the *first* search over the whole
* library would otherwise fetch, and decode, hundreds of them while the user waits. This
* does that work up front, a few at a time in idle periods. The `Image`s are held so
* their decoded bitmaps stay resident: RAM is not the constraint on the machine this is
* for (`?pi=1`, see lib/lowPower.ts), the main thread during a keystroke is.
*/
import { useEffect } from "react";
import { coverUrl } from "../api/client";
import type { Album } from "../api/types";
const BATCH = 6;
const idle = (run: () => void): void => {
if (typeof requestIdleCallback === "function") requestIdleCallback(run, { timeout: 2000 });
else setTimeout(run, 50);
};
/** Module-level on purpose: dropping these would let the browser discard what they hold. */
const held = new Map<string, HTMLImageElement>();
export function useCoverWarmup(albums: Album[], enabled: boolean): void {
useEffect(() => {
if (!enabled) return;
let cancelled = false;
const pending = albums.filter((a) => a.has_cover && !a.locked);
let next = 0;
const step = () => {
if (cancelled || next >= pending.length) return;
const batch = pending.slice(next, next + BATCH);
next += BATCH;
void Promise.all(
batch.map((album) => {
const url = coverUrl(album.id, { size: "thumb", version: album.cover_v });
if (held.has(url)) return undefined;
const img = new Image();
img.decoding = "async";
img.src = url;
held.set(url, img);
return img.decode().catch(() => undefined);
}),
).then(() => idle(step));
};
idle(step);
return () => {
cancelled = true;
};
}, [albums, enabled]);
}

17
web/src/hooks/useEvent.ts Normal file
View File

@@ -0,0 +1,17 @@
/** A callback with a stable identity that always calls the latest closure.
*
* For handlers passed to a `memo`ised child: `App` re-renders twice a second while a
* track plays (websocket position frames), and a fresh inline handler each time would
* defeat `memo` and re-render the whole card grid with it. */
import { useCallback, useLayoutEffect, useRef } from "react";
export function useEvent<Args extends unknown[], Result>(
handler: (...args: Args) => Result,
): (...args: Args) => Result {
const latest = useRef(handler);
useLayoutEffect(() => {
latest.current = handler;
});
return useCallback((...args: Args) => latest.current(...args), []);
}

View File

@@ -0,0 +1,68 @@
/** How many items of a long list to render, growing as the user gets near the end.
Committing every card of a 340-album search result inside one keystroke is what made typing
feel stuck, and it is work for cards nobody has scrolled to. So a list renders its first
`initial` items and asks for `step` more whenever a sentinel placed after the list comes
within a screen of the scroll box's edge, or the keyboard selection reaches the last one
rendered. A new `resetKey` (another query, group or category) starts over at `initial`, so
a short search never inherits the length of the previous long one.
Growth runs in a transition: the next keystroke interrupts it rather than waiting for it.
*/
import { startTransition, useCallback, useEffect, useState } from "react";
import type { RefObject } from "react";
export function nextCount(count: number, step: number, total: number): number {
return Math.min(total, count + step);
}
export function useIncrementalCount(
total: number,
initial: number,
step: number,
resetKey: string,
root: RefObject<HTMLElement | null>,
selected: number,
): { visible: number; sentinelRef: (node: HTMLElement | null) => void } {
const [state, setState] = useState({ key: resetKey, count: initial });
const [sentinel, setSentinel] = useState<HTMLElement | null>(null);
// Adjusting state during render is React's documented way to reset on a changed input;
// an effect would paint one frame of the old, long list first.
let count = state.count;
if (state.key !== resetKey) {
count = initial;
setState({ key: resetKey, count });
} else if (count < initial) {
// The grid measured its real column count after first paint.
count = initial;
}
const grow = useCallback(() => {
startTransition(() =>
setState((previous) => ({ ...previous, count: nextCount(Math.max(previous.count, initial), step, total) })),
);
}, [initial, step, total]);
// Recreated on every growth: an observer only reports *changes*, so one that stays
// attached to a sentinel that is still on screen would never ask a second time.
useEffect(() => {
if (!sentinel || count >= total) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting)) grow();
},
{ root: root.current, rootMargin: "100% 0px" },
);
observer.observe(sentinel);
return () => observer.disconnect();
}, [sentinel, count, total, grow, root]);
// Arrowing down past the fold: the selected item must exist to be scrolled to.
useEffect(() => {
if (selected >= count - 1 && count < total) grow();
}, [selected, count, total, grow]);
return { visible: Math.min(count, total), sentinelRef: setSentinel };
}

View File

@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import type { Album } from "../../api/types"; import type { Album } from "../../api/types";
import { handleKey, initialUiState, selectionAlbumAt, selectionAt, type UiState } from "../keyboard"; import { handleKey, initialUiState, isRootShelf, selectionAlbumAt, selectionAt, type UiState } from "../keyboard";
import { buildSearchIndex, normalize, results as computeResults, type Results } from "../search"; import { buildSearchIndex, normalize, results as computeResults, type Results } from "../search";
function album(id: string, over: Partial<Album> = {}): Album { function album(id: string, over: Partial<Album> = {}): Album {
@@ -291,7 +291,7 @@ describe("keyboard", () => {
const searching = { ...inPlay, view: "browse" as const }; const searching = { ...inPlay, view: "browse" as const };
expect(press("Escape", searching)).toEqual([ expect(press("Escape", searching)).toEqual([
{ type: "ui", patch: { search: "", selIndex: 0 } }, { type: "ui", patch: { search: "", searchOpen: false, selIndex: 0 } },
]); ]);
const inTracks = { ...searching, search: "" }; const inTracks = { ...searching, search: "" };
@@ -471,4 +471,33 @@ describe("selectionAt", () => {
const ui: UiState = { ...initialUiState, search: "zzzznothing" }; const ui: UiState = { ...initialUiState, search: "zzzznothing" };
expect(selectionAt(resultsFor(ui), 0)).toBeNull(); expect(selectionAt(resultsFor(ui), 0)).toBeNull();
}); });
it("opens the search box on / and closes it with ESC before anything else", () => {
expect(press("/")).toContainEqual({
type: "ui",
patch: { view: "browse", searchOpen: true },
});
const open = { ...initialUiState, searchOpen: true };
expect(press("Escape", open)).toEqual([
{ type: "ui", patch: { search: "", searchOpen: false, selIndex: 0 } },
]);
});
it("drops the root shelves as soon as the search box is up", () => {
expect(isRootShelf(initialUiState)).toBe(true);
expect(isRootShelf({ ...initialUiState, search: "a" })).toBe(false);
expect(isRootShelf({ ...initialUiState, searchOpen: true })).toBe(false);
});
it("flips between album and title search with / while the box is up", () => {
const open = { ...initialUiState, searchOpen: true, search: "ab" };
expect(press("/", open)).toContainEqual({
type: "ui",
patch: { mode: "tracks", searchOpen: true, selIndex: 0 },
});
expect(press("/", { ...open, mode: "tracks" })).toContainEqual({
type: "ui",
patch: { mode: "albums", searchOpen: true, selIndex: 0 },
});
});
}); });

View File

@@ -0,0 +1,55 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const store = new Map<string, string>();
async function load(search: string) {
vi.resetModules();
vi.stubGlobal("location", { search });
vi.stubGlobal("localStorage", {
getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => void store.set(k, v),
removeItem: (k: string) => void store.delete(k),
});
return import("../perfSettings");
}
describe("perfSettings", () => {
beforeEach(() => store.clear());
afterEach(() => vi.unstubAllGlobals());
it("is full-fat without pi, ignoring stored values", async () => {
store.set("musicmouse.perf.v1", JSON.stringify({ ambience: false }));
const m = await load("");
expect(m.PERF_ENABLED).toBe(false);
expect(m.PERF).toEqual(m.FULL);
});
it("uses the pi preset under ?pi=1", async () => {
const m = await load("?pi=1");
expect(m.PERF).toEqual(m.PI);
});
it("merges stored overrides over the preset", async () => {
store.set("musicmouse.perf.v1", JSON.stringify({ ambience: true, playbackClockFps: 30 }));
const m = await load("?pi=1");
expect(m.PERF.ambience).toBe(true);
expect(m.PERF.playbackClockFps).toBe(30);
expect(m.PERF.cardBlur).toBe(m.PI.cardBlur);
});
it("drops wrong-typed fields and survives corrupt JSON", async () => {
store.set("musicmouse.perf.v1", JSON.stringify({ ambience: "yes", ambienceScale: null }));
expect((await load("?pi=1")).PERF).toEqual((await load("?pi=1")).PI);
store.set("musicmouse.perf.v1", "{nope");
const m = await load("?pi=1");
expect(m.PERF).toEqual(m.PI);
});
it("round-trips through save and reset", async () => {
const m = await load("?pi=1");
m.savePerfSettings({ ...m.PI, cardBlur: true });
expect((await load("?pi=1")).PERF.cardBlur).toBe(true);
m.resetPerfSettings();
expect((await load("?pi=1")).PERF.cardBlur).toBe(false);
});
});

View File

@@ -3,6 +3,8 @@ import { describe, expect, it } from "vitest";
import type { Album, Track } from "../../api/types"; import type { Album, Track } from "../../api/types";
import { import {
buildSearchIndex, buildSearchIndex,
effectiveSearch,
listMode,
MAX_SONG_HITS, MAX_SONG_HITS,
normalize, normalize,
results as computeResults, results as computeResults,
@@ -111,14 +113,14 @@ describe("songMatches", () => {
expect(query({ mode: "tracks", search: "geheime" }).songs).toEqual([]); expect(query({ mode: "tracks", search: "geheime" }).songs).toEqual([]);
}); });
it("caps an empty query, which would otherwise be the whole library", () => { it("caps a query that matches most of the library", () => {
const many = Array.from({ length: 60 }, (_, i) => const many = Array.from({ length: 60 }, (_, i) =>
album(`m${i}`, { tracks: [track(`Lied ${i}`)] }), album(`m${i}`, { tracks: [track(`Lied ${i}`)] }),
); );
const index = buildSearchIndex(many); const index = buildSearchIndex(many);
const hits = computeResults({ const hits = computeResults({
index, index,
search: "", search: "lied",
mode: "tracks", mode: "tracks",
group: null, group: null,
category: null, category: null,
@@ -129,3 +131,28 @@ describe("songMatches", () => {
expect(hits.at(-1)!.title).toBe(`Lied ${MAX_SONG_HITS - 1}`); expect(hits.at(-1)!.title).toBe(`Lied ${MAX_SONG_HITS - 1}`);
}); });
}); });
describe("minimum query length", () => {
it("ignores a single letter and anything that normalizes to one", () => {
expect(effectiveSearch("e")).toBe("");
expect(effectiveSearch(" é ")).toBe("");
expect(effectiveSearch("ab")).toBe("ab");
expect(effectiveSearch("a b")).toBe("a b");
});
it("leaves the screen alone for one letter and filters from two", () => {
const index = buildSearchIndex([album("1"), album("2")]);
const base = { index, mode: "albums" as const, group: "music" as Group, category: null };
expect(listMode({ ...base, search: "a" })).toBe("categories");
expect(computeResults({ ...base, search: "a" }).albums).toHaveLength(0);
expect(listMode({ ...base, search: "al" })).toBe("albums");
expect(computeResults({ ...base, search: "al" }).albums).toHaveLength(2);
});
it("shows no titles until two letters are typed", () => {
const base = { index: INDEX, mode: "tracks" as const, group: null, category: null };
expect(computeResults({ ...base, search: "" }).songs).toEqual([]);
expect(computeResults({ ...base, search: "v" }).songs).toEqual([]);
expect(computeResults({ ...base, search: "va" }).songs.length).toBeGreaterThan(0);
});
});

View File

@@ -7,6 +7,7 @@
*/ */
import type { Album, AlbumKind, PlayerState } from "../api/types"; import type { Album, AlbumKind, PlayerState } from "../api/types";
import { PERF } from "./perfSettings";
import { GROUP_HUE } from "./theme"; import { GROUP_HUE } from "./theme";
export const isBook = (album: Album): boolean => album.kind === "book"; export const isBook = (album: Album): boolean => album.kind === "book";
@@ -37,15 +38,18 @@ function stripes(album: Album, width: number): string {
); );
} }
/** A book spine: page edges on the right, a darker board on the left. */ /** A book spine: page edges on the right, a darker board on the left.
*
* Stops are sRGB hex on purpose - see the note on `.stage` in app.css: an oklch stop
* makes Gecko interpolate in Oklab on the CPU at every display-list build, and these
* four gradients sit under every book card. The page edges are oklch(97% 0.02 55) and
* oklch(90% 0.03 55) (`GROUP_HUE.audiobooks`), the ridges oklch(98% 0 0 / .35). */
export function spine(album: Album): string { export function spine(album: Album): string {
const [primary, secondary] = colours(album); const [primary, secondary] = colours(album);
const hue = GROUP_HUE.audiobooks;
return [ return [
`linear-gradient(90deg, transparent 0 95%, oklch(97% 0.02 ${hue}) 95% 97.5%,` + "linear-gradient(90deg, transparent 0 95%, #fff2e9 95% 97.5%, #efd9cc 97.5% 100%)",
` oklch(90% 0.03 ${hue}) 97.5% 100%)`, "linear-gradient(90deg, transparent 0 3.5%, #f8f8f859 3.5% 4.3%," +
"linear-gradient(90deg, transparent 0 3.5%, oklch(98% 0 0 / .35) 3.5% 4.3%," + " transparent 4.3% 7%, #f8f8f859 7% 7.8%, transparent 7.8%)",
" transparent 4.3% 7%, oklch(98% 0 0 / .35) 7% 7.8%, transparent 7.8%)",
`linear-gradient(90deg, ${secondary} 0 11%, ${primary} 11% 13%, transparent 13%)`, `linear-gradient(90deg, ${secondary} 0 11%, ${primary} 11% 13%, transparent 13%)`,
`linear-gradient(155deg, ${primary} 0%, ${secondary} 100%)`, `linear-gradient(155deg, ${primary} 0%, ${secondary} 100%)`,
].join(","); ].join(",");
@@ -55,6 +59,17 @@ export function coverBackground(album: Album, stripeWidth: number): string {
return isBook(album) ? spine(album) : stripes(album, stripeWidth); return isBook(album) ? spine(album) : stripes(album, stripeWidth);
} }
/** What sits under a cover that has real art. The art is opaque, so the stripes or the
* four-gradient spine would be painted for nothing - on a 340-card grid that is real
* display-list work. A flat primary colour is all a still-loading image needs; a book
* keeps one cheap gradient for the page edges its clip leaves showing on the right. */
export function coverUnderlay(album: Album): string {
const [primary] = colours(album);
return isBook(album)
? `linear-gradient(90deg, ${primary} 0 95%, #fff2e9 95% 97.5%, #efd9cc 97.5% 100%)`
: primary;
}
/** The card's own tint: warm paper for books, cool glass for music. Amber /** The card's own tint: warm paper for books, cool glass for music. Amber
* (`GROUP_HUE.audiobooks`) rather than a duller yellow-brown, so it reads as rich * (`GROUP_HUE.audiobooks`) rather than a duller yellow-brown, so it reads as rich
* rather than washed-out. */ * rather than washed-out. */
@@ -63,8 +78,16 @@ export const cardBackground = (album: Album): string =>
? `oklch(92% 0.09 ${GROUP_HUE.audiobooks} / .7)` ? `oklch(92% 0.09 ${GROUP_HUE.audiobooks} / .7)`
: `oklch(95% 0.015 ${GROUP_HUE.music} / .66)`; : `oklch(95% 0.015 ${GROUP_HUE.music} / .66)`;
/** One blurred shadow per card, up to ~340 of them: dropped under `?pi=1`, where a plain
* translucent card reads fine and the shadow is a per-card blur render task. Books keep
* their two inset "board" bands, which are cheap and are what make them look like books. */
export const cardShadow = (album: Album): string => export const cardShadow = (album: Album): string =>
isBook(album) !PERF.cardShadows
? isBook(album)
? `inset -7px 0 0 oklch(86% 0.09 ${GROUP_HUE.audiobooks} / .7), ` +
`inset -11px 0 0 oklch(78% 0.09 ${GROUP_HUE.audiobooks} / .7)`
: "none"
: isBook(album)
? `inset -7px 0 0 oklch(86% 0.09 ${GROUP_HUE.audiobooks} / .7), ` + ? `inset -7px 0 0 oklch(86% 0.09 ${GROUP_HUE.audiobooks} / .7), ` +
`inset -11px 0 0 oklch(78% 0.09 ${GROUP_HUE.audiobooks} / .7), ` + `inset -11px 0 0 oklch(78% 0.09 ${GROUP_HUE.audiobooks} / .7), ` +
`0 6px 18px oklch(15% 0.05 ${GROUP_HUE.music} / .35)` `0 6px 18px oklch(15% 0.05 ${GROUP_HUE.music} / .35)`

View File

@@ -15,6 +15,8 @@ import type { Group, Mode, Results } from "./search";
export interface UiState { export interface UiState {
search: string; search: string;
/** `/` was pressed: show the search box before the first letter arrives. */
searchOpen: boolean;
mode: Mode; mode: Mode;
/** `null` is the bare root screen (three shelves); otherwise which one is open. */ /** `null` is the bare root screen (three shelves); otherwise which one is open. */
group: Group | null; group: Group | null;
@@ -41,6 +43,7 @@ export interface UiState {
export const initialUiState: UiState = { export const initialUiState: UiState = {
search: "", search: "",
searchOpen: false,
mode: "albums", mode: "albums",
group: null, group: null,
category: null, category: null,
@@ -179,7 +182,9 @@ function moveSelection(state: UiState, results: Results, dx: number, dy: number)
* should be one step too, not two, regardless of whether a category was reached that * should be one step too, not two, regardless of whether a category was reached that
* way or by opening the group first. Always safe to call. */ * way or by opening the group first. Always safe to call. */
export function browseBackActions(state: UiState): Action[] { export function browseBackActions(state: UiState): Action[] {
if (state.search) return [{ type: "ui", patch: { search: "", selIndex: 0 } }]; if (state.search || state.searchOpen) {
return [{ type: "ui", patch: { search: "", searchOpen: false, selIndex: 0 } }];
}
if (state.mode === "tracks") return [{ type: "ui", patch: { mode: "albums", selIndex: 0 } }]; if (state.mode === "tracks") return [{ type: "ui", patch: { mode: "albums", selIndex: 0 } }];
return [{ type: "ui", patch: { group: null, category: null, selIndex: 0 } }]; return [{ type: "ui", patch: { group: null, category: null, selIndex: 0 } }];
} }
@@ -201,9 +206,28 @@ function escape(state: UiState): Action[] {
return browseBackActions(state); return browseBackActions(state);
} }
/** The true root: nothing chosen yet, rendered as three shelves rather than a list. */ /** Whether the search box is on screen: something typed, `/` pressed, or track search. */
export function searchBoxShown(state: UiState): boolean {
return state.search.length > 0 || state.searchOpen || state.mode === "tracks";
}
/** The true root: nothing chosen yet, rendered as three shelves rather than a list. The
* shelves go away the moment the search box appears, before there is a query to filter by. */
export function isRootShelf(state: UiState): boolean { export function isRootShelf(state: UiState): boolean {
return state.group === null && !state.search && state.mode === "albums" && state.category === null; return state.group === null && !searchBoxShown(state) && state.category === null;
}
/** Album search <-> title search, keeping whatever has been typed. `searchOpen` keeps the
* box up when the switch lands on albums with nothing typed, where it would otherwise
* vanish and bring the shelves back. */
export function toggleModeActions(state: UiState): Action[] {
return [
{ type: "pop", freq: 460 },
{
type: "ui",
patch: { mode: state.mode === "tracks" ? "albums" : "tracks", searchOpen: true, selIndex: 0 },
},
];
} }
export interface KeyEvent { export interface KeyEvent {
@@ -340,7 +364,9 @@ export function handleKey(
]; ];
} }
case "/": case "/":
return [{ type: "ui", patch: { view: "browse" } }]; // With the box already up, `/` flips what it searches; otherwise it opens it.
if (browsing && searchBoxShown(state)) return toggleModeActions(state);
return [{ type: "ui", patch: { view: "browse", searchOpen: true } }];
case "ArrowRight": case "ArrowRight":
if (event.shiftKey) return [{ type: "seek", delta: SEEK_STEP }]; if (event.shiftKey) return [{ type: "seek", delta: SEEK_STEP }];

View File

@@ -44,15 +44,11 @@
* answerable by editing the address bar. * answerable by editing the address bar.
*/ */
/** True when the page was loaded with `?pi=1`. */ import { PERF } from "./perfSettings";
export const LOW_POWER = readLowPowerFlag();
function readLowPowerFlag(): boolean { // Superseded as a single switch: every knob below and in theme.ts now comes from
// `location` is absent under vitest's default node environment. // `PERF` (lib/perfSettings.ts), which `?pi=1` seeds and the settings dialog overrides.
if (typeof location === "undefined") return false; // This header is kept for the measurements it records.
const value = new URLSearchParams(location.search).get("pi");
return value === "1" || value === "true";
}
export interface AmbienceQuality { export interface AmbienceQuality {
/** Multiplies the canvas backing store relative to its on-screen size, before /** Multiplies the canvas backing store relative to its on-screen size, before
@@ -80,9 +76,12 @@ export interface AmbienceQuality {
* canvas off there entirely. Kept, and kept accurate, because it is the setting that * canvas off there entirely. Kept, and kept accurate, because it is the setting that
* matters the moment anyone turns the canvas back on for a weak device - the half-scale * matters the moment anyone turns the canvas back on for a weak device - the half-scale
* backing store is what took it from 53.5% to 25%. */ * backing store is what took it from 53.5% to 25%. */
export const AMBIENCE_QUALITY: AmbienceQuality = LOW_POWER export const AMBIENCE_QUALITY: AmbienceQuality = {
? { resolutionScale: 0.5, maxBackingStorePx: 1280, maxFps: 30, maxBubbles: 60 } resolutionScale: PERF.ambienceScale,
: { resolutionScale: 1, maxBackingStorePx: 4096, maxFps: 0, maxBubbles: 0 }; maxBackingStorePx: PERF.ambienceMaxPx,
maxFps: PERF.ambienceMaxFps,
maxBubbles: PERF.ambienceMaxBubbles,
};
/** How many times a second the interpolated playback position may push a new React /** How many times a second the interpolated playback position may push a new React
* render. 0 means "every animation frame", which is what it has always done. * render. 0 means "every animation frame", which is what it has always done.
@@ -96,4 +95,4 @@ export const AMBIENCE_QUALITY: AmbienceQuality = LOW_POWER
* *
* Only bites while something is playing, so it is not in the idle table above. * Only bites while something is playing, so it is not in the idle table above.
*/ */
export const PLAYBACK_CLOCK_FPS = LOW_POWER ? 10 : 0; export const PLAYBACK_CLOCK_FPS = PERF.playbackClockFps;

147
web/src/lib/perfSettings.ts Normal file
View File

@@ -0,0 +1,147 @@
/** Per-knob performance settings, the granular successor to the old all-or-nothing `?pi=1`.
*
* `?pi=1` still switches the panel on, but no longer flips everything at once: it selects
* the `PI` preset as the starting point, and each knob can then be overridden from the
* settings dialog (`PerfPanel`) and is stored in this browser's localStorage. Without
* `?pi=1` the stored values are ignored and the full-fat `FULL` preset applies, so a
* laptop opening the same page always gets the full version - which is what makes "is this
* the profile or the hardware?" answerable by editing the address bar.
*
* Read once at module load. The knobs feed module-level constants (`SHOW_AMBIENCE`, ...)
* and the service worker registration, so applying a change means a page reload.
*
* `SHOW_GLASS_BLUR` is deliberately not a knob: on the Pi it measured five times *worse*
* with the blur off. See the table in lib/lowPower.ts.
*/
export interface PerfSettings {
/** Fade/slide animation when entering a group/category or opening the album modal. */
viewTransitions: boolean;
/** The play view's animated canvas background (gradient + bubbles). */
ambience: boolean;
/** Real backdrop blur on each repeated glass card/row. */
cardBlur: boolean;
/** Decorative CSS loops: the room page's bubbles and the dolphin mascot. */
decorativeAnimations: boolean;
/** Drop shadows under covers in the album cards. */
cardShadows: boolean;
/** Drop shadows under tiles in the browse grid. */
tileShadows: boolean;
/** Typing game: pets move (off = placed but held still). */
petAnimations: boolean;
/** Typing game: rising bubbles. */
tippenBubbles: boolean;
/** Typing game: fade/slide view transitions. */
tippenViewTransitions: boolean;
/** Register the service worker (off also unregisters an existing one). */
serviceWorker: boolean;
/** Pre-decode covers in the background. */
coverWarmup: boolean;
/** Max playback-position renders per second. 0 = every animation frame. */
playbackClockFps: number;
/** Ambient canvas backing-store scale relative to its on-screen size. */
ambienceScale: number;
/** Ceiling on either ambient canvas backing-store axis, in px. */
ambienceMaxPx: number;
/** Ambient canvas frame-rate cap. 0 = whatever the display offers. */
ambienceMaxFps: number;
/** Ceiling on ambient bubbles alive at once. 0 = unlimited. */
ambienceMaxBubbles: number;
}
/** Today's behaviour without `?pi=1`. */
export const FULL: PerfSettings = {
viewTransitions: true,
ambience: true,
cardBlur: true,
decorativeAnimations: true,
cardShadows: true,
tileShadows: true,
petAnimations: true,
tippenBubbles: true,
tippenViewTransitions: true,
serviceWorker: true,
coverWarmup: false,
playbackClockFps: 0,
ambienceScale: 1,
ambienceMaxPx: 4096,
ambienceMaxFps: 0,
ambienceMaxBubbles: 0,
};
/** Today's behaviour with `?pi=1`. */
export const PI: PerfSettings = {
viewTransitions: false,
ambience: false,
cardBlur: false,
decorativeAnimations: false,
cardShadows: false,
tileShadows: false,
petAnimations: false,
tippenBubbles: false,
tippenViewTransitions: false,
serviceWorker: false,
coverWarmup: true,
playbackClockFps: 10,
ambienceScale: 0.5,
ambienceMaxPx: 1280,
ambienceMaxFps: 30,
ambienceMaxBubbles: 60,
};
export const STORAGE_KEY = "musicmouse.perf.v1";
/** True when the page was loaded with `?pi=1`. */
export const PERF_ENABLED = readPiFlag();
function readPiFlag(): boolean {
// `location` is absent under vitest's default node environment.
if (typeof location === "undefined") return false;
const value = new URLSearchParams(location.search).get("pi");
return value === "1" || value === "true";
}
/** Keep only stored fields whose type matches the preset's; anything else is dropped. */
function sanitize(raw: unknown): Partial<PerfSettings> {
if (typeof raw !== "object" || raw === null) return {};
const out: Record<string, boolean | number> = {};
for (const [key, def] of Object.entries(PI)) {
const value = (raw as Record<string, unknown>)[key];
if (typeof value === typeof def && (typeof value !== "number" || Number.isFinite(value))) {
out[key] = value as boolean | number;
}
}
return out as Partial<PerfSettings>;
}
export function loadStoredPerf(): Partial<PerfSettings> {
try {
const text = localStorage.getItem(STORAGE_KEY);
return text ? sanitize(JSON.parse(text)) : {};
} catch {
return {};
}
}
export function loadPerfSettings(): PerfSettings {
if (!PERF_ENABLED) return { ...FULL };
return { ...PI, ...loadStoredPerf() };
}
export function savePerfSettings(settings: PerfSettings): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
} catch {
// Storage blocked: the dialog still reloads, it just comes back with the preset.
}
}
export function resetPerfSettings(): void {
try {
localStorage.removeItem(STORAGE_KEY);
} catch {
// ignore
}
}
export const PERF: PerfSettings = loadPerfSettings();

View File

@@ -53,6 +53,16 @@ function searchWords(value: string): string[] {
.filter(Boolean); .filter(Boolean);
} }
/** One letter matches most of the library, which is a screen of noise and not a
* search - the first character is the user finding their footing, not asking. */
export const MIN_SEARCH_CHARS = 2;
/** The query that actually filters: `""` until there are at least `MIN_SEARCH_CHARS`
* letters to go on, so the screen stays where it is while the first one is typed. */
export function effectiveSearch(value: string): string {
return searchWords(value).join("").length >= MIN_SEARCH_CHARS ? value : "";
}
/** Every word has to show up somewhere in the haystack, in any order. */ /** Every word has to show up somewhere in the haystack, in any order. */
function matchesWords(haystack: string, words: string[]): boolean { function matchesWords(haystack: string, words: string[]): boolean {
return words.every((word) => haystack.includes(word)); return words.every((word) => haystack.includes(word));
@@ -185,7 +195,7 @@ function trackPool(query: BrowseQuery): TrackEntry[] {
/** Which of the three lists the browse view is showing. */ /** Which of the three lists the browse view is showing. */
export function listMode(query: BrowseQuery): "tracks" | "albums" | "categories" { export function listMode(query: BrowseQuery): "tracks" | "albums" | "categories" {
if (query.mode === "tracks") return "tracks"; if (query.mode === "tracks") return "tracks";
if (query.search || query.category) return "albums"; if (effectiveSearch(query.search) || query.category) return "albums";
return "categories"; return "categories";
} }
@@ -198,7 +208,7 @@ export function categoryMatches(query: BrowseQuery): Category[] {
export function albumMatches(query: BrowseQuery): Album[] { export function albumMatches(query: BrowseQuery): Album[] {
if (query.mode === "tracks") return []; if (query.mode === "tracks") return [];
const words = searchWords(query.search); const words = searchWords(effectiveSearch(query.search));
if (!words.length && !query.category) return []; if (!words.length && !query.category) return [];
let candidates = albumPool(query); let candidates = albumPool(query);
if (query.category) candidates = candidates.filter((e) => e.album.category === query.category); if (query.category) candidates = candidates.filter((e) => e.album.category === query.category);
@@ -206,15 +216,17 @@ export function albumMatches(query: BrowseQuery): Album[] {
return candidates.map((e) => e.album); return candidates.map((e) => e.album);
} }
/** Capped, because an empty query over 900 podcast episodes is not a useful screen. */ /** Capped, because a query like "e" over 900 podcast episodes is not a useful screen. */
export const MAX_SONG_HITS = 40; export const MAX_SONG_HITS = 40;
export function songMatches(query: BrowseQuery): SongHit[] { export function songMatches(query: BrowseQuery): SongHit[] {
if (query.mode !== "tracks") return []; if (query.mode !== "tracks") return [];
const words = searchWords(query.search); const words = searchWords(effectiveSearch(query.search));
// Titles are five thousand rows: nothing until there is a query, not the first 40.
if (!words.length) return [];
const hits: SongHit[] = []; const hits: SongHit[] = [];
for (const track of trackPool(query)) { for (const track of trackPool(query)) {
if (words.length && !matchesWords(track.haystack, words)) continue; if (!matchesWords(track.haystack, words)) continue;
hits.push({ hits.push({
album: track.album, album: track.album,
index: track.index, index: track.index,

View File

@@ -11,7 +11,7 @@
import type { CSSProperties } from "react"; import type { CSSProperties } from "react";
import { LOW_POWER } from "./lowPower"; import { PERF } from "./perfSettings";
import type { Group } from "./search"; import type { Group } from "./search";
import type { UiState } from "./keyboard"; import type { UiState } from "./keyboard";
@@ -33,7 +33,7 @@ export const ROW_SPACING = 25;
/** Fade the album/category grid in (with a slight upward slide) when a group or /** Fade the album/category grid in (with a slight upward slide) when a group or
* category is entered, and the album modal in when it opens. One animation per * category is entered, and the album modal in when it opens. One animation per
* container, not per card, so cost stays flat regardless of grid size. */ * container, not per card, so cost stays flat regardless of grid size. */
export const ANIMATE_VIEW_TRANSITIONS = !LOW_POWER; export const ANIMATE_VIEW_TRANSITIONS = PERF.viewTransitions;
/** Frost the glass panels/cards/rows with a real backdrop blur. /** Frost the glass panels/cards/rows with a real backdrop blur.
* *
@@ -54,7 +54,7 @@ export const SHOW_GLASS_BLUR = true;
* loop that repaints the full viewport is a floor you cannot get under while it runs at * loop that repaints the full viewport is a floor you cannot get under while it runs at
* all, and on a Pi 4 that floor is too high. Nothing else in the app needs a frame loop, * all, and on a Pi 4 that floor is too high. Nothing else in the app needs a frame loop,
* so with this off the browser has nothing to do between one keypress and the next. */ * so with this off the browser has nothing to do between one keypress and the next. */
export const SHOW_AMBIENCE = !LOW_POWER; export const SHOW_AMBIENCE = PERF.ambience;
/** Frost the *repeated* glass surfaces - every album card in the grid, every row in a /** Frost the *repeated* glass surfaces - every album card in the grid, every row in a
* track list - as opposed to the handful of panels wrapped around them. * track list - as opposed to the handful of panels wrapped around them.
@@ -65,14 +65,14 @@ export const SHOW_AMBIENCE = !LOW_POWER;
* card is one live backdrop copy out of three hundred sitting directly over the * card is one live backdrop copy out of three hundred sitting directly over the
* animated canvas, so every canvas frame re-blurs all of them. Measured on the Pi with * animated canvas, so every canvas frame re-blurs all of them. Measured on the Pi with
* a full album grid on screen, Chromium: see lib/lowPower.ts. */ * a full album grid on screen, Chromium: see lib/lowPower.ts. */
export const SHOW_CARD_BLUR = !LOW_POWER; export const SHOW_CARD_BLUR = PERF.cardBlur;
/** Run the purely decorative CSS animation loops: the room page's bubble field and /** Run the purely decorative CSS animation loops: the room page's bubble field and
* the dolphin mascot's bob/swim. Off under `?pi=1`. On their own they measured as * the dolphin mascot's bob/swim. Off under `?pi=1`. On their own they measured as
* noise, but "nothing on this screen moves by itself" is a property worth having * noise, but "nothing on this screen moves by itself" is a property worth having
* outright rather than a sum of small wins - a compositor with no animation to service * outright rather than a sum of small wins - a compositor with no animation to service
* has nothing to wake up for. */ * has nothing to wake up for. */
export const SHOW_DECORATIVE_ANIMATIONS = !LOW_POWER; export const SHOW_DECORATIVE_ANIMATIONS = PERF.decorativeAnimations;
// ------------------------------------------------------------------ colors -- // ------------------------------------------------------------------ colors --

View File

@@ -4,7 +4,7 @@
* through components. The hue lives in styles/app.css because CSS is where it is used; * through components. The hue lives in styles/app.css because CSS is where it is used;
* it is repeated here only for the canvas, which cannot read a custom property. */ * it is repeated here only for the canvas, which cannot read a custom property. */
import { LOW_POWER } from "../lowPower"; import { PERF } from "../perfSettings";
/** The turquoise lagoon. Music is 210, Hörbücher 55, "Mein Zimmer" 300. */ /** The turquoise lagoon. Music is 210, Hörbücher 55, "Mein Zimmer" 300. */
export const HUE = 175; export const HUE = 175;
@@ -17,7 +17,7 @@ export const SHOW_GLASS_BLUR = true;
/** The decorative rising bubbles behind everything. A dozen elements on a CSS transform /** The decorative rising bubbles behind everything. A dozen elements on a CSS transform
* loop - off under `?pi=1`, like every other loop in the app. */ * loop - off under `?pi=1`, like every other loop in the app. */
export const SHOW_BUBBLES = !LOW_POWER; export const SHOW_BUBBLES = PERF.tippenBubbles;
/** The earned pets swimming behind every screen. The reward that is always in view - off /** The earned pets swimming behind every screen. The reward that is always in view - off
* only to rule it out when chasing a performance problem. * only to rule it out when chasing a performance problem.
@@ -29,7 +29,7 @@ export const SHOW_BUBBLES = !LOW_POWER;
export const SHOW_AQUARIUM_CREATURES = true; export const SHOW_AQUARIUM_CREATURES = true;
/** Fade screens in on entry. */ /** Fade screens in on entry. */
export const ANIMATE_VIEW_TRANSITIONS = !LOW_POWER; export const ANIMATE_VIEW_TRANSITIONS = PERF.tippenViewTransitions;
/** Show the on-screen keyboard with the finger colours. "auto" fades it out key by key /** Show the on-screen keyboard with the finger colours. "auto" fades it out key by key
* as each one is mastered - the scaffold that removes itself, which is the whole point * as each one is mastered - the scaffold that removes itself, which is the whole point

View File

@@ -2,6 +2,7 @@ import { StrictMode } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { App } from "./App"; import { App } from "./App";
import { PERF } from "./lib/perfSettings";
import "./styles/app.css"; import "./styles/app.css";
import "./styles/room.css"; import "./styles/room.css";
import "./styles/tippen.css"; import "./styles/tippen.css";
@@ -15,8 +16,20 @@ createRoot(document.getElementById("root")!).render(
// Registered after load so it never competes with the first paint. It is what makes // Registered after load so it never competes with the first paint. It is what makes
// iOS offer "Zum Home-Bildschirm" as an app rather than a bookmark; see public/sw.js // iOS offer "Zum Home-Bildschirm" as an app rather than a bookmark; see public/sw.js
// for what it does and deliberately does not cache. // for what it does and deliberately does not cache.
//
// Not under `?pi=1`: that is the kiosk, which *is* the server. There is nothing to
// install, and the worker only adds a fetch handler (and a `cache.put` of every response)
// in front of requests that are already on localhost. Any worker an earlier visit left
// behind is removed so the kiosk stops paying for it.
if ("serviceWorker" in navigator && import.meta.env.PROD) { if ("serviceWorker" in navigator && import.meta.env.PROD) {
window.addEventListener("load", () => { window.addEventListener("load", () => {
if (!PERF.serviceWorker) {
void navigator.serviceWorker
.getRegistrations()
.then((registrations) => Promise.all(registrations.map((r) => r.unregister())))
.catch(() => undefined);
return;
}
void navigator.serviceWorker.register("/sw.js").catch(() => { void navigator.serviceWorker.register("/sw.js").catch(() => {
// A plain http:// origin that is not localhost cannot register one. The app // A plain http:// origin that is not localhost cannot register one. The app
// works exactly the same, it just cannot be installed. // works exactly the same, it just cannot be installed.

View File

@@ -104,12 +104,11 @@ button {
height: 100vh; height: 100vh;
height: 100dvh; height: 100dvh;
overflow: hidden; overflow: hidden;
background: linear-gradient( /* Gradient stops are plain sRGB hex, not oklch(): a gradient with an oklch stop
180deg, interpolates in Oklab, which Gecko expands stop by stop on the CPU every time the
oklch(55% 0.07 210) 0%, display list is rebuilt - about 15% of a Pi-profile's busy time. Values are the
oklch(38% 0.06 210) 45%, oklch(55% 0.07 210) / (38% 0.06 210) / (20% 0.045 210) they replace. */
var(--sea-deep) 100% background: linear-gradient(180deg, #397d88 0%, #0e4b54 45%, #001b21 100%);
);
} }
/* Still used by the room-control page's own `<Bubbles>` field (`RoomView.tsx`) - the /* Still used by the room-control page's own `<Bubbles>` field (`RoomView.tsx`) - the
@@ -270,7 +269,7 @@ button {
flex: none; flex: none;
border-radius: 999px; border-radius: 999px;
font-size: 17px; font-size: 17px;
background: linear-gradient(160deg, oklch(97% 0.01 210 / 0.38), oklch(97% 0.01 210 / 0.12)); background: linear-gradient(160deg, #eef7f961, #eef7f91f) /* oklch(97% 0.01 210 / .38 -> .12) */;
backdrop-filter: blur(8px); backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
border: 1px solid oklch(97% 0.01 210 / 0.4); border: 1px solid oklch(97% 0.01 210 / 0.4);
@@ -282,7 +281,7 @@ button {
/* A frosted panel wrapped around a shelf/list of rows so they read as a distinct /* A frosted panel wrapped around a shelf/list of rows so they read as a distinct
surface floating over the stage gradient, rather than flat text on a gradient. */ surface floating over the stage gradient, rather than flat text on a gradient. */
.glass-panel { .glass-panel {
background: linear-gradient(160deg, oklch(97% 0.01 210 / 0.14), oklch(97% 0.01 210 / 0.05)); background: linear-gradient(160deg, #eef7f924, #eef7f90d) /* oklch(97% 0.01 210 / .14 -> .05) */;
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
border: 1px solid oklch(97% 0.01 210 / 0.16); border: 1px solid oklch(97% 0.01 210 / 0.16);
@@ -344,3 +343,89 @@ input[type="number"],
input[type="range"] { input[type="range"] {
font: inherit; font: inherit;
} }
/* The search field. Big on purpose: typing is how the whole app is driven, so where the
letters land has to be unmissable. */
.searchbox-row {
flex: none;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
margin: 6px 32px 10px;
}
.searchbox {
display: flex;
align-items: center;
gap: 16px;
width: min(680px, 100%);
min-height: 68px;
box-sizing: border-box;
padding: 10px 26px 10px 14px;
border-radius: 999px;
background: var(--paper);
color: var(--ink);
border: 3px solid var(--accent);
box-shadow: 0 6px 22px var(--shadow);
font-size: 30px;
font-weight: 800;
}
.searchbox-mode {
flex: none;
font-size: 16px;
padding: 8px 14px;
border-radius: 999px;
background: var(--ink);
color: #fff;
border: none;
font: inherit;
font-size: 16px;
cursor: pointer;
}
.searchbox-mode:hover {
background: var(--accent);
}
.searchbox-text {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.searchbox-placeholder {
color: oklch(30% 0.04 210 / .45);
}
.searchbox-caret {
display: inline-block;
width: 3px;
height: 1em;
margin-left: 3px;
vertical-align: -0.12em;
background: var(--accent);
animation: searchbox-blink 1.1s steps(1) infinite;
}
/* Empty: the caret sits where the first letter will land, ahead of the placeholder. */
.searchbox[data-empty] .searchbox-caret {
margin: 0 3px 0 0;
}
.searchbox-note {
color: oklch(90% 0.02 210 / .85);
font-size: 15px;
font-weight: 700;
}
@keyframes searchbox-blink {
50% { opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.searchbox-caret { animation: none; }
}