eloigil6 commited on
Commit
7b00de0
·
1 Parent(s): a39beee

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
Files changed (5) hide show
  1. app.py +28 -0
  2. frontend/index.html +172 -76
  3. frontend/main.js +36 -12
  4. frontend/style.css +424 -33
  5. 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
- <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 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
- {
13
- "imports": {
14
- "three": "https://cdn.jsdelivr.net/npm/[email protected]/build/three.module.js"
 
 
 
 
15
  }
16
- }
17
- </script>
18
- </head>
19
- <body>
20
- <div id="title-overlay">
21
- <h1 id="title">LoFinity</h1>
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="controls-row">
42
- <div id="coin-slot-area">
43
- <div id="coin-slot"></div>
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
- <div id="cassette-stage" class="hidden">
64
- <div id="cassette">
65
- <div class="cassette-label"><span id="cassette-title"></span></div>
66
- <div class="reels">
67
- <span class="reel"></span>
68
- <span class="tape-window"></span>
69
- <span class="reel"></span>
 
 
 
 
 
 
 
 
 
 
 
70
  </div>
 
71
  </div>
72
- <div id="player">
73
- <button id="play-btn" aria-label="play/pause">▶</button>
74
- <div id="progress"><div id="progress-fill"></div></div>
75
- <span id="time">0:00</span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
76
  </div>
77
- <button id="again-btn">MAKE ANOTHER</button>
 
 
 
 
 
 
 
 
 
78
  </div>
79
  </div>
80
 
81
- <div id="now-playing" class="hidden">
82
- <button id="np-toggle" aria-label="play/pause">❚❚</button>
83
- <span id="np-title"></span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
84
  </div>
85
- <audio id="tape-audio"></audio>
86
- <canvas id="scene"></canvas>
87
- <script type="module" src="/static/main.js"></script>
88
- </body>
 
 
 
 
 
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 ?? (data.audio?.path ? `/gradio_api/file=${data.audio.path}` : null);
 
309
  if (!url) throw new Error("no audio in response");
310
  return { title: data.title, url };
311
  };
312
- window.lofinity = { generate: generateFn };
 
 
 
 
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 + Math.pow(Math.max(0, Math.sin(elapsed * 1.9)), 12) * 0.06;
 
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(0.38, 0.55, 0.12 + 0.3 * pulse);
472
- world.vending.userData.dispenserMaterial.color.setHSL(0.13, 0.85, 0.08 + 0.3 * pulse);
 
 
 
 
 
 
 
 
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: opacity 1.8s ease, transform 1.8s cubic-bezier(0.55, 0, 0.55, 1);
 
 
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: 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
 
@@ -294,11 +298,23 @@ body {
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 {
@@ -337,10 +353,19 @@ body {
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 {
@@ -362,8 +387,13 @@ body {
362
  }
363
 
364
  @keyframes blink {
365
- 0%, 100% { opacity: 1; }
366
- 50% { opacity: 0.15; }
 
 
 
 
 
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) { 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 -------------------------------------------------- */
@@ -413,7 +463,9 @@ body {
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
 
@@ -422,7 +474,9 @@ body {
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 {
@@ -479,7 +533,11 @@ body {
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 {
@@ -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: 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 {
@@ -515,7 +577,9 @@ body {
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
 
@@ -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 { transform: rotate(360deg); }
 
 
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: 0 4px 0 #16213a, inset 0 2px 2px rgba(255, 255, 255, 0.18);
 
 
644
  }
645
 
646
  #again-btn:hover {
@@ -649,7 +720,9 @@ body {
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 {
@@ -690,7 +763,9 @@ body {
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 {
@@ -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({ generate, onRequestClose, onGeneratingChange }) {
 
 
 
 
 
 
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({ title, url }) {
86
- cassetteTitle.textContent = title;
87
- pillTitle.textContent = title;
88
- audio.src = url;
 
 
 
 
 
 
 
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
- 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
 
@@ -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
  }