eloigil6 commited on
Commit
8aeb502
·
1 Parent(s): 1d6cdd5

Implement vending machine modal and audio generation features. Updated app.py to include audio URL for browser clients, enhanced index.html with a modal for user interaction, and added corresponding styles in style.css. Improved main.js to manage camera transitions and modal behavior during audio generation.

Browse files
Files changed (6) hide show
  1. app.py +3 -1
  2. frontend/index.html +60 -1
  3. frontend/main.js +121 -22
  4. frontend/style.css +558 -0
  5. frontend/ui.js +174 -0
  6. frontend/world.js +4 -0
app.py CHANGED
@@ -46,7 +46,9 @@ def generate_song(prompt: str) -> dict:
46
 
47
  return {
48
  "title": f"Demo Tape — {prompt[:32] or 'untitled'}",
49
- "audio": FileData(path=str(out)),
 
 
50
  }
51
 
52
 
 
46
 
47
  return {
48
  "title": f"Demo Tape — {prompt[:32] or 'untitled'}",
49
+ # url is what browser clients play from; the path alone only works
50
+ # for the python client, which builds the file URL itself
51
+ "audio": FileData(path=str(out), url=f"/gradio_api/file={out}"),
52
  }
53
 
54
 
frontend/index.html CHANGED
@@ -6,7 +6,7 @@
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 href="https://fonts.googleapis.com/css2?family=Baloo+2:wght@600;800&display=swap" rel="stylesheet" />
10
  <link rel="stylesheet" href="/static/style.css" />
11
  <script type="importmap">
