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- 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 66 |
}
|
| 67 |
|
| 68 |
function showError(message) {
|
|
@@ -104,7 +117,7 @@ export function initUI({
|
|
| 104 |
}
|
| 105 |
}
|
| 106 |
|
| 107 |
-
function
|
| 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 |
-
|
| 116 |
-
deckCassette.style.setProperty("--
|
| 117 |
-
|
| 118 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 = !
|
| 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 |
-
|
| 188 |
} else {
|
| 189 |
-
if (load &&
|
| 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
|
| 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 = !
|
| 272 |
playBtn.textContent = playing ? "❚❚" : "▶";
|
| 273 |
pillToggle.textContent = playing ? "❚❚" : "▶";
|
| 274 |
deckPlay.textContent = playing ? "❚❚" : "▶";
|
|
@@ -277,29 +367,46 @@ export function initUI({
|
|
| 277 |
syncCards();
|
| 278 |
}
|
| 279 |
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 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(
|
| 289 |
-
deckTime.textContent = fmt(
|
| 290 |
-
}
|
| 291 |
|
| 292 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 293 |
playBtn.addEventListener("click", togglePlay);
|
| 294 |
pillToggle.addEventListener("click", togglePlay);
|
| 295 |
deckPlay.addEventListener("click", () => {
|
| 296 |
-
if (
|
| 297 |
});
|
| 298 |
|
| 299 |
const seek = (bar) => (e) => {
|
| 300 |
-
if (!
|
| 301 |
const rect = bar.getBoundingClientRect();
|
| 302 |
-
|
| 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 |
-
!(
|
| 351 |
);
|
| 352 |
}
|
| 353 |
|
|
@@ -362,7 +469,7 @@ export function initUI({
|
|
| 362 |
modal.classList.add("opening");
|
| 363 |
setStage(currentStage());
|
| 364 |
syncPill();
|
| 365 |
-
if (!
|
| 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;
|