Spaces:
Running on Zero
Running on Zero
Add song metadata handling and collection feature. Updated app.py to generate and store song metadata alongside audio files, introduced a new API endpoint for listing songs, and enhanced frontend components in ui.js and main.js to manage song collection display and interactions. Modified index.html and style.css for improved UI presentation of the cassette collection.
Browse files- app.py +28 -0
- frontend/index.html +172 -76
- frontend/main.js +36 -12
- frontend/style.css +424 -33
- frontend/ui.js +157 -10
app.py
CHANGED
|
@@ -19,6 +19,7 @@ import math
|
|
| 19 |
import os
|
| 20 |
import struct
|
| 21 |
import threading
|
|
|
|
| 22 |
import uuid
|
| 23 |
import wave
|
| 24 |
from pathlib import Path
|
|
@@ -217,6 +218,10 @@ def generate_song(prompt: str) -> dict:
|
|
| 217 |
print(f"[lofinity] brewing {title!r} :: {music_prompt}")
|
| 218 |
engine = stub_engine if ENGINE == "stub" else musicgen_engine
|
| 219 |
out = engine(music_prompt)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 220 |
return {
|
| 221 |
"title": title,
|
| 222 |
# url is what browser clients play from; the path alone only works
|
|
@@ -225,6 +230,29 @@ def generate_song(prompt: str) -> dict:
|
|
| 225 |
}
|
| 226 |
|
| 227 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 228 |
@app.get("/")
|
| 229 |
async def homepage():
|
| 230 |
return FileResponse(FRONTEND / "index.html")
|
|
|
|
| 19 |
import os
|
| 20 |
import struct
|
| 21 |
import threading
|
| 22 |
+
import time
|
| 23 |
import uuid
|
| 24 |
import wave
|
| 25 |
from pathlib import Path
|
|
|
|
| 218 |
print(f"[lofinity] brewing {title!r} :: {music_prompt}")
|
| 219 |
engine = stub_engine if ENGINE == "stub" else musicgen_engine
|
| 220 |
out = engine(music_prompt)
|
| 221 |
+
# sidecar metadata so the tape shows up in the collection later
|
| 222 |
+
out.with_suffix(".json").write_text(
|
| 223 |
+
json.dumps({"title": title, "prompt": prompt, "created": time.time()})
|
| 224 |
+
)
|
| 225 |
return {
|
| 226 |
"title": title,
|
| 227 |
# url is what browser clients play from; the path alone only works
|
|
|
|
| 230 |
}
|
| 231 |
|
| 232 |
|
| 233 |
+
@app.api(name="list_songs")
|
| 234 |
+
def list_songs() -> list:
|
| 235 |
+
"""Every tape actually vended, newest first. A wav without its sidecar
|
| 236 |
+
label is not a tape the user owns (stub tones, dev leftovers) — skip it."""
|
| 237 |
+
songs = []
|
| 238 |
+
for wav in SONGS_DIR.glob("*.wav"):
|
| 239 |
+
meta_path = wav.with_suffix(".json")
|
| 240 |
+
if not meta_path.exists():
|
| 241 |
+
continue
|
| 242 |
+
title, created = "Untitled Tape", wav.stat().st_mtime
|
| 243 |
+
try:
|
| 244 |
+
meta = json.loads(meta_path.read_text())
|
| 245 |
+
title = str(meta.get("title") or title)
|
| 246 |
+
created = float(meta.get("created") or created)
|
| 247 |
+
except (ValueError, OSError):
|
| 248 |
+
pass # a torn label, but the tape was vended — still plays fine
|
| 249 |
+
songs.append(
|
| 250 |
+
{"title": title, "created": created, "url": f"/gradio_api/file={wav}"}
|
| 251 |
+
)
|
| 252 |
+
songs.sort(key=lambda s: s["created"], reverse=True)
|
| 253 |
+
return songs
|
| 254 |
+
|
| 255 |
+
|
| 256 |
@app.get("/")
|
| 257 |
async def homepage():
|
| 258 |
return FileResponse(FRONTEND / "index.html")
|
frontend/index.html
CHANGED
|
@@ -1,89 +1,185 @@
|
|
| 1 |
<!doctype html>
|
| 2 |
<html lang="en">
|
| 3 |
-
<head>
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
}
|
| 16 |
-
|
| 17 |
-
</
|
| 18 |
-
<
|
| 19 |
-
<
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
<p id="subtitle">♪ chill beats, freshly vended</p>
|
| 23 |
-
</div>
|
| 24 |
-
<div id="vending-label" class="hover-label"><span>♪ vend a vibe</span></div>
|
| 25 |
-
<div id="collection-label" class="hover-label"><span>♪ cassette collection</span></div>
|
| 26 |
-
|
| 27 |
-
<div id="machine-modal" class="hidden">
|
| 28 |
-
<button id="modal-close" aria-label="close">×</button>
|
| 29 |
-
<div id="led-screen">
|
| 30 |
-
<p class="led-title">PICK YOUR VIBE<span class="led-cursor">_</span></p>
|
| 31 |
-
<p class="led-sub">TELL THE MACHINE WHAT TO BREW</p>
|
| 32 |
-
<textarea id="prompt-input" rows="3" maxlength="200"
|
| 33 |
-
placeholder="rainy night in kyoto, soft piano, vinyl crackle..."></textarea>
|
| 34 |
-
<div id="generating" class="hidden">
|
| 35 |
-
<div class="eq"><i></i><i></i><i></i><i></i><i></i></div>
|
| 36 |
-
<p class="led-status">BREWING YOUR BEAT<span class="dots">...</span></p>
|
| 37 |
-
<p class="led-sub">REAL TAPES TAKE A MINUTE OR TWO</p>
|
| 38 |
-
</div>
|
| 39 |
-
<p id="error-msg" class="hidden"></p>
|
| 40 |
</div>
|
| 41 |
-
<div id="
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
<svg id="wado-coin" viewBox="0 0 100 100" aria-hidden="true">
|
| 45 |
-
<defs>
|
| 46 |
-
<radialGradient id="bronze" cx="35%" cy="30%" r="80%">
|
| 47 |
-
<stop offset="0%" stop-color="#dcb670" />
|
| 48 |
-
<stop offset="65%" stop-color="#b08540" />
|
| 49 |
-
<stop offset="100%" stop-color="#8a6630" />
|
| 50 |
-
</radialGradient>
|
| 51 |
-
</defs>
|
| 52 |
-
<circle cx="50" cy="50" r="47" fill="url(#bronze)" stroke="#7a5a28" stroke-width="4" />
|
| 53 |
-
<circle cx="50" cy="50" r="38" fill="none" stroke="#8a6a35" stroke-width="1.5" opacity="0.6" />
|
| 54 |
-
<rect x="41" y="41" width="18" height="18" fill="#3b2c16" stroke="#7a5a28" stroke-width="3" />
|
| 55 |
-
<text x="50" y="26" text-anchor="middle" dominant-baseline="central" class="wado-kanji">和</text>
|
| 56 |
-
<text x="75" y="51" text-anchor="middle" dominant-baseline="central" class="wado-kanji">同</text>
|
| 57 |
-
<text x="50" y="76" text-anchor="middle" dominant-baseline="central" class="wado-kanji">開</text>
|
| 58 |
-
<text x="25" y="51" text-anchor="middle" dominant-baseline="central" class="wado-kanji">珎</text>
|
| 59 |
-
</svg>
|
| 60 |
-
</div>
|
| 61 |
-
<button id="coin-button">INSERT COIN</button>
|
| 62 |
</div>
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
</div>
|
|
|
|
| 71 |
</div>
|
| 72 |
-
<div id="
|
| 73 |
-
<
|
| 74 |
-
|
| 75 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
</div>
|
| 77 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
</div>
|
| 79 |
</div>
|
| 80 |
|
| 81 |
-
<div id="
|
| 82 |
-
<button id="
|
| 83 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
</div>
|
| 85 |
-
|
| 86 |
-
<
|
| 87 |
-
|
| 88 |
-
</
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
</html>
|
|
|
|
| 1 |
<!doctype html>
|
| 2 |
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
| 6 |
+
<title>LoFinity</title>
|
| 7 |
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
| 8 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
| 9 |
+
<link
|
| 10 |
+
href="https://fonts.googleapis.com/css2?family=Baloo+2:wght@600;800&family=DotGothic16&display=swap"
|
| 11 |
+
rel="stylesheet"
|
| 12 |
+
/>
|
| 13 |
+
<link rel="stylesheet" href="/static/style.css" />
|
| 14 |
+
<script type="importmap">
|
| 15 |
+
{
|
| 16 |
+
"imports": {
|
| 17 |
+
"three": "https://cdn.jsdelivr.net/npm/[email protected]/build/three.module.js"
|
| 18 |
+
}
|
| 19 |
}
|
| 20 |
+
</script>
|
| 21 |
+
</head>
|
| 22 |
+
<body>
|
| 23 |
+
<div id="title-overlay">
|
| 24 |
+
<h1 id="title">LoFinity</h1>
|
| 25 |
+
<p id="subtitle">♪ chill beats, freshly vended</p>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
</div>
|
| 27 |
+
<div id="vending-label" class="hover-label"><span>♪ vend a vibe</span></div>
|
| 28 |
+
<div id="collection-label" class="hover-label">
|
| 29 |
+
<span>♪ cassette collection</span>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
</div>
|
| 31 |
+
|
| 32 |
+
<div id="machine-modal" class="hidden">
|
| 33 |
+
<button id="modal-close" aria-label="close">×</button>
|
| 34 |
+
<div id="led-screen">
|
| 35 |
+
<p class="led-title">PICK YOUR VIBE<span class="led-cursor">_</span></p>
|
| 36 |
+
<p class="led-sub">TELL THE MACHINE WHAT TO BREW</p>
|
| 37 |
+
<textarea
|
| 38 |
+
id="prompt-input"
|
| 39 |
+
rows="3"
|
| 40 |
+
maxlength="200"
|
| 41 |
+
placeholder="rainy night in kyoto, soft piano, vinyl crackle..."
|
| 42 |
+
></textarea>
|
| 43 |
+
<div id="generating" class="hidden">
|
| 44 |
+
<div class="eq"><i></i><i></i><i></i><i></i><i></i></div>
|
| 45 |
+
<p class="led-status">
|
| 46 |
+
BREWING YOUR BEAT<span class="dots">...</span>
|
| 47 |
+
</p>
|
| 48 |
+
<p class="led-sub">REAL TAPES TAKE A MINUTE OR TWO</p>
|
| 49 |
</div>
|
| 50 |
+
<p id="error-msg" class="hidden"></p>
|
| 51 |
</div>
|
| 52 |
+
<div id="controls-row">
|
| 53 |
+
<div id="coin-slot-area">
|
| 54 |
+
<div id="coin-slot"></div>
|
| 55 |
+
<svg id="wado-coin" viewBox="0 0 100 100" aria-hidden="true">
|
| 56 |
+
<defs>
|
| 57 |
+
<radialGradient id="bronze" cx="35%" cy="30%" r="80%">
|
| 58 |
+
<stop offset="0%" stop-color="#dcb670" />
|
| 59 |
+
<stop offset="65%" stop-color="#b08540" />
|
| 60 |
+
<stop offset="100%" stop-color="#8a6630" />
|
| 61 |
+
</radialGradient>
|
| 62 |
+
</defs>
|
| 63 |
+
<circle
|
| 64 |
+
cx="50"
|
| 65 |
+
cy="50"
|
| 66 |
+
r="47"
|
| 67 |
+
fill="url(#bronze)"
|
| 68 |
+
stroke="#7a5a28"
|
| 69 |
+
stroke-width="4"
|
| 70 |
+
/>
|
| 71 |
+
<circle
|
| 72 |
+
cx="50"
|
| 73 |
+
cy="50"
|
| 74 |
+
r="38"
|
| 75 |
+
fill="none"
|
| 76 |
+
stroke="#8a6a35"
|
| 77 |
+
stroke-width="1.5"
|
| 78 |
+
opacity="0.6"
|
| 79 |
+
/>
|
| 80 |
+
<rect
|
| 81 |
+
x="41"
|
| 82 |
+
y="41"
|
| 83 |
+
width="18"
|
| 84 |
+
height="18"
|
| 85 |
+
fill="#3b2c16"
|
| 86 |
+
stroke="#7a5a28"
|
| 87 |
+
stroke-width="3"
|
| 88 |
+
/>
|
| 89 |
+
<text
|
| 90 |
+
x="50"
|
| 91 |
+
y="26"
|
| 92 |
+
text-anchor="middle"
|
| 93 |
+
dominant-baseline="central"
|
| 94 |
+
class="wado-kanji"
|
| 95 |
+
>
|
| 96 |
+
和
|
| 97 |
+
</text>
|
| 98 |
+
<text
|
| 99 |
+
x="75"
|
| 100 |
+
y="51"
|
| 101 |
+
text-anchor="middle"
|
| 102 |
+
dominant-baseline="central"
|
| 103 |
+
class="wado-kanji"
|
| 104 |
+
>
|
| 105 |
+
同
|
| 106 |
+
</text>
|
| 107 |
+
<text
|
| 108 |
+
x="50"
|
| 109 |
+
y="76"
|
| 110 |
+
text-anchor="middle"
|
| 111 |
+
dominant-baseline="central"
|
| 112 |
+
class="wado-kanji"
|
| 113 |
+
>
|
| 114 |
+
開
|
| 115 |
+
</text>
|
| 116 |
+
<text
|
| 117 |
+
x="25"
|
| 118 |
+
y="51"
|
| 119 |
+
text-anchor="middle"
|
| 120 |
+
dominant-baseline="central"
|
| 121 |
+
class="wado-kanji"
|
| 122 |
+
>
|
| 123 |
+
珎
|
| 124 |
+
</text>
|
| 125 |
+
</svg>
|
| 126 |
+
</div>
|
| 127 |
+
<button id="coin-button">INSERT COIN</button>
|
| 128 |
+
</div>
|
| 129 |
+
<div id="cassette-stage" class="hidden">
|
| 130 |
+
<div id="cassette">
|
| 131 |
+
<div class="cassette-label"><span id="cassette-title"></span></div>
|
| 132 |
+
<div class="reels">
|
| 133 |
+
<span class="reel"></span>
|
| 134 |
+
<span class="tape-window"></span>
|
| 135 |
+
<span class="reel"></span>
|
| 136 |
+
</div>
|
| 137 |
+
</div>
|
| 138 |
+
<div id="player">
|
| 139 |
+
<button id="play-btn" aria-label="play/pause">▶</button>
|
| 140 |
+
<div id="progress"><div id="progress-fill"></div></div>
|
| 141 |
+
<span id="time">0:00</span>
|
| 142 |
+
</div>
|
| 143 |
+
<button id="again-btn">MAKE ANOTHER</button>
|
| 144 |
</div>
|
| 145 |
+
</div>
|
| 146 |
+
|
| 147 |
+
<div id="collection-panel" class="hidden">
|
| 148 |
+
<p id="collection-heading"><span>♪ cassette collection</span></p>
|
| 149 |
+
<button id="collection-close" aria-label="close collection">×</button>
|
| 150 |
+
<p id="collection-status" class="hidden"></p>
|
| 151 |
+
<div id="carousel-row">
|
| 152 |
+
<button class="caro-btn" id="caro-prev" aria-label="scroll left">‹</button>
|
| 153 |
+
<div id="carousel"></div>
|
| 154 |
+
<button class="caro-btn" id="caro-next" aria-label="scroll right">›</button>
|
| 155 |
</div>
|
| 156 |
</div>
|
| 157 |
|
| 158 |
+
<div id="tape-deck" class="hidden">
|
| 159 |
+
<button id="deck-play" aria-label="play/pause">▶</button>
|
| 160 |
+
<div id="deck-info">
|
| 161 |
+
<span id="deck-title">pick a tape</span>
|
| 162 |
+
<div id="deck-progress"><div id="deck-fill"></div></div>
|
| 163 |
+
</div>
|
| 164 |
+
<span id="deck-time">0:00</span>
|
| 165 |
+
<div id="deck-cassette" aria-hidden="true">
|
| 166 |
+
<span class="mini-reel"></span>
|
| 167 |
+
<span class="mini-reel"></span>
|
| 168 |
+
</div>
|
| 169 |
+
<a id="deck-download" class="disabled" aria-label="download tape" title="download tape">
|
| 170 |
+
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor"
|
| 171 |
+
stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
| 172 |
+
<path d="M12 3v12" /><path d="m6 11 6 6 6-6" /><path d="M5 21h14" />
|
| 173 |
+
</svg>
|
| 174 |
+
</a>
|
| 175 |
</div>
|
| 176 |
+
|
| 177 |
+
<div id="now-playing" class="hidden">
|
| 178 |
+
<button id="np-toggle" aria-label="play/pause">❚❚</button>
|
| 179 |
+
<span id="np-title"></span>
|
| 180 |
+
</div>
|
| 181 |
+
<audio id="tape-audio"></audio>
|
| 182 |
+
<canvas id="scene"></canvas>
|
| 183 |
+
<script type="module" src="/static/main.js"></script>
|
| 184 |
+
</body>
|
| 185 |
</html>
|
frontend/main.js
CHANGED
|
@@ -32,7 +32,7 @@ const camera = new THREE.PerspectiveCamera(
|
|
| 32 |
55,
|
| 33 |
window.innerWidth / window.innerHeight,
|
| 34 |
0.1,
|
| 35 |
-
1000
|
| 36 |
);
|
| 37 |
|
| 38 |
// The camera starts high in the sky gaze and dollies down/back during the
|
|
@@ -160,7 +160,7 @@ const interactables = [
|
|
| 160 |
window.addEventListener("pointermove", (e) => {
|
| 161 |
pointerNDC.set(
|
| 162 |
(e.clientX / window.innerWidth) * 2 - 1,
|
| 163 |
-
-(e.clientY / window.innerHeight) * 2 + 1
|
| 164 |
);
|
| 165 |
pointerActive = true;
|
| 166 |
});
|
|
@@ -190,8 +190,8 @@ function updateHover() {
|
|
| 190 |
raycaster.setFromCamera(pointerNDC, camera);
|
| 191 |
setHoveredItem(
|
| 192 |
interactables.find(
|
| 193 |
-
(item) => raycaster.intersectObject(item.object, true).length > 0
|
| 194 |
-
) ?? null
|
| 195 |
);
|
| 196 |
|
| 197 |
if (hoveredItem) {
|
|
@@ -253,6 +253,7 @@ function closeMachineView() {
|
|
| 253 |
}
|
| 254 |
|
| 255 |
function closeBenchView() {
|
|
|
|
| 256 |
startViewTransition("zoom-out", CAM_END, LOOK_SCENE);
|
| 257 |
}
|
| 258 |
|
|
@@ -286,15 +287,23 @@ function setMachineBusy(busy) {
|
|
| 286 |
// ---------------------------------------------------------------------------
|
| 287 |
|
| 288 |
let generateFn = null;
|
|
|
|
| 289 |
|
| 290 |
const ui = initUI({
|
| 291 |
generate: (prompt) => {
|
| 292 |
if (!generateFn) return Promise.reject(new Error("backend not connected"));
|
| 293 |
return generateFn(prompt);
|
| 294 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
| 295 |
onRequestClose: () => {
|
| 296 |
if (view.mode === "machine") closeMachineView();
|
| 297 |
},
|
|
|
|
|
|
|
|
|
|
| 298 |
onGeneratingChange: setMachineBusy,
|
| 299 |
});
|
| 300 |
|
|
@@ -305,11 +314,16 @@ import("https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js")
|
|
| 305 |
const result = await client.predict("/generate_song", { prompt });
|
| 306 |
const data = result.data[0];
|
| 307 |
const url =
|
| 308 |
-
data.audio?.url ??
|
|
|
|
| 309 |
if (!url) throw new Error("no audio in response");
|
| 310 |
return { title: data.title, url };
|
| 311 |
};
|
| 312 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 313 |
console.log("[LoFinity] backend connected");
|
| 314 |
})
|
| 315 |
.catch((err) => console.warn("[LoFinity] backend not reachable:", err));
|
|
@@ -335,10 +349,10 @@ function startDescent() {
|
|
| 335 |
const pageLoaded = new Promise((resolve) =>
|
| 336 |
document.readyState === "complete"
|
| 337 |
? resolve()
|
| 338 |
-
: window.addEventListener("load", resolve)
|
| 339 |
);
|
| 340 |
Promise.all([pageLoaded, document.fonts.ready]).then(() =>
|
| 341 |
-
setTimeout(startDescent, HOLD_MS)
|
| 342 |
);
|
| 343 |
|
| 344 |
const easeInOutCubic = (t) =>
|
|
@@ -374,6 +388,7 @@ window.lofinityDebug = {
|
|
| 374 |
view.mode = "bench";
|
| 375 |
camera.position.copy(BENCH_CAM);
|
| 376 |
lookTarget.copy(BENCH_LOOK);
|
|
|
|
| 377 |
},
|
| 378 |
stats() {
|
| 379 |
return { ...renderer.info.render, ...renderer.info.memory };
|
|
@@ -427,7 +442,8 @@ function animate(now = 0) {
|
|
| 427 |
bird.rotation.y =
|
| 428 |
-0.5 + Math.sin(elapsed * 0.4) * 0.4 + Math.sin(elapsed * 1.1) * 0.12;
|
| 429 |
bird.position.y =
|
| 430 |
-
bird.userData.baseY +
|
|
|
|
| 431 |
bird.userData.tail.rotation.x =
|
| 432 |
-0.35 + Math.pow(Math.max(0, Math.sin(elapsed * 1.3 + 1)), 8) * 0.5;
|
| 433 |
|
|
@@ -452,6 +468,7 @@ function animate(now = 0) {
|
|
| 452 |
if (view.mode === "zoom-in") {
|
| 453 |
view.mode = view.target;
|
| 454 |
if (view.target === "machine") ui.openModal();
|
|
|
|
| 455 |
} else {
|
| 456 |
view.mode = "scene";
|
| 457 |
intro.idleStart = elapsed; // sway ramps back in
|
|
@@ -468,8 +485,16 @@ function animate(now = 0) {
|
|
| 468 |
if (machineBusy) {
|
| 469 |
// the machine hums while it brews
|
| 470 |
const pulse = 0.5 + 0.5 * Math.sin(elapsed * 6);
|
| 471 |
-
world.vending.userData.screenMaterial.color.setHSL(
|
| 472 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 473 |
}
|
| 474 |
|
| 475 |
camera.lookAt(lookTarget);
|
|
@@ -477,4 +502,3 @@ function animate(now = 0) {
|
|
| 477 |
renderer.render(scene, camera);
|
| 478 |
}
|
| 479 |
animate();
|
| 480 |
-
|
|
|
|
| 32 |
55,
|
| 33 |
window.innerWidth / window.innerHeight,
|
| 34 |
0.1,
|
| 35 |
+
1000,
|
| 36 |
);
|
| 37 |
|
| 38 |
// The camera starts high in the sky gaze and dollies down/back during the
|
|
|
|
| 160 |
window.addEventListener("pointermove", (e) => {
|
| 161 |
pointerNDC.set(
|
| 162 |
(e.clientX / window.innerWidth) * 2 - 1,
|
| 163 |
+
-(e.clientY / window.innerHeight) * 2 + 1,
|
| 164 |
);
|
| 165 |
pointerActive = true;
|
| 166 |
});
|
|
|
|
| 190 |
raycaster.setFromCamera(pointerNDC, camera);
|
| 191 |
setHoveredItem(
|
| 192 |
interactables.find(
|
| 193 |
+
(item) => raycaster.intersectObject(item.object, true).length > 0,
|
| 194 |
+
) ?? null,
|
| 195 |
);
|
| 196 |
|
| 197 |
if (hoveredItem) {
|
|
|
|
| 253 |
}
|
| 254 |
|
| 255 |
function closeBenchView() {
|
| 256 |
+
ui.closeCollection();
|
| 257 |
startViewTransition("zoom-out", CAM_END, LOOK_SCENE);
|
| 258 |
}
|
| 259 |
|
|
|
|
| 287 |
// ---------------------------------------------------------------------------
|
| 288 |
|
| 289 |
let generateFn = null;
|
| 290 |
+
let listSongsFn = null;
|
| 291 |
|
| 292 |
const ui = initUI({
|
| 293 |
generate: (prompt) => {
|
| 294 |
if (!generateFn) return Promise.reject(new Error("backend not connected"));
|
| 295 |
return generateFn(prompt);
|
| 296 |
},
|
| 297 |
+
listSongs: () => {
|
| 298 |
+
if (!listSongsFn) return Promise.reject(new Error("backend not connected"));
|
| 299 |
+
return listSongsFn();
|
| 300 |
+
},
|
| 301 |
onRequestClose: () => {
|
| 302 |
if (view.mode === "machine") closeMachineView();
|
| 303 |
},
|
| 304 |
+
onRequestCloseCollection: () => {
|
| 305 |
+
if (view.mode === "bench") closeBenchView();
|
| 306 |
+
},
|
| 307 |
onGeneratingChange: setMachineBusy,
|
| 308 |
});
|
| 309 |
|
|
|
|
| 314 |
const result = await client.predict("/generate_song", { prompt });
|
| 315 |
const data = result.data[0];
|
| 316 |
const url =
|
| 317 |
+
data.audio?.url ??
|
| 318 |
+
(data.audio?.path ? `/gradio_api/file=${data.audio.path}` : null);
|
| 319 |
if (!url) throw new Error("no audio in response");
|
| 320 |
return { title: data.title, url };
|
| 321 |
};
|
| 322 |
+
listSongsFn = async () => {
|
| 323 |
+
const result = await client.predict("/list_songs", {});
|
| 324 |
+
return result.data[0] ?? [];
|
| 325 |
+
};
|
| 326 |
+
window.lofinity = { generate: generateFn, listSongs: listSongsFn };
|
| 327 |
console.log("[LoFinity] backend connected");
|
| 328 |
})
|
| 329 |
.catch((err) => console.warn("[LoFinity] backend not reachable:", err));
|
|
|
|
| 349 |
const pageLoaded = new Promise((resolve) =>
|
| 350 |
document.readyState === "complete"
|
| 351 |
? resolve()
|
| 352 |
+
: window.addEventListener("load", resolve),
|
| 353 |
);
|
| 354 |
Promise.all([pageLoaded, document.fonts.ready]).then(() =>
|
| 355 |
+
setTimeout(startDescent, HOLD_MS),
|
| 356 |
);
|
| 357 |
|
| 358 |
const easeInOutCubic = (t) =>
|
|
|
|
| 388 |
view.mode = "bench";
|
| 389 |
camera.position.copy(BENCH_CAM);
|
| 390 |
lookTarget.copy(BENCH_LOOK);
|
| 391 |
+
ui.openCollection();
|
| 392 |
},
|
| 393 |
stats() {
|
| 394 |
return { ...renderer.info.render, ...renderer.info.memory };
|
|
|
|
| 442 |
bird.rotation.y =
|
| 443 |
-0.5 + Math.sin(elapsed * 0.4) * 0.4 + Math.sin(elapsed * 1.1) * 0.12;
|
| 444 |
bird.position.y =
|
| 445 |
+
bird.userData.baseY +
|
| 446 |
+
Math.pow(Math.max(0, Math.sin(elapsed * 1.9)), 12) * 0.06;
|
| 447 |
bird.userData.tail.rotation.x =
|
| 448 |
-0.35 + Math.pow(Math.max(0, Math.sin(elapsed * 1.3 + 1)), 8) * 0.5;
|
| 449 |
|
|
|
|
| 468 |
if (view.mode === "zoom-in") {
|
| 469 |
view.mode = view.target;
|
| 470 |
if (view.target === "machine") ui.openModal();
|
| 471 |
+
else if (view.target === "bench") ui.openCollection();
|
| 472 |
} else {
|
| 473 |
view.mode = "scene";
|
| 474 |
intro.idleStart = elapsed; // sway ramps back in
|
|
|
|
| 485 |
if (machineBusy) {
|
| 486 |
// the machine hums while it brews
|
| 487 |
const pulse = 0.5 + 0.5 * Math.sin(elapsed * 6);
|
| 488 |
+
world.vending.userData.screenMaterial.color.setHSL(
|
| 489 |
+
0.38,
|
| 490 |
+
0.55,
|
| 491 |
+
0.12 + 0.3 * pulse,
|
| 492 |
+
);
|
| 493 |
+
world.vending.userData.dispenserMaterial.color.setHSL(
|
| 494 |
+
0.13,
|
| 495 |
+
0.85,
|
| 496 |
+
0.08 + 0.3 * pulse,
|
| 497 |
+
);
|
| 498 |
}
|
| 499 |
|
| 500 |
camera.lookAt(lookTarget);
|
|
|
|
| 502 |
renderer.render(scene, camera);
|
| 503 |
}
|
| 504 |
animate();
|
|
|
frontend/style.css
CHANGED
|
@@ -25,7 +25,9 @@ body {
|
|
| 25 |
z-index: 10;
|
| 26 |
opacity: 1;
|
| 27 |
transform: translateY(0);
|
| 28 |
-
transition:
|
|
|
|
|
|
|
| 29 |
}
|
| 30 |
|
| 31 |
#title-overlay.hidden {
|
|
@@ -276,7 +278,9 @@ body {
|
|
| 276 |
font-size: 1.3rem;
|
| 277 |
letter-spacing: 0.1em;
|
| 278 |
color: #5dff8d;
|
| 279 |
-
text-shadow:
|
|
|
|
|
|
|
| 280 |
animation: led-flicker 4s steps(1) infinite;
|
| 281 |
}
|
| 282 |
|
|
@@ -294,11 +298,23 @@ body {
|
|
| 294 |
}
|
| 295 |
|
| 296 |
@keyframes led-flicker {
|
| 297 |
-
0%,
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 302 |
}
|
| 303 |
|
| 304 |
#prompt-input {
|
|
@@ -337,10 +353,19 @@ body {
|
|
| 337 |
}
|
| 338 |
|
| 339 |
@keyframes shake {
|
| 340 |
-
0%,
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 344 |
}
|
| 345 |
|
| 346 |
#generating {
|
|
@@ -362,8 +387,13 @@ body {
|
|
| 362 |
}
|
| 363 |
|
| 364 |
@keyframes blink {
|
| 365 |
-
0%,
|
| 366 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 367 |
}
|
| 368 |
|
| 369 |
.eq {
|
|
@@ -382,15 +412,35 @@ body {
|
|
| 382 |
animation: eq-bounce 1s ease-in-out infinite;
|
| 383 |
}
|
| 384 |
|
| 385 |
-
.eq i:nth-child(1) {
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
.eq i:nth-child(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 390 |
|
| 391 |
@keyframes eq-bounce {
|
| 392 |
-
0%,
|
| 393 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 394 |
}
|
| 395 |
|
| 396 |
/* --- coin slot + red button -------------------------------------------------- */
|
|
@@ -413,7 +463,9 @@ body {
|
|
| 413 |
background: linear-gradient(#5fabd6, #4f9cc8);
|
| 414 |
border: 2px solid #3f86ad;
|
| 415 |
border-radius: 10px;
|
| 416 |
-
box-shadow:
|
|
|
|
|
|
|
| 417 |
perspective: 300px;
|
| 418 |
}
|
| 419 |
|
|
@@ -422,7 +474,9 @@ body {
|
|
| 422 |
height: 40px;
|
| 423 |
background: #0e1116;
|
| 424 |
border-radius: 5px;
|
| 425 |
-
box-shadow:
|
|
|
|
|
|
|
| 426 |
}
|
| 427 |
|
| 428 |
#wado-coin {
|
|
@@ -479,7 +533,11 @@ body {
|
|
| 479 |
}
|
| 480 |
|
| 481 |
@keyframes slot-flash {
|
| 482 |
-
50% {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 483 |
}
|
| 484 |
|
| 485 |
#coin-button {
|
|
@@ -494,12 +552,16 @@ body {
|
|
| 494 |
letter-spacing: 0.12em;
|
| 495 |
text-shadow: 0 2px 3px rgba(0, 0, 0, 0.45);
|
| 496 |
cursor: pointer;
|
| 497 |
-
box-shadow:
|
|
|
|
|
|
|
| 498 |
}
|
| 499 |
|
| 500 |
#coin-button:active {
|
| 501 |
transform: translateY(3px);
|
| 502 |
-
box-shadow:
|
|
|
|
|
|
|
| 503 |
}
|
| 504 |
|
| 505 |
#coin-button:disabled {
|
|
@@ -515,7 +577,9 @@ body {
|
|
| 515 |
background: linear-gradient(#3a4254, #2c3344);
|
| 516 |
border-radius: 14px;
|
| 517 |
padding-bottom: 4px;
|
| 518 |
-
box-shadow:
|
|
|
|
|
|
|
| 519 |
animation: cassette-out 0.6s cubic-bezier(0.34, 1.56, 0.64, 1) both;
|
| 520 |
}
|
| 521 |
|
|
@@ -560,12 +624,15 @@ body {
|
|
| 560 |
box-sizing: border-box;
|
| 561 |
}
|
| 562 |
|
| 563 |
-
#cassette.playing .reel
|
|
|
|
| 564 |
animation: reel-spin 2.4s linear infinite;
|
| 565 |
}
|
| 566 |
|
| 567 |
@keyframes reel-spin {
|
| 568 |
-
to {
|
|
|
|
|
|
|
| 569 |
}
|
| 570 |
|
| 571 |
.tape-window {
|
|
@@ -584,7 +651,8 @@ body {
|
|
| 584 |
}
|
| 585 |
|
| 586 |
#play-btn,
|
| 587 |
-
#np-toggle
|
|
|
|
| 588 |
width: 44px;
|
| 589 |
height: 44px;
|
| 590 |
flex: none;
|
|
@@ -598,7 +666,8 @@ body {
|
|
| 598 |
}
|
| 599 |
|
| 600 |
#play-btn:active,
|
| 601 |
-
#np-toggle:active
|
|
|
|
| 602 |
transform: translateY(2px);
|
| 603 |
box-shadow: 0 1px 0 #d9ab1e;
|
| 604 |
}
|
|
@@ -640,7 +709,9 @@ body {
|
|
| 640 |
font-size: 1rem;
|
| 641 |
letter-spacing: 0.12em;
|
| 642 |
cursor: pointer;
|
| 643 |
-
box-shadow:
|
|
|
|
|
|
|
| 644 |
}
|
| 645 |
|
| 646 |
#again-btn:hover {
|
|
@@ -649,7 +720,9 @@ body {
|
|
| 649 |
|
| 650 |
#again-btn:active {
|
| 651 |
transform: translateY(2px);
|
| 652 |
-
box-shadow:
|
|
|
|
|
|
|
| 653 |
}
|
| 654 |
|
| 655 |
#error-msg {
|
|
@@ -690,7 +763,9 @@ body {
|
|
| 690 |
font-weight: 700;
|
| 691 |
font-size: 0.95rem;
|
| 692 |
color: #2b3a55;
|
| 693 |
-
transition:
|
|
|
|
|
|
|
| 694 |
}
|
| 695 |
|
| 696 |
#now-playing.hidden {
|
|
@@ -711,3 +786,319 @@ body {
|
|
| 711 |
overflow: hidden;
|
| 712 |
text-overflow: ellipsis;
|
| 713 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
z-index: 10;
|
| 26 |
opacity: 1;
|
| 27 |
transform: translateY(0);
|
| 28 |
+
transition:
|
| 29 |
+
opacity 1.8s ease,
|
| 30 |
+
transform 1.8s cubic-bezier(0.55, 0, 0.55, 1);
|
| 31 |
}
|
| 32 |
|
| 33 |
#title-overlay.hidden {
|
|
|
|
| 278 |
font-size: 1.3rem;
|
| 279 |
letter-spacing: 0.1em;
|
| 280 |
color: #5dff8d;
|
| 281 |
+
text-shadow:
|
| 282 |
+
0 0 8px rgba(93, 255, 141, 0.7),
|
| 283 |
+
0 0 2px rgba(93, 255, 141, 0.9);
|
| 284 |
animation: led-flicker 4s steps(1) infinite;
|
| 285 |
}
|
| 286 |
|
|
|
|
| 298 |
}
|
| 299 |
|
| 300 |
@keyframes led-flicker {
|
| 301 |
+
0%,
|
| 302 |
+
93%,
|
| 303 |
+
100% {
|
| 304 |
+
opacity: 1;
|
| 305 |
+
}
|
| 306 |
+
94% {
|
| 307 |
+
opacity: 0.75;
|
| 308 |
+
}
|
| 309 |
+
95% {
|
| 310 |
+
opacity: 1;
|
| 311 |
+
}
|
| 312 |
+
97% {
|
| 313 |
+
opacity: 0.85;
|
| 314 |
+
}
|
| 315 |
+
98% {
|
| 316 |
+
opacity: 1;
|
| 317 |
+
}
|
| 318 |
}
|
| 319 |
|
| 320 |
#prompt-input {
|
|
|
|
| 353 |
}
|
| 354 |
|
| 355 |
@keyframes shake {
|
| 356 |
+
0%,
|
| 357 |
+
100% {
|
| 358 |
+
transform: translateX(0);
|
| 359 |
+
}
|
| 360 |
+
25% {
|
| 361 |
+
transform: translateX(-6px);
|
| 362 |
+
}
|
| 363 |
+
50% {
|
| 364 |
+
transform: translateX(6px);
|
| 365 |
+
}
|
| 366 |
+
75% {
|
| 367 |
+
transform: translateX(-4px);
|
| 368 |
+
}
|
| 369 |
}
|
| 370 |
|
| 371 |
#generating {
|
|
|
|
| 387 |
}
|
| 388 |
|
| 389 |
@keyframes blink {
|
| 390 |
+
0%,
|
| 391 |
+
100% {
|
| 392 |
+
opacity: 1;
|
| 393 |
+
}
|
| 394 |
+
50% {
|
| 395 |
+
opacity: 0.15;
|
| 396 |
+
}
|
| 397 |
}
|
| 398 |
|
| 399 |
.eq {
|
|
|
|
| 412 |
animation: eq-bounce 1s ease-in-out infinite;
|
| 413 |
}
|
| 414 |
|
| 415 |
+
.eq i:nth-child(1) {
|
| 416 |
+
height: 40%;
|
| 417 |
+
animation-delay: 0s;
|
| 418 |
+
}
|
| 419 |
+
.eq i:nth-child(2) {
|
| 420 |
+
height: 75%;
|
| 421 |
+
animation-delay: 0.15s;
|
| 422 |
+
}
|
| 423 |
+
.eq i:nth-child(3) {
|
| 424 |
+
height: 100%;
|
| 425 |
+
animation-delay: 0.3s;
|
| 426 |
+
}
|
| 427 |
+
.eq i:nth-child(4) {
|
| 428 |
+
height: 65%;
|
| 429 |
+
animation-delay: 0.45s;
|
| 430 |
+
}
|
| 431 |
+
.eq i:nth-child(5) {
|
| 432 |
+
height: 45%;
|
| 433 |
+
animation-delay: 0.6s;
|
| 434 |
+
}
|
| 435 |
|
| 436 |
@keyframes eq-bounce {
|
| 437 |
+
0%,
|
| 438 |
+
100% {
|
| 439 |
+
transform: scaleY(0.5);
|
| 440 |
+
}
|
| 441 |
+
50% {
|
| 442 |
+
transform: scaleY(1);
|
| 443 |
+
}
|
| 444 |
}
|
| 445 |
|
| 446 |
/* --- coin slot + red button -------------------------------------------------- */
|
|
|
|
| 463 |
background: linear-gradient(#5fabd6, #4f9cc8);
|
| 464 |
border: 2px solid #3f86ad;
|
| 465 |
border-radius: 10px;
|
| 466 |
+
box-shadow:
|
| 467 |
+
inset 0 2px 4px rgba(255, 255, 255, 0.35),
|
| 468 |
+
inset 0 -3px 5px rgba(31, 79, 116, 0.4);
|
| 469 |
perspective: 300px;
|
| 470 |
}
|
| 471 |
|
|
|
|
| 474 |
height: 40px;
|
| 475 |
background: #0e1116;
|
| 476 |
border-radius: 5px;
|
| 477 |
+
box-shadow:
|
| 478 |
+
inset 0 0 5px #000,
|
| 479 |
+
0 1px 0 rgba(255, 255, 255, 0.4);
|
| 480 |
}
|
| 481 |
|
| 482 |
#wado-coin {
|
|
|
|
| 533 |
}
|
| 534 |
|
| 535 |
@keyframes slot-flash {
|
| 536 |
+
50% {
|
| 537 |
+
box-shadow:
|
| 538 |
+
inset 0 0 5px #000,
|
| 539 |
+
0 0 12px rgba(93, 255, 141, 0.9);
|
| 540 |
+
}
|
| 541 |
}
|
| 542 |
|
| 543 |
#coin-button {
|
|
|
|
| 552 |
letter-spacing: 0.12em;
|
| 553 |
text-shadow: 0 2px 3px rgba(0, 0, 0, 0.45);
|
| 554 |
cursor: pointer;
|
| 555 |
+
box-shadow:
|
| 556 |
+
0 5px 0 #8f1d18,
|
| 557 |
+
inset 0 2px 3px rgba(255, 255, 255, 0.4);
|
| 558 |
}
|
| 559 |
|
| 560 |
#coin-button:active {
|
| 561 |
transform: translateY(3px);
|
| 562 |
+
box-shadow:
|
| 563 |
+
0 2px 0 #8f1d18,
|
| 564 |
+
inset 0 2px 3px rgba(255, 255, 255, 0.4);
|
| 565 |
}
|
| 566 |
|
| 567 |
#coin-button:disabled {
|
|
|
|
| 577 |
background: linear-gradient(#3a4254, #2c3344);
|
| 578 |
border-radius: 14px;
|
| 579 |
padding-bottom: 4px;
|
| 580 |
+
box-shadow:
|
| 581 |
+
inset 0 2px 6px rgba(255, 255, 255, 0.12),
|
| 582 |
+
0 8px 22px rgba(15, 40, 90, 0.3);
|
| 583 |
animation: cassette-out 0.6s cubic-bezier(0.34, 1.56, 0.64, 1) both;
|
| 584 |
}
|
| 585 |
|
|
|
|
| 624 |
box-sizing: border-box;
|
| 625 |
}
|
| 626 |
|
| 627 |
+
#cassette.playing .reel,
|
| 628 |
+
.cassette-card.playing .reel {
|
| 629 |
animation: reel-spin 2.4s linear infinite;
|
| 630 |
}
|
| 631 |
|
| 632 |
@keyframes reel-spin {
|
| 633 |
+
to {
|
| 634 |
+
transform: rotate(360deg);
|
| 635 |
+
}
|
| 636 |
}
|
| 637 |
|
| 638 |
.tape-window {
|
|
|
|
| 651 |
}
|
| 652 |
|
| 653 |
#play-btn,
|
| 654 |
+
#np-toggle,
|
| 655 |
+
#deck-play {
|
| 656 |
width: 44px;
|
| 657 |
height: 44px;
|
| 658 |
flex: none;
|
|
|
|
| 666 |
}
|
| 667 |
|
| 668 |
#play-btn:active,
|
| 669 |
+
#np-toggle:active,
|
| 670 |
+
#deck-play:active {
|
| 671 |
transform: translateY(2px);
|
| 672 |
box-shadow: 0 1px 0 #d9ab1e;
|
| 673 |
}
|
|
|
|
| 709 |
font-size: 1rem;
|
| 710 |
letter-spacing: 0.12em;
|
| 711 |
cursor: pointer;
|
| 712 |
+
box-shadow:
|
| 713 |
+
0 4px 0 #16213a,
|
| 714 |
+
inset 0 2px 2px rgba(255, 255, 255, 0.18);
|
| 715 |
}
|
| 716 |
|
| 717 |
#again-btn:hover {
|
|
|
|
| 720 |
|
| 721 |
#again-btn:active {
|
| 722 |
transform: translateY(2px);
|
| 723 |
+
box-shadow:
|
| 724 |
+
0 2px 0 #16213a,
|
| 725 |
+
inset 0 2px 2px rgba(255, 255, 255, 0.18);
|
| 726 |
}
|
| 727 |
|
| 728 |
#error-msg {
|
|
|
|
| 763 |
font-weight: 700;
|
| 764 |
font-size: 0.95rem;
|
| 765 |
color: #2b3a55;
|
| 766 |
+
transition:
|
| 767 |
+
opacity 0.3s ease,
|
| 768 |
+
transform 0.3s ease;
|
| 769 |
}
|
| 770 |
|
| 771 |
#now-playing.hidden {
|
|
|
|
| 786 |
overflow: hidden;
|
| 787 |
text-overflow: ellipsis;
|
| 788 |
}
|
| 789 |
+
|
| 790 |
+
/* --- cassette collection: carousel ------------------------------------------ */
|
| 791 |
+
|
| 792 |
+
#collection-panel {
|
| 793 |
+
position: fixed;
|
| 794 |
+
top: 6vh;
|
| 795 |
+
left: 50%;
|
| 796 |
+
transform: translateX(-50%);
|
| 797 |
+
width: min(780px, 94vw);
|
| 798 |
+
z-index: 18;
|
| 799 |
+
text-align: center;
|
| 800 |
+
transition:
|
| 801 |
+
opacity 0.35s ease,
|
| 802 |
+
transform 0.35s ease;
|
| 803 |
+
}
|
| 804 |
+
|
| 805 |
+
#collection-panel.hidden {
|
| 806 |
+
opacity: 0;
|
| 807 |
+
transform: translate(-50%, -18px);
|
| 808 |
+
pointer-events: none;
|
| 809 |
+
}
|
| 810 |
+
|
| 811 |
+
#collection-heading {
|
| 812 |
+
margin: 0;
|
| 813 |
+
}
|
| 814 |
+
|
| 815 |
+
#collection-heading span {
|
| 816 |
+
display: inline-block;
|
| 817 |
+
font-family: "Baloo 2", sans-serif;
|
| 818 |
+
font-weight: 800;
|
| 819 |
+
font-size: clamp(1.1rem, 2.2vw, 1.5rem);
|
| 820 |
+
color: #3a2c14;
|
| 821 |
+
background: #ffd84a;
|
| 822 |
+
padding: 0.4em 1.3em;
|
| 823 |
+
border-radius: 999px;
|
| 824 |
+
box-shadow: 0 8px 24px rgba(18, 44, 96, 0.3);
|
| 825 |
+
}
|
| 826 |
+
|
| 827 |
+
#collection-close {
|
| 828 |
+
position: absolute;
|
| 829 |
+
top: -6px;
|
| 830 |
+
right: 0;
|
| 831 |
+
width: 36px;
|
| 832 |
+
height: 36px;
|
| 833 |
+
border-radius: 50%;
|
| 834 |
+
border: 1px solid rgba(255, 255, 255, 0.5);
|
| 835 |
+
background: rgba(255, 255, 255, 0.22);
|
| 836 |
+
backdrop-filter: blur(10px);
|
| 837 |
+
-webkit-backdrop-filter: blur(10px);
|
| 838 |
+
color: #fff;
|
| 839 |
+
font-size: 1.15rem;
|
| 840 |
+
line-height: 1;
|
| 841 |
+
cursor: pointer;
|
| 842 |
+
text-shadow: 0 1px 3px rgba(15, 40, 90, 0.4);
|
| 843 |
+
}
|
| 844 |
+
|
| 845 |
+
#collection-close:hover {
|
| 846 |
+
background: rgba(255, 255, 255, 0.38);
|
| 847 |
+
}
|
| 848 |
+
|
| 849 |
+
#collection-status {
|
| 850 |
+
margin: 26px 0 0;
|
| 851 |
+
font-family: "Baloo 2", sans-serif;
|
| 852 |
+
font-weight: 700;
|
| 853 |
+
font-size: 1.05rem;
|
| 854 |
+
color: #fff;
|
| 855 |
+
text-shadow: 0 2px 8px rgba(15, 40, 90, 0.45);
|
| 856 |
+
}
|
| 857 |
+
|
| 858 |
+
#collection-status.hidden {
|
| 859 |
+
display: none;
|
| 860 |
+
}
|
| 861 |
+
|
| 862 |
+
#carousel-row {
|
| 863 |
+
display: flex;
|
| 864 |
+
align-items: center;
|
| 865 |
+
gap: 10px;
|
| 866 |
+
margin-top: 16px;
|
| 867 |
+
}
|
| 868 |
+
|
| 869 |
+
.caro-btn {
|
| 870 |
+
flex: none;
|
| 871 |
+
width: 40px;
|
| 872 |
+
height: 40px;
|
| 873 |
+
border-radius: 50%;
|
| 874 |
+
border: 1px solid rgba(255, 255, 255, 0.5);
|
| 875 |
+
background: rgba(255, 255, 255, 0.22);
|
| 876 |
+
backdrop-filter: blur(10px);
|
| 877 |
+
-webkit-backdrop-filter: blur(10px);
|
| 878 |
+
color: #fff;
|
| 879 |
+
font-size: 1.5rem;
|
| 880 |
+
line-height: 1;
|
| 881 |
+
padding-bottom: 4px;
|
| 882 |
+
cursor: pointer;
|
| 883 |
+
text-shadow: 0 1px 3px rgba(15, 40, 90, 0.4);
|
| 884 |
+
}
|
| 885 |
+
|
| 886 |
+
.caro-btn:hover {
|
| 887 |
+
background: rgba(255, 255, 255, 0.38);
|
| 888 |
+
}
|
| 889 |
+
|
| 890 |
+
#carousel {
|
| 891 |
+
flex: 1;
|
| 892 |
+
display: flex;
|
| 893 |
+
gap: 16px;
|
| 894 |
+
overflow-x: auto;
|
| 895 |
+
scroll-snap-type: x mandatory;
|
| 896 |
+
padding: 14px 6px 20px;
|
| 897 |
+
scrollbar-width: none;
|
| 898 |
+
}
|
| 899 |
+
|
| 900 |
+
#carousel::-webkit-scrollbar {
|
| 901 |
+
display: none;
|
| 902 |
+
}
|
| 903 |
+
|
| 904 |
+
.cassette-card {
|
| 905 |
+
flex: none;
|
| 906 |
+
width: 196px;
|
| 907 |
+
box-sizing: border-box;
|
| 908 |
+
scroll-snap-align: center;
|
| 909 |
+
cursor: pointer;
|
| 910 |
+
border: none;
|
| 911 |
+
padding: 0 0 4px;
|
| 912 |
+
font-family: "Baloo 2", sans-serif;
|
| 913 |
+
background: linear-gradient(var(--shell1, #3a4254), var(--shell2, #2c3344));
|
| 914 |
+
border-radius: 14px;
|
| 915 |
+
box-shadow:
|
| 916 |
+
inset 0 2px 6px rgba(255, 255, 255, 0.14),
|
| 917 |
+
0 8px 22px rgba(15, 40, 90, 0.32);
|
| 918 |
+
transition:
|
| 919 |
+
transform 0.18s ease,
|
| 920 |
+
box-shadow 0.18s ease;
|
| 921 |
+
animation: cassette-out 0.5s cubic-bezier(0.34, 1.56, 0.64, 1) both;
|
| 922 |
+
}
|
| 923 |
+
|
| 924 |
+
.cassette-card:hover {
|
| 925 |
+
transform: translateY(-4px);
|
| 926 |
+
}
|
| 927 |
+
|
| 928 |
+
.cassette-card.selected {
|
| 929 |
+
transform: translateY(-6px);
|
| 930 |
+
box-shadow:
|
| 931 |
+
0 0 0 3px #ffd84a,
|
| 932 |
+
inset 0 2px 6px rgba(255, 255, 255, 0.14),
|
| 933 |
+
0 12px 28px rgba(15, 40, 90, 0.4);
|
| 934 |
+
}
|
| 935 |
+
|
| 936 |
+
.cassette-card .cassette-label {
|
| 937 |
+
margin: 10px 12px 8px;
|
| 938 |
+
padding: 6px 8px;
|
| 939 |
+
font-size: 0.85rem;
|
| 940 |
+
}
|
| 941 |
+
|
| 942 |
+
.cassette-card .reels {
|
| 943 |
+
padding: 0 22px 10px;
|
| 944 |
+
}
|
| 945 |
+
|
| 946 |
+
.cassette-card .reel {
|
| 947 |
+
width: 28px;
|
| 948 |
+
height: 28px;
|
| 949 |
+
border-width: 4px;
|
| 950 |
+
}
|
| 951 |
+
|
| 952 |
+
.cassette-card .tape-window {
|
| 953 |
+
height: 13px;
|
| 954 |
+
margin: 0 10px;
|
| 955 |
+
}
|
| 956 |
+
|
| 957 |
+
/* --- tape deck: glass pill player -------------------------------------------- */
|
| 958 |
+
|
| 959 |
+
#tape-deck {
|
| 960 |
+
position: fixed;
|
| 961 |
+
bottom: 26px;
|
| 962 |
+
left: 50%;
|
| 963 |
+
transform: translateX(-50%);
|
| 964 |
+
z-index: 18;
|
| 965 |
+
display: flex;
|
| 966 |
+
align-items: center;
|
| 967 |
+
gap: 13px;
|
| 968 |
+
width: min(620px, 92vw);
|
| 969 |
+
box-sizing: border-box;
|
| 970 |
+
padding: 9px 10px;
|
| 971 |
+
border-radius: 999px;
|
| 972 |
+
background: rgba(255, 255, 255, 0.16);
|
| 973 |
+
border: 1px solid rgba(255, 255, 255, 0.42);
|
| 974 |
+
backdrop-filter: blur(16px) saturate(1.35);
|
| 975 |
+
-webkit-backdrop-filter: blur(16px) saturate(1.35);
|
| 976 |
+
box-shadow:
|
| 977 |
+
inset 0 1px 0 rgba(255, 255, 255, 0.5),
|
| 978 |
+
0 16px 44px rgba(15, 40, 90, 0.38);
|
| 979 |
+
font-family: "Baloo 2", sans-serif;
|
| 980 |
+
transition:
|
| 981 |
+
opacity 0.35s ease,
|
| 982 |
+
transform 0.35s ease;
|
| 983 |
+
}
|
| 984 |
+
|
| 985 |
+
#tape-deck.hidden {
|
| 986 |
+
opacity: 0;
|
| 987 |
+
transform: translate(-50%, 18px);
|
| 988 |
+
pointer-events: none;
|
| 989 |
+
}
|
| 990 |
+
|
| 991 |
+
#deck-info {
|
| 992 |
+
flex: 1;
|
| 993 |
+
min-width: 0;
|
| 994 |
+
display: flex;
|
| 995 |
+
flex-direction: column;
|
| 996 |
+
gap: 5px;
|
| 997 |
+
}
|
| 998 |
+
|
| 999 |
+
#deck-title {
|
| 1000 |
+
font-weight: 800;
|
| 1001 |
+
font-size: 0.98rem;
|
| 1002 |
+
color: #fff;
|
| 1003 |
+
text-shadow: 0 1px 4px rgba(15, 40, 90, 0.45);
|
| 1004 |
+
white-space: nowrap;
|
| 1005 |
+
overflow: hidden;
|
| 1006 |
+
text-overflow: ellipsis;
|
| 1007 |
+
padding-right: 6px;
|
| 1008 |
+
}
|
| 1009 |
+
|
| 1010 |
+
#deck-progress {
|
| 1011 |
+
height: 8px;
|
| 1012 |
+
border-radius: 4px;
|
| 1013 |
+
background: rgba(13, 26, 43, 0.35);
|
| 1014 |
+
cursor: pointer;
|
| 1015 |
+
overflow: hidden;
|
| 1016 |
+
}
|
| 1017 |
+
|
| 1018 |
+
#deck-fill {
|
| 1019 |
+
height: 100%;
|
| 1020 |
+
width: 0%;
|
| 1021 |
+
border-radius: 4px;
|
| 1022 |
+
background: linear-gradient(90deg, #ffd84a, #e8a13c);
|
| 1023 |
+
box-shadow: 0 0 8px rgba(255, 216, 74, 0.65);
|
| 1024 |
+
}
|
| 1025 |
+
|
| 1026 |
+
#deck-time {
|
| 1027 |
+
flex: none;
|
| 1028 |
+
font-family: "DotGothic16", monospace;
|
| 1029 |
+
font-size: 0.85rem;
|
| 1030 |
+
color: #fff;
|
| 1031 |
+
text-shadow: 0 1px 3px rgba(15, 40, 90, 0.45);
|
| 1032 |
+
min-width: 38px;
|
| 1033 |
+
text-align: right;
|
| 1034 |
+
}
|
| 1035 |
+
|
| 1036 |
+
#deck-cassette {
|
| 1037 |
+
flex: none;
|
| 1038 |
+
position: relative;
|
| 1039 |
+
width: 64px;
|
| 1040 |
+
height: 42px;
|
| 1041 |
+
border-radius: 8px;
|
| 1042 |
+
background: linear-gradient(var(--shell1, #3a4254), var(--shell2, #2c3344));
|
| 1043 |
+
box-shadow:
|
| 1044 |
+
inset 0 1px 2px rgba(255, 255, 255, 0.2),
|
| 1045 |
+
0 3px 10px rgba(15, 40, 90, 0.3);
|
| 1046 |
+
display: flex;
|
| 1047 |
+
align-items: flex-end;
|
| 1048 |
+
justify-content: center;
|
| 1049 |
+
gap: 12px;
|
| 1050 |
+
padding-bottom: 6px;
|
| 1051 |
+
box-sizing: border-box;
|
| 1052 |
+
}
|
| 1053 |
+
|
| 1054 |
+
#deck-cassette::before {
|
| 1055 |
+
content: "";
|
| 1056 |
+
position: absolute;
|
| 1057 |
+
top: 5px;
|
| 1058 |
+
left: 7px;
|
| 1059 |
+
right: 7px;
|
| 1060 |
+
height: 12px;
|
| 1061 |
+
background: #f6f1e3;
|
| 1062 |
+
border: 1px solid #e0d6bd;
|
| 1063 |
+
border-radius: 3px;
|
| 1064 |
+
box-sizing: border-box;
|
| 1065 |
+
}
|
| 1066 |
+
|
| 1067 |
+
.mini-reel {
|
| 1068 |
+
width: 15px;
|
| 1069 |
+
height: 15px;
|
| 1070 |
+
border-radius: 50%;
|
| 1071 |
+
border: 3px dashed #cfd6e4;
|
| 1072 |
+
background: #1d232f;
|
| 1073 |
+
box-sizing: border-box;
|
| 1074 |
+
}
|
| 1075 |
+
|
| 1076 |
+
#tape-deck.playing .mini-reel {
|
| 1077 |
+
animation: reel-spin 2.4s linear infinite;
|
| 1078 |
+
}
|
| 1079 |
+
|
| 1080 |
+
#deck-download {
|
| 1081 |
+
flex: none;
|
| 1082 |
+
width: 44px;
|
| 1083 |
+
height: 44px;
|
| 1084 |
+
border-radius: 50%;
|
| 1085 |
+
display: flex;
|
| 1086 |
+
align-items: center;
|
| 1087 |
+
justify-content: center;
|
| 1088 |
+
border: 1px solid rgba(255, 255, 255, 0.5);
|
| 1089 |
+
background: rgba(255, 255, 255, 0.22);
|
| 1090 |
+
color: #fff;
|
| 1091 |
+
cursor: pointer;
|
| 1092 |
+
text-decoration: none;
|
| 1093 |
+
box-sizing: border-box;
|
| 1094 |
+
transition: background 0.15s ease;
|
| 1095 |
+
}
|
| 1096 |
+
|
| 1097 |
+
#deck-download:hover {
|
| 1098 |
+
background: rgba(255, 255, 255, 0.4);
|
| 1099 |
+
}
|
| 1100 |
+
|
| 1101 |
+
#deck-download.disabled {
|
| 1102 |
+
opacity: 0.4;
|
| 1103 |
+
pointer-events: none;
|
| 1104 |
+
}
|
frontend/ui.js
CHANGED
|
@@ -4,7 +4,13 @@
|
|
| 4 |
const $ = (id) => document.getElementById(id);
|
| 5 |
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
| 6 |
|
| 7 |
-
export function initUI({
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
const modal = $("machine-modal");
|
| 9 |
const input = $("prompt-input");
|
| 10 |
const coinBtn = $("coin-button");
|
|
@@ -22,9 +28,22 @@ export function initUI({ generate, onRequestClose, onGeneratingChange }) {
|
|
| 22 |
const pill = $("now-playing");
|
| 23 |
const pillToggle = $("np-toggle");
|
| 24 |
const pillTitle = $("np-title");
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
|
| 26 |
audio.loop = true; // a cassette of endless chill
|
| 27 |
let busy = false;
|
|
|
|
|
|
|
| 28 |
|
| 29 |
function setStage(stage) {
|
| 30 |
controlsRow.classList.toggle("hidden", stage !== "prompt");
|
|
@@ -82,11 +101,117 @@ export function initUI({ generate, onRequestClose, onGeneratingChange }) {
|
|
| 82 |
}
|
| 83 |
}
|
| 84 |
|
| 85 |
-
function loadCassette(
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
audio.play().catch(() => {});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
}
|
| 91 |
|
| 92 |
// --- audio wiring ---------------------------------------------------------
|
|
@@ -98,27 +223,46 @@ export function initUI({ generate, onRequestClose, onGeneratingChange }) {
|
|
| 98 |
const playing = !audio.paused;
|
| 99 |
playBtn.textContent = playing ? "❚❚" : "▶";
|
| 100 |
pillToggle.textContent = playing ? "❚❚" : "▶";
|
|
|
|
| 101 |
cassette.classList.toggle("playing", playing);
|
|
|
|
|
|
|
| 102 |
}
|
| 103 |
|
| 104 |
audio.addEventListener("play", syncPlayState);
|
| 105 |
audio.addEventListener("pause", syncPlayState);
|
| 106 |
audio.addEventListener("timeupdate", () => {
|
| 107 |
if (audio.duration) {
|
| 108 |
-
|
|
|
|
|
|
|
| 109 |
}
|
| 110 |
timeEl.textContent = fmt(audio.currentTime);
|
|
|
|
| 111 |
});
|
| 112 |
|
| 113 |
const togglePlay = () => (audio.paused ? audio.play() : audio.pause());
|
| 114 |
playBtn.addEventListener("click", togglePlay);
|
| 115 |
pillToggle.addEventListener("click", togglePlay);
|
|
|
|
|
|
|
|
|
|
| 116 |
|
| 117 |
-
|
| 118 |
if (!audio.duration) return;
|
| 119 |
-
const rect =
|
| 120 |
audio.currentTime = ((e.clientX - rect.left) / rect.width) * audio.duration;
|
| 121 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
|
| 123 |
// --- modal controls ---------------------------------------------------------
|
| 124 |
|
|
@@ -142,8 +286,9 @@ export function initUI({ generate, onRequestClose, onGeneratingChange }) {
|
|
| 142 |
});
|
| 143 |
|
| 144 |
function syncPill() {
|
|
|
|
| 145 |
const modalHidden = modal.classList.contains("hidden");
|
| 146 |
-
pill.classList.toggle("hidden", !(audio.src && modalHidden));
|
| 147 |
}
|
| 148 |
|
| 149 |
let closeTimer = null;
|
|
@@ -170,5 +315,7 @@ export function initUI({ generate, onRequestClose, onGeneratingChange }) {
|
|
| 170 |
}, 300);
|
| 171 |
syncPill();
|
| 172 |
},
|
|
|
|
|
|
|
| 173 |
};
|
| 174 |
}
|
|
|
|
| 4 |
const $ = (id) => document.getElementById(id);
|
| 5 |
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
| 6 |
|
| 7 |
+
export function initUI({
|
| 8 |
+
generate,
|
| 9 |
+
listSongs,
|
| 10 |
+
onRequestClose,
|
| 11 |
+
onRequestCloseCollection,
|
| 12 |
+
onGeneratingChange,
|
| 13 |
+
}) {
|
| 14 |
const modal = $("machine-modal");
|
| 15 |
const input = $("prompt-input");
|
| 16 |
const coinBtn = $("coin-button");
|
|
|
|
| 28 |
const pill = $("now-playing");
|
| 29 |
const pillToggle = $("np-toggle");
|
| 30 |
const pillTitle = $("np-title");
|
| 31 |
+
const collectionPanel = $("collection-panel");
|
| 32 |
+
const collectionStatus = $("collection-status");
|
| 33 |
+
const carousel = $("carousel");
|
| 34 |
+
const deck = $("tape-deck");
|
| 35 |
+
const deckPlay = $("deck-play");
|
| 36 |
+
const deckTitle = $("deck-title");
|
| 37 |
+
const deckProgress = $("deck-progress");
|
| 38 |
+
const deckFill = $("deck-fill");
|
| 39 |
+
const deckTime = $("deck-time");
|
| 40 |
+
const deckCassette = $("deck-cassette");
|
| 41 |
+
const deckDownload = $("deck-download");
|
| 42 |
|
| 43 |
audio.loop = true; // a cassette of endless chill
|
| 44 |
let busy = false;
|
| 45 |
+
let collectionOpen = false;
|
| 46 |
+
let currentSong = null; // { title, url } of the tape in the deck
|
| 47 |
|
| 48 |
function setStage(stage) {
|
| 49 |
controlsRow.classList.toggle("hidden", stage !== "prompt");
|
|
|
|
| 101 |
}
|
| 102 |
}
|
| 103 |
|
| 104 |
+
function loadCassette(song, shell) {
|
| 105 |
+
currentSong = song;
|
| 106 |
+
cassetteTitle.textContent = song.title;
|
| 107 |
+
pillTitle.textContent = song.title;
|
| 108 |
+
deckTitle.textContent = song.title;
|
| 109 |
+
deckDownload.href = song.url;
|
| 110 |
+
deckDownload.setAttribute("download", `${song.title}.wav`);
|
| 111 |
+
deckDownload.classList.remove("disabled");
|
| 112 |
+
deckCassette.style.setProperty("--shell1", (shell ?? SHELL_COLORS[0])[0]);
|
| 113 |
+
deckCassette.style.setProperty("--shell2", (shell ?? SHELL_COLORS[0])[1]);
|
| 114 |
+
audio.src = song.url;
|
| 115 |
audio.play().catch(() => {});
|
| 116 |
+
syncCards();
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
// --- cassette collection: carousel + tape deck ------------------------------
|
| 120 |
+
|
| 121 |
+
// shell gradients, cycled per card like the tape pile on the bench
|
| 122 |
+
const SHELL_COLORS = [
|
| 123 |
+
["#3a4254", "#2c3344"],
|
| 124 |
+
["#f2e8d5", "#dccfae"],
|
| 125 |
+
["#d95d4e", "#b8453a"],
|
| 126 |
+
["#7cc4ea", "#58a8d6"],
|
| 127 |
+
["#f2c84b", "#dba62a"],
|
| 128 |
+
["#5d4a37", "#46382a"],
|
| 129 |
+
];
|
| 130 |
+
|
| 131 |
+
function setCollectionStatus(message) {
|
| 132 |
+
collectionStatus.classList.toggle("hidden", !message);
|
| 133 |
+
collectionStatus.textContent = message ?? "";
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
// the wav filename is a uuid — a stable identity even when one source hands
|
| 137 |
+
// out absolute URLs and the other relative ones
|
| 138 |
+
const tapeKey = (url) => url?.split("/").pop();
|
| 139 |
+
|
| 140 |
+
function syncCards() {
|
| 141 |
+
const playing = !audio.paused;
|
| 142 |
+
for (const card of carousel.children) {
|
| 143 |
+
const selected =
|
| 144 |
+
!!currentSong && tapeKey(card.dataset.url) === tapeKey(currentSong.url);
|
| 145 |
+
card.classList.toggle("selected", selected);
|
| 146 |
+
card.classList.toggle("playing", selected && playing);
|
| 147 |
+
}
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
function renderCarousel(songs) {
|
| 151 |
+
carousel.innerHTML = "";
|
| 152 |
+
songs.forEach((song, i) => {
|
| 153 |
+
const shell = SHELL_COLORS[i % SHELL_COLORS.length];
|
| 154 |
+
const card = document.createElement("button");
|
| 155 |
+
card.className = "cassette-card";
|
| 156 |
+
card.dataset.url = song.url;
|
| 157 |
+
card.style.setProperty("--shell1", shell[0]);
|
| 158 |
+
card.style.setProperty("--shell2", shell[1]);
|
| 159 |
+
card.style.animationDelay = `${Math.min(i * 60, 480)}ms`;
|
| 160 |
+
card.title = song.title;
|
| 161 |
+
|
| 162 |
+
const label = document.createElement("div");
|
| 163 |
+
label.className = "cassette-label";
|
| 164 |
+
const labelText = document.createElement("span");
|
| 165 |
+
labelText.textContent = song.title;
|
| 166 |
+
label.appendChild(labelText);
|
| 167 |
+
|
| 168 |
+
const reels = document.createElement("div");
|
| 169 |
+
reels.className = "reels";
|
| 170 |
+
reels.innerHTML =
|
| 171 |
+
'<span class="reel"></span><span class="tape-window"></span><span class="reel"></span>';
|
| 172 |
+
|
| 173 |
+
card.append(label, reels);
|
| 174 |
+
card.addEventListener("click", () => {
|
| 175 |
+
if (tapeKey(currentSong?.url) === tapeKey(song.url)) togglePlay();
|
| 176 |
+
else loadCassette(song, shell);
|
| 177 |
+
});
|
| 178 |
+
carousel.appendChild(card);
|
| 179 |
+
});
|
| 180 |
+
syncCards();
|
| 181 |
+
carousel
|
| 182 |
+
.querySelector(".selected")
|
| 183 |
+
?.scrollIntoView({ inline: "center", block: "nearest" });
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
async function openCollection() {
|
| 187 |
+
collectionOpen = true;
|
| 188 |
+
collectionPanel.classList.remove("hidden");
|
| 189 |
+
deck.classList.remove("hidden");
|
| 190 |
+
syncPill();
|
| 191 |
+
carousel.innerHTML = "";
|
| 192 |
+
setCollectionStatus("rummaging through the tapes…");
|
| 193 |
+
let songs;
|
| 194 |
+
try {
|
| 195 |
+
songs = await listSongs();
|
| 196 |
+
} catch (err) {
|
| 197 |
+
console.warn("[LoFinity] couldn't list tapes:", err);
|
| 198 |
+
if (collectionOpen) setCollectionStatus("the tape shelf is unreachable — try again later");
|
| 199 |
+
return;
|
| 200 |
+
}
|
| 201 |
+
if (!collectionOpen) return; // closed while we were rummaging
|
| 202 |
+
if (!songs.length) {
|
| 203 |
+
setCollectionStatus("no tapes yet — go vend a vibe at the machine ♪");
|
| 204 |
+
return;
|
| 205 |
+
}
|
| 206 |
+
setCollectionStatus(null);
|
| 207 |
+
renderCarousel(songs);
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
function closeCollection() {
|
| 211 |
+
collectionOpen = false;
|
| 212 |
+
collectionPanel.classList.add("hidden");
|
| 213 |
+
deck.classList.add("hidden");
|
| 214 |
+
syncPill();
|
| 215 |
}
|
| 216 |
|
| 217 |
// --- audio wiring ---------------------------------------------------------
|
|
|
|
| 223 |
const playing = !audio.paused;
|
| 224 |
playBtn.textContent = playing ? "❚❚" : "▶";
|
| 225 |
pillToggle.textContent = playing ? "❚❚" : "▶";
|
| 226 |
+
deckPlay.textContent = playing ? "❚❚" : "▶";
|
| 227 |
cassette.classList.toggle("playing", playing);
|
| 228 |
+
deck.classList.toggle("playing", playing);
|
| 229 |
+
syncCards();
|
| 230 |
}
|
| 231 |
|
| 232 |
audio.addEventListener("play", syncPlayState);
|
| 233 |
audio.addEventListener("pause", syncPlayState);
|
| 234 |
audio.addEventListener("timeupdate", () => {
|
| 235 |
if (audio.duration) {
|
| 236 |
+
const pct = `${(audio.currentTime / audio.duration) * 100}%`;
|
| 237 |
+
progressFill.style.width = pct;
|
| 238 |
+
deckFill.style.width = pct;
|
| 239 |
}
|
| 240 |
timeEl.textContent = fmt(audio.currentTime);
|
| 241 |
+
deckTime.textContent = fmt(audio.currentTime);
|
| 242 |
});
|
| 243 |
|
| 244 |
const togglePlay = () => (audio.paused ? audio.play() : audio.pause());
|
| 245 |
playBtn.addEventListener("click", togglePlay);
|
| 246 |
pillToggle.addEventListener("click", togglePlay);
|
| 247 |
+
deckPlay.addEventListener("click", () => {
|
| 248 |
+
if (audio.src) togglePlay();
|
| 249 |
+
});
|
| 250 |
|
| 251 |
+
const seek = (bar) => (e) => {
|
| 252 |
if (!audio.duration) return;
|
| 253 |
+
const rect = bar.getBoundingClientRect();
|
| 254 |
audio.currentTime = ((e.clientX - rect.left) / rect.width) * audio.duration;
|
| 255 |
+
};
|
| 256 |
+
progress.addEventListener("click", seek(progress));
|
| 257 |
+
deckProgress.addEventListener("click", seek(deckProgress));
|
| 258 |
+
|
| 259 |
+
$("collection-close").addEventListener("click", () => onRequestCloseCollection());
|
| 260 |
+
$("caro-prev").addEventListener("click", () =>
|
| 261 |
+
carousel.scrollBy({ left: -440, behavior: "smooth" })
|
| 262 |
+
);
|
| 263 |
+
$("caro-next").addEventListener("click", () =>
|
| 264 |
+
carousel.scrollBy({ left: 440, behavior: "smooth" })
|
| 265 |
+
);
|
| 266 |
|
| 267 |
// --- modal controls ---------------------------------------------------------
|
| 268 |
|
|
|
|
| 286 |
});
|
| 287 |
|
| 288 |
function syncPill() {
|
| 289 |
+
// the corner pill yields to the machine modal and the tape deck
|
| 290 |
const modalHidden = modal.classList.contains("hidden");
|
| 291 |
+
pill.classList.toggle("hidden", !(audio.src && modalHidden && !collectionOpen));
|
| 292 |
}
|
| 293 |
|
| 294 |
let closeTimer = null;
|
|
|
|
| 315 |
}, 300);
|
| 316 |
syncPill();
|
| 317 |
},
|
| 318 |
+
openCollection,
|
| 319 |
+
closeCollection,
|
| 320 |
};
|
| 321 |
}
|