12
  {
@@ -22,6 +22,65 @@
22
  <p id="subtitle">♪ chill beats, freshly vended</p>
23
  </div>
24
  <div id="vending-label"><span>♪ vend a vibe</span></div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
  <canvas id="scene"></canvas>
26
  <script type="module" src="/static/main.js"></script>
27
  </body>
 
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 href="https://fonts.googleapis.com/css2?family=Baloo+2:wght@600;800&family=DotGothic16&display=swap" rel="stylesheet" />
10
  <link rel="stylesheet" href="/static/style.css" />
11
  <script type="importmap">
12
  {
 
22
  <p id="subtitle">♪ chill beats, freshly vended</p>
23
  </div>
24
  <div id="vending-label"><span>♪ vend a vibe</span></div>
25
+
26
+ <div id="machine-modal" class="hidden">
27
+ <button id="modal-close" aria-label="close">×</button>
28
+ <div id="led-screen">
29
+ <p class="led-title">PICK YOUR VIBE<span class="led-cursor">_</span></p>
30
+ <p class="led-sub">TELL THE MACHINE WHAT TO BREW</p>
31
+ <textarea id="prompt-input" rows="3" maxlength="200"
32
+ placeholder="rainy night in kyoto, soft piano, vinyl crackle..."></textarea>
33
+ <div id="generating" class="hidden">
34
+ <div class="eq"><i></i><i></i><i></i><i></i><i></i></div>
35
+ <p class="led-status">BREWING YOUR BEAT<span class="dots">...</span></p>
36
+ </div>
37
+ <p id="error-msg" class="hidden"></p>
38
+ </div>
39
+ <div id="controls-row">
40
+ <div id="coin-slot-area">
41
+ <div id="coin-slot"></div>
42
+ <svg id="wado-coin" viewBox="0 0 100 100" aria-hidden="true">
43
+ <defs>
44
+ <radialGradient id="bronze" cx="35%" cy="30%" r="80%">
45
+ <stop offset="0%" stop-color="#dcb670" />
46
+ <stop offset="65%" stop-color="#b08540" />
47
+ <stop offset="100%" stop-color="#8a6630" />
48
+ </radialGradient>
49
+ </defs>
50
+ <circle cx="50" cy="50" r="47" fill="url(#bronze)" stroke="#7a5a28" stroke-width="4" />
51
+ <circle cx="50" cy="50" r="38" fill="none" stroke="#8a6a35" stroke-width="1.5" opacity="0.6" />
52
+ <rect x="41" y="41" width="18" height="18" fill="#3b2c16" stroke="#7a5a28" stroke-width="3" />
53
+ <text x="50" y="26" text-anchor="middle" dominant-baseline="central" class="wado-kanji">和</text>
54
+ <text x="75" y="51" text-anchor="middle" dominant-baseline="central" class="wado-kanji">同</text>
55
+ <text x="50" y="76" text-anchor="middle" dominant-baseline="central" class="wado-kanji">開</text>
56
+ <text x="25" y="51" text-anchor="middle" dominant-baseline="central" class="wado-kanji">珎</text>
57
+ </svg>
58
+ </div>
59
+ <button id="coin-button">INSERT COIN</button>
60
+ </div>
61
+ <div id="cassette-stage" class="hidden">
62
+ <div id="cassette">
63
+ <div class="cassette-label"><span id="cassette-title"></span></div>
64
+ <div class="reels">
65
+ <span class="reel"></span>
66
+ <span class="tape-window"></span>
67
+ <span class="reel"></span>
68
+ </div>
69
+ </div>
70
+ <div id="player">
71
+ <button id="play-btn" aria-label="play/pause">▶</button>
72
+ <div id="progress"><div id="progress-fill"></div></div>
73
+ <span id="time">0:00</span>
74
+ </div>
75
+ <button id="again-btn">MAKE ANOTHER</button>
76
+ </div>
77
+ </div>
78
+
79
+ <div id="now-playing" class="hidden">
80
+ <button id="np-toggle" aria-label="play/pause">❚❚</button>
81
+ <span id="np-title"></span>
82
+ </div>
83
+ <audio id="tape-audio"></audio>
84
  <canvas id="scene"></canvas>
85
  <script type="module" src="/static/main.js"></script>
86
  </body>
frontend/main.js CHANGED
@@ -4,6 +4,7 @@
4
 
5
  import * as THREE from "three";
6
  import { buildWorld } from "/static/world.js";
 
7
 
8
  // ---------------------------------------------------------------------------
9
  // Renderer / scene / camera
@@ -138,7 +139,7 @@ function setVendingHover(hovered) {
138
  }
139
 
140
  function updateVendingHover() {
141
- if (intro.phase !== "idle" || !pointerActive) {
142
  setVendingHover(false);
143
  return;
144
  }
@@ -154,12 +155,95 @@ function updateVendingHover() {
154
  }
155
  }
156
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
157
  canvas.addEventListener("click", () => {
158
- if (!vendingHovered) return;
159
- // Stage 3 continues here: camera zoom + prompt/coin modal
160
- console.log("[LoFinity] vending machine clicked");
161
  });
162
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
163
  // ---------------------------------------------------------------------------
164
  // Intro: hold on the sky, then ease the gaze down to the scene
165
  // ---------------------------------------------------------------------------
@@ -190,7 +274,7 @@ Promise.all([pageLoaded, document.fonts.ready]).then(() =>
190
  const easeInOutCubic = (t) =>
191
  t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
192
 
193
- // Dev helpers: jump straight to either end of the intro.
194
  window.lofinityDebug = {
195
  skipIntro() {
196
  intro.phase = "idle";
@@ -205,6 +289,16 @@ window.lofinityDebug = {
205
  lookTarget.copy(LOOK_SKY);
206
  overlay.classList.remove("hidden");
207
  },
 
 
 
 
 
 
 
 
 
 
208
  };
209
 
210
  // ---------------------------------------------------------------------------
@@ -234,7 +328,21 @@ function animate() {
234
  intro.phase = "idle";
235
  intro.idleStart = elapsed;
236
  }
237
- } else if (intro.phase === "idle") {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
238
  // Gentle lofi sway, ramping in so the descent lands without a jolt
239
  const tSway = elapsed - intro.idleStart;
240
  const ramp = Math.min(1, tSway / 5);
@@ -242,25 +350,16 @@ function animate() {
242
  camera.position.y = CAM_END.y + Math.sin(tSway * 0.4) * 0.12 * ramp;
243
  }
244
 
 
 
 
 
 
 
 
245
  camera.lookAt(lookTarget);
246
  updateVendingHover();
247
  renderer.render(scene, camera);
248
  }
249
  animate();
250
 
251
- // ---------------------------------------------------------------------------
252
- // Backend bridge (stub check — UI hooks come with the vending machine modal)
253
- // ---------------------------------------------------------------------------
254
-
255
- import("https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js")
256
- .then(async ({ Client }) => {
257
- const client = await Client.connect(window.location.origin);
258
- window.lofinity = {
259
- generate: async (prompt) => {
260
- const result = await client.predict("/generate_song", { prompt });
261
- return result.data[0];
262
- },
263
- };
264
- console.log("[LoFinity] backend connected — try: await lofinity.generate('rainy night')");
265
- })
266
- .catch((err) => console.warn("[LoFinity] backend not reachable:", err));
 
4
 
5
  import * as THREE from "three";
6
  import { buildWorld } from "/static/world.js";
7
+ import { initUI } from "/static/ui.js";
8
 
9
  // ---------------------------------------------------------------------------
10
  // Renderer / scene / camera
 
139
  }
140
 
141
  function updateVendingHover() {
142
+ if (intro.phase !== "idle" || view.mode !== "scene" || !pointerActive) {
143
  setVendingHover(false);
144
  return;
145
  }
 
155
  }
156
  }
157
 
158
+ // ---------------------------------------------------------------------------
159
+ // Machine view: zoom in on click, modal while zoomed, zoom back out on close
160
+ // ---------------------------------------------------------------------------
161
+
162
+ // Frames the machine on the left so the modal card sits beside it.
163
+ const MACHINE_CAM = new THREE.Vector3(-2.3, 3.0, 10.8);
164
+ const MACHINE_LOOK = new THREE.Vector3(-2.9, 2.6, 4.2);
165
+ const ZOOM_MS = 1500;
166
+
167
+ const view = {
168
+ mode: "scene", // scene | zoom-in | machine | zoom-out
169
+ t0: 0,
170
+ camFrom: new THREE.Vector3(),
171
+ camTo: new THREE.Vector3(),
172
+ lookFrom: new THREE.Vector3(),
173
+ lookTo: new THREE.Vector3(),
174
+ };
175
+
176
+ function startViewTransition(mode, camTo, lookTo) {
177
+ view.mode = mode;
178
+ view.t0 = performance.now();
179
+ view.camFrom.copy(camera.position);
180
+ view.lookFrom.copy(lookTarget);
181
+ view.camTo.copy(camTo);
182
+ view.lookTo.copy(lookTo);
183
+ }
184
+
185
+ function zoomToMachine() {
186
+ setVendingHover(false);
187
+ startViewTransition("zoom-in", MACHINE_CAM, MACHINE_LOOK);
188
+ }
189
+
190
+ function closeMachineView() {
191
+ ui.closeModal();
192
+ startViewTransition("zoom-out", CAM_END, LOOK_SCENE);
193
+ }
194
+
195
  canvas.addEventListener("click", () => {
196
+ if (vendingHovered && view.mode === "scene" && intro.phase === "idle") {
197
+ zoomToMachine();
198
+ }
199
  });
200
 
201
+ // Machine glow while generating
202
+ let machineBusy = false;
203
+ const SCREEN_BASE = new THREE.Color(0x1c2f28);
204
+ const DISPENSER_BASE = new THREE.Color(0x10181d);
205
+
206
+ function setMachineBusy(busy) {
207
+ machineBusy = busy;
208
+ if (!busy) {
209
+ world.vending.userData.screenMaterial.color.copy(SCREEN_BASE);
210
+ world.vending.userData.dispenserMaterial.color.copy(DISPENSER_BASE);
211
+ }
212
+ }
213
+
214
+ // ---------------------------------------------------------------------------
215
+ // Backend bridge + modal UI
216
+ // ---------------------------------------------------------------------------
217
+
218
+ let generateFn = null;
219
+
220
+ const ui = initUI({
221
+ generate: (prompt) => {
222
+ if (!generateFn) return Promise.reject(new Error("backend not connected"));
223
+ return generateFn(prompt);
224
+ },
225
+ onRequestClose: () => {
226
+ if (view.mode === "machine") closeMachineView();
227
+ },
228
+ onGeneratingChange: setMachineBusy,
229
+ });
230
+
231
+ import("https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js")
232
+ .then(async ({ Client }) => {
233
+ const client = await Client.connect(window.location.origin);
234
+ generateFn = async (prompt) => {
235
+ const result = await client.predict("/generate_song", { prompt });
236
+ const data = result.data[0];
237
+ const url =
238
+ data.audio?.url ?? (data.audio?.path ? `/gradio_api/file=${data.audio.path}` : null);
239
+ if (!url) throw new Error("no audio in response");
240
+ return { title: data.title, url };
241
+ };
242
+ window.lofinity = { generate: generateFn };
243
+ console.log("[LoFinity] backend connected");
244
+ })
245
+ .catch((err) => console.warn("[LoFinity] backend not reachable:", err));
246
+
247
  // ---------------------------------------------------------------------------
248
  // Intro: hold on the sky, then ease the gaze down to the scene
249
  // ---------------------------------------------------------------------------
 
274
  const easeInOutCubic = (t) =>
275
  t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
276
 
277
+ // Dev helpers: jump straight to any app state.
278
  window.lofinityDebug = {
279
  skipIntro() {
280
  intro.phase = "idle";
 
289
  lookTarget.copy(LOOK_SKY);
290
  overlay.classList.remove("hidden");
291
  },
292
+ openMachine() {
293
+ this.skipIntro();
294
+ view.mode = "machine";
295
+ camera.position.copy(MACHINE_CAM);
296
+ lookTarget.copy(MACHINE_LOOK);
297
+ ui.openModal();
298
+ },
299
+ closeMachine() {
300
+ closeMachineView();
301
+ },
302
  };
303
 
304
  // ---------------------------------------------------------------------------
 
328
  intro.phase = "idle";
329
  intro.idleStart = elapsed;
330
  }
