Compare commits
16 Commits
dd14f5d901
...
deploy/mus
| Author | SHA1 | Date | |
|---|---|---|---|
| 683cda35cb | |||
| 2db4368fc9 | |||
| 9fd106b757 | |||
| 794126bfdb | |||
| ec9d617ad8 | |||
| 6ccb3e458f | |||
| 83d16e0058 | |||
| ebab03b700 | |||
| 3fea56d4e1 | |||
| e1c10f5408 | |||
| 0ef5a04cb9 | |||
| 3cdad1e714 | |||
| 2514ecbc35 | |||
| cc3db44c4e | |||
| a7fb56c9fe | |||
| fa3d92189c |
10
.vscode/settings.json
vendored
Normal file
10
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"files.associations": {
|
||||||
|
"*.tcc": "cpp",
|
||||||
|
"deque": "cpp",
|
||||||
|
"string": "cpp",
|
||||||
|
"unordered_map": "cpp",
|
||||||
|
"vector": "cpp",
|
||||||
|
"system_error": "cpp"
|
||||||
|
}
|
||||||
|
}
|
||||||
27
claude-design/AppHeader.dc.html
Normal file
27
claude-design/AppHeader.dc.html
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<script src="./support.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<x-dc>
|
||||||
|
<div style="display:grid;grid-template-columns:1fr auto 1fr;align-items:center;gap:16px;padding:14px 78px 8px 32px;">
|
||||||
|
<a href="{{ linkHref }}" style="justify-self:start;text-decoration:none;background:oklch(97% 0.01 230 / .95);border-radius:999px;padding:9px 16px;font-size:14px;font-weight:800;color:oklch(30% 0.03 230);display:inline-flex;align-items:center;gap:8px;box-shadow:0 6px 18px oklch(15% 0.04 230 / .4);white-space:nowrap;">{{ linkLabel }}</a>
|
||||||
|
|
||||||
|
<div style="display:flex;align-items:center;gap:12px;">
|
||||||
|
<img src="{{ mascot }}" alt="Delfin" style="width:82px;height:82px;object-fit:contain;flex:none;filter:drop-shadow(0 4px 10px oklch(15% 0.04 230 / .45));">
|
||||||
|
<div style="font-size:44px;font-weight:900;color:oklch(97% 0.01 230);letter-spacing:.5px;white-space:nowrap;">{{ title }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="justify-self:end;display:flex;align-items:center;gap:8px;">
|
||||||
|
<sc-for list="{{ actions }}" as="action" hint-placeholder-count="0">
|
||||||
|
<button onClick="{{ action.onClick }}" style="{{ action.style }}">{{ action.label }}</button>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</x-dc>
|
||||||
|
<script type="text/x-dc" data-dc-script data-props="{"$preview": {"width": 1180, "height": 100}, "title": {"editor": "text", "default": "Musik Delphin", "tsType": "string"}, "mascot": {"editor": "text", "default": "dolphin-mascot.png", "tsType": "string"}, "linkHref": {"editor": "text", "default": "./Mein%20Zimmer.dc.html", "tsType": "string"}, "linkLabel": {"editor": "text", "default": "💡 Mein Zimmer", "tsType": "string"}, "actions": {"editor": null, "tsType": "Array<{label: string, style: string, onClick: () => void}>"}}"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
BIN
claude-design/Dolphin Beats Music Player.zip
Normal file
BIN
claude-design/Dolphin Beats Music Player.zip
Normal file
Binary file not shown.
920
claude-design/Dolphin Beats.dc.html
Normal file
920
claude-design/Dolphin Beats.dc.html
Normal file
@@ -0,0 +1,920 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<script src="./support.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<x-dc>
|
||||||
|
<helmet>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Nunito:wght@500;700;800;900&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
@keyframes bubbleRise { 0% { transform: translateY(0) scale(1); opacity: .55; } 100% { transform: translateY(-120vh) scale(1.3); opacity: 0; } }
|
||||||
|
@keyframes dolphinBob { 0%, 100% { transform: translateY(0) rotate(-2deg); } 50% { transform: translateY(-10px) rotate(2deg); } }
|
||||||
|
@keyframes dolphinSwim {
|
||||||
|
0% { transform: translate(-20vw, 10vh) rotate(-3deg) scaleX(1); }
|
||||||
|
15% { transform: translate(2vw, 2vh) rotate(4deg) scaleX(1); }
|
||||||
|
30% { transform: translate(24vw, 12vh) rotate(-3deg) scaleX(1); }
|
||||||
|
45% { transform: translate(46vw, 2vh) rotate(4deg) scaleX(1); }
|
||||||
|
60% { transform: translate(68vw, 12vh) rotate(-3deg) scaleX(1); }
|
||||||
|
70% { transform: translate(84vw, 6vh) rotate(0deg) scaleX(1); }
|
||||||
|
74% { transform: translate(84vw, 6vh) rotate(0deg) scaleX(-1); }
|
||||||
|
85% { transform: translate(52vw, 18vh) rotate(-3deg) scaleX(-1); }
|
||||||
|
95% { transform: translate(16vw, 6vh) rotate(3deg) scaleX(-1); }
|
||||||
|
100% { transform: translate(-20vw, 10vh) rotate(-3deg) scaleX(-1); }
|
||||||
|
}
|
||||||
|
body { margin: 0; font-family: 'Nunito', system-ui, sans-serif; }
|
||||||
|
a { color: oklch(70% 0.16 340); }
|
||||||
|
a:hover { color: oklch(55% 0.16 340); }
|
||||||
|
</style>
|
||||||
|
</helmet>
|
||||||
|
|
||||||
|
<div style="position:relative;width:100%;height:100vh;overflow:hidden;background:linear-gradient(180deg, oklch(55% 0.07 210) 0%, oklch(38% 0.06 210) 45%, oklch(20% 0.045 210) 100%);">
|
||||||
|
|
||||||
|
<div style="position:absolute;left:5%;bottom:-40px;width:14px;height:14px;border-radius:50%;background:oklch(90% 0.02 210 / .5);animation:bubbleRise 9s linear infinite;animation-delay:0s;"></div>
|
||||||
|
<div style="position:absolute;left:15%;bottom:-40px;width:22px;height:22px;border-radius:50%;background:oklch(90% 0.02 210 / .4);animation:bubbleRise 12s linear infinite;animation-delay:2s;"></div>
|
||||||
|
<div style="position:absolute;left:28%;bottom:-40px;width:10px;height:10px;border-radius:50%;background:oklch(90% 0.02 210 / .5);animation:bubbleRise 7s linear infinite;animation-delay:1s;"></div>
|
||||||
|
<div style="position:absolute;left:42%;bottom:-40px;width:18px;height:18px;border-radius:50%;background:oklch(90% 0.02 210 / .45);animation:bubbleRise 10s linear infinite;animation-delay:4s;"></div>
|
||||||
|
<div style="position:absolute;left:58%;bottom:-40px;width:12px;height:12px;border-radius:50%;background:oklch(90% 0.02 210 / .5);animation:bubbleRise 8s linear infinite;animation-delay:3s;"></div>
|
||||||
|
<div style="position:absolute;left:70%;bottom:-40px;width:26px;height:26px;border-radius:50%;background:oklch(90% 0.02 210 / .35);animation:bubbleRise 13s linear infinite;animation-delay:5s;"></div>
|
||||||
|
<div style="position:absolute;left:82%;bottom:-40px;width:16px;height:16px;border-radius:50%;background:oklch(90% 0.02 210 / .5);animation:bubbleRise 9.5s linear infinite;animation-delay:1.5s;"></div>
|
||||||
|
<div style="position:absolute;left:92%;bottom:-40px;width:10px;height:10px;border-radius:50%;background:oklch(90% 0.02 210 / .5);animation:bubbleRise 6.5s linear infinite;animation-delay:2.5s;"></div>
|
||||||
|
<div style="position:absolute;left:2%;bottom:-40px;width:8px;height:8px;border-radius:50%;background:oklch(90% 0.02 210 / .45);animation:bubbleRise 7.5s linear infinite;animation-delay:3.5s;"></div>
|
||||||
|
<div style="position:absolute;left:9%;bottom:-40px;width:18px;height:18px;border-radius:50%;background:oklch(90% 0.02 210 / .3);animation:bubbleRise 14s linear infinite;animation-delay:6s;"></div>
|
||||||
|
<div style="position:absolute;left:21%;bottom:-40px;width:12px;height:12px;border-radius:50%;background:oklch(90% 0.02 210 / .5);animation:bubbleRise 8.5s linear infinite;animation-delay:5.5s;"></div>
|
||||||
|
<div style="position:absolute;left:34%;bottom:-40px;width:24px;height:24px;border-radius:50%;background:oklch(90% 0.02 210 / .32);animation:bubbleRise 12.5s linear infinite;animation-delay:1.2s;"></div>
|
||||||
|
<div style="position:absolute;left:38%;bottom:-40px;width:9px;height:9px;border-radius:50%;background:oklch(90% 0.02 210 / .5);animation:bubbleRise 6.8s linear infinite;animation-delay:4.6s;"></div>
|
||||||
|
<div style="position:absolute;left:48%;bottom:-40px;width:14px;height:14px;border-radius:50%;background:oklch(90% 0.02 210 / .42);animation:bubbleRise 10.5s linear infinite;animation-delay:0.6s;"></div>
|
||||||
|
<div style="position:absolute;left:53%;bottom:-40px;width:20px;height:20px;border-radius:50%;background:oklch(90% 0.02 210 / .3);animation:bubbleRise 13.5s linear infinite;animation-delay:7s;"></div>
|
||||||
|
<div style="position:absolute;left:63%;bottom:-40px;width:8px;height:8px;border-radius:50%;background:oklch(90% 0.02 210 / .5);animation:bubbleRise 7.2s linear infinite;animation-delay:2.2s;"></div>
|
||||||
|
<div style="position:absolute;left:66%;bottom:-40px;width:16px;height:16px;border-radius:50%;background:oklch(90% 0.02 210 / .38);animation:bubbleRise 11.5s linear infinite;animation-delay:5.2s;"></div>
|
||||||
|
<div style="position:absolute;left:76%;bottom:-40px;width:11px;height:11px;border-radius:50%;background:oklch(90% 0.02 210 / .48);animation:bubbleRise 9.2s linear infinite;animation-delay:6.4s;"></div>
|
||||||
|
<div style="position:absolute;left:87%;bottom:-40px;width:22px;height:22px;border-radius:50%;background:oklch(90% 0.02 210 / .28);animation:bubbleRise 15s linear infinite;animation-delay:3.2s;"></div>
|
||||||
|
<div style="position:absolute;left:96%;bottom:-40px;width:14px;height:14px;border-radius:50%;background:oklch(90% 0.02 210 / .42);animation:bubbleRise 10.8s linear infinite;animation-delay:8s;"></div>
|
||||||
|
<div style="position:absolute;left:45%;bottom:-40px;width:7px;height:7px;border-radius:50%;background:oklch(90% 0.02 210 / .5);animation:bubbleRise 6.2s linear infinite;animation-delay:7.6s;"></div>
|
||||||
|
|
||||||
|
<sc-if value="{{ isBrowse }}" hint-placeholder-val="{{ true }}">
|
||||||
|
<div style="position:relative;z-index:1;height:100%;display:flex;flex-direction:column;">
|
||||||
|
|
||||||
|
<!-- header -->
|
||||||
|
<dc-import name="AppHeader" title="Musik Delphin" mascot="dolphin-mascot.png" link-href="./Mein%20Zimmer.dc.html" link-label="💡 Mein Zimmer" hint-size="100%,116px"></dc-import>
|
||||||
|
|
||||||
|
<div style="display:flex;align-items:center;justify-content:center;gap:10px;padding:4px 32px 6px;">
|
||||||
|
<button onClick="{{ setFilterAll }}" style="{{ filterAllStyle }}">Alles</button>
|
||||||
|
<button onClick="{{ setFilterMusic }}" style="{{ filterMusicStyle }}">🎵 Musik</button>
|
||||||
|
<button onClick="{{ setFilterBook }}" style="{{ filterBookStyle }}">📖 Hörbücher</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- search pill -->
|
||||||
|
<sc-if value="{{ showSearchBar }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<div style="margin:2px 32px 6px;display:flex;align-items:center;justify-content:center;gap:12px;">
|
||||||
|
<div style="background:oklch(97% 0.01 210);color:oklch(30% 0.04 210);font-weight:800;font-size:20px;padding:10px 20px;border-radius:999px;box-shadow:0 4px 14px oklch(15% 0.05 210 / .35);display:flex;align-items:center;gap:10px;">
|
||||||
|
<span style="font-size:14px;font-weight:800;background:oklch(30% 0.04 210);color:#fff;padding:4px 10px;border-radius:999px;">{{ modeLabel }}</span>
|
||||||
|
<span>{{ searchDisplay }}</span>
|
||||||
|
</div>
|
||||||
|
<div style="color:oklch(90% 0.02 210 / .85);font-weight:700;font-size:14px;">{{ resultCountLabel }}</div>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
|
||||||
|
<!-- results -->
|
||||||
|
<div ref="{{ scrollerRef }}" style="flex:1;overflow:auto;min-height:0;padding:14px 32px 250px;">
|
||||||
|
|
||||||
|
<!-- song hits -->
|
||||||
|
<sc-if value="{{ hasSongHits }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<div style="margin-bottom:30px;">
|
||||||
|
<div style="display:flex;align-items:baseline;justify-content:center;gap:10px;margin-bottom:12px;">
|
||||||
|
<div style="font-size:19px;font-weight:900;color:oklch(97% 0.01 210);">Titel</div>
|
||||||
|
<div style="font-size:13px;font-weight:700;color:oklch(88% 0.02 210 / .7);">{{ songHitLabel }}</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;flex-direction:column;gap:8px;max-width:760px;margin:0 auto;">
|
||||||
|
<sc-for list="{{ songHits }}" as="song" hint-placeholder-count="3">
|
||||||
|
<div onClick="{{ playSongHit }}" data-nav-index="{{ song.navIndex }}" data-album="{{ song.albumId }}" data-index="{{ song.index }}" style="{{ song.rowStyle }}">
|
||||||
|
<div style="{{ song.chipStyle }}"></div>
|
||||||
|
<div style="flex:1;min-width:0;">
|
||||||
|
<div style="font-size:16px;font-weight:800;color:oklch(97% 0.01 210);">{{ song.title }}</div>
|
||||||
|
<div style="font-size:12px;font-weight:700;color:oklch(88% 0.02 210 / .65);">{{ song.albumLine }}</div>
|
||||||
|
</div>
|
||||||
|
<div style="font:700 13px ui-monospace,Menlo,monospace;color:oklch(88% 0.02 210 / .6);">{{ song.duration }}</div>
|
||||||
|
</div>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
|
||||||
|
<!-- category grid -->
|
||||||
|
<sc-if value="{{ hasCategories }}" hint-placeholder-val="{{ true }}">
|
||||||
|
<div>
|
||||||
|
<div style="font-size:19px;font-weight:900;color:oklch(97% 0.01 210);margin-bottom:12px;text-align:center;">{{ categorySectionLabel }}</div>
|
||||||
|
<div ref="{{ gridRef }}" style="display:grid;grid-template-columns:repeat(auto-fill, minmax(180px, 1fr));gap:24px;max-width:1180px;margin:0 auto;">
|
||||||
|
<sc-for list="{{ categories }}" as="cat" hint-placeholder-count="8">
|
||||||
|
<div onClick="{{ openCategory }}" data-nav-index="{{ cat.navIndex }}" data-key="{{ cat.key }}" style="{{ cat.cardStyle }}">
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:4px;padding:8px;aspect-ratio:1;">
|
||||||
|
<sc-for list="{{ cat.tiles }}" as="tile" hint-placeholder-count="4">
|
||||||
|
<div style="{{ tile.style }}"></div>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
<div style="padding:4px 12px 14px;">
|
||||||
|
<div style="font-size:16px;font-weight:800;color:oklch(22% 0.03 210);line-height:1.2;">{{ cat.name }}</div>
|
||||||
|
<div style="font-size:12px;font-weight:800;color:oklch(40% 0.17 340);margin-top:5px;">{{ cat.countLabel }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
|
||||||
|
<!-- album grid -->
|
||||||
|
<sc-if value="{{ hasAlbumHits }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<div>
|
||||||
|
<sc-if value="{{ hasCategoryCrumb }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<div style="display:flex;align-items:center;justify-content:center;gap:12px;margin-bottom:14px;">
|
||||||
|
<button onClick="{{ clearCategory }}" style="border:none;cursor:pointer;font-family:inherit;font-size:14px;font-weight:800;padding:9px 16px;border-radius:999px;background:oklch(97% 0.01 210 / .95);color:oklch(30% 0.04 210);">← Alle</button>
|
||||||
|
<div style="font-size:22px;font-weight:900;color:oklch(97% 0.01 210);">{{ categoryCrumb }}</div>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
<sc-if value="{{ hasSearch }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<div style="font-size:19px;font-weight:900;color:oklch(97% 0.01 210);margin-bottom:12px;text-align:center;">{{ albumSectionLabel }}</div>
|
||||||
|
</sc-if>
|
||||||
|
<div ref="{{ gridRef }}" style="display:grid;grid-template-columns:repeat(auto-fill, minmax(180px, 1fr));gap:24px;max-width:1180px;margin:0 auto;justify-content:center;">
|
||||||
|
<sc-for list="{{ filteredAlbums }}" as="album" hint-placeholder-count="8">
|
||||||
|
<div onClick="{{ openAlbumCard }}" data-nav-index="{{ album.navIndex }}" data-id="{{ album.id }}" style="{{ album.cardStyle }}">
|
||||||
|
<div style="{{ album.coverStyle }}">
|
||||||
|
<span style="{{ album.coverLabelStyle }}">{{ album.coverLabel }}</span>
|
||||||
|
</div>
|
||||||
|
<div style="{{ album.bodyStyle }}">
|
||||||
|
<div style="font-size:16px;font-weight:800;color:oklch(22% 0.03 210);line-height:1.2;">{{ album.title }}</div>
|
||||||
|
<div style="font-size:13px;font-weight:700;color:oklch(30% 0.03 210);margin-top:2px;">{{ album.artist }}</div>
|
||||||
|
<div style="font-size:12px;font-weight:800;color:oklch(40% 0.17 340);margin-top:6px;">{{ album.songCountLabel }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
|
||||||
|
<sc-if value="{{ hasNoResults }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<div style="text-align:center;margin-top:60px;color:oklch(92% 0.02 210 / .8);">
|
||||||
|
<img src="dolphin-mascot.png" alt="" style="width:120px;height:120px;object-fit:contain;opacity:.9;">
|
||||||
|
<div style="font-size:22px;font-weight:800;margin-top:10px;">Nichts gefunden — probier andere Buchstaben!</div>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
|
||||||
|
<!-- play view -->
|
||||||
|
<sc-if value="{{ isPlay }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<div style="position:relative;z-index:1;height:100%;min-height:0;overflow:auto;">
|
||||||
|
<div style="min-height:100%;box-sizing:border-box;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:clamp(8px, 1.8vh, 26px);padding:clamp(14px, 3.5vh, 40px) 40px;">
|
||||||
|
|
||||||
|
<img src="dolphin-mascot.png" alt="Delfin mit Kopfhörern" style="position:absolute;top:14%;left:0;width:140px;height:140px;object-fit:contain;z-index:0;pointer-events:none;opacity:.92;animation:dolphinSwim 62s linear infinite;filter:drop-shadow(0 10px 26px oklch(10% 0.05 210 / .45));">
|
||||||
|
|
||||||
|
<div style="{{ bigCoverStyle }}">
|
||||||
|
<span style="font-family:ui-monospace,Menlo,monospace;font-size:13px;color:oklch(98% 0 0 / .85);letter-spacing:1px;">ALBUM COVER</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="text-align:center;max-width:760px;position:relative;z-index:1;flex:none;">
|
||||||
|
<div style="font-size:clamp(28px, 4.6vh, 46px);font-weight:900;color:oklch(97% 0.01 210);line-height:1.1;text-wrap:pretty;">{{ currentSongTitle }}</div>
|
||||||
|
<div style="font-size:20px;font-weight:700;color:oklch(88% 0.02 210 / .8);margin-top:8px;">{{ currentAlbumLine }}</div>
|
||||||
|
<div style="font-size:15px;font-weight:800;color:oklch(78% 0.14 340);margin-top:6px;">{{ trackCounter }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="width:min(680px, 90%);position:relative;z-index:1;">
|
||||||
|
<div style="height:14px;border-radius:999px;background:oklch(30% 0.03 210 / .55);overflow:hidden;">
|
||||||
|
<div style="height:100%;border-radius:999px;background:oklch(70% 0.16 340);{{ progressStyle }}"></div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;margin-top:8px;font-size:14px;font-weight:800;color:oklch(88% 0.02 210 / .8);">
|
||||||
|
<span style="font-family:ui-monospace,Menlo,monospace;">{{ trackRemainingLabel }}</span>
|
||||||
|
<span>{{ albumRemainingLabel }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex;align-items:center;gap:22px;position:relative;z-index:1;flex:none;">
|
||||||
|
<button onClick="{{ prevTrack }}" style="width:clamp(50px, 9vh, 76px);height:clamp(50px, 9vh, 76px);border-radius:999px;border:none;padding:0;background:oklch(97% 0.01 210 / .16);cursor:pointer;display:flex;align-items:center;justify-content:center;"><span style="display:flex;align-items:center;gap:3px;"><span style="width:6px;height:26px;border-radius:2px;background:#fff;"></span><span style="width:22px;height:26px;background:#fff;clip-path:polygon(100% 0%, 100% 100%, 0% 50%);"></span></span></button>
|
||||||
|
<button onClick="{{ togglePlay }}" style="width:clamp(68px, 12vh, 110px);height:clamp(68px, 12vh, 110px);border-radius:999px;border:none;padding:0;background:oklch(70% 0.16 340);cursor:pointer;display:flex;align-items:center;justify-content:center;box-shadow:0 10px 30px oklch(70% 0.16 340 / .5);"><sc-if value="{{ isPlaying }}" hint-placeholder-val="{{ false }}"><span style="display:flex;gap:9px;"><span style="width:10px;height:clamp(26px, 4.6vh, 42px);border-radius:3px;background:#fff;"></span><span style="width:10px;height:clamp(26px, 4.6vh, 42px);border-radius:3px;background:#fff;"></span></span></sc-if><sc-if value="{{ isPaused }}" hint-placeholder-val="{{ true }}"><span style="width:clamp(24px, 4.2vh, 38px);height:clamp(26px, 4.6vh, 42px);background:#fff;clip-path:polygon(6% 0%, 100% 50%, 6% 100%);"></span></sc-if></button>
|
||||||
|
<button onClick="{{ nextTrack }}" style="width:clamp(50px, 9vh, 76px);height:clamp(50px, 9vh, 76px);border-radius:999px;border:none;padding:0;background:oklch(97% 0.01 210 / .16);cursor:pointer;display:flex;align-items:center;justify-content:center;"><span style="display:flex;align-items:center;gap:3px;"><span style="width:22px;height:26px;background:#fff;clip-path:polygon(0% 0%, 0% 100%, 100% 50%);"></span><span style="width:6px;height:26px;border-radius:2px;background:#fff;"></span></span></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex;align-items:center;gap:12px;position:relative;z-index:1;">
|
||||||
|
<button onClick="{{ muteToggle }}" title="Stumm" style="border:none;background:none;padding:0;cursor:pointer;font-size:24px;line-height:1;">🔊</button>
|
||||||
|
<div style="display:flex;gap:5px;">
|
||||||
|
<sc-for list="{{ volumeBars }}" as="bar" hint-placeholder-count="5">
|
||||||
|
<button onClick="{{ setVolume }}" data-level="{{ bar.level }}" title="Lautstärke" style="width:18px;height:clamp(26px, 4.6vh, 44px);border-radius:5px;{{ bar.style }}"></button>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button onClick="{{ goBrowse }}" style="position:absolute;top:28px;left:32px;border:none;cursor:pointer;background:oklch(97% 0.01 210 / .95);border-radius:999px;padding:11px 20px;font-family:inherit;font-size:14px;font-weight:800;color:oklch(30% 0.04 210);display:flex;align-items:center;gap:8px;box-shadow:0 8px 24px oklch(15% 0.05 210 / .4);">
|
||||||
|
<span style="font:800 13px ui-monospace,monospace;background:oklch(30% 0.04 210);color:#fff;padding:2px 8px;border-radius:6px;">/</span>
|
||||||
|
Zurück zur Suche
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
|
||||||
|
<!-- album detail panel -->
|
||||||
|
<sc-if value="{{ hasOpenAlbum }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<div onClick="{{ closeAlbum }}" style="position:absolute;inset:0;z-index:4;background:oklch(15% 0.03 210 / .6);display:flex;align-items:center;justify-content:center;padding:40px;">
|
||||||
|
<div onClick="{{ stopClick }}" style="background:oklch(96% 0.012 210);border-radius:26px;padding:26px;width:640px;max-width:100%;max-height:100%;overflow:auto;box-shadow:0 24px 60px oklch(10% 0.04 210 / .55);">
|
||||||
|
<div style="display:flex;gap:20px;align-items:flex-start;">
|
||||||
|
<div style="{{ openAlbumCoverStyle }}">
|
||||||
|
<span style="font-family:ui-monospace,Menlo,monospace;font-size:11px;color:oklch(98% 0 0 / .8);">ALBUM COVER</span>
|
||||||
|
</div>
|
||||||
|
<div style="flex:1;min-width:0;">
|
||||||
|
<div style="font-size:26px;font-weight:900;color:oklch(22% 0.03 210);line-height:1.15;">{{ openAlbumTitle }}</div>
|
||||||
|
<div style="font-size:15px;font-weight:700;color:oklch(45% 0.03 210 / .85);margin-top:2px;">{{ openAlbumArtist }}</div>
|
||||||
|
<div style="font-size:13px;font-weight:700;color:oklch(50% 0.03 210 / .7);margin-top:2px;">{{ openAlbumMeta }}</div>
|
||||||
|
<button onClick="{{ playOpenAlbum }}" style="margin-top:14px;border:none;cursor:pointer;background:oklch(70% 0.16 340);color:#fff;font-family:inherit;font-size:16px;font-weight:800;padding:12px 22px;border-radius:999px;display:flex;align-items:center;gap:10px;box-shadow:0 4px 14px oklch(70% 0.16 340 / .45);">
|
||||||
|
<span style="width:14px;height:16px;background:#fff;clip-path:polygon(6% 0%, 100% 50%, 6% 100%);"></span>
|
||||||
|
Alle Songs abspielen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button onClick="{{ closeAlbum }}" style="border:none;cursor:pointer;background:oklch(88% 0.02 210);color:oklch(30% 0.04 210);font-family:inherit;font-size:18px;font-weight:900;width:40px;height:40px;border-radius:999px;flex:none;">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex;flex-direction:column;gap:6px;margin-top:22px;">
|
||||||
|
<sc-for list="{{ openAlbumSongs }}" as="song" hint-placeholder-count="5">
|
||||||
|
<div onClick="{{ playSongHit }}" data-album="{{ song.albumId }}" data-index="{{ song.index }}" style="{{ song.rowStyle }}">
|
||||||
|
<div style="{{ song.numStyle }}">{{ song.number }}</div>
|
||||||
|
<div style="flex:1;min-width:0;font-size:16px;font-weight:800;{{ song.titleStyle }}">{{ song.title }}</div>
|
||||||
|
<div style="font:700 13px ui-monospace,Menlo,monospace;color:oklch(45% 0.03 210 / .7);">{{ song.duration }}</div>
|
||||||
|
</div>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
|
||||||
|
<!-- shortcut help toggle + overlay -->
|
||||||
|
<button onClick="{{ toggleHelp }}" title="Zaubertasten (F1)" style="position:absolute;top:18px;right:24px;z-index:3;width:44px;height:44px;background:oklch(97% 0.01 210 / .95);border:none;border-radius:999px;box-shadow:0 6px 18px oklch(15% 0.05 210 / .4);font-size:20px;font-weight:900;color:oklch(30% 0.04 210);cursor:pointer;display:flex;align-items:center;justify-content:center;">?</button>
|
||||||
|
|
||||||
|
<sc-if value="{{ showHelp }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<div onClick="{{ toggleHelp }}" style="position:absolute;inset:0;background:oklch(15% 0.03 210 / .55);z-index:5;display:flex;align-items:center;justify-content:center;">
|
||||||
|
<div style="background:oklch(97% 0.01 210);border-radius:24px;padding:26px 30px;width:300px;box-shadow:0 20px 50px oklch(10% 0.04 210 / .5);">
|
||||||
|
<div style="font-size:17px;font-weight:800;color:oklch(30% 0.04 210);margin-bottom:14px;letter-spacing:.3px;">⌨️ Zaubertasten</div>
|
||||||
|
<div style="display:flex;flex-direction:column;gap:10px;">
|
||||||
|
<div style="display:flex;align-items:center;gap:10px;">
|
||||||
|
<div style="font:800 12px ui-monospace,monospace;background:oklch(30% 0.04 210);color:#fff;padding:5px 12px;border-radius:8px;min-width:56px;text-align:center;">LEER</div>
|
||||||
|
<div style="font-size:14px;font-weight:700;color:oklch(35% 0.03 210);">Play / Pause</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;align-items:center;gap:10px;">
|
||||||
|
<div style="display:flex;gap:4px;">
|
||||||
|
<div style="font:800 12px ui-monospace,monospace;background:oklch(30% 0.04 210);color:#fff;padding:5px 8px;border-radius:8px;">CTRL+H</div>
|
||||||
|
<div style="font:800 12px ui-monospace,monospace;background:oklch(30% 0.04 210);color:#fff;padding:5px 8px;border-radius:8px;">CTRL+L</div>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:14px;font-weight:700;color:oklch(35% 0.03 210);">Song zurück / vor</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;align-items:center;gap:10px;">
|
||||||
|
<div style="display:flex;gap:4px;">
|
||||||
|
<div style="font:800 12px ui-monospace,monospace;background:oklch(30% 0.04 210);color:#fff;padding:5px 8px;border-radius:8px;">CTRL+J</div>
|
||||||
|
<div style="font:800 12px ui-monospace,monospace;background:oklch(30% 0.04 210);color:#fff;padding:5px 8px;border-radius:8px;">CTRL+K</div>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:14px;font-weight:700;color:oklch(35% 0.03 210);">Leiser / lauter</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;align-items:center;gap:10px;">
|
||||||
|
<div style="font:800 12px ui-monospace,monospace;background:oklch(30% 0.04 210);color:#fff;padding:5px 12px;border-radius:8px;min-width:56px;text-align:center;">A-Z</div>
|
||||||
|
<div style="font-size:14px;font-weight:700;color:oklch(35% 0.03 210);">Album oder Hörbuch suchen</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;align-items:center;gap:10px;">
|
||||||
|
<div style="font:800 12px ui-monospace,monospace;background:oklch(30% 0.04 210);color:#fff;padding:5px 12px;border-radius:8px;min-width:56px;text-align:center;">?</div>
|
||||||
|
<div style="font-size:14px;font-weight:700;color:oklch(35% 0.03 210);">Einzelne Titel suchen</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;align-items:center;gap:10px;">
|
||||||
|
<div style="font:800 12px ui-monospace,monospace;background:oklch(30% 0.04 210);color:#fff;padding:5px 12px;border-radius:8px;min-width:56px;text-align:center;">F1</div>
|
||||||
|
<div style="font-size:14px;font-weight:700;color:oklch(35% 0.03 210);">Diese Hilfe</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;align-items:center;gap:10px;">
|
||||||
|
<div style="font:800 12px ui-monospace,monospace;background:oklch(30% 0.04 210);color:#fff;padding:5px 12px;border-radius:8px;min-width:56px;text-align:center;">TAB</div>
|
||||||
|
<div style="font-size:14px;font-weight:700;color:oklch(35% 0.03 210);">Musik / Hörbücher / alles</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;align-items:center;gap:10px;">
|
||||||
|
<div style="font:800 12px ui-monospace,monospace;background:oklch(30% 0.04 210);color:#fff;padding:5px 12px;border-radius:8px;min-width:56px;text-align:center;">← ↑ ↓ →</div>
|
||||||
|
<div style="font-size:14px;font-weight:700;color:oklch(35% 0.03 210);">Auswahl bewegen</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;align-items:center;gap:10px;">
|
||||||
|
<div style="font:800 12px ui-monospace,monospace;background:oklch(30% 0.04 210);color:#fff;padding:5px 12px;border-radius:8px;min-width:56px;text-align:center;">ENTER</div>
|
||||||
|
<div style="font-size:14px;font-weight:700;color:oklch(35% 0.03 210);">Auswahl abspielen</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;align-items:center;gap:10px;">
|
||||||
|
<div style="font:800 12px ui-monospace,monospace;background:oklch(30% 0.04 210);color:#fff;padding:5px 12px;border-radius:8px;min-width:56px;text-align:center;">ESC</div>
|
||||||
|
<div style="font-size:14px;font-weight:700;color:oklch(35% 0.03 210);">Schließen / Suche löschen</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:16px;font-size:12px;font-weight:600;color:oklch(50% 0.03 210 / .8);text-align:center;">Tippe irgendwohin, um zu schließen</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
|
||||||
|
<!-- player bar -->
|
||||||
|
<sc-if value="{{ isBrowse }}" hint-placeholder-val="{{ true }}">
|
||||||
|
<div style="position:absolute;left:0;right:0;bottom:0;z-index:2;background:oklch(18% 0.05 210 / .96);backdrop-filter:blur(6px);padding:16px clamp(12px, 3vw, 32px);box-sizing:border-box;display:flex;align-items:center;justify-content:center;gap:clamp(10px, 1.6vw, 22px);border-top:3px solid oklch(70% 0.16 340 / .5);">
|
||||||
|
|
||||||
|
<img src="dolphin-mascot.png" alt="Delfin mit Kopfhörern" style="width:clamp(56px, 7vw, 84px);height:clamp(56px, 7vw, 84px);object-fit:contain;flex:0 1 auto;min-width:0;animation:{{ dolphinBobAnim }};filter:drop-shadow(0 4px 12px oklch(10% 0.05 210 / .5));">
|
||||||
|
|
||||||
|
<div style="width:52px;height:52px;border-radius:12px;flex:none;{{ playerCoverStyle }}"></div>
|
||||||
|
|
||||||
|
<div style="flex:0 1 220px;min-width:0;overflow:hidden;">
|
||||||
|
<div style="font-size:16px;font-weight:800;color:oklch(97% 0.01 210);">{{ currentSongTitle }}</div>
|
||||||
|
<div style="font-size:13px;font-weight:600;color:oklch(85% 0.02 210 / .75);">{{ currentAlbumLine }}</div>
|
||||||
|
<div style="font-size:12px;font-weight:800;color:oklch(75% 0.14 340);margin-top:1px;">{{ trackCounter }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="flex:1 1 auto;max-width:360px;min-width:90px;">
|
||||||
|
<div style="height:10px;border-radius:999px;background:oklch(30% 0.03 210 / .6);overflow:hidden;">
|
||||||
|
<div style="height:100%;border-radius:999px;background:oklch(70% 0.16 340);{{ progressStyle }}"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex;align-items:center;gap:10px;">
|
||||||
|
<button onClick="{{ prevTrack }}" style="width:48px;height:48px;border-radius:999px;border:none;padding:0;background:oklch(30% 0.04 210);color:#fff;line-height:1;cursor:pointer;display:flex;align-items:center;justify-content:center;"><span style="display:flex;align-items:center;gap:2px;"><span style="width:4px;height:16px;border-radius:1px;background:#fff;"></span><span style="width:14px;height:16px;background:#fff;clip-path:polygon(100% 0%, 100% 100%, 0% 50%);"></span></span></button>
|
||||||
|
<button onClick="{{ togglePlay }}" style="width:60px;height:60px;border-radius:999px;border:none;padding:0;background:oklch(70% 0.16 340);color:#fff;line-height:1;cursor:pointer;display:flex;align-items:center;justify-content:center;box-shadow:0 4px 14px oklch(70% 0.16 340 / .5);"><sc-if value="{{ isPlaying }}" hint-placeholder-val="{{ false }}"><span style="display:flex;gap:5px;"><span style="width:6px;height:22px;border-radius:2px;background:#fff;"></span><span style="width:6px;height:22px;border-radius:2px;background:#fff;"></span></span></sc-if><sc-if value="{{ isPaused }}" hint-placeholder-val="{{ true }}"><span style="width:20px;height:22px;background:#fff;clip-path:polygon(6% 0%, 100% 50%, 6% 100%);"></span></sc-if></button>
|
||||||
|
<button onClick="{{ nextTrack }}" style="width:48px;height:48px;border-radius:999px;border:none;padding:0;background:oklch(30% 0.04 210);color:#fff;line-height:1;cursor:pointer;display:flex;align-items:center;justify-content:center;"><span style="display:flex;align-items:center;gap:2px;"><span style="width:14px;height:16px;background:#fff;clip-path:polygon(0% 0%, 0% 100%, 100% 50%);"></span><span style="width:4px;height:16px;border-radius:1px;background:#fff;"></span></span></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex;align-items:center;gap:8px;flex:0 0 auto;">
|
||||||
|
<button onClick="{{ muteToggle }}" title="Stumm" style="border:none;background:none;padding:0;cursor:pointer;font-size:18px;line-height:1;">🔊</button>
|
||||||
|
<div style="display:flex;gap:3px;">
|
||||||
|
<sc-for list="{{ volumeBars }}" as="bar" hint-placeholder-count="5">
|
||||||
|
<button onClick="{{ setVolume }}" data-level="{{ bar.level }}" title="Lautstärke" style="width:11px;height:30px;border-radius:4px;{{ bar.style }}"></button>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</x-dc>
|
||||||
|
<script type="text/x-dc" data-dc-script data-props="{"$preview": {"width": 1280, "height": 800}}">
|
||||||
|
const ALBUMS = [
|
||||||
|
{ id: 1, title: "Ocean Adventure", artist: "The Coral Crew", hue: 180, songs: [["Set Sail", "2:41"], ["Wave Rider", "3:05"], ["Lighthouse", "2:28"], ["Salty Wind", "3:22"], ["Safe Harbour", "2:55"]] },
|
||||||
|
{ id: 2, title: "Splash Party", artist: "Wavy Tunes", hue: 200, songs: [["Cannonball", "2:12"], ["Pool Noodle Boogie", "2:47"], ["Big Splash", "3:01"], ["Towel Dance", "2:33"]] },
|
||||||
|
{ id: 3, title: "Deep Blue Dreams", artist: "Finn & Friends", hue: 220, songs: [["Sinking Slow", "3:48"], ["Blue Lullaby", "4:12"], ["Silent Depths", "3:30"], ["Bioluminescence", "4:02"], ["Morning Surface", "3:14"]] },
|
||||||
|
{ id: 4, title: "Bubble Trouble", artist: "The Coral Crew", hue: 250, songs: [["Bubble Machine", "2:20"], ["Pop Pop Pop", "1:58"], ["Fizzy Water", "2:44"], ["Upside Down", "3:06"]] },
|
||||||
|
{ id: 5, title: "Starfish Disco", artist: "Reef Riders", hue: 20, songs: [["Five Arms Up", "3:18"], ["Glitter Sand", "2:52"], ["Disco Tide", "3:40"], ["Neon Anemone", "3:02"], ["Last Dance", "4:05"]] },
|
||||||
|
{ id: 6, title: "Whale Song", artist: "Finn & Friends", hue: 240, songs: [["Long Call", "5:12"], ["Migration", "4:36"], ["Under Ice", "3:58"], ["Deep Echo", "4:20"]] },
|
||||||
|
{ id: 7, title: "Tidal Groove", artist: "Wavy Tunes", hue: 195, songs: [["High Tide", "3:11"], ["Low Tide", "2:49"], ["Moon Pull", "3:35"], ["Rock Pool", "2:58"], ["Undertow", "3:24"]] },
|
||||||
|
{ id: 8, title: "Coral Reef Jam", artist: "Reef Riders", hue: 15, songs: [["Clownfish Shuffle", "2:36"], ["Parrotfish Parade", "3:08"], ["Coral Bloom", "3:44"], ["Reef Party", "2:50"]] },
|
||||||
|
{ id: 9, title: "Sandy Shores", artist: "Beach Buddies", hue: 40, songs: [["Barefoot", "2:26"], ["Sandcastle", "3:02"], ["Seagull Song", "2:14"], ["Sunset Picnic", "3:30"], ["Beach Fire", "3:52"]] },
|
||||||
|
{ id: 10, title: "Moonlit Lagoon", artist: "Finn & Friends", hue: 260, songs: [["Silver Water", "4:08"], ["Night Swim", "3:36"], ["Sleepy Palms", "3:12"], ["Star Reflection", "4:24"]] },
|
||||||
|
{ id: 11, title: "Jellyfish Jive", artist: "The Coral Crew", hue: 300, songs: [["Wobble Wobble", "2:18"], ["Glow Parade", "2:56"], ["Tentacle Twist", "3:14"], ["Drifting", "3:40"], ["Sting Free", "2:44"]] },
|
||||||
|
{ id: 12, title: "Seahorse Shuffle", artist: "Beach Buddies", hue: 30, songs: [["Tiny Trot", "2:22"], ["Seagrass Swing", "2:58"], ["Curly Tail", "3:16"], ["Slow Parade", "3:04"]] },
|
||||||
|
{ id: 13, title: "Current Rhythm", artist: "Wavy Tunes", hue: 205, songs: [["Fast Current", "3:26"], ["Drift Beat", "3:02"], ["Whirlpool", "3:48"], ["Cross Stream", "2:52"], ["Open Water", "4:10"]] },
|
||||||
|
{ id: 14, title: "Anchor Down", artist: "Reef Riders", hue: 10, songs: [["Chain Rattle", "2:40"], ["Harbour Lights", "3:20"], ["Old Rope", "3:04"], ["Anchor Down", "3:52"]] },
|
||||||
|
{ id: 15, type: "book", series: "Wal Wilma", title: "Der kleine Wal Wilma", artist: "Erzählt von Lena Fisch", hue: 230, songs: [["Kapitel 1 — Wilma wacht auf", "8:12"], ["Kapitel 2 — Die große Welle", "9:04"], ["Kapitel 3 — Ein Freund im Dunkeln", "7:48"], ["Kapitel 4 — Heimweg", "8:36"]] },
|
||||||
|
{ id: 16, type: "book", series: "Käpt'n Krabbe", title: "Käpt'n Krabbe und der Schatz", artist: "Erzählt von Jonas Meer", hue: 25, songs: [["Kapitel 1 — Die alte Karte", "10:20"], ["Kapitel 2 — Sturm vor Kap Kork", "11:02"], ["Kapitel 3 — Die Höhle", "9:44"], ["Kapitel 4 — Gold und Muscheln", "10:12"], ["Kapitel 5 — Zurück im Hafen", "8:58"]] },
|
||||||
|
{ id: 17, type: "book", series: "Nele", title: "Nele taucht ab", artist: "Erzählt von Mira Sand", hue: 190, songs: [["Kapitel 1 — Die neue Taucherbrille", "7:30"], ["Kapitel 2 — Unten am Riff", "8:14"], ["Kapitel 3 — Das leise Lied", "9:06"], ["Kapitel 4 — Wieder oben", "6:52"]] },
|
||||||
|
{ id: 18, type: "book", series: "Muschelbande", title: "Die Muschelbande", artist: "Erzählt von Tom Anker", hue: 300, songs: [["Kapitel 1 — Drei Freunde", "8:48"], ["Kapitel 2 — Der Plan", "9:22"], ["Kapitel 3 — Verfolgt", "10:04"], ["Kapitel 4 — Das Versteck", "8:16"], ["Kapitel 5 — Alles gut", "7:40"]] },
|
||||||
|
{ id: 19, type: "book", series: "Riff-Geschichten", title: "Gute-Nacht-Geschichten vom Riff", artist: "Erzählt von Lena Fisch", hue: 265, songs: [["Der schläfrige Seestern", "6:40"], ["Wo die Quallen leuchten", "7:12"], ["Das müde Seepferdchen", "6:28"], ["Lied der Strömung", "8:02"]] },
|
||||||
|
{ id: 20, type: "book", series: "Wal Wilma", title: "Wal Wilma im Eismeer", artist: "Erzählt von Lena Fisch", hue: 245, songs: [["Kapitel 1 — Kaltes Wasser", "9:10"], ["Kapitel 2 — Das Eisloch", "8:26"], ["Kapitel 3 — Der Pinguin", "9:44"], ["Kapitel 4 — Nach Hause", "8:02"]] },
|
||||||
|
{ id: 21, type: "book", series: "Käpt'n Krabbe", title: "Käpt'n Krabbe segelt los", artist: "Erzählt von Jonas Meer", hue: 35, songs: [["Kapitel 1 — Neue Segel", "9:52"], ["Kapitel 2 — Der blinde Passagier", "10:36"], ["Kapitel 3 — Nebelbank", "9:18"], ["Kapitel 4 — Land in Sicht", "8:44"]] },
|
||||||
|
{ id: 22, type: "book", series: "Nele", title: "Nele und der Seestern", artist: "Erzählt von Mira Sand", hue: 200, songs: [["Kapitel 1 — Fünf Arme", "7:22"], ["Kapitel 2 — Der Gezeitenteich", "8:06"], ["Kapitel 3 — Zurück ins Meer", "7:54"]] },
|
||||||
|
];
|
||||||
|
const TRACK_DURATION_MS = 24000;
|
||||||
|
|
||||||
|
class Component extends DCLogic {
|
||||||
|
state = {
|
||||||
|
search: "",
|
||||||
|
mode: "albums",
|
||||||
|
category: null,
|
||||||
|
filter: "all",
|
||||||
|
albumId: null,
|
||||||
|
trackIndex: 0,
|
||||||
|
openAlbumId: null,
|
||||||
|
view: "browse",
|
||||||
|
selIndex: 0,
|
||||||
|
cols: 4,
|
||||||
|
isPlaying: false,
|
||||||
|
volume: 70,
|
||||||
|
progress: 0,
|
||||||
|
showHelp: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
playPop(freq) {
|
||||||
|
try {
|
||||||
|
if (!this._ac) this._ac = new (window.AudioContext || window.webkitAudioContext)();
|
||||||
|
const ac = this._ac;
|
||||||
|
const osc = ac.createOscillator();
|
||||||
|
const gain = ac.createGain();
|
||||||
|
osc.type = "sine";
|
||||||
|
osc.frequency.setValueAtTime(freq, ac.currentTime);
|
||||||
|
osc.frequency.exponentialRampToValueAtTime(freq * 1.8, ac.currentTime + 0.08);
|
||||||
|
gain.gain.setValueAtTime(0.15, ac.currentTime);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.001, ac.currentTime + 0.15);
|
||||||
|
osc.connect(gain).connect(ac.destination);
|
||||||
|
osc.start();
|
||||||
|
osc.stop(ac.currentTime + 0.16);
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
this._onKey = (e) => this.handleKey(e);
|
||||||
|
window.addEventListener("keydown", this._onKey);
|
||||||
|
this._onResize = () => this.measureCols();
|
||||||
|
window.addEventListener("resize", this._onResize);
|
||||||
|
this.measureCols();
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
window.removeEventListener("keydown", this._onKey);
|
||||||
|
window.removeEventListener("resize", this._onResize);
|
||||||
|
if (this._timer) clearInterval(this._timer);
|
||||||
|
}
|
||||||
|
|
||||||
|
navCounts() {
|
||||||
|
return { songs: this.songMatches().length, albums: this.albumMatches().length + this.categoryMatches().length };
|
||||||
|
}
|
||||||
|
|
||||||
|
moveSel(dx, dy) {
|
||||||
|
const { songs, albums } = this.navCounts();
|
||||||
|
const total = songs + albums;
|
||||||
|
if (!total) return;
|
||||||
|
const cols = Math.max(1, this.state.cols);
|
||||||
|
this.setState((s) => {
|
||||||
|
let i = Math.min(s.selIndex, total - 1);
|
||||||
|
if (dx) i += dx;
|
||||||
|
if (dy) {
|
||||||
|
if (i < songs) i += dy;
|
||||||
|
else i += dy * cols;
|
||||||
|
}
|
||||||
|
if (i < 0) i = 0;
|
||||||
|
if (i > total - 1) i = total - 1;
|
||||||
|
return { selIndex: i };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
playSelection() {
|
||||||
|
const songs = this.songMatches();
|
||||||
|
const albums = this.albumMatches();
|
||||||
|
const cats = this.categoryMatches();
|
||||||
|
const i = Math.min(this.state.selIndex, songs.length + albums.length + cats.length - 1);
|
||||||
|
if (i < 0) return;
|
||||||
|
if (i < songs.length) return this.playSong(songs[i].album.id, songs[i].index);
|
||||||
|
if (cats.length) {
|
||||||
|
const c = cats[i - songs.length];
|
||||||
|
if (c) this.setState({ category: c.key, selIndex: 0 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const a = albums[i - songs.length];
|
||||||
|
if (a) this.playSong(a.id, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate() {
|
||||||
|
const sc = this._scroller;
|
||||||
|
if (!sc) return;
|
||||||
|
if (this._lastSel === this.state.selIndex) return;
|
||||||
|
this._lastSel = this.state.selIndex;
|
||||||
|
const el = sc.querySelector('[data-nav-index="' + this.state.selIndex + '"]');
|
||||||
|
if (!el) return;
|
||||||
|
const top = el.offsetTop - sc.offsetTop;
|
||||||
|
const bottom = top + el.offsetHeight;
|
||||||
|
const pad = 24;
|
||||||
|
if (top - pad < sc.scrollTop) sc.scrollTop = Math.max(0, top - pad);
|
||||||
|
else if (bottom + pad > sc.scrollTop + sc.clientHeight) sc.scrollTop = bottom + pad - sc.clientHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
measureCols() {
|
||||||
|
const g = this._grid;
|
||||||
|
if (!g) return;
|
||||||
|
const n = getComputedStyle(g).gridTemplateColumns.split(" ").filter(Boolean).length;
|
||||||
|
if (n && n !== this.state.cols) this.setState({ cols: n });
|
||||||
|
}
|
||||||
|
|
||||||
|
normalize(s) {
|
||||||
|
return (s || "").toLowerCase().replace(/[^a-z0-9]/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
setFilter(f) {
|
||||||
|
this.playPop(380);
|
||||||
|
this.setState({ filter: f, selIndex: 0, view: "browse", category: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
cycleFilter() {
|
||||||
|
const order = ["all", "music", "book"];
|
||||||
|
this.setFilter(order[(order.indexOf(this.state.filter) + 1) % order.length]);
|
||||||
|
}
|
||||||
|
|
||||||
|
secs(d) {
|
||||||
|
const p = (d || "0:00").split(":");
|
||||||
|
return Number(p[0]) * 60 + Number(p[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
clock(total) {
|
||||||
|
const t = Math.max(0, Math.round(total));
|
||||||
|
const h = Math.floor(t / 3600);
|
||||||
|
const m = Math.floor((t % 3600) / 60);
|
||||||
|
const s = t % 60;
|
||||||
|
const pad = (n) => String(n).padStart(2, "0");
|
||||||
|
return h ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
remainingSecs(album) {
|
||||||
|
if (!album) return 0;
|
||||||
|
const i = this.state.trackIndex;
|
||||||
|
const cur = this.secs(album.songs[i] ? album.songs[i][1] : "0:00");
|
||||||
|
let rest = cur * (1 - this.state.progress / 100);
|
||||||
|
for (let k = i + 1; k < album.songs.length; k++) rest += this.secs(album.songs[k][1]);
|
||||||
|
return rest;
|
||||||
|
}
|
||||||
|
|
||||||
|
isBook(a) {
|
||||||
|
return a.type === "book";
|
||||||
|
}
|
||||||
|
|
||||||
|
categoryOf(a) {
|
||||||
|
return this.isBook(a) ? a.series || a.artist : a.artist;
|
||||||
|
}
|
||||||
|
|
||||||
|
listMode() {
|
||||||
|
if (this.state.mode === "tracks") return "tracks";
|
||||||
|
if (this.state.search || this.state.category) return "albums";
|
||||||
|
return "categories";
|
||||||
|
}
|
||||||
|
|
||||||
|
categoryMatches() {
|
||||||
|
if (this.listMode() !== "categories") return [];
|
||||||
|
const map = new Map();
|
||||||
|
this.pool().forEach((a) => {
|
||||||
|
const key = this.categoryOf(a);
|
||||||
|
if (!map.has(key)) map.set(key, { key, name: key, albums: [] });
|
||||||
|
map.get(key).albums.push(a);
|
||||||
|
});
|
||||||
|
return Array.from(map.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
navTotal() {
|
||||||
|
return this.songMatches().length + this.albumMatches().length + this.categoryMatches().length;
|
||||||
|
}
|
||||||
|
|
||||||
|
inFilter(a) {
|
||||||
|
const f = this.state.filter;
|
||||||
|
if (f === "book") return this.isBook(a);
|
||||||
|
if (f === "music") return !this.isBook(a);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
pool() {
|
||||||
|
return ALBUMS.filter((a) => this.inFilter(a));
|
||||||
|
}
|
||||||
|
|
||||||
|
albumMatches() {
|
||||||
|
if (this.state.mode === "tracks") return [];
|
||||||
|
const q = this.normalize(this.state.search);
|
||||||
|
let pool = this.pool();
|
||||||
|
if (!q && !this.state.category) return [];
|
||||||
|
if (this.state.category) pool = pool.filter((a) => this.categoryOf(a) === this.state.category);
|
||||||
|
if (!q) return pool;
|
||||||
|
return pool.filter((a) => this.normalize(a.title + a.artist).includes(q));
|
||||||
|
}
|
||||||
|
|
||||||
|
songMatches() {
|
||||||
|
if (this.state.mode !== "tracks") return [];
|
||||||
|
const q = this.normalize(this.state.search);
|
||||||
|
const hits = [];
|
||||||
|
this.pool().forEach((a) => {
|
||||||
|
a.songs.forEach((s, i) => {
|
||||||
|
if (!q || this.normalize(s[0]).includes(q)) hits.push({ album: a, index: i, title: s[0], duration: s[1] });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return hits.slice(0, 40);
|
||||||
|
}
|
||||||
|
|
||||||
|
playSong(albumId, index) {
|
||||||
|
this.playPop(300);
|
||||||
|
this.setState({ albumId, trackIndex: index, isPlaying: true, progress: 0, view: "play", openAlbumId: null });
|
||||||
|
this.startTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
startTimer() {
|
||||||
|
if (this._timer) clearInterval(this._timer);
|
||||||
|
this._timer = setInterval(() => {
|
||||||
|
this.setState((s) => {
|
||||||
|
if (!s.isPlaying) return {};
|
||||||
|
const next = s.progress + 100 / (TRACK_DURATION_MS / 200);
|
||||||
|
if (next >= 100) {
|
||||||
|
this.advance(1, true);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
return { progress: next };
|
||||||
|
});
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
advance(dir, silent) {
|
||||||
|
if (!silent) this.playPop(260);
|
||||||
|
this.setState((s) => {
|
||||||
|
const album = ALBUMS.find((a) => a.id === s.albumId);
|
||||||
|
if (!album) {
|
||||||
|
const first = this.albumMatches()[0] || ALBUMS[0];
|
||||||
|
return { albumId: first.id, trackIndex: 0, progress: 0, isPlaying: true };
|
||||||
|
}
|
||||||
|
const next = s.trackIndex + dir;
|
||||||
|
if (next >= 0 && next < album.songs.length) {
|
||||||
|
return { trackIndex: next, progress: 0, isPlaying: true };
|
||||||
|
}
|
||||||
|
// step to the neighbouring album and continue there
|
||||||
|
const order = ALBUMS;
|
||||||
|
const ai = order.findIndex((a) => a.id === album.id);
|
||||||
|
const nextAlbum = order[(ai + (dir > 0 ? 1 : -1) + order.length) % order.length];
|
||||||
|
return {
|
||||||
|
albumId: nextAlbum.id,
|
||||||
|
trackIndex: dir > 0 ? 0 : nextAlbum.songs.length - 1,
|
||||||
|
progress: 0,
|
||||||
|
isPlaying: true,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
handleKey(e) {
|
||||||
|
const key = e.key;
|
||||||
|
if (e.ctrlKey || e.metaKey) {
|
||||||
|
const k = key.toLowerCase();
|
||||||
|
if (k === "l") { e.preventDefault(); this.advance(1); return; }
|
||||||
|
if (k === "h") { e.preventDefault(); this.advance(-1); return; }
|
||||||
|
if (k === "k") { e.preventDefault(); this.setState((s) => ({ volume: Math.min(100, s.volume + 10) })); return; }
|
||||||
|
if (k === "j") { e.preventDefault(); this.setState((s) => ({ volume: Math.max(0, s.volume - 10) })); return; }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (key === "Tab") {
|
||||||
|
e.preventDefault();
|
||||||
|
this.cycleFilter();
|
||||||
|
} else if (key === "/") {
|
||||||
|
e.preventDefault();
|
||||||
|
this.setState({ view: "browse" });
|
||||||
|
} else if (key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
this.togglePlayInternal();
|
||||||
|
} else if (key === "ArrowRight" || key === "ArrowLeft" || key === "ArrowUp" || key === "ArrowDown") {
|
||||||
|
e.preventDefault();
|
||||||
|
const browsing = this.state.view === "browse" && this.state.openAlbumId === null && !this.state.showHelp;
|
||||||
|
if (browsing) {
|
||||||
|
if (key === "ArrowRight") this.moveSel(1, 0);
|
||||||
|
else if (key === "ArrowLeft") this.moveSel(-1, 0);
|
||||||
|
else if (key === "ArrowDown") this.moveSel(0, 1);
|
||||||
|
else this.moveSel(0, -1);
|
||||||
|
} else if (key === "ArrowRight") this.advance(1);
|
||||||
|
else if (key === "ArrowLeft") this.advance(-1);
|
||||||
|
else if (key === "ArrowUp") this.setState((s) => ({ volume: Math.min(100, s.volume + 10) }));
|
||||||
|
else this.setState((s) => ({ volume: Math.max(0, s.volume - 10) }));
|
||||||
|
} else if (key === "?") {
|
||||||
|
e.preventDefault();
|
||||||
|
this.playPop(460);
|
||||||
|
this.setState({ mode: "tracks", search: "", selIndex: 0, view: "browse", showHelp: false });
|
||||||
|
} else if (key === "F1") {
|
||||||
|
e.preventDefault();
|
||||||
|
this.setState((s) => ({ showHelp: !s.showHelp }));
|
||||||
|
} else if (key === "Escape") {
|
||||||
|
this.setState((s) => (s.showHelp || s.openAlbumId !== null
|
||||||
|
? { showHelp: false, openAlbumId: null }
|
||||||
|
: s.search
|
||||||
|
? { search: "", selIndex: 0 }
|
||||||
|
: s.mode === "tracks"
|
||||||
|
? { mode: "albums", selIndex: 0 }
|
||||||
|
: { category: null, selIndex: 0 }));
|
||||||
|
} else if (key === "Backspace") {
|
||||||
|
this.setState((s) => ({ search: s.search.slice(0, -1), selIndex: 0 }));
|
||||||
|
} else if (key === "Enter") {
|
||||||
|
if (this.state.openAlbumId !== null) {
|
||||||
|
this.playSong(this.state.openAlbumId, 0);
|
||||||
|
} else {
|
||||||
|
this.playSelection();
|
||||||
|
}
|
||||||
|
} else if (/^[a-zA-Z0-9]$/.test(key)) {
|
||||||
|
this.setState((s) => ({ search: s.search + key, view: "browse", selIndex: 0 }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
togglePlayInternal() {
|
||||||
|
this.playPop(340);
|
||||||
|
this.setState((s) => {
|
||||||
|
if (s.albumId === null) {
|
||||||
|
const first = this.albumMatches()[0];
|
||||||
|
if (!first) return {};
|
||||||
|
this.startTimer();
|
||||||
|
return { albumId: first.id, trackIndex: 0, isPlaying: true, progress: 0 };
|
||||||
|
}
|
||||||
|
return { isPlaying: !s.isPlaying };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
coverBg(a, w) {
|
||||||
|
if (this.isBook(a)) {
|
||||||
|
const h = a.hue;
|
||||||
|
return `background:
|
||||||
|
linear-gradient(90deg, transparent 0 95%, oklch(97% 0.02 88) 95% 97.5%, oklch(90% 0.03 88) 97.5% 100%),
|
||||||
|
linear-gradient(90deg, transparent 0 3.5%, oklch(92% 0.04 ${h} / .45) 3.5% 4.3%, transparent 4.3% 7%, oklch(92% 0.04 ${h} / .45) 7% 7.8%, transparent 7.8%),
|
||||||
|
linear-gradient(90deg, oklch(30% 0.06 ${h}) 0 11%, oklch(24% 0.05 ${h}) 11% 13%, transparent 13%),
|
||||||
|
linear-gradient(155deg, oklch(64% 0.10 ${h}) 0%, oklch(48% 0.09 ${h}) 100%);`;
|
||||||
|
}
|
||||||
|
return this.stripes(a.hue, w);
|
||||||
|
}
|
||||||
|
|
||||||
|
filterPill(value, active) {
|
||||||
|
return `border:none;cursor:pointer;font-family:inherit;font-size:14px;font-weight:800;padding:9px 15px;border-radius:999px;white-space:nowrap;${
|
||||||
|
active
|
||||||
|
? "background:oklch(97% 0.01 210);color:oklch(28% 0.04 210);box-shadow:0 4px 14px oklch(15% 0.05 210 / .35);"
|
||||||
|
: "background:oklch(97% 0.01 210 / .16);color:oklch(97% 0.01 210);"
|
||||||
|
}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
stripes(hue, w) {
|
||||||
|
return `background:repeating-linear-gradient(135deg, oklch(62% 0.12 ${hue}) 0px, oklch(62% 0.12 ${hue}) ${w}px, oklch(50% 0.12 ${hue}) ${w}px, oklch(50% 0.12 ${hue}) ${w * 2}px);`;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderVals() {
|
||||||
|
const albums = this.albumMatches();
|
||||||
|
const songHits = this.songMatches();
|
||||||
|
const album = ALBUMS.find((a) => a.id === this.state.albumId) || null;
|
||||||
|
const song = album ? album.songs[this.state.trackIndex] : null;
|
||||||
|
const openAlbum = ALBUMS.find((a) => a.id === this.state.openAlbumId) || null;
|
||||||
|
|
||||||
|
const cats = this.categoryMatches();
|
||||||
|
const total = songHits.length + albums.length + cats.length;
|
||||||
|
const sel = total ? Math.min(this.state.selIndex, total - 1) : -1;
|
||||||
|
|
||||||
|
const categoryCards = cats.map((c, ci) => {
|
||||||
|
const isSel = sel === songHits.length + ci;
|
||||||
|
const books = c.albums.filter((a) => this.isBook(a)).length;
|
||||||
|
const allBooks = books === c.albums.length;
|
||||||
|
const tiles = c.albums.slice(0, 4).map((a) => ({
|
||||||
|
style: `border-radius:6px;${this.coverBg(a, 7)}`,
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
key: c.key,
|
||||||
|
navIndex: songHits.length + ci,
|
||||||
|
name: c.name,
|
||||||
|
typeLabel: allBooks ? "📖 Hörbücher" : books ? "🎵📖 Gemischt" : "🎵 Musik",
|
||||||
|
countLabel: `${c.albums.length} ${allBooks ? (c.albums.length === 1 ? "Hörbuch" : "Hörbücher") : c.albums.length === 1 ? "Album" : "Alben"}`,
|
||||||
|
tiles,
|
||||||
|
cardStyle: `background:${allBooks ? "oklch(93% 0.055 88 / .7)" : "oklch(95% 0.015 210 / .66)"};backdrop-filter:blur(6px);border-radius:${allBooks ? "6px 18px 18px 6px" : "16px"};overflow:hidden;cursor:pointer;transition:transform .12s ease;box-shadow:0 6px 18px oklch(15% 0.05 210 / .35);${
|
||||||
|
isSel ? "outline:5px solid oklch(97% 0.01 210);outline-offset:3px;transform:translateY(-3px);" : ""
|
||||||
|
}`,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const albumCards = albums.map((a, ai) => {
|
||||||
|
const isSel = sel === songHits.length + cats.length + ai;
|
||||||
|
const ring = isSel
|
||||||
|
? "outline:5px solid oklch(97% 0.01 210);outline-offset:3px;transform:translateY(-3px);"
|
||||||
|
: a.id === this.state.albumId
|
||||||
|
? "outline:4px solid oklch(70% 0.16 340);"
|
||||||
|
: "";
|
||||||
|
const book = this.isBook(a);
|
||||||
|
return {
|
||||||
|
id: a.id,
|
||||||
|
navIndex: songHits.length + cats.length + ai,
|
||||||
|
title: a.title,
|
||||||
|
artist: a.artist,
|
||||||
|
typeLabel: book ? "📖 Hörbuch" : "🎵 Musik",
|
||||||
|
coverLabel: book ? "HÖRBUCH COVER" : "ALBUM COVER",
|
||||||
|
coverLabelStyle: book
|
||||||
|
? "font-family:ui-monospace,Menlo,monospace;font-size:10px;color:oklch(98% 0 0 / .9);letter-spacing:.5px;text-align:center;margin-left:11%;padding:14px 10px;border:2px solid oklch(98% 0 0 / .55);border-radius:4px;"
|
||||||
|
: "font-family:ui-monospace,Menlo,monospace;font-size:11px;color:oklch(98% 0 0 / .8);letter-spacing:.5px;",
|
||||||
|
songCountLabel: book ? `${a.songs.length} Kapitel` : `${a.songs.length} Songs`,
|
||||||
|
bodyStyle: `padding:10px 12px 14px;${book ? "padding-right:22px;" : ""}`,
|
||||||
|
badgeStyle: `position:absolute;top:8px;left:8px;font-size:11px;font-weight:800;padding:4px 9px;border-radius:999px;background:${book ? "oklch(94% 0.09 88)" : "oklch(97% 0.01 210)"};color:oklch(26% 0.04 210);box-shadow:0 2px 6px oklch(15% 0.05 210 / .3);`,
|
||||||
|
cardStyle: `background:${book ? "oklch(93% 0.055 88 / .7)" : "oklch(95% 0.015 210 / .66)"};backdrop-filter:blur(6px);border-radius:${book ? "6px 18px 18px 6px" : "16px"};overflow:hidden;cursor:pointer;transition:transform .12s ease;box-shadow:${
|
||||||
|
book
|
||||||
|
? "inset -7px 0 0 oklch(88% 0.06 88 / .7), inset -11px 0 0 oklch(82% 0.06 88 / .7), 0 6px 18px oklch(15% 0.05 210 / .35)"
|
||||||
|
: "0 6px 18px oklch(15% 0.05 210 / .35)"
|
||||||
|
};${ring}`,
|
||||||
|
coverStyle: `width:100%;aspect-ratio:${book ? "0.82" : "1"};position:relative;display:flex;align-items:center;justify-content:center;${this.coverBg(a, 14)}`,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const songRows = songHits.map((h, hi) => ({
|
||||||
|
albumId: h.album.id,
|
||||||
|
navIndex: hi,
|
||||||
|
index: h.index,
|
||||||
|
title: h.title,
|
||||||
|
duration: h.duration,
|
||||||
|
albumLine: `${this.isBook(h.album) ? "📖 " : ""}${h.album.title} · ${h.album.artist}`,
|
||||||
|
rowStyle: `display:flex;align-items:center;gap:14px;padding:10px 16px;border-radius:14px;cursor:pointer;background:oklch(97% 0.01 210 / ${album && album.id === h.album.id && this.state.trackIndex === h.index ? ".22" : ".10"});${sel === hi ? "outline:4px solid oklch(97% 0.01 210);outline-offset:2px;" : ""}`,
|
||||||
|
chipStyle: `width:38px;height:38px;flex:none;border-radius:${this.isBook(h.album) ? "6px 12px 12px 6px" : "10px"};${this.coverBg(h.album, 7)}`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const openSongs = openAlbum
|
||||||
|
? openAlbum.songs.map((s, i) => {
|
||||||
|
const isCurrent = album && album.id === openAlbum.id && this.state.trackIndex === i;
|
||||||
|
return {
|
||||||
|
albumId: openAlbum.id,
|
||||||
|
index: i,
|
||||||
|
number: String(i + 1),
|
||||||
|
title: s[0],
|
||||||
|
duration: s[1],
|
||||||
|
rowStyle: `display:flex;align-items:center;gap:14px;padding:10px 14px;border-radius:14px;cursor:pointer;background:${isCurrent ? "oklch(70% 0.16 340 / .16)" : "oklch(30% 0.03 210 / .05)"};`,
|
||||||
|
numStyle: `width:30px;height:30px;border-radius:999px;flex:none;display:flex;align-items:center;justify-content:center;font:800 13px ui-monospace,Menlo,monospace;background:${isCurrent ? "oklch(70% 0.16 340)" : "oklch(88% 0.02 210)"};color:${isCurrent ? "#fff" : "oklch(35% 0.03 210)"};`,
|
||||||
|
titleStyle: `color:${isCurrent ? "oklch(45% 0.16 340)" : "oklch(24% 0.03 210)"};`,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const volumeBars = [0, 1, 2, 3, 4].map((i) => ({
|
||||||
|
level: (i + 1) * 20,
|
||||||
|
style: `border:none;padding:0;cursor:pointer;background:${this.state.volume >= (i + 1) * 20 ? "oklch(70% 0.16 340)" : "oklch(88% 0.02 210 / .4)"};`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const parts = [];
|
||||||
|
if (this.state.mode === "tracks") parts.push(`${songHits.length} Titel`);
|
||||||
|
else if (albums.length) parts.push(`${albums.length} Album${albums.length === 1 ? "" : "en"}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
filterAllStyle: this.filterPill("all", this.state.filter === "all"),
|
||||||
|
filterMusicStyle: this.filterPill("music", this.state.filter === "music"),
|
||||||
|
filterBookStyle: this.filterPill("book", this.state.filter === "book"),
|
||||||
|
setFilterAll: () => this.setFilter("all"),
|
||||||
|
setFilterMusic: () => this.setFilter("music"),
|
||||||
|
setFilterBook: () => this.setFilter("book"),
|
||||||
|
scrollerRef: (el) => { this._scroller = el; },
|
||||||
|
gridRef: (el) => { this._grid = el; if (el) requestAnimationFrame(() => this.measureCols()); },
|
||||||
|
showHelp: this.state.showHelp,
|
||||||
|
toggleHelp: () => this.setState((s) => ({ showHelp: !s.showHelp })),
|
||||||
|
hasSearch: this.state.search.length > 0,
|
||||||
|
showSearchBar: this.state.search.length > 0 || this.state.mode === "tracks",
|
||||||
|
modeLabel: this.state.mode === "tracks" ? "♪ Titel" : "🔎 Alben",
|
||||||
|
searchDisplay: this.state.search ? `"${this.state.search}"` : "tippe …",
|
||||||
|
resultCountLabel: parts.length ? parts.join(" · ") + " gefunden" : "nichts gefunden",
|
||||||
|
albumSectionLabel: this.state.filter === "book" ? "Hörbücher" : this.state.filter === "music" ? "Alben" : "Alben & Hörbücher",
|
||||||
|
categories: categoryCards,
|
||||||
|
hasCategories: categoryCards.length > 0,
|
||||||
|
categorySectionLabel: this.state.filter === "book" ? "Figuren" : this.state.filter === "music" ? "Künstler" : "Figuren & Künstler",
|
||||||
|
openCategory: (e) => {
|
||||||
|
this.playPop(440);
|
||||||
|
this.setState({ category: e.currentTarget.dataset.key, selIndex: 0 });
|
||||||
|
},
|
||||||
|
hasCategoryCrumb: this.state.category !== null && !this.state.search,
|
||||||
|
categoryCrumb: this.state.category || "",
|
||||||
|
clearCategory: () => this.setState({ category: null, selIndex: 0 }),
|
||||||
|
hasAlbumHits: albums.length > 0,
|
||||||
|
hasSongHits: songHits.length > 0,
|
||||||
|
hasNoResults: albums.length === 0 && songHits.length === 0 && cats.length === 0,
|
||||||
|
songHitLabel: `${songHits.length} Treffer`,
|
||||||
|
filteredAlbums: albumCards,
|
||||||
|
songHits: songRows,
|
||||||
|
openAlbumCard: (e) => {
|
||||||
|
this.playPop(420);
|
||||||
|
const nav = Number(e.currentTarget.dataset.navIndex);
|
||||||
|
this.setState({ openAlbumId: Number(e.currentTarget.dataset.id), selIndex: isNaN(nav) ? this.state.selIndex : nav });
|
||||||
|
},
|
||||||
|
closeAlbum: () => this.setState({ openAlbumId: null }),
|
||||||
|
stopClick: (e) => e.stopPropagation(),
|
||||||
|
playOpenAlbum: () => {
|
||||||
|
if (this.state.openAlbumId !== null) {
|
||||||
|
this.playSong(this.state.openAlbumId, 0);
|
||||||
|
this.setState({ openAlbumId: null });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
playSongHit: (e) => {
|
||||||
|
const d = e.currentTarget.dataset;
|
||||||
|
this.playSong(Number(d.album), Number(d.index));
|
||||||
|
},
|
||||||
|
hasOpenAlbum: openAlbum !== null,
|
||||||
|
openAlbumTitle: openAlbum ? openAlbum.title : "",
|
||||||
|
openAlbumArtist: openAlbum ? openAlbum.artist : "",
|
||||||
|
openAlbumMeta: openAlbum
|
||||||
|
? this.isBook(openAlbum)
|
||||||
|
? `📖 Hörbuch · ${openAlbum.songs.length} Kapitel`
|
||||||
|
: `🎵 Musik · ${openAlbum.songs.length} Songs`
|
||||||
|
: "",
|
||||||
|
openAlbumCoverStyle: openAlbum
|
||||||
|
? `width:150px;height:150px;flex:none;border-radius:${this.isBook(openAlbum) ? "10px 18px 18px 10px" : "16px"};display:flex;align-items:center;justify-content:center;${this.coverBg(openAlbum, 12)}`
|
||||||
|
: "display:none;",
|
||||||
|
openAlbumSongs: openSongs,
|
||||||
|
togglePlay: () => this.togglePlayInternal(),
|
||||||
|
prevTrack: () => this.advance(-1),
|
||||||
|
nextTrack: () => this.advance(1),
|
||||||
|
isBrowse: this.state.view === "browse",
|
||||||
|
isPlay: this.state.view === "play",
|
||||||
|
goBrowse: () => this.setState({ view: "browse" }),
|
||||||
|
|
||||||
|
bigCoverStyle: album
|
||||||
|
? `width:min(340px, 28vh);height:min(340px, 28vh);flex:none;border-radius:${this.isBook(album) ? "18px 34px 34px 18px" : "28px"};display:flex;align-items:center;justify-content:center;box-shadow:0 20px 50px oklch(10% 0.05 210 / .55);${this.coverBg(album, 22)}`
|
||||||
|
: "width:min(340px, 28vh);height:min(340px, 28vh);flex:none;border-radius:28px;background:oklch(35% 0.03 210);",
|
||||||
|
isPlaying: this.state.isPlaying,
|
||||||
|
isPaused: !this.state.isPlaying,
|
||||||
|
currentSongTitle: song ? song[0] : "Wähl ein Album!",
|
||||||
|
currentAlbumLine: album ? `${this.isBook(album) ? "📖 " : ""}${album.title} · ${album.artist}` : "Tippen oder klicken",
|
||||||
|
trackCounter: album ? `${this.isBook(album) ? "Kapitel" : "Song"} ${this.state.trackIndex + 1} von ${album.songs.length}` : "",
|
||||||
|
playerCoverStyle: album ? this.coverBg(album, 8) : "background:oklch(35% 0.03 210);",
|
||||||
|
albumRemainingLabel: album
|
||||||
|
? `Noch ${this.clock(this.remainingSecs(album))} ${this.isBook(album) ? "im Hörbuch" : "im Album"}`
|
||||||
|
: "",
|
||||||
|
trackRemainingLabel: album && album.songs[this.state.trackIndex]
|
||||||
|
? `−${this.clock(this.secs(album.songs[this.state.trackIndex][1]) * (1 - this.state.progress / 100))}`
|
||||||
|
: "",
|
||||||
|
progressStyle: `width:${this.state.progress}%;transition:width .2s linear;`,
|
||||||
|
dolphinBobAnim: this.state.isPlaying ? "dolphinBob 1.6s ease-in-out infinite" : "none",
|
||||||
|
volumeBars,
|
||||||
|
setVolume: (e) => {
|
||||||
|
this.playPop(500);
|
||||||
|
this.setState({ volume: Number(e.currentTarget.dataset.level) });
|
||||||
|
},
|
||||||
|
muteToggle: () => this.setState((s) => ({ volume: s.volume > 0 ? 0 : 60 })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
317
claude-design/Mein Zimmer.dc.html
Normal file
317
claude-design/Mein Zimmer.dc.html
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<script src="./support.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<x-dc>
|
||||||
|
<helmet>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Nunito:wght@500;700;800;900&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
@keyframes bubbleRise { 0% { transform: translateY(0) scale(1); opacity: .55; } 100% { transform: translateY(-120vh) scale(1.3); opacity: 0; } }
|
||||||
|
@keyframes dolphinFloat { 0%, 100% { transform: translateY(0) rotate(-2deg); } 50% { transform: translateY(-12px) rotate(2deg); } }
|
||||||
|
body { margin: 0; font-family: 'Nunito', system-ui, sans-serif; }
|
||||||
|
a { color: oklch(80% 0.14 302); }
|
||||||
|
a:hover { color: oklch(92% 0.10 302); }
|
||||||
|
</style>
|
||||||
|
</helmet>
|
||||||
|
|
||||||
|
<div style="position:relative;width:100%;min-height:100vh;box-sizing:border-box;overflow:hidden;background:linear-gradient(180deg, oklch(56% 0.14 305) 0%, oklch(38% 0.12 300) 45%, oklch(21% 0.08 298) 100%);">
|
||||||
|
|
||||||
|
<div style="position:absolute;left:4%;bottom:-40px;width:14px;height:14px;border-radius:50%;background:oklch(94% 0.03 302 / .5);animation:bubbleRise 9s linear infinite;"></div>
|
||||||
|
<div style="position:absolute;left:11%;bottom:-40px;width:22px;height:22px;border-radius:50%;background:oklch(94% 0.03 302 / .35);animation:bubbleRise 12s linear infinite;animation-delay:2s;"></div>
|
||||||
|
<div style="position:absolute;left:19%;bottom:-40px;width:10px;height:10px;border-radius:50%;background:oklch(94% 0.03 302 / .5);animation:bubbleRise 7s linear infinite;animation-delay:1s;"></div>
|
||||||
|
<div style="position:absolute;left:27%;bottom:-40px;width:18px;height:18px;border-radius:50%;background:oklch(94% 0.03 302 / .42);animation:bubbleRise 10.5s linear infinite;animation-delay:4s;"></div>
|
||||||
|
<div style="position:absolute;left:34%;bottom:-40px;width:8px;height:8px;border-radius:50%;background:oklch(94% 0.03 302 / .5);animation:bubbleRise 6.8s linear infinite;animation-delay:3s;"></div>
|
||||||
|
<div style="position:absolute;left:42%;bottom:-40px;width:24px;height:24px;border-radius:50%;background:oklch(94% 0.03 302 / .3);animation:bubbleRise 13.5s linear infinite;animation-delay:5.5s;"></div>
|
||||||
|
<div style="position:absolute;left:49%;bottom:-40px;width:12px;height:12px;border-radius:50%;background:oklch(94% 0.03 302 / .48);animation:bubbleRise 8.4s linear infinite;animation-delay:1.6s;"></div>
|
||||||
|
<div style="position:absolute;left:57%;bottom:-40px;width:16px;height:16px;border-radius:50%;background:oklch(94% 0.03 302 / .4);animation:bubbleRise 11.2s linear infinite;animation-delay:6.2s;"></div>
|
||||||
|
<div style="position:absolute;left:64%;bottom:-40px;width:9px;height:9px;border-radius:50%;background:oklch(94% 0.03 302 / .5);animation:bubbleRise 7.4s linear infinite;animation-delay:2.4s;"></div>
|
||||||
|
<div style="position:absolute;left:72%;bottom:-40px;width:20px;height:20px;border-radius:50%;background:oklch(94% 0.03 302 / .32);animation:bubbleRise 14s linear infinite;animation-delay:3.8s;"></div>
|
||||||
|
<div style="position:absolute;left:80%;bottom:-40px;width:11px;height:11px;border-radius:50%;background:oklch(94% 0.03 302 / .48);animation:bubbleRise 9.2s linear infinite;animation-delay:5s;"></div>
|
||||||
|
<div style="position:absolute;left:88%;bottom:-40px;width:26px;height:26px;border-radius:50%;background:oklch(94% 0.03 302 / .28);animation:bubbleRise 15s linear infinite;animation-delay:1.2s;"></div>
|
||||||
|
<div style="position:absolute;left:95%;bottom:-40px;width:13px;height:13px;border-radius:50%;background:oklch(94% 0.03 302 / .45);animation:bubbleRise 10.2s linear infinite;animation-delay:7s;"></div>
|
||||||
|
|
||||||
|
<div style="position:relative;z-index:1;padding-top:10px;">
|
||||||
|
<dc-import name="AppHeader" title="Mein Zimmer" mascot="dolphin-remote.png" link-href="./Dolphin%20Beats.dc.html" link-label="♪ Musik" hint-size="100%,116px"></dc-import>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="position:relative;z-index:1;max-width:1180px;margin:0 auto;padding:0 32px 64px;box-sizing:border-box;">
|
||||||
|
|
||||||
|
<div style="margin-top:18px;">
|
||||||
|
<div style="font-size:19px;font-weight:900;color:oklch(97% 0.01 302);text-align:center;margin-bottom:14px;">Szenen</div>
|
||||||
|
<div style="display:flex;flex-wrap:wrap;justify-content:center;gap:12px;">
|
||||||
|
<sc-for list="{{ scenes }}" as="scene" hint-placeholder-count="5">
|
||||||
|
<button onClick="{{ activateScene }}" data-id="{{ scene.id }}" style="{{ scene.style }}">
|
||||||
|
<span style="font-size:22px;">{{ scene.emoji }}</span>
|
||||||
|
<span>{{ scene.name }}</span>
|
||||||
|
</button>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:grid;grid-template-columns:repeat(auto-fit, minmax(300px, 1fr));gap:20px;margin-top:30px;">
|
||||||
|
|
||||||
|
<sc-for list="{{ lights }}" as="light" hint-placeholder-count="4">
|
||||||
|
<div style="{{ light.cardStyle }}">
|
||||||
|
<div style="display:flex;align-items:center;gap:14px;">
|
||||||
|
<div style="{{ light.iconWrapStyle }}">
|
||||||
|
<sc-if value="{{ light.isBeyond }}" hint-placeholder-val="{{ true }}">
|
||||||
|
<svg viewBox="0 0 48 48" width="34" height="34" aria-hidden="true">
|
||||||
|
<circle cx="24" cy="24" r="17" fill="none" stroke="currentColor" stroke-width="3"></circle>
|
||||||
|
<circle cx="24" cy="24" r="9" fill="currentColor" opacity=".85"></circle>
|
||||||
|
<circle cx="24" cy="24" r="22" fill="none" stroke="currentColor" stroke-width="1.5" opacity=".4"></circle>
|
||||||
|
</svg>
|
||||||
|
</sc-if>
|
||||||
|
<sc-if value="{{ light.isStrip }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<svg viewBox="0 0 48 48" width="34" height="34" aria-hidden="true">
|
||||||
|
<rect x="4" y="19" width="40" height="10" rx="5" fill="none" stroke="currentColor" stroke-width="3"></rect>
|
||||||
|
<circle cx="13" cy="24" r="2.4" fill="currentColor"></circle>
|
||||||
|
<circle cx="24" cy="24" r="2.4" fill="currentColor"></circle>
|
||||||
|
<circle cx="35" cy="24" r="2.4" fill="currentColor"></circle>
|
||||||
|
</svg>
|
||||||
|
</sc-if>
|
||||||
|
<sc-if value="{{ light.isCeiling }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<svg viewBox="0 0 48 48" width="34" height="34" aria-hidden="true">
|
||||||
|
<path d="M24 4v7" stroke="currentColor" stroke-width="3" stroke-linecap="round"></path>
|
||||||
|
<path d="M9 30 L24 11 L39 30 Z" fill="none" stroke="currentColor" stroke-width="3" stroke-linejoin="round"></path>
|
||||||
|
<path d="M16 37h16" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity=".55"></path>
|
||||||
|
<path d="M20 43h8" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity=".3"></path>
|
||||||
|
</svg>
|
||||||
|
</sc-if>
|
||||||
|
</div>
|
||||||
|
<div style="flex:1;min-width:0;font-size:19px;font-weight:900;color:oklch(20% 0.03 300);">{{ light.name }}</div>
|
||||||
|
<button onClick="{{ toggleLight }}" data-id="{{ light.id }}" style="{{ light.switchStyle }}">
|
||||||
|
<span style="{{ light.knobStyle }}"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top:16px;">
|
||||||
|
<div style="display:flex;gap:6px;">
|
||||||
|
<sc-for list="{{ light.levels }}" as="lvl" hint-placeholder-count="5">
|
||||||
|
<button onClick="{{ setLevel }}" data-id="{{ lvl.lightId }}" data-level="{{ lvl.value }}" style="{{ lvl.style }}"></button>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<sc-if value="{{ light.hasColors }}" hint-placeholder-val="{{ true }}">
|
||||||
|
<div style="margin-top:14px;">
|
||||||
|
<div style="display:flex;flex-wrap:wrap;gap:10px;">
|
||||||
|
<sc-for list="{{ light.swatches }}" as="sw" hint-placeholder-count="5">
|
||||||
|
<button onClick="{{ setColor }}" data-id="{{ sw.lightId }}" data-color="{{ sw.value }}" title="{{ sw.name }}" style="{{ sw.style }}"></button>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
</div>
|
||||||
|
</sc-for>
|
||||||
|
|
||||||
|
<div style="background:oklch(96% 0.012 300 / .55);backdrop-filter:blur(6px);border-radius:22px;padding:18px 20px 20px;box-shadow:0 8px 24px oklch(15% 0.05 300 / .35);">
|
||||||
|
<div style="display:flex;align-items:center;gap:14px;">
|
||||||
|
<div style="width:56px;height:56px;border-radius:16px;flex:none;display:flex;align-items:center;justify-content:center;background:oklch(88% 0.03 300);color:oklch(35% 0.05 300);">
|
||||||
|
<svg viewBox="0 0 48 48" width="34" height="34" aria-hidden="true">
|
||||||
|
<rect x="7" y="7" width="34" height="34" rx="4" fill="none" stroke="currentColor" stroke-width="3"></rect>
|
||||||
|
<path d="M7 16h34M7 24h34M7 32h34" stroke="currentColor" stroke-width="2.5"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div style="flex:1;min-width:0;">
|
||||||
|
<div style="font-size:19px;font-weight:900;color:oklch(20% 0.03 300);">Rollo</div>
|
||||||
|
<div style="font-size:13px;font-weight:700;color:oklch(45% 0.03 300 / .8);">{{ shutterLabel }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top:16px;display:flex;gap:18px;align-items:stretch;">
|
||||||
|
<div style="width:104px;flex:none;border-radius:12px;overflow:hidden;position:relative;background:linear-gradient(180deg, oklch(82% 0.08 220), oklch(70% 0.09 200));border:3px solid oklch(35% 0.04 300);">
|
||||||
|
<div style="position:absolute;left:0;right:0;top:0;{{ shutterFillStyle }}"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="flex:1;min-width:0;display:flex;flex-direction:column;gap:10px;">
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;">
|
||||||
|
<sc-for list="{{ shutterPresets }}" as="p" hint-placeholder-count="4">
|
||||||
|
<button onClick="{{ setShutter }}" data-value="{{ p.value }}" style="{{ p.style }}">{{ p.name }}</button>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px;">
|
||||||
|
<button onClick="{{ shutterUp }}" style="{{ upStyle }}">▲</button>
|
||||||
|
<button onClick="{{ shutterStop }}" style="{{ stopStyle }}">■</button>
|
||||||
|
<button onClick="{{ shutterDown }}" style="{{ downStyle }}">▼</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</x-dc>
|
||||||
|
<script type="text/x-dc" data-dc-script data-props="{"$preview": {"width": 1280, "height": 900}}">
|
||||||
|
const SWATCHES = [
|
||||||
|
{ name: "Warmweiß", value: "oklch(90% 0.06 85)" },
|
||||||
|
{ name: "Sonnengelb", value: "oklch(85% 0.16 95)" },
|
||||||
|
{ name: "Korallenrot", value: "oklch(65% 0.20 25)" },
|
||||||
|
{ name: "Delfinblau", value: "oklch(70% 0.15 235)" },
|
||||||
|
{ name: "Riffgrün", value: "oklch(75% 0.16 155)" },
|
||||||
|
{ name: "Quallenlila", value: "oklch(65% 0.18 310)" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const SCENES = [
|
||||||
|
{ id: "lesen", name: "Lesen", emoji: "📖", lights: [{ on: true, level: 4, color: "oklch(90% 0.06 85)" }, { on: true, level: 2, color: "oklch(90% 0.06 85)" }, { on: false, level: 2, color: "oklch(70% 0.15 235)" }], ceiling: { on: true, level: 3 }, shutter: 0 },
|
||||||
|
{ id: "spielen", name: "Spielen", emoji: "🐬", lights: [{ on: true, level: 5, color: "oklch(75% 0.16 155)" }, { on: true, level: 5, color: "oklch(70% 0.15 235)" }, { on: true, level: 4, color: "oklch(65% 0.18 310)" }], ceiling: { on: true, level: 5 }, shutter: 0 },
|
||||||
|
{ id: "party", name: "Disco", emoji: "🪩", lights: [{ on: true, level: 5, color: "oklch(65% 0.18 310)" }, { on: true, level: 5, color: "oklch(65% 0.20 25)" }, { on: true, level: 5, color: "oklch(70% 0.15 235)" }], ceiling: { on: false, level: 2 }, shutter: 85 },
|
||||||
|
{ id: "nacht", name: "Gute Nacht", emoji: "🌙", lights: [{ on: true, level: 1, color: "oklch(70% 0.15 235)" }, { on: false, level: 1, color: "oklch(70% 0.15 235)" }, { on: true, level: 1, color: "oklch(65% 0.18 310)" }], ceiling: { on: false, level: 1 }, shutter: 100 },
|
||||||
|
{ id: "aufwachen", name: "Aufwachen", emoji: "🌞", lights: [{ on: true, level: 3, color: "oklch(85% 0.16 95)" }, { on: true, level: 3, color: "oklch(85% 0.16 95)" }, { on: false, level: 2, color: "oklch(85% 0.16 95)" }], ceiling: { on: true, level: 2 }, shutter: 0 },
|
||||||
|
];
|
||||||
|
|
||||||
|
class Component extends DCLogic {
|
||||||
|
state = {
|
||||||
|
lights: [
|
||||||
|
{ id: "beyond1", kind: "beyond", name: "Hue Beyond links", on: true, level: 4, color: "oklch(70% 0.15 235)" },
|
||||||
|
{ id: "beyond2", kind: "beyond", name: "Hue Beyond rechts", on: false, level: 3, color: "oklch(75% 0.16 155)" },
|
||||||
|
{ id: "strip", kind: "strip", name: "Lichtband Bett", on: true, level: 3, color: "oklch(65% 0.18 310)" },
|
||||||
|
{ id: "ceiling", kind: "ceiling", name: "Deckenlampe", on: true, level: 5, color: null },
|
||||||
|
],
|
||||||
|
shutter: 0,
|
||||||
|
moving: null,
|
||||||
|
scene: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
if (this._mover) clearInterval(this._mover);
|
||||||
|
}
|
||||||
|
|
||||||
|
patch(id, changes) {
|
||||||
|
this.setState((s) => ({
|
||||||
|
scene: null,
|
||||||
|
lights: s.lights.map((l) => (l.id === id ? Object.assign({}, l, changes) : l)),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
move(dir) {
|
||||||
|
if (this._mover) clearInterval(this._mover);
|
||||||
|
this.setState({ moving: dir, scene: null });
|
||||||
|
this._mover = setInterval(() => {
|
||||||
|
this.setState((s) => {
|
||||||
|
const next = s.shutter + dir * 4;
|
||||||
|
if (next <= 0) { clearInterval(this._mover); this._mover = null; return { shutter: 0, moving: null }; }
|
||||||
|
if (next >= 100) { clearInterval(this._mover); this._mover = null; return { shutter: 100, moving: null }; }
|
||||||
|
return { shutter: next };
|
||||||
|
});
|
||||||
|
}, 90);
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
if (this._mover) clearInterval(this._mover);
|
||||||
|
this._mover = null;
|
||||||
|
this.setState({ moving: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
glow(color, level) {
|
||||||
|
return `0 0 0 4px ${color.replace(")", " / .28)")}, 0 10px 26px ${color.replace(")", ` / ${0.18 + level * 0.08})`)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderVals() {
|
||||||
|
const shutter = Math.round(this.state.shutter);
|
||||||
|
|
||||||
|
const lights = this.state.lights.map((l) => {
|
||||||
|
const tint = l.color || "oklch(92% 0.05 90)";
|
||||||
|
const active = l.on;
|
||||||
|
return {
|
||||||
|
id: l.id,
|
||||||
|
name: l.name,
|
||||||
|
isBeyond: l.kind === "beyond",
|
||||||
|
isStrip: l.kind === "strip",
|
||||||
|
isCeiling: l.kind === "ceiling",
|
||||||
|
hasColors: l.kind !== "ceiling",
|
||||||
|
cardStyle: `background:oklch(96% 0.012 300 / .55);backdrop-filter:blur(6px);border-radius:22px;padding:18px 20px 20px;box-shadow:0 8px 24px oklch(15% 0.05 300 / .35)${active ? `, 0 0 0 3px ${tint.replace(")", " / .55)")}` : ""};`,
|
||||||
|
iconWrapStyle: `width:56px;height:56px;border-radius:16px;flex:none;display:flex;align-items:center;justify-content:center;transition:background .15s ease;background:${active ? tint : "oklch(88% 0.02 300)"};color:${active ? "oklch(28% 0.05 300)" : "oklch(55% 0.02 300)"};${active ? `box-shadow:0 0 22px ${tint.replace(")", " / .7)")};` : ""}`,
|
||||||
|
switchStyle: `width:74px;height:42px;flex:none;border:none;cursor:pointer;border-radius:999px;padding:4px;display:flex;justify-content:${active ? "flex-end" : "flex-start"};background:${active ? "oklch(62% 0.15 302)" : "oklch(84% 0.01 300)"};transition:background .15s ease;`,
|
||||||
|
knobStyle: "width:34px;height:34px;border-radius:999px;background:#fff;box-shadow:0 2px 6px oklch(20% 0.03 300 / .4);display:block;",
|
||||||
|
levels: [1, 2, 3, 4, 5].map((v) => ({
|
||||||
|
lightId: l.id,
|
||||||
|
value: v,
|
||||||
|
style: `flex:1;height:46px;min-width:0;border:none;cursor:pointer;border-radius:12px;background:${active && l.level >= v ? tint : "oklch(88% 0.02 300)"};opacity:${active ? 1 : 0.55};`,
|
||||||
|
})),
|
||||||
|
swatches: SWATCHES.map((sw) => ({
|
||||||
|
lightId: l.id,
|
||||||
|
value: sw.value,
|
||||||
|
name: sw.name,
|
||||||
|
style: `width:46px;height:46px;border-radius:999px;cursor:pointer;background:${sw.value};border:${l.color === sw.value ? "4px solid oklch(28% 0.05 300)" : "4px solid oklch(96% 0.012 300 / .8)"};box-shadow:0 3px 8px oklch(20% 0.03 300 / .35);`,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const presetBtn = (active) =>
|
||||||
|
`border:none;cursor:pointer;font-family:inherit;font-size:14px;font-weight:800;padding:0 10px;height:48px;border-radius:14px;background:${active ? "oklch(62% 0.15 302)" : "oklch(89% 0.02 300)"};color:${active ? "#fff" : "oklch(28% 0.04 300)"};`;
|
||||||
|
|
||||||
|
const label =
|
||||||
|
shutter >= 99 ? "Ganz zu" :
|
||||||
|
shutter >= 80 ? "Fast zu" :
|
||||||
|
shutter >= 40 ? "Halb zu" :
|
||||||
|
shutter <= 1 ? "Offen" : `${shutter}% zu`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
lights,
|
||||||
|
toggleLight: (e) => {
|
||||||
|
const id = e.currentTarget.dataset.id;
|
||||||
|
const l = this.state.lights.find((x) => x.id === id);
|
||||||
|
this.patch(id, { on: !l.on });
|
||||||
|
},
|
||||||
|
setLevel: (e) => {
|
||||||
|
const d = e.currentTarget.dataset;
|
||||||
|
this.patch(d.id, { level: Number(d.level), on: true });
|
||||||
|
},
|
||||||
|
setColor: (e) => {
|
||||||
|
const d = e.currentTarget.dataset;
|
||||||
|
this.patch(d.id, { color: d.color, on: true });
|
||||||
|
},
|
||||||
|
shutterLabel: this.state.moving ? (this.state.moving > 0 ? "Fährt runter …" : "Fährt hoch …") : label,
|
||||||
|
shutterFillStyle: `height:${shutter}%;transition:height .2s linear;background:repeating-linear-gradient(180deg, oklch(70% 0.03 300) 0px, oklch(70% 0.03 300) 9px, oklch(58% 0.03 300) 9px, oklch(58% 0.03 300) 12px);box-shadow:0 4px 10px oklch(20% 0.03 300 / .4);`,
|
||||||
|
shutterPresets: [
|
||||||
|
{ name: "Offen", value: 0, style: presetBtn(shutter <= 1) },
|
||||||
|
{ name: "Halb zu", value: 50, style: presetBtn(shutter > 1 && shutter < 80) },
|
||||||
|
{ name: "Fast zu", value: 85, style: presetBtn(shutter >= 80 && shutter < 99) },
|
||||||
|
{ name: "Ganz zu", value: 100, style: presetBtn(shutter >= 99) },
|
||||||
|
],
|
||||||
|
setShutter: (e) => {
|
||||||
|
this.stop();
|
||||||
|
this.setState({ shutter: Number(e.currentTarget.dataset.value), scene: null });
|
||||||
|
},
|
||||||
|
shutterUp: () => this.move(-1),
|
||||||
|
shutterDown: () => this.move(1),
|
||||||
|
shutterStop: () => this.stop(),
|
||||||
|
upStyle: `border:none;cursor:pointer;font-family:inherit;font-size:20px;font-weight:900;height:52px;border-radius:14px;background:${this.state.moving === -1 ? "oklch(62% 0.15 302)" : "oklch(89% 0.02 300)"};color:${this.state.moving === -1 ? "#fff" : "oklch(28% 0.04 300)"};`,
|
||||||
|
downStyle: `border:none;cursor:pointer;font-family:inherit;font-size:20px;font-weight:900;height:52px;border-radius:14px;background:${this.state.moving === 1 ? "oklch(62% 0.15 302)" : "oklch(89% 0.02 300)"};color:${this.state.moving === 1 ? "#fff" : "oklch(28% 0.04 300)"};`,
|
||||||
|
stopStyle: "border:none;cursor:pointer;font-family:inherit;font-size:18px;font-weight:900;height:52px;border-radius:14px;background:oklch(65% 0.18 25);color:#fff;",
|
||||||
|
scenes: SCENES.map((sc) => ({
|
||||||
|
id: sc.id,
|
||||||
|
name: sc.name,
|
||||||
|
emoji: sc.emoji,
|
||||||
|
style: `border:none;cursor:pointer;font-family:inherit;font-size:15px;font-weight:800;padding:12px 20px;border-radius:999px;display:flex;align-items:center;gap:10px;min-height:52px;background:${
|
||||||
|
this.state.scene === sc.id ? "oklch(97% 0.01 302)" : "oklch(97% 0.01 302 / .18)"
|
||||||
|
};color:${this.state.scene === sc.id ? "oklch(26% 0.05 300)" : "oklch(97% 0.01 302)"};box-shadow:${
|
||||||
|
this.state.scene === sc.id ? "0 6px 18px oklch(15% 0.05 300 / .4)" : "none"
|
||||||
|
};`,
|
||||||
|
})),
|
||||||
|
activateScene: (e) => {
|
||||||
|
const sc = SCENES.find((x) => x.id === e.currentTarget.dataset.id);
|
||||||
|
if (!sc) return;
|
||||||
|
this.stop();
|
||||||
|
this.setState((s) => ({
|
||||||
|
scene: sc.id,
|
||||||
|
shutter: sc.shutter,
|
||||||
|
lights: s.lights.map((l, i) =>
|
||||||
|
l.kind === "ceiling"
|
||||||
|
? Object.assign({}, l, sc.ceiling)
|
||||||
|
: Object.assign({}, l, sc.lights[i] || {})
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
BIN
claude-design/dolphin-mascot.png
Normal file
BIN
claude-design/dolphin-mascot.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 697 KiB |
BIN
claude-design/dolphin-remote.png
Normal file
BIN
claude-design/dolphin-remote.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 788 KiB |
BIN
claude-design/pasted-1787085770763-0.png
Normal file
BIN
claude-design/pasted-1787085770763-0.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 697 KiB |
BIN
claude-design/pasted-1787122665953-0.png
Normal file
BIN
claude-design/pasted-1787122665953-0.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 788 KiB |
BIN
claude-design/pasted-1787124615900-0.png
Normal file
BIN
claude-design/pasted-1787124615900-0.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 788 KiB |
1911
claude-design/support.js
Normal file
1911
claude-design/support.js
Normal file
File diff suppressed because it is too large
Load Diff
435
claude-design/three-d-stage.js
Normal file
435
claude-design/three-d-stage.js
Normal file
@@ -0,0 +1,435 @@
|
|||||||
|
// @ds-adherence-ignore -- omelette starter scaffold (raw elements/hex/px by design)
|
||||||
|
// Copied omelette starter. Re-running copy_starter_component with this kind overwrites this file with the latest version (page content is unaffected).
|
||||||
|
/* BEGIN USAGE */
|
||||||
|
/**
|
||||||
|
* <three-d-stage> — 3D object viewer + exporter shell (three.js).
|
||||||
|
*
|
||||||
|
* The stage owns the whole scene: WebGL renderer, neutral studio lighting
|
||||||
|
* with a soft ground shadow, orbit controls (drag to orbit, wheel to zoom,
|
||||||
|
* right-drag to pan), a camera auto-framed to the object's bounds, resize
|
||||||
|
* handling, and a download toolbar that exports the current object as
|
||||||
|
* OBJ + MTL or GLB (binary glTF). FBX cannot be exported in the browser;
|
||||||
|
* GLB is the interchange format every modern 3D tool imports.
|
||||||
|
*
|
||||||
|
* three.js loads through the page's import map. Include this EXACT pinned
|
||||||
|
* map in <head>, before any module runs — versions and integrity hashes
|
||||||
|
* stay together (same map the "3D object" skill mandates):
|
||||||
|
*
|
||||||
|
* <script type="importmap">
|
||||||
|
* {
|
||||||
|
* "imports": {
|
||||||
|
* "three": "https://unpkg.com/three@0.184.0/build/three.module.js",
|
||||||
|
* "three/addons/controls/OrbitControls.js": "https://unpkg.com/three@0.184.0/examples/jsm/controls/OrbitControls.js",
|
||||||
|
* "three/addons/exporters/OBJExporter.js": "https://unpkg.com/three@0.184.0/examples/jsm/exporters/OBJExporter.js",
|
||||||
|
* "three/addons/exporters/GLTFExporter.js": "https://unpkg.com/three@0.184.0/examples/jsm/exporters/GLTFExporter.js"
|
||||||
|
* },
|
||||||
|
* "integrity": {
|
||||||
|
* "https://unpkg.com/three@0.184.0/build/three.module.js": "sha384-8FCZ1eVO6it4+pbec2aDtnTrwjWXZLJRC+MAGCIPDgsYnUrl/E0A2YlF8ioMKI/J",
|
||||||
|
* "https://unpkg.com/three@0.184.0/build/three.core.js": "sha384-dw2ooPewaEIrAgl6oFDBmmBWCE9oW9LxRGcfwZ0hLvEprzo202wXl7vCYHRlSnOT",
|
||||||
|
* "https://unpkg.com/three@0.184.0/examples/jsm/controls/OrbitControls.js": "sha384-4rziNxOBZKQ69i+w+f89KJ55TCYquwchVbByQwmaOeIOXdOU2PLDn3kOfXHwIJC9",
|
||||||
|
* "https://unpkg.com/three@0.184.0/examples/jsm/exporters/OBJExporter.js": "sha384-nbwtoZENJD3Vq+ACK0CuGQdPMuDWHkamC2KJD70EV5nfg6jQjfppKOea07YJN+N3",
|
||||||
|
* "https://unpkg.com/three@0.184.0/examples/jsm/exporters/GLTFExporter.js": "sha384-VofkvpG6HERhFCYbsUOHeNXBCqID2nfqkQqnVzE1jc/oPcz+qJ13ADdXH08hE+cQ"
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* </script>
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* <style>three-d-stage:not(:defined){visibility:hidden}</style>
|
||||||
|
* <three-d-stage name="rocket"></three-d-stage>
|
||||||
|
* <script src="three-d-stage.js"></script>
|
||||||
|
* <script type="module">
|
||||||
|
* const stage = document.querySelector('three-d-stage');
|
||||||
|
* const { THREE } = await stage.ready;
|
||||||
|
* const model = new THREE.Group();
|
||||||
|
* // …build the model out of named meshes with named materials —
|
||||||
|
* // the names become the o / usemtl entries in the exported OBJ…
|
||||||
|
* stage.setObject(model);
|
||||||
|
* </script>
|
||||||
|
*
|
||||||
|
* Attributes:
|
||||||
|
* name — export file basename (default "model")
|
||||||
|
* background — CSS color behind the scene (default a warm paper tone)
|
||||||
|
* autorotate — when present, a slow turntable until the user interacts
|
||||||
|
*
|
||||||
|
* Model in real-world meters, centered on the origin, y-up — exports
|
||||||
|
* inherit the scene's units and orientation. The stage fills its own box;
|
||||||
|
* size it with ordinary CSS (default 100vw/100vh page hero).
|
||||||
|
*
|
||||||
|
* Default setup: neutral studio lighting (hemisphere + key + fill), a
|
||||||
|
* soft ground shadow, and NO environment map — so high metalness has
|
||||||
|
* nothing to reflect and renders near-black. Cap metalness around
|
||||||
|
* 0.3–0.4 and carry a metal look with a brighter base color. The copied
|
||||||
|
* file is yours: adjust the lights, shadow, or background in _boot()
|
||||||
|
* when the object needs a different look.
|
||||||
|
*/
|
||||||
|
/* END USAGE */
|
||||||
|
|
||||||
|
(() => {
|
||||||
|
const stylesheet = `
|
||||||
|
:host {
|
||||||
|
position: relative;
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100vh;
|
||||||
|
background: var(--stage-bg, #f0eee6);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
canvas { display: block; outline: none; }
|
||||||
|
.toolbar {
|
||||||
|
position: absolute;
|
||||||
|
right: 16px;
|
||||||
|
bottom: 16px;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
|
}
|
||||||
|
.toolbar button {
|
||||||
|
appearance: none;
|
||||||
|
border: 1px solid rgba(20, 20, 19, 0.18);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.92);
|
||||||
|
color: #1a1915;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 9px 12px;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.toolbar button:hover { background: #fff; }
|
||||||
|
.toolbar button:active { transform: translateY(1px); }
|
||||||
|
.toolbar button[disabled] { opacity: 0.5; pointer-events: none; }
|
||||||
|
.note {
|
||||||
|
position: absolute;
|
||||||
|
left: 16px;
|
||||||
|
bottom: 16px;
|
||||||
|
max-width: 60%;
|
||||||
|
font: 400 12px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
|
color: rgba(26, 25, 21, 0.55);
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.err {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px;
|
||||||
|
font: 500 14px/1.6 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
|
color: #8a2f20;
|
||||||
|
text-align: center;
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
function download(blob, filename) {
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 4000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tell the host an export attempt settled — telemetry only. The host
|
||||||
|
* (HTMLViewer) verifies the source and re-reads these fields defensively
|
||||||
|
* before counting; nothing else crosses the frame boundary. Guarded so
|
||||||
|
* telemetry can never break the download path. */
|
||||||
|
function notifyExport(format, ok) {
|
||||||
|
try {
|
||||||
|
window.parent.postMessage(
|
||||||
|
{ type: 'omelette:notify-3d-export', format: format, ok: ok === true },
|
||||||
|
'*'
|
||||||
|
);
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ThreeDStage extends HTMLElement {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
const root = this.attachShadow({ mode: 'open' });
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.textContent = stylesheet;
|
||||||
|
root.appendChild(style);
|
||||||
|
this._err = document.createElement('div');
|
||||||
|
this._err.className = 'err';
|
||||||
|
root.appendChild(this._err);
|
||||||
|
const note = document.createElement('div');
|
||||||
|
note.className = 'note';
|
||||||
|
note.textContent = 'Drag to orbit · scroll to zoom · right-drag to pan';
|
||||||
|
root.appendChild(note);
|
||||||
|
this._toolbar = document.createElement('div');
|
||||||
|
this._toolbar.className = 'toolbar';
|
||||||
|
this._objBtn = document.createElement('button');
|
||||||
|
this._objBtn.type = 'button';
|
||||||
|
this._objBtn.textContent = 'Download OBJ + MTL';
|
||||||
|
this._objBtn.addEventListener('click', () => this._runExport('obj'));
|
||||||
|
this._glbBtn = document.createElement('button');
|
||||||
|
this._glbBtn.type = 'button';
|
||||||
|
this._glbBtn.textContent = 'Download GLB';
|
||||||
|
this._glbBtn.addEventListener('click', () => this._runExport('glb'));
|
||||||
|
this._toolbar.appendChild(this._objBtn);
|
||||||
|
this._toolbar.appendChild(this._glbBtn);
|
||||||
|
root.appendChild(this._toolbar);
|
||||||
|
this._setButtonsEnabled(false);
|
||||||
|
/** Resolves with { THREE } once the scene is live — build the model
|
||||||
|
* in `await stage.ready` so nothing races the library load. */
|
||||||
|
this.ready = new Promise((resolve, reject) => {
|
||||||
|
this._readyResolve = resolve;
|
||||||
|
this._readyReject = reject;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
connectedCallback() {
|
||||||
|
if (this._booted) {
|
||||||
|
// Re-attached after a removal — resume what disconnected stopped.
|
||||||
|
if (this._renderer) {
|
||||||
|
this._renderer.setAnimationLoop(this._loop);
|
||||||
|
this._ro && this._ro.observe(this);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._booted = true;
|
||||||
|
this._boot().catch((err) => {
|
||||||
|
this._err.style.display = 'flex';
|
||||||
|
this._err.textContent =
|
||||||
|
'three.js failed to load.\n' +
|
||||||
|
'Check that the pinned <script type="importmap"> from the usage ' +
|
||||||
|
'notes is in <head> before any module script.\n\n' +
|
||||||
|
String(err && err.message ? err.message : err);
|
||||||
|
this._readyReject(err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async _boot() {
|
||||||
|
const bg = this.getAttribute('background');
|
||||||
|
if (bg) this.style.setProperty('--stage-bg', bg);
|
||||||
|
const [THREE, controlsMod] = await Promise.all([
|
||||||
|
import('three'),
|
||||||
|
import('three/addons/controls/OrbitControls.js'),
|
||||||
|
]);
|
||||||
|
this._THREE = THREE;
|
||||||
|
// preserveDrawingBuffer keeps the last frame readable after
|
||||||
|
// compositing (toDataURL / drawImage) — it's what lets the
|
||||||
|
// screenshot tools capture the scene instead of a blank canvas.
|
||||||
|
const renderer = new THREE.WebGLRenderer({
|
||||||
|
antialias: true,
|
||||||
|
alpha: true,
|
||||||
|
preserveDrawingBuffer: true,
|
||||||
|
});
|
||||||
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
|
||||||
|
renderer.shadowMap.enabled = true;
|
||||||
|
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
||||||
|
this._renderer = renderer;
|
||||||
|
this.shadowRoot.insertBefore(renderer.domElement, this._err);
|
||||||
|
|
||||||
|
const scene = new THREE.Scene();
|
||||||
|
this._scene = scene;
|
||||||
|
|
||||||
|
const camera = new THREE.PerspectiveCamera(45, 1, 0.01, 500);
|
||||||
|
camera.position.set(3, 2.2, 4);
|
||||||
|
this._camera = camera;
|
||||||
|
|
||||||
|
const controls = new controlsMod.OrbitControls(camera, renderer.domElement);
|
||||||
|
controls.enableDamping = true;
|
||||||
|
controls.dampingFactor = 0.08;
|
||||||
|
this._controls = controls;
|
||||||
|
|
||||||
|
// Neutral studio: soft sky/ground wash, a shadow-casting key light,
|
||||||
|
// and a dim fill from behind so silhouettes never go black.
|
||||||
|
scene.add(new THREE.HemisphereLight(0xffffff, 0xd8d2c4, 1.0));
|
||||||
|
const key = new THREE.DirectionalLight(0xffffff, 2.2);
|
||||||
|
key.position.set(4, 7, 5);
|
||||||
|
key.castShadow = true;
|
||||||
|
key.shadow.mapSize.set(2048, 2048);
|
||||||
|
key.shadow.bias = -0.0002;
|
||||||
|
this._key = key;
|
||||||
|
scene.add(key);
|
||||||
|
const fill = new THREE.DirectionalLight(0xfff4e6, 0.5);
|
||||||
|
fill.position.set(-5, 3, -4);
|
||||||
|
scene.add(fill);
|
||||||
|
|
||||||
|
const ground = new THREE.Mesh(
|
||||||
|
new THREE.PlaneGeometry(200, 200),
|
||||||
|
new THREE.ShadowMaterial({ opacity: 0.18 })
|
||||||
|
);
|
||||||
|
ground.rotation.x = -Math.PI / 2;
|
||||||
|
ground.receiveShadow = true;
|
||||||
|
this._ground = ground;
|
||||||
|
scene.add(ground);
|
||||||
|
|
||||||
|
this._autorotate = this.hasAttribute('autorotate');
|
||||||
|
controls.autoRotate = this._autorotate;
|
||||||
|
controls.autoRotateSpeed = 1.2;
|
||||||
|
controls.addEventListener('start', () => {
|
||||||
|
controls.autoRotate = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
const fit = () => {
|
||||||
|
const w = this.clientWidth || 1;
|
||||||
|
const h = this.clientHeight || 1;
|
||||||
|
renderer.setSize(w, h);
|
||||||
|
camera.aspect = w / h;
|
||||||
|
camera.updateProjectionMatrix();
|
||||||
|
};
|
||||||
|
fit();
|
||||||
|
this._ro = new ResizeObserver(fit);
|
||||||
|
this._loop = () => {
|
||||||
|
controls.update();
|
||||||
|
renderer.render(scene, camera);
|
||||||
|
};
|
||||||
|
// Detached while three.js was fetching? Stay idle — the
|
||||||
|
// connectedCallback resume starts the loop and observer on
|
||||||
|
// reattach.
|
||||||
|
if (this.isConnected) {
|
||||||
|
this._ro.observe(this);
|
||||||
|
renderer.setAnimationLoop(this._loop);
|
||||||
|
}
|
||||||
|
|
||||||
|
this._readyResolve({ THREE });
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnectedCallback() {
|
||||||
|
// Stop rendering and observing while detached; connectedCallback
|
||||||
|
// resumes both. (The renderer itself is kept — a move within the
|
||||||
|
// document must not rebuild the scene.)
|
||||||
|
if (this._renderer) this._renderer.setAnimationLoop(null);
|
||||||
|
if (this._ro) this._ro.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Show (and own) the object. Replaces any previous object, enables
|
||||||
|
* shadows on every mesh, rests it on the ground plane, and frames
|
||||||
|
* the camera to its bounds. */
|
||||||
|
setObject(object) {
|
||||||
|
const THREE = this._THREE;
|
||||||
|
if (!THREE) throw new Error('three-d-stage: not ready — await stage.ready first');
|
||||||
|
if (this._object) this._scene.remove(this._object);
|
||||||
|
this._object = object;
|
||||||
|
object.traverse((o) => {
|
||||||
|
if (o.isMesh) {
|
||||||
|
o.castShadow = true;
|
||||||
|
o.receiveShadow = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const box = new THREE.Box3().setFromObject(object);
|
||||||
|
if (!box.isEmpty()) {
|
||||||
|
// Rest the object on the ground without moving its origin.
|
||||||
|
this._ground.position.y = box.min.y;
|
||||||
|
const sphere = box.getBoundingSphere(new THREE.Sphere());
|
||||||
|
const dist =
|
||||||
|
(sphere.radius / Math.tan((this._camera.fov * Math.PI) / 360)) * 1.35;
|
||||||
|
const dir = new THREE.Vector3(1, 0.55, 1.25).normalize();
|
||||||
|
this._camera.position
|
||||||
|
.copy(sphere.center)
|
||||||
|
.add(dir.multiplyScalar(dist));
|
||||||
|
this._camera.near = Math.max(dist / 100, 0.01);
|
||||||
|
this._camera.far = dist * 100;
|
||||||
|
this._camera.updateProjectionMatrix();
|
||||||
|
this._controls.target.copy(sphere.center);
|
||||||
|
this._controls.update();
|
||||||
|
const span = sphere.radius * 3;
|
||||||
|
this._key.shadow.camera.left = -span;
|
||||||
|
this._key.shadow.camera.right = span;
|
||||||
|
this._key.shadow.camera.top = span;
|
||||||
|
this._key.shadow.camera.bottom = -span;
|
||||||
|
this._key.shadow.camera.updateProjectionMatrix();
|
||||||
|
}
|
||||||
|
this._scene.add(object);
|
||||||
|
this._setButtonsEnabled(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
get _basename() {
|
||||||
|
return (this.getAttribute('name') || 'model').replace(/[^\w.-]+/g, '_');
|
||||||
|
}
|
||||||
|
|
||||||
|
_setButtonsEnabled(on) {
|
||||||
|
this._objBtn.disabled = !on;
|
||||||
|
this._glbBtn.disabled = !on;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every mesh and material needs a unique name for o/usemtl lines —
|
||||||
|
* fill in stable fallbacks, and return the unique material list. */
|
||||||
|
_nameParts() {
|
||||||
|
const mats = [];
|
||||||
|
const seen = new Set();
|
||||||
|
let meshI = 0;
|
||||||
|
let matI = 0;
|
||||||
|
this._object.traverse((o) => {
|
||||||
|
if (!o.isMesh) return;
|
||||||
|
if (!o.name) o.name = 'part_' + meshI;
|
||||||
|
meshI += 1;
|
||||||
|
const list = Array.isArray(o.material) ? o.material : [o.material];
|
||||||
|
for (const m of list) {
|
||||||
|
if (!m || mats.includes(m)) continue;
|
||||||
|
if (!m.name) {
|
||||||
|
m.name = 'mat_' + matI;
|
||||||
|
matI += 1;
|
||||||
|
}
|
||||||
|
while (seen.has(m.name)) {
|
||||||
|
m.name = m.name + '_' + matI;
|
||||||
|
matI += 1;
|
||||||
|
}
|
||||||
|
seen.add(m.name);
|
||||||
|
mats.push(m);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return mats;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One export attempt, reported to the host however it settles.
|
||||||
|
* Rethrows so a failure stays visible on the guest console exactly as
|
||||||
|
* before. The no-object early return is not an attempt (the toolbar is
|
||||||
|
* disabled until the model loads) and reports nothing. */
|
||||||
|
async _runExport(format) {
|
||||||
|
if (!this._object) return;
|
||||||
|
try {
|
||||||
|
await (format === 'obj' ? this._exportObj() : this._exportGlb());
|
||||||
|
notifyExport(format, true);
|
||||||
|
} catch (err) {
|
||||||
|
notifyExport(format, false);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async _exportObj() {
|
||||||
|
if (!this._object) return;
|
||||||
|
const mod = await import('three/addons/exporters/OBJExporter.js');
|
||||||
|
const mats = this._nameParts();
|
||||||
|
const base = this._basename;
|
||||||
|
const obj =
|
||||||
|
'mtllib ' + base + '.mtl\n' + new mod.OBJExporter().parse(this._object);
|
||||||
|
let mtl = '# Exported by three-d-stage\n';
|
||||||
|
for (const m of mats) {
|
||||||
|
const c = m.color || { r: 0.8, g: 0.8, b: 0.8 };
|
||||||
|
const rough = typeof m.roughness === 'number' ? m.roughness : 0.5;
|
||||||
|
const opacity = typeof m.opacity === 'number' ? m.opacity : 1;
|
||||||
|
mtl += 'newmtl ' + m.name + '\n';
|
||||||
|
mtl +=
|
||||||
|
'Kd ' + c.r.toFixed(4) + ' ' + c.g.toFixed(4) + ' ' + c.b.toFixed(4) + '\n';
|
||||||
|
mtl += 'Ks 0.2000 0.2000 0.2000\n';
|
||||||
|
mtl += 'Ns ' + Math.round((1 - rough) * 200) + '\n';
|
||||||
|
mtl += 'd ' + opacity.toFixed(4) + '\n\n';
|
||||||
|
}
|
||||||
|
download(new Blob([obj], { type: 'text/plain' }), base + '.obj');
|
||||||
|
download(new Blob([mtl], { type: 'text/plain' }), base + '.mtl');
|
||||||
|
}
|
||||||
|
|
||||||
|
async _exportGlb() {
|
||||||
|
if (!this._object) return;
|
||||||
|
const mod = await import('three/addons/exporters/GLTFExporter.js');
|
||||||
|
this._nameParts();
|
||||||
|
const base = this._basename;
|
||||||
|
const buf = await new mod.GLTFExporter().parseAsync(this._object, {
|
||||||
|
binary: true,
|
||||||
|
});
|
||||||
|
download(
|
||||||
|
new Blob([buf], { type: 'model/gltf-binary' }),
|
||||||
|
base + '.glb'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define('three-d-stage', ThreeDStage);
|
||||||
|
})();
|
||||||
@@ -61,6 +61,19 @@ general:
|
|||||||
# a half-finished .tmp - is ignored.
|
# a half-finished .tmp - is ignored.
|
||||||
audio_extensions: [".mp3", ".ogg", ".oga", ".opus", ".flac", ".wav", ".m4a", ".aac"]
|
audio_extensions: [".mp3", ".ogg", ".oga", ".opus", ".flac", ".wav", ".m4a", ".aac"]
|
||||||
|
|
||||||
|
# How many episodes of each podcast show to keep, newest first. A show that has
|
||||||
|
# published for years grows without bound: GEOlino Spezial alone is 358 episodes and
|
||||||
|
# 5.8 GB, which will not sit next to the rest of a library on a Pi's SD card.
|
||||||
|
#
|
||||||
|
# The same number caps what gets downloaded, which is what makes the folder settle.
|
||||||
|
# Prune to the newest N but fetch everything the feed offers, and every poll would
|
||||||
|
# re-download the episodes the last one deleted.
|
||||||
|
#
|
||||||
|
# Lowering this DELETES the episodes that fall outside the window on the next poll,
|
||||||
|
# and an episode that has aged out of its feed cannot be fetched again. Use `null` to
|
||||||
|
# keep every episode and mind the free space yourself.
|
||||||
|
podcast_episode_limit: 50
|
||||||
|
|
||||||
# The web front-end. Omit the whole section to run without it.
|
# The web front-end. Omit the whole section to run without it.
|
||||||
#
|
#
|
||||||
# There is no authentication: this is a device on a home network. The settings panel
|
# There is no authentication: this is a device on a home network. The settings panel
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import logging
|
|||||||
import sys
|
import sys
|
||||||
from collections.abc import Awaitable, Callable, Coroutine
|
from collections.abc import Awaitable, Callable, Coroutine
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any, TypeVar
|
||||||
|
|
||||||
import httpx2
|
import httpx2
|
||||||
|
|
||||||
@@ -324,7 +324,10 @@ def _build_app(
|
|||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
def _service_of_type[T: Service](services: list[Service], kind: type[T]) -> T | None:
|
T = TypeVar("T", bound=Service)
|
||||||
|
|
||||||
|
|
||||||
|
def _service_of_type(services: list[Service], kind: type[T]) -> T | None:
|
||||||
return next((s for s in services if isinstance(s, kind)), None)
|
return next((s for s in services if isinstance(s, kind)), None)
|
||||||
|
|
||||||
|
|
||||||
@@ -378,6 +381,7 @@ def _build_services(
|
|||||||
on_change=lambda: app.rescan_library(
|
on_change=lambda: app.rescan_library(
|
||||||
broadcast=web_service.hub.broadcast_library if web_service else None
|
broadcast=web_service.hub.broadcast_library if web_service else None
|
||||||
),
|
),
|
||||||
|
episode_limit=app.config.general.podcast_episode_limit,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import contextlib
|
|||||||
import inspect
|
import inspect
|
||||||
import logging
|
import logging
|
||||||
from collections.abc import Callable, Coroutine
|
from collections.abc import Callable, Coroutine
|
||||||
from typing import Any
|
from typing import Any, TypeAlias, TypeVar
|
||||||
|
|
||||||
from musicmouse.events import Event
|
from musicmouse.events import Event
|
||||||
|
|
||||||
@@ -24,8 +24,12 @@ _log = logging.getLogger(__name__)
|
|||||||
|
|
||||||
__all__ = ["EventBus", "Handler", "Unsubscribe"]
|
__all__ = ["EventBus", "Handler", "Unsubscribe"]
|
||||||
|
|
||||||
type Handler[E: Event] = Callable[[E], Coroutine[Any, Any, None] | None]
|
#: An alias carrying a TypeVar is generic on its own, so ``Handler[SomeEvent]`` still
|
||||||
type Unsubscribe = Callable[[], None]
|
#: parameterises it the way the PEP 695 form did.
|
||||||
|
E = TypeVar("E", bound=Event)
|
||||||
|
|
||||||
|
Handler: TypeAlias = Callable[[E], Coroutine[Any, Any, None] | None]
|
||||||
|
Unsubscribe: TypeAlias = Callable[[], None]
|
||||||
|
|
||||||
|
|
||||||
class EventBus:
|
class EventBus:
|
||||||
@@ -63,7 +67,7 @@ class EventBus:
|
|||||||
|
|
||||||
# --------------------------------------------------------------- subscription
|
# --------------------------------------------------------------- subscription
|
||||||
|
|
||||||
def subscribe[E: Event](self, event_type: type[E], handler: Handler[E]) -> Unsubscribe:
|
def subscribe(self, event_type: type[E], handler: Handler[E]) -> Unsubscribe:
|
||||||
"""Register ``handler`` for ``event_type`` and any subclass of it."""
|
"""Register ``handler`` for ``event_type`` and any subclass of it."""
|
||||||
self._handlers.setdefault(event_type, []).append(handler)
|
self._handlers.setdefault(event_type, []).append(handler)
|
||||||
self._resolved.clear()
|
self._resolved.clear()
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Annotated, Any, Final, Literal, Self
|
from typing import Annotated, Any, Final, Literal, Self, TypeAlias
|
||||||
|
|
||||||
from pydantic import (
|
from pydantic import (
|
||||||
BaseModel,
|
BaseModel,
|
||||||
@@ -26,6 +26,7 @@ from ruamel.yaml.error import YAMLError
|
|||||||
|
|
||||||
from musicmouse.color import ColorRGBW, parse_color
|
from musicmouse.color import ColorRGBW, parse_color
|
||||||
from musicmouse.hardware import NO_FIGURE_TAG, RFID_TAG_LENGTH
|
from musicmouse.hardware import NO_FIGURE_TAG, RFID_TAG_LENGTH
|
||||||
|
from musicmouse.library.podcast_feeds import DEFAULT_EPISODE_LIMIT
|
||||||
|
|
||||||
_log = logging.getLogger(__name__)
|
_log = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -50,7 +51,7 @@ __all__ = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
#: Number keys on the IR remote, as lircd's ``BTN_0``..``BTN_9`` map to them.
|
#: Number keys on the IR remote, as lircd's ``BTN_0``..``BTN_9`` map to them.
|
||||||
type Digit = Literal["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"]
|
Digit: TypeAlias = Literal["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"]
|
||||||
|
|
||||||
DEFAULT_AUDIO_EXTENSIONS = (".mp3", ".ogg", ".oga", ".opus", ".flac", ".wav", ".m4a", ".aac")
|
DEFAULT_AUDIO_EXTENSIONS = (".mp3", ".ogg", ".oga", ".opus", ".flac", ".wav", ".m4a", ".aac")
|
||||||
|
|
||||||
@@ -288,6 +289,14 @@ class GeneralConfig(_Strict):
|
|||||||
|
|
||||||
audio_extensions: tuple[str, ...] = DEFAULT_AUDIO_EXTENSIONS
|
audio_extensions: tuple[str, ...] = DEFAULT_AUDIO_EXTENSIONS
|
||||||
|
|
||||||
|
#: How many episodes of each podcast show to keep, newest first. A show that has
|
||||||
|
#: published for years grows without bound and will eventually fill the device's SD
|
||||||
|
#: card. ``null`` keeps every episode, and minding the free space is then on you.
|
||||||
|
#:
|
||||||
|
#: Lowering this *deletes* the episodes that fall outside the window on the next
|
||||||
|
#: poll, and an episode that has aged out of its feed cannot be fetched again.
|
||||||
|
podcast_episode_limit: int | None = Field(default=DEFAULT_EPISODE_LIMIT, ge=1)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def serial_simulated(self) -> bool:
|
def serial_simulated(self) -> bool:
|
||||||
return self.serial_port == SIMULATE
|
return self.serial_port == SIMULATE
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ from __future__ import annotations
|
|||||||
import struct
|
import struct
|
||||||
from dataclasses import dataclass, replace
|
from dataclasses import dataclass, replace
|
||||||
from enum import IntEnum
|
from enum import IntEnum
|
||||||
from typing import override
|
from typing import TypeAlias
|
||||||
|
|
||||||
from musicmouse.effects import (
|
from musicmouse.effects import (
|
||||||
EffectAlexaSwipeConfig,
|
EffectAlexaSwipeConfig,
|
||||||
@@ -162,7 +162,7 @@ class FirmwareLog:
|
|||||||
text: str
|
text: str
|
||||||
|
|
||||||
|
|
||||||
type Decoded = InputEvent | FirmwareLog
|
Decoded: TypeAlias = InputEvent | FirmwareLog
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------------- encoding
|
# ------------------------------------------------------------------------- encoding
|
||||||
@@ -367,12 +367,11 @@ class SetButtonBrightness:
|
|||||||
button: Button
|
button: Button
|
||||||
brightness: float
|
brightness: float
|
||||||
|
|
||||||
@override
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
return f"{self.button.slug} backlight <- {self.brightness:.2f}"
|
return f"{self.button.slug} backlight <- {self.brightness:.2f}"
|
||||||
|
|
||||||
|
|
||||||
type HostCommand = SetEffect | SetButtonBrightness
|
HostCommand: TypeAlias = SetEffect | SetButtonBrightness
|
||||||
|
|
||||||
_ID_TO_EFFECT: dict[int, tuple[LedZone, type[LedEffect]]] = {
|
_ID_TO_EFFECT: dict[int, tuple[LedZone, type[LedEffect]]] = {
|
||||||
message: (zone, effect_cls)
|
message: (zone, effect_cls)
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Literal
|
from typing import Literal, TypeAlias
|
||||||
|
|
||||||
from musicmouse.effects import LedEffect
|
from musicmouse.effects import LedEffect
|
||||||
from musicmouse.hardware import Button, ButtonAction, LedZone, RotaryDirection, TouchButton
|
from musicmouse.hardware import Button, ButtonAction, LedZone, RotaryDirection, TouchButton
|
||||||
@@ -53,7 +53,7 @@ __all__ = [
|
|||||||
"VolumeChanged",
|
"VolumeChanged",
|
||||||
]
|
]
|
||||||
|
|
||||||
type EventSource = Literal["device", "player", "mqtt", "web", "lirc", "simulator", "system"]
|
EventSource: TypeAlias = Literal["device", "player", "mqtt", "web", "lirc", "simulator", "system"]
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||||
|
|||||||
@@ -5,7 +5,14 @@ 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>.jpg medium: art pulled out of an ID3 APIC frame
|
├── 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
|
||||||
|
│ frame, or copied from a cover.jpg in the
|
||||||
|
│ folder - downscaled to MAX_COVER_PX on
|
||||||
|
│ the way in. An album's `cover` always
|
||||||
|
│ points here and never into the library,
|
||||||
|
│ so nothing can serve full-size art by
|
||||||
|
│ accident.
|
||||||
└── analysis/<track_key>.json expensive: minutes of DSP per track
|
└── analysis/<track_key>.json expensive: minutes of DSP per track
|
||||||
analysis/<track_key>.beats.json
|
analysis/<track_key>.beats.json
|
||||||
analysis/<track_key>.curve.json
|
analysis/<track_key>.curve.json
|
||||||
@@ -18,12 +25,15 @@ folder or re-sorting a section then costs nothing.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, cast
|
from typing import Any, Final, cast
|
||||||
|
|
||||||
|
from PIL import Image, UnidentifiedImageError
|
||||||
|
|
||||||
from musicmouse.library.analysis import BeatGrid, TrackAnalysis, TrackCurves
|
from musicmouse.library.analysis import BeatGrid, TrackAnalysis, TrackCurves
|
||||||
from musicmouse.library.models import Album, LibraryTrack
|
from musicmouse.library.models import Album, LibraryTrack
|
||||||
@@ -37,7 +47,16 @@ __all__ = ["Fingerprint", "LibraryCache"]
|
|||||||
#: worked out - not just when the JSON shape does. A cached entry is reused whenever its
|
#: worked out - not just when the JSON shape does. A cached entry is reused whenever its
|
||||||
#: files are untouched, so otherwise a change to that logic is invisible until somebody
|
#: files are untouched, so otherwise a change to that logic is invisible until somebody
|
||||||
#: edits their music folder.
|
#: edits their music folder.
|
||||||
_INDEX_VERSION = 4
|
# 5: every album's `cover` now points into this cache, downscaled, including the ones
|
||||||
|
# that used to point straight at a `cover.jpg` in the library folder.
|
||||||
|
# 6: MAX_COVER_PX dropped from 640 to 384. This is what applies it. `shrink_cover` never
|
||||||
|
# scales art *up*, so a cover already written at some size is only ever rewritten
|
||||||
|
# smaller - which means a cache holding 640 px files cannot be brought to 384 px by
|
||||||
|
# re-reading them, only by going back to the original art. Discarding the index does
|
||||||
|
# exactly that: every album is scanned again and `store_cover` runs on the real art.
|
||||||
|
#
|
||||||
|
# Bumping this is cheap by design - see the module docstring.
|
||||||
|
_INDEX_VERSION = 6
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
@@ -84,11 +103,64 @@ 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)
|
||||||
path.write_bytes(data)
|
# 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))
|
||||||
|
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:
|
||||||
|
"""Rewrite any already-stored cover that predates :data:`MAX_COVER_PX`.
|
||||||
|
|
||||||
|
Needed because :func:`~musicmouse.library.scanner.scan_library` reuses an album
|
||||||
|
whose fingerprint is unchanged *without* re-reading its tags, so a cover written
|
||||||
|
by an older version would otherwise never be touched again. Reading a JPEG's
|
||||||
|
dimensions only parses its header, so once every file is within the limit this
|
||||||
|
costs one small read per album and nothing else.
|
||||||
|
|
||||||
|
Also backfills any missing (or stale) thumbnail. Returns the number of full-size
|
||||||
|
files actually rewritten.
|
||||||
|
"""
|
||||||
|
rewritten = 0
|
||||||
|
for path in sorted(self.covers.glob("*.jpg")):
|
||||||
|
if path.name.endswith(".thumb.jpg"):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
with Image.open(path) as image:
|
||||||
|
oversized = max(image.size) > MAX_COVER_PX
|
||||||
|
except (OSError, UnidentifiedImageError):
|
||||||
|
continue
|
||||||
|
if oversized:
|
||||||
|
try:
|
||||||
|
shrunk = shrink_cover(path.read_bytes())
|
||||||
|
path.write_bytes(shrunk)
|
||||||
|
except OSError: # pragma: no cover - a cache we cannot write is not fatal
|
||||||
|
continue
|
||||||
|
rewritten += 1
|
||||||
|
# Covers stored before thumbnails existed get theirs here, without a rescan.
|
||||||
|
self._refresh_thumb(path)
|
||||||
|
return rewritten
|
||||||
|
|
||||||
# ------------------------------------------------------------------ analysis
|
# ------------------------------------------------------------------ analysis
|
||||||
|
|
||||||
def load_analysis(self, key: str) -> TrackAnalysis | None:
|
def load_analysis(self, key: str) -> TrackAnalysis | None:
|
||||||
@@ -213,3 +285,78 @@ def _album_from_json(data: dict[str, Any]) -> Album:
|
|||||||
for track in data["tracks"]
|
for track in data["tracks"]
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------- covers
|
||||||
|
|
||||||
|
#: Longest edge kept for cached album art, in pixels.
|
||||||
|
#:
|
||||||
|
#: The art that comes out of an ID3 APIC frame is sized for a record sleeve, not for a
|
||||||
|
#: screen: a real library here averaged 3000x3000 and 580 kB per cover, 140 MB for 284
|
||||||
|
#: albums. The browser was decoding nine megapixels - about 36 MB of bitmap - for every
|
||||||
|
#: cover it painted, to show it in a 185 px card on a Pi with 2 GB of RAM.
|
||||||
|
#:
|
||||||
|
#: 340 is the largest any of this app's screens asks for (the play view; the browse grid
|
||||||
|
#: asks for 180 and the player bar for 56), so this only has to beat 340 to be lossless
|
||||||
|
#: where it shows.
|
||||||
|
#:
|
||||||
|
#: It was 640 first, for headroom on a tablet at devicePixelRatio 2, and that headroom
|
||||||
|
#: turned out to be expensive on the device that actually runs this. Measured on
|
||||||
|
#: musicdolphin, typing "conni" over a 343-album library, keydown to painted, worst
|
||||||
|
#: keystroke of three runs: 3.4-9.1 s at 640 px against 0.2 s at 384 px. Decode cost
|
||||||
|
#: goes with pixel count, and a browse grid paints a card 132 px wide.
|
||||||
|
#:
|
||||||
|
#: A tablet at devicePixelRatio 2 therefore gets a slightly soft cover on the play view
|
||||||
|
#: and nowhere else. That is the trade: a visible sharpness margin nobody asked for, for
|
||||||
|
#: a search box that answers a keystroke in a frame.
|
||||||
|
MAX_COVER_PX: Final = 384
|
||||||
|
|
||||||
|
#: Re-encode quality. At these dimensions the difference from 95 is invisible and the
|
||||||
|
#: file is a third of the size.
|
||||||
|
_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
|
||||||
|
|
||||||
|
#: Baseline JPEG, 4:2:0 (see :func:`shrink_cover`). libjpeg-turbo is the fastest decoder
|
||||||
|
#: 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
|
||||||
|
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
|
||||||
|
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:
|
||||||
|
with Image.open(io.BytesIO(data)) as image:
|
||||||
|
if max(image.size) <= max_px:
|
||||||
|
return data
|
||||||
|
# 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")
|
||||||
|
# `thumbnail` keeps the aspect ratio and never scales up.
|
||||||
|
image.thumbnail((max_px, max_px), Image.Resampling.LANCZOS)
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
image.save(
|
||||||
|
buffer,
|
||||||
|
format="JPEG",
|
||||||
|
quality=quality,
|
||||||
|
optimize=True,
|
||||||
|
progressive=False,
|
||||||
|
subsampling="4:2:0",
|
||||||
|
)
|
||||||
|
return buffer.getvalue()
|
||||||
|
except (OSError, UnidentifiedImageError, ValueError):
|
||||||
|
return data
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|||||||
import hashlib
|
import hashlib
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import TypeAlias
|
||||||
|
|
||||||
from musicmouse.library.analysis import TrackAnalysis
|
from musicmouse.library.analysis import TrackAnalysis
|
||||||
from musicmouse.library.sections import AlbumKind
|
from musicmouse.library.sections import AlbumKind
|
||||||
@@ -15,7 +16,7 @@ __all__ = ["Album", "AlbumColors", "LibraryTrack", "album_id", "track_key"]
|
|||||||
#: Primary, secondary and accent as ``"#rrggbb"`` - the format
|
#: Primary, secondary and accent as ``"#rrggbb"`` - the format
|
||||||
#: :func:`musicmouse.color.parse_color` already accepts, so the LED side needs no new
|
#: :func:`musicmouse.color.parse_color` already accepts, so the LED side needs no new
|
||||||
#: parsing and the frontend gets CSS colours for free.
|
#: parsing and the frontend gets CSS colours for free.
|
||||||
type AlbumColors = tuple[str, str, str]
|
AlbumColors: TypeAlias = tuple[str, str, str]
|
||||||
|
|
||||||
|
|
||||||
def album_id(root: Path, folder: Path) -> str:
|
def album_id(root: Path, folder: Path) -> str:
|
||||||
|
|||||||
@@ -12,6 +12,12 @@ a feed offers real per-episode artwork, it's saved alongside as a same-named sid
|
|||||||
image, which ``scanner.py``'s ``_cover_for_episode`` picks up automatically. A
|
image, which ``scanner.py``'s ``_cover_for_episode`` picks up automatically. A
|
||||||
video-only enclosure (some shows publish no audio feed at all) is transcoded to audio
|
video-only enclosure (some shows publish no audio feed at all) is transcoded to audio
|
||||||
via the ``ffmpeg`` binary, which must be on ``PATH`` for those shows to sync.
|
via the ``ffmpeg`` binary, which must be on ``PATH`` for those shows to sync.
|
||||||
|
A show folder is also kept to a fixed number of the newest episodes
|
||||||
|
(:data:`DEFAULT_EPISODE_LIMIT`), so a long-running feed cannot fill the device's disk.
|
||||||
|
The same limit caps what is downloaded, which is what stops the two halves fighting:
|
||||||
|
prune what is older than the newest N, download only the newest N, and the folder
|
||||||
|
settles instead of re-fetching every episode it just deleted.
|
||||||
|
|
||||||
Nothing here raises on bad input - an unreachable feed or a broken enclosure is logged
|
Nothing here raises on bad input - an unreachable feed or a broken enclosure is logged
|
||||||
and skipped, not a crash, mirroring ``scanner.py``'s own rule.
|
and skipped, not a crash, mirroring ``scanner.py``'s own rule.
|
||||||
"""
|
"""
|
||||||
@@ -37,6 +43,7 @@ from musicmouse.library.sections import SECTIONS
|
|||||||
_log = logging.getLogger(__name__)
|
_log = logging.getLogger(__name__)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"DEFAULT_EPISODE_LIMIT",
|
||||||
"FEED_MARKER_NAME",
|
"FEED_MARKER_NAME",
|
||||||
"AudioExtractionError",
|
"AudioExtractionError",
|
||||||
"Episode",
|
"Episode",
|
||||||
@@ -46,7 +53,9 @@ __all__ = [
|
|||||||
"episode_filename",
|
"episode_filename",
|
||||||
"find_feed_shows",
|
"find_feed_shows",
|
||||||
"missing_episodes",
|
"missing_episodes",
|
||||||
|
"newest_episodes",
|
||||||
"parse_feed",
|
"parse_feed",
|
||||||
|
"prune_show",
|
||||||
"resolve_episode_cover",
|
"resolve_episode_cover",
|
||||||
"sync_all_shows",
|
"sync_all_shows",
|
||||||
"sync_show",
|
"sync_show",
|
||||||
@@ -54,6 +63,15 @@ __all__ = [
|
|||||||
|
|
||||||
FEED_MARKER_NAME: Final = "feed.txt"
|
FEED_MARKER_NAME: Final = "feed.txt"
|
||||||
|
|
||||||
|
#: How many episodes of one show to keep, newest first. A show that has published for
|
||||||
|
#: years is otherwise unbounded: GEOlino Spezial alone is 358 episodes and 5.8 GB, which
|
||||||
|
#: does not fit next to the rest of the library on a Pi's SD card.
|
||||||
|
#:
|
||||||
|
#: This caps downloads as well as deletions, and it has to be one number for both. Prune
|
||||||
|
#: to the newest N but download everything the feed offers, and every poll would
|
||||||
|
#: re-fetch the episodes the last one deleted, forever.
|
||||||
|
DEFAULT_EPISODE_LIMIT: Final = 50
|
||||||
|
|
||||||
#: Enclosure content-type -> file extension, for a URL whose own suffix is missing or
|
#: Enclosure content-type -> file extension, for a URL whose own suffix is missing or
|
||||||
#: not a real extension (tracking-redirect URLs are common in the wild).
|
#: not a real extension (tracking-redirect URLs are common in the wild).
|
||||||
_EXTENSION_BY_TYPE: Final[dict[str, str]] = {
|
_EXTENSION_BY_TYPE: Final[dict[str, str]] = {
|
||||||
@@ -287,6 +305,80 @@ def missing_episodes(folder: Path, episodes: list[Episode]) -> list[tuple[Episod
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def newest_episodes(episodes: list[Episode], keep: int | None) -> list[Episode]:
|
||||||
|
"""The ``keep`` most recently published episodes, newest first.
|
||||||
|
|
||||||
|
Applied to the *feed* before anything is downloaded. Without it, capping the folder
|
||||||
|
would be pointless: the next poll would see every pruned episode as missing again.
|
||||||
|
"""
|
||||||
|
ordered = sorted(episodes, key=lambda episode: episode.published, reverse=True)
|
||||||
|
return ordered if keep is None else ordered[:keep]
|
||||||
|
|
||||||
|
|
||||||
|
def _episode_date(name: str) -> datetime | None:
|
||||||
|
"""The date out of a ``YYYYMMDD - Title.ext`` filename, or ``None`` if it has none.
|
||||||
|
|
||||||
|
Deliberately strict. A file that does not follow the convention is one this module
|
||||||
|
did not write - something dropped in by hand under another name - and it is left
|
||||||
|
alone rather than guessed at, because the alternative is deleting somebody's file
|
||||||
|
on a parse that happened to fail.
|
||||||
|
"""
|
||||||
|
stem = Path(name).stem
|
||||||
|
if len(stem) < 8 or not stem[:8].isdigit():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return datetime.strptime(stem[:8], "%Y%m%d").replace(tzinfo=UTC)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def prune_show(folder: Path, keep: int | None, extensions: frozenset[str] | None = None) -> int:
|
||||||
|
"""Delete all but the ``keep`` newest episodes in ``folder``. Returns how many went.
|
||||||
|
|
||||||
|
Only touches files named the way :func:`episode_filename` names them, so a
|
||||||
|
``feed.txt``, a ``folder.jpg``, the failed-download record and any hand-named file
|
||||||
|
are all safe. An episode's sidecar cover image goes with it - it shares the stem,
|
||||||
|
and leaving it behind would strand art for a track that no longer exists.
|
||||||
|
|
||||||
|
``keep`` of ``None`` disables pruning entirely.
|
||||||
|
"""
|
||||||
|
if keep is None:
|
||||||
|
return 0
|
||||||
|
audio_extensions = extensions if extensions is not None else _KNOWN_EXTENSIONS
|
||||||
|
|
||||||
|
dated: list[tuple[datetime, Path]] = []
|
||||||
|
for path in folder.iterdir():
|
||||||
|
if not path.is_file() or path.suffix.lower() not in audio_extensions:
|
||||||
|
continue
|
||||||
|
published = _episode_date(path.name)
|
||||||
|
if published is not None:
|
||||||
|
dated.append((published, path))
|
||||||
|
|
||||||
|
if len(dated) <= keep:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
dated.sort(key=lambda item: (item[0], item[1].name), reverse=True)
|
||||||
|
removed = 0
|
||||||
|
for _published, path in dated[keep:]:
|
||||||
|
try:
|
||||||
|
path.unlink()
|
||||||
|
except OSError as exc:
|
||||||
|
_log.warning("Could not delete old episode %s: %s", path, exc)
|
||||||
|
continue
|
||||||
|
removed += 1
|
||||||
|
for image_extension in _IMAGE_EXTENSIONS:
|
||||||
|
sidecar = path.with_suffix(image_extension)
|
||||||
|
try:
|
||||||
|
sidecar.unlink(missing_ok=True)
|
||||||
|
except OSError as exc:
|
||||||
|
_log.debug("Could not delete cover %s: %s", sidecar, exc)
|
||||||
|
if removed:
|
||||||
|
_log.info(
|
||||||
|
"Pruned %d old episode(s) from %s, keeping the newest %d", removed, folder.name, keep
|
||||||
|
)
|
||||||
|
return removed
|
||||||
|
|
||||||
|
|
||||||
def _load_failed_downloads(folder: Path) -> dict[str, datetime]:
|
def _load_failed_downloads(folder: Path) -> dict[str, datetime]:
|
||||||
"""Filename -> when it last failed to download, for episodes ``sync_show`` should
|
"""Filename -> when it last failed to download, for episodes ``sync_show`` should
|
||||||
leave alone until :data:`_RETRY_BACKOFF` has passed.
|
leave alone until :data:`_RETRY_BACKOFF` has passed.
|
||||||
@@ -437,14 +529,28 @@ async def resolve_episode_cover(client: httpx2.AsyncClient, episode: Episode) ->
|
|||||||
return _extract_og_image(response.text)
|
return _extract_og_image(response.text)
|
||||||
|
|
||||||
|
|
||||||
async def sync_show(client: httpx2.AsyncClient, folder: Path, feed_url: str) -> bool:
|
async def sync_show(
|
||||||
"""Download every episode in ``feed_url`` that ``folder`` doesn't have yet.
|
client: httpx2.AsyncClient,
|
||||||
|
folder: Path,
|
||||||
|
feed_url: str,
|
||||||
|
*,
|
||||||
|
keep: int | None = DEFAULT_EPISODE_LIMIT,
|
||||||
|
) -> bool:
|
||||||
|
"""Bring ``folder`` up to date with the newest ``keep`` episodes of ``feed_url``.
|
||||||
|
|
||||||
Returns whether anything changed. Errors - an unreachable feed, a malformed one, a
|
Downloads what is missing from that window and deletes what has fallen out of it.
|
||||||
single broken enclosure - are logged and swallowed here so one bad show never stops
|
Both halves use the same ``keep``, which is what makes the folder settle - see
|
||||||
the others or takes down the poll loop. An episode that fails is remembered and left
|
:data:`DEFAULT_EPISODE_LIMIT`.
|
||||||
alone for :data:`_RETRY_BACKOFF` before it's attempted again, so a permanently dead
|
|
||||||
enclosure doesn't get hammered on every poll.
|
Returns whether anything changed, deletions included: a pruned folder needs a
|
||||||
|
rescan just as much as a downloaded episode does, or the library keeps offering
|
||||||
|
tracks whose files are gone.
|
||||||
|
|
||||||
|
Errors - an unreachable feed, a malformed one, a single broken enclosure - are
|
||||||
|
logged and swallowed here so one bad show never stops the others or takes down the
|
||||||
|
poll loop. An episode that fails is remembered and left alone for
|
||||||
|
:data:`_RETRY_BACKOFF` before it's attempted again, so a permanently dead enclosure
|
||||||
|
doesn't get hammered on every poll.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
response = await client.get(feed_url, timeout=_HTTP_TIMEOUT, follow_redirects=True)
|
response = await client.get(feed_url, timeout=_HTTP_TIMEOUT, follow_redirects=True)
|
||||||
@@ -453,7 +559,7 @@ async def sync_show(client: httpx2.AsyncClient, folder: Path, feed_url: str) ->
|
|||||||
_log.warning("Could not fetch podcast feed %s for %s: %s", feed_url, folder.name, exc)
|
_log.warning("Could not fetch podcast feed %s for %s: %s", feed_url, folder.name, exc)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
pending = missing_episodes(folder, parse_feed(response.content))
|
pending = missing_episodes(folder, newest_episodes(parse_feed(response.content), keep))
|
||||||
failed = _load_failed_downloads(folder)
|
failed = _load_failed_downloads(folder)
|
||||||
now = datetime.now(UTC)
|
now = datetime.now(UTC)
|
||||||
changed = False
|
changed = False
|
||||||
@@ -490,13 +596,20 @@ async def sync_show(client: httpx2.AsyncClient, folder: Path, feed_url: str) ->
|
|||||||
pending_filenames = {filename for _episode, filename in pending}
|
pending_filenames = {filename for _episode, filename in pending}
|
||||||
failed = {filename: when for filename, when in failed.items() if filename in pending_filenames}
|
failed = {filename: when for filename, when in failed.items() if filename in pending_filenames}
|
||||||
_save_failed_downloads(folder, failed)
|
_save_failed_downloads(folder, failed)
|
||||||
|
|
||||||
|
# After downloading, not before: an episode that just arrived is one of the newest
|
||||||
|
# and must be counted when deciding what falls off the end.
|
||||||
|
if prune_show(folder, keep):
|
||||||
|
changed = True
|
||||||
return changed
|
return changed
|
||||||
|
|
||||||
|
|
||||||
async def sync_all_shows(client: httpx2.AsyncClient, root: Path) -> bool:
|
async def sync_all_shows(
|
||||||
|
client: httpx2.AsyncClient, root: Path, *, keep: int | None = DEFAULT_EPISODE_LIMIT
|
||||||
|
) -> bool:
|
||||||
"""Poll every show with a feed marker under ``root``. Returns whether any changed."""
|
"""Poll every show with a feed marker under ``root``. Returns whether any changed."""
|
||||||
changed = False
|
changed = False
|
||||||
for _section_name, folder, feed_url in find_feed_shows(root):
|
for _section_name, folder, feed_url in find_feed_shows(root):
|
||||||
if await sync_show(client, folder, feed_url):
|
if await sync_show(client, folder, feed_url, keep=keep):
|
||||||
changed = True
|
changed = True
|
||||||
return changed
|
return changed
|
||||||
|
|||||||
@@ -119,10 +119,20 @@ def _most_common(values: list[str]) -> str:
|
|||||||
def _cover_for(
|
def _cover_for(
|
||||||
folder: Path, paths: list[Path], identifier: str, cache: LibraryCache
|
folder: Path, paths: list[Path], identifier: str, cache: LibraryCache
|
||||||
) -> tuple[Path | None, bytes | None]:
|
) -> tuple[Path | None, bytes | None]:
|
||||||
|
"""The album's art, as a path *into the cache* plus the original bytes.
|
||||||
|
|
||||||
|
Every branch goes through :meth:`LibraryCache.store_cover`, including the one that
|
||||||
|
finds a ``cover.jpg`` already sitting in the folder. Returning that file directly
|
||||||
|
would be the obvious thing and was the original behaviour, and it quietly undid the
|
||||||
|
downscaling: a folder cover is whatever the internet gave it, often 1920px or more,
|
||||||
|
and the browser then decoded all of it to fill a 185px card. The bytes come back
|
||||||
|
undownscaled either way, because `colors_from_cover` wants the real art.
|
||||||
|
"""
|
||||||
for name in _COVER_NAMES:
|
for name in _COVER_NAMES:
|
||||||
candidate = folder / name
|
candidate = folder / name
|
||||||
if candidate.is_file():
|
if candidate.is_file():
|
||||||
return candidate, candidate.read_bytes()
|
art = candidate.read_bytes()
|
||||||
|
return cache.store_cover(identifier, art), art
|
||||||
for path in paths[:3]:
|
for path in paths[:3]:
|
||||||
# Podcast feeds sometimes art only some episodes; a couple of tries is enough.
|
# Podcast feeds sometimes art only some episodes; a couple of tries is enough.
|
||||||
art = _embedded_art(path)
|
art = _embedded_art(path)
|
||||||
@@ -143,14 +153,16 @@ def _cover_for_episode(
|
|||||||
for extension in _SIDECAR_COVER_EXTENSIONS:
|
for extension in _SIDECAR_COVER_EXTENSIONS:
|
||||||
candidate = path.with_suffix(extension)
|
candidate = path.with_suffix(extension)
|
||||||
if candidate.is_file():
|
if candidate.is_file():
|
||||||
return candidate, candidate.read_bytes()
|
art = candidate.read_bytes()
|
||||||
|
return cache.store_cover(identifier, art), art
|
||||||
art = _embedded_art(path)
|
art = _embedded_art(path)
|
||||||
if art is not None:
|
if art is not None:
|
||||||
return cache.store_cover(identifier, art), art
|
return cache.store_cover(identifier, art), art
|
||||||
for name in _COVER_NAMES:
|
for name in _COVER_NAMES:
|
||||||
candidate = folder / name
|
candidate = folder / name
|
||||||
if candidate.is_file():
|
if candidate.is_file():
|
||||||
return candidate, candidate.read_bytes()
|
shared = candidate.read_bytes()
|
||||||
|
return cache.store_cover(identifier, shared), shared
|
||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
@@ -295,6 +307,13 @@ def scan_library(
|
|||||||
figure name to what it holds, which is the one thing the folders cannot say.
|
figure name to what it holds, which is the one thing the folders cannot say.
|
||||||
"""
|
"""
|
||||||
cache.prepare()
|
cache.prepare()
|
||||||
|
# Covers written before MAX_COVER_PX existed are still whatever size the tag held,
|
||||||
|
# and the reuse path below means an unchanged album never rewrites its own. One
|
||||||
|
# pass here catches them; after the first run every file is already small and this
|
||||||
|
# is 300-odd header reads.
|
||||||
|
shrunk = cache.shrink_stored_covers()
|
||||||
|
if shrunk:
|
||||||
|
_log.info("Downscaled %d oversized cover(s) in the cache", shrunk)
|
||||||
known = known or {}
|
known = known or {}
|
||||||
out: dict[str, tuple[Album, Fingerprint]] = {}
|
out: dict[str, tuple[Album, Fingerprint]] = {}
|
||||||
last_report = time.monotonic()
|
last_report = time.monotonic()
|
||||||
|
|||||||
@@ -8,15 +8,15 @@ quirks are not understood yet.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Final, Literal
|
from typing import Final, Literal, TypeAlias
|
||||||
|
|
||||||
__all__ = ["SECTIONS", "AlbumKind", "ArtistSource", "Section", "TitleSource", "TrackOrder"]
|
__all__ = ["SECTIONS", "AlbumKind", "ArtistSource", "Section", "TitleSource", "TrackOrder"]
|
||||||
|
|
||||||
type AlbumKind = Literal["music", "book"]
|
AlbumKind: TypeAlias = Literal["music", "book"]
|
||||||
type TrackOrder = Literal["filename", "newest_first"]
|
TrackOrder: TypeAlias = Literal["filename", "newest_first"]
|
||||||
type TitleSource = Literal["tags", "folder"]
|
TitleSource: TypeAlias = Literal["tags", "folder"]
|
||||||
type ArtistSource = Literal["tags", "folder"]
|
ArtistSource: TypeAlias = Literal["tags", "folder"]
|
||||||
type AlbumUnit = Literal["folder", "episode"]
|
AlbumUnit: TypeAlias = Literal["folder", "episode"]
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
from collections.abc import Callable, Coroutine
|
from collections.abc import Callable, Coroutine
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any, TypeAlias, TypeVar
|
||||||
|
|
||||||
from musicmouse.bus import EventBus
|
from musicmouse.bus import EventBus
|
||||||
from musicmouse.events import Event
|
from musicmouse.events import Event
|
||||||
@@ -20,12 +20,14 @@ _log = logging.getLogger(__name__)
|
|||||||
|
|
||||||
__all__ = ["Reaction", "on", "register_all", "registered"]
|
__all__ = ["Reaction", "on", "register_all", "registered"]
|
||||||
|
|
||||||
type Reaction[E: Event] = Callable[[E, "App"], Coroutine[Any, Any, None] | None]
|
E = TypeVar("E", bound=Event)
|
||||||
|
|
||||||
|
Reaction: TypeAlias = Callable[[E, "App"], Coroutine[Any, Any, None] | None]
|
||||||
|
|
||||||
_REGISTRY: list[tuple[type[Event], Reaction[Any]]] = []
|
_REGISTRY: list[tuple[type[Event], Reaction[Any]]] = []
|
||||||
|
|
||||||
|
|
||||||
def on[E: Event](event_type: type[E]) -> Callable[[Reaction[E]], Reaction[E]]:
|
def on(event_type: type[E]) -> Callable[[Reaction[E]], Reaction[E]]:
|
||||||
"""Register a reaction for ``event_type`` (and any subclass of it)."""
|
"""Register a reaction for ``event_type`` (and any subclass of it)."""
|
||||||
|
|
||||||
def decorator(reaction: Reaction[E]) -> Reaction[E]:
|
def decorator(reaction: Reaction[E]) -> Reaction[E]:
|
||||||
@@ -46,7 +48,7 @@ def register_all(bus: EventBus, app: App) -> None:
|
|||||||
_log.debug("Registered %d reactions", len(_REGISTRY))
|
_log.debug("Registered %d reactions", len(_REGISTRY))
|
||||||
|
|
||||||
|
|
||||||
def _bind[E: Event](reaction: Reaction[E], app: App) -> Callable[[E], Any]:
|
def _bind(reaction: Reaction[E], app: App) -> Callable[[E], Any]:
|
||||||
def handler(event: E) -> Any:
|
def handler(event: E) -> Any:
|
||||||
return reaction(event, app)
|
return reaction(event, app)
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ from typing import Final
|
|||||||
import httpx2
|
import httpx2
|
||||||
|
|
||||||
from musicmouse.library import MusicLibrary
|
from musicmouse.library import MusicLibrary
|
||||||
from musicmouse.library.podcast_feeds import sync_all_shows
|
from musicmouse.library.podcast_feeds import DEFAULT_EPISODE_LIMIT, sync_all_shows
|
||||||
|
|
||||||
_log = logging.getLogger(__name__)
|
_log = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -34,11 +34,13 @@ class PodcastFeedService:
|
|||||||
client: httpx2.AsyncClient,
|
client: httpx2.AsyncClient,
|
||||||
on_change: Callable[[], Awaitable[None]],
|
on_change: Callable[[], Awaitable[None]],
|
||||||
interval: float = _CHECK_INTERVAL_SECONDS,
|
interval: float = _CHECK_INTERVAL_SECONDS,
|
||||||
|
episode_limit: int | None = DEFAULT_EPISODE_LIMIT,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.library = library
|
self.library = library
|
||||||
self.client = client
|
self.client = client
|
||||||
self.on_change = on_change
|
self.on_change = on_change
|
||||||
self.interval = interval
|
self.interval = interval
|
||||||
|
self.episode_limit = episode_limit
|
||||||
|
|
||||||
async def run(self) -> None:
|
async def run(self) -> None:
|
||||||
"""Checks immediately at startup, then every `interval` seconds.
|
"""Checks immediately at startup, then every `interval` seconds.
|
||||||
@@ -51,7 +53,8 @@ class PodcastFeedService:
|
|||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
if await sync_all_shows(self.client, self.library.root):
|
keep = self.episode_limit
|
||||||
|
if await sync_all_shows(self.client, self.library.root, keep=keep):
|
||||||
await self.on_change()
|
await self.on_change()
|
||||||
except Exception:
|
except Exception:
|
||||||
_log.exception("Podcast feed check failed; will retry next interval")
|
_log.exception("Podcast feed check failed; will retry next interval")
|
||||||
|
|||||||
@@ -97,16 +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,
|
||||||
# Cover files are content-addressed by album id and rewritten only by a
|
# A URL carrying `v` (the file's mtime, from `AlbumOut.cover_v`) names one
|
||||||
# rescan, so a long cache is safe and saves 20 requests per page load.
|
# exact set of bytes: reprocessing the art changes the library payload, so
|
||||||
headers={"Cache-Control": "public, max-age=604800"},
|
# the client asks for a new URL. Those can be cached forever, and the
|
||||||
|
# browser then never asks again - no conditional request per card.
|
||||||
|
#
|
||||||
|
# Without `v` the id alone is not enough (the bytes behind it change whenever
|
||||||
|
# the art is reprocessed - when `MAX_COVER_PX` arrived, browsers went on
|
||||||
|
# decoding old 3000px files out of their cache), so `no-cache` makes the
|
||||||
|
# browser revalidate: it keeps the file and usually gets a 304.
|
||||||
|
headers={
|
||||||
|
"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")
|
||||||
|
|||||||
@@ -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=[
|
||||||
|
|||||||
@@ -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 "
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from typing import TypeVar
|
||||||
|
|
||||||
from musicmouse.app import App
|
from musicmouse.app import App
|
||||||
from musicmouse.clock import Clock, FakeClock
|
from musicmouse.clock import Clock, FakeClock
|
||||||
@@ -329,9 +330,10 @@ def _int(text: str) -> int:
|
|||||||
raise ScriptError(f"{text!r} is not a whole number") from None
|
raise ScriptError(f"{text!r} is not a whole number") from None
|
||||||
|
|
||||||
|
|
||||||
def _enum_by_name[T: (Button, ButtonAction, TouchButton)](
|
_EnumT = TypeVar("_EnumT", Button, ButtonAction, TouchButton)
|
||||||
enum: type[T], name: str, what: str
|
|
||||||
) -> T:
|
|
||||||
|
def _enum_by_name(enum: type[_EnumT], name: str, what: str) -> _EnumT:
|
||||||
try:
|
try:
|
||||||
return enum[name.upper()]
|
return enum[name.upper()]
|
||||||
except KeyError:
|
except KeyError:
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Final, Literal
|
from typing import Final, Literal, TypeAlias
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, ValidationError, model_validator
|
from pydantic import BaseModel, ConfigDict, ValidationError, model_validator
|
||||||
from ruamel.yaml import YAML
|
from ruamel.yaml import YAML
|
||||||
@@ -37,9 +37,9 @@ __all__ = [
|
|||||||
"world_reward",
|
"world_reward",
|
||||||
]
|
]
|
||||||
|
|
||||||
type LessonKind = Literal["letters", "fragments", "words", "sentences"]
|
LessonKind: TypeAlias = Literal["letters", "fragments", "words", "sentences"]
|
||||||
type ModeId = Literal["dive", "bubbles", "feed", "race"]
|
ModeId: TypeAlias = Literal["dive", "bubbles", "feed", "race"]
|
||||||
type CreatureId = Literal["clownfish", "octopus", "seahorse", "turtle", "pearlmussel"]
|
CreatureId: TypeAlias = Literal["clownfish", "octopus", "seahorse", "turtle", "pearlmussel"]
|
||||||
|
|
||||||
#: In world order - see ``tippen/src/lib/aquarium.ts``, the one place this list is
|
#: In world order - see ``tippen/src/lib/aquarium.ts``, the one place this list is
|
||||||
#: allowed to grow, since each id names a drawing under ``public/aquarium/``.
|
#: allowed to grow, since each id names a drawing under ``public/aquarium/``.
|
||||||
|
|||||||
@@ -2,7 +2,11 @@
|
|||||||
name = "musicmouse"
|
name = "musicmouse"
|
||||||
version = "2.0.0"
|
version = "2.0.0"
|
||||||
description = "Host backend for the MusicMouse RFID music player"
|
description = "Host backend for the MusicMouse RFID music player"
|
||||||
requires-python = ">=3.13"
|
# Raspberry Pi OS (Bookworm) ships Python 3.11, and its system interpreter is what the
|
||||||
|
# device runs. Staying on it means apt and piwheels supply prebuilt armhf wheels for
|
||||||
|
# the native dependencies (pydantic-core, Pillow), instead of needing a separate
|
||||||
|
# toolchain to fetch a newer interpreter and compile them on the Pi.
|
||||||
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aiomqtt>=2.0",
|
"aiomqtt>=2.0",
|
||||||
"fastapi>=0.115",
|
"fastapi>=0.115",
|
||||||
@@ -50,7 +54,7 @@ filterwarnings = ["error"]
|
|||||||
|
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
line-length = 100
|
line-length = 100
|
||||||
target-version = "py313"
|
target-version = "py311"
|
||||||
# Course material and scratch work, not part of the backend. See notebooks/README.md.
|
# Course material and scratch work, not part of the backend. See notebooks/README.md.
|
||||||
extend-exclude = ["notebooks"]
|
extend-exclude = ["notebooks"]
|
||||||
|
|
||||||
@@ -63,7 +67,7 @@ select = ["ARG", "B", "C4", "E", "F", "I", "N", "PTH", "RUF", "SIM", "UP", "W"]
|
|||||||
"musicmouse/services/mqtt/entity.py" = ["ARG002", "B027"]
|
"musicmouse/services/mqtt/entity.py" = ["ARG002", "B027"]
|
||||||
|
|
||||||
[tool.mypy]
|
[tool.mypy]
|
||||||
python_version = "3.13"
|
python_version = "3.11"
|
||||||
strict = true
|
strict = true
|
||||||
files = ["musicmouse"]
|
files = ["musicmouse"]
|
||||||
warn_unreachable = true
|
warn_unreachable = true
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import contextlib
|
import contextlib
|
||||||
|
import io
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import threading
|
import threading
|
||||||
@@ -246,9 +247,14 @@ async def test_a_cover_file_is_found_and_used(config_dir: Path) -> None:
|
|||||||
Image.new("RGB", (32, 32), (200, 40, 30)).save(folder / "cover.jpg")
|
Image.new("RGB", (32, 32), (200, 40, 30)).save(folder / "cover.jpg")
|
||||||
|
|
||||||
album = album_named(await build(config_dir), "Kinderparty Lieder")
|
album = album_named(await build(config_dir), "Kinderparty Lieder")
|
||||||
assert album.cover == folder / "cover.jpg"
|
# Into the cache, not at the library file: a folder cover is whatever size it came
|
||||||
|
# in at, and serving it directly is what used to put 1920px art in a 185px card.
|
||||||
|
assert album.cover is not None
|
||||||
|
assert album.cover.parent == config_dir / ".cache" / "covers"
|
||||||
|
assert album.cover.stem == album.id
|
||||||
# A solid red cover has one usable colour; the rest fall back to the synthesised
|
# A solid red cover has one usable colour; the rest fall back to the synthesised
|
||||||
# palette rather than repeating it.
|
# palette rather than repeating it - and that still reads the real art, not the
|
||||||
|
# downscaled copy.
|
||||||
assert album.colors[0] != colors_from_id(album.id)[0]
|
assert album.colors[0] != colors_from_id(album.id)[0]
|
||||||
|
|
||||||
|
|
||||||
@@ -263,9 +269,19 @@ async def test_an_episodes_sidecar_cover_wins_over_the_shows_shared_one(config_d
|
|||||||
|
|
||||||
library = await build(config_dir)
|
library = await build(config_dir)
|
||||||
|
|
||||||
assert album_named(library, "Neu").cover == podcast / "20260101 - Neu.jpg"
|
# Both covers now live in the cache under their own album id, so which file won is
|
||||||
|
# no longer visible in the path - read the colour back out instead.
|
||||||
|
def cover_colour(title: str) -> tuple[int, int, int]:
|
||||||
|
cover = album_named(library, title).cover
|
||||||
|
assert cover is not None
|
||||||
|
with Image.open(cover) as image:
|
||||||
|
return image.convert("RGB").getpixel((0, 0)) # type: ignore[return-value]
|
||||||
|
|
||||||
|
red, green, blue = cover_colour("Neu")
|
||||||
|
assert red > 150 and green < 90, "the episode's own sidecar art should win"
|
||||||
# No sidecar for this one, so it still falls back to the show's shared cover.
|
# No sidecar for this one, so it still falls back to the show's shared cover.
|
||||||
assert album_named(library, "Alt").cover == podcast / "cover.jpg"
|
red, green, blue = cover_colour("Alt")
|
||||||
|
assert red < 60 and blue > red, "falls back to the show's shared cover"
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- cache
|
# --------------------------------------------------------------------------- cache
|
||||||
@@ -726,3 +742,137 @@ async def test_a_parallel_pass_is_cancellable_mid_flight(config_dir: Path) -> No
|
|||||||
def test_the_default_worker_count_leaves_a_core_for_everything_else() -> None:
|
def test_the_default_worker_count_leaves_a_core_for_everything_else() -> None:
|
||||||
count = default_worker_count()
|
count = default_worker_count()
|
||||||
assert 1 <= count <= max(1, (os.cpu_count() or 1) - 1)
|
assert 1 <= count <= max(1, (os.cpu_count() or 1) - 1)
|
||||||
|
|
||||||
|
|
||||||
|
class TestCoverDownscaling:
|
||||||
|
"""Album art arrives sized for a record sleeve; screens want a fraction of that."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _jpeg(size: tuple[int, int]) -> bytes:
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
Image.new("RGB", size, (120, 40, 200)).save(buffer, format="JPEG")
|
||||||
|
return buffer.getvalue()
|
||||||
|
|
||||||
|
def test_oversized_art_is_shrunk_to_the_long_edge(self) -> None:
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from musicmouse.library.cache import MAX_COVER_PX, shrink_cover
|
||||||
|
|
||||||
|
original = self._jpeg((3000, 2000))
|
||||||
|
shrunk = shrink_cover(original)
|
||||||
|
with Image.open(io.BytesIO(shrunk)) as image:
|
||||||
|
width, height = image.size
|
||||||
|
assert max(width, height) == MAX_COVER_PX
|
||||||
|
# Aspect ratio survives, to within the rounding of a whole pixel.
|
||||||
|
assert abs(width / height - 3000 / 2000) < 0.01
|
||||||
|
assert len(shrunk) < len(original)
|
||||||
|
|
||||||
|
def test_art_already_small_enough_is_returned_untouched(self) -> None:
|
||||||
|
"""Byte-identical, not merely similar: repeated scans must not re-encode art
|
||||||
|
over and over, each pass losing a little more to JPEG."""
|
||||||
|
from musicmouse.library.cache import shrink_cover
|
||||||
|
|
||||||
|
original = self._jpeg((300, 300))
|
||||||
|
assert shrink_cover(original) is original
|
||||||
|
|
||||||
|
def test_unreadable_art_is_passed_through_rather_than_dropped(self) -> None:
|
||||||
|
"""A cover too big is a performance problem; a cover missing is a visible one."""
|
||||||
|
from musicmouse.library.cache import shrink_cover
|
||||||
|
|
||||||
|
assert shrink_cover(b"not an image at all") == b"not an image at all"
|
||||||
|
|
||||||
|
def test_store_cover_shrinks_on_the_way_in(self, tmp_path: Path) -> None:
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from musicmouse.library.cache import MAX_COVER_PX, LibraryCache
|
||||||
|
|
||||||
|
cache = LibraryCache(tmp_path)
|
||||||
|
cache.prepare()
|
||||||
|
path = cache.store_cover("abc123", self._jpeg((2400, 2400)))
|
||||||
|
with Image.open(path) as image:
|
||||||
|
assert max(image.size) == MAX_COVER_PX
|
||||||
|
|
||||||
|
def test_a_cover_jpg_in_the_library_folder_is_cached_downscaled_too(
|
||||||
|
self, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
"""The branch that quietly undid all of this: an album with art already sitting
|
||||||
|
beside it used to have `cover` point straight at that file, so it was served at
|
||||||
|
whatever size the internet gave it - often 1920px into a 185px card."""
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from musicmouse.library.cache import MAX_COVER_PX, LibraryCache
|
||||||
|
from musicmouse.library.scanner import _cover_for
|
||||||
|
|
||||||
|
cache = LibraryCache(tmp_path / "cache")
|
||||||
|
cache.prepare()
|
||||||
|
folder = tmp_path / "album"
|
||||||
|
folder.mkdir()
|
||||||
|
(folder / "cover.jpg").write_bytes(self._jpeg((1920, 1920)))
|
||||||
|
|
||||||
|
path, art = _cover_for(folder, [], "album1", cache)
|
||||||
|
|
||||||
|
assert path is not None
|
||||||
|
assert path.parent == cache.covers, "cover must point into the cache, not the library"
|
||||||
|
with Image.open(path) as image:
|
||||||
|
assert max(image.size) == MAX_COVER_PX
|
||||||
|
# The untouched original still comes back, because colour extraction wants it.
|
||||||
|
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:
|
||||||
|
"""The scanner reuses an unchanged album without re-reading its tags, so a
|
||||||
|
cover stored before the limit existed would otherwise never be rewritten."""
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from musicmouse.library.cache import MAX_COVER_PX, LibraryCache
|
||||||
|
|
||||||
|
cache = LibraryCache(tmp_path)
|
||||||
|
cache.prepare()
|
||||||
|
stale = cache.cover_path("old")
|
||||||
|
stale.write_bytes(self._jpeg((3000, 3000)))
|
||||||
|
fresh = cache.cover_path("new")
|
||||||
|
fresh.write_bytes(self._jpeg((320, 320)))
|
||||||
|
fresh_before = fresh.read_bytes()
|
||||||
|
|
||||||
|
assert cache.shrink_stored_covers() == 1
|
||||||
|
with Image.open(stale) as image:
|
||||||
|
assert max(image.size) == MAX_COVER_PX
|
||||||
|
assert fresh.read_bytes() == fresh_before
|
||||||
|
# Second pass has nothing left to do - the cheap steady state.
|
||||||
|
assert cache.shrink_stored_covers() == 0
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ class ScriptedLircd:
|
|||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self._writer: asyncio.StreamWriter | None = None
|
self._writer: asyncio.StreamWriter | None = None
|
||||||
self._connected = asyncio.Event()
|
self._connected = asyncio.Event()
|
||||||
|
self._closing = asyncio.Event()
|
||||||
self.connection_count = 0
|
self.connection_count = 0
|
||||||
|
|
||||||
async def _handle(self, _reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
async def _handle(self, _reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
||||||
@@ -77,7 +78,14 @@ class ScriptedLircd:
|
|||||||
self.connection_count += 1
|
self.connection_count += 1
|
||||||
self._connected.set()
|
self._connected.set()
|
||||||
with contextlib.suppress(asyncio.CancelledError):
|
with contextlib.suppress(asyncio.CancelledError):
|
||||||
await asyncio.Event().wait() # held open until the test drops it
|
await self._closing.wait() # held open until the test drops it
|
||||||
|
writer.close()
|
||||||
|
with contextlib.suppress(OSError, asyncio.CancelledError):
|
||||||
|
await writer.wait_closed()
|
||||||
|
|
||||||
|
def shutdown(self) -> None:
|
||||||
|
"""Let go of every connection still being held open, so the server can close."""
|
||||||
|
self._closing.set()
|
||||||
|
|
||||||
async def wait_connected(self) -> None:
|
async def wait_connected(self) -> None:
|
||||||
await self._connected.wait()
|
await self._connected.wait()
|
||||||
@@ -108,8 +116,10 @@ async def lircd() -> AsyncIterator[tuple[ScriptedLircd, asyncio.base_events.Serv
|
|||||||
finally:
|
finally:
|
||||||
server.close()
|
server.close()
|
||||||
# The scripted connection handler holds its connection open forever (it does
|
# The scripted connection handler holds its connection open forever (it does
|
||||||
# not know the test is done), so `wait_closed()` alone would hang here.
|
# not know the test is done), so `wait_closed()` alone would hang here. 3.13
|
||||||
server.close_clients()
|
# has Server.close_clients() for exactly this; ask the handlers to let go
|
||||||
|
# instead, because the device runs the Pi's Python 3.11.
|
||||||
|
station.shutdown()
|
||||||
await server.wait_closed()
|
await server.wait_closed()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
|
from typing import TypeVar
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -58,7 +59,10 @@ def seen(bus: EventBus) -> list[Event]:
|
|||||||
return events
|
return events
|
||||||
|
|
||||||
|
|
||||||
def only[T: Event](events: list[Event], event_type: type[T]) -> list[T]:
|
T = TypeVar("T", bound=Event)
|
||||||
|
|
||||||
|
|
||||||
|
def only(events: list[Event], event_type: type[T]) -> list[T]:
|
||||||
return [e for e in events if isinstance(e, event_type)]
|
return [e for e in events if isinstance(e, event_type)]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import TypeVar
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -55,7 +56,10 @@ def seen(bus: EventBus) -> list[Event]:
|
|||||||
return events
|
return events
|
||||||
|
|
||||||
|
|
||||||
def only[T: Event](events: list[Event], event_type: type[T]) -> list[T]:
|
T = TypeVar("T", bound=Event)
|
||||||
|
|
||||||
|
|
||||||
|
def only(events: list[Event], event_type: type[T]) -> list[T]:
|
||||||
return [e for e in events if isinstance(e, event_type)]
|
return [e for e in events if isinstance(e, event_type)]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,9 @@ from musicmouse.library.podcast_feeds import (
|
|||||||
episode_filename,
|
episode_filename,
|
||||||
find_feed_shows,
|
find_feed_shows,
|
||||||
missing_episodes,
|
missing_episodes,
|
||||||
|
newest_episodes,
|
||||||
parse_feed,
|
parse_feed,
|
||||||
|
prune_show,
|
||||||
resolve_episode_cover,
|
resolve_episode_cover,
|
||||||
sync_all_shows,
|
sync_all_shows,
|
||||||
sync_show,
|
sync_show,
|
||||||
@@ -630,3 +632,148 @@ async def test_sync_show_survives_a_corrupt_failure_record(tmp_path: Path) -> No
|
|||||||
|
|
||||||
assert changed is True
|
assert changed is True
|
||||||
assert (folder / "20250101 - Episode One.mp3").exists()
|
assert (folder / "20250101 - Episode One.mp3").exists()
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- episode retention
|
||||||
|
|
||||||
|
|
||||||
|
def _episode(day: int, *, title: str | None = None) -> Episode:
|
||||||
|
return Episode(
|
||||||
|
title=title or f"Episode {day}",
|
||||||
|
published=datetime(2025, 1, day, tzinfo=UTC),
|
||||||
|
enclosure_url=f"http://example.com/ep{day}.mp3",
|
||||||
|
enclosure_type="audio/mpeg",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _write_episode(folder: Path, name: str) -> Path:
|
||||||
|
path = folder / name
|
||||||
|
path.write_bytes(b"audio")
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def test_newest_episodes_takes_the_most_recent_and_orders_them_newest_first() -> None:
|
||||||
|
episodes = [_episode(1), _episode(5), _episode(3)]
|
||||||
|
assert [e.published.day for e in newest_episodes(episodes, 2)] == [5, 3]
|
||||||
|
|
||||||
|
|
||||||
|
def test_newest_episodes_keeps_everything_when_the_limit_is_none() -> None:
|
||||||
|
episodes = [_episode(1), _episode(5), _episode(3)]
|
||||||
|
assert len(newest_episodes(episodes, None)) == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_prune_show_deletes_only_the_oldest_beyond_the_limit(tmp_path: Path) -> None:
|
||||||
|
folder = tmp_path / "show"
|
||||||
|
folder.mkdir()
|
||||||
|
for day in range(1, 6):
|
||||||
|
_write_episode(folder, f"2025010{day} - Episode {day}.mp3")
|
||||||
|
|
||||||
|
assert prune_show(folder, 2) == 3
|
||||||
|
|
||||||
|
remaining = sorted(path.name for path in folder.iterdir())
|
||||||
|
assert remaining == ["20250104 - Episode 4.mp3", "20250105 - Episode 5.mp3"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_prune_show_is_a_noop_below_the_limit(tmp_path: Path) -> None:
|
||||||
|
folder = tmp_path / "show"
|
||||||
|
folder.mkdir()
|
||||||
|
_write_episode(folder, "20250101 - Episode 1.mp3")
|
||||||
|
assert prune_show(folder, 50) == 0
|
||||||
|
assert prune_show(folder, None) == 0
|
||||||
|
assert (folder / "20250101 - Episode 1.mp3").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_prune_show_takes_the_sidecar_cover_with_the_episode(tmp_path: Path) -> None:
|
||||||
|
folder = tmp_path / "show"
|
||||||
|
folder.mkdir()
|
||||||
|
_write_episode(folder, "20250101 - Old.mp3")
|
||||||
|
(folder / "20250101 - Old.jpg").write_bytes(b"art")
|
||||||
|
_write_episode(folder, "20250102 - New.mp3")
|
||||||
|
(folder / "20250102 - New.jpg").write_bytes(b"art")
|
||||||
|
|
||||||
|
assert prune_show(folder, 1) == 1
|
||||||
|
|
||||||
|
assert not (folder / "20250101 - Old.jpg").exists()
|
||||||
|
assert (folder / "20250102 - New.jpg").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_prune_show_leaves_everything_it_did_not_name(tmp_path: Path) -> None:
|
||||||
|
"""A folder holds more than episodes, and a hand-placed file is not ours to delete."""
|
||||||
|
folder = tmp_path / "show"
|
||||||
|
folder.mkdir()
|
||||||
|
for day in range(1, 4):
|
||||||
|
_write_episode(folder, f"2025010{day} - Episode {day}.mp3")
|
||||||
|
(folder / "feed.txt").write_text("http://example.com/feed.xml\n")
|
||||||
|
(folder / "folder.jpg").write_bytes(b"art")
|
||||||
|
(folder / ".failed-downloads.json").write_text("{}")
|
||||||
|
_write_episode(folder, "Grandpa's tape.mp3")
|
||||||
|
|
||||||
|
prune_show(folder, 1)
|
||||||
|
|
||||||
|
assert (folder / "feed.txt").exists()
|
||||||
|
assert (folder / "folder.jpg").exists()
|
||||||
|
assert (folder / ".failed-downloads.json").exists()
|
||||||
|
assert (folder / "Grandpa's tape.mp3").exists()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sync_show_prunes_to_the_limit_after_downloading(tmp_path: Path) -> None:
|
||||||
|
folder = tmp_path / "show"
|
||||||
|
folder.mkdir()
|
||||||
|
|
||||||
|
def handler(request: httpx2.Request) -> httpx2.Response:
|
||||||
|
if str(request.url) == "http://example.com/feed.xml":
|
||||||
|
return httpx2.Response(200, content=_FEED)
|
||||||
|
return httpx2.Response(200, content=b"audio-bytes")
|
||||||
|
|
||||||
|
async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as client:
|
||||||
|
changed = await sync_show(client, folder, "http://example.com/feed.xml", keep=1)
|
||||||
|
|
||||||
|
assert changed is True
|
||||||
|
episodes = sorted(path.name for path in folder.iterdir() if path.suffix == ".mp3")
|
||||||
|
assert episodes == ["20250103 - Episode Two_ Special_Chars_.mp3"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sync_show_does_not_redownload_what_it_pruned(tmp_path: Path) -> None:
|
||||||
|
"""The whole point of capping downloads as well as deletions.
|
||||||
|
|
||||||
|
Prune to the newest N but fetch everything the feed offers, and each poll would
|
||||||
|
re-download the episodes the previous one deleted - forever, at full size.
|
||||||
|
"""
|
||||||
|
folder = tmp_path / "show"
|
||||||
|
folder.mkdir()
|
||||||
|
downloads: list[str] = []
|
||||||
|
|
||||||
|
def handler(request: httpx2.Request) -> httpx2.Response:
|
||||||
|
if str(request.url) == "http://example.com/feed.xml":
|
||||||
|
return httpx2.Response(200, content=_FEED)
|
||||||
|
downloads.append(str(request.url))
|
||||||
|
return httpx2.Response(200, content=b"audio-bytes")
|
||||||
|
|
||||||
|
async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as client:
|
||||||
|
await sync_show(client, folder, "http://example.com/feed.xml", keep=1)
|
||||||
|
first_pass = len(downloads)
|
||||||
|
changed_again = await sync_show(client, folder, "http://example.com/feed.xml", keep=1)
|
||||||
|
|
||||||
|
assert changed_again is False
|
||||||
|
assert len(downloads) == first_pass
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sync_show_reports_a_change_when_it_only_pruned(tmp_path: Path) -> None:
|
||||||
|
"""A deletion needs a rescan as much as a download does, or the library goes on
|
||||||
|
offering tracks whose files are gone."""
|
||||||
|
folder = tmp_path / "show"
|
||||||
|
folder.mkdir()
|
||||||
|
_write_episode(folder, "20250101 - Episode One.mp3")
|
||||||
|
_write_episode(folder, "20250103 - Episode Two_ Special_Chars_.mp3")
|
||||||
|
|
||||||
|
def handler(request: httpx2.Request) -> httpx2.Response:
|
||||||
|
return httpx2.Response(200, content=_FEED)
|
||||||
|
|
||||||
|
async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as client:
|
||||||
|
changed = await sync_show(client, folder, "http://example.com/feed.xml", keep=1)
|
||||||
|
|
||||||
|
assert changed is True
|
||||||
|
assert not (folder / "20250101 - Episode One.mp3").exists()
|
||||||
|
|||||||
@@ -32,7 +32,9 @@ async def test_run_checks_immediately_and_calls_on_change_only_when_something_ch
|
|||||||
) -> None:
|
) -> None:
|
||||||
calls = 0
|
calls = 0
|
||||||
|
|
||||||
async def fake_sync_all_shows(client: httpx2.AsyncClient, root: Path) -> bool:
|
async def fake_sync_all_shows(
|
||||||
|
client: httpx2.AsyncClient, root: Path, *, keep: int | None = None
|
||||||
|
) -> bool:
|
||||||
nonlocal calls
|
nonlocal calls
|
||||||
calls += 1
|
calls += 1
|
||||||
return calls == 1 # only the very first pass finds something new
|
return calls == 1 # only the very first pass finds something new
|
||||||
@@ -63,7 +65,9 @@ async def test_run_survives_a_failing_pass(
|
|||||||
) -> None:
|
) -> None:
|
||||||
calls = 0
|
calls = 0
|
||||||
|
|
||||||
async def fake_sync_all_shows(client: httpx2.AsyncClient, root: Path) -> bool:
|
async def fake_sync_all_shows(
|
||||||
|
client: httpx2.AsyncClient, root: Path, *, keep: int | None = None
|
||||||
|
) -> bool:
|
||||||
nonlocal calls
|
nonlocal calls
|
||||||
calls += 1
|
calls += 1
|
||||||
if calls == 1:
|
if calls == 1:
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import TypeVar
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -49,7 +50,10 @@ def seen(sim: Simulation) -> list[Event]:
|
|||||||
return events
|
return events
|
||||||
|
|
||||||
|
|
||||||
def only[T: Event](events: list[Event], event_type: type[T]) -> list[T]:
|
T = TypeVar("T", bound=Event)
|
||||||
|
|
||||||
|
|
||||||
|
def only(events: list[Event], event_type: type[T]) -> list[T]:
|
||||||
return [e for e in events if isinstance(e, event_type)]
|
return [e for e in events if isinstance(e, event_type)]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import contextlib
|
|||||||
import json
|
import json
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import TypeAlias
|
||||||
|
|
||||||
import httpx2
|
import httpx2
|
||||||
import pytest
|
import pytest
|
||||||
@@ -27,7 +28,7 @@ from tests.websocket_harness import WebSocketSession, websocket_connect
|
|||||||
TRACK_SECONDS = 10.0
|
TRACK_SECONDS = 10.0
|
||||||
|
|
||||||
#: Shorthand: every test takes the same client type.
|
#: Shorthand: every test takes the same client type.
|
||||||
type Client = httpx2.AsyncClient
|
Client: TypeAlias = httpx2.AsyncClient
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -89,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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -206,6 +206,9 @@ if [[ $COPY_CACHE -eq 1 ]]; then
|
|||||||
for part in covers analysis; do
|
for part in covers analysis; do
|
||||||
[[ -d "${CACHE}/${part}" ]] || continue
|
[[ -d "${CACHE}/${part}" ]] || continue
|
||||||
echo "--- ${part}"
|
echo "--- ${part}"
|
||||||
|
# rsync creates the last path component but not a missing one above it, and on a
|
||||||
|
# fresh device neither .musicmouse-cache nor its subdirectory exists yet.
|
||||||
|
[[ $DRY_RUN -eq 1 ]] || ssh "$SSH_TARGET" "mkdir -p '${DEST}/.musicmouse-cache/${part}'"
|
||||||
# No --delete: the device may have analysed tracks this machine never saw.
|
# No --delete: the device may have analysed tracks this machine never saw.
|
||||||
rsync "${RSYNC_OPTS[@]}" \
|
rsync "${RSYNC_OPTS[@]}" \
|
||||||
"${CACHE}/${part}/" "${SSH_TARGET}:${DEST}/.musicmouse-cache/${part}/"
|
"${CACHE}/${part}/" "${SSH_TARGET}:${DEST}/.musicmouse-cache/${part}/"
|
||||||
|
|||||||
1
slint-frontend/.gitignore
vendored
Normal file
1
slint-frontend/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
target
|
||||||
5908
slint-frontend/Cargo.lock
generated
Normal file
5908
slint-frontend/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
65
slint-frontend/Cargo.toml
Normal file
65
slint-frontend/Cargo.toml
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
[package]
|
||||||
|
name = "musicmouse-slint"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
description = "Native Slint front-end for the MusicMouse player"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
# default-features off so the renderer is an explicit choice below, never an
|
||||||
|
# accident: on a Pi 5 (Mesa V3D) the default FemtoVG renderer draws every runtime
|
||||||
|
# loaded Image as solid black - see slint-ui/slint#11785 - which for this app means
|
||||||
|
# every album cover. The `skia` feature is the device's way out.
|
||||||
|
slint = { version = "1.15", default-features = false, features = [
|
||||||
|
"compat-1-2",
|
||||||
|
"std",
|
||||||
|
] }
|
||||||
|
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
# Blocking HTTP and blocking websockets, each on its own thread, rather than an async
|
||||||
|
# runtime. The whole client makes one library request, one websocket connection and a
|
||||||
|
# trickle of covers; tokio would be more machinery than the job has work.
|
||||||
|
ureq = { version = "2.12", default-features = false, features = ["json", "gzip"] }
|
||||||
|
tungstenite = { version = "0.24", default-features = false, features = ["handshake"] }
|
||||||
|
image = { version = "0.25", default-features = false, features = ["jpeg", "png"] }
|
||||||
|
dirs = "5"
|
||||||
|
# Only for `normalize()`: folding "Grüffelo" to "gruffelo" needs a real NFD pass, which
|
||||||
|
# is what the web client gets from String.prototype.normalize for free.
|
||||||
|
unicode-normalization = "0.1"
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
slint-build = "1.15"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = ["desktop"]
|
||||||
|
|
||||||
|
# Development on a normal desktop session. FemtoVG is fine on x86/Mesa and builds in
|
||||||
|
# seconds, which is what makes it the right default *here* and the wrong one on the Pi.
|
||||||
|
# `renderer-software` rides along so `SLINT_BACKEND=winit-software` works without a
|
||||||
|
# rebuild: it is how the UI can be rendered and screenshotted on a machine with no GPU
|
||||||
|
# (or no session), and it is the fallback the Pi 5 FemtoVG bug leaves standing.
|
||||||
|
desktop = ["slint/backend-winit", "slint/renderer-femtovg", "slint/renderer-software"]
|
||||||
|
|
||||||
|
# Same window, Skia instead - the combination the device runs, so visual checks here
|
||||||
|
# match what ships. Costs a long first build while rust-skia bootstraps.
|
||||||
|
skia = ["slint/backend-winit", "slint/renderer-skia"]
|
||||||
|
|
||||||
|
# The device: straight onto KMS/DRM with no X, no Wayland and no compositor, input
|
||||||
|
# via libinput. (`backend-linuxkms-noseat` is deprecated upstream in favour of this.)
|
||||||
|
#
|
||||||
|
# Build it with `--no-default-features --features kiosk`: cargo features are additive,
|
||||||
|
# so leaving `desktop` enabled links winit and linuxkms into the same binary.
|
||||||
|
kiosk = ["slint/backend-linuxkms-libinput", "slint/renderer-skia"]
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
opt-level = 3
|
||||||
|
lto = "thin"
|
||||||
|
codegen-units = 1
|
||||||
|
panic = "abort"
|
||||||
|
|
||||||
|
# The UI is laid out and hit-tested in debug builds too; an unoptimised image resize
|
||||||
|
# on a 343-album first run is otherwise the slowest thing in the app by far.
|
||||||
|
[profile.dev.package.image]
|
||||||
|
opt-level = 3
|
||||||
|
[profile.dev.package.slint]
|
||||||
|
opt-level = 2
|
||||||
192
slint-frontend/README.md
Normal file
192
slint-frontend/README.md
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
# Musik Delfin — the Slint front-end
|
||||||
|
|
||||||
|
A native front-end for the music player, next to `../web/`. Same backend, same layout,
|
||||||
|
same German copy; no browser. It exists because the React UI is sluggish on the Pi 5
|
||||||
|
kiosk, and the things making it sluggish are browser-shaped: a compositor deciding what
|
||||||
|
gets its own layer, a `requestAnimationFrame` loop that repaints the viewport, and a
|
||||||
|
JPEG decode per album card per cold start.
|
||||||
|
|
||||||
|
The music player and the room panel ("Mein Zimmer"). The typing game (`Tippen`), parent
|
||||||
|
mode and the IR-remote assignment are still web-only.
|
||||||
|
|
||||||
|
## Running it
|
||||||
|
|
||||||
|
Start the backend, then:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
nix-shell # cargo, the GL/X11/Wayland libs, and Nunito
|
||||||
|
cargo run # windowed, FemtoVG
|
||||||
|
cargo run -- --server http://musicdolphin:8080
|
||||||
|
cargo run -- --page room # open on the room panel rather than the player
|
||||||
|
```
|
||||||
|
|
||||||
|
Checks:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo test && cargo clippy --all-targets && cargo fmt --check
|
||||||
|
```
|
||||||
|
|
||||||
|
`tools/headless-shots.sh` renders the UI and screenshots it inside a nested headless
|
||||||
|
compositor, for checking a layout change on a machine with no display (or a locked one).
|
||||||
|
A Slint window needs a real compositor - under bare Xvfb nothing maps at all.
|
||||||
|
|
||||||
|
## Build profiles
|
||||||
|
|
||||||
|
| | Backend | Renderer | For |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `cargo run` | winit | FemtoVG | development here |
|
||||||
|
| `cargo run --features skia` | winit | Skia | checking what the device will show |
|
||||||
|
| `cargo build --release --no-default-features --features kiosk` | linuxkms + libinput | Skia | the Pi |
|
||||||
|
|
||||||
|
**The device must not use FemtoVG.** On a Pi 5 (Mesa V3D), FemtoVG draws every
|
||||||
|
runtime-loaded `Image` as solid black - [slint#11785][bug], open as of this writing -
|
||||||
|
which for this app means every album cover. Skia and the software renderer are both
|
||||||
|
unaffected. FemtoVG stays the default *here* because it builds in seconds and is fine on
|
||||||
|
desktop Mesa; `kiosk` is the profile that ships.
|
||||||
|
|
||||||
|
`--no-default-features` is not optional on that last one: the features are additive, so
|
||||||
|
leaving `desktop` on would link winit *and* linuxkms into the same binary.
|
||||||
|
|
||||||
|
`kiosk` takes the framebuffer directly through KMS/DRM: no X, no Wayland, no compositor.
|
||||||
|
(`backend-linuxkms-noseat` is deprecated upstream in favour of the libinput variant used
|
||||||
|
here.) `SLINT_BACKEND=winit-software` forces the software renderer without a rebuild.
|
||||||
|
|
||||||
|
[bug]: https://github.com/slint-ui/slint/issues/11785
|
||||||
|
|
||||||
|
## Where the speed comes from
|
||||||
|
|
||||||
|
Four things, in rough order of how much they matter on the device:
|
||||||
|
|
||||||
|
**Covers are downscaled once, ever.** The backend serves one size - 640 px on the long
|
||||||
|
edge - and has no `?size=`. A grid card is 180 px. `src/covers.rs` fetches each cover
|
||||||
|
once on a worker thread, resizes it to the tier that was asked for, and writes a JPEG
|
||||||
|
thumbnail to `~/.cache/musicmouse-slint/covers/<album-id>@<px>.jpg`. Every later start
|
||||||
|
reads that instead. There are two tiers and only two, which is what makes the cache
|
||||||
|
worth having: a per-widget pixel size would give each album a dozen near-identical files
|
||||||
|
and a fresh decode for each.
|
||||||
|
|
||||||
|
The cache is dropped wholesale when the websocket announces a rescan
|
||||||
|
(`{"type": "library"}`), because that is the one moment the backend rewrites the art
|
||||||
|
behind an unchanged cover URL. That is the bug the web client had to learn about the
|
||||||
|
hard way - see the comment at `python-backend/musicmouse/services/web/api.py:106`.
|
||||||
|
|
||||||
|
**The grid is virtualized.** Rust hands the UI a `[GridRow]` - the album list
|
||||||
|
pre-chunked into rows - and the view puts those in a `ListView`, which only instantiates
|
||||||
|
the rows on screen. A flat list of 660 cards gives it no rows to skip, hence the
|
||||||
|
chunking. This is the counterpart of `content-visibility: auto` on `.grid > .card` in
|
||||||
|
the web UI, which exists for exactly the same measurement: 340 live cards saturated the
|
||||||
|
Pi's renderer so completely that the main thread stopped scheduling anything.
|
||||||
|
|
||||||
|
**The progress bar animates instead of ticking.** Position arrives twice a second. The
|
||||||
|
web client interpolates it onto animation frames with a JS clock and a render budget;
|
||||||
|
here the fill is a Slint `animate width` between the pushed values, so interpolation
|
||||||
|
costs a property evaluation per frame on the render side and there is no clock at all.
|
||||||
|
It is suppressed for the one frame a track changes on, so a new track jumps rather than
|
||||||
|
sliding across two unrelated positions.
|
||||||
|
|
||||||
|
**Nothing on screen animates by itself.** The web UI's per-track ambient canvas is not
|
||||||
|
here. Halving its resolution and its frame rate took it from 53.5% of a core to 25.0%
|
||||||
|
and 25% was still too much: a loop repainting the viewport is a floor you cannot get
|
||||||
|
under while it runs at all. The stage keeps its gradient, which is what `?pi=1` already
|
||||||
|
does on the device this replaces.
|
||||||
|
|
||||||
|
## What is deliberately different from the web UI
|
||||||
|
|
||||||
|
**No glass blur.** Slint has no `backdrop-filter`, so the panels use a flat translucent
|
||||||
|
fill - exactly the web app's own `data-blur="off"` fallback. Some depth is genuinely
|
||||||
|
lost. Faking it by compositing a pre-blurred copy would reintroduce the per-frame cost
|
||||||
|
that dropping the blur was meant to avoid.
|
||||||
|
|
||||||
|
Worth knowing if you ever port the blur back: on the Pi, `backdrop-filter` measured five
|
||||||
|
times *better* than no blur (25% of a core against 118%), because it is what promotes
|
||||||
|
each panel to its own compositing layer. That is a browser-compositor artifact and does
|
||||||
|
not transfer here - but it is why the web UI keeps the panel blur under `?pi=1`.
|
||||||
|
|
||||||
|
**No dolphin.** The mascot and its swim/bob loops are not ported.
|
||||||
|
|
||||||
|
**Layout** is Slint's, not a transcription of the CSS: fixed row heights instead of
|
||||||
|
`clamp()` and `vh`, a 1080p-shaped fixed measure for the track list rather than
|
||||||
|
`max-width`. The colours are the same - `ui/theme.slint` carries the web UI's oklch
|
||||||
|
tokens converted to sRGB, with the oklch source kept beside each one, because oklch is
|
||||||
|
the form the design is actually maintained in.
|
||||||
|
|
||||||
|
## How it is put together
|
||||||
|
|
||||||
|
The split that matters is the web UI's, unchanged: **what is playing** comes from the
|
||||||
|
backend, **what you are looking at** lives here. The buttons on the mouse, a figurine on
|
||||||
|
the reader and Home Assistant all move playback too, so this follows it rather than
|
||||||
|
assuming it is in charge.
|
||||||
|
|
||||||
|
Three worker threads and no async runtime - the whole client makes one library request,
|
||||||
|
one websocket connection and a trickle of covers.
|
||||||
|
|
||||||
|
| Path | What it is |
|
||||||
|
|---|---|
|
||||||
|
| `src/api.rs` | The wire types and the HTTP calls. Mirrors `services/web/schemas.py`. |
|
||||||
|
| `src/ws.rs` | The state websocket, with reconnect-and-reseed. |
|
||||||
|
| `src/library.rs` | The search index and the browse hierarchy. Port of `web/src/lib/search.ts`. |
|
||||||
|
| `src/keymap.rs` | The keyboard model, as a pure function. Port of `web/src/lib/keyboard.ts`. |
|
||||||
|
| `src/covers.rs` | The thumbnail cache, and the generated stand-in art. |
|
||||||
|
| `src/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/main.rs` | Wiring: worker channels in, Slint models out. |
|
||||||
|
| `ui/*.slint` | Layout only. Nothing here formats a number or picks a word. |
|
||||||
|
|
||||||
|
`src/keymap.rs` and `src/library.rs` are ports of modules that were already pure
|
||||||
|
functions with their own tests, and they keep them: 43 tests, no window required.
|
||||||
|
|
||||||
|
## Keyboard
|
||||||
|
|
||||||
|
The same map as the web UI.
|
||||||
|
|
||||||
|
| Key | |
|
||||||
|
|---|---|
|
||||||
|
| `SPACE` | play / pause (types a space mid-query) |
|
||||||
|
| `SHIFT+H` / `SHIFT+L` | previous / next |
|
||||||
|
| `SHIFT+J` / `SHIFT+K` | quieter / louder |
|
||||||
|
| `SHIFT+M` | mute |
|
||||||
|
| `SHIFT+←` / `SHIFT+→` | seek 15 s |
|
||||||
|
| `A-Z`, `0-9` | search albums as you type |
|
||||||
|
| `?` | search individual tracks |
|
||||||
|
| `TAB` | cycle Musik / Hörbücher / Podcasts |
|
||||||
|
| `← ↑ ↓ →` | move the selection while browsing; transport and volume while playing |
|
||||||
|
| `CTRL+H/J/K/L` | the same, without leaving the home row |
|
||||||
|
| `CTRL+D` / `CTRL+U` | half a page |
|
||||||
|
| `ENTER` | play the selection |
|
||||||
|
| `SHIFT+ENTER` | open the track list |
|
||||||
|
| `ESC` | back out one layer at a time |
|
||||||
|
| `/` | back to browsing |
|
||||||
|
| `CTRL+TAB` | cycle Musik / Mein Zimmer / Tippen |
|
||||||
|
|
||||||
|
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
|
||||||
|
reach Rust as `"escape"`, `"left"`, `"enter"` and so on - so `src/keymap.rs` is testable
|
||||||
|
without a window and without hardcoding keysym values.
|
||||||
|
|
||||||
|
## Fonts
|
||||||
|
|
||||||
|
Nunito, via `SLINT_DEFAULT_FONT`, which `shell.nix` sets. Slint cannot read the woff2
|
||||||
|
the web app self-hosts, so this wants the TTF; a deployment can drop one at
|
||||||
|
`fonts/Nunito.ttf` instead. With neither, everything still renders in the system sans.
|
||||||
3
slint-frontend/build.rs
Normal file
3
slint-frontend/build.rs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
slint_build::compile("ui/app.slint").expect("compiling ui/app.slint");
|
||||||
|
}
|
||||||
BIN
slint-frontend/fonts/Nunito.ttf
Normal file
BIN
slint-frontend/fonts/Nunito.ttf
Normal file
Binary file not shown.
9
slint-frontend/fonts/README.md
Normal file
9
slint-frontend/fonts/README.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
Nunito, the face the design is drawn in, as a variable TTF.
|
||||||
|
|
||||||
|
`web/public/fonts/` has the same family as woff2, which is right for a browser and
|
||||||
|
useless here: Slint cannot read woff2. This is the upstream variable TTF (weights
|
||||||
|
200-1000), which `SLINT_DEFAULT_FONT` is pointed at - by `shell.nix` for development,
|
||||||
|
and by the launcher `roles/pi_slint_frontend` installs on the device.
|
||||||
|
|
||||||
|
Nunito is licensed under the SIL Open Font License 1.1:
|
||||||
|
https://github.com/googlefonts/nunito
|
||||||
62
slint-frontend/shell.nix
Normal file
62
slint-frontend/shell.nix
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
# Dev shell for the Slint front-end.
|
||||||
|
#
|
||||||
|
# NixOS has no rustup-installed toolchain here, and Slint's renderers link against
|
||||||
|
# system GL/X11/Wayland libraries that a plain `cargo build` cannot find on its own -
|
||||||
|
# hence LD_LIBRARY_PATH below rather than just a list of buildInputs.
|
||||||
|
{ pkgs ? import <nixpkgs> { } }:
|
||||||
|
|
||||||
|
let
|
||||||
|
# Everything the winit backend dlopen()s at runtime. Missing one of these shows up
|
||||||
|
# as a panic inside winit, not as a link error, so they are easy to misdiagnose.
|
||||||
|
runtimeLibs = with pkgs; [
|
||||||
|
libGL
|
||||||
|
libxkbcommon
|
||||||
|
wayland
|
||||||
|
xorg.libX11
|
||||||
|
xorg.libXcursor
|
||||||
|
xorg.libXi
|
||||||
|
xorg.libXrandr
|
||||||
|
fontconfig
|
||||||
|
freetype
|
||||||
|
];
|
||||||
|
in
|
||||||
|
pkgs.mkShell {
|
||||||
|
nativeBuildInputs = with pkgs; [
|
||||||
|
cargo
|
||||||
|
rustc
|
||||||
|
rustfmt
|
||||||
|
clippy
|
||||||
|
pkg-config
|
||||||
|
# Only needed when building with --features skia: rust-skia compiles its own
|
||||||
|
# bindings shim and wants a C++ toolchain plus python for the GN build.
|
||||||
|
clang
|
||||||
|
python3
|
||||||
|
];
|
||||||
|
|
||||||
|
# Nunito is the face the design is drawn in. `SLINT_DEFAULT_FONT` takes one file and
|
||||||
|
# makes it the primary font. This points at the copy vendored in `fonts/` rather than
|
||||||
|
# at nixpkgs, so development and the device render from the identical file.
|
||||||
|
SLINT_DEFAULT_FONT = toString ./fonts/Nunito.ttf;
|
||||||
|
|
||||||
|
buildInputs = runtimeLibs ++ (with pkgs; [
|
||||||
|
fontconfig
|
||||||
|
# libinput/libdrm/libseat are the linuxkms (kiosk) backend's dependencies. They
|
||||||
|
# cost nothing to have present on a desktop build.
|
||||||
|
libinput
|
||||||
|
libdrm
|
||||||
|
seatd
|
||||||
|
udev
|
||||||
|
]);
|
||||||
|
|
||||||
|
LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath runtimeLibs;
|
||||||
|
|
||||||
|
# rust-skia downloads a prebuilt libskia rather than building it, when it can.
|
||||||
|
SKIA_BINARIES_URL_TEMPLATE = null;
|
||||||
|
|
||||||
|
shellHook = ''
|
||||||
|
echo "musicmouse slint-frontend — cargo $(cargo --version | cut -d' ' -f2)"
|
||||||
|
echo " cargo run # windowed, femtovg"
|
||||||
|
echo " cargo run --features skia # windowed, skia (what the Pi uses)"
|
||||||
|
echo " cargo build --features kiosk # linuxkms + skia, for the device"
|
||||||
|
'';
|
||||||
|
}
|
||||||
334
slint-frontend/src/api.rs
Normal file
334
slint-frontend/src/api.rs
Normal file
@@ -0,0 +1,334 @@
|
|||||||
|
//! The shapes the backend serves, and the calls that fetch them.
|
||||||
|
//!
|
||||||
|
//! Mirrors `python-backend/musicmouse/services/web/schemas.py`, by way of
|
||||||
|
//! `web/src/api/types.ts` - the three are hand-kept in step. Only the music player's
|
||||||
|
//! share of the API is here; settings, the IR remote, Home Assistant and the typing
|
||||||
|
//! game are all reachable and all deliberately absent.
|
||||||
|
//!
|
||||||
|
//! Every field the backend can omit is modelled with `#[serde(default)]` rather than a
|
||||||
|
//! required `Option`, so a backend that grows a field does not break this client and
|
||||||
|
//! one that loses a field fails at the one call site that cared.
|
||||||
|
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
/// Default for a development run; `--server` overrides it.
|
||||||
|
pub const DEFAULT_BASE_URL: &str = "http://127.0.0.1:8080";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum AlbumKind {
|
||||||
|
#[default]
|
||||||
|
Music,
|
||||||
|
Book,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AlbumKind {
|
||||||
|
/// Shape is how you tell the two apart without reading anything: albums square,
|
||||||
|
/// audiobooks taller than wide, everywhere they appear. Nothing else in this app
|
||||||
|
/// may set a cover's aspect ratio. Ported from `web/src/lib/covers.ts`.
|
||||||
|
pub fn aspect(self) -> f32 {
|
||||||
|
match self {
|
||||||
|
Self::Music => 1.0,
|
||||||
|
Self::Book => 0.82,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct Track {
|
||||||
|
pub title: String,
|
||||||
|
/// Seconds, read from the file's tags at scan time. `0.0` when it carries none.
|
||||||
|
#[serde(default)]
|
||||||
|
pub duration: f64,
|
||||||
|
/// A typing-reward track not yet earned. This front-end does not host the typing
|
||||||
|
/// game, but it still has to not show what the game is withholding.
|
||||||
|
#[serde(default)]
|
||||||
|
pub locked: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
// `series`, and the parts of `PlayerState` the UI does not read, are kept because these
|
||||||
|
// structs are the record of what the backend serves - dropping a field would make this
|
||||||
|
// file a list of what this front-end happens to use today, which is a much less useful
|
||||||
|
// thing to read next to `schemas.py`.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct Album {
|
||||||
|
pub id: String,
|
||||||
|
/// One of the four fixed shelf folder names: `Figuren`, `Musik`, `Hörbücher`,
|
||||||
|
/// `Kinderpodcasts`. What `Group::of` reads to spot a podcast.
|
||||||
|
pub section: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub kind: AlbumKind,
|
||||||
|
pub title: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub artist: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub series: Option<String>,
|
||||||
|
/// The figurine whose folder this is, when it has one.
|
||||||
|
#[serde(default)]
|
||||||
|
pub figure: Option<String>,
|
||||||
|
/// Series for audiobooks and podcasts, artist for music. What browsing groups by.
|
||||||
|
#[serde(default)]
|
||||||
|
pub category: String,
|
||||||
|
/// Exactly three `#rrggbb`: primary, secondary, accent, pulled out of the cover art
|
||||||
|
/// or synthesised from the id. The generated stand-in cover is painted from these.
|
||||||
|
#[serde(default)]
|
||||||
|
pub colors: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub has_cover: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub duration: f64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub tracks: Vec<Track>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub locked: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Album {
|
||||||
|
pub fn is_book(&self) -> bool {
|
||||||
|
self.kind == AlbumKind::Book
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `"Album · Artist"`, unless a podcast makes those the same string.
|
||||||
|
pub fn line(&self) -> String {
|
||||||
|
let prefix = if self.is_book() { "📖 " } else { "" };
|
||||||
|
if !self.artist.is_empty() && self.artist != self.title {
|
||||||
|
format!("{prefix}{} · {}", self.title, self.artist)
|
||||||
|
} else {
|
||||||
|
format!("{prefix}{}", self.title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `colors` padded out to the three the painter wants, with the same fallbacks the
|
||||||
|
/// web client uses, so a library entry missing them looks the same in both.
|
||||||
|
pub fn palette(&self) -> [&str; 3] {
|
||||||
|
[
|
||||||
|
self.colors.first().map_or("#4a6fa5", String::as_str),
|
||||||
|
self.colors.get(1).map_or("#6a8fc5", String::as_str),
|
||||||
|
self.colors.get(2).map_or("#a5804a", String::as_str),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
struct LibraryOut {
|
||||||
|
albums: Vec<Album>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
#[derive(Debug, Clone, Default, Deserialize)]
|
||||||
|
pub struct Connection {
|
||||||
|
#[serde(default)]
|
||||||
|
pub firmware: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub mqtt: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub lirc: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What is playing. Owned entirely by the backend: the buttons on the mouse, a figurine
|
||||||
|
/// on the reader and Home Assistant all move it too, so this client follows it rather
|
||||||
|
/// than assuming it is in charge.
|
||||||
|
#[allow(dead_code)] // see the note above `Album`
|
||||||
|
#[derive(Debug, Clone, Default, Deserialize)]
|
||||||
|
pub struct PlayerState {
|
||||||
|
#[serde(default)]
|
||||||
|
pub playing: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub album_id: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub album_title: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub artist: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub kind: Option<AlbumKind>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub track_index: i32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub track_title: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub track_count: i32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub position: f64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub duration: f64,
|
||||||
|
/// Percent, 0..100. The device's configured range never leaves the backend.
|
||||||
|
#[serde(default)]
|
||||||
|
pub volume: i32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub active_figure: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub connected: Connection,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The push-only websocket's frames. `library` carries no payload - it is a bare "your
|
||||||
|
/// index is stale, fetch it again" after a rescan.
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(tag = "type", rename_all = "lowercase")]
|
||||||
|
pub enum ServerMessage {
|
||||||
|
State {
|
||||||
|
state: PlayerState,
|
||||||
|
},
|
||||||
|
Position {
|
||||||
|
position: f64,
|
||||||
|
#[serde(default)]
|
||||||
|
duration: f64,
|
||||||
|
},
|
||||||
|
Library,
|
||||||
|
/// A frame this build does not know. Ignored rather than fatal, so the backend can
|
||||||
|
/// grow a message type without this client falling over on it.
|
||||||
|
#[serde(other)]
|
||||||
|
Unknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------------- client --
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Client {
|
||||||
|
base: String,
|
||||||
|
agent: ureq::Agent,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enough of an error type to put a cause on screen. Nothing here is recoverable in a
|
||||||
|
/// way the caller could branch on - the UI either has a library or says it cannot reach
|
||||||
|
/// the mouse - so the variants are not worth splitting.
|
||||||
|
pub type Error = String;
|
||||||
|
|
||||||
|
impl Client {
|
||||||
|
pub fn new(base_url: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
base: base_url.trim_end_matches('/').to_string(),
|
||||||
|
agent: ureq::AgentBuilder::new()
|
||||||
|
.timeout_connect(std::time::Duration::from_secs(4))
|
||||||
|
.timeout_read(std::time::Duration::from_secs(30))
|
||||||
|
.build(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `http://host:port` -> `ws://host:port/api/ws`. The device serves plain HTTP, but
|
||||||
|
/// deriving the scheme rather than hardcoding it keeps a TLS proxy in front working.
|
||||||
|
pub fn websocket_url(&self) -> String {
|
||||||
|
let ws = if let Some(rest) = self.base.strip_prefix("https://") {
|
||||||
|
format!("wss://{rest}")
|
||||||
|
} else if let Some(rest) = self.base.strip_prefix("http://") {
|
||||||
|
format!("ws://{rest}")
|
||||||
|
} else {
|
||||||
|
format!("ws://{}", self.base)
|
||||||
|
};
|
||||||
|
format!("{ws}/api/ws")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cover_url(&self, album_id: &str) -> String {
|
||||||
|
format!("{}/api/albums/{album_id}/cover", self.base)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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> {
|
||||||
|
let out: LibraryOut = self
|
||||||
|
.agent
|
||||||
|
.get(&format!("{}/api/library", self.base))
|
||||||
|
.call()
|
||||||
|
.map_err(|e| format!("GET /api/library: {e}"))?
|
||||||
|
.into_json()
|
||||||
|
.map_err(|e| format!("GET /api/library: bad JSON: {e}"))?;
|
||||||
|
Ok(out.albums)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn state(&self) -> Result<PlayerState, Error> {
|
||||||
|
self.agent
|
||||||
|
.get(&format!("{}/api/state", self.base))
|
||||||
|
.call()
|
||||||
|
.map_err(|e| format!("GET /api/state: {e}"))?
|
||||||
|
.into_json()
|
||||||
|
.map_err(|e| format!("GET /api/state: bad JSON: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Raw cover bytes, or `Ok(None)` for a 404 - which is a normal answer, not a
|
||||||
|
/// failure: `has_cover` says so in advance and the client paints the album's own
|
||||||
|
/// colours instead.
|
||||||
|
pub fn cover(&self, album_id: &str) -> Result<Option<Vec<u8>>, Error> {
|
||||||
|
let response = match self.agent.get(&self.cover_url(album_id)).call() {
|
||||||
|
Ok(response) => response,
|
||||||
|
Err(ureq::Error::Status(404, _)) => return Ok(None),
|
||||||
|
Err(e) => return Err(format!("GET cover {album_id}: {e}")),
|
||||||
|
};
|
||||||
|
let mut bytes = Vec::new();
|
||||||
|
std::io::Read::read_to_end(&mut response.into_reader(), &mut bytes)
|
||||||
|
.map_err(|e| format!("GET cover {album_id}: {e}"))?;
|
||||||
|
Ok(Some(bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------- commands --
|
||||||
|
//
|
||||||
|
// Every one of these emits the same *intent* the physical buttons emit, so this UI
|
||||||
|
// has no privileged path and no way to get out of step with a figure someone puts
|
||||||
|
// on the reader. They all answer 204 with no body - nothing to parse.
|
||||||
|
|
||||||
|
fn command(&self, path: &str, body: serde_json::Value) -> Result<(), Error> {
|
||||||
|
let request = self.agent.post(&format!("{}/api{path}", self.base));
|
||||||
|
let result = if body.is_null() {
|
||||||
|
request.call()
|
||||||
|
} else {
|
||||||
|
request.send_json(body)
|
||||||
|
};
|
||||||
|
result.map(|_| ()).map_err(|e| format!("POST {path}: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn play(&self, album_id: &str, track_index: i32) -> Result<(), Error> {
|
||||||
|
self.command(
|
||||||
|
"/play",
|
||||||
|
serde_json::json!({ "album_id": album_id, "track_index": track_index.max(0) }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resume(&self) -> Result<(), Error> {
|
||||||
|
self.command("/resume", serde_json::Value::Null)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn pause(&self) -> Result<(), Error> {
|
||||||
|
self.command("/pause", serde_json::Value::Null)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn next(&self) -> Result<(), Error> {
|
||||||
|
self.command("/next", serde_json::Value::Null)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn previous(&self) -> Result<(), Error> {
|
||||||
|
self.command("/previous", serde_json::Value::Null)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn seek(&self, position: f64) -> Result<(), Error> {
|
||||||
|
self.command(
|
||||||
|
"/seek",
|
||||||
|
serde_json::json!({ "position": position.max(0.0) }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn volume(&self, percent: i32) -> Result<(), Error> {
|
||||||
|
self.command(
|
||||||
|
"/volume",
|
||||||
|
serde_json::json!({ "percent": percent.clamp(0, 100) }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
406
slint-frontend/src/covers.rs
Normal file
406
slint-frontend/src/covers.rs
Normal file
@@ -0,0 +1,406 @@
|
|||||||
|
//! Album art: fetched once, downscaled once, then reused forever.
|
||||||
|
//!
|
||||||
|
//! The backend serves one size and only one - `MAX_COVER_PX = 640` on the longest edge,
|
||||||
|
//! JPEG, at `/api/albums/{id}/cover`, with no `?size=` to ask for less. A browse grid
|
||||||
|
//! draws that at 192 px and the play view at 384, so painting the served file directly
|
||||||
|
//! means decoding a 640x640 JPEG for every visible card on every cold start. At 343
|
||||||
|
//! albums on a Pi that is the whole of "the UI feels slow".
|
||||||
|
//!
|
||||||
|
//! So this keeps a thumbnail cache on disk, keyed by album id *and* the size that was
|
||||||
|
//! asked for, and the decode-and-resize cost is paid once ever rather than once per
|
||||||
|
//! launch. A miss goes to the network on a worker thread; the UI never blocks on one.
|
||||||
|
//!
|
||||||
|
//! **Invalidation.** The bytes behind a cover URL do change while the URL does not -
|
||||||
|
//! the id addresses which album the art belongs to, not which art. Rather than pay a
|
||||||
|
//! conditional request per cover per start, this cache is cleared wholesale when the
|
||||||
|
//! websocket announces a rescan (`{"type": "library"}`), which is the one moment the
|
||||||
|
//! backend reprocesses cover art. See the long comment at
|
||||||
|
//! `python-backend/musicmouse/services/web/api.py:106` for how the web client learned
|
||||||
|
//! this the hard way.
|
||||||
|
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::mpsc::{Receiver, Sender};
|
||||||
|
|
||||||
|
use image::imageops::FilterType;
|
||||||
|
use image::{Rgba, RgbaImage};
|
||||||
|
|
||||||
|
use crate::api::{AlbumKind, Client};
|
||||||
|
use crate::library::Group;
|
||||||
|
use crate::oklch::{mix, oklch_to_rgb, parse_hex};
|
||||||
|
|
||||||
|
/// The two sizes anything on screen actually asks for. Keeping it to two is what makes
|
||||||
|
/// the disk cache worth having: a per-widget pixel size would give every album a dozen
|
||||||
|
/// near-identical files and a fresh decode for each.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
pub enum Tier {
|
||||||
|
/// Grid cards, shelf tiles, track rows and the player bar's 56 px thumbnail, which
|
||||||
|
/// scales down from this rather than earning a tier of its own.
|
||||||
|
Thumb,
|
||||||
|
/// The play view's cover, and the album sheet's.
|
||||||
|
Hero,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Tier {
|
||||||
|
pub fn px(self) -> u32 {
|
||||||
|
match self {
|
||||||
|
Self::Thumb => 192,
|
||||||
|
Self::Hero => 384,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub struct Key {
|
||||||
|
pub album_id: String,
|
||||||
|
pub tier: Tier,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Everything the worker needs about an album, flattened so it can cross a thread
|
||||||
|
/// boundary without the `Rc<Album>` the UI side holds.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Request {
|
||||||
|
pub album_id: String,
|
||||||
|
pub tier: Tier,
|
||||||
|
pub has_cover: bool,
|
||||||
|
pub is_book: bool,
|
||||||
|
pub locked: bool,
|
||||||
|
pub colors: [String; 3],
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A finished bitmap on its way back to the UI thread. Raw RGBA rather than a
|
||||||
|
/// `slint::Image`, because the image types are built on the UI thread by whoever
|
||||||
|
/// receives this.
|
||||||
|
pub struct Ready {
|
||||||
|
pub key: Key,
|
||||||
|
pub width: u32,
|
||||||
|
pub height: u32,
|
||||||
|
pub rgba: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ generated art --
|
||||||
|
|
||||||
|
/// Diagonal two-tone stripes: the stand-in for a music cover, and the thing that shows
|
||||||
|
/// through underneath one that has not arrived yet.
|
||||||
|
fn stripes(width: u32, height: u32, primary: [u8; 3], secondary: [u8; 3]) -> RgbaImage {
|
||||||
|
// `max(6, size / 13)` in `web/src/lib/covers.ts` - the stripe scales with the art so
|
||||||
|
// a 56 px thumbnail does not turn into a grey smear of forty bands.
|
||||||
|
let band = (width / 13).max(6) as f32;
|
||||||
|
RgbaImage::from_fn(width, height, |x, y| {
|
||||||
|
// 135deg in CSS points the gradient line at the bottom-right corner, so the
|
||||||
|
// position along it is the projection of (x, y) onto (1, 1)/sqrt(2).
|
||||||
|
let along = (x + y) as f32 * std::f32::consts::FRAC_1_SQRT_2;
|
||||||
|
let c = if ((along / band) as u32).is_multiple_of(2) {
|
||||||
|
primary
|
||||||
|
} else {
|
||||||
|
secondary
|
||||||
|
};
|
||||||
|
Rgba([c[0], c[1], c[2], 255])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A book: page edges down the right, a darker board down the left, over a diagonal
|
||||||
|
/// wash. Four layers in CSS, four passes here, in the same order.
|
||||||
|
fn spine(width: u32, height: u32, primary: [u8; 3], secondary: [u8; 3]) -> RgbaImage {
|
||||||
|
// The audiobook shelf's own hue - warm amber, not a duller yellow-brown - so a
|
||||||
|
// generated spine and the shelf it sits on are the same family by construction.
|
||||||
|
let hue = Group::Audiobooks.hue();
|
||||||
|
let page_light = oklch_to_rgb(0.97, 0.02, hue);
|
||||||
|
let page_dark = oklch_to_rgb(0.90, 0.03, hue);
|
||||||
|
|
||||||
|
RgbaImage::from_fn(width, height, |x, y| {
|
||||||
|
let fx = x as f32 / width.max(1) as f32;
|
||||||
|
let fy = y as f32 / height.max(1) as f32;
|
||||||
|
|
||||||
|
// Layer 4 (bottom): linear-gradient(155deg, primary -> secondary).
|
||||||
|
let t = ((fx * 0.42 + fy * 0.91) / 1.33).clamp(0.0, 1.0);
|
||||||
|
let mut c = mix(primary, secondary, t);
|
||||||
|
|
||||||
|
// Layer 3: the board down the left edge.
|
||||||
|
if fx < 0.11 {
|
||||||
|
c = secondary;
|
||||||
|
} else if fx < 0.13 {
|
||||||
|
c = primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Layer 2: two thin highlight rules over the board.
|
||||||
|
if (0.035..0.043).contains(&fx) || (0.070..0.078).contains(&fx) {
|
||||||
|
c = mix(c, [255, 255, 255], 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Layer 1 (top): the page block on the right.
|
||||||
|
if fx >= 0.975 {
|
||||||
|
c = page_dark;
|
||||||
|
} else if fx >= 0.95 {
|
||||||
|
c = page_light;
|
||||||
|
}
|
||||||
|
|
||||||
|
Rgba([c[0], c[1], c[2], 255])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What an album looks like before - or instead of - its real art.
|
||||||
|
pub fn generated(request: &Request) -> RgbaImage {
|
||||||
|
let primary = parse_hex(&request.colors[0], [0x4a, 0x6f, 0xa5]);
|
||||||
|
let secondary = parse_hex(&request.colors[1], [0x6a, 0x8f, 0xc5]);
|
||||||
|
let px = request.tier.px();
|
||||||
|
let (width, height) = dimensions(px, request.is_book);
|
||||||
|
if request.is_book {
|
||||||
|
spine(width, height, primary, secondary)
|
||||||
|
} else {
|
||||||
|
stripes(width, height, primary, secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shape comes from the media type and from nowhere else: albums square, audiobooks
|
||||||
|
/// taller than wide. `px` fixes the longest edge either way.
|
||||||
|
fn dimensions(px: u32, is_book: bool) -> (u32, u32) {
|
||||||
|
let kind = if is_book {
|
||||||
|
AlbumKind::Book
|
||||||
|
} else {
|
||||||
|
AlbumKind::Music
|
||||||
|
};
|
||||||
|
(((px as f32) * kind.aspect()).round() as u32, px)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------------- worker --
|
||||||
|
|
||||||
|
pub struct Worker {
|
||||||
|
client: Client,
|
||||||
|
dir: Option<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Worker {
|
||||||
|
pub fn new(client: Client) -> Self {
|
||||||
|
let dir = dirs::cache_dir().map(|d| d.join("musicmouse-slint").join("covers"));
|
||||||
|
if let Some(dir) = &dir {
|
||||||
|
if let Err(e) = std::fs::create_dir_all(dir) {
|
||||||
|
// Not fatal: without a disk cache every cover is simply fetched and
|
||||||
|
// resized again next launch, which is the old behaviour, not a failure.
|
||||||
|
eprintln!("cover cache unavailable at {}: {e}", dir.display());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Self { client, dir }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn path(&self, key: &Key) -> Option<PathBuf> {
|
||||||
|
self.dir
|
||||||
|
.as_ref()
|
||||||
|
.map(|d| d.join(format!("{}@{}.jpg", key.album_id, key.tier.px())))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drop every stored thumbnail. Called when the backend says it has rescanned, and
|
||||||
|
/// therefore possibly rewritten the art behind these ids.
|
||||||
|
pub fn clear_disk(&self) {
|
||||||
|
let Some(dir) = &self.dir else { return };
|
||||||
|
if let Ok(entries) = std::fs::read_dir(dir) {
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let _ = std::fs::remove_file(entry.path());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render(&self, request: &Request) -> RgbaImage {
|
||||||
|
let px = request.tier.px();
|
||||||
|
let (want_w, want_h) = dimensions(px, request.is_book);
|
||||||
|
|
||||||
|
// A locked album is one the typing game is withholding. It has real art, and
|
||||||
|
// showing it would give away the reward, so it never gets fetched at all.
|
||||||
|
if request.locked || !request.has_cover {
|
||||||
|
return generated(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(path) = self.path(&Key {
|
||||||
|
album_id: request.album_id.clone(),
|
||||||
|
tier: request.tier,
|
||||||
|
}) {
|
||||||
|
if let Ok(image) = image::open(&path) {
|
||||||
|
return image.to_rgba8();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let bytes = match self.client.cover(&request.album_id) {
|
||||||
|
Ok(Some(bytes)) => bytes,
|
||||||
|
// 404 is a normal answer - `has_cover` can be stale by a rescan - and a
|
||||||
|
// network error should show the album, not a hole.
|
||||||
|
Ok(None) | Err(_) => return generated(request),
|
||||||
|
};
|
||||||
|
let Ok(decoded) = image::load_from_memory(&bytes) else {
|
||||||
|
return generated(request);
|
||||||
|
};
|
||||||
|
|
||||||
|
// `resize_to_fill` rather than `resize`: a real cover is square but a book's
|
||||||
|
// slot is not, and letterboxing one inside the other would break the shelf
|
||||||
|
// metaphor that the whole browse screen is built on.
|
||||||
|
let thumb = decoded.resize_to_fill(want_w, want_h, FilterType::CatmullRom);
|
||||||
|
|
||||||
|
if let Some(path) = self.path(&Key {
|
||||||
|
album_id: request.album_id.clone(),
|
||||||
|
tier: request.tier,
|
||||||
|
}) {
|
||||||
|
// Storing JPEG, not PNG: these are photographs, so JPEG is both smaller on
|
||||||
|
// disk and faster to decode on the next start, which is the whole point.
|
||||||
|
let _ = thumb
|
||||||
|
.to_rgb8()
|
||||||
|
.save_with_format(&path, image::ImageFormat::Jpeg);
|
||||||
|
}
|
||||||
|
thumb.to_rgba8()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs the fetch/decode/resize loop off the UI thread, calling `deliver` for each
|
||||||
|
/// finished bitmap. `deliver` is expected to hop back onto the Slint event loop.
|
||||||
|
pub fn spawn(
|
||||||
|
client: Client,
|
||||||
|
jobs: Receiver<Job>,
|
||||||
|
deliver: impl Fn(Ready) + Send + 'static,
|
||||||
|
) -> std::thread::JoinHandle<()> {
|
||||||
|
std::thread::Builder::new()
|
||||||
|
.name("covers".into())
|
||||||
|
.spawn(move || {
|
||||||
|
let worker = Worker::new(client);
|
||||||
|
// A cover asked for while it is already in flight - a card scrolled past
|
||||||
|
// twice, a grid rebuilt on a keystroke - must not become a second fetch.
|
||||||
|
let mut done: HashSet<Key> = HashSet::new();
|
||||||
|
for job in jobs {
|
||||||
|
match job {
|
||||||
|
Job::Clear => {
|
||||||
|
worker.clear_disk();
|
||||||
|
done.clear();
|
||||||
|
}
|
||||||
|
Job::Fetch(request) => {
|
||||||
|
let key = Key {
|
||||||
|
album_id: request.album_id.clone(),
|
||||||
|
tier: request.tier,
|
||||||
|
};
|
||||||
|
if !done.insert(key.clone()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let image = worker.render(&request);
|
||||||
|
deliver(Ready {
|
||||||
|
key,
|
||||||
|
width: image.width(),
|
||||||
|
height: image.height(),
|
||||||
|
rgba: image.into_raw(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.expect("spawning the cover worker")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum Job {
|
||||||
|
Fetch(Request),
|
||||||
|
Clear,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The UI thread's half: what is already decoded, and what has been asked for.
|
||||||
|
pub struct Store {
|
||||||
|
jobs: Sender<Job>,
|
||||||
|
ready: HashMap<Key, slint::Image>,
|
||||||
|
requested: HashSet<Key>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Store {
|
||||||
|
pub fn new(jobs: Sender<Job>) -> Self {
|
||||||
|
Self {
|
||||||
|
jobs,
|
||||||
|
ready: HashMap::new(),
|
||||||
|
requested: HashSet::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn insert(&mut self, ready: Ready) {
|
||||||
|
let mut buffer =
|
||||||
|
slint::SharedPixelBuffer::<slint::Rgba8Pixel>::new(ready.width, ready.height);
|
||||||
|
buffer.make_mut_bytes().copy_from_slice(&ready.rgba);
|
||||||
|
self.ready
|
||||||
|
.insert(ready.key, slint::Image::from_rgba8(buffer));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The cover for an album if it is decoded, and a fetch queued if it is not. Callers
|
||||||
|
/// paint the album's own colours in the meantime, so this returning `None` is a
|
||||||
|
/// normal frame rather than a missing one.
|
||||||
|
pub fn get(&mut self, request: Request) -> Option<slint::Image> {
|
||||||
|
let key = Key {
|
||||||
|
album_id: request.album_id.clone(),
|
||||||
|
tier: request.tier,
|
||||||
|
};
|
||||||
|
if let Some(image) = self.ready.get(&key) {
|
||||||
|
return Some(image.clone());
|
||||||
|
}
|
||||||
|
if self.requested.insert(key) {
|
||||||
|
let _ = self.jobs.send(Job::Fetch(request));
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear(&mut self) {
|
||||||
|
self.ready.clear();
|
||||||
|
self.requested.clear();
|
||||||
|
let _ = self.jobs.send(Job::Clear);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn request(is_book: bool, tier: Tier) -> Request {
|
||||||
|
Request {
|
||||||
|
album_id: "abc".into(),
|
||||||
|
tier,
|
||||||
|
has_cover: false,
|
||||||
|
is_book,
|
||||||
|
locked: false,
|
||||||
|
colors: ["#4a6fa5".into(), "#6a8fc5".into(), "#a5804a".into()],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shape_follows_the_media_type() {
|
||||||
|
assert_eq!(dimensions(192, false), (192, 192));
|
||||||
|
assert_eq!(dimensions(192, true), (157, 192));
|
||||||
|
assert_eq!(dimensions(384, true), (315, 384));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generated_art_fills_the_tier_it_was_asked_for() {
|
||||||
|
let music = generated(&request(false, Tier::Thumb));
|
||||||
|
assert_eq!((music.width(), music.height()), (192, 192));
|
||||||
|
let book = generated(&request(true, Tier::Hero));
|
||||||
|
assert_eq!((book.width(), book.height()), (315, 384));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stripes_actually_alternate() {
|
||||||
|
let art = generated(&request(false, Tier::Thumb));
|
||||||
|
let colours: HashSet<[u8; 3]> = (0..192)
|
||||||
|
.map(|x| {
|
||||||
|
let p = art.get_pixel(x, 0).0;
|
||||||
|
[p[0], p[1], p[2]]
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert_eq!(colours.len(), 2, "expected exactly the two stripe colours");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_book_has_bright_page_edges_on_its_right() {
|
||||||
|
let art = generated(&request(true, Tier::Hero));
|
||||||
|
let (w, h) = (art.width(), art.height());
|
||||||
|
let edge = art.get_pixel(w - 2, h / 2).0;
|
||||||
|
let middle = art.get_pixel(w / 2, h / 2).0;
|
||||||
|
let brightness = |p: [u8; 4]| p[0] as u32 + p[1] as u32 + p[2] as u32;
|
||||||
|
assert!(
|
||||||
|
brightness(edge) > brightness(middle),
|
||||||
|
"page edges should be lighter than the cover"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_generated_pixel_is_opaque() {
|
||||||
|
let art = generated(&request(true, Tier::Thumb));
|
||||||
|
assert!(art.pixels().all(|p| p.0[3] == 255));
|
||||||
|
}
|
||||||
|
}
|
||||||
61
slint-frontend/src/format.rs
Normal file
61
slint-frontend/src/format.rs
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
//! Durations, as the screen says them. Ported from `web/src/lib/format.ts`.
|
||||||
|
|
||||||
|
/// `m:ss`, or `h:mm:ss` once there is an hour to show. Negative and non-finite inputs
|
||||||
|
/// collapse to `0:00` rather than rendering `-1:-3`, because both reach here honestly:
|
||||||
|
/// a position can briefly exceed a tag-derived duration, and a track with no duration
|
||||||
|
/// tag at all arrives as `0.0`.
|
||||||
|
pub fn clock(seconds: f64) -> String {
|
||||||
|
if !seconds.is_finite() || seconds <= 0.0 {
|
||||||
|
return "0:00".into();
|
||||||
|
}
|
||||||
|
let total = seconds.round() as u64;
|
||||||
|
let (hours, minutes, secs) = (total / 3600, (total % 3600) / 60, total % 60);
|
||||||
|
if hours > 0 {
|
||||||
|
format!("{hours}:{minutes:02}:{secs:02}")
|
||||||
|
} else {
|
||||||
|
format!("{minutes}:{secs:02}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How much of the album is left: the rest of this track plus every track after it.
|
||||||
|
pub fn remaining_in_album(durations: &[f64], track_index: usize, position: f64) -> f64 {
|
||||||
|
let current = durations.get(track_index).copied().unwrap_or(0.0);
|
||||||
|
let rest: f64 = durations.iter().skip(track_index + 1).sum();
|
||||||
|
(current - position).max(0.0) + rest
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clock_formats_minutes_and_hours() {
|
||||||
|
assert_eq!(clock(0.0), "0:00");
|
||||||
|
assert_eq!(clock(9.0), "0:09");
|
||||||
|
assert_eq!(clock(61.0), "1:01");
|
||||||
|
assert_eq!(clock(599.0), "9:59");
|
||||||
|
assert_eq!(clock(3600.0), "1:00:00");
|
||||||
|
assert_eq!(clock(12130.0), "3:22:10");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clock_survives_the_inputs_a_real_player_produces() {
|
||||||
|
assert_eq!(clock(-4.0), "0:00");
|
||||||
|
assert_eq!(clock(f64::NAN), "0:00");
|
||||||
|
assert_eq!(clock(f64::INFINITY), "0:00");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn remaining_counts_this_track_and_the_ones_after_it() {
|
||||||
|
let durations = [100.0, 200.0, 300.0];
|
||||||
|
assert_eq!(remaining_in_album(&durations, 0, 0.0), 600.0);
|
||||||
|
assert_eq!(remaining_in_album(&durations, 1, 50.0), 450.0);
|
||||||
|
assert_eq!(remaining_in_album(&durations, 2, 300.0), 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_position_past_the_tagged_duration_does_not_go_negative() {
|
||||||
|
assert_eq!(remaining_in_album(&[100.0, 50.0], 0, 130.0), 50.0);
|
||||||
|
assert_eq!(remaining_in_album(&[], 7, 10.0), 0.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
319
slint-frontend/src/ha.rs
Normal file
319
slint-frontend/src/ha.rs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
915
slint-frontend/src/keymap.rs
Normal file
915
slint-frontend/src/keymap.rs
Normal file
@@ -0,0 +1,915 @@
|
|||||||
|
//! What you are looking at, and what a keypress does to it.
|
||||||
|
//!
|
||||||
|
//! Ported from `web/src/lib/keyboard.ts`, which is already a pure function from a
|
||||||
|
//! keypress to a list of actions - the single most portable thing in that codebase, and
|
||||||
|
//! tested there without a DOM for the same reason it is tested here without a window.
|
||||||
|
//!
|
||||||
|
//! The split this rests on: **what is playing** comes from the backend, **what you are
|
||||||
|
//! looking at** lives here. A real player has other front-ends - the buttons on the
|
||||||
|
//! mouse, a figurine on the reader, Home Assistant - so this UI follows playback rather
|
||||||
|
//! than owning it, and owns only the browse state.
|
||||||
|
//!
|
||||||
|
//! Not ported: the typing game, the room-lights page, the in-app help overlay and the
|
||||||
|
//! IR-remote assign mode, none of which this front-end hosts.
|
||||||
|
|
||||||
|
use crate::library::{Group, Results, GROUPS};
|
||||||
|
|
||||||
|
pub const VOLUME_STEP: i32 = 10;
|
||||||
|
pub const SEEK_STEP: f64 = 15.0;
|
||||||
|
/// How far Ctrl+d/Ctrl+u jump - vim's half-page scroll, applied to rows of cards.
|
||||||
|
pub const PAGE_ROWS: i32 = 3;
|
||||||
|
|
||||||
|
/// 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)]
|
||||||
|
pub struct UiState {
|
||||||
|
pub page: Page,
|
||||||
|
pub search: String,
|
||||||
|
pub tracks_mode: bool,
|
||||||
|
/// `None` is the bare root screen (three shelves); otherwise which one is open.
|
||||||
|
pub group: Option<Group>,
|
||||||
|
pub category: Option<String>,
|
||||||
|
pub sel_index: usize,
|
||||||
|
/// Which of the three root shelves is focused. Only meaningful at the bare root,
|
||||||
|
/// where `sel_index` doubles as "which tile in that row".
|
||||||
|
pub shelf_row: usize,
|
||||||
|
pub play_view: bool,
|
||||||
|
pub open_album: Option<String>,
|
||||||
|
/// Which track is highlighted in the open album's list.
|
||||||
|
pub sheet_index: usize,
|
||||||
|
/// Grid columns, recomputed from the window width - `move_selection` needs it to
|
||||||
|
/// know what one row down means.
|
||||||
|
pub cols: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for UiState {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
page: Page::Music,
|
||||||
|
search: String::new(),
|
||||||
|
tracks_mode: false,
|
||||||
|
group: None,
|
||||||
|
category: None,
|
||||||
|
sel_index: 0,
|
||||||
|
shelf_row: 0,
|
||||||
|
play_view: false,
|
||||||
|
open_album: None,
|
||||||
|
sheet_index: 0,
|
||||||
|
cols: 4,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UiState {
|
||||||
|
/// The true root: nothing chosen yet, drawn as three shelves rather than a list.
|
||||||
|
pub fn is_root_shelf(&self) -> bool {
|
||||||
|
self.group.is_none()
|
||||||
|
&& self.search.is_empty()
|
||||||
|
&& !self.tracks_mode
|
||||||
|
&& self.category.is_none()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn browsing(&self) -> bool {
|
||||||
|
!self.play_view && self.open_album.is_none()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One step of "back": search, then track-search mode, then group and category
|
||||||
|
/// together - the order ESC and the corner button both use.
|
||||||
|
///
|
||||||
|
/// `group` and `category` clear as one step because a root shelf tile sets both at
|
||||||
|
/// once, jumping straight to one category's albums; undoing that jump should be one
|
||||||
|
/// step too, however the category was reached.
|
||||||
|
pub fn browse_back(&mut self) {
|
||||||
|
self.sel_index = 0;
|
||||||
|
if !self.search.is_empty() {
|
||||||
|
self.search.clear();
|
||||||
|
} else if self.tracks_mode {
|
||||||
|
self.tracks_mode = false;
|
||||||
|
} else {
|
||||||
|
self.group = None;
|
||||||
|
self.category = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether anything is left to back out of - what the corner button's presence is.
|
||||||
|
pub fn can_go_back(&self) -> bool {
|
||||||
|
self.page != Page::Music
|
||||||
|
|| self.play_view
|
||||||
|
|| self.open_album.is_some()
|
||||||
|
|| !self.search.is_empty()
|
||||||
|
|| self.tracks_mode
|
||||||
|
|| self.group.is_some()
|
||||||
|
|| self.category.is_some()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a keypress asks the *player* to do. Everything else a key can do is a change to
|
||||||
|
/// [`UiState`], applied in place.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub enum Action {
|
||||||
|
Play {
|
||||||
|
album_id: String,
|
||||||
|
track_index: usize,
|
||||||
|
},
|
||||||
|
Toggle,
|
||||||
|
Next,
|
||||||
|
Previous,
|
||||||
|
/// Percentage points, positive or negative.
|
||||||
|
Volume(i32),
|
||||||
|
/// Seconds, positive or negative.
|
||||||
|
Seek(f64),
|
||||||
|
Mute,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where the flat selection index is pointing. Songs, then categories, then albums.
|
||||||
|
enum Selection {
|
||||||
|
Song {
|
||||||
|
album_id: String,
|
||||||
|
track_index: usize,
|
||||||
|
},
|
||||||
|
Category(String),
|
||||||
|
Album(String),
|
||||||
|
None,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn selection_at(results: &Results, sel_index: usize) -> Selection {
|
||||||
|
let total = results.total();
|
||||||
|
if total == 0 {
|
||||||
|
return Selection::None;
|
||||||
|
}
|
||||||
|
let index = sel_index.min(total - 1);
|
||||||
|
if let Some(hit) = results.songs.get(index) {
|
||||||
|
return Selection::Song {
|
||||||
|
album_id: hit.album.id.clone(),
|
||||||
|
track_index: hit.index,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let after_songs = index - results.songs.len();
|
||||||
|
if let Some(category) = results.categories.get(after_songs) {
|
||||||
|
return Selection::Category(category.key.clone());
|
||||||
|
}
|
||||||
|
match results.albums.get(after_songs - results.categories.len()) {
|
||||||
|
Some(album) => Selection::Album(album.id.clone()),
|
||||||
|
None => Selection::None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The album behind the selection, when "show me the tracks" makes sense for it: an
|
||||||
|
/// album tile directly, or - so you can reach the rest of it - the album behind a song
|
||||||
|
/// hit. `None` for a category tile, and for a podcast episode, which is a single track
|
||||||
|
/// with nothing else to show.
|
||||||
|
pub fn selection_album(results: &Results, sel_index: usize) -> Option<String> {
|
||||||
|
let total = results.total();
|
||||||
|
if total == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let index = sel_index.min(total - 1);
|
||||||
|
let album = if let Some(hit) = results.songs.get(index) {
|
||||||
|
Some(&hit.album)
|
||||||
|
} else {
|
||||||
|
let after_songs = index - results.songs.len();
|
||||||
|
if after_songs < results.categories.len() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
results.albums.get(after_songs - results.categories.len())
|
||||||
|
}?;
|
||||||
|
(Group::of(album) != Group::Podcasts).then(|| album.id.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The bare root is a genuine two-axis layout: three rows, each a differently-long row
|
||||||
|
/// of tiles. `shelf_lengths` is how long each row actually is, so a vertical move can
|
||||||
|
/// clamp the column to the row it lands on rather than guessing.
|
||||||
|
fn move_root_shelf(state: &mut UiState, dx: i32, dy: i32, shelf_lengths: &[usize; 3]) {
|
||||||
|
if dy != 0 {
|
||||||
|
state.shelf_row = (state.shelf_row as i32 + dy).clamp(0, GROUPS.len() as i32 - 1) as usize;
|
||||||
|
state.sel_index = state
|
||||||
|
.sel_index
|
||||||
|
.min(shelf_lengths[state.shelf_row].saturating_sub(1));
|
||||||
|
}
|
||||||
|
if dx != 0 {
|
||||||
|
let last = shelf_lengths[state.shelf_row].saturating_sub(1) as i32;
|
||||||
|
state.sel_index = (state.sel_index as i32 + dx).clamp(0, last.max(0)) as usize;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn move_selection(state: &mut UiState, results: &Results, dx: i32, dy: i32) {
|
||||||
|
let total = results.total();
|
||||||
|
if total == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cols = state.cols.max(1) as i32;
|
||||||
|
let mut index = state.sel_index.min(total - 1) as i32;
|
||||||
|
index += dx;
|
||||||
|
if dy != 0 {
|
||||||
|
// Song hits are a single-column list; everything below them is a grid.
|
||||||
|
index += if (index as usize) < results.songs.len() {
|
||||||
|
dy
|
||||||
|
} else {
|
||||||
|
dy * cols
|
||||||
|
};
|
||||||
|
}
|
||||||
|
state.sel_index = index.clamp(0, total as i32 - 1) as usize;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The keys that mean the same thing from anywhere, matching the muscle memory of every
|
||||||
|
/// other vim-ish media app. Keyed on the exact shifted letter, so this intercepts only
|
||||||
|
/// these five - every other Shift+letter still reaches the search box. Search is
|
||||||
|
/// case-insensitive, so nothing searchable is lost.
|
||||||
|
fn shift_media(key: &str) -> Option<Action> {
|
||||||
|
match key.to_ascii_uppercase().as_str() {
|
||||||
|
"H" => Some(Action::Previous),
|
||||||
|
"L" => Some(Action::Next),
|
||||||
|
"K" => Some(Action::Volume(VOLUME_STEP)),
|
||||||
|
"J" => Some(Action::Volume(-VOLUME_STEP)),
|
||||||
|
"M" => Some(Action::Mute),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A plain character key's default: type it into the search box. Letters and digits
|
||||||
|
/// only - `char::is_alphanumeric` rather than `[a-z0-9]`, so the umlauts a German title
|
||||||
|
/// needs are searchable too.
|
||||||
|
fn type_into_search(state: &mut UiState, key: &str) -> bool {
|
||||||
|
let mut chars = key.chars();
|
||||||
|
match (chars.next(), chars.next()) {
|
||||||
|
(Some(c), None) if c.is_alphanumeric() => {
|
||||||
|
state.search.push(c);
|
||||||
|
state.play_view = false;
|
||||||
|
state.sel_index = 0;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Translate one keypress: mutate `state`, and return whatever the player should be
|
||||||
|
/// asked to do.
|
||||||
|
///
|
||||||
|
/// `key` is already named by the UI layer - `"escape"`, `"left"`, `"enter"`, … - or is
|
||||||
|
/// the literal character typed. `shelf_lengths` and `sheet_tracks` are the two counts
|
||||||
|
/// that `results` does not carry but the clamping needs.
|
||||||
|
pub fn handle_key(
|
||||||
|
key: &str,
|
||||||
|
ctrl: bool,
|
||||||
|
shift: bool,
|
||||||
|
state: &mut UiState,
|
||||||
|
results: &Results,
|
||||||
|
shelf_lengths: &[usize; 3],
|
||||||
|
sheet_tracks: usize,
|
||||||
|
) -> Vec<Action> {
|
||||||
|
// Reachable from anywhere, including from the play view and with an album sheet open.
|
||||||
|
if shift && !ctrl {
|
||||||
|
if let Some(action) = shift_media(key) {
|
||||||
|
return vec![action];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if key == "space" {
|
||||||
|
// Except mid-search, where a space is punctuation the query needs, as in
|
||||||
|
// "geolino azte".
|
||||||
|
if state.browsing() && !state.search.is_empty() {
|
||||||
|
state.search.push(' ');
|
||||||
|
state.sel_index = 0;
|
||||||
|
return vec![];
|
||||||
|
}
|
||||||
|
return vec![Action::Toggle];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl+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
|
||||||
|
// arrows do but without leaving the home row. Ctrl+d/u are vim's half-page jump.
|
||||||
|
if ctrl {
|
||||||
|
if state.open_album.is_some() {
|
||||||
|
match key.to_ascii_lowercase().as_str() {
|
||||||
|
"j" => {
|
||||||
|
state.sheet_index = (state.sheet_index + 1).min(sheet_tracks.saturating_sub(1))
|
||||||
|
}
|
||||||
|
"k" => state.sheet_index = state.sheet_index.saturating_sub(1),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
return vec![];
|
||||||
|
}
|
||||||
|
if state.play_view {
|
||||||
|
return vec![];
|
||||||
|
}
|
||||||
|
let lower = key.to_ascii_lowercase();
|
||||||
|
if state.is_root_shelf() {
|
||||||
|
match lower.as_str() {
|
||||||
|
"h" => move_root_shelf(state, -1, 0, shelf_lengths),
|
||||||
|
"l" => move_root_shelf(state, 1, 0, shelf_lengths),
|
||||||
|
"j" | "d" => move_root_shelf(state, 0, 1, shelf_lengths),
|
||||||
|
"k" | "u" => move_root_shelf(state, 0, -1, shelf_lengths),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
return vec![];
|
||||||
|
}
|
||||||
|
match lower.as_str() {
|
||||||
|
"h" => move_selection(state, results, -1, 0),
|
||||||
|
"l" => move_selection(state, results, 1, 0),
|
||||||
|
"j" => move_selection(state, results, 0, 1),
|
||||||
|
"k" => move_selection(state, results, 0, -1),
|
||||||
|
"d" => move_selection(state, results, 0, PAGE_ROWS),
|
||||||
|
"u" => move_selection(state, results, 0, -PAGE_ROWS),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
return vec![];
|
||||||
|
}
|
||||||
|
|
||||||
|
let browsing = state.browsing();
|
||||||
|
|
||||||
|
match key {
|
||||||
|
"tab" => {
|
||||||
|
let current = state.group.map_or(-1, |g| g.index() as i32);
|
||||||
|
state.group = Some(Group::from_index(
|
||||||
|
((current + 1) % GROUPS.len() as i32) as usize,
|
||||||
|
));
|
||||||
|
state.category = None;
|
||||||
|
state.sel_index = 0;
|
||||||
|
state.play_view = false;
|
||||||
|
vec![]
|
||||||
|
}
|
||||||
|
"/" => {
|
||||||
|
state.play_view = false;
|
||||||
|
vec![]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Arrows browse while browsing and drive the player while playing - the one
|
||||||
|
// pair of keys that changes meaning with the screen, because on the play view
|
||||||
|
// there is no selection for them to move.
|
||||||
|
"right" => {
|
||||||
|
if shift {
|
||||||
|
return vec![Action::Seek(SEEK_STEP)];
|
||||||
|
}
|
||||||
|
if !browsing {
|
||||||
|
return vec![Action::Next];
|
||||||
|
}
|
||||||
|
if state.is_root_shelf() {
|
||||||
|
move_root_shelf(state, 1, 0, shelf_lengths);
|
||||||
|
} else {
|
||||||
|
move_selection(state, results, 1, 0);
|
||||||
|
}
|
||||||
|
vec![]
|
||||||
|
}
|
||||||
|
"left" => {
|
||||||
|
if shift {
|
||||||
|
return vec![Action::Seek(-SEEK_STEP)];
|
||||||
|
}
|
||||||
|
if !browsing {
|
||||||
|
return vec![Action::Previous];
|
||||||
|
}
|
||||||
|
if state.is_root_shelf() {
|
||||||
|
move_root_shelf(state, -1, 0, shelf_lengths);
|
||||||
|
} else {
|
||||||
|
move_selection(state, results, -1, 0);
|
||||||
|
}
|
||||||
|
vec![]
|
||||||
|
}
|
||||||
|
"down" => {
|
||||||
|
if !browsing {
|
||||||
|
return vec![Action::Volume(-VOLUME_STEP)];
|
||||||
|
}
|
||||||
|
if state.is_root_shelf() {
|
||||||
|
move_root_shelf(state, 0, 1, shelf_lengths);
|
||||||
|
} else {
|
||||||
|
move_selection(state, results, 0, 1);
|
||||||
|
}
|
||||||
|
vec![]
|
||||||
|
}
|
||||||
|
"up" => {
|
||||||
|
if !browsing {
|
||||||
|
return vec![Action::Volume(VOLUME_STEP)];
|
||||||
|
}
|
||||||
|
if state.is_root_shelf() {
|
||||||
|
move_root_shelf(state, 0, -1, shelf_lengths);
|
||||||
|
} else {
|
||||||
|
move_selection(state, results, 0, -1);
|
||||||
|
}
|
||||||
|
vec![]
|
||||||
|
}
|
||||||
|
|
||||||
|
"?" => {
|
||||||
|
state.tracks_mode = true;
|
||||||
|
state.search.clear();
|
||||||
|
state.sel_index = 0;
|
||||||
|
state.play_view = false;
|
||||||
|
vec![]
|
||||||
|
}
|
||||||
|
"escape" => {
|
||||||
|
// Peel one layer at a time rather than dumping you back at the top. The
|
||||||
|
// 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;
|
||||||
|
} else if state.play_view {
|
||||||
|
state.play_view = false;
|
||||||
|
} else {
|
||||||
|
state.browse_back();
|
||||||
|
}
|
||||||
|
vec![]
|
||||||
|
}
|
||||||
|
"backspace" => {
|
||||||
|
if !state.search.is_empty() {
|
||||||
|
state.search.pop();
|
||||||
|
state.sel_index = 0;
|
||||||
|
}
|
||||||
|
vec![]
|
||||||
|
}
|
||||||
|
"enter" => {
|
||||||
|
if let Some(album_id) = state.open_album.clone() {
|
||||||
|
return vec![Action::Play {
|
||||||
|
album_id,
|
||||||
|
track_index: state.sheet_index,
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
if browsing && state.is_root_shelf() {
|
||||||
|
let row = state.shelf_row.min(GROUPS.len() - 1);
|
||||||
|
state.group = Some(Group::from_index(row));
|
||||||
|
// A shelf row with no tiles still opens its group; there is simply no
|
||||||
|
// category to pre-select.
|
||||||
|
state.category = None;
|
||||||
|
state.sel_index = 0;
|
||||||
|
return vec![];
|
||||||
|
}
|
||||||
|
if shift {
|
||||||
|
if let Some(album_id) = selection_album(results, state.sel_index) {
|
||||||
|
state.open_album = Some(album_id);
|
||||||
|
state.sheet_index = 0;
|
||||||
|
return vec![];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match selection_at(results, state.sel_index) {
|
||||||
|
Selection::Song {
|
||||||
|
album_id,
|
||||||
|
track_index,
|
||||||
|
} => {
|
||||||
|
vec![Action::Play {
|
||||||
|
album_id,
|
||||||
|
track_index,
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
Selection::Album(album_id) => vec![Action::Play {
|
||||||
|
album_id,
|
||||||
|
track_index: 0,
|
||||||
|
}],
|
||||||
|
Selection::Category(key) => {
|
||||||
|
state.category = Some(key);
|
||||||
|
state.sel_index = 0;
|
||||||
|
vec![]
|
||||||
|
}
|
||||||
|
Selection::None => vec![],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"f1" => vec![],
|
||||||
|
_ => {
|
||||||
|
type_into_search(state, key);
|
||||||
|
vec![]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::library::{Category, Library, SongHit};
|
||||||
|
|
||||||
|
fn empty() -> Results {
|
||||||
|
Results::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn press(key: &str, state: &mut UiState) -> Vec<Action> {
|
||||||
|
handle_key(key, false, false, state, &empty(), &[0, 0, 0], 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn typing_a_letter_searches_and_leaves_the_play_view() {
|
||||||
|
let mut state = UiState {
|
||||||
|
play_view: true,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
press("k", &mut state);
|
||||||
|
press("i", &mut state);
|
||||||
|
assert_eq!(state.search, "ki");
|
||||||
|
assert!(!state.play_view);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn umlauts_are_searchable_like_any_other_letter() {
|
||||||
|
let mut state = UiState::default();
|
||||||
|
press("ö", &mut state);
|
||||||
|
assert_eq!(state.search, "ö");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn space_toggles_but_types_mid_search() {
|
||||||
|
let mut state = UiState::default();
|
||||||
|
assert_eq!(press("space", &mut state), vec![Action::Toggle]);
|
||||||
|
|
||||||
|
state.search = "geolino".into();
|
||||||
|
assert!(press("space", &mut state).is_empty());
|
||||||
|
assert_eq!(state.search, "geolino ");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn space_toggles_from_the_play_view_even_with_a_query_behind_it() {
|
||||||
|
let mut state = UiState {
|
||||||
|
search: "conni".into(),
|
||||||
|
play_view: true,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert_eq!(press("space", &mut state), vec![Action::Toggle]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shift_media_works_from_every_screen() {
|
||||||
|
for play_view in [false, true] {
|
||||||
|
let mut state = UiState {
|
||||||
|
play_view,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let got = handle_key("H", false, true, &mut state, &empty(), &[0, 0, 0], 0);
|
||||||
|
assert_eq!(got, vec![Action::Previous]);
|
||||||
|
let got = handle_key("K", false, true, &mut state, &empty(), &[0, 0, 0], 0);
|
||||||
|
assert_eq!(got, vec![Action::Volume(VOLUME_STEP)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn other_shifted_letters_still_reach_the_search_box() {
|
||||||
|
let mut state = UiState::default();
|
||||||
|
handle_key("C", false, true, &mut state, &empty(), &[0, 0, 0], 0);
|
||||||
|
assert_eq!(state.search, "C");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn arrows_browse_while_browsing_and_drive_the_player_while_playing() {
|
||||||
|
let mut state = UiState {
|
||||||
|
group: Some(Group::Music),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert!(press("right", &mut state).is_empty());
|
||||||
|
|
||||||
|
state.play_view = true;
|
||||||
|
assert_eq!(press("right", &mut state), vec![Action::Next]);
|
||||||
|
assert_eq!(press("up", &mut state), vec![Action::Volume(VOLUME_STEP)]);
|
||||||
|
assert_eq!(
|
||||||
|
press("down", &mut state),
|
||||||
|
vec![Action::Volume(-VOLUME_STEP)]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shift_arrows_seek_from_either_screen() {
|
||||||
|
let mut state = UiState::default();
|
||||||
|
let got = handle_key("right", false, true, &mut state, &empty(), &[0, 0, 0], 0);
|
||||||
|
assert_eq!(got, vec![Action::Seek(SEEK_STEP)]);
|
||||||
|
let got = handle_key("left", false, true, &mut state, &empty(), &[0, 0, 0], 0);
|
||||||
|
assert_eq!(got, vec![Action::Seek(-SEEK_STEP)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_root_shelf_is_two_axis_and_clamps_to_the_row_it_lands_on() {
|
||||||
|
let mut state = UiState::default();
|
||||||
|
let lengths = [5, 2, 0];
|
||||||
|
|
||||||
|
handle_key("right", false, false, &mut state, &empty(), &lengths, 0);
|
||||||
|
handle_key("right", false, false, &mut state, &empty(), &lengths, 0);
|
||||||
|
handle_key("right", false, false, &mut state, &empty(), &lengths, 0);
|
||||||
|
assert_eq!((state.shelf_row, state.sel_index), (0, 3));
|
||||||
|
|
||||||
|
// Row 1 only has two tiles, so the column has to come back with it.
|
||||||
|
handle_key("down", false, false, &mut state, &empty(), &lengths, 0);
|
||||||
|
assert_eq!((state.shelf_row, state.sel_index), (1, 1));
|
||||||
|
|
||||||
|
// Row 2 has none at all.
|
||||||
|
handle_key("down", false, false, &mut state, &empty(), &lengths, 0);
|
||||||
|
assert_eq!((state.shelf_row, state.sel_index), (2, 0));
|
||||||
|
|
||||||
|
// And it does not walk off the bottom.
|
||||||
|
handle_key("down", false, false, &mut state, &empty(), &lengths, 0);
|
||||||
|
assert_eq!(state.shelf_row, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn enter_at_the_root_opens_the_focused_shelfs_group() {
|
||||||
|
let mut state = UiState {
|
||||||
|
shelf_row: 1,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert!(press("enter", &mut state).is_empty());
|
||||||
|
assert_eq!(state.group, Some(Group::Audiobooks));
|
||||||
|
assert_eq!(state.sel_index, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn escape_peels_one_layer_at_a_time() {
|
||||||
|
let mut state = UiState {
|
||||||
|
group: Some(Group::Music),
|
||||||
|
category: Some("Rolf".into()),
|
||||||
|
search: "abc".into(),
|
||||||
|
tracks_mode: true,
|
||||||
|
play_view: true,
|
||||||
|
open_album: Some("x".into()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
press("escape", &mut state);
|
||||||
|
assert!(state.open_album.is_none(), "the sheet closes first");
|
||||||
|
press("escape", &mut state);
|
||||||
|
assert!(!state.play_view, "then the play view");
|
||||||
|
press("escape", &mut state);
|
||||||
|
assert_eq!(state.search, "", "then the query");
|
||||||
|
press("escape", &mut state);
|
||||||
|
assert!(!state.tracks_mode, "then track-search mode");
|
||||||
|
press("escape", &mut state);
|
||||||
|
assert_eq!(
|
||||||
|
(state.group, state.category.clone()),
|
||||||
|
(None, None),
|
||||||
|
"then both at once"
|
||||||
|
);
|
||||||
|
assert!(!state.can_go_back());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn 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]
|
||||||
|
fn tab_cycles_the_three_groups_and_wraps() {
|
||||||
|
let mut state = UiState::default();
|
||||||
|
for expected in [
|
||||||
|
Group::Music,
|
||||||
|
Group::Audiobooks,
|
||||||
|
Group::Podcasts,
|
||||||
|
Group::Music,
|
||||||
|
] {
|
||||||
|
press("tab", &mut state);
|
||||||
|
assert_eq!(state.group, Some(expected));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn backspace_only_bites_when_there_is_a_query() {
|
||||||
|
let mut state = UiState {
|
||||||
|
search: "ab".into(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
press("backspace", &mut state);
|
||||||
|
assert_eq!(state.search, "a");
|
||||||
|
press("backspace", &mut state);
|
||||||
|
press("backspace", &mut state);
|
||||||
|
assert_eq!(state.search, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn question_mark_switches_to_track_search_and_clears_the_query() {
|
||||||
|
let mut state = UiState {
|
||||||
|
search: "conni".into(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
press("?", &mut state);
|
||||||
|
assert!(state.tracks_mode);
|
||||||
|
assert_eq!(state.search, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- selection over a real results set ----
|
||||||
|
|
||||||
|
fn results_with(songs: usize, categories: usize, albums: usize) -> Results {
|
||||||
|
let library = Library::build(
|
||||||
|
(0..albums.max(1))
|
||||||
|
.map(|i| {
|
||||||
|
serde_json::from_value(serde_json::json!({
|
||||||
|
"id": format!("al{i}"),
|
||||||
|
"section": "Musik",
|
||||||
|
"kind": "music",
|
||||||
|
"title": format!("Album {i}"),
|
||||||
|
"artist": "A",
|
||||||
|
"category": "A",
|
||||||
|
"tracks": [{ "title": "T", "duration": 10.0 }],
|
||||||
|
}))
|
||||||
|
.unwrap()
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
);
|
||||||
|
Results {
|
||||||
|
songs: (0..songs)
|
||||||
|
.map(|i| SongHit {
|
||||||
|
album: std::rc::Rc::clone(&library.albums[0]),
|
||||||
|
index: i,
|
||||||
|
title: format!("S{i}"),
|
||||||
|
duration: 10.0,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
categories: (0..categories)
|
||||||
|
.map(|i| Category {
|
||||||
|
key: format!("C{i}"),
|
||||||
|
albums: vec![],
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
albums: library
|
||||||
|
.albums
|
||||||
|
.iter()
|
||||||
|
.take(albums)
|
||||||
|
.map(std::rc::Rc::clone)
|
||||||
|
.collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_selection_runs_flat_across_songs_then_categories_then_albums() {
|
||||||
|
let results = results_with(2, 2, 3);
|
||||||
|
assert_eq!(results.total(), 7);
|
||||||
|
|
||||||
|
let mut state = UiState {
|
||||||
|
group: Some(Group::Music),
|
||||||
|
cols: 3,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Inside the song list a vertical move is one row, not one grid row.
|
||||||
|
handle_key("down", false, false, &mut state, &results, &[0, 0, 0], 0);
|
||||||
|
assert_eq!(state.sel_index, 1);
|
||||||
|
|
||||||
|
// Past the songs it jumps a whole grid row.
|
||||||
|
state.sel_index = 2;
|
||||||
|
handle_key("down", false, false, &mut state, &results, &[0, 0, 0], 0);
|
||||||
|
assert_eq!(state.sel_index, 5);
|
||||||
|
|
||||||
|
// And it never runs off the end.
|
||||||
|
handle_key("down", false, false, &mut state, &results, &[0, 0, 0], 0);
|
||||||
|
assert_eq!(state.sel_index, 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn enter_plays_a_song_hit_at_its_own_track_index() {
|
||||||
|
let results = results_with(2, 0, 0);
|
||||||
|
let mut state = UiState {
|
||||||
|
group: Some(Group::Music),
|
||||||
|
sel_index: 1,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let got = handle_key("enter", false, false, &mut state, &results, &[0, 0, 0], 0);
|
||||||
|
assert_eq!(
|
||||||
|
got,
|
||||||
|
vec![Action::Play {
|
||||||
|
album_id: "al0".into(),
|
||||||
|
track_index: 1
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn enter_on_a_category_drills_in_rather_than_playing() {
|
||||||
|
let results = results_with(0, 2, 0);
|
||||||
|
let mut state = UiState {
|
||||||
|
group: Some(Group::Music),
|
||||||
|
sel_index: 1,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert!(handle_key("enter", false, false, &mut state, &results, &[0, 0, 0], 0).is_empty());
|
||||||
|
assert_eq!(state.category.as_deref(), Some("C1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn enter_on_an_album_plays_it_from_the_top() {
|
||||||
|
let results = results_with(0, 0, 3);
|
||||||
|
let mut state = UiState {
|
||||||
|
group: Some(Group::Music),
|
||||||
|
sel_index: 2,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let got = handle_key("enter", false, false, &mut state, &results, &[0, 0, 0], 0);
|
||||||
|
assert_eq!(
|
||||||
|
got,
|
||||||
|
vec![Action::Play {
|
||||||
|
album_id: "al2".into(),
|
||||||
|
track_index: 0
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shift_enter_opens_the_track_list_instead() {
|
||||||
|
let results = results_with(0, 0, 2);
|
||||||
|
let mut state = UiState {
|
||||||
|
group: Some(Group::Music),
|
||||||
|
sel_index: 1,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert!(handle_key("enter", false, true, &mut state, &results, &[0, 0, 0], 0).is_empty());
|
||||||
|
assert_eq!(state.open_album.as_deref(), Some("al1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shift_enter_on_a_category_falls_through_to_drilling_in() {
|
||||||
|
let results = results_with(0, 1, 0);
|
||||||
|
let mut state = UiState {
|
||||||
|
group: Some(Group::Music),
|
||||||
|
sel_index: 0,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
handle_key("enter", false, true, &mut state, &results, &[0, 0, 0], 0);
|
||||||
|
assert!(state.open_album.is_none());
|
||||||
|
assert_eq!(state.category.as_deref(), Some("C0"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ctrl_jk_walks_the_open_track_list_and_clamps() {
|
||||||
|
let mut state = UiState {
|
||||||
|
open_album: Some("al0".into()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
handle_key("j", true, false, &mut state, &empty(), &[0, 0, 0], 3);
|
||||||
|
handle_key("j", true, false, &mut state, &empty(), &[0, 0, 0], 3);
|
||||||
|
handle_key("j", true, false, &mut state, &empty(), &[0, 0, 0], 3);
|
||||||
|
assert_eq!(state.sheet_index, 2);
|
||||||
|
handle_key("k", true, false, &mut state, &empty(), &[0, 0, 0], 3);
|
||||||
|
assert_eq!(state.sheet_index, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn enter_with_the_sheet_open_plays_the_highlighted_track() {
|
||||||
|
let mut state = UiState {
|
||||||
|
open_album: Some("al0".into()),
|
||||||
|
sheet_index: 2,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let got = handle_key("enter", false, false, &mut state, &empty(), &[0, 0, 0], 4);
|
||||||
|
assert_eq!(
|
||||||
|
got,
|
||||||
|
vec![Action::Play {
|
||||||
|
album_id: "al0".into(),
|
||||||
|
track_index: 2
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
504
slint-frontend/src/library.rs
Normal file
504
slint-frontend/src/library.rs
Normal file
@@ -0,0 +1,504 @@
|
|||||||
|
//! Browsing and searching the library, entirely on the client.
|
||||||
|
//!
|
||||||
|
//! The whole index arrives in one response, so type-to-search has no round trip - which
|
||||||
|
//! is the point of a keyboard-first UI. Ported from `web/src/lib/search.ts`, including
|
||||||
|
//! the reason that module is shaped the way it is:
|
||||||
|
//!
|
||||||
|
//! The cost that matters is per keystroke, not per library load. A real collection is
|
||||||
|
//! 343 albums and 4969 tracks, and the naive version re-derives its search keys from
|
||||||
|
//! scratch on every one of them for every letter typed - `normalize` runs a lowercase,
|
||||||
|
//! a Unicode NFD decomposition and two filtering passes, so track search would mean
|
||||||
|
//! five thousand NFD normalizations before a single comparison. That is work whose
|
||||||
|
//! answer cannot change until the library does, so it happens once, in
|
||||||
|
//! [`Library::build`], and a keystroke becomes `str::contains` over strings that
|
||||||
|
//! already exist.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use unicode_normalization::UnicodeNormalization;
|
||||||
|
|
||||||
|
use crate::api::Album;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
pub enum Group {
|
||||||
|
Music,
|
||||||
|
Audiobooks,
|
||||||
|
Podcasts,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const GROUPS: [Group; 3] = [Group::Music, Group::Audiobooks, Group::Podcasts];
|
||||||
|
|
||||||
|
impl Group {
|
||||||
|
/// Which of the three shelves an album belongs on. A podcast is exactly a
|
||||||
|
/// `Kinderpodcasts`-section album; everything else buckets by `kind`, so a Figuren
|
||||||
|
/// album joins whichever of music/audiobooks matches what it actually holds.
|
||||||
|
pub fn of(album: &Album) -> Self {
|
||||||
|
if album.section == "Kinderpodcasts" {
|
||||||
|
Self::Podcasts
|
||||||
|
} else if album.is_book() {
|
||||||
|
Self::Audiobooks
|
||||||
|
} else {
|
||||||
|
Self::Music
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn index(self) -> usize {
|
||||||
|
match self {
|
||||||
|
Self::Music => 0,
|
||||||
|
Self::Audiobooks => 1,
|
||||||
|
Self::Podcasts => 2,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_index(index: usize) -> Self {
|
||||||
|
GROUPS[index.min(2)]
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Music => "Musik",
|
||||||
|
Self::Audiobooks => "Hörbücher",
|
||||||
|
Self::Podcasts => "Podcasts",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn icon(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Music => "🎵",
|
||||||
|
Self::Audiobooks => "📖",
|
||||||
|
Self::Podcasts => "🎙️",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Heading over the category tiles inside this group.
|
||||||
|
pub fn category_label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Music => "Künstler",
|
||||||
|
Self::Audiobooks => "Figuren",
|
||||||
|
Self::Podcasts => "Sendungen",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Heading over the album grid inside this group.
|
||||||
|
pub fn section_label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Music => "Alben",
|
||||||
|
Self::Audiobooks => "Hörbücher",
|
||||||
|
Self::Podcasts => "Episoden",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-group hue in oklch degrees, shared with the generated cover art so a shelf
|
||||||
|
/// already hints at the mood its albums play into.
|
||||||
|
pub fn hue(self) -> f32 {
|
||||||
|
match self {
|
||||||
|
Self::Music => 210.0, // today's sea - the baseline hue
|
||||||
|
Self::Audiobooks => 55.0, // warm amber
|
||||||
|
Self::Podcasts => 300.0, // the one otherwise-unused family
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Diacritics and punctuation are noise when a child is hunting for letters.
|
||||||
|
pub fn normalize(value: &str) -> String {
|
||||||
|
value
|
||||||
|
.to_lowercase()
|
||||||
|
.nfd()
|
||||||
|
// Combining marks, i.e. what NFD just split off the base letters.
|
||||||
|
.filter(|c| !matches!(*c as u32, 0x0300..=0x036F))
|
||||||
|
.filter(|c| c.is_ascii_alphanumeric())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Words of a query, normalized individually. A space-separated query like "conni rad"
|
||||||
|
/// should find "Conni lernt Rad fahren" even though "rad" is not right after "conni",
|
||||||
|
/// so requiring the whole phrase to be one contiguous substring is too strict.
|
||||||
|
fn search_words(value: &str) -> Vec<String> {
|
||||||
|
value
|
||||||
|
.split_whitespace()
|
||||||
|
.map(normalize)
|
||||||
|
.filter(|w| !w.is_empty())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every word has to show up somewhere in the haystack, in any order.
|
||||||
|
fn matches_words(haystack: &str, words: &[String]) -> bool {
|
||||||
|
words.iter().all(|word| haystack.contains(word.as_str()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct AlbumEntry {
|
||||||
|
pub album: Rc<Album>,
|
||||||
|
/// `normalize(title + artist)`, as a naive `album_matches` would recompute per key.
|
||||||
|
haystack: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct TrackEntry {
|
||||||
|
pub album: Rc<Album>,
|
||||||
|
/// Index within `album.tracks`, which is what playback wants.
|
||||||
|
pub index: usize,
|
||||||
|
pub title: String,
|
||||||
|
pub duration: f64,
|
||||||
|
haystack: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct Category {
|
||||||
|
pub key: String,
|
||||||
|
pub albums: Vec<Rc<Album>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct SongHit {
|
||||||
|
pub album: Rc<Album>,
|
||||||
|
pub index: usize,
|
||||||
|
pub title: String,
|
||||||
|
pub duration: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Everything a search needs, derived once per library payload.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct Library {
|
||||||
|
pub albums: Vec<Rc<Album>>,
|
||||||
|
entries: Vec<AlbumEntry>,
|
||||||
|
by_group: [Vec<usize>; 3],
|
||||||
|
/// Each group's categories, already grouped and sorted.
|
||||||
|
categories_by_group: [Vec<Category>; 3],
|
||||||
|
/// Unlocked tracks only, album order then track order. A locked track shows as a
|
||||||
|
/// question mark wherever it appears, so it has no title to match on and is left
|
||||||
|
/// out of the index entirely rather than skipped at match time.
|
||||||
|
tracks: Vec<TrackEntry>,
|
||||||
|
tracks_by_group: [Vec<usize>; 3],
|
||||||
|
by_id: HashMap<String, Rc<Album>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Library {
|
||||||
|
pub fn build(albums: Vec<Album>) -> Self {
|
||||||
|
let mut library = Self::default();
|
||||||
|
// One map per group rather than one overall: two groups may legitimately hold
|
||||||
|
// the same category name, and merging them would put an artist's albums on
|
||||||
|
// the audiobook shelf.
|
||||||
|
let mut category_maps: [HashMap<String, Vec<Rc<Album>>>; 3] = Default::default();
|
||||||
|
|
||||||
|
for album in albums {
|
||||||
|
let album = Rc::new(album);
|
||||||
|
let group = Group::of(&album);
|
||||||
|
|
||||||
|
library.by_id.insert(album.id.clone(), Rc::clone(&album));
|
||||||
|
library.albums.push(Rc::clone(&album));
|
||||||
|
|
||||||
|
library.by_group[group.index()].push(library.entries.len());
|
||||||
|
library.entries.push(AlbumEntry {
|
||||||
|
haystack: normalize(&format!("{}{}", album.title, album.artist)),
|
||||||
|
album: Rc::clone(&album),
|
||||||
|
});
|
||||||
|
|
||||||
|
category_maps[group.index()]
|
||||||
|
.entry(album.category.clone())
|
||||||
|
.or_default()
|
||||||
|
.push(Rc::clone(&album));
|
||||||
|
|
||||||
|
for (index, track) in album.tracks.iter().enumerate() {
|
||||||
|
if track.locked {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
library.tracks_by_group[group.index()].push(library.tracks.len());
|
||||||
|
library.tracks.push(TrackEntry {
|
||||||
|
haystack: normalize(&track.title),
|
||||||
|
album: Rc::clone(&album),
|
||||||
|
index,
|
||||||
|
title: track.title.clone(),
|
||||||
|
duration: track.duration,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (slot, map) in library.categories_by_group.iter_mut().zip(category_maps) {
|
||||||
|
let mut categories: Vec<Category> = map
|
||||||
|
.into_iter()
|
||||||
|
.map(|(key, albums)| Category { key, albums })
|
||||||
|
.collect();
|
||||||
|
// `localeCompare(_, "de")` in the original. A full collator is more than
|
||||||
|
// this earns; normalizing first folds the umlauts, which is the whole of
|
||||||
|
// what German ordering asks for here.
|
||||||
|
categories.sort_by_key(|c| normalize(&c.key));
|
||||||
|
*slot = categories;
|
||||||
|
}
|
||||||
|
|
||||||
|
library
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get(&self, album_id: &str) -> Option<&Rc<Album>> {
|
||||||
|
self.by_id.get(album_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn categories(&self, group: Group) -> &[Category] {
|
||||||
|
&self.categories_by_group[group.index()]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------- queries --
|
||||||
|
|
||||||
|
/// `group: None` means unrestricted - typing at the root searches every group at once.
|
||||||
|
pub struct Query<'a> {
|
||||||
|
pub search: &'a str,
|
||||||
|
pub tracks_mode: bool,
|
||||||
|
pub group: Option<Group>,
|
||||||
|
pub category: Option<&'a str>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which of the three lists the browse view is showing.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum ListMode {
|
||||||
|
Tracks,
|
||||||
|
Albums,
|
||||||
|
Categories,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Capped, because an empty query over 900 podcast episodes is not a useful screen.
|
||||||
|
pub const MAX_SONG_HITS: usize = 40;
|
||||||
|
|
||||||
|
/// Selection indices run flat across songs, then categories, then albums, so one pair
|
||||||
|
/// of arrow keys walks the whole page.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct Results {
|
||||||
|
pub songs: Vec<SongHit>,
|
||||||
|
pub categories: Vec<Category>,
|
||||||
|
pub albums: Vec<Rc<Album>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Results {
|
||||||
|
pub fn total(&self) -> usize {
|
||||||
|
self.songs.len() + self.categories.len() + self.albums.len()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Library {
|
||||||
|
pub fn list_mode(&self, query: &Query) -> ListMode {
|
||||||
|
if query.tracks_mode {
|
||||||
|
ListMode::Tracks
|
||||||
|
} else if !query.search.is_empty() || query.category.is_some() {
|
||||||
|
ListMode::Albums
|
||||||
|
} else {
|
||||||
|
ListMode::Categories
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn album_pool(&self, query: &Query) -> Vec<usize> {
|
||||||
|
match query.group {
|
||||||
|
None => (0..self.entries.len()).collect(),
|
||||||
|
Some(group) => self.by_group[group.index()].clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn category_matches(&self, query: &Query) -> Vec<Category> {
|
||||||
|
// `group: None` is the bare root screen, rendered as three shelves instead of a
|
||||||
|
// flat category list - each shelf asks again with its own group filled in.
|
||||||
|
match (self.list_mode(query), query.group) {
|
||||||
|
(ListMode::Categories, Some(group)) => self.categories(group).to_vec(),
|
||||||
|
_ => Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn album_matches(&self, query: &Query) -> Vec<Rc<Album>> {
|
||||||
|
if query.tracks_mode {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
let words = search_words(query.search);
|
||||||
|
if words.is_empty() && query.category.is_none() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
self.album_pool(query)
|
||||||
|
.into_iter()
|
||||||
|
.map(|i| &self.entries[i])
|
||||||
|
.filter(|entry| match query.category {
|
||||||
|
Some(category) => entry.album.category == category,
|
||||||
|
None => true,
|
||||||
|
})
|
||||||
|
.filter(|entry| words.is_empty() || matches_words(&entry.haystack, &words))
|
||||||
|
.map(|entry| Rc::clone(&entry.album))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn song_matches(&self, query: &Query) -> Vec<SongHit> {
|
||||||
|
if !query.tracks_mode {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
let words = search_words(query.search);
|
||||||
|
let pool: &[usize] = match query.group {
|
||||||
|
None => return self.take_song_hits(self.tracks.iter(), &words),
|
||||||
|
Some(group) => &self.tracks_by_group[group.index()],
|
||||||
|
};
|
||||||
|
self.take_song_hits(pool.iter().map(|&i| &self.tracks[i]), &words)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stopping at the cap rather than filling every hit and slicing is what keeps an
|
||||||
|
/// empty query in tracks mode from walking all 4969 tracks to show 40 rows.
|
||||||
|
fn take_song_hits<'a>(
|
||||||
|
&self,
|
||||||
|
pool: impl Iterator<Item = &'a TrackEntry>,
|
||||||
|
words: &[String],
|
||||||
|
) -> Vec<SongHit> {
|
||||||
|
pool.filter(|track| words.is_empty() || matches_words(&track.haystack, words))
|
||||||
|
.take(MAX_SONG_HITS)
|
||||||
|
.map(|track| SongHit {
|
||||||
|
album: Rc::clone(&track.album),
|
||||||
|
index: track.index,
|
||||||
|
title: track.title.clone(),
|
||||||
|
duration: track.duration,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn results(&self, query: &Query) -> Results {
|
||||||
|
Results {
|
||||||
|
songs: self.song_matches(query),
|
||||||
|
categories: self.category_matches(query),
|
||||||
|
albums: self.album_matches(query),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn album(id: &str, title: &str, artist: &str, section: &str, tracks: &[&str]) -> Album {
|
||||||
|
serde_json::from_value(serde_json::json!({
|
||||||
|
"id": id,
|
||||||
|
"section": section,
|
||||||
|
"kind": if section == "Musik" { "music" } else { "book" },
|
||||||
|
"title": title,
|
||||||
|
"artist": artist,
|
||||||
|
"category": artist,
|
||||||
|
"tracks": tracks.iter().map(|t| serde_json::json!({ "title": t, "duration": 60.0 })).collect::<Vec<_>>(),
|
||||||
|
}))
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fixture() -> Library {
|
||||||
|
Library::build(vec![
|
||||||
|
album(
|
||||||
|
"a",
|
||||||
|
"Grüffelo",
|
||||||
|
"Julia Donaldson",
|
||||||
|
"Hörbücher",
|
||||||
|
&["Kapitel 1", "Kapitel 2"],
|
||||||
|
),
|
||||||
|
album("b", "Bibi Blocksberg", "Bibi", "Hörbücher", &["Hexerei"]),
|
||||||
|
album(
|
||||||
|
"c",
|
||||||
|
"Rote Lieder",
|
||||||
|
"Rolf Zuckowski",
|
||||||
|
"Musik",
|
||||||
|
&["Wie schön"],
|
||||||
|
),
|
||||||
|
album(
|
||||||
|
"d",
|
||||||
|
"Maus Folge 3",
|
||||||
|
"Sendung mit der Maus",
|
||||||
|
"Kinderpodcasts",
|
||||||
|
&["Folge 3"],
|
||||||
|
),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normalize_folds_umlauts_and_punctuation() {
|
||||||
|
assert_eq!(normalize("Hörbücher!"), "horbucher");
|
||||||
|
assert_eq!(normalize("Grüffelo"), "gruffelo");
|
||||||
|
assert_eq!(normalize("A-B C"), "abc");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn groups_split_podcasts_out_of_books() {
|
||||||
|
let library = fixture();
|
||||||
|
assert_eq!(Group::of(library.get("d").unwrap()), Group::Podcasts);
|
||||||
|
assert_eq!(Group::of(library.get("a").unwrap()), Group::Audiobooks);
|
||||||
|
assert_eq!(Group::of(library.get("c").unwrap()), Group::Music);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn album_search_is_word_order_independent() {
|
||||||
|
let library = fixture();
|
||||||
|
let hits = library.album_matches(&Query {
|
||||||
|
search: "donaldson gruffelo",
|
||||||
|
tracks_mode: false,
|
||||||
|
group: None,
|
||||||
|
category: None,
|
||||||
|
});
|
||||||
|
assert_eq!(hits.len(), 1);
|
||||||
|
assert_eq!(hits[0].id, "a");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_empty_query_lists_no_albums_but_does_list_categories() {
|
||||||
|
let library = fixture();
|
||||||
|
let query = Query {
|
||||||
|
search: "",
|
||||||
|
tracks_mode: false,
|
||||||
|
group: Some(Group::Audiobooks),
|
||||||
|
category: None,
|
||||||
|
};
|
||||||
|
assert!(library.album_matches(&query).is_empty());
|
||||||
|
assert_eq!(library.category_matches(&query).len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_root_screen_has_no_flat_category_list_of_its_own() {
|
||||||
|
let library = fixture();
|
||||||
|
let query = Query {
|
||||||
|
search: "",
|
||||||
|
tracks_mode: false,
|
||||||
|
group: None,
|
||||||
|
category: None,
|
||||||
|
};
|
||||||
|
assert!(library.category_matches(&query).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_category_lists_its_albums_without_a_search() {
|
||||||
|
let library = fixture();
|
||||||
|
let hits = library.album_matches(&Query {
|
||||||
|
search: "",
|
||||||
|
tracks_mode: false,
|
||||||
|
group: Some(Group::Audiobooks),
|
||||||
|
category: Some("Bibi"),
|
||||||
|
});
|
||||||
|
assert_eq!(hits.len(), 1);
|
||||||
|
assert_eq!(hits[0].id, "b");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn track_search_is_capped_and_group_scoped() {
|
||||||
|
let library = fixture();
|
||||||
|
let hits = library.song_matches(&Query {
|
||||||
|
search: "kapitel",
|
||||||
|
tracks_mode: true,
|
||||||
|
group: None,
|
||||||
|
category: None,
|
||||||
|
});
|
||||||
|
assert_eq!(hits.len(), 2);
|
||||||
|
assert!(hits.len() <= MAX_SONG_HITS);
|
||||||
|
|
||||||
|
let scoped = library.song_matches(&Query {
|
||||||
|
search: "",
|
||||||
|
tracks_mode: true,
|
||||||
|
group: Some(Group::Music),
|
||||||
|
category: None,
|
||||||
|
});
|
||||||
|
assert_eq!(scoped.len(), 1);
|
||||||
|
assert_eq!(scoped[0].title, "Wie schön");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn categories_sort_by_folded_key() {
|
||||||
|
let library = fixture();
|
||||||
|
let keys: Vec<&str> = library
|
||||||
|
.categories(Group::Audiobooks)
|
||||||
|
.iter()
|
||||||
|
.map(|c| c.key.as_str())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(keys, ["Bibi", "Julia Donaldson"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
1363
slint-frontend/src/main.rs
Normal file
1363
slint-frontend/src/main.rs
Normal file
File diff suppressed because it is too large
Load Diff
95
slint-frontend/src/oklch.rs
Normal file
95
slint-frontend/src/oklch.rs
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
//! oklch -> sRGB.
|
||||||
|
//!
|
||||||
|
//! The web front-end's palette is oklch throughout (`web/src/styles/app.css`) and
|
||||||
|
//! Slint has no such colour space, so every value crossing over is converted here.
|
||||||
|
//! Ported from `web/src/lib/oklch.ts`.
|
||||||
|
//!
|
||||||
|
//! The static palette is converted once at build time into the literals in
|
||||||
|
//! `ui/theme.slint`; this module exists for the values that are only known at runtime -
|
||||||
|
//! the per-group hues behind the generated book-spine art.
|
||||||
|
|
||||||
|
/// `l` is 0..1 (not the 0..100% that CSS writes), `c` is chroma, `h` is degrees.
|
||||||
|
pub fn oklch_to_rgb(l: f32, c: f32, h: f32) -> [u8; 3] {
|
||||||
|
let (sin, cos) = (h.to_radians().sin(), h.to_radians().cos());
|
||||||
|
let (a, b) = (c * cos, c * sin);
|
||||||
|
|
||||||
|
let l_ = l + 0.396_337_78 * a + 0.215_803_76 * b;
|
||||||
|
let m_ = l - 0.105_561_34 * a - 0.063_854_17 * b;
|
||||||
|
let s_ = l - 0.089_484_18 * a - 1.291_485_5 * b;
|
||||||
|
let (l3, m3, s3) = (l_ * l_ * l_, m_ * m_ * m_, s_ * s_ * s_);
|
||||||
|
|
||||||
|
let lin = [
|
||||||
|
4.076_741_7 * l3 - 3.307_711_6 * m3 + 0.230_969_94 * s3,
|
||||||
|
-1.268_438 * l3 + 2.609_757_4 * m3 - 0.341_319_38 * s3,
|
||||||
|
-0.004_196_086_3 * l3 - 0.703_418_6 * m3 + 1.707_614_7 * s3,
|
||||||
|
];
|
||||||
|
lin.map(|v| {
|
||||||
|
// Gamut clipping is per channel and deliberately crude: every colour this app
|
||||||
|
// actually asks for is inside sRGB, so a channel landing outside it means a
|
||||||
|
// typo, and a hard clamp shows that as a flat primary rather than hiding it.
|
||||||
|
let v = v.clamp(0.0, 1.0);
|
||||||
|
let encoded = if v <= 0.003_130_8 {
|
||||||
|
12.92 * v
|
||||||
|
} else {
|
||||||
|
1.055 * v.powf(1.0 / 2.4) - 0.055
|
||||||
|
};
|
||||||
|
(encoded * 255.0).round().clamp(0.0, 255.0) as u8
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `#rrggbb` as the backend writes it. Anything unparseable falls back rather than
|
||||||
|
/// failing: the colours are decoration, and an album with a malformed one should still
|
||||||
|
/// appear on the shelf.
|
||||||
|
pub fn parse_hex(hex: &str, fallback: [u8; 3]) -> [u8; 3] {
|
||||||
|
let digits = hex.trim().trim_start_matches('#');
|
||||||
|
if digits.len() != 6 {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
let byte = |i: usize| u8::from_str_radix(&digits[i..i + 2], 16).ok();
|
||||||
|
match (byte(0), byte(2), byte(4)) {
|
||||||
|
(Some(r), Some(g), Some(b)) => [r, g, b],
|
||||||
|
_ => fallback,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Linear blend, for the spine's translucent highlight lines.
|
||||||
|
pub fn mix(base: [u8; 3], over: [u8; 3], alpha: f32) -> [u8; 3] {
|
||||||
|
let alpha = alpha.clamp(0.0, 1.0);
|
||||||
|
[0, 1, 2].map(|i| (base[i] as f32 * (1.0 - alpha) + over[i] as f32 * alpha).round() as u8)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn known_anchors_round_trip_into_srgb() {
|
||||||
|
// Pure white and pure black are the two values with no room to be subtly wrong.
|
||||||
|
assert_eq!(oklch_to_rgb(1.0, 0.0, 0.0), [255, 255, 255]);
|
||||||
|
assert_eq!(oklch_to_rgb(0.0, 0.0, 0.0), [0, 0, 0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_accent_pink_lands_where_the_web_ui_puts_it() {
|
||||||
|
// oklch(70% 0.16 340) is `--accent`; the browser resolves it to ~#e A pink in
|
||||||
|
// the red-magenta corner. Assert the shape of it rather than an exact byte.
|
||||||
|
let [r, g, b] = oklch_to_rgb(0.70, 0.16, 340.0);
|
||||||
|
assert!(r > g && b > g, "expected a pink, got {r},{g},{b}");
|
||||||
|
assert!(r > 200, "expected a light pink, got {r},{g},{b}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hex_parsing_falls_back_rather_than_failing() {
|
||||||
|
assert_eq!(parse_hex("#4a6fa5", [0, 0, 0]), [0x4a, 0x6f, 0xa5]);
|
||||||
|
assert_eq!(parse_hex("4a6fa5", [0, 0, 0]), [0x4a, 0x6f, 0xa5]);
|
||||||
|
assert_eq!(parse_hex("nope", [1, 2, 3]), [1, 2, 3]);
|
||||||
|
assert_eq!(parse_hex("", [1, 2, 3]), [1, 2, 3]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mix_interpolates_between_the_two_ends() {
|
||||||
|
assert_eq!(mix([0, 0, 0], [255, 255, 255], 0.0), [0, 0, 0]);
|
||||||
|
assert_eq!(mix([0, 0, 0], [255, 255, 255], 1.0), [255, 255, 255]);
|
||||||
|
assert_eq!(mix([0, 0, 0], [200, 100, 50], 0.5), [100, 50, 25]);
|
||||||
|
}
|
||||||
|
}
|
||||||
97
slint-frontend/src/ws.rs
Normal file
97
slint-frontend/src/ws.rs
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
//! The state websocket, with reconnect-and-reseed.
|
||||||
|
//!
|
||||||
|
//! Ported from `web/src/hooks/usePlayerState.ts`. The backend pushes and never listens,
|
||||||
|
//! so this is a read loop and nothing else. Two details are load-bearing:
|
||||||
|
//!
|
||||||
|
//! * **Reseed on open, not just on connect.** A reconnect can have missed every change
|
||||||
|
//! between the drop and the new socket, and the server only sends a snapshot to a
|
||||||
|
//! client it already considers connected. So each successful open is followed by a
|
||||||
|
//! `GET /api/state`.
|
||||||
|
//! * **Position is not on the event bus.** The server polls its player on a 0.5 s timer
|
||||||
|
//! and sends `position` frames only while something is playing - so the absence of
|
||||||
|
//! them is not a stall, and the UI interpolates between them rather than waiting.
|
||||||
|
|
||||||
|
use std::sync::mpsc::Sender;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use crate::api::{Client, PlayerState, ServerMessage};
|
||||||
|
|
||||||
|
/// What the UI thread hears from this connection. Wider than `ServerMessage` because
|
||||||
|
/// connectivity is itself a thing the screen shows.
|
||||||
|
pub enum Update {
|
||||||
|
Connected,
|
||||||
|
Disconnected,
|
||||||
|
State(Box<PlayerState>),
|
||||||
|
Position {
|
||||||
|
position: f64,
|
||||||
|
duration: f64,
|
||||||
|
},
|
||||||
|
/// The library was rescanned: re-fetch it, and drop the cover thumbnails, since a
|
||||||
|
/// rescan is exactly when the art behind an unchanged URL gets rewritten.
|
||||||
|
LibraryChanged,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Matches the web client's retry interval. Short enough that the screen comes back
|
||||||
|
/// before a child gives up on it, long enough not to spin while the backend restarts.
|
||||||
|
const RETRY: Duration = Duration::from_millis(1500);
|
||||||
|
|
||||||
|
pub fn spawn(client: Client, updates: Sender<Update>) -> std::thread::JoinHandle<()> {
|
||||||
|
std::thread::Builder::new()
|
||||||
|
.name("ws".into())
|
||||||
|
.spawn(move || loop {
|
||||||
|
match connect_once(&client, &updates) {
|
||||||
|
Ok(()) | Err(()) => {}
|
||||||
|
}
|
||||||
|
// `send` failing means the UI is gone, which is the one reason to stop.
|
||||||
|
if updates.send(Update::Disconnected).is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
std::thread::sleep(RETRY);
|
||||||
|
})
|
||||||
|
.expect("spawning the websocket thread")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn connect_once(client: &Client, updates: &Sender<Update>) -> Result<(), ()> {
|
||||||
|
let (mut socket, _) = tungstenite::connect(client.websocket_url()).map_err(|_| ())?;
|
||||||
|
|
||||||
|
if updates.send(Update::Connected).is_err() {
|
||||||
|
return Err(());
|
||||||
|
}
|
||||||
|
// Reseed: see the note at the top of this module.
|
||||||
|
if let Ok(state) = client.state() {
|
||||||
|
if updates.send(Update::State(Box::new(state))).is_err() {
|
||||||
|
return Err(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let message = match socket.read() {
|
||||||
|
Ok(message) => message,
|
||||||
|
Err(_) => return Err(()),
|
||||||
|
};
|
||||||
|
let text = match message {
|
||||||
|
tungstenite::Message::Text(text) => text,
|
||||||
|
tungstenite::Message::Close(_) => return Ok(()),
|
||||||
|
// Ping/Pong are answered by tungstenite itself on the next write; binary
|
||||||
|
// frames are not part of this protocol.
|
||||||
|
_ => continue,
|
||||||
|
};
|
||||||
|
let parsed: ServerMessage = match serde_json::from_str(&text) {
|
||||||
|
Ok(parsed) => parsed,
|
||||||
|
// A frame this build cannot read is not a reason to drop the connection -
|
||||||
|
// the next one is probably fine.
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
let update = match parsed {
|
||||||
|
ServerMessage::State { state } => Update::State(Box::new(state)),
|
||||||
|
ServerMessage::Position { position, duration } => {
|
||||||
|
Update::Position { position, duration }
|
||||||
|
}
|
||||||
|
ServerMessage::Library => Update::LibraryChanged,
|
||||||
|
ServerMessage::Unknown => continue,
|
||||||
|
};
|
||||||
|
if updates.send(update).is_err() {
|
||||||
|
return Err(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
56
slint-frontend/tools/headless-shots.sh
Executable file
56
slint-frontend/tools/headless-shots.sh
Executable file
@@ -0,0 +1,56 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Render the UI and screenshot it without touching your session.
|
||||||
|
#
|
||||||
|
# A Slint window needs a compositor: under bare Xvfb nothing maps, and on a locked or
|
||||||
|
# remote machine there is no session to draw into. So this starts a nested headless
|
||||||
|
# sway, runs the app inside it, drives it with synthetic keys and grabs a frame per
|
||||||
|
# screen. It is how the shots in README.md were made, and how to check a layout change
|
||||||
|
# on a machine with no display.
|
||||||
|
#
|
||||||
|
# nix-shell -E '(import ./shell.nix {}).overrideAttrs (o: {
|
||||||
|
# nativeBuildInputs = o.nativeBuildInputs ++ (with (import <nixpkgs> {}); [ sway grim wtype ]);
|
||||||
|
# })' --run 'tools/headless-shots.sh out/ http://127.0.0.1:8080'
|
||||||
|
#
|
||||||
|
set -euo pipefail
|
||||||
|
OUTDIR="${1:?usage: headless-shots.sh <outdir> [server-url]}"
|
||||||
|
SERVER="${2:-http://127.0.0.1:8080}"
|
||||||
|
BIN="${BIN:-./target/release/musicmouse-slint}"
|
||||||
|
mkdir -p "$OUTDIR"
|
||||||
|
|
||||||
|
export XDG_RUNTIME_DIR="$(mktemp -d)"
|
||||||
|
unset WAYLAND_DISPLAY DISPLAY
|
||||||
|
export WLR_BACKENDS=headless WLR_LIBINPUT_NO_DEVICES=1 WLR_RENDERER=pixman
|
||||||
|
trap 'kill %1 2>/dev/null || true; rm -rf "$XDG_RUNTIME_DIR"' EXIT
|
||||||
|
|
||||||
|
launcher="$XDG_RUNTIME_DIR/launch.sh"
|
||||||
|
printf '#!/usr/bin/env bash\nexec %q --server %q >%q/app.log 2>&1\n' \
|
||||||
|
"$(realpath "$BIN")" "$SERVER" "$XDG_RUNTIME_DIR" > "$launcher"
|
||||||
|
chmod +x "$launcher"
|
||||||
|
printf 'output HEADLESS-1 mode 1920x1080\ndefault_border none\nexec %s\n' "$launcher" \
|
||||||
|
> "$XDG_RUNTIME_DIR/sway.cfg"
|
||||||
|
|
||||||
|
sway -c "$XDG_RUNTIME_DIR/sway.cfg" >"$XDG_RUNTIME_DIR/sway.log" 2>&1 &
|
||||||
|
for _ in $(seq 30); do [ -S "$XDG_RUNTIME_DIR/wayland-1" ] && break; sleep 1; done
|
||||||
|
export WAYLAND_DISPLAY=wayland-1
|
||||||
|
sleep 16 # library fetch plus the first covers
|
||||||
|
|
||||||
|
# Each step is ONE wtype invocation, and starts with a throwaway keypress.
|
||||||
|
#
|
||||||
|
# wtype creates a virtual keyboard, sends, and exits. The client needs a moment to
|
||||||
|
# notice the seat gained a keyboard, so the first key or two of any invocation is
|
||||||
|
# dropped - which makes a per-key loop lose every key it sends. Batching a step into
|
||||||
|
# one invocation and opening it with "x" + BackSpace spends that warm-up on a keypress
|
||||||
|
# that cancels itself out.
|
||||||
|
WARM=(-s 260 "x" -k BackSpace)
|
||||||
|
shot() { sleep 2.5; grim "$OUTDIR/$1.png"; echo " $1"; }
|
||||||
|
|
||||||
|
shot 01-root
|
||||||
|
wtype "${WARM[@]}" -k Down -k Return; shot 02-categories
|
||||||
|
wtype "${WARM[@]}" -k Return; shot 03-grid
|
||||||
|
wtype "${WARM[@]}" -k Escape -k Escape "maja"; shot 04-search
|
||||||
|
wtype "${WARM[@]}" -k Escape -k question "brumm"; shot 05-tracksearch
|
||||||
|
wtype "${WARM[@]}" -k Return; sleep 4; shot 06-play
|
||||||
|
wtype "${WARM[@]}" -k Escape; shot 07-bar
|
||||||
|
|
||||||
|
echo "wrote 7 frames to $OUTDIR"
|
||||||
347
slint-frontend/ui/app.slint
Normal file
347
slint-frontend/ui/app.slint
Normal file
@@ -0,0 +1,347 @@
|
|||||||
|
import { Theme } from "theme.slint";
|
||||||
|
import { RoundButton } from "widgets.slint";
|
||||||
|
import { ShelfView, ResultsView } from "browse.slint";
|
||||||
|
import { PlayerBar, PlayView, AlbumSheet } from "player.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, RoomCard, RoomRow, SceneTile, Swatch, Tab, Theme }
|
||||||
|
|
||||||
|
export component AppWindow inherits Window {
|
||||||
|
title: "Musik Delfin";
|
||||||
|
default-font-family: Theme.font;
|
||||||
|
default-font-size: Theme.text-body;
|
||||||
|
// The stage. In the web app a per-track animated canvas sits on top of this; here
|
||||||
|
// it is the whole background, on purpose. A full-viewport repaint loop is a floor
|
||||||
|
// you cannot get under while it runs at all, and it is the first thing `?pi=1`
|
||||||
|
// already removes on the device this is meant to replace.
|
||||||
|
background: @linear-gradient(180deg,
|
||||||
|
Theme.stage-top 0%,
|
||||||
|
Theme.stage-mid 45%,
|
||||||
|
Theme.sea-deep 100%);
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------- 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 <string> splash-text;
|
||||||
|
in property <string> status-label;
|
||||||
|
in property <bool> online: false;
|
||||||
|
|
||||||
|
in property <bool> play-view: false;
|
||||||
|
in property <bool> show-sheet: false;
|
||||||
|
in property <bool> can-go-back: false;
|
||||||
|
|
||||||
|
in property <[Shelf]> shelves;
|
||||||
|
in property <bool> root-screen: true;
|
||||||
|
in property <int> shelf-row: 0;
|
||||||
|
|
||||||
|
in property <string> group-label;
|
||||||
|
in property <string> category-heading;
|
||||||
|
in property <string> album-heading;
|
||||||
|
in property <bool> show-search: false;
|
||||||
|
in property <string> search-text;
|
||||||
|
in property <string> mode-label;
|
||||||
|
in property <string> count-label;
|
||||||
|
in property <[TrackRow]> songs;
|
||||||
|
in property <[CategoryRow]> category-grid;
|
||||||
|
in property <[GridRow]> grid;
|
||||||
|
in property <bool> no-results: false;
|
||||||
|
in property <int> sel-index: 0;
|
||||||
|
in property <int> sel-row: 0;
|
||||||
|
|
||||||
|
in property <NowPlaying> now;
|
||||||
|
in property <bool> playing: false;
|
||||||
|
in property <float> progress: 0.0;
|
||||||
|
in property <bool> smooth: true;
|
||||||
|
in property <int> volume: 60;
|
||||||
|
in property <bool> has-bar: false;
|
||||||
|
|
||||||
|
in property <string> sheet-title;
|
||||||
|
in property <string> sheet-subtitle;
|
||||||
|
in property <string> sheet-meta;
|
||||||
|
in property <image> sheet-cover;
|
||||||
|
in property <bool> sheet-is-book: false;
|
||||||
|
in property <[TrackRow]> sheet-tracks;
|
||||||
|
in property <int> sheet-index: 0;
|
||||||
|
|
||||||
|
// How wide the grid may be. Rust reads this back to decide the column count, so the
|
||||||
|
// chunking into `GridRow`s and the layout agree about how many fit.
|
||||||
|
out property <length> grid-width: self.width - 32px - Theme.rail-clearance;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- callbacks --
|
||||||
|
// The special keys are named here rather than in Rust because this is where the
|
||||||
|
// `Key.*` constants exist; Rust receives "escape"/"left"/"enter"/… or the literal
|
||||||
|
// character that was typed.
|
||||||
|
callback key(string, bool, bool);
|
||||||
|
callback toggle();
|
||||||
|
callback next();
|
||||||
|
callback previous();
|
||||||
|
callback seek(float);
|
||||||
|
callback set-volume(int);
|
||||||
|
callback mute();
|
||||||
|
callback go-back();
|
||||||
|
callback enter-group(int);
|
||||||
|
callback enter-category(int, string);
|
||||||
|
callback open-category(string);
|
||||||
|
callback open-album(string);
|
||||||
|
callback play-album(string);
|
||||||
|
callback play-song(string, int);
|
||||||
|
callback open-play-view();
|
||||||
|
callback close-sheet();
|
||||||
|
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;
|
||||||
|
|
||||||
|
keys := FocusScope {
|
||||||
|
key-pressed(event) => {
|
||||||
|
root.key(
|
||||||
|
event.text == Key.Escape ? "escape"
|
||||||
|
: event.text == Key.Return ? "enter"
|
||||||
|
: event.text == Key.Backspace ? "backspace"
|
||||||
|
: event.text == Key.Tab ? "tab"
|
||||||
|
: event.text == Key.LeftArrow ? "left"
|
||||||
|
: event.text == Key.RightArrow ? "right"
|
||||||
|
: event.text == Key.UpArrow ? "up"
|
||||||
|
: event.text == Key.DownArrow ? "down"
|
||||||
|
: event.text == Key.F1 ? "f1"
|
||||||
|
: event.text == Key.Space ? "space"
|
||||||
|
: event.text,
|
||||||
|
event.modifiers.control,
|
||||||
|
event.modifiers.shift);
|
||||||
|
accept
|
||||||
|
}
|
||||||
|
|
||||||
|
VerticalLayout {
|
||||||
|
// ------------------------------------------------------------ header --
|
||||||
|
if root.page == "music" && !root.play-view: HorizontalLayout {
|
||||||
|
alignment: center;
|
||||||
|
spacing: 16px;
|
||||||
|
padding-top: 18px;
|
||||||
|
padding-bottom: 6px;
|
||||||
|
Text {
|
||||||
|
text: "Musik Delfin";
|
||||||
|
font-size: 34px;
|
||||||
|
font-weight: 900;
|
||||||
|
color: Theme.paper;
|
||||||
|
}
|
||||||
|
if root.status-label != "": VerticalLayout {
|
||||||
|
alignment: center;
|
||||||
|
Rectangle {
|
||||||
|
background: Theme.paper.with-alpha(0.16);
|
||||||
|
border-radius: Theme.pill;
|
||||||
|
width: status.preferred-width;
|
||||||
|
height: status.preferred-height;
|
||||||
|
status := HorizontalLayout {
|
||||||
|
padding-left: 14px;
|
||||||
|
padding-right: 14px;
|
||||||
|
padding-top: 6px;
|
||||||
|
padding-bottom: 6px;
|
||||||
|
Text {
|
||||||
|
text: root.status-label;
|
||||||
|
font-size: Theme.text-small + 1px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: root.online ? Theme.paper : #ffb4a2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------- body --
|
||||||
|
if root.page == "music" && !root.play-view && root.root-screen: ShelfView {
|
||||||
|
shelves: root.shelves;
|
||||||
|
shelf-row: root.shelf-row;
|
||||||
|
sel-index: root.sel-index;
|
||||||
|
enter-group(s) => { root.enter-group(s); }
|
||||||
|
enter-category(s, key) => { root.enter-category(s, key); }
|
||||||
|
}
|
||||||
|
|
||||||
|
if root.page == "music" && !root.play-view && !root.root-screen: ResultsView {
|
||||||
|
group-label: root.group-label;
|
||||||
|
category-heading: root.category-heading;
|
||||||
|
album-heading: root.album-heading;
|
||||||
|
show-search: root.show-search;
|
||||||
|
search-text: root.search-text;
|
||||||
|
mode-label: root.mode-label;
|
||||||
|
count-label: root.count-label;
|
||||||
|
songs: root.songs;
|
||||||
|
category-grid: root.category-grid;
|
||||||
|
grid: root.grid;
|
||||||
|
sel-index: root.sel-index;
|
||||||
|
sel-row: root.sel-row;
|
||||||
|
empty: root.no-results;
|
||||||
|
play-song(id, i) => { root.play-song(id, i); }
|
||||||
|
open-category(key) => { root.open-category(key); }
|
||||||
|
open-album(id) => { root.open-album(id); }
|
||||||
|
play-album(id) => { root.play-album(id); }
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
playing: root.playing;
|
||||||
|
progress: root.progress;
|
||||||
|
smooth: root.smooth;
|
||||||
|
volume: root.volume;
|
||||||
|
toggle => { root.toggle(); }
|
||||||
|
next => { root.next(); }
|
||||||
|
previous => { root.previous(); }
|
||||||
|
seek(f) => { root.seek(f); }
|
||||||
|
set-volume(v) => { root.set-volume(v); }
|
||||||
|
mute => { root.mute(); }
|
||||||
|
open-album => { root.open-album(root.now.title); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The player bar floats over the browse screen rather than displacing it, which is
|
||||||
|
// why the scrollers above reserve bottom padding for it. Geometry is set here
|
||||||
|
// 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
|
||||||
|
// left gaps for the grid behind it to show through.
|
||||||
|
if root.page == "music" && !root.play-view && root.has-bar: PlayerBar {
|
||||||
|
x: 0;
|
||||||
|
y: parent.height - self.height;
|
||||||
|
width: parent.width;
|
||||||
|
now: root.now;
|
||||||
|
playing: root.playing;
|
||||||
|
progress: root.progress;
|
||||||
|
smooth: root.smooth;
|
||||||
|
volume: root.volume;
|
||||||
|
toggle => { root.toggle(); }
|
||||||
|
next => { root.next(); }
|
||||||
|
previous => { root.previous(); }
|
||||||
|
seek(f) => { root.seek(f); }
|
||||||
|
set-volume(v) => { root.set-volume(v); }
|
||||||
|
mute => { root.mute(); }
|
||||||
|
open-play-view => { root.open-play-view(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// One fixed round button in the top-left: back, everywhere it applies, so "top
|
||||||
|
// left" always means the same thing.
|
||||||
|
if root.can-go-back: RoundButton {
|
||||||
|
x: 24px;
|
||||||
|
y: 18px;
|
||||||
|
label: "‹";
|
||||||
|
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 {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
title: root.sheet-title;
|
||||||
|
subtitle: root.sheet-subtitle;
|
||||||
|
meta: root.sheet-meta;
|
||||||
|
cover: root.sheet-cover;
|
||||||
|
is-book: root.sheet-is-book;
|
||||||
|
tracks: root.sheet-tracks;
|
||||||
|
sel-index: root.sheet-index;
|
||||||
|
close => { root.close-sheet(); }
|
||||||
|
play(i) => { root.play-sheet-track(i); }
|
||||||
|
play-all => { root.play-sheet-track(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
if root.loading: Rectangle {
|
||||||
|
background: Theme.sea-deep.with-alpha(0.92);
|
||||||
|
TouchArea { }
|
||||||
|
VerticalLayout {
|
||||||
|
alignment: center;
|
||||||
|
spacing: 18px;
|
||||||
|
Text {
|
||||||
|
text: "🐬";
|
||||||
|
font-size: 96px;
|
||||||
|
horizontal-alignment: center;
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
text: root.splash-text;
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: Theme.paper;
|
||||||
|
horizontal-alignment: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
567
slint-frontend/ui/browse.slint
Normal file
567
slint-frontend/ui/browse.slint
Normal file
@@ -0,0 +1,567 @@
|
|||||||
|
import { ListView } from "std-widgets.slint";
|
||||||
|
import { Theme } from "theme.slint";
|
||||||
|
import { GlassPanel, Cover } from "widgets.slint";
|
||||||
|
import { AlbumTile, GridRow, CategoryTile, CategoryRow, Shelf, TrackRow } from "models.slint";
|
||||||
|
|
||||||
|
// Heights are fixed rather than derived from content, and that is the whole reason the
|
||||||
|
// grid can be virtualized: a `ListView` that has to measure a row before it can decide
|
||||||
|
// whether to skip it has not skipped anything. The web grid does the same thing with
|
||||||
|
// `contain-intrinsic-size` for the same reason.
|
||||||
|
//
|
||||||
|
// A book is taller than an album, so the cover *slot* is sized for the taller of the
|
||||||
|
// two and each cover is centred in it. That keeps every row identical without
|
||||||
|
// flattening the shape difference that tells the two media apart.
|
||||||
|
export global Metrics {
|
||||||
|
out property <length> cover-slot: 200px;
|
||||||
|
out property <length> card-text: 78px;
|
||||||
|
out property <length> card-height: 278px; // cover-slot + card-text
|
||||||
|
out property <length> row-height: 302px; // card-height + grid-gap
|
||||||
|
out property <length> shelf-height: 236px;
|
||||||
|
out property <length> category-row-height: 260px;
|
||||||
|
}
|
||||||
|
|
||||||
|
component SelectionRing inherits Rectangle {
|
||||||
|
in property <bool> selected;
|
||||||
|
in property <bool> current;
|
||||||
|
|
||||||
|
// Selection outranks "currently playing": when the keyboard is on the album that is
|
||||||
|
// also playing, the ring it needs to see is the one that moves.
|
||||||
|
border-width: root.selected ? 5px : (root.current ? 4px : 0px);
|
||||||
|
border-color: root.selected ? Theme.paper : Theme.accent;
|
||||||
|
border-radius: Theme.radius-card + 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
component AlbumCard inherits Rectangle {
|
||||||
|
in property <AlbumTile> tile;
|
||||||
|
in property <bool> selected;
|
||||||
|
callback open();
|
||||||
|
callback play();
|
||||||
|
|
||||||
|
height: Metrics.card-height;
|
||||||
|
|
||||||
|
ring := SelectionRing {
|
||||||
|
selected: root.selected;
|
||||||
|
current: root.tile.current;
|
||||||
|
x: -7px;
|
||||||
|
y: -7px;
|
||||||
|
width: parent.width + 14px;
|
||||||
|
height: parent.height + 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
card := Rectangle {
|
||||||
|
background: root.tile.is-book ? Theme.card-book : Theme.card-music;
|
||||||
|
border-radius: Theme.radius-card;
|
||||||
|
drop-shadow-color: #00101759;
|
||||||
|
drop-shadow-blur: 18px;
|
||||||
|
drop-shadow-offset-y: 6px;
|
||||||
|
// The lift on selection: 120ms, the same as the web card's transition.
|
||||||
|
y: root.selected ? -3px : 0px;
|
||||||
|
animate y { duration: 120ms; easing: ease-out; }
|
||||||
|
|
||||||
|
VerticalLayout {
|
||||||
|
padding: 10px;
|
||||||
|
spacing: 6px;
|
||||||
|
|
||||||
|
// The cover, centred in a slot tall enough for the taller media type.
|
||||||
|
Rectangle {
|
||||||
|
height: Metrics.cover-slot - 20px;
|
||||||
|
HorizontalLayout {
|
||||||
|
alignment: center;
|
||||||
|
Cover {
|
||||||
|
source: root.tile.cover;
|
||||||
|
is-book: root.tile.is-book;
|
||||||
|
height: parent.height;
|
||||||
|
width: root.tile.is-book ? parent.height * 0.82 : parent.height;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TouchArea {
|
||||||
|
// Cover opens the track list; the title below starts it playing.
|
||||||
|
// Two targets, because "show me what is in this" and "play this"
|
||||||
|
// are different intentions and a child has both.
|
||||||
|
clicked => { root.open(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
height: Metrics.card-text - 26px;
|
||||||
|
VerticalLayout {
|
||||||
|
spacing: 1px;
|
||||||
|
Text {
|
||||||
|
text: root.tile.title;
|
||||||
|
font-size: Theme.text-body - 1px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: Theme.ink;
|
||||||
|
wrap: word-wrap;
|
||||||
|
overflow: elide;
|
||||||
|
// Two lines always, whether or not the title needs them, so
|
||||||
|
// every card in the row ends at the same place.
|
||||||
|
height: (Theme.text-body - 1px) * 2.4;
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
text: root.tile.artist;
|
||||||
|
font-size: Theme.text-small - 1px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: Theme.ink.with-alpha(0.65);
|
||||||
|
overflow: elide;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TouchArea {
|
||||||
|
clicked => { root.play(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: root.tile.meta;
|
||||||
|
font-size: Theme.text-tiny;
|
||||||
|
font-weight: 800;
|
||||||
|
color: Theme.count-pink;
|
||||||
|
overflow: elide;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
component CategoryCard inherits Rectangle {
|
||||||
|
in property <CategoryTile> tile;
|
||||||
|
in property <bool> selected;
|
||||||
|
in property <length> tile-width: Theme.shelf-tile;
|
||||||
|
callback open();
|
||||||
|
|
||||||
|
width: root.tile-width;
|
||||||
|
height: Metrics.shelf-height;
|
||||||
|
|
||||||
|
SelectionRing {
|
||||||
|
selected: root.selected;
|
||||||
|
current: false;
|
||||||
|
x: -7px;
|
||||||
|
y: -7px;
|
||||||
|
width: parent.width + 14px;
|
||||||
|
height: parent.height + 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
background: root.tile.is-book ? Theme.card-book : Theme.card-music;
|
||||||
|
border-radius: Theme.radius-card;
|
||||||
|
drop-shadow-color: #00101759;
|
||||||
|
drop-shadow-blur: 18px;
|
||||||
|
drop-shadow-offset-y: 6px;
|
||||||
|
y: root.selected ? -3px : 0px;
|
||||||
|
animate y { duration: 120ms; easing: ease-out; }
|
||||||
|
|
||||||
|
VerticalLayout {
|
||||||
|
padding: 8px;
|
||||||
|
spacing: 5px;
|
||||||
|
|
||||||
|
// One album fills the tile; several show as a 2x2 contact sheet, which is
|
||||||
|
// how a category reads as "a stack of these" without a label saying so.
|
||||||
|
Rectangle {
|
||||||
|
height: root.tile-width - 16px;
|
||||||
|
clip: true;
|
||||||
|
if root.tile.cover-count <= 1: Cover {
|
||||||
|
source: root.tile.cover1;
|
||||||
|
is-book: root.tile.is-book;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
if root.tile.cover-count > 1: VerticalLayout {
|
||||||
|
spacing: 4px;
|
||||||
|
HorizontalLayout {
|
||||||
|
spacing: 4px;
|
||||||
|
Cover { source: root.tile.cover1; radius: 6px; }
|
||||||
|
Cover { source: root.tile.cover2; radius: 6px; }
|
||||||
|
}
|
||||||
|
HorizontalLayout {
|
||||||
|
spacing: 4px;
|
||||||
|
Cover { source: root.tile.cover3; radius: 6px; }
|
||||||
|
Cover { source: root.tile.cover4; radius: 6px; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: root.tile.key;
|
||||||
|
font-size: Theme.text-body;
|
||||||
|
font-weight: 800;
|
||||||
|
color: Theme.ink;
|
||||||
|
overflow: elide;
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
text: root.tile.count-label;
|
||||||
|
font-size: Theme.text-tiny;
|
||||||
|
font-weight: 800;
|
||||||
|
color: Theme.count-pink;
|
||||||
|
overflow: elide;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TouchArea {
|
||||||
|
clicked => { root.open(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
component TrackListRow inherits Rectangle {
|
||||||
|
in property <TrackRow> track;
|
||||||
|
in property <bool> selected;
|
||||||
|
callback play();
|
||||||
|
|
||||||
|
height: 58px;
|
||||||
|
background: root.track.current ? Theme.row-active : Theme.row-idle;
|
||||||
|
border-radius: Theme.radius-row;
|
||||||
|
border-width: root.selected ? 3px : 1px;
|
||||||
|
border-color: root.selected ? Theme.paper : (root.track.current ? Theme.accent : Theme.glass-border);
|
||||||
|
|
||||||
|
HorizontalLayout {
|
||||||
|
padding-left: 12px;
|
||||||
|
padding-right: 16px;
|
||||||
|
spacing: 12px;
|
||||||
|
|
||||||
|
VerticalLayout {
|
||||||
|
alignment: center;
|
||||||
|
Cover {
|
||||||
|
source: root.track.cover;
|
||||||
|
is-book: root.track.is-book;
|
||||||
|
radius: 8px;
|
||||||
|
height: 40px;
|
||||||
|
width: root.track.is-book ? 33px : 40px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
VerticalLayout {
|
||||||
|
alignment: center;
|
||||||
|
Text {
|
||||||
|
text: root.track.title;
|
||||||
|
font-size: Theme.text-body;
|
||||||
|
font-weight: 800;
|
||||||
|
color: Theme.paper;
|
||||||
|
overflow: elide;
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
text: root.track.subtitle;
|
||||||
|
font-size: Theme.text-tiny;
|
||||||
|
font-weight: 700;
|
||||||
|
color: Theme.text-sub.with-alpha(0.75);
|
||||||
|
overflow: elide;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
VerticalLayout {
|
||||||
|
alignment: center;
|
||||||
|
Text {
|
||||||
|
text: root.track.duration;
|
||||||
|
font-family: Theme.mono;
|
||||||
|
font-size: Theme.text-small;
|
||||||
|
font-weight: 700;
|
||||||
|
color: Theme.text-dim.with-alpha(0.8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TouchArea {
|
||||||
|
clicked => { root.play(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The root screen: three shelves, each scrolling sideways.
|
||||||
|
export component ShelfView inherits Flickable {
|
||||||
|
in property <[Shelf]> shelves;
|
||||||
|
in property <int> shelf-row;
|
||||||
|
in property <int> sel-index;
|
||||||
|
callback enter-group(int); // shelf index
|
||||||
|
callback enter-category(int, string); // shelf index, category key
|
||||||
|
|
||||||
|
content-height: layout.preferred-height;
|
||||||
|
content-width: self.width;
|
||||||
|
|
||||||
|
layout := VerticalLayout {
|
||||||
|
padding-left: 32px;
|
||||||
|
padding-right: Theme.rail-clearance;
|
||||||
|
padding-top: 14px;
|
||||||
|
// Clear of the player bar, which floats over this rather than displacing it.
|
||||||
|
padding-bottom: 210px;
|
||||||
|
spacing: 25px;
|
||||||
|
|
||||||
|
for shelf[s] in root.shelves: GlassPanel {
|
||||||
|
height: Metrics.shelf-height + 74px;
|
||||||
|
// The panel itself is a target: it opens the whole group.
|
||||||
|
TouchArea {
|
||||||
|
clicked => { root.enter-group(s); }
|
||||||
|
}
|
||||||
|
|
||||||
|
VerticalLayout {
|
||||||
|
padding: 14px;
|
||||||
|
spacing: 10px;
|
||||||
|
|
||||||
|
HorizontalLayout {
|
||||||
|
spacing: 10px;
|
||||||
|
alignment: start;
|
||||||
|
// A round frosted badge behind the emoji, which otherwise renders
|
||||||
|
// thin and low-contrast straight on the stage gradient.
|
||||||
|
Rectangle {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: 17px;
|
||||||
|
background: Theme.paper.with-alpha(0.2);
|
||||||
|
border-width: 1px;
|
||||||
|
border-color: Theme.paper.with-alpha(0.4);
|
||||||
|
Text {
|
||||||
|
text: shelf.icon;
|
||||||
|
font-size: 17px;
|
||||||
|
vertical-alignment: center;
|
||||||
|
horizontal-alignment: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
VerticalLayout {
|
||||||
|
alignment: center;
|
||||||
|
Text {
|
||||||
|
text: shelf.label;
|
||||||
|
font-size: Theme.text-heading;
|
||||||
|
font-weight: 900;
|
||||||
|
color: Theme.paper;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
VerticalLayout {
|
||||||
|
alignment: center;
|
||||||
|
Text {
|
||||||
|
text: "›";
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 900;
|
||||||
|
color: Theme.paper.with-alpha(0.6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if shelf.tiles.length == 0: Text {
|
||||||
|
text: shelf.empty-label;
|
||||||
|
font-size: Theme.text-small;
|
||||||
|
font-weight: 700;
|
||||||
|
color: Theme.text-dim.with-alpha(0.6);
|
||||||
|
horizontal-alignment: center;
|
||||||
|
vertical-alignment: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
if shelf.tiles.length > 0: Flickable {
|
||||||
|
content-width: shelf.tiles.length * (Theme.shelf-tile + 12px) + 20px;
|
||||||
|
content-height: self.height;
|
||||||
|
// Keep the focused tile in view as the arrow keys walk past the fold.
|
||||||
|
content-x: root.shelf-row != s
|
||||||
|
? 0px
|
||||||
|
: -max(0px, min(
|
||||||
|
self.content-width - self.width,
|
||||||
|
root.sel-index * (Theme.shelf-tile + 12px) - self.width / 2 + Theme.shelf-tile / 2));
|
||||||
|
animate content-x { duration: 180ms; easing: ease-out; }
|
||||||
|
|
||||||
|
HorizontalLayout {
|
||||||
|
spacing: 12px;
|
||||||
|
padding: 10px;
|
||||||
|
alignment: start;
|
||||||
|
for tile[i] in shelf.tiles: CategoryCard {
|
||||||
|
tile: tile;
|
||||||
|
selected: root.shelf-row == s && root.sel-index == i;
|
||||||
|
open => { root.enter-category(s, tile.key); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inside a group, or showing search results.
|
||||||
|
//
|
||||||
|
// Exactly one of the three lists is ever populated, and that is a property of the query
|
||||||
|
// model rather than a coincidence worth defending against: track hits only exist in
|
||||||
|
// track-search mode, which forces the album list empty; the category list only exists
|
||||||
|
// when there is neither a query nor a chosen category, which is exactly when the album
|
||||||
|
// list is empty. So this shows one list filling the page, instead of stacking three
|
||||||
|
// inside a scroller and nesting a second scroller in it.
|
||||||
|
export component ResultsView inherits VerticalLayout {
|
||||||
|
in property <string> group-label;
|
||||||
|
in property <string> category-heading;
|
||||||
|
in property <string> album-heading;
|
||||||
|
in property <bool> show-search;
|
||||||
|
in property <string> search-text;
|
||||||
|
in property <string> mode-label;
|
||||||
|
in property <string> count-label;
|
||||||
|
in property <[TrackRow]> songs;
|
||||||
|
in property <[CategoryRow]> category-grid;
|
||||||
|
in property <[GridRow]> grid;
|
||||||
|
in property <int> sel-index;
|
||||||
|
/// Which row of the visible grid holds the selection. Every row is the same height
|
||||||
|
/// by construction, so following the keyboard is arithmetic, not measurement.
|
||||||
|
in property <int> sel-row;
|
||||||
|
in property <bool> empty;
|
||||||
|
callback play-song(string, int);
|
||||||
|
callback open-category(string);
|
||||||
|
callback open-album(string);
|
||||||
|
callback play-album(string);
|
||||||
|
|
||||||
|
spacing: 6px;
|
||||||
|
padding-left: 32px;
|
||||||
|
padding-right: Theme.rail-clearance;
|
||||||
|
|
||||||
|
if root.group-label != "": Text {
|
||||||
|
text: root.group-label;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: Theme.paper;
|
||||||
|
horizontal-alignment: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
if root.show-search: HorizontalLayout {
|
||||||
|
alignment: center;
|
||||||
|
spacing: 12px;
|
||||||
|
padding-bottom: 4px;
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
background: Theme.paper;
|
||||||
|
border-radius: Theme.pill;
|
||||||
|
drop-shadow-color: #00101759;
|
||||||
|
drop-shadow-blur: 14px;
|
||||||
|
drop-shadow-offset-y: 4px;
|
||||||
|
width: chip.preferred-width;
|
||||||
|
height: chip.preferred-height;
|
||||||
|
|
||||||
|
chip := HorizontalLayout {
|
||||||
|
padding-left: 10px;
|
||||||
|
padding-right: 20px;
|
||||||
|
padding-top: 10px;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
spacing: 10px;
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
background: Theme.ink;
|
||||||
|
border-radius: Theme.pill;
|
||||||
|
width: label.preferred-width;
|
||||||
|
label := HorizontalLayout {
|
||||||
|
padding-left: 10px;
|
||||||
|
padding-right: 10px;
|
||||||
|
padding-top: 4px;
|
||||||
|
padding-bottom: 4px;
|
||||||
|
Text {
|
||||||
|
text: root.mode-label;
|
||||||
|
font-size: Theme.text-small + 1px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
text: root.search-text;
|
||||||
|
font-size: Theme.text-heading;
|
||||||
|
font-weight: 800;
|
||||||
|
color: Theme.ink;
|
||||||
|
vertical-alignment: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
VerticalLayout {
|
||||||
|
alignment: center;
|
||||||
|
Text {
|
||||||
|
text: root.count-label;
|
||||||
|
font-size: Theme.text-small + 1px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: Theme.text-dim.with-alpha(0.85);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if root.empty: VerticalLayout {
|
||||||
|
alignment: center;
|
||||||
|
Text {
|
||||||
|
text: "Nichts gefunden — probier andere Buchstaben!";
|
||||||
|
font-size: Theme.text-heading;
|
||||||
|
font-weight: 800;
|
||||||
|
color: Theme.paper;
|
||||||
|
horizontal-alignment: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- track hits -----------------------------------------------------------
|
||||||
|
// Capped at 40 rows upstream, so this one does not need virtualizing. Held to a
|
||||||
|
// readable column rather than the full width, which is what lets a long album
|
||||||
|
// line elide instead of running the width of a 1080p screen. The heading rides
|
||||||
|
// inside that column so the two are aligned with each other, not with the page.
|
||||||
|
if root.songs.length > 0: Flickable {
|
||||||
|
content-height: songs-column.preferred-height + 210px;
|
||||||
|
content-width: self.width;
|
||||||
|
HorizontalLayout {
|
||||||
|
alignment: center;
|
||||||
|
songs-column := VerticalLayout {
|
||||||
|
// Without this the column is centred in whatever vertical space is
|
||||||
|
// left over, which on a short result list leaves it floating.
|
||||||
|
alignment: start;
|
||||||
|
width: 860px;
|
||||||
|
spacing: 8px;
|
||||||
|
Text {
|
||||||
|
text: "Titel";
|
||||||
|
font-size: Theme.text-heading;
|
||||||
|
font-weight: 900;
|
||||||
|
color: Theme.paper;
|
||||||
|
}
|
||||||
|
GlassPanel {
|
||||||
|
height: song-rows.preferred-height;
|
||||||
|
song-rows := VerticalLayout {
|
||||||
|
padding: 12px;
|
||||||
|
spacing: 6px;
|
||||||
|
for song in root.songs: TrackListRow {
|
||||||
|
track: song;
|
||||||
|
selected: root.sel-index == song.nav;
|
||||||
|
play => { root.play-song(song.album-id, song.index); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- categories -----------------------------------------------------------
|
||||||
|
if root.category-grid.length > 0: Text {
|
||||||
|
text: root.category-heading;
|
||||||
|
font-size: Theme.text-heading;
|
||||||
|
font-weight: 900;
|
||||||
|
color: Theme.paper;
|
||||||
|
}
|
||||||
|
if root.category-grid.length > 0: ListView {
|
||||||
|
for row in root.category-grid: HorizontalLayout {
|
||||||
|
height: Metrics.category-row-height;
|
||||||
|
spacing: Theme.grid-gap;
|
||||||
|
alignment: start;
|
||||||
|
for tile in row.tiles: CategoryCard {
|
||||||
|
tile: tile;
|
||||||
|
tile-width: Theme.card-width;
|
||||||
|
selected: root.sel-index == tile.nav;
|
||||||
|
open => { root.open-category(tile.key); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- albums ---------------------------------------------------------------
|
||||||
|
if root.grid.length > 0: Text {
|
||||||
|
text: root.album-heading;
|
||||||
|
font-size: Theme.text-heading;
|
||||||
|
font-weight: 900;
|
||||||
|
color: Theme.paper;
|
||||||
|
}
|
||||||
|
if root.grid.length > 0: album-list := ListView {
|
||||||
|
// Follow the keyboard without measuring anything.
|
||||||
|
content-y: -max(0px, min(
|
||||||
|
max(0px, self.content-height - self.height),
|
||||||
|
root.sel-row * Metrics.row-height));
|
||||||
|
animate content-y { duration: 180ms; easing: ease-out; }
|
||||||
|
|
||||||
|
for row in root.grid: HorizontalLayout {
|
||||||
|
height: Metrics.row-height;
|
||||||
|
spacing: Theme.grid-gap;
|
||||||
|
alignment: start;
|
||||||
|
for tile in row.tiles: AlbumCard {
|
||||||
|
width: Theme.card-width;
|
||||||
|
tile: tile;
|
||||||
|
selected: root.sel-index == tile.nav;
|
||||||
|
open => { root.open-album(tile.id); }
|
||||||
|
play => { root.play-album(tile.id); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
151
slint-frontend/ui/models.slint
Normal file
151
slint-frontend/ui/models.slint
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
// The shapes that cross between Rust and the UI.
|
||||||
|
//
|
||||||
|
// Everything here is already *presentation*: labels are formatted, durations are
|
||||||
|
// strings, covers are decoded images. Nothing in the .slint files formats a number or
|
||||||
|
// picks a word, so the German copy and the duration format live in one place (Rust)
|
||||||
|
// rather than being split across two languages.
|
||||||
|
//
|
||||||
|
// `nav` is each item's position in the single flat selection order that runs songs ->
|
||||||
|
// categories -> albums, so one pair of arrow keys walks the whole page. It is assigned
|
||||||
|
// in Rust, where the three lists are built together.
|
||||||
|
|
||||||
|
export struct AlbumTile {
|
||||||
|
id: string,
|
||||||
|
title: string,
|
||||||
|
artist: string,
|
||||||
|
// "12 Songs · 45:03 · 🧸" - already assembled, including the figurine marker.
|
||||||
|
meta: string,
|
||||||
|
cover: image,
|
||||||
|
is-book: bool,
|
||||||
|
locked: bool,
|
||||||
|
// This album is the one playing; drawn with the accent ring rather than the
|
||||||
|
// selection ring, and outranked by it when they coincide.
|
||||||
|
current: bool,
|
||||||
|
nav: int,
|
||||||
|
}
|
||||||
|
|
||||||
|
// The grid is handed to the UI pre-chunked into rows, because a `ListView` virtualizes
|
||||||
|
// its model and a flat list of 343 cards has no rows to skip. This is the direct
|
||||||
|
// counterpart of the web app's `content-visibility: auto` on `.grid > .card`, which
|
||||||
|
// exists for exactly the same measurement: 340 live cards saturated the Pi's renderer.
|
||||||
|
export struct GridRow {
|
||||||
|
tiles: [AlbumTile],
|
||||||
|
}
|
||||||
|
|
||||||
|
export struct CategoryTile {
|
||||||
|
key: string,
|
||||||
|
// "7 Hörbücher"
|
||||||
|
count-label: string,
|
||||||
|
// Up to four covers in a 2x2, or one filling the tile when the category holds a
|
||||||
|
// single album. Four fields rather than an array: the count is fixed and small, and
|
||||||
|
// this keeps the tile a plain struct.
|
||||||
|
cover1: image,
|
||||||
|
cover2: image,
|
||||||
|
cover3: image,
|
||||||
|
cover4: image,
|
||||||
|
cover-count: int,
|
||||||
|
is-book: bool,
|
||||||
|
nav: int,
|
||||||
|
}
|
||||||
|
|
||||||
|
export struct CategoryRow {
|
||||||
|
tiles: [CategoryTile],
|
||||||
|
}
|
||||||
|
|
||||||
|
// One of the three shelves on the root screen.
|
||||||
|
export struct Shelf {
|
||||||
|
label: string,
|
||||||
|
icon: string,
|
||||||
|
tiles: [CategoryTile],
|
||||||
|
empty-label: string,
|
||||||
|
}
|
||||||
|
|
||||||
|
export struct TrackRow {
|
||||||
|
title: string,
|
||||||
|
subtitle: string,
|
||||||
|
duration: string,
|
||||||
|
cover: image,
|
||||||
|
is-book: bool,
|
||||||
|
current: bool,
|
||||||
|
locked: bool,
|
||||||
|
album-id: string,
|
||||||
|
index: int,
|
||||||
|
nav: int,
|
||||||
|
}
|
||||||
|
|
||||||
|
// What the player bar and the full-screen view both say. Shared so the two cannot
|
||||||
|
// drift apart on a copy change.
|
||||||
|
export struct NowPlaying {
|
||||||
|
title: string,
|
||||||
|
subtitle: string,
|
||||||
|
// "Song 3 von 8" / "Kapitel 3 von 8"
|
||||||
|
counter: string,
|
||||||
|
cover: image,
|
||||||
|
hero-cover: image,
|
||||||
|
is-book: bool,
|
||||||
|
has-album: bool,
|
||||||
|
// "−4:12", the time left in this track.
|
||||||
|
clock-label: string,
|
||||||
|
// "Noch 38:20 im Album" - empty for a podcast, where it means nothing.
|
||||||
|
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,
|
||||||
|
}
|
||||||
413
slint-frontend/ui/player.slint
Normal file
413
slint-frontend/ui/player.slint
Normal file
@@ -0,0 +1,413 @@
|
|||||||
|
import { Theme } from "theme.slint";
|
||||||
|
import { Cover, Transport, ProgressBar, VolumeBars, RoundButton, GlassPanel } from "widgets.slint";
|
||||||
|
import { NowPlaying, TrackRow } from "models.slint";
|
||||||
|
|
||||||
|
// The bar along the bottom of the browse screen.
|
||||||
|
export component PlayerBar inherits Rectangle {
|
||||||
|
in property <NowPlaying> now;
|
||||||
|
in property <bool> playing;
|
||||||
|
in property <float> progress;
|
||||||
|
in property <bool> smooth;
|
||||||
|
in property <int> volume;
|
||||||
|
callback toggle();
|
||||||
|
callback next();
|
||||||
|
callback previous();
|
||||||
|
callback seek(float);
|
||||||
|
callback set-volume(int);
|
||||||
|
callback mute();
|
||||||
|
callback open-play-view();
|
||||||
|
|
||||||
|
height: 96px;
|
||||||
|
background: Theme.bar-bg;
|
||||||
|
|
||||||
|
// The accent rule along the top is what separates the bar from the stage without a
|
||||||
|
// shadow, which over a gradient reads as a smudge.
|
||||||
|
Rectangle {
|
||||||
|
y: 0;
|
||||||
|
height: 3px;
|
||||||
|
width: 100%;
|
||||||
|
background: Theme.accent.with-alpha(0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
HorizontalLayout {
|
||||||
|
padding-left: 24px;
|
||||||
|
padding-right: 24px;
|
||||||
|
padding-top: 16px;
|
||||||
|
padding-bottom: 16px;
|
||||||
|
spacing: 18px;
|
||||||
|
|
||||||
|
// Cover: the way back into the full-screen view.
|
||||||
|
VerticalLayout {
|
||||||
|
alignment: center;
|
||||||
|
Rectangle {
|
||||||
|
width: root.now.is-book ? 46px : 56px;
|
||||||
|
height: 56px;
|
||||||
|
Cover {
|
||||||
|
source: root.now.cover;
|
||||||
|
is-book: root.now.is-book;
|
||||||
|
radius: 10px;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
TouchArea {
|
||||||
|
clicked => { root.open-play-view(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
VerticalLayout {
|
||||||
|
alignment: center;
|
||||||
|
width: 240px;
|
||||||
|
Text {
|
||||||
|
text: root.now.title;
|
||||||
|
font-size: Theme.text-body;
|
||||||
|
font-weight: 800;
|
||||||
|
color: Theme.paper;
|
||||||
|
overflow: elide;
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
text: root.now.subtitle;
|
||||||
|
font-size: Theme.text-small;
|
||||||
|
font-weight: 600;
|
||||||
|
color: Theme.text-sub.with-alpha(0.75);
|
||||||
|
overflow: elide;
|
||||||
|
}
|
||||||
|
if root.now.has-album: Text {
|
||||||
|
text: root.now.counter;
|
||||||
|
font-size: Theme.text-tiny;
|
||||||
|
font-weight: 800;
|
||||||
|
color: Theme.accent-dim;
|
||||||
|
overflow: elide;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
VerticalLayout {
|
||||||
|
alignment: center;
|
||||||
|
horizontal-stretch: 1;
|
||||||
|
ProgressBar {
|
||||||
|
progress: root.progress;
|
||||||
|
smooth: root.smooth;
|
||||||
|
on-dark: true;
|
||||||
|
bar-height: 10px;
|
||||||
|
seek(fraction) => { root.seek(fraction); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
VerticalLayout {
|
||||||
|
alignment: center;
|
||||||
|
Transport {
|
||||||
|
playing: root.playing;
|
||||||
|
size: 48px;
|
||||||
|
toggle => { root.toggle(); }
|
||||||
|
next => { root.next(); }
|
||||||
|
previous => { root.previous(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
VerticalLayout {
|
||||||
|
alignment: center;
|
||||||
|
VolumeBars {
|
||||||
|
volume: root.volume;
|
||||||
|
bar-width: 11px;
|
||||||
|
bar-height: 30px;
|
||||||
|
set-volume(v) => { root.set-volume(v); }
|
||||||
|
mute => { root.mute(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The full-screen now-playing view.
|
||||||
|
export component PlayView inherits VerticalLayout {
|
||||||
|
in property <NowPlaying> now;
|
||||||
|
in property <bool> playing;
|
||||||
|
in property <float> progress;
|
||||||
|
in property <bool> smooth;
|
||||||
|
in property <int> volume;
|
||||||
|
callback toggle();
|
||||||
|
callback next();
|
||||||
|
callback previous();
|
||||||
|
callback seek(float);
|
||||||
|
callback set-volume(int);
|
||||||
|
callback mute();
|
||||||
|
callback open-album();
|
||||||
|
|
||||||
|
alignment: center;
|
||||||
|
spacing: 18px;
|
||||||
|
padding: 32px;
|
||||||
|
|
||||||
|
HorizontalLayout {
|
||||||
|
alignment: center;
|
||||||
|
Rectangle {
|
||||||
|
height: 320px;
|
||||||
|
width: root.now.is-book ? 262px : 320px;
|
||||||
|
drop-shadow-color: #0016208c;
|
||||||
|
drop-shadow-blur: 40px;
|
||||||
|
drop-shadow-offset-y: 20px;
|
||||||
|
|
||||||
|
if root.now.has-album: Cover {
|
||||||
|
source: root.now.hero-cover;
|
||||||
|
is-book: root.now.is-book;
|
||||||
|
radius: 28px;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
if !root.now.has-album: Rectangle {
|
||||||
|
background: #2a4f57;
|
||||||
|
border-radius: 28px;
|
||||||
|
}
|
||||||
|
TouchArea {
|
||||||
|
clicked => { root.open-album(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
VerticalLayout {
|
||||||
|
spacing: 6px;
|
||||||
|
Text {
|
||||||
|
text: root.now.title;
|
||||||
|
font-size: Theme.text-hero;
|
||||||
|
font-weight: 900;
|
||||||
|
color: Theme.paper;
|
||||||
|
horizontal-alignment: center;
|
||||||
|
wrap: word-wrap;
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
text: root.now.subtitle;
|
||||||
|
font-size: Theme.text-heading;
|
||||||
|
font-weight: 700;
|
||||||
|
color: Theme.text-dim.with-alpha(0.8);
|
||||||
|
horizontal-alignment: center;
|
||||||
|
overflow: elide;
|
||||||
|
}
|
||||||
|
if root.now.has-album: Text {
|
||||||
|
text: root.now.counter;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: Theme.accent-dim;
|
||||||
|
horizontal-alignment: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HorizontalLayout {
|
||||||
|
alignment: center;
|
||||||
|
VerticalLayout {
|
||||||
|
width: 680px;
|
||||||
|
spacing: 8px;
|
||||||
|
ProgressBar {
|
||||||
|
progress: root.progress;
|
||||||
|
smooth: root.smooth;
|
||||||
|
bar-height: 14px;
|
||||||
|
seek(fraction) => { root.seek(fraction); }
|
||||||
|
}
|
||||||
|
HorizontalLayout {
|
||||||
|
Text {
|
||||||
|
text: root.now.clock-label;
|
||||||
|
font-family: Theme.mono;
|
||||||
|
font-size: Theme.text-small + 1px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: Theme.text-dim.with-alpha(0.8);
|
||||||
|
}
|
||||||
|
Rectangle { horizontal-stretch: 1; }
|
||||||
|
Text {
|
||||||
|
text: root.now.remaining-label;
|
||||||
|
font-size: Theme.text-small + 1px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: Theme.text-dim.with-alpha(0.8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Transport {
|
||||||
|
playing: root.playing;
|
||||||
|
size: 72px;
|
||||||
|
toggle => { root.toggle(); }
|
||||||
|
next => { root.next(); }
|
||||||
|
previous => { root.previous(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
VolumeBars {
|
||||||
|
volume: root.volume;
|
||||||
|
bar-width: 18px;
|
||||||
|
bar-height: 44px;
|
||||||
|
set-volume(v) => { root.set-volume(v); }
|
||||||
|
mute => { root.mute(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The track list for one album, over a dimmed stage.
|
||||||
|
export component AlbumSheet inherits Rectangle {
|
||||||
|
in property <string> title;
|
||||||
|
in property <string> subtitle;
|
||||||
|
in property <string> meta;
|
||||||
|
in property <image> cover;
|
||||||
|
in property <bool> is-book;
|
||||||
|
in property <[TrackRow]> tracks;
|
||||||
|
in property <int> sel-index;
|
||||||
|
callback close();
|
||||||
|
callback play(int);
|
||||||
|
callback play-all();
|
||||||
|
|
||||||
|
background: Theme.backdrop.with-alpha(0.6);
|
||||||
|
|
||||||
|
// Catches every click that misses the sheet, so tapping the dimmed area closes it.
|
||||||
|
TouchArea {
|
||||||
|
clicked => { root.close(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
width: 640px;
|
||||||
|
height: min(root.height - 80px, 720px);
|
||||||
|
background: Theme.sheet-bg;
|
||||||
|
border-radius: Theme.radius-sheet;
|
||||||
|
drop-shadow-color: #0016208c;
|
||||||
|
drop-shadow-blur: 60px;
|
||||||
|
drop-shadow-offset-y: 24px;
|
||||||
|
|
||||||
|
// Without this the backdrop's TouchArea above would receive clicks aimed at the
|
||||||
|
// sheet's own background and close it.
|
||||||
|
TouchArea { }
|
||||||
|
|
||||||
|
VerticalLayout {
|
||||||
|
padding: 26px;
|
||||||
|
spacing: 16px;
|
||||||
|
|
||||||
|
HorizontalLayout {
|
||||||
|
spacing: 18px;
|
||||||
|
Cover {
|
||||||
|
source: root.cover;
|
||||||
|
is-book: root.is-book;
|
||||||
|
width: root.is-book ? 123px : 150px;
|
||||||
|
height: 150px;
|
||||||
|
}
|
||||||
|
VerticalLayout {
|
||||||
|
alignment: start;
|
||||||
|
spacing: 6px;
|
||||||
|
Text {
|
||||||
|
text: root.title;
|
||||||
|
font-size: Theme.text-title;
|
||||||
|
font-weight: 900;
|
||||||
|
color: Theme.ink;
|
||||||
|
wrap: word-wrap;
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
text: root.subtitle;
|
||||||
|
font-size: Theme.text-body;
|
||||||
|
font-weight: 700;
|
||||||
|
color: Theme.ink.with-alpha(0.7);
|
||||||
|
overflow: elide;
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
text: root.meta;
|
||||||
|
font-size: Theme.text-small;
|
||||||
|
font-weight: 800;
|
||||||
|
color: Theme.count-pink;
|
||||||
|
overflow: elide;
|
||||||
|
}
|
||||||
|
Rectangle {
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
Rectangle {
|
||||||
|
background: play-all-touch.pressed ? Theme.accent-dim : Theme.accent;
|
||||||
|
border-radius: Theme.pill;
|
||||||
|
width: play-all.preferred-width;
|
||||||
|
height: play-all.preferred-height;
|
||||||
|
play-all := HorizontalLayout {
|
||||||
|
padding-left: 18px;
|
||||||
|
padding-right: 18px;
|
||||||
|
padding-top: 9px;
|
||||||
|
padding-bottom: 9px;
|
||||||
|
Text {
|
||||||
|
text: "▶ Alle abspielen";
|
||||||
|
font-size: Theme.text-small + 1px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
play-all-touch := TouchArea {
|
||||||
|
clicked => { root.play-all(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
VerticalLayout {
|
||||||
|
alignment: start;
|
||||||
|
RoundButton {
|
||||||
|
label: "×";
|
||||||
|
size: 40px;
|
||||||
|
clicked => { root.close(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Flickable {
|
||||||
|
content-height: list.preferred-height;
|
||||||
|
content-width: self.width;
|
||||||
|
// Follow the keyboard through the list without measuring: every row is
|
||||||
|
// the same height, so the selected one's offset is arithmetic.
|
||||||
|
content-y: -max(0px, min(
|
||||||
|
max(0px, self.content-height - self.height),
|
||||||
|
root.sel-index * 52px - self.height / 2));
|
||||||
|
animate content-y { duration: 150ms; easing: ease-out; }
|
||||||
|
|
||||||
|
list := VerticalLayout {
|
||||||
|
spacing: 4px;
|
||||||
|
for track[i] in root.tracks: Rectangle {
|
||||||
|
height: 48px;
|
||||||
|
background: track.current ? Theme.accent.with-alpha(0.18) : transparent;
|
||||||
|
border-radius: 14px;
|
||||||
|
border-width: root.sel-index == i ? 3px : 0px;
|
||||||
|
border-color: Theme.accent;
|
||||||
|
|
||||||
|
HorizontalLayout {
|
||||||
|
padding-left: 14px;
|
||||||
|
padding-right: 14px;
|
||||||
|
spacing: 12px;
|
||||||
|
VerticalLayout {
|
||||||
|
alignment: center;
|
||||||
|
Rectangle {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
border-radius: 15px;
|
||||||
|
background: track.current ? Theme.accent : Theme.ink.with-alpha(0.12);
|
||||||
|
Text {
|
||||||
|
text: i + 1;
|
||||||
|
font-size: Theme.text-tiny;
|
||||||
|
font-weight: 800;
|
||||||
|
color: track.current ? white : Theme.ink;
|
||||||
|
vertical-alignment: center;
|
||||||
|
horizontal-alignment: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
VerticalLayout {
|
||||||
|
alignment: center;
|
||||||
|
Text {
|
||||||
|
text: track.title;
|
||||||
|
font-size: Theme.text-small + 1px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: Theme.ink;
|
||||||
|
overflow: elide;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
VerticalLayout {
|
||||||
|
alignment: center;
|
||||||
|
Text {
|
||||||
|
text: track.duration;
|
||||||
|
font-family: Theme.mono;
|
||||||
|
font-size: Theme.text-tiny;
|
||||||
|
font-weight: 700;
|
||||||
|
color: Theme.ink.with-alpha(0.6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TouchArea {
|
||||||
|
clicked => { root.play(i); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
460
slint-frontend/ui/room.slint
Normal file
460
slint-frontend/ui/room.slint
Normal 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"); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
107
slint-frontend/ui/theme.slint
Normal file
107
slint-frontend/ui/theme.slint
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
// The palette, converted once from the web front-end's oklch tokens
|
||||||
|
// (`web/src/styles/app.css`) into the sRGB hex Slint speaks. The oklch source value is
|
||||||
|
// kept beside each one, because that is the form the design is actually maintained in -
|
||||||
|
// if a colour needs adjusting, adjust the oklch and re-convert (`src/oklch.rs`), rather
|
||||||
|
// than nudging the hex and letting the two drift.
|
||||||
|
|
||||||
|
export global Theme {
|
||||||
|
// ------------------------------------------------------------------ colours --
|
||||||
|
out property <color> ink: #113339; // oklch(30% 0.04 210)
|
||||||
|
out property <color> paper: #eef7f9; // oklch(97% 0.01 210)
|
||||||
|
out property <color> accent: #de73bd; // oklch(70% 0.16 340)
|
||||||
|
out property <color> accent-dim: #f292d3; // oklch(78% 0.14 340)
|
||||||
|
out property <color> sea-deep: #001b21; // oklch(20% 0.045 210)
|
||||||
|
out property <color> shadow: #001017; // oklch(15% 0.05 210)
|
||||||
|
|
||||||
|
// The stage gradient, which in the web app sits under an animated canvas. There is
|
||||||
|
// no canvas here: a full-viewport repaint loop is the one cost a Pi cannot absorb,
|
||||||
|
// and `?pi=1` already removes it on the device this replaces.
|
||||||
|
out property <color> stage-top: #397d88; // oklch(55% 0.07 210)
|
||||||
|
out property <color> stage-mid: #0e4b54; // oklch(38% 0.06 210)
|
||||||
|
|
||||||
|
out property <color> text-dim: #c9dbdf; // oklch(88% 0.02 210)
|
||||||
|
out property <color> text-sub: #c0d2d5; // oklch(85% 0.02 210)
|
||||||
|
out property <color> count-pink: #7d0563; // oklch(40% 0.17 340)
|
||||||
|
|
||||||
|
out property <color> card-music: #e4f1f4; // oklch(95% 0.015 210)
|
||||||
|
out property <color> card-book: #ffd5ab; // oklch(92% 0.09 55)
|
||||||
|
out property <color> book-edge-1: #ffc298; // oklch(86% 0.09 55)
|
||||||
|
out property <color> book-edge-2: #e5a880; // oklch(78% 0.09 55)
|
||||||
|
|
||||||
|
out property <color> bar-bg: #00171e; // oklch(18% 0.05 210)
|
||||||
|
out property <color> sheet-bg: #e9f4f6; // oklch(96% 0.012 210)
|
||||||
|
out property <color> backdrop: #000e12; // oklch(15% 0.03 210)
|
||||||
|
// Two track colours, not one with an alpha: the seek bar sits on the pale stage in
|
||||||
|
// the play view and on the near-black player bar in the browse view, and a single
|
||||||
|
// translucent fill that reads correctly on one is invisible on the other.
|
||||||
|
out property <color> progress-track: #1b3236; // oklch(30% 0.03 210)
|
||||||
|
out property <color> progress-track-dark: #2e4f57;
|
||||||
|
out property <color> vol-empty: #c9dbdf; // oklch(88% 0.02 210)
|
||||||
|
|
||||||
|
// Glass, without the glass. `backdrop-filter` has no counterpart here, so these are
|
||||||
|
// exactly the web app's own `data-blur="off"` fallback: a flat translucent fill.
|
||||||
|
// That is a real loss of depth and the honest substitute for it - faking a blur by
|
||||||
|
// compositing a pre-blurred copy would reintroduce the per-frame cost the blur was
|
||||||
|
// dropped to avoid.
|
||||||
|
out property <brush> glass: @linear-gradient(160deg, #eef7f924 0%, #eef7f90d 100%);
|
||||||
|
out property <color> glass-border: #eef7f929;
|
||||||
|
out property <color> row-idle: #f7fcfd1a;
|
||||||
|
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 --
|
||||||
|
out property <length> radius-panel: 22px;
|
||||||
|
out property <length> radius-card: 16px;
|
||||||
|
out property <length> radius-row: 12px;
|
||||||
|
out property <length> radius-sheet: 26px;
|
||||||
|
out property <length> pill: 999px;
|
||||||
|
|
||||||
|
// The album grid's track width. The web grid is `minmax(180px, 1fr)`; keeping the
|
||||||
|
// same number keeps the same number of columns at 1920, which is what the shelf
|
||||||
|
// rows and the measured card height below were both tuned against.
|
||||||
|
out property <length> card-width: 180px;
|
||||||
|
out property <length> grid-gap: 24px;
|
||||||
|
out property <length> shelf-tile: 148px;
|
||||||
|
|
||||||
|
// How much of the right edge the page must leave clear for nothing to render
|
||||||
|
// underneath the corner controls.
|
||||||
|
out property <length> rail-clearance: 88px;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- typography --
|
||||||
|
// Heavy throughout: 700/800/900. Durations and counters use the monospace family so
|
||||||
|
// a ticking clock does not reflow the row it sits in.
|
||||||
|
out property <string> font: "Nunito";
|
||||||
|
out property <string> mono: "monospace";
|
||||||
|
|
||||||
|
out property <length> text-hero: 44px;
|
||||||
|
out property <length> text-title: 26px;
|
||||||
|
out property <length> text-heading: 20px;
|
||||||
|
out property <length> text-body: 16px;
|
||||||
|
out property <length> text-small: 13px;
|
||||||
|
out property <length> text-tiny: 12px;
|
||||||
|
}
|
||||||
299
slint-frontend/ui/widgets.slint
Normal file
299
slint-frontend/ui/widgets.slint
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
import { Theme } from "theme.slint";
|
||||||
|
|
||||||
|
// A frosted surface wrapped around a shelf or a list, so it reads as something
|
||||||
|
// floating over the stage rather than text on a gradient.
|
||||||
|
export component GlassPanel inherits Rectangle {
|
||||||
|
background: Theme.glass;
|
||||||
|
border-width: 1px;
|
||||||
|
border-color: Theme.glass-border;
|
||||||
|
border-radius: Theme.radius-panel;
|
||||||
|
drop-shadow-color: #00101759;
|
||||||
|
drop-shadow-blur: 24px;
|
||||||
|
drop-shadow-offset-y: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Album art. `source` is whatever the cover store has decoded; until it has one, the
|
||||||
|
// Rust side hands over generated art built from the album's own three colours, so this
|
||||||
|
// never has an empty frame to show.
|
||||||
|
//
|
||||||
|
// The shape comes from the media type and from nowhere else: square for an album,
|
||||||
|
// taller than wide for an audiobook, everywhere either appears.
|
||||||
|
export component Cover inherits Rectangle {
|
||||||
|
in property <image> source;
|
||||||
|
in property <bool> is-book: false;
|
||||||
|
in property <length> radius: Theme.radius-card;
|
||||||
|
|
||||||
|
clip: true;
|
||||||
|
border-radius: root.radius;
|
||||||
|
// A book keeps a sliver of its generated spine showing past real square artwork, so
|
||||||
|
// the shelf metaphor survives contact with a photograph.
|
||||||
|
background: root.is-book ? Theme.book-edge-2 : transparent;
|
||||||
|
|
||||||
|
Image {
|
||||||
|
source: root.source;
|
||||||
|
image-fit: cover;
|
||||||
|
width: root.is-book ? parent.width * 1.05 : parent.width;
|
||||||
|
height: parent.height;
|
||||||
|
x: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prev / next / play. Every glyph is drawn from rectangles and a clip, not an icon
|
||||||
|
// font: there is no icon font on the device, and a missing glyph is a blank button.
|
||||||
|
export component TransportButton inherits Rectangle {
|
||||||
|
in property <length> size: 48px;
|
||||||
|
in property <bool> primary: false;
|
||||||
|
in property <bool> playing: false;
|
||||||
|
in property <string> direction: "play"; // "play" | "next" | "previous"
|
||||||
|
callback clicked();
|
||||||
|
|
||||||
|
width: root.size;
|
||||||
|
height: root.size;
|
||||||
|
border-radius: root.size / 2;
|
||||||
|
background: root.primary
|
||||||
|
? (touch.pressed ? Theme.accent-dim : Theme.accent)
|
||||||
|
: (touch.pressed ? #2a4f57 : Theme.ink);
|
||||||
|
drop-shadow-color: root.primary ? #de73bd80 : #00101766;
|
||||||
|
drop-shadow-blur: root.primary ? 20px : 10px;
|
||||||
|
drop-shadow-offset-y: 4px;
|
||||||
|
animate background { duration: 100ms; }
|
||||||
|
|
||||||
|
// Pause: two bars. Play and the skips: a triangle, clipped out of a square.
|
||||||
|
if root.direction == "play" && root.playing: HorizontalLayout {
|
||||||
|
alignment: center;
|
||||||
|
spacing: root.size * 0.1;
|
||||||
|
Rectangle {
|
||||||
|
width: root.size * 0.1;
|
||||||
|
height: root.size * 0.34;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
Rectangle {
|
||||||
|
width: root.size * 0.1;
|
||||||
|
height: root.size * 0.34;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if root.direction != "play" || !root.playing: HorizontalLayout {
|
||||||
|
alignment: center;
|
||||||
|
spacing: root.size * 0.06;
|
||||||
|
// "Previous" is the same triangle mirrored, plus the bar it butts against.
|
||||||
|
if root.direction == "previous": Rectangle {
|
||||||
|
width: root.size * 0.07;
|
||||||
|
height: root.size * 0.3;
|
||||||
|
border-radius: 1px;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
Rectangle {
|
||||||
|
width: root.size * 0.3;
|
||||||
|
height: root.size * 0.3;
|
||||||
|
Path {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
fill: white;
|
||||||
|
viewbox-width: 100;
|
||||||
|
viewbox-height: 100;
|
||||||
|
commands: root.direction == "previous"
|
||||||
|
? "M 100 0 L 100 100 L 0 50 Z"
|
||||||
|
: "M 6 0 L 100 50 L 6 100 Z";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if root.direction == "next": Rectangle {
|
||||||
|
width: root.size * 0.07;
|
||||||
|
height: root.size * 0.3;
|
||||||
|
border-radius: 1px;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
touch := TouchArea {
|
||||||
|
clicked => { root.clicked(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export component Transport inherits HorizontalLayout {
|
||||||
|
in property <bool> playing;
|
||||||
|
in property <length> size: 48px;
|
||||||
|
callback toggle();
|
||||||
|
callback next();
|
||||||
|
callback previous();
|
||||||
|
|
||||||
|
alignment: center;
|
||||||
|
spacing: root.size * 0.21;
|
||||||
|
|
||||||
|
VerticalLayout {
|
||||||
|
alignment: center;
|
||||||
|
TransportButton {
|
||||||
|
direction: "previous";
|
||||||
|
size: root.size;
|
||||||
|
clicked => { root.previous(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
VerticalLayout {
|
||||||
|
alignment: center;
|
||||||
|
TransportButton {
|
||||||
|
direction: "play";
|
||||||
|
primary: true;
|
||||||
|
playing: root.playing;
|
||||||
|
// The play button is the one target a child aims at from across the room.
|
||||||
|
size: root.size * 1.26;
|
||||||
|
clicked => { root.toggle(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
VerticalLayout {
|
||||||
|
alignment: center;
|
||||||
|
TransportButton {
|
||||||
|
direction: "next";
|
||||||
|
size: root.size;
|
||||||
|
clicked => { root.next(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The seek bar.
|
||||||
|
//
|
||||||
|
// `position` is only pushed twice a second, so the fill is *animated* between frames
|
||||||
|
// rather than recomputed on a client-side clock. That puts the interpolation on the
|
||||||
|
// render side, where it costs a property evaluation per frame instead of a layout pass,
|
||||||
|
// and it is why there is no equivalent of the web client's `usePlaybackClock` here.
|
||||||
|
//
|
||||||
|
// The animation is suppressed whenever `smooth` goes false - while dragging, and for
|
||||||
|
// the frame a track changes on - because sliding the fill across two unrelated
|
||||||
|
// positions is exactly the "it moves slightly backwards" artefact the web UI has.
|
||||||
|
export component ProgressBar inherits Rectangle {
|
||||||
|
in property <float> progress; // 0..1
|
||||||
|
in property <bool> interactive: true;
|
||||||
|
in property <length> bar-height: 10px;
|
||||||
|
in property <bool> smooth: true;
|
||||||
|
/// Set on the player bar, whose background is darker than the seek bar's own
|
||||||
|
/// track would otherwise show against.
|
||||||
|
in property <bool> on-dark: false;
|
||||||
|
callback seek(float);
|
||||||
|
|
||||||
|
height: max(root.bar-height, 28px);
|
||||||
|
|
||||||
|
track := Rectangle {
|
||||||
|
y: (parent.height - root.bar-height) / 2;
|
||||||
|
height: root.bar-height;
|
||||||
|
width: 100%;
|
||||||
|
border-radius: root.bar-height / 2;
|
||||||
|
background: root.on-dark ? Theme.progress-track-dark : Theme.progress-track.with-alpha(0.6);
|
||||||
|
clip: true;
|
||||||
|
|
||||||
|
fill := Rectangle {
|
||||||
|
x: 0;
|
||||||
|
width: parent.width * clamp(root.progress, 0.0, 1.0);
|
||||||
|
height: 100%;
|
||||||
|
border-radius: root.bar-height / 2;
|
||||||
|
background: Theme.accent;
|
||||||
|
animate width {
|
||||||
|
duration: root.smooth ? 500ms : 0ms;
|
||||||
|
easing: linear;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A grab handle only while it is worth having one: at player-bar size the bar is
|
||||||
|
// the target, and a knob would be smaller than the thing it sits on.
|
||||||
|
if root.interactive && root.bar-height >= 12px: Rectangle {
|
||||||
|
x: max(0px, min(parent.width - self.width, track.width * clamp(root.progress, 0.0, 1.0) - self.width / 2));
|
||||||
|
y: (parent.height - self.height) / 2;
|
||||||
|
width: root.bar-height * 1.7;
|
||||||
|
height: root.bar-height * 1.7;
|
||||||
|
border-radius: self.width / 2;
|
||||||
|
background: Theme.paper;
|
||||||
|
drop-shadow-color: #00101773;
|
||||||
|
drop-shadow-blur: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
if root.interactive: TouchArea {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
// Both a click and a drag resolve to "the position under the finger", so
|
||||||
|
// scrubbing works without a separate drag state machine.
|
||||||
|
moved => {
|
||||||
|
if (self.pressed) { root.seek(clamp(self.mouse-x / track.width, 0.0, 1.0)); }
|
||||||
|
}
|
||||||
|
clicked => { root.seek(clamp(self.mouse-x / track.width, 0.0, 1.0)); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Volume as five discrete steps, not a continuous slider: a child aiming at a bar hits
|
||||||
|
// a level, where a slider would hand back whatever pixel they landed on.
|
||||||
|
export component VolumeBars inherits HorizontalLayout {
|
||||||
|
in property <int> volume; // 0..100
|
||||||
|
in property <length> bar-width: 11px;
|
||||||
|
in property <length> bar-height: 30px;
|
||||||
|
callback set-volume(int);
|
||||||
|
callback mute();
|
||||||
|
|
||||||
|
spacing: root.bar-width * 0.3;
|
||||||
|
alignment: center;
|
||||||
|
|
||||||
|
VerticalLayout {
|
||||||
|
alignment: center;
|
||||||
|
Rectangle {
|
||||||
|
width: root.bar-height * 0.8;
|
||||||
|
height: root.bar-height * 0.8;
|
||||||
|
border-radius: self.width / 2;
|
||||||
|
background: speaker.has-hover ? #ffffff26 : transparent;
|
||||||
|
Text {
|
||||||
|
text: root.volume == 0 ? "🔇" : "🔊";
|
||||||
|
font-size: root.bar-width * 1.6;
|
||||||
|
vertical-alignment: center;
|
||||||
|
horizontal-alignment: center;
|
||||||
|
}
|
||||||
|
speaker := TouchArea {
|
||||||
|
clicked => { root.mute(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for level[i] in [20, 40, 60, 80, 100]: VerticalLayout {
|
||||||
|
alignment: center;
|
||||||
|
Rectangle {
|
||||||
|
width: root.bar-width;
|
||||||
|
// Stepped heights, so the control reads as a volume ramp at a glance.
|
||||||
|
height: root.bar-height * (0.45 + 0.55 * (i + 1) / 5);
|
||||||
|
border-radius: 5px;
|
||||||
|
background: root.volume >= level ? Theme.accent : Theme.vol-empty.with-alpha(0.4);
|
||||||
|
animate background { duration: 120ms; }
|
||||||
|
TouchArea {
|
||||||
|
clicked => { root.set-volume(level); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A round icon button - back, close, and the fixed corner controls.
|
||||||
|
export component RoundButton inherits Rectangle {
|
||||||
|
in property <string> label;
|
||||||
|
in property <length> size: 44px;
|
||||||
|
in property <bool> highlighted: false;
|
||||||
|
callback clicked();
|
||||||
|
|
||||||
|
width: root.size;
|
||||||
|
height: root.size;
|
||||||
|
border-radius: root.size / 2;
|
||||||
|
background: root.highlighted
|
||||||
|
? Theme.accent
|
||||||
|
: (touch.pressed ? Theme.text-dim : Theme.paper.with-alpha(0.95));
|
||||||
|
drop-shadow-color: #00101766;
|
||||||
|
drop-shadow-blur: 18px;
|
||||||
|
drop-shadow-offset-y: 6px;
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: root.label;
|
||||||
|
font-size: root.size * 0.45;
|
||||||
|
font-weight: 900;
|
||||||
|
color: root.highlighted ? white : Theme.ink;
|
||||||
|
vertical-alignment: center;
|
||||||
|
horizontal-alignment: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
touch := TouchArea {
|
||||||
|
clicked => { root.clicked(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
21
todo.md
Normal file
21
todo.md
Normal 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
|
||||||
@@ -39,6 +39,14 @@ npx tsc --noEmit # strict, noUncheckedIndexedAccess
|
|||||||
Only the pure modules are tested - the search, the keyboard machine and the time
|
Only the pure modules are tested - the search, the keyboard machine and the time
|
||||||
formatting. That is where the behaviour lives; the components are mostly layout.
|
formatting. That is where the behaviour lives; the components are mostly layout.
|
||||||
|
|
||||||
|
`src/lib/search.ts` builds its normalized search keys once per library payload
|
||||||
|
(`buildSearchIndex`) rather than per keystroke. It matters more than it sounds: a real
|
||||||
|
collection is 343 albums and 4969 tracks, and `normalize()` does a Unicode NFD
|
||||||
|
decomposition, so track search used to mean five thousand of those before a single
|
||||||
|
comparison - 4 ms per keystroke on a laptop, and this runs on a Pi. The same build also
|
||||||
|
partitions albums by shelf and pre-sorts each shelf's categories, which `App` and
|
||||||
|
`BrowseView` were otherwise deriving separately from the same data.
|
||||||
|
|
||||||
## How it is put together
|
## How it is put together
|
||||||
|
|
||||||
The split that matters: **what is playing** comes from the backend, **what you are
|
The split that matters: **what is playing** comes from the backend, **what you are
|
||||||
@@ -52,7 +60,8 @@ are local.
|
|||||||
| Path | What it is |
|
| Path | What it is |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `src/lib/keyboard.ts` | The key map, as a pure function from a keypress to a list of actions. Testable without a DOM. |
|
| `src/lib/keyboard.ts` | The key map, as a pure function from a keypress to a list of actions. Testable without a DOM. |
|
||||||
| `src/lib/search.ts` | Filtering and the three-level browse hierarchy. The whole library is in memory, so this is instant. |
|
| `src/lib/search.ts` | Filtering and the three-level browse hierarchy. The whole library is in memory, so this is instant - see the note on `buildSearchIndex` below. |
|
||||||
|
| `src/lib/lowPower.ts` | The `?pi=1` profile: what gets cheaper, and by how much. |
|
||||||
| `src/lib/covers.ts` | Generated cover art from the album's three colours, and the book-spine treatment. |
|
| `src/lib/covers.ts` | Generated cover art from the album's three colours, and the book-spine treatment. |
|
||||||
| `src/hooks/usePlayerState.ts` | The websocket, with reconnect-and-reseed. |
|
| `src/hooks/usePlayerState.ts` | The websocket, with reconnect-and-reseed. |
|
||||||
| `src/hooks/usePlaybackClock.ts` | Interpolates the 2 Hz position onto animation frames. |
|
| `src/hooks/usePlaybackClock.ts` | Interpolates the 2 Hz position onto animation frames. |
|
||||||
@@ -84,6 +93,42 @@ pointer. `F1` shows the same list in-app.
|
|||||||
`SHIFT+arrows` is the one addition to the mockup: plain arrows were already taken, and
|
`SHIFT+arrows` is the one addition to the mockup: plain arrows were already taken, and
|
||||||
seeking is the one thing a real player can do that the mockup could not.
|
seeking is the one thing a real player can do that the mockup could not.
|
||||||
|
|
||||||
|
## The Raspberry Pi profile
|
||||||
|
|
||||||
|
`?pi=1` loads the same app with nothing on screen animating itself. It exists for
|
||||||
|
musicdolphin - a Pi 4 driving a 1920x1080 kiosk screen.
|
||||||
|
|
||||||
|
| | Full | `?pi=1` |
|
||||||
|
|---|---|---|
|
||||||
|
| Ambient canvas (gradient + bubbles) | on | **not rendered** |
|
||||||
|
| Decorative CSS loops, view transitions | on | off |
|
||||||
|
| Typing game bubbles, next-key pulse | on | off |
|
||||||
|
| Typing game pets | swimming | placed, held still |
|
||||||
|
| `backdrop-filter` on cards and list rows | on | off |
|
||||||
|
| `backdrop-filter` on panels | on | **on** |
|
||||||
|
| Progress-bar re-renders | every animation frame | 10 per second |
|
||||||
|
|
||||||
|
Two of those rows are worth knowing the reasons for, because both are the opposite of
|
||||||
|
what they look like.
|
||||||
|
|
||||||
|
**The panel blur stays on.** Dropping it measured *five times worse* on the device -
|
||||||
|
118% of a core against 25%. `backdrop-filter` is what promotes each glass panel to its
|
||||||
|
own compositing layer; without it the panels and everything under them collapse into
|
||||||
|
one layer that repaints wholesale. The card blur goes because that is a different
|
||||||
|
problem: three hundred of them in one grid, not one per panel.
|
||||||
|
|
||||||
|
**The canvas goes away rather than getting cheaper.** Half resolution and 30 fps took
|
||||||
|
it from 53.5% of a core to 25.0%, and 25% was still not good enough to use. A
|
||||||
|
`requestAnimationFrame` loop repainting the viewport is a floor you cannot get under
|
||||||
|
while it runs at all. `.stage` keeps its own static CSS gradient, so there is still a
|
||||||
|
sea behind everything - it just no longer moves or follows the track.
|
||||||
|
`AMBIENCE_QUALITY` in `src/lib/lowPower.ts` is kept accurate for whoever turns the
|
||||||
|
canvas back on.
|
||||||
|
|
||||||
|
The flag is read once at module load from the URL and nowhere else - no persistence, no
|
||||||
|
auto-detection. That is what makes "is this the profile or the hardware?" answerable by
|
||||||
|
editing the address bar. The kiosk gets it from `pi_kiosk_url` in the ansible repo.
|
||||||
|
|
||||||
## Parent mode
|
## Parent mode
|
||||||
|
|
||||||
`?parentMode=1` opens a settings panel - volume limits, the rotary step, button
|
`?parentMode=1` opens a settings panel - volume limits, the rotary step, button
|
||||||
|
|||||||
@@ -4,8 +4,9 @@
|
|||||||
// cached answer for any of those would be a lie.
|
// cached answer for any of those would be a lie.
|
||||||
|
|
||||||
const SHELL = "musikdelphin-shell-v1";
|
const SHELL = "musikdelphin-shell-v1";
|
||||||
const COVERS = "musikdelphin-covers-v1";
|
|
||||||
|
|
||||||
|
// There is no cover cache any more; `activate` below deletes every cache that is not
|
||||||
|
// SHELL, which retires the two generations of cover cache this worker used to keep.
|
||||||
self.addEventListener("install", (event) => {
|
self.addEventListener("install", (event) => {
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
caches
|
caches
|
||||||
@@ -29,7 +30,7 @@ self.addEventListener("activate", (event) => {
|
|||||||
caches
|
caches
|
||||||
.keys()
|
.keys()
|
||||||
.then((names) =>
|
.then((names) =>
|
||||||
Promise.all(names.filter((n) => n !== SHELL && n !== COVERS).map((n) => caches.delete(n))),
|
Promise.all(names.filter((n) => n !== SHELL).map((n) => caches.delete(n))),
|
||||||
)
|
)
|
||||||
.then(() => self.clients.claim()),
|
.then(() => self.clients.claim()),
|
||||||
);
|
);
|
||||||
@@ -42,19 +43,22 @@ self.addEventListener("fetch", (event) => {
|
|||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
if (url.origin !== self.location.origin) return;
|
if (url.origin !== self.location.origin) return;
|
||||||
|
|
||||||
// Cover art is immutable per album id and is the only heavy thing here.
|
// Cover art is NOT cached here, and that is deliberate. This worker only ever
|
||||||
if (url.pathname.startsWith("/api/albums/")) {
|
// registers on localhost or over HTTPS (see the README), which on this setup means
|
||||||
event.respondWith(
|
// the kiosk on the device itself - where the backend is the same machine, and a
|
||||||
caches.open(COVERS).then(async (cache) => {
|
// Cache Storage lookup plus a revalidating fetch plus a cache write is strictly more
|
||||||
const hit = await cache.match(request);
|
// work than just asking for the file.
|
||||||
if (hit) return hit;
|
//
|
||||||
const response = await fetch(request);
|
// It was measured, after a stale-while-revalidate version of this handler made
|
||||||
if (response.ok) cache.put(request, response.clone());
|
// searching visibly worse. Typing "conni" over a 343-album library, keydown to
|
||||||
return response;
|
// painted, worst keystroke: 5580 ms through this worker against 461 ms with
|
||||||
}),
|
// Network.setBypassServiceWorker on. The main thread was not running any JavaScript
|
||||||
);
|
// during those stalls - it sat waiting while ImageDecodeTask blocked on bytes this
|
||||||
return;
|
// worker was serialising through a single thread.
|
||||||
}
|
//
|
||||||
|
// If the player ever moves behind a TLS proxy for the tablet, revisit: caching covers
|
||||||
|
// is worth something over wifi, and would want a budget rather than every cover on
|
||||||
|
// every load.
|
||||||
|
|
||||||
// Everything else under /api is live state. Never cache it, never serve it stale.
|
// Everything else under /api is live state. Never cache it, never serve it stale.
|
||||||
if (url.pathname.startsWith("/api/")) return;
|
if (url.pathname.startsWith("/api/")) return;
|
||||||
|
|||||||
113
web/src/App.tsx
113
web/src/App.tsx
@@ -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,21 +25,26 @@ 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 { categoryMatches, groupOf, results as computeResults } from "./lib/search";
|
import { buildSearchIndex, categoryMatches, groupOf, results as computeResults } from "./lib/search";
|
||||||
import { SHOW_AMBIENCE, 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",
|
||||||
);
|
);
|
||||||
@@ -92,33 +104,46 @@ export function App() {
|
|||||||
setUi((previous) => (previous.cols === columns ? previous : { ...previous, cols: columns }));
|
setUi((previous) => (previous.cols === columns ? previous : { ...previous, cols: columns }));
|
||||||
}, [columns]);
|
}, [columns]);
|
||||||
|
|
||||||
|
// Normalized search keys, group partitions and each shelf's category list, derived
|
||||||
|
// once per library payload instead of per keystroke - see lib/search.ts. The library
|
||||||
|
// only reloads when the backend says a rescan finished, so this is genuinely rare.
|
||||||
|
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({
|
||||||
albums: library.albums,
|
index,
|
||||||
search: ui.search,
|
search: deferredSearch,
|
||||||
mode: ui.mode,
|
mode: ui.mode,
|
||||||
group: ui.group,
|
group: ui.group,
|
||||||
category: ui.category,
|
category: ui.category,
|
||||||
}),
|
}),
|
||||||
[library.albums, ui.search, ui.mode, ui.group, ui.category],
|
[index, deferredSearch, ui.mode, ui.group, ui.category],
|
||||||
);
|
);
|
||||||
|
|
||||||
// The bare root's three shelves each compute their own categories independently
|
// The bare root's three shelves, by category key only: the keyboard handler needs
|
||||||
// (see BrowseView) - the keyboard handler needs the same lists, by key only, to know
|
// these to know how far Ctrl+hjkl can move within a row and which category ENTER
|
||||||
// how far Ctrl+hjkl can move within a row and which category ENTER opens.
|
// opens. BrowseView renders the same shelves from the same precomputed lists.
|
||||||
const rootShelves = useMemo(
|
const rootShelves = useMemo(
|
||||||
() =>
|
() =>
|
||||||
GROUP_ORDER.map((group) =>
|
GROUP_ORDER.map((group) =>
|
||||||
categoryMatches({
|
categoryMatches({
|
||||||
albums: library.albums,
|
index,
|
||||||
search: "",
|
search: "",
|
||||||
mode: "albums",
|
mode: "albums",
|
||||||
category: null,
|
category: null,
|
||||||
group,
|
group,
|
||||||
}).map((entry) => entry.key),
|
}).map((entry) => entry.key),
|
||||||
),
|
),
|
||||||
[library.albums],
|
[index],
|
||||||
);
|
);
|
||||||
|
|
||||||
const byId = useMemo(
|
const byId = useMemo(
|
||||||
@@ -346,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") {
|
||||||
@@ -366,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;
|
||||||
@@ -387,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]);
|
||||||
|
|
||||||
@@ -407,7 +432,11 @@ export function App() {
|
|||||||
setUi((previous) => ({ ...previous, page: previous.page === page ? "music" : page }));
|
setUi((previous) => ({ ...previous, page: previous.page === page ? "music" : page }));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="stage" data-blur={SHOW_GLASS_BLUR ? undefined : "off"}>
|
<div
|
||||||
|
className="stage"
|
||||||
|
data-blur={SHOW_GLASS_BLUR ? undefined : "off"}
|
||||||
|
data-cardblur={SHOW_CARD_BLUR ? undefined : "off"}
|
||||||
|
>
|
||||||
{state && SHOW_AMBIENCE && (
|
{state && SHOW_AMBIENCE && (
|
||||||
<Ambience
|
<Ambience
|
||||||
album={currentAlbum}
|
album={currentAlbum}
|
||||||
@@ -432,9 +461,13 @@ export function App() {
|
|||||||
<BrowseView
|
<BrowseView
|
||||||
results={results}
|
results={results}
|
||||||
group={ui.group}
|
group={ui.group}
|
||||||
albums={library.albums}
|
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}
|
||||||
@@ -497,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
|
||||||
@@ -550,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 && (
|
||||||
|
|||||||
20
web/src/api/__tests__/client.test.ts
Normal file
20
web/src/api/__tests__/client.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import {
|
|||||||
type Oklch,
|
type Oklch,
|
||||||
} from "../lib/ambience";
|
} from "../lib/ambience";
|
||||||
import type { AmbienceTunables, ManualControl } from "../lib/ambienceTunables";
|
import type { AmbienceTunables, ManualControl } from "../lib/ambienceTunables";
|
||||||
|
import { AMBIENCE_QUALITY } from "../lib/lowPower";
|
||||||
import { groupOf, type Group } from "../lib/search";
|
import { groupOf, type Group } from "../lib/search";
|
||||||
|
|
||||||
/** Everything `?debugDynamicUI=1` wants to see: the raw analysis, what it maps to,
|
/** Everything `?debugDynamicUI=1` wants to see: the raw analysis, what it maps to,
|
||||||
@@ -247,16 +248,22 @@ export function Ambience({ album, state, tunables, manual, onDebugFrame }: Props
|
|||||||
// can't feed back into the size it just measured (see the comment there). The
|
// can't feed back into the size it just measured (see the comment there). The
|
||||||
// clamp is a second line of defence against the same failure mode from anywhere
|
// clamp is a second line of defence against the same failure mode from anywhere
|
||||||
// else: an unclamped size can hit the browser's canvas allocation limit and throw.
|
// else: an unclamped size can hit the browser's canvas allocation limit and throw.
|
||||||
const MAX_BACKING_STORE_PX = 4096;
|
//
|
||||||
|
// `resolutionScale` below 1 (the `?pi=1` profile) makes the backing store smaller
|
||||||
|
// than the canvas is on screen and lets the browser scale it back up. Everything
|
||||||
|
// in `tick` still works in CSS-pixel units, because the transform is derived from
|
||||||
|
// whatever the backing store ended up being rather than from `dpr`.
|
||||||
|
const quality = AMBIENCE_QUALITY;
|
||||||
const resize = () => {
|
const resize = () => {
|
||||||
const dpr = window.devicePixelRatio || 1;
|
const dpr = (window.devicePixelRatio || 1) * quality.resolutionScale;
|
||||||
const rect = canvas.getBoundingClientRect();
|
const rect = canvas.getBoundingClientRect();
|
||||||
size.width = rect.width;
|
size.width = rect.width;
|
||||||
size.height = rect.height;
|
size.height = rect.height;
|
||||||
canvas.width = Math.min(MAX_BACKING_STORE_PX, Math.max(1, Math.round(rect.width * dpr)));
|
const cap = quality.maxBackingStorePx;
|
||||||
canvas.height = Math.min(MAX_BACKING_STORE_PX, Math.max(1, Math.round(rect.height * dpr)));
|
canvas.width = Math.min(cap, Math.max(1, Math.round(rect.width * dpr)));
|
||||||
// Scale factors from the (possibly clamped) backing store, not raw `dpr`, so
|
canvas.height = Math.min(cap, Math.max(1, Math.round(rect.height * dpr)));
|
||||||
// drawing in CSS-pixel units still lands correctly even when clamped.
|
// Scale factors from the (possibly clamped, possibly downscaled) backing store,
|
||||||
|
// not raw `dpr`, so drawing in CSS-pixel units still lands correctly.
|
||||||
const scaleX = canvas.width / Math.max(1, rect.width);
|
const scaleX = canvas.width / Math.max(1, rect.width);
|
||||||
const scaleY = canvas.height / Math.max(1, rect.height);
|
const scaleY = canvas.height / Math.max(1, rect.height);
|
||||||
ctx.setTransform(scaleX, 0, 0, scaleY, 0, 0);
|
ctx.setTransform(scaleX, 0, 0, scaleY, 0, 0);
|
||||||
@@ -312,7 +319,16 @@ export function Ambience({ album, state, tunables, manual, onDebugFrame }: Props
|
|||||||
smoothedDrive,
|
smoothedDrive,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// A frame budget, not a timer: rAF still drives everything, this just returns
|
||||||
|
// early until enough time has passed. The 1ms slack stops a 30fps budget from
|
||||||
|
// landing just inside a 60Hz vsync interval and silently halving again to 20.
|
||||||
|
const minFrameMs = quality.maxFps > 0 ? 1000 / quality.maxFps - 1 : 0;
|
||||||
|
|
||||||
const tick = (now: number) => {
|
const tick = (now: number) => {
|
||||||
|
if (now - lastFrame < minFrameMs) {
|
||||||
|
raf = requestAnimationFrame(tick);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const dt = Math.min(MAX_FRAME_SECONDS, (now - lastFrame) / 1000);
|
const dt = Math.min(MAX_FRAME_SECONDS, (now - lastFrame) / 1000);
|
||||||
lastFrame = now;
|
lastFrame = now;
|
||||||
|
|
||||||
@@ -386,6 +402,9 @@ export function Ambience({ album, state, tunables, manual, onDebugFrame }: Props
|
|||||||
spawnAccumulator += (current.spawnRate + kick * BEAT_BURST_RATE) * dt;
|
spawnAccumulator += (current.spawnRate + kick * BEAT_BURST_RATE) * dt;
|
||||||
while (spawnAccumulator >= 1) {
|
while (spawnAccumulator >= 1) {
|
||||||
spawnAccumulator -= 1;
|
spawnAccumulator -= 1;
|
||||||
|
// The cap drops the *newest* bubble rather than evicting an old one, so a
|
||||||
|
// loud passage thins the field out instead of making it flicker.
|
||||||
|
if (quality.maxBubbles > 0 && bubbles.length >= quality.maxBubbles) break;
|
||||||
bubbles.push(spawnBubble(size.width));
|
bubbles.push(spawnBubble(size.width));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -402,7 +421,9 @@ export function Ambience({ album, state, tunables, manual, onDebugFrame }: Props
|
|||||||
return b.risen < size.height + BUBBLE_MAX_SIZE * 2;
|
return b.risen < size.height + BUBBLE_MAX_SIZE * 2;
|
||||||
});
|
});
|
||||||
|
|
||||||
ctx.clearRect(0, 0, size.width, size.height);
|
// No `clearRect`: the gradient below is fully opaque (`oklchString` defaults to
|
||||||
|
// alpha 1) and covers the whole canvas, so clearing first is a second
|
||||||
|
// full-screen write per frame that nothing can ever see.
|
||||||
const [top, mid, deep] = current.gradient;
|
const [top, mid, deep] = current.gradient;
|
||||||
const gradient = ctx.createLinearGradient(0, 0, 0, size.height);
|
const gradient = ctx.createLinearGradient(0, 0, 0, size.height);
|
||||||
gradient.addColorStop(0, oklchString(top));
|
gradient.addColorStop(0, oklchString(top));
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -18,8 +18,8 @@ import {
|
|||||||
unitLabel,
|
unitLabel,
|
||||||
} from "../lib/covers";
|
} from "../lib/covers";
|
||||||
import { clock } from "../lib/format";
|
import { clock } from "../lib/format";
|
||||||
import type { Category, Group, Results, 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,14 +32,26 @@ 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 {
|
||||||
results: Results;
|
results: Results;
|
||||||
group: Group | null;
|
group: Group | null;
|
||||||
albums: Album[];
|
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,
|
||||||
albums,
|
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,25 +124,21 @@ 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(
|
||||||
() =>
|
() =>
|
||||||
GROUPS.map((shelfGroup) => ({
|
GROUPS.map((shelfGroup) => ({
|
||||||
group: shelfGroup,
|
group: shelfGroup,
|
||||||
categories: categoryMatches({
|
categories: categoryMatches({
|
||||||
albums,
|
index,
|
||||||
search: "",
|
search: "",
|
||||||
mode: "albums",
|
mode: "albums",
|
||||||
category: null,
|
category: null,
|
||||||
group: shelfGroup,
|
group: shelfGroup,
|
||||||
}),
|
}),
|
||||||
})),
|
})),
|
||||||
[albums],
|
[index],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Keep the selection on screen as the arrow keys walk past the fold.
|
// Keep the selection on screen as the arrow keys walk past the fold.
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
158
web/src/components/PerfPanel.tsx
Normal file
158
web/src/components/PerfPanel.tsx
Normal 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 & 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
|
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
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";
|
||||||
|
|
||||||
@@ -37,8 +38,11 @@ export function AquariumCreatures({ creatures, opacity }: Props) {
|
|||||||
const present = useRef<ReadonlySet<CreatureId>>(new Set(creatures));
|
const present = useRef<ReadonlySet<CreatureId>>(new Set(creatures));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Reduced motion: every pet is still placed and shown, it just holds still.
|
// Reduced motion: every pet is still placed and shown, it just holds still. `?pi=1`
|
||||||
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
// 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.
|
||||||
|
const reducedMotion =
|
||||||
|
!PERF.petAnimations || window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||||
let frame = 0;
|
let frame = 0;
|
||||||
let lastTime = performance.now();
|
let lastTime = performance.now();
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
/** The gradient stage every screen sits on - the app's one full-height container.
|
/** The gradient stage every screen sits on - the app's one full-height container.
|
||||||
* `data-blur` is read by app.css to drop the backdrop filters wholesale.
|
* `data-blur` is read by app.css to drop the backdrop filters wholesale, and `data-anim`
|
||||||
|
* by tippen.css to stop the one CSS animation loop that is not a component's to switch
|
||||||
|
* off - the pulse on the next key to press.
|
||||||
*
|
*
|
||||||
* The pets swim here rather than on the home screen, so they stay with her on the lesson
|
* The pets swim here rather than on the home screen, so they stay with her on the lesson
|
||||||
* map and during a run too. */
|
* map and during a run too. */
|
||||||
@@ -7,6 +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 { 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";
|
||||||
@@ -21,7 +24,11 @@ interface Props {
|
|||||||
|
|
||||||
export function Stage({ children, creatures, dimmed }: Props) {
|
export function Stage({ children, creatures, dimmed }: Props) {
|
||||||
return (
|
return (
|
||||||
<div className="tp-stage" data-blur={SHOW_GLASS_BLUR ? "on" : "off"}>
|
<div
|
||||||
|
className="tp-stage"
|
||||||
|
data-blur={SHOW_GLASS_BLUR ? "on" : "off"}
|
||||||
|
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 />}
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
55
web/src/hooks/useCoverWarmup.ts
Normal file
55
web/src/hooks/useCoverWarmup.ts
Normal 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
17
web/src/hooks/useEvent.ts
Normal 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), []);
|
||||||
|
}
|
||||||
68
web/src/hooks/useIncrementalCount.ts
Normal file
68
web/src/hooks/useIncrementalCount.ts
Normal 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 };
|
||||||
|
}
|
||||||
@@ -7,6 +7,13 @@ also gives beat-synced animation the frame-accurate clock it will need later.
|
|||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
import { PLAYBACK_CLOCK_FPS } from "../lib/lowPower";
|
||||||
|
|
||||||
|
/** A render budget, not a timer - rAF still drives the loop, this only decides which
|
||||||
|
* frames are allowed to push state. The 1ms slack keeps a budget from landing just
|
||||||
|
* inside a vsync interval and silently halving the rate it asked for. */
|
||||||
|
const MIN_RENDER_MS = PLAYBACK_CLOCK_FPS > 0 ? 1000 / PLAYBACK_CLOCK_FPS - 1 : 0;
|
||||||
|
|
||||||
export function usePlaybackClock(position: number, playing: boolean): number {
|
export function usePlaybackClock(position: number, playing: boolean): number {
|
||||||
const [interpolated, setInterpolated] = useState(position);
|
const [interpolated, setInterpolated] = useState(position);
|
||||||
const anchor = useRef({ position, at: performance.now() });
|
const anchor = useRef({ position, at: performance.now() });
|
||||||
@@ -20,10 +27,13 @@ export function usePlaybackClock(position: number, playing: boolean): number {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!playing) return;
|
if (!playing) return;
|
||||||
let frame = 0;
|
let frame = 0;
|
||||||
const tick = () => {
|
let lastRender = 0;
|
||||||
|
const tick = (now: number) => {
|
||||||
|
frame = requestAnimationFrame(tick);
|
||||||
|
if (now - lastRender < MIN_RENDER_MS) return;
|
||||||
|
lastRender = now;
|
||||||
const { position: base, at } = anchor.current;
|
const { position: base, at } = anchor.current;
|
||||||
setInterpolated(base + (performance.now() - at) / 1000);
|
setInterpolated(base + (performance.now() - at) / 1000);
|
||||||
frame = requestAnimationFrame(tick);
|
|
||||||
};
|
};
|
||||||
frame = requestAnimationFrame(tick);
|
frame = requestAnimationFrame(tick);
|
||||||
return () => cancelAnimationFrame(frame);
|
return () => cancelAnimationFrame(frame);
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
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 { 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 {
|
||||||
return {
|
return {
|
||||||
@@ -40,9 +40,11 @@ const key = (k: string, over: Partial<Parameters<typeof handleKey>[0]> = {}) =>
|
|||||||
...over,
|
...over,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const INDEX = buildSearchIndex(ALBUMS);
|
||||||
|
|
||||||
const resultsFor = (ui: UiState) =>
|
const resultsFor = (ui: UiState) =>
|
||||||
computeResults({
|
computeResults({
|
||||||
albums: ALBUMS,
|
index: INDEX,
|
||||||
search: ui.search,
|
search: ui.search,
|
||||||
mode: ui.mode,
|
mode: ui.mode,
|
||||||
group: ui.group,
|
group: ui.group,
|
||||||
@@ -289,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: "" };
|
||||||
@@ -469,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 },
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
55
web/src/lib/__tests__/perfSettings.test.ts
Normal file
55
web/src/lib/__tests__/perfSettings.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
158
web/src/lib/__tests__/search.test.ts
Normal file
158
web/src/lib/__tests__/search.test.ts
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import type { Album, Track } from "../../api/types";
|
||||||
|
import {
|
||||||
|
buildSearchIndex,
|
||||||
|
effectiveSearch,
|
||||||
|
listMode,
|
||||||
|
MAX_SONG_HITS,
|
||||||
|
normalize,
|
||||||
|
results as computeResults,
|
||||||
|
type Group,
|
||||||
|
} from "../search";
|
||||||
|
|
||||||
|
function track(title: string, over: Partial<Track> = {}): Track {
|
||||||
|
return { title, duration: 60, analysis: null, locked: false, unlock_hint: null, ...over };
|
||||||
|
}
|
||||||
|
|
||||||
|
function album(id: string, over: Partial<Album> = {}): Album {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
section: "Musik",
|
||||||
|
kind: "music",
|
||||||
|
title: `Album ${id}`,
|
||||||
|
artist: "Kinderparty",
|
||||||
|
series: null,
|
||||||
|
figure: null,
|
||||||
|
category: "Kinderparty",
|
||||||
|
colors: ["#111111", "#222222", "#333333"],
|
||||||
|
has_cover: false,
|
||||||
|
duration: 120,
|
||||||
|
locked: false,
|
||||||
|
tracks: [track(`Lied ${id}`)],
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const ALBUMS: Album[] = [
|
||||||
|
album("a", { title: "Conni lernt Rad fahren", artist: "Conni", kind: "book", category: "Conni" }),
|
||||||
|
album("b", { title: "Käpt'n Krabbe", artist: "Rolf", category: "Rolf" }),
|
||||||
|
album("c", {
|
||||||
|
section: "Kinderpodcasts",
|
||||||
|
title: "GEOlino Spezial",
|
||||||
|
artist: "WDR",
|
||||||
|
category: "GEOlino",
|
||||||
|
tracks: [track("Der Vater der Chemie"), track("Geheime Folge", { locked: true })],
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const INDEX = buildSearchIndex(ALBUMS);
|
||||||
|
|
||||||
|
const query = (over: Partial<Parameters<typeof computeResults>[0]> = {}) =>
|
||||||
|
computeResults({ index: INDEX, search: "", mode: "albums", group: null, category: null, ...over });
|
||||||
|
|
||||||
|
describe("buildSearchIndex", () => {
|
||||||
|
it("buckets albums onto the three shelves by section and kind", () => {
|
||||||
|
const groups = Object.fromEntries(
|
||||||
|
(["music", "audiobooks", "podcasts"] as Group[]).map((g) => [
|
||||||
|
g,
|
||||||
|
INDEX.byGroup[g].map((e) => e.album.id),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(groups).toEqual({ music: ["b"], audiobooks: ["a"], podcasts: ["c"] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sorts each shelf's categories, and only its own", () => {
|
||||||
|
expect(INDEX.categoriesByGroup.music.map((c) => c.key)).toEqual(["Rolf"]);
|
||||||
|
expect(INDEX.categoriesByGroup.audiobooks.map((c) => c.key)).toEqual(["Conni"]);
|
||||||
|
expect(INDEX.categoriesByGroup.podcasts.map((c) => c.key)).toEqual(["GEOlino"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves locked tracks out entirely - they have no title to match on", () => {
|
||||||
|
expect(INDEX.tracks.map((t) => t.title)).not.toContain("Geheime Folge");
|
||||||
|
expect(INDEX.tracks).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stores the same normalized keys the matchers used to compute per keystroke", () => {
|
||||||
|
const conni = INDEX.entries.find((e) => e.album.id === "a")!;
|
||||||
|
expect(conni.haystack).toBe(normalize("Conni lernt Rad fahrenConni"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("albumMatches", () => {
|
||||||
|
it("finds words in any order, across title and artist", () => {
|
||||||
|
expect(query({ search: "conni rad" }).albums.map((a) => a.id)).toEqual(["a"]);
|
||||||
|
expect(query({ search: "rad conni" }).albums.map((a) => a.id)).toEqual(["a"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores punctuation and diacritics", () => {
|
||||||
|
expect(query({ search: "kaptn" }).albums.map((a) => a.id)).toEqual(["b"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stays inside the shelf once one is entered", () => {
|
||||||
|
expect(query({ search: "conni", group: "music" }).albums).toEqual([]);
|
||||||
|
expect(query({ search: "conni", group: "audiobooks" }).albums.map((a) => a.id)).toEqual(["a"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists a whole category with no search text", () => {
|
||||||
|
expect(query({ group: "music", category: "Rolf" }).albums.map((a) => a.id)).toEqual(["b"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows nothing at the bare root - that screen is the three shelves", () => {
|
||||||
|
expect(query().albums).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("songMatches", () => {
|
||||||
|
it("searches track titles, not album titles", () => {
|
||||||
|
const hits = query({ mode: "tracks", search: "vater" }).songs;
|
||||||
|
expect(hits.map((h) => [h.album.id, h.index, h.title])).toEqual([["c", 0, "Der Vater der Chemie"]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never returns a locked track", () => {
|
||||||
|
expect(query({ mode: "tracks", search: "geheime" }).songs).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caps a query that matches most of the library", () => {
|
||||||
|
const many = Array.from({ length: 60 }, (_, i) =>
|
||||||
|
album(`m${i}`, { tracks: [track(`Lied ${i}`)] }),
|
||||||
|
);
|
||||||
|
const index = buildSearchIndex(many);
|
||||||
|
const hits = computeResults({
|
||||||
|
index,
|
||||||
|
search: "lied",
|
||||||
|
mode: "tracks",
|
||||||
|
group: null,
|
||||||
|
category: null,
|
||||||
|
}).songs;
|
||||||
|
expect(hits).toHaveLength(MAX_SONG_HITS);
|
||||||
|
// Album order, then track order - the cap takes a prefix, it does not reshuffle.
|
||||||
|
expect(hits[0]!.title).toBe("Lied 0");
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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)`
|
||||||
|
|||||||
@@ -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 }];
|
||||||
|
|||||||
98
web/src/lib/lowPower.ts
Normal file
98
web/src/lib/lowPower.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
/** `?pi=1` - the same app, cut down to what a Raspberry Pi 4 can actually paint.
|
||||||
|
*
|
||||||
|
* The device this exists for is musicdolphin: a Pi 4 driving a 1920x1080 monitor
|
||||||
|
* through a Firefox kiosk window. The GPU is fine there (`V3D 4.2`, accelerated), but
|
||||||
|
* Firefox on Linux rasterizes 2D canvas in the content process, and the ambient canvas
|
||||||
|
* repaints the full screen every frame. Idle - nothing playing, nothing moving but the
|
||||||
|
* gradient - that was half a core in the content process alone.
|
||||||
|
*
|
||||||
|
* Everything here was measured on the device rather than reasoned about, and the
|
||||||
|
* reasoning lost. Sum of the Firefox process tree, idle on the browse screen, 15s
|
||||||
|
* average:
|
||||||
|
*
|
||||||
|
* full app 53.5%
|
||||||
|
* + ambient canvas at half resolution 25.0% <- the whole win
|
||||||
|
* + 30fps cap on top of that 25.0% (no idle change)
|
||||||
|
* + decorative CSS animation loops switched off 24.6% (no change; left on)
|
||||||
|
* + backdrop-filter glass blur switched off 118.1% <- 5x WORSE
|
||||||
|
*
|
||||||
|
* That last row is why this module is so much smaller than it started out. On this
|
||||||
|
* device `backdrop-filter` is load-bearing *for performance*: it promotes each glass
|
||||||
|
* panel to its own compositing layer, so a canvas frame underneath repaints only the
|
||||||
|
* canvas. Take the blur away and the canvas and everything stacked above it collapse
|
||||||
|
* into one layer, and every single frame repaints the whole page - all of the album
|
||||||
|
* grid included. The most expensive-looking CSS in the app is the thing keeping the
|
||||||
|
* rest of it cheap. Do not "optimize" it away without re-running the numbers above.
|
||||||
|
*
|
||||||
|
* That got it from 53.5% to 25%, and 25% was still not enough to use. The profile now
|
||||||
|
* goes further and stops the animation instead of thinning it: under `?pi=1` the
|
||||||
|
* ambient canvas is not rendered at all (`SHOW_AMBIENCE`), no bubbles rise anywhere,
|
||||||
|
* the decorative CSS loops are off, the view transitions are off, and the typing game's
|
||||||
|
* pets are placed but held still. A requestAnimationFrame loop that repaints the whole
|
||||||
|
* viewport is a floor you cannot get under while it runs at all, and on a Pi 4 that
|
||||||
|
* floor is too high. With it gone the browser has nothing to do between one keypress
|
||||||
|
* and the next. `.stage` keeps its own static CSS gradient, so the screen still has a
|
||||||
|
* sea behind it - it just no longer moves or follows the track.
|
||||||
|
*
|
||||||
|
* What stays: both blurs on the panels (see the table - dropping those was five times
|
||||||
|
* worse), and the card blur off, which is a count problem rather than a blur problem.
|
||||||
|
*
|
||||||
|
* Read once at module load, from the URL and nowhere else: no persistence, no
|
||||||
|
* auto-detection. The kiosk points Firefox at `http://localhost:8080/?pi=1` (see the
|
||||||
|
* `pi_kiosk_url` host var in the ansible repo) and a laptop opening the same page gets
|
||||||
|
* the full-fat version, which is what makes "is this the profile or the hardware?"
|
||||||
|
* answerable by editing the address bar.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { PERF } from "./perfSettings";
|
||||||
|
|
||||||
|
// Superseded as a single switch: every knob below and in theme.ts now comes from
|
||||||
|
// `PERF` (lib/perfSettings.ts), which `?pi=1` seeds and the settings dialog overrides.
|
||||||
|
// This header is kept for the measurements it records.
|
||||||
|
|
||||||
|
export interface AmbienceQuality {
|
||||||
|
/** Multiplies the canvas backing store relative to its on-screen size, before
|
||||||
|
* `devicePixelRatio`. At 0.5 the loop fills a quarter of the pixels and the browser
|
||||||
|
* scales the result back up. This is the one change that moved the number: 53.5% ->
|
||||||
|
* 25.0% idle. On a soft gradient with translucent circles drifting over it, the
|
||||||
|
* upscale is not something you can point at on the screen. */
|
||||||
|
resolutionScale: number;
|
||||||
|
/** Hard ceiling on either backing-store axis. Also a guard against a runaway
|
||||||
|
* measurement hitting the browser's canvas allocation limit. */
|
||||||
|
maxBackingStorePx: number;
|
||||||
|
/** Frames per second the loop will paint. 0 means "whatever the display offers".
|
||||||
|
* Worth nothing at idle once the resolution is halved - see the table above - and
|
||||||
|
* kept only for the playing case, where each frame also samples the mood curve and
|
||||||
|
* moves every bubble. The gradient eases over ~2s; nothing here has anything to say
|
||||||
|
* at 60fps it cannot say at 30. */
|
||||||
|
maxFps: number;
|
||||||
|
/** Ceiling on bubbles alive at once. 0 means unlimited. Spawn rate is driven by the
|
||||||
|
* music, so a loud track on a big screen can otherwise pile up more circles per
|
||||||
|
* frame than this device can draw. */
|
||||||
|
maxBubbles: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Unreachable under `?pi=1` as things stand, because `SHOW_AMBIENCE` switches the
|
||||||
|
* 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
|
||||||
|
* backing store is what took it from 53.5% to 25%. */
|
||||||
|
export const AMBIENCE_QUALITY: AmbienceQuality = {
|
||||||
|
resolutionScale: PERF.ambienceScale,
|
||||||
|
maxBackingStorePx: PERF.ambienceMaxPx,
|
||||||
|
maxFps: PERF.ambienceMaxFps,
|
||||||
|
maxBubbles: PERF.ambienceMaxBubbles,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 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.
|
||||||
|
*
|
||||||
|
* `usePlaybackClock` exists to stop the progress bar stepping twice a second, and it
|
||||||
|
* does that with a `setState` per animation frame - so `PlayView` and `PlayerBar` each
|
||||||
|
* re-render sixty times a second for the whole length of a track. A progress bar a
|
||||||
|
* thousand-odd pixels wide advances one pixel every few *hundred* milliseconds on a
|
||||||
|
* three-minute track, and the time label beside it only has one-second resolution, so
|
||||||
|
* ten updates a second is already more than either can show.
|
||||||
|
*
|
||||||
|
* Only bites while something is playing, so it is not in the idle table above.
|
||||||
|
*/
|
||||||
|
export const PLAYBACK_CLOCK_FPS = PERF.playbackClockFps;
|
||||||
147
web/src/lib/perfSettings.ts
Normal file
147
web/src/lib/perfSettings.ts
Normal 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();
|
||||||
@@ -2,6 +2,18 @@
|
|||||||
|
|
||||||
The whole index arrives in one response, so type-to-search has no round trip and feels
|
The whole index arrives in one response, so type-to-search has no round trip and feels
|
||||||
instant - which is the point of a keyboard-first UI. Ported from the design mockup.
|
instant - which is the point of a keyboard-first UI. Ported from the design mockup.
|
||||||
|
|
||||||
|
The cost that matters here is per keystroke, not per library load. A real collection is
|
||||||
|
343 albums and 4969 tracks, and the naive version of this module re-derived its search
|
||||||
|
keys from scratch on every single one of them every time a letter was typed:
|
||||||
|
`normalize()` runs `toLowerCase` + Unicode NFD decomposition + two regex passes, so
|
||||||
|
track search meant five thousand NFD normalizations before a single comparison. That is
|
||||||
|
work whose answer cannot change until the library itself changes.
|
||||||
|
|
||||||
|
So it is done once, in `buildSearchIndex`, and a keystroke is reduced to
|
||||||
|
`String.includes` over strings that already exist. The same build also partitions the
|
||||||
|
albums by group and pre-computes each group's sorted category list - which `App` and
|
||||||
|
`BrowseView` were otherwise both deriving, independently, from the same albums.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Album } from "../api/types";
|
import type { Album } from "../api/types";
|
||||||
@@ -41,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));
|
||||||
@@ -58,23 +80,122 @@ export function inGroup(album: Album, group: Group): boolean {
|
|||||||
return groupOf(album) === group;
|
return groupOf(album) === group;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** `null` means unrestricted - typing at the root searches every group at once. */
|
// ------------------------------------------------------------------- index --
|
||||||
export function pool(albums: Album[], group: Group | null): Album[] {
|
|
||||||
return group === null ? albums : albums.filter((album) => inGroup(album, group));
|
const GROUPS: readonly Group[] = ["music", "audiobooks", "podcasts"];
|
||||||
|
|
||||||
|
export interface AlbumEntry {
|
||||||
|
album: Album;
|
||||||
|
group: Group;
|
||||||
|
/** `normalize(title + artist)`, as `albumMatches` used to compute per keystroke. */
|
||||||
|
haystack: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BrowseQuery {
|
export interface TrackEntry {
|
||||||
|
album: Album;
|
||||||
|
/** Index within `album.tracks`, which is what `SongHit` and playback both want. */
|
||||||
|
index: number;
|
||||||
|
title: string;
|
||||||
|
duration: number;
|
||||||
|
group: Group;
|
||||||
|
haystack: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Everything a search needs, derived once per library payload. */
|
||||||
|
export interface SearchIndex {
|
||||||
|
/** The albums exactly as the backend sent them, for callers that want the plain list. */
|
||||||
albums: Album[];
|
albums: Album[];
|
||||||
|
entries: AlbumEntry[];
|
||||||
|
byGroup: Record<Group, AlbumEntry[]>;
|
||||||
|
/** Each group's categories, already grouped and sorted. Same array identity on every
|
||||||
|
* read, so a `useMemo` downstream of it stays stable. */
|
||||||
|
categoriesByGroup: Record<Group, Category[]>;
|
||||||
|
/** Unlocked tracks only, album order then track order. A locked track shows as a
|
||||||
|
* question mark wherever it appears, so it has no title to match on and is left out
|
||||||
|
* of the index entirely rather than skipped at match time. A reward being earned
|
||||||
|
* reloads the library, which rebuilds this. */
|
||||||
|
tracks: TrackEntry[];
|
||||||
|
tracksByGroup: Record<Group, TrackEntry[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyByGroup = <T,>(): Record<Group, T[]> => ({ music: [], audiobooks: [], podcasts: [] });
|
||||||
|
|
||||||
|
export function buildSearchIndex(albums: Album[]): SearchIndex {
|
||||||
|
const entries: AlbumEntry[] = [];
|
||||||
|
const byGroup = emptyByGroup<AlbumEntry>();
|
||||||
|
const tracks: TrackEntry[] = [];
|
||||||
|
const tracksByGroup = emptyByGroup<TrackEntry>();
|
||||||
|
const categoryMaps: Record<Group, Map<string, Category>> = {
|
||||||
|
music: new Map(),
|
||||||
|
audiobooks: new Map(),
|
||||||
|
podcasts: new Map(),
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const album of albums) {
|
||||||
|
const group = groupOf(album);
|
||||||
|
const entry: AlbumEntry = { album, group, haystack: normalize(album.title + album.artist) };
|
||||||
|
entries.push(entry);
|
||||||
|
byGroup[group].push(entry);
|
||||||
|
|
||||||
|
const categories = categoryMaps[group];
|
||||||
|
let category = categories.get(album.category);
|
||||||
|
if (!category) {
|
||||||
|
category = { key: album.category, albums: [] };
|
||||||
|
categories.set(album.category, category);
|
||||||
|
}
|
||||||
|
category.albums.push(album);
|
||||||
|
|
||||||
|
album.tracks.forEach((track, index) => {
|
||||||
|
if (track.locked) return;
|
||||||
|
const trackEntry: TrackEntry = {
|
||||||
|
album,
|
||||||
|
index,
|
||||||
|
title: track.title,
|
||||||
|
duration: track.duration,
|
||||||
|
group,
|
||||||
|
haystack: normalize(track.title),
|
||||||
|
};
|
||||||
|
tracks.push(trackEntry);
|
||||||
|
tracksByGroup[group].push(trackEntry);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const categoriesByGroup = emptyByGroup<Category>();
|
||||||
|
for (const group of GROUPS) {
|
||||||
|
categoriesByGroup[group] = [...categoryMaps[group].values()].sort((a, b) =>
|
||||||
|
a.key.localeCompare(b.key, "de"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { albums, entries, byGroup, categoriesByGroup, tracks, tracksByGroup };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** For the moment before the library has loaded, and for tests that don't need one. */
|
||||||
|
export const EMPTY_INDEX: SearchIndex = buildSearchIndex([]);
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------- queries --
|
||||||
|
|
||||||
|
export interface BrowseQuery {
|
||||||
|
index: SearchIndex;
|
||||||
search: string;
|
search: string;
|
||||||
mode: Mode;
|
mode: Mode;
|
||||||
group: Group | null;
|
group: Group | null;
|
||||||
category: string | null;
|
category: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** `null` group means unrestricted - typing at the root searches every group at once. */
|
||||||
|
function albumPool(query: BrowseQuery): AlbumEntry[] {
|
||||||
|
return query.group === null ? query.index.entries : query.index.byGroup[query.group];
|
||||||
|
}
|
||||||
|
|
||||||
|
function trackPool(query: BrowseQuery): TrackEntry[] {
|
||||||
|
return query.group === null ? query.index.tracks : query.index.tracksByGroup[query.group];
|
||||||
|
}
|
||||||
|
|
||||||
/** 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";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,48 +203,41 @@ export function categoryMatches(query: BrowseQuery): Category[] {
|
|||||||
// `group === null` is the bare root screen, rendered as three shelves instead of a
|
// `group === null` is the bare root screen, rendered as three shelves instead of a
|
||||||
// flat category list - each shelf calls this again with its own group filled in.
|
// flat category list - each shelf calls this again with its own group filled in.
|
||||||
if (listMode(query) !== "categories" || query.group === null) return [];
|
if (listMode(query) !== "categories" || query.group === null) return [];
|
||||||
const map = new Map<string, Category>();
|
return query.index.categoriesByGroup[query.group];
|
||||||
for (const album of pool(query.albums, query.group)) {
|
|
||||||
const key = album.category;
|
|
||||||
let entry = map.get(key);
|
|
||||||
if (!entry) {
|
|
||||||
entry = { key, albums: [] };
|
|
||||||
map.set(key, entry);
|
|
||||||
}
|
|
||||||
entry.albums.push(album);
|
|
||||||
}
|
|
||||||
return [...map.values()].sort((a, b) => a.key.localeCompare(b.key, "de"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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));
|
||||||
let candidates = pool(query.albums, query.group);
|
|
||||||
if (!words.length && !query.category) return [];
|
if (!words.length && !query.category) return [];
|
||||||
if (query.category) candidates = candidates.filter((a) => a.category === query.category);
|
let candidates = albumPool(query);
|
||||||
if (!words.length) return candidates;
|
if (query.category) candidates = candidates.filter((e) => e.album.category === query.category);
|
||||||
return candidates.filter((a) => matchesWords(normalize(a.title + a.artist), words));
|
if (words.length) candidates = candidates.filter((e) => matchesWords(e.haystack, words));
|
||||||
|
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 album of pool(query.albums, query.group)) {
|
for (const track of trackPool(query)) {
|
||||||
album.tracks.forEach((track, index) => {
|
if (!matchesWords(track.haystack, words)) continue;
|
||||||
// A locked track has no real title to search by - it shows as a question mark
|
hits.push({
|
||||||
// wherever it appears, so it has nothing useful to match here either.
|
album: track.album,
|
||||||
if (track.locked) return;
|
index: track.index,
|
||||||
if (!words.length || matchesWords(normalize(track.title), words)) {
|
title: track.title,
|
||||||
hits.push({ album, index, title: track.title, duration: track.duration });
|
duration: track.duration,
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
// Stopping at the cap rather than filling every hit and slicing is what keeps an
|
||||||
|
// empty query in tracks mode from walking all 4969 tracks for 40 rows.
|
||||||
if (hits.length >= MAX_SONG_HITS) break;
|
if (hits.length >= MAX_SONG_HITS) break;
|
||||||
}
|
}
|
||||||
return hits.slice(0, MAX_SONG_HITS);
|
return hits;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Results {
|
export interface Results {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
|
|
||||||
import type { CSSProperties } from "react";
|
import type { CSSProperties } from "react";
|
||||||
|
|
||||||
|
import { PERF } from "./perfSettings";
|
||||||
import type { Group } from "./search";
|
import type { Group } from "./search";
|
||||||
import type { UiState } from "./keyboard";
|
import type { UiState } from "./keyboard";
|
||||||
|
|
||||||
@@ -32,24 +33,46 @@ 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 = true;
|
export const ANIMATE_VIEW_TRANSITIONS = PERF.viewTransitions;
|
||||||
|
|
||||||
/** Frost the glass panels/cards/rows with a real backdrop blur. `backdrop-filter` is
|
/** Frost the glass panels/cards/rows with a real backdrop blur.
|
||||||
* one of the most GPU-expensive CSS effects in the app - a live backdrop copy+blur
|
*
|
||||||
* per element, repeated for every album card in the unvirtualized grid - so turn
|
* The obvious thing to cut on weak hardware, and on the Pi that is exactly wrong: it
|
||||||
* this off on weak hardware (e.g. an old Raspberry Pi) to fall back to a plain
|
* measured five times *worse* with the blur off (118% of a core against 25%). The blur
|
||||||
* translucent background with no blur. */
|
* is what promotes each panel to its own compositing layer; without it the animated
|
||||||
|
* canvas and the whole album grid above it share one layer and every canvas frame
|
||||||
|
* repaints all of it. See the table in lib/lowPower.ts before touching this. */
|
||||||
export const SHOW_GLASS_BLUR = true;
|
export const SHOW_GLASS_BLUR = true;
|
||||||
|
|
||||||
/** Render the play view's animated canvas background (gradient + bubbles, both
|
/** Render the play view's animated canvas background (gradient + bubbles, both
|
||||||
* redrawn every frame at 60fps). Off falls back to `.stage`'s own static CSS
|
* redrawn every frame). Off falls back to `.stage`'s own static CSS gradient, which is
|
||||||
* gradient, which is still underneath the canvas either way. */
|
* still underneath the canvas either way - so the screen keeps a background, it just
|
||||||
export const SHOW_AMBIENCE = true;
|
* stops being a per-track one and stops moving.
|
||||||
|
*
|
||||||
|
* Off under `?pi=1`. Halving its resolution and its frame rate first (see
|
||||||
|
* `AMBIENCE_QUALITY`) was not enough on the device this exists for: a requestAnimationFrame
|
||||||
|
* 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,
|
||||||
|
* so with this off the browser has nothing to do between one keypress and the next. */
|
||||||
|
export const SHOW_AMBIENCE = PERF.ambience;
|
||||||
|
|
||||||
|
/** 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.
|
||||||
|
*
|
||||||
|
* Separate from `SHOW_GLASS_BLUR` because the two behave nothing alike, and lumping
|
||||||
|
* them together is what made the first attempt at a Pi profile five times slower. A
|
||||||
|
* panel is one live backdrop copy that buys its whole subtree a compositing layer. A
|
||||||
|
* 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
|
||||||
|
* a full album grid on screen, Chromium: see lib/lowPower.ts. */
|
||||||
|
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. Individually cheap (opacity/transform only) but
|
* the dolphin mascot's bob/swim. Off under `?pi=1`. On their own they measured as
|
||||||
* free to cut on weak hardware. */
|
* noise, but "nothing on this screen moves by itself" is a property worth having
|
||||||
export const SHOW_DECORATIVE_ANIMATIONS = true;
|
* outright rather than a sum of small wins - a compositor with no animation to service
|
||||||
|
* has nothing to wake up for. */
|
||||||
|
export const SHOW_DECORATIVE_ANIMATIONS = PERF.decorativeAnimations;
|
||||||
|
|
||||||
// ------------------------------------------------------------------ colors --
|
// ------------------------------------------------------------------ colors --
|
||||||
|
|
||||||
|
|||||||
@@ -4,21 +4,32 @@
|
|||||||
* 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 { 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;
|
||||||
|
|
||||||
/** Frost the glass panels with a real backdrop blur. Expensive on weak GPUs. */
|
/** Frost the glass panels with a real backdrop blur. Looks like the first thing to cut
|
||||||
|
* on a weak GPU; on the Pi it measured five times worse with it off, because the blur is
|
||||||
|
* what gives each panel its own compositing layer. Same finding as the music app's
|
||||||
|
* matching toggle - see the table in ../lowPower.ts. */
|
||||||
export const SHOW_GLASS_BLUR = true;
|
export const SHOW_GLASS_BLUR = true;
|
||||||
|
|
||||||
/** The decorative rising bubbles behind everything. */
|
/** The decorative rising bubbles behind everything. A dozen elements on a CSS transform
|
||||||
export const SHOW_BUBBLES = true;
|
* loop - off under `?pi=1`, like every other loop in the app. */
|
||||||
|
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.
|
||||||
|
*
|
||||||
|
* Stays on under `?pi=1`, but the pets hold still there: `AquariumCreatures` already has
|
||||||
|
* that exact mode for `prefers-reduced-motion`, where every pet is placed and shown and
|
||||||
|
* simply does not swim. Taking the animation away is the point; taking away what she
|
||||||
|
* earned is not. */
|
||||||
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 = true;
|
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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -157,6 +156,17 @@ button {
|
|||||||
box-shadow: 0 4px 14px var(--shadow);
|
box-shadow: 0 4px 14px var(--shadow);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* `SHOW_CARD_BLUR` (`lib/theme.ts`) off sets `data-cardblur="off"` - drop the backdrop
|
||||||
|
blur from the surfaces there are *many* of, and only those. An album grid is up to
|
||||||
|
343 cards, each one a live backdrop copy sitting directly over the animated canvas,
|
||||||
|
so every canvas frame re-blurs all of them. The panels below keep their blur: a
|
||||||
|
panel is one copy, and it is what gives its whole subtree a compositing layer. */
|
||||||
|
.stage[data-cardblur="off"] .card,
|
||||||
|
.stage[data-cardblur="off"] .list-row {
|
||||||
|
backdrop-filter: none;
|
||||||
|
-webkit-backdrop-filter: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* `SHOW_GLASS_BLUR` (`lib/theme.ts`) off sets `data-blur="off"` on `.stage` - drop
|
/* `SHOW_GLASS_BLUR` (`lib/theme.ts`) off sets `data-blur="off"` on `.stage` - drop
|
||||||
the (GPU-expensive) backdrop blur everywhere below and fall back to each
|
the (GPU-expensive) backdrop blur everywhere below and fall back to each
|
||||||
element's own plain translucent background. */
|
element's own plain translucent background. */
|
||||||
@@ -199,6 +209,27 @@ button {
|
|||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Searching "e" over a real library puts 340 cards in this grid at once, each one a
|
||||||
|
cover image, a shadow and a backdrop blur, all of them live whether or not they are
|
||||||
|
on screen. On the Pi that saturated the renderer so completely that a DevTools
|
||||||
|
evaluate could not be scheduled on the main thread inside 30 seconds - which is
|
||||||
|
exactly what "sluggish" felt like.
|
||||||
|
|
||||||
|
`content-visibility: auto` is the browser's own answer to this and costs nothing
|
||||||
|
elsewhere: off-screen cards skip layout, paint and compositing entirely, and come
|
||||||
|
back the moment they scroll near the viewport. The grid's tracks are sized by
|
||||||
|
`minmax(180px, 1fr)` rather than by card content, so skipping that content cannot
|
||||||
|
change the column layout; `contain-intrinsic-size` supplies the block-axis guess
|
||||||
|
(a card measured 185x289 on the device) and `auto` then remembers each card's real
|
||||||
|
size once it has been rendered once, so scroll height and `offsetTop` - which the
|
||||||
|
keep-the-selection-on-screen effect in BrowseView reads - stay honest.
|
||||||
|
|
||||||
|
Deliberately not behind ?pi=1: there is no visual difference to trade away. */
|
||||||
|
.grid > .card {
|
||||||
|
content-visibility: auto;
|
||||||
|
contain-intrinsic-size: auto 289px;
|
||||||
|
}
|
||||||
|
|
||||||
.shelf-row {
|
.shelf-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
@@ -238,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);
|
||||||
@@ -250,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);
|
||||||
@@ -312,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; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -214,6 +214,12 @@
|
|||||||
animation: keyPulse 1.3s ease-out infinite;
|
animation: keyPulse 1.3s ease-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* `?pi=1` (`data-anim="off"`, set in Stage.tsx): the key still lifts and lightens, it
|
||||||
|
just stops breathing. The last animation loop left on that device. */
|
||||||
|
.tp-stage[data-anim="off"] .tp-kb-key[data-next="true"] {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
|
||||||
.tp-kb-key[data-home="true"]::after {
|
.tp-kb-key[data-home="true"]::after {
|
||||||
/* The tactile bump on F and J, drawn so it can be pointed at on screen too. */
|
/* The tactile bump on F and J, drawn so it can be pointed at on screen too. */
|
||||||
content: "";
|
content: "";
|
||||||
|
|||||||
Reference in New Issue
Block a user