eloigil6 commited on
Commit
93c06d6
·
1 Parent(s): 5f70190

Implement audio crossfade and playlist functionality in UI. Enhanced the audio management system by introducing two audio elements for crossfading, updating the track loading mechanism, and adding playlist support. Refactored related functions for improved clarity and performance, ensuring a seamless user experience during audio transitions.

Browse files
Files changed (1) hide show
  1. frontend/ui.js +133 -26
frontend/ui.js CHANGED
@@ -42,7 +42,20 @@ export function initUI({
42
  const deckCassette = $("deck-cassette");
43
  const deckDownload = $("deck-download");
44
 
45
- audio.loop = true; // a cassette of endless chill
 
 
 
 
 
 
 
 
 
 
 
 
 
46
  let busy = false;
47
  let collectionOpen = false;
48
  let gameboyOpen = false;
@@ -62,7 +75,7 @@ export function initUI({
62
 
63
  function currentStage() {
64
  if (busy) return "generating";
65
- return audio.src ? "cassette" : "prompt";
66
  }
67
 
68
  function showError(message) {
@@ -104,7 +117,7 @@ export function initUI({
104
  }
105
  }
106
 
107
- function loadCassette(song, shell) {
108
  currentSong = song;
109
  cassetteTitle.textContent = song.title;
110
  pillTitle.textContent = song.title;
@@ -112,13 +125,90 @@ export function initUI({
112
  deckDownload.href = song.url;
113
  deckDownload.setAttribute("download", `${song.title}.wav`);
114
  deckDownload.classList.remove("disabled");
115
- deckCassette.style.setProperty("--shell1", (shell ?? SHELL_COLORS[0])[0]);
116
- deckCassette.style.setProperty("--shell2", (shell ?? SHELL_COLORS[0])[1]);
117
- audio.src = song.url;
118
- audio.play().catch(() => {});
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
119
  syncCards();
120
  }
121
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
122
  // --- cassette collection: carousel + tape deck ------------------------------
123
 
124
  // shell gradients, cycled per card like the tape pile on the bench
@@ -155,7 +245,7 @@ export function initUI({
155
  const CF_FADE = [1, 0.9, 0.46, 0]; // opacity
156
 
157
  function layoutCarousel() {
158
- const playing = !audio.paused;
159
  cards.forEach((card, i) => {
160
  const offset = i - centerIndex;
161
  const d = Math.min(Math.abs(offset), 3);
@@ -184,9 +274,9 @@ export function initUI({
184
  centerIndex = Math.max(0, Math.min(cards.length - 1, index));
185
  const song = songsList[centerIndex];
186
  if (load && song && tapeKey(song.url) !== tapeKey(currentSong?.url)) {
187
- loadCassette(song, SHELL_COLORS[centerIndex % SHELL_COLORS.length]);
188
  } else {
189
- if (load && audio.paused) audio.play().catch(() => {});
190
  layoutCarousel();
191
  }
192
  }
@@ -218,7 +308,7 @@ export function initUI({
218
  card.addEventListener("click", () => {
219
  if (i !== centerIndex) centerOn(i, { load: true });
220
  else if (tapeKey(currentSong?.url) === tapeKey(song.url)) togglePlay();
221
- else loadCassette(song, shell);
222
  });
223
  carousel.appendChild(card);
224
  return card;
@@ -268,7 +358,7 @@ export function initUI({
268
  `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, "0")}`;
269
 
270
  function syncPlayState() {
271
- const playing = !audio.paused;
272
  playBtn.textContent = playing ? "❚❚" : "▶";
273
  pillToggle.textContent = playing ? "❚❚" : "▶";
274
  deckPlay.textContent = playing ? "❚❚" : "▶";
@@ -277,29 +367,46 @@ export function initUI({
277
  syncCards();
278
  }
279
 
280
- audio.addEventListener("play", syncPlayState);
281
- audio.addEventListener("pause", syncPlayState);
282
- audio.addEventListener("timeupdate", () => {
283
- if (audio.duration) {
284
- const pct = `${(audio.currentTime / audio.duration) * 100}%`;
285
  progressFill.style.width = pct;
286
  deckFill.style.width = pct;
287
  }
288
- timeEl.textContent = fmt(audio.currentTime);
289
- deckTime.textContent = fmt(audio.currentTime);
290
- });
291
 
292
- const togglePlay = () => (audio.paused ? audio.play() : audio.pause());
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
293
  playBtn.addEventListener("click", togglePlay);
294
  pillToggle.addEventListener("click", togglePlay);
295
  deckPlay.addEventListener("click", () => {
296
- if (audio.src) togglePlay();
297
  });
298
 
299
  const seek = (bar) => (e) => {
300
- if (!audio.duration) return;
301
  const rect = bar.getBoundingClientRect();
302
- audio.currentTime = ((e.clientX - rect.left) / rect.width) * audio.duration;
303
  };
304
  progress.addEventListener("click", seek(progress));
305
  deckProgress.addEventListener("click", seek(deckProgress));
@@ -347,7 +454,7 @@ export function initUI({
347
  const modalHidden = modal.classList.contains("hidden");
348
  pill.classList.toggle(
349
  "hidden",
350
- !(audio.src && modalHidden && !collectionOpen && !gameboyOpen),
351
  );
352
  }
353
 
@@ -362,7 +469,7 @@ export function initUI({
362
  modal.classList.add("opening");
363
  setStage(currentStage());
364
  syncPill();
365
- if (!audio.src && !busy) setTimeout(() => input.focus(), 600);
366
  },
367
  closeModal() {
368
  if (modal.classList.contains("hidden")) return;
 
42
  const deckCassette = $("deck-cassette");
43
  const deckDownload = $("deck-download");
44
 
45
+ // Two audio elements so tapes can crossfade — one element can't overlap
46
+ // itself. The UI always reflects `active`; the other fades underneath it.
47
+ const audioA = audio; // the #tape-audio element
48
+ const audioB = new Audio();
49
+ audioB.preload = "auto";
50
+ audioB.id = "tape-audio-b";
51
+ document.body.appendChild(audioB); // no controls => invisible; in DOM for consistency
52
+ let active = audioA;
53
+ let fadeTimer = null;
54
+ let playlist = []; // collection songs, when playing as a playlist
55
+ let playlistIndex = -1; // active track's index in `playlist`, or -1 = single loop
56
+ const CROSSFADE = 2.5; // s — overlap as one tape rolls into the next
57
+ const PICK_FADE = 0.9; // s — quick blend when you pick or skip a tape by hand
58
+
59
  let busy = false;
60
  let collectionOpen = false;
61
  let gameboyOpen = false;
 
75
 
76
  function currentStage() {
77
  if (busy) return "generating";
78
+ return active.src ? "cassette" : "prompt";
79
  }
80
 
81
  function showError(message) {
 
117
  }
118
  }
119
 
120
+ function setNowPlaying(song, shell) {
121
  currentSong = song;
122
  cassetteTitle.textContent = song.title;
123
  pillTitle.textContent = song.title;
 
125
  deckDownload.href = song.url;
126
  deckDownload.setAttribute("download", `${song.title}.wav`);
127
  deckDownload.classList.remove("disabled");
128
+ const s = shell ?? SHELL_COLORS[0];
129
+ deckCassette.style.setProperty("--shell1", s[0]);
130
+ deckCassette.style.setProperty("--shell2", s[1]);
131
+ }
132
+
133
+ function cancelFade() {
134
+ if (fadeTimer) {
135
+ clearInterval(fadeTimer);
136
+ fadeTimer = null;
137
+ }
138
+ }
139
+
140
+ // Equal-power crossfade: raise `incoming` while lowering `outgoing` so total
141
+ // loudness holds steady (a plain linear fade dips in the middle). Driven by a
142
+ // timer rather than rAF so it still completes when the tab is backgrounded.
143
+ function rampTo(incoming, outgoing, durSec, onDone) {
144
+ cancelFade();
145
+ const t0 = performance.now();
146
+ fadeTimer = setInterval(() => {
147
+ const t = Math.min((performance.now() - t0) / (durSec * 1000), 1);
148
+ incoming.volume = Math.sin((t * Math.PI) / 2);
149
+ if (outgoing) outgoing.volume = Math.cos((t * Math.PI) / 2);
150
+ if (t >= 1) {
151
+ cancelFade();
152
+ incoming.volume = 1;
153
+ if (outgoing) {
154
+ outgoing.pause();
155
+ outgoing.volume = 1;
156
+ }
157
+ onDone && onDone();
158
+ }
159
+ }, 33);
160
+ }
161
+
162
+ // Start `url` on the idle element and crossfade the active one into it.
163
+ function startTrack(url, { loop, fadeSec }) {
164
+ const incoming = active === audioA ? audioB : audioA;
165
+ const outgoing = active;
166
+ const overlap = !!outgoing.src && !outgoing.paused;
167
+ incoming.src = url;
168
+ incoming.loop = loop;
169
+ incoming.currentTime = 0;
170
+ incoming.volume = overlap ? 0 : 1;
171
+ active = incoming; // the UI follows the incoming element from here on
172
+ incoming.play().catch(() => {});
173
+ if (overlap) rampTo(incoming, outgoing, fadeSec);
174
+ else cancelFade();
175
+ syncPlayState();
176
+ }
177
+
178
+ // A freshly vended tape plays on its own and loops (not part of a playlist).
179
+ function loadCassette(song, shell) {
180
+ playlist = [];
181
+ playlistIndex = -1;
182
+ setNowPlaying(song, shell);
183
+ startTrack(song.url, { loop: true, fadeSec: PICK_FADE });
184
  syncCards();
185
  }
186
 
187
+ // Play the collection as a playlist from `index`; tracks crossfade into one
188
+ // another and wrap back to the start.
189
+ function playCollection(index, fadeSec = PICK_FADE) {
190
+ if (!songsList.length) return;
191
+ const n = songsList.length;
192
+ playlist = songsList;
193
+ playlistIndex = ((index % n) + n) % n;
194
+ centerIndex = playlistIndex;
195
+ setNowPlaying(
196
+ songsList[playlistIndex],
197
+ SHELL_COLORS[playlistIndex % SHELL_COLORS.length],
198
+ );
199
+ startTrack(songsList[playlistIndex].url, { loop: false, fadeSec });
200
+ layoutCarousel();
201
+ }
202
+
203
+ // Near the end of a playlist track, roll into the next one.
204
+ function maybeAdvance() {
205
+ if (playlistIndex < 0 || fadeTimer || !active.duration) return;
206
+ if (active.currentTime < 1) return; // ignore the very start of a track
207
+ if (active.duration - active.currentTime <= CROSSFADE) {
208
+ playCollection(playlistIndex + 1, CROSSFADE);
209
+ }
210
+ }
211
+
212
  // --- cassette collection: carousel + tape deck ------------------------------
213
 
214
  // shell gradients, cycled per card like the tape pile on the bench
 
245
  const CF_FADE = [1, 0.9, 0.46, 0]; // opacity
246
 
247
  function layoutCarousel() {
248
+ const playing = !active.paused;
249
  cards.forEach((card, i) => {
250
  const offset = i - centerIndex;
251
  const d = Math.min(Math.abs(offset), 3);
 
274
  centerIndex = Math.max(0, Math.min(cards.length - 1, index));
275
  const song = songsList[centerIndex];
276
  if (load && song && tapeKey(song.url) !== tapeKey(currentSong?.url)) {
277
+ playCollection(centerIndex); // start the playlist at this tape
278
  } else {
279
+ if (load && active.paused) active.play().catch(() => {});
280
  layoutCarousel();
281
  }
282
  }
 
308
  card.addEventListener("click", () => {
309
  if (i !== centerIndex) centerOn(i, { load: true });
310
  else if (tapeKey(currentSong?.url) === tapeKey(song.url)) togglePlay();
311
+ else playCollection(i);
312
  });
313
  carousel.appendChild(card);
314
  return card;
 
358
  `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, "0")}`;
359
 
360
  function syncPlayState() {
361
+ const playing = !active.paused;
362
  playBtn.textContent = playing ? "❚❚" : "▶";
363
  pillToggle.textContent = playing ? "❚❚" : "▶";
364
  deckPlay.textContent = playing ? "❚❚" : "▶";
 
367
  syncCards();
368
  }
369
 
370
+ function updateProgress() {
371
+ if (active.duration) {
372
+ const pct = `${(active.currentTime / active.duration) * 100}%`;
 
 
373
  progressFill.style.width = pct;
374
  deckFill.style.width = pct;
375
  }
376
+ timeEl.textContent = fmt(active.currentTime);
377
+ deckTime.textContent = fmt(active.currentTime);
378
+ }
379
 
380
+ // both elements fire events; only react to the one the UI is tracking
381
+ for (const el of [audioA, audioB]) {
382
+ el.addEventListener("play", (e) => e.target === active && syncPlayState());
383
+ el.addEventListener("pause", (e) => e.target === active && syncPlayState());
384
+ el.addEventListener("timeupdate", (e) => {
385
+ if (e.target !== active) return;
386
+ updateProgress();
387
+ maybeAdvance();
388
+ });
389
+ }
390
+
391
+ function togglePlay() {
392
+ if (active.paused) {
393
+ active.play().catch(() => {});
394
+ } else {
395
+ cancelFade(); // a manual pause stops any in-flight crossfade too
396
+ audioA.pause();
397
+ audioB.pause();
398
+ }
399
+ }
400
  playBtn.addEventListener("click", togglePlay);
401
  pillToggle.addEventListener("click", togglePlay);
402
  deckPlay.addEventListener("click", () => {
403
+ if (active.src) togglePlay();
404
  });
405
 
406
  const seek = (bar) => (e) => {
407
+ if (!active.duration) return;
408
  const rect = bar.getBoundingClientRect();
409
+ active.currentTime = ((e.clientX - rect.left) / rect.width) * active.duration;
410
  };
411
  progress.addEventListener("click", seek(progress));
412
  deckProgress.addEventListener("click", seek(deckProgress));
 
454
  const modalHidden = modal.classList.contains("hidden");
455
  pill.classList.toggle(
456
  "hidden",
457
+ !(active.src && modalHidden && !collectionOpen && !gameboyOpen),
458
  );
459
  }
460
 
 
469
  modal.classList.add("opening");
470
  setStage(currentStage());
471
  syncPill();
472
+ if (!active.src && !busy) setTimeout(() => input.focus(), 600);
473
  },
474
  closeModal() {
475
  if (modal.classList.contains("hidden")) return;