331
+ } else if (view.mode === "zoom-in" || view.mode === "zoom-out") {
332
+ const t = Math.min((performance.now() - view.t0) / ZOOM_MS, 1);
333
+ const e = easeInOutCubic(t);
334
+ camera.position.lerpVectors(view.camFrom, view.camTo, e);
335
+ lookTarget.lerpVectors(view.lookFrom, view.lookTo, e);
336
+ if (t >= 1) {
337
+ if (view.mode === "zoom-in") {
338
+ view.mode = "machine";
339
+ ui.openModal();
340
+ } else {
341
+ view.mode = "scene";
342
+ intro.idleStart = elapsed; // sway ramps back in
343
+ }
344
+ }
345
+ } else if (intro.phase === "idle" && view.mode === "scene") {
346
  // Gentle lofi sway, ramping in so the descent lands without a jolt
347
  const tSway = elapsed - intro.idleStart;
348
  const ramp = Math.min(1, tSway / 5);
 
350
  camera.position.y = CAM_END.y + Math.sin(tSway * 0.4) * 0.12 * ramp;
351
  }
352
 
353
+ if (machineBusy) {
354
+ // the machine hums while it brews
355
+ const pulse = 0.5 + 0.5 * Math.sin(elapsed * 6);
356
+ world.vending.userData.screenMaterial.color.setHSL(0.38, 0.55, 0.12 + 0.3 * pulse);
357
+ world.vending.userData.dispenserMaterial.color.setHSL(0.13, 0.85, 0.08 + 0.3 * pulse);
358
+ }
359
+
360
  camera.lookAt(lookTarget);
361
  updateVendingHover();
362
  renderer.render(scene, camera);
363
  }
364
  animate();
365
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/style.css CHANGED
@@ -153,3 +153,561 @@ body {
153
  transform: translateY(-7px) rotate(1.2deg);
154
  }
155
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
153
  transform: translateY(-7px) rotate(1.2deg);
154
  }
155
  }
156
+
157
+ /* --- vending machine modal: machine control panel -------------------------- */
158
+
159
+ #machine-modal {
160
+ position: fixed;
161
+ top: 50%;
162
+ right: 5vw;
163
+ transform: translateY(-50%);
164
+ transform-origin: 100% 0%;
165
+ width: min(390px, 86vw);
166
+ box-sizing: border-box;
167
+ background: linear-gradient(165deg, #8ed0f4, #6db8e0 70%, #5fabd6);
168
+ border: 3px solid #4f9cc8;
169
+ border-radius: 20px;
170
+ box-shadow:
171
+ inset 0 2px 0 rgba(255, 255, 255, 0.55),
172
+ inset 0 -3px 8px rgba(31, 79, 116, 0.35),
173
+ 0 24px 70px rgba(15, 40, 90, 0.4);
174
+ padding: 22px 20px 18px;
175
+ font-family: "DotGothic16", "Baloo 2", monospace;
176
+ color: #1d2a40;
177
+ z-index: 20;
178
+ }
179
+
180
+ #machine-modal.hidden {
181
+ display: none;
182
+ }
183
+
184
+ /* anime panel entrance: a thin slash from the corner that springs open */
185
+ #machine-modal.opening {
186
+ animation: modal-in 0.55s cubic-bezier(0.25, 0.9, 0.35, 1.2) both;
187
+ }
188
+
189
+ @keyframes modal-in {
190
+ 0% {
191
+ transform: translateY(-50%) scale(0.04, 0.04) skewX(-10deg);
192
+ opacity: 0;
193
+ }
194
+ 35% {
195
+ transform: translateY(-50%) scale(1.06, 0.07) skewX(-7deg);
196
+ opacity: 1;
197
+ }
198
+ 62% {
199
+ transform: translateY(-50%) scale(0.99, 1.06) skewX(2.5deg);
200
+ }
201
+ 80% {
202
+ transform: translateY(-50%) scale(1.01, 0.98) skewX(-1deg);
203
+ }
204
+ 100% {
205
+ transform: translateY(-50%) scale(1, 1) skewX(0deg);
206
+ }
207
+ }
208
+
209
+ #machine-modal.closing {
210
+ animation: modal-out 0.3s ease-in both;
211
+ }
212
+
213
+ @keyframes modal-out {
214
+ 0% {
215
+ transform: translateY(-50%) scale(1, 1);
216
+ opacity: 1;
217
+ }
218
+ 45% {
219
+ transform: translateY(-50%) scale(1.04, 0.06) skewX(-6deg);
220
+ opacity: 1;
221
+ }
222
+ 100% {
223
+ transform: translateY(-50%) scale(0.03, 0.03) skewX(-10deg);
224
+ opacity: 0;
225
+ }
226
+ }
227
+
228
+ #modal-close {
229
+ position: absolute;
230
+ top: 12px;
231
+ right: 14px;
232
+ width: 32px;
233
+ height: 32px;
234
+ border-radius: 50%;
235
+ border: 2px solid #16243c;
236
+ background: #2b3a55;
237
+ color: #cfe0f0;
238
+ font-size: 1.05rem;
239
+ line-height: 1;
240
+ cursor: pointer;
241
+ z-index: 2;
242
+ }
243
+
244
+ #modal-close:hover {
245
+ background: #16243c;
246
+ }
247
+
248
+ /* --- LED screen ------------------------------------------------------------ */
249
+
250
+ #led-screen {
251
+ position: relative;
252
+ background: #0b120d;
253
+ border: 2px solid #14241a;
254
+ border-radius: 12px;
255
+ box-shadow: inset 0 0 22px rgba(0, 0, 0, 0.85);
256
+ padding: 14px 14px 12px;
257
+ overflow: hidden;
258
+ }
259
+
260
+ #led-screen::after {
261
+ content: "";
262
+ position: absolute;
263
+ inset: 0;
264
+ pointer-events: none;
265
+ background: repeating-linear-gradient(
266
+ to bottom,
267
+ transparent 0 2px,
268
+ rgba(0, 0, 0, 0.22) 2px 3px
269
+ );
270
+ border-radius: inherit;
271
+ }
272
+
273
+ .led-title {
274
+ margin: 0;
275
+ font-family: "DotGothic16", monospace;
276
+ font-size: 1.3rem;
277
+ letter-spacing: 0.1em;
278
+ color: #5dff8d;
279
+ text-shadow: 0 0 8px rgba(93, 255, 141, 0.7), 0 0 2px rgba(93, 255, 141, 0.9);
280
+ animation: led-flicker 4s steps(1) infinite;
281
+ }
282
+
283
+ .led-cursor {
284
+ animation: blink 1.1s steps(1) infinite;
285
+ }
286
+
287
+ .led-sub {
288
+ margin: 4px 0 0;
289
+ font-family: "DotGothic16", monospace;
290
+ font-size: 0.78rem;
291
+ letter-spacing: 0.12em;
292
+ color: #3fd470;
293
+ opacity: 0.8;
294
+ }
295
+
296
+ @keyframes led-flicker {
297
+ 0%, 93%, 100% { opacity: 1; }
298
+ 94% { opacity: 0.75; }
299
+ 95% { opacity: 1; }
300
+ 97% { opacity: 0.85; }
301
+ 98% { opacity: 1; }
302
+ }
303
+
304
+ #prompt-input {
305
+ width: 100%;
306
+ box-sizing: border-box;
307
+ margin-top: 12px;
308
+ font-family: "DotGothic16", monospace;
309
+ font-size: 1rem;
310
+ letter-spacing: 0.04em;
311
+ color: #5dff8d;
312
+ caret-color: #5dff8d;
313
+ text-shadow: 0 0 6px rgba(93, 255, 141, 0.5);
314
+ padding: 10px 12px;
315
+ border-radius: 8px;
316
+ border: 1px dashed rgba(93, 255, 141, 0.35);
317
+ background: rgba(0, 0, 0, 0.3);
318
+ resize: none;
319
+ outline: none;
320
+ }
321
+
322
+ #prompt-input::placeholder {
323
+ color: rgba(93, 255, 141, 0.3);
324
+ text-shadow: none;
325
+ }
326
+
327
+ #prompt-input:focus {
328
+ border: 1px solid rgba(93, 255, 141, 0.8);
329
+ }
330
+
331
+ #prompt-input:disabled {
332
+ opacity: 0.5;
333
+ }
334
+
335
+ #prompt-input.shake {
336
+ animation: shake 0.4s ease;
337
+ }
338
+
339
+ @keyframes shake {
340
+ 0%, 100% { transform: translateX(0); }
341
+ 25% { transform: translateX(-6px); }
342
+ 50% { transform: translateX(6px); }
343
+ 75% { transform: translateX(-4px); }
344
+ }
345
+
346
+ #generating {
347
+ margin-top: 14px;
348
+ text-align: center;
349
+ }
350
+
351
+ .led-status {
352
+ margin: 10px 0 4px;
353
+ font-family: "DotGothic16", monospace;
354
+ font-size: 0.95rem;
355
+ letter-spacing: 0.1em;
356
+ color: #5dff8d;
357
+ text-shadow: 0 0 8px rgba(93, 255, 141, 0.7);
358
+ }
359
+
360
+ .dots {
361
+ animation: blink 1.4s steps(1) infinite;
362
+ }
363
+
364
+ @keyframes blink {
365
+ 0%, 100% { opacity: 1; }
366
+ 50% { opacity: 0.15; }
367
+ }
368
+
369
+ .eq {
370
+ display: flex;
371
+ gap: 5px;
372
+ justify-content: center;
373
+ align-items: flex-end;
374
+ height: 34px;
375
+ }
376
+
377
+ .eq i {
378
+ width: 7px;
379
+ border-radius: 2px;
380
+ background: linear-gradient(#5dff8d, #1f8f4a);
381
+ box-shadow: 0 0 6px rgba(93, 255, 141, 0.5);
382
+ animation: eq-bounce 1s ease-in-out infinite;
383
+ }
384
+
385
+ .eq i:nth-child(1) { height: 40%; animation-delay: 0s; }
386
+ .eq i:nth-child(2) { height: 75%; animation-delay: 0.15s; }
387
+ .eq i:nth-child(3) { height: 100%; animation-delay: 0.3s; }
388
+ .eq i:nth-child(4) { height: 65%; animation-delay: 0.45s; }
389
+ .eq i:nth-child(5) { height: 45%; animation-delay: 0.6s; }
390
+
391
+ @keyframes eq-bounce {
392
+ 0%, 100% { transform: scaleY(0.5); }
393
+ 50% { transform: scaleY(1); }
394
+ }
395
+
396
+ /* --- coin slot + red button -------------------------------------------------- */
397
+
398
+ #controls-row {
399
+ margin-top: 16px;
400
+ display: flex;
401
+ align-items: center;
402
+ gap: 14px;
403
+ }
404
+
405
+ #coin-slot-area {
406
+ position: relative;
407
+ flex: none;
408
+ width: 64px;
409
+ height: 64px;
410
+ display: flex;
411
+ align-items: center;
412
+ justify-content: center;
413
+ background: linear-gradient(#5fabd6, #4f9cc8);
414
+ border: 2px solid #3f86ad;
415
+ border-radius: 10px;
416
+ box-shadow: inset 0 2px 4px rgba(255, 255, 255, 0.35), inset 0 -3px 5px rgba(31, 79, 116, 0.4);
417
+ perspective: 300px;
418
+ }
419
+
420
+ #coin-slot {
421
+ width: 10px;
422
+ height: 40px;
423
+ background: #0e1116;
424
+ border-radius: 5px;
425
+ box-shadow: inset 0 0 5px #000, 0 1px 0 rgba(255, 255, 255, 0.4);
426
+ }
427
+
428
+ #wado-coin {
429
+ position: absolute;
430
+ width: 46px;
431
+ height: 46px;
432
+ left: 50%;
433
+ top: 50%;
434
+ margin: -23px 0 0 -23px;
435
+ opacity: 0;
436
+ pointer-events: none;
437
+ filter: drop-shadow(0 3px 4px rgba(20, 30, 50, 0.45));
438
+ }
439
+
440
+ .wado-kanji {
441
+ font-family: "Hiragino Mincho ProN", "Yu Mincho", serif;
442
+ font-size: 21px;
443
+ fill: #5d431d;
444
+ }
445
+
446
+ .inserting #wado-coin {
447
+ animation: coin-insert 1.15s ease-in-out forwards;
448
+ }
449
+
450
+ @keyframes coin-insert {
451
+ 0% {
452
+ opacity: 0;
453
+ transform: translate(-58px, 16px) rotate(-30deg) scale(0.3);
454
+ }
455
+ 18% {
456
+ opacity: 1;
457
+ transform: translate(-40px, -8px) rotate(-10deg) scale(1.25);
458
+ }
459
+ 42% {
460
+ opacity: 1;
461
+ transform: translate(0, -16px) rotate(0deg) scale(1.1);
462
+ }
463
+ 60% {
464
+ opacity: 1;
465
+ transform: translate(0, -16px) rotateY(80deg) scale(1);
466
+ }
467
+ 78% {
468
+ opacity: 1;
469
+ transform: translate(0, 6px) rotateY(80deg) scale(0.95);
470
+ }
471
+ 100% {
472
+ opacity: 0;
473
+ transform: translate(0, 26px) rotateY(80deg) scale(0.9);
474
+ }
475
+ }
476
+
477
+ .inserting #coin-slot {
478
+ animation: slot-flash 0.35s ease 0.95s;
479
+ }
480
+
481
+ @keyframes slot-flash {
482
+ 50% { box-shadow: inset 0 0 5px #000, 0 0 12px rgba(93, 255, 141, 0.9); }
483
+ }
484
+
485
+ #coin-button {
486
+ flex: 1;
487
+ padding: 16px 10px;
488
+ border-radius: 12px;
489
+ border: 2px solid #a32119;
490
+ background: linear-gradient(#ff6a5e, #e03a30 55%, #c52d24);
491
+ color: #fff;
492
+ font-family: "DotGothic16", monospace;
493
+ font-size: 1.2rem;
494
+ letter-spacing: 0.12em;
495
+ text-shadow: 0 2px 3px rgba(0, 0, 0, 0.45);
496
+ cursor: pointer;
497
+ box-shadow: 0 5px 0 #8f1d18, inset 0 2px 3px rgba(255, 255, 255, 0.4);
498
+ }
499
+
500
+ #coin-button:active {
501
+ transform: translateY(3px);
502
+ box-shadow: 0 2px 0 #8f1d18, inset 0 2px 3px rgba(255, 255, 255, 0.4);
503
+ }
504
+
505
+ #coin-button:disabled {
506
+ cursor: default;
507
+ filter: saturate(0.7) brightness(0.92);
508
+ }
509
+
510
+ #cassette-stage {
511
+ margin-top: 16px;
512
+ }
513
+
514
+ #cassette {
515
+ background: linear-gradient(#3a4254, #2c3344);
516
+ border-radius: 14px;
517
+ padding-bottom: 4px;
518
+ box-shadow: inset 0 2px 6px rgba(255, 255, 255, 0.12), 0 8px 22px rgba(15, 40, 90, 0.3);
519
+ animation: cassette-out 0.6s cubic-bezier(0.34, 1.56, 0.64, 1) both;
520
+ }
521
+
522
+ @keyframes cassette-out {
523
+ from {
524
+ transform: translateY(56px) scale(0.92);
525
+ opacity: 0;
526
+ }
527
+ to {
528
+ transform: translateY(0) scale(1);
529
+ opacity: 1;
530
+ }
531
+ }
532
+
533
+ .cassette-label {
534
+ background: #f6f1e3;
535
+ border: 2px solid #e0d6bd;
536
+ border-radius: 8px;
537
+ margin: 12px 14px 10px;
538
+ padding: 8px 10px;
539
+ text-align: center;
540
+ font-weight: 800;
541
+ font-size: 0.95rem;
542
+ color: #4a3b2c;
543
+ white-space: nowrap;
544
+ overflow: hidden;
545
+ text-overflow: ellipsis;
546
+ }
547
+
548
+ .reels {
549
+ display: flex;
550
+ align-items: center;
551
+ padding: 0 28px 12px;
552
+ }
553
+
554
+ .reel {
555
+ width: 34px;
556
+ height: 34px;
557
+ border-radius: 50%;
558
+ border: 5px dashed #cfd6e4;
559
+ background: #1d232f;
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 { transform: rotate(360deg); }
569
+ }
570
+
571
+ .tape-window {
572
+ flex: 1;
573
+ height: 16px;
574
+ margin: 0 12px;
575
+ background: #1d232f;
576
+ border-radius: 8px;
577
+ }
578
+
579
+ #player {
580
+ display: flex;
581
+ align-items: center;
582
+ gap: 10px;
583
+ margin-top: 14px;
584
+ }
585
+
586
+ #play-btn,
587
+ #np-toggle {
588
+ width: 44px;
589
+ height: 44px;
590
+ flex: none;
591
+ border-radius: 50%;
592
+ border: none;
593
+ background: #ffd84a;
594
+ color: #3a2c14;
595
+ font-size: 1rem;
596
+ cursor: pointer;
597
+ box-shadow: 0 3px 0 #d9ab1e;
598
+ }
599
+
600
+ #play-btn:active,
601
+ #np-toggle:active {
602
+ transform: translateY(2px);
603
+ box-shadow: 0 1px 0 #d9ab1e;
604
+ }
605
+
606
+ #progress {
607
+ flex: 1;
608
+ height: 8px;
609
+ border-radius: 4px;
610
+ background: rgba(13, 26, 43, 0.4);
611
+ cursor: pointer;
612
+ overflow: hidden;
613
+ }
614
+
615
+ #progress-fill {
616
+ height: 100%;
617
+ width: 0%;
618
+ border-radius: 4px;
619
+ background: linear-gradient(90deg, #5dff8d, #2ea65a);
620
+ box-shadow: 0 0 6px rgba(93, 255, 141, 0.6);
621
+ }
622
+
623
+ #time {
624
+ font-family: "DotGothic16", monospace;
625
+ font-size: 0.85rem;
626
+ color: #16213a;
627
+ min-width: 38px;
628
+ text-align: right;
629
+ }
630
+
631
+ #again-btn {
632
+ margin-top: 14px;
633
+ width: 100%;
634
+ padding: 11px;
635
+ border-radius: 12px;
636
+ border: 2px solid #16213a;
637
+ background: linear-gradient(#3a4c6e, #2b3a55);
638
+ color: #cfe0f0;
639
+ font-family: "DotGothic16", monospace;
640
+ font-size: 1rem;
641
+ letter-spacing: 0.12em;
642
+ cursor: pointer;
643
+ box-shadow: 0 4px 0 #16213a, inset 0 2px 2px rgba(255, 255, 255, 0.18);
644
+ }
645
+
646
+ #again-btn:hover {
647
+ background: linear-gradient(#465a80, #324466);
648
+ }
649
+
650
+ #again-btn:active {
651
+ transform: translateY(2px);
652
+ box-shadow: 0 2px 0 #16213a, inset 0 2px 2px rgba(255, 255, 255, 0.18);
653
+ }
654
+
655
+ #error-msg {
656
+ font-family: "DotGothic16", monospace;
657
+ color: #ff6b5e;
658
+ text-shadow: 0 0 8px rgba(255, 107, 94, 0.7);
659
+ font-size: 0.88rem;
660
+ letter-spacing: 0.08em;
661
+ margin: 10px 0 0;
662
+ text-align: center;
663
+ }
664
+
665
+ #error-msg.hidden,
666
+ #controls-row.hidden,
667
+ #prompt-input.hidden,
668
+ #generating.hidden,
669
+ #cassette-stage.hidden {
670
+ display: none;
671
+ }
672
+
673
+ /* --- now playing pill ------------------------------------------------------ */
674
+
675
+ #now-playing {
676
+ position: fixed;
677
+ left: 18px;
678
+ bottom: 18px;
679
+ z-index: 15;
680
+ display: flex;
681
+ align-items: center;
682
+ gap: 10px;
683
+ padding: 8px 18px 8px 8px;
684
+ background: rgba(255, 255, 255, 0.92);
685
+ backdrop-filter: blur(8px);
686
+ -webkit-backdrop-filter: blur(8px);
687
+ border-radius: 999px;
688
+ box-shadow: 0 8px 24px rgba(15, 40, 90, 0.25);
689
+ font-family: "Baloo 2", sans-serif;
690
+ font-weight: 700;
691
+ font-size: 0.95rem;
692
+ color: #2b3a55;
693
+ transition: opacity 0.3s ease, transform 0.3s ease;
694
+ }
695
+
696
+ #now-playing.hidden {
697
+ opacity: 0;
698
+ transform: translateY(12px);
699
+ pointer-events: none;
700
+ }
701
+
702
+ #now-playing #np-toggle {
703
+ width: 36px;
704
+ height: 36px;
705
+ font-size: 0.85rem;
706
+ }
707
+
708
+ #np-title {
709
+ max-width: 230px;
710
+ white-space: nowrap;
711
+ overflow: hidden;
712
+ text-overflow: ellipsis;
713
+ }
frontend/ui.js ADDED
@@ -0,0 +1,174 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // LoFinity — vending machine modal: prompt → coin drop → brewing → cassette.
2
+ // main.js owns the camera; this module owns the DOM and the audio element.
3
+
4
+ const $ = (id) => document.getElementById(id);
5
+ const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
6
+
7
+ export function initUI({ generate, onRequestClose, onGeneratingChange }) {
8
+ const modal = $("machine-modal");
9
+ const input = $("prompt-input");
10
+ const coinBtn = $("coin-button");
11
+ const controlsRow = $("controls-row");
12
+ const generating = $("generating");
13
+ const cassetteStage = $("cassette-stage");
14
+ const cassette = $("cassette");
15
+ const cassetteTitle = $("cassette-title");
16
+ const errorMsg = $("error-msg");
17
+ const audio = $("tape-audio");
18
+ const playBtn = $("play-btn");
19
+ const progress = $("progress");
20
+ const progressFill = $("progress-fill");
21
+ const timeEl = $("time");
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");
31
+ generating.classList.toggle("hidden", stage !== "generating");
32
+ cassetteStage.classList.toggle("hidden", stage !== "cassette");
33
+ input.classList.toggle("hidden", stage === "cassette");
34
+ input.disabled = stage === "generating";
35
+ if (stage === "prompt") {
36
+ controlsRow.classList.remove("inserting");
37
+ coinBtn.disabled = false;
38
+ }
39
+ }
40
+
41
+ function currentStage() {
42
+ if (busy) return "generating";
43
+ return audio.src ? "cassette" : "prompt";
44
+ }
45
+
46
+ function showError(message) {
47
+ errorMsg.textContent = message;
48
+ errorMsg.classList.remove("hidden");
49
+ }
50
+
51
+ async function startGeneration() {
52
+ if (busy) return;
53
+ const prompt = input.value.trim();
54
+ if (!prompt) {
55
+ input.classList.remove("shake");
56
+ void input.offsetWidth; // restart the animation
57
+ input.classList.add("shake");
58
+ input.focus();
59
+ return;
60
+ }
61
+ busy = true;
62
+ errorMsg.classList.add("hidden");
63
+ coinBtn.disabled = true;
64
+ controlsRow.classList.add("inserting");
65
+ await delay(1200); // the wadōkaichin makes its way into the slot
66
+ setStage("generating");
67
+ onGeneratingChange(true);
68
+ try {
69
+ // hold the brewing moment even if the backend is fast
70
+ const [result] = await Promise.all([generate(prompt), delay(2600)]);
71
+ loadCassette(result);
72
+ busy = false;
73
+ setStage("cassette");
74
+ } catch (err) {
75
+ console.error("[LoFinity] generation failed:", err);
76
+ busy = false;
77
+ setStage("prompt");
78
+ showError("the machine jammed — try again?");
79
+ } finally {
80
+ onGeneratingChange(false);
81
+ syncPill();
82
+ }
83
+ }
84
+
85
+ function loadCassette({ title, url }) {
86
+ cassetteTitle.textContent = title;
87
+ pillTitle.textContent = title;
88
+ audio.src = url;
89
+ audio.play().catch(() => {});
90
+ }
91
+
92
+ // --- audio wiring ---------------------------------------------------------
93
+
94
+ const fmt = (s) =>
95
+ `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, "0")}`;
96
+
97
+ function syncPlayState() {
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
+ progressFill.style.width = `${(audio.currentTime / audio.duration) * 100}%`;
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
+ progress.addEventListener("click", (e) => {
118
+ if (!audio.duration) return;
119
+ const rect = progress.getBoundingClientRect();
120
+ audio.currentTime = ((e.clientX - rect.left) / rect.width) * audio.duration;
121
+ });
122
+
123
+ // --- modal controls ---------------------------------------------------------
124
+
125
+ coinBtn.addEventListener("click", startGeneration);
126
+ input.addEventListener("keydown", (e) => {
127
+ if (e.key === "Enter" && !e.shiftKey) {
128
+ e.preventDefault();
129
+ startGeneration();
130
+ }
131
+ });
132
+ $("again-btn").addEventListener("click", () => {
133
+ setStage("prompt");
134
+ input.focus();
135
+ input.select();
136
+ });
137
+ $("modal-close").addEventListener("click", () => onRequestClose());
138
+ window.addEventListener("keydown", (e) => {
139
+ if (e.key === "Escape" && !modal.classList.contains("hidden")) {
140
+ onRequestClose();
141
+ }
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;
150
+
151
+ return {
152
+ openModal() {
153
+ clearTimeout(closeTimer);
154
+ modal.classList.remove("hidden", "closing");
155
+ void modal.offsetWidth; // restart the entrance animation
156
+ modal.classList.add("opening");
157
+ setStage(currentStage());
158
+ syncPill();
159
+ if (!audio.src && !busy) setTimeout(() => input.focus(), 600);
160
+ },
161
+ closeModal() {
162
+ if (modal.classList.contains("hidden")) return;
163
+ modal.classList.remove("opening");
164
+ modal.classList.add("closing");
165
+ clearTimeout(closeTimer);
166
+ closeTimer = setTimeout(() => {
167
+ modal.classList.add("hidden");
168
+ modal.classList.remove("closing");
169
+ syncPill();
170
+ }, 300);
171
+ syncPill();
172
+ },
173
+ };
174
+ }
frontend/world.js CHANGED
@@ -338,6 +338,10 @@ function buildVendingMachine() {
338
  dispenserInner.position.set(-0.3, 0.5, 0.97);
339
  g.add(dispenserInner);
340
 
 
 
 
 
341
  // Door handle
342
  const handle = box(0.1, 1.2, 0.07, lambert(0xd8e6ee));
343
  handle.position.set(0.45, 2.9, 1.0);
 
338
  dispenserInner.position.set(-0.3, 0.5, 0.97);
339
  g.add(dispenserInner);
340
 
341
+ // referenced by main.js to make the machine glow while generating
342
+ g.userData.screenMaterial = screen.material;
343
+ g.userData.dispenserMaterial = dispenserInner.material;
344
+
345
  // Door handle
346
  const handle = box(0.1, 1.2, 0.07, lambert(0xd8e6ee));
347
  handle.position.set(0.45, 2.9, 1.0);