Spaces:
Running on Zero
Running on Zero
File size: 25,440 Bytes
8aeb502 1af28be 8aeb502 7b00de0 d0f696e 7b00de0 576433d 3bf1ce3 7b00de0 8aeb502 576433d 3bf1ce3 8aeb502 d0f696e 8aeb502 d0f696e 613bdc6 0d2e5fe 613bdc6 0d2e5fe 613bdc6 0d2e5fe 613bdc6 0d2e5fe 613bdc6 8aeb502 d0f696e 8aeb502 7b00de0 e60d445 7b00de0 8aeb502 93c06d6 e60d445 93c06d6 8aeb502 7b00de0 576433d 1af28be 7b00de0 2916784 8aeb502 ca87834 0d2e5fe ca87834 0d2e5fe ca87834 8e4f3a3 8aeb502 3bf1ce3 d0f696e 8aeb502 d0f696e 8aeb502 93c06d6 8aeb502 d0f696e 0d2e5fe d0f696e 8aeb502 d0f696e 8aeb502 d0f696e 8aeb502 d0f696e 0d2e5fe 2916784 d0f696e 8aeb502 d0f696e 8aeb502 93c06d6 7b00de0 93c06d6 e60d445 7b00de0 93c06d6 e60d445 93c06d6 e60d445 93c06d6 e60d445 93c06d6 7b00de0 c58be5d 93c06d6 c58be5d 93c06d6 c58be5d 93c06d6 c58be5d 7b00de0 c58be5d 7b00de0 c58be5d 7b00de0 c58be5d 93c06d6 7b00de0 c58be5d 7b00de0 c58be5d 0d2e5fe c58be5d 7b00de0 2916784 7b00de0 2916784 7b00de0 2916784 7b00de0 8aeb502 93c06d6 8aeb502 7b00de0 8aeb502 7b00de0 ca87834 8aeb502 93c06d6 7b00de0 8aeb502 93c06d6 8aeb502 93c06d6 8aeb502 7b00de0 93c06d6 7b00de0 8aeb502 e60d445 7b00de0 93c06d6 7b00de0 0d2e5fe 7b00de0 0d2e5fe 576433d 3bf1ce3 0d2e5fe 8aeb502 d0f696e 8aeb502 1abd679 8aeb502 c58be5d 8aeb502 576433d 8aeb502 576433d 93c06d6 576433d 8aeb502 576433d 8aeb502 93c06d6 8aeb502 7b00de0 576433d 1af28be 576433d 1af28be 576433d 8aeb502 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 | // LoFinity — vending machine modal: prompt → coin drop → brewing → cassette.
// main.js owns the camera; this module owns the DOM and the audio element.
import { createGarden } from "/static/garden.js";
const $ = (id) => document.getElementById(id);
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
export function initUI({
generate,
getProgress,
onRequestClose,
onRequestCloseCollection,
onRequestCloseGameboy,
onPlayWhileWaiting,
onGeneratingChange,
}) {
const modal = $("machine-modal");
const gameboyModal = $("gameboy-modal");
const playWaitBtn = $("play-while-waiting");
const input = $("prompt-input");
const lengthRow = $("length-row");
const lengthSlider = $("length-slider");
const lengthValue = $("length-value");
const coinBtn = $("coin-button");
// slider stops → (seconds sent to the backend, label on the screen). 1 min and
// 1.5 min are stitched from 30s chunks. The set is hardware-dependent — a
// CPU-only backend allows only 30s — so we fetch the real list from /api/config
// and collapse the slider when there's a single option.
const fmtLen = (s) =>
`${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
let LENGTHS = [30, 60, 90].map((s) => ({ seconds: s, label: fmtLen(s) }));
const selectedLength = () =>
LENGTHS[Number(lengthSlider.value)] ?? LENGTHS[0];
// adapt the slider to what this backend actually allows; defensive — any failure
// keeps the 30/60/90 default, so the GPU path is never affected
fetch("/api/config")
.then((r) => (r.ok ? r.json() : null))
.then((cfg) => {
const allowed =
cfg && Array.isArray(cfg.allowed_seconds) ? cfg.allowed_seconds : null;
if (!allowed || !allowed.length) return;
LENGTHS = allowed.map((s) => ({ seconds: s, label: fmtLen(s) }));
lengthSlider.max = String(Math.max(0, LENGTHS.length - 1));
if (Number(lengthSlider.value) > LENGTHS.length - 1)
lengthSlider.value = "0";
lengthValue.textContent = selectedLength().label;
if (LENGTHS.length <= 1) lengthRow.style.display = "none"; // single option → no slider
})
.catch(() => {});
const controlsRow = $("controls-row");
const generating = $("generating");
const brewFill = $("brew-bar-fill");
const cassetteStage = $("cassette-stage");
const cassette = $("cassette");
const cassetteTitle = $("cassette-title");
const errorMsg = $("error-msg");
const audio = $("tape-audio");
const playBtn = $("play-btn");
const progress = $("progress");
const progressFill = $("progress-fill");
const timeEl = $("time");
const pill = $("now-playing");
const pillToggle = $("np-toggle");
const pillTitle = $("np-title");
const collectionPanel = $("collection-panel");
const collectionStatus = $("collection-status");
const carousel = $("carousel");
const deck = $("tape-deck");
const deckPlay = $("deck-play");
const deckTitle = $("deck-title");
const deckProgress = $("deck-progress");
const deckFill = $("deck-fill");
const deckTime = $("deck-time");
const deckCassette = $("deck-cassette");
const deckLoop = $("deck-loop");
const deckDownload = $("deck-download");
// Two audio elements so tapes can crossfade — one element can't overlap
// itself. The UI always reflects `active`; the other fades underneath it.
const audioA = audio; // the #tape-audio element
const audioB = new Audio();
audioB.preload = "auto";
audioB.id = "tape-audio-b";
document.body.appendChild(audioB); // no controls => invisible; in DOM for consistency
let active = audioA;
let fadeTimer = null;
let playlist = []; // collection songs, when playing as a playlist
let playlistIndex = -1; // active track's index in `playlist`, or -1 = single loop
// When on (the default), the current tape loops; toggle it off to roll through
// the rest of the shelf. Controlled by the loop button on the tape deck.
let loopOne = true;
const CROSSFADE = 2.5; // s — overlap as one tape rolls into the next
const PICK_FADE = 0.9; // s — quick blend when you pick or skip a tape by hand
let busy = false;
let collectionOpen = false;
let gameboyOpen = false;
let garden = null; // the Game Boy garden mini-game (lazily started)
let currentSong = null; // { title, url } of the tape in the deck
// The collection lives in memory, per browser session — never on the server,
// so tapes are private and a reload starts the shelf empty. Newest first.
const sessionTapes = [];
// --- lobby music ------------------------------------------------------------
// A café-jazz bed that plays at startup and whenever the player is idle (no
// tape loaded, or paused), and ducks out under a playing tape. Browsers block
// audio autoplay until the user interacts, so it's primed on the first gesture;
// after that it just follows the player state via syncPlayState() -> updateLobby().
// Track: Alex Morgan — "Peaceful Cafe Jazz" (Pixabay, royalty-free).
const LOBBY_VOL = 0.2; // 70% of the original 0.4
const lobby = new Audio("/static/lobby.mp3");
lobby.loop = true;
lobby.preload = "auto";
lobby.volume = 0;
lobby.id = "lobby-audio";
document.body.appendChild(lobby);
let lobbyFade = null;
function fadeLobby(target, durSec = 0.7) {
if (lobbyFade) clearInterval(lobbyFade);
const from = lobby.volume;
const steps = Math.max(1, Math.round(durSec * 30));
let i = 0;
lobbyFade = setInterval(() => {
i += 1;
lobby.volume = Math.max(
0,
Math.min(1, from + (target - from) * (i / steps)),
);
if (i >= steps) {
clearInterval(lobbyFade);
lobbyFade = null;
if (target === 0) lobby.pause();
}
}, 1000 / 30);
}
// Play the bed only while nothing else is — fade it out under a live tape.
function updateLobby() {
const tapePlaying = !!active.src && !active.paused;
if (tapePlaying) {
if (!lobby.paused) fadeLobby(0);
} else {
if (lobby.paused) {
lobby.volume = 0;
lobby.play().catch(() => {}); // gated on a user gesture (primed below)
}
fadeLobby(LOBBY_VOL);
}
}
// Autoplay needs a user gesture; kick the bed off on the first one.
function primeLobby() {
window.removeEventListener("pointerdown", primeLobby);
window.removeEventListener("keydown", primeLobby);
updateLobby();
}
window.addEventListener("pointerdown", primeLobby);
window.addEventListener("keydown", primeLobby);
// --- mute -------------------------------------------------------------------
// Top-right toggle that silences ALL audio (tapes + lobby). Persisted in
// localStorage so the choice survives reloads. Uses each element's `muted`
// flag, which is independent of the volume/fade logic — unmuting restores it.
const muteBtn = $("mute-btn");
const MUTE_KEY = "lofinity:muted";
let muted = false;
try {
muted = localStorage.getItem(MUTE_KEY) === "1";
} catch {
/* private mode — default to unmuted */
}
function applyMute() {
document.querySelectorAll("audio").forEach((el) => (el.muted = muted));
muteBtn.classList.toggle("muted", muted);
muteBtn.setAttribute("aria-pressed", String(muted));
muteBtn.title = muted ? "unmute" : "mute";
}
applyMute(); // restore the persisted choice on load
muteBtn.addEventListener("click", () => {
muted = !muted;
try {
localStorage.setItem(MUTE_KEY, muted ? "1" : "0");
} catch {
/* ignore */
}
applyMute();
});
function setStage(stage) {
controlsRow.classList.toggle("hidden", stage !== "prompt");
generating.classList.toggle("hidden", stage !== "generating");
cassetteStage.classList.toggle("hidden", stage !== "cassette");
playWaitBtn.classList.toggle("hidden", stage !== "generating");
lengthRow.classList.toggle("hidden", stage !== "prompt");
input.classList.toggle("hidden", stage === "cassette");
input.disabled = stage === "generating";
if (stage === "prompt") {
controlsRow.classList.remove("inserting");
coinBtn.disabled = false;
lengthSlider.disabled = false;
}
}
function currentStage() {
if (busy) return "generating";
return active.src ? "cassette" : "prompt";
}
function showError(message) {
errorMsg.textContent = message;
errorMsg.classList.remove("hidden");
}
// --- brewing progress bar -------------------------------------------------
let brewTimer = null;
function setBrewProgress(frac) {
brewFill.style.width = `${Math.max(0, Math.min(1, frac)) * 100}%`;
}
// poll the backend for chunks-done while a tape brews; fills in 30s steps
function startBrewPolling() {
setBrewProgress(0);
if (!getProgress) return;
let sawFresh = false; // guard against reading the previous brew's leftover 100%
brewTimer = setInterval(async () => {
const p = await getProgress();
if (!p || !p.total) return;
if (!sawFresh) {
if (p.done < p.total)
sawFresh = true; // this brew has actually started
else return; // a stale "complete" from the last tape — ignore it
}
setBrewProgress(p.done / p.total);
}, 600);
}
function stopBrewPolling() {
if (brewTimer) clearInterval(brewTimer);
brewTimer = null;
}
async function startGeneration() {
if (busy) return;
const prompt = input.value.trim();
if (!prompt) {
input.classList.remove("shake");
void input.offsetWidth; // restart the animation
input.classList.add("shake");
input.focus();
return;
}
busy = true;
errorMsg.classList.add("hidden");
coinBtn.disabled = true;
lengthSlider.disabled = true; // lock the length in once the coin drops
controlsRow.classList.add("inserting");
await delay(1200); // the wadōkaichin makes its way into the slot
setStage("generating");
onGeneratingChange(true);
startBrewPolling();
try {
// hold the brewing moment even if the backend is fast
const seconds = selectedLength().seconds;
const [result] = await Promise.all([
generate(prompt, seconds),
delay(2600),
]);
sessionTapes.unshift(result); // newest first, kept only for this session
setBrewProgress(1); // the tape's here — top the bar off
loadCassette(result);
busy = false;
setStage("cassette");
} catch (err) {
console.error("[LoFinity] generation failed:", err);
busy = false;
setStage("prompt");
showError("the machine jammed — try again?");
} finally {
stopBrewPolling();
onGeneratingChange(false);
syncPill();
}
}
function setNowPlaying(song, shell) {
currentSong = song;
cassetteTitle.textContent = song.title;
pillTitle.textContent = song.title;
deckTitle.textContent = song.title;
deckDownload.href = song.url;
deckDownload.setAttribute("download", `${song.title}.wav`);
deckDownload.classList.remove("disabled");
const s = shell ?? SHELL_COLORS[0];
deckCassette.style.setProperty("--shell1", s[0]);
deckCassette.style.setProperty("--shell2", s[1]);
}
function cancelFade() {
if (fadeTimer) {
clearInterval(fadeTimer);
fadeTimer = null;
}
}
// Equal-power crossfade: raise `incoming` while lowering `outgoing` so total
// loudness holds steady (a plain linear fade dips in the middle). Driven by a
// timer rather than rAF so it still completes when the tab is backgrounded.
function rampTo(incoming, outgoing, durSec, onDone) {
cancelFade();
const t0 = performance.now();
fadeTimer = setInterval(() => {
const t = Math.min((performance.now() - t0) / (durSec * 1000), 1);
incoming.volume = Math.sin((t * Math.PI) / 2);
if (outgoing) outgoing.volume = Math.cos((t * Math.PI) / 2);
if (t >= 1) {
cancelFade();
incoming.volume = 1;
if (outgoing) {
outgoing.pause();
outgoing.volume = 1;
}
onDone && onDone();
}
}, 33);
}
// Start `url` on the idle element and crossfade the active one into it.
function startTrack(url, { loop, fadeSec }) {
const incoming = active === audioA ? audioB : audioA;
const outgoing = active;
const overlap = !!outgoing.src && !outgoing.paused;
incoming.src = url;
incoming.loop = loop;
incoming.currentTime = 0;
incoming.volume = overlap ? 0 : 1;
active = incoming; // the UI follows the incoming element from here on
incoming.play().catch(() => {});
if (overlap) rampTo(incoming, outgoing, fadeSec);
else cancelFade();
syncPlayState();
}
// A freshly vended tape starts playing and then rolls into the rest of the
// session's tapes (newest → older) instead of looping on itself. With only
// one tape on the shelf, the playlist re-triggers it, so a lone tape still
// loops seamlessly.
function loadCassette(song) {
songsList = sessionTapes; // the shelf doubles as the now-playing playlist
const idx = sessionTapes.indexOf(song);
playCollection(idx >= 0 ? idx : 0, PICK_FADE);
}
// Play the collection as a playlist from `index`; tracks crossfade into one
// another and wrap back to the start.
function playCollection(index, fadeSec = PICK_FADE) {
if (!songsList.length) return;
const n = songsList.length;
playlist = songsList;
playlistIndex = ((index % n) + n) % n;
centerIndex = playlistIndex;
setNowPlaying(
songsList[playlistIndex],
SHELL_COLORS[playlistIndex % SHELL_COLORS.length],
);
startTrack(songsList[playlistIndex].url, { loop: loopOne, fadeSec });
layoutCarousel();
}
// Near the end of a playlist track, roll into the next one — unless the user
// has the loop toggle on, in which case the tape repeats itself natively.
function maybeAdvance() {
if (loopOne || playlistIndex < 0 || fadeTimer || !active.duration) return;
if (active.currentTime < 1) return; // ignore the very start of a track
if (active.duration - active.currentTime <= CROSSFADE) {
playCollection(playlistIndex + 1, CROSSFADE);
}
}
// --- cassette collection: carousel + tape deck ------------------------------
// shell gradients, cycled per card like the tape pile on the bench
const SHELL_COLORS = [
["#3a4254", "#2c3344"],
["#f2e8d5", "#dccfae"],
["#d95d4e", "#b8453a"],
["#7cc4ea", "#58a8d6"],
["#f2c84b", "#dba62a"],
["#5d4a37", "#46382a"],
];
function setCollectionStatus(message) {
collectionStatus.classList.toggle("hidden", !message);
collectionStatus.textContent = message ?? "";
}
// the wav filename is a uuid — a stable identity even when one source hands
// out absolute URLs and the other relative ones
const tapeKey = (url) => url?.split("/").pop();
let cards = []; // card nodes in song order
let songsList = []; // songs backing the cards
let centerIndex = 0; // which tape sits in the middle (the selected one)
const caroPrev = $("caro-prev");
const caroNext = $("caro-next");
// Coverflow geometry, indexed by distance from the centre (capped at 3): the
// middle tape is biggest and face-on; neighbours shrink and angle inward;
// anything past ±2 is parked offscreen and hidden, so ~5 tapes show at once.
const CF_STEP = [0, 150, 268, 360]; // px from centre
const CF_SCALE = [1.16, 0.82, 0.64, 0.5];
const CF_TILT = [0, 20, 28, 28]; // deg, rotated toward the centre
const CF_FADE = [1, 0.9, 0.46, 0]; // opacity
function layoutCarousel() {
const playing = !active.paused;
cards.forEach((card, i) => {
const offset = i - centerIndex;
const d = Math.min(Math.abs(offset), 3);
const dir = Math.sign(offset);
card.style.transform =
`translate(-50%, -50%) translateX(${dir * CF_STEP[d]}px) ` +
`scale(${CF_SCALE[d]}) rotateY(${-dir * CF_TILT[d]}deg)`;
card.style.opacity = CF_FADE[d];
card.style.zIndex = 30 - d;
card.style.pointerEvents = d <= 2 ? "auto" : "none";
card.setAttribute("aria-hidden", d <= 2 ? "false" : "true");
card.classList.toggle("selected", offset === 0);
card.classList.toggle("playing", offset === 0 && playing);
});
caroPrev.disabled = centerIndex <= 0;
caroNext.disabled = centerIndex >= cards.length - 1;
}
// older callers just want the selection/playing classes refreshed in place
const syncCards = layoutCarousel;
// Bring tape `index` to the middle. `load` also drops it into the deck and
// plays it (an explicit pick); open-time centering passes load:false.
function centerOn(index, { load = false } = {}) {
if (!cards.length) return;
centerIndex = Math.max(0, Math.min(cards.length - 1, index));
const song = songsList[centerIndex];
if (load && song && tapeKey(song.url) !== tapeKey(currentSong?.url)) {
playCollection(centerIndex); // start the playlist at this tape
} else {
if (load && active.paused) active.play().catch(() => {});
layoutCarousel();
}
}
function renderCarousel(songs) {
songsList = songs;
carousel.innerHTML = "";
cards = songs.map((song, i) => {
const shell = SHELL_COLORS[i % SHELL_COLORS.length];
const card = document.createElement("button");
card.className = "cassette-card";
card.dataset.url = song.url;
card.style.setProperty("--shell1", shell[0]);
card.style.setProperty("--shell2", shell[1]);
card.title = song.title;
const label = document.createElement("div");
label.className = "cassette-label";
const labelText = document.createElement("span");
labelText.textContent = song.title;
label.appendChild(labelText);
const reels = document.createElement("div");
reels.className = "reels";
reels.innerHTML =
'<span class="reel"></span><span class="tape-window"></span><span class="reel"></span>';
card.append(label, reels);
card.addEventListener("click", () => {
if (i !== centerIndex) centerOn(i, { load: true });
else if (tapeKey(currentSong?.url) === tapeKey(song.url)) togglePlay();
else playCollection(i);
});
carousel.appendChild(card);
return card;
});
// open centered on the tape already playing, otherwise the newest one
const playingIdx = songs.findIndex(
(s) => tapeKey(s.url) === tapeKey(currentSong?.url),
);
centerIndex = playingIdx >= 0 ? playingIdx : 0;
layoutCarousel();
}
function openCollection() {
collectionOpen = true;
collectionPanel.classList.remove("hidden");
deck.classList.remove("hidden");
syncPill();
carousel.innerHTML = "";
if (!sessionTapes.length) {
setCollectionStatus("no tapes yet — go vend a vibe at the machine ♪");
return;
}
setCollectionStatus(null);
renderCarousel(sessionTapes);
}
function closeCollection() {
collectionOpen = false;
collectionPanel.classList.add("hidden");
deck.classList.add("hidden");
syncPill();
}
// --- audio wiring ---------------------------------------------------------
const fmt = (s) =>
`${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, "0")}`;
function syncPlayState() {
const playing = !active.paused;
playBtn.textContent = playing ? "❚❚" : "▶";
pillToggle.textContent = playing ? "❚❚" : "▶";
deckPlay.textContent = playing ? "❚❚" : "▶";
cassette.classList.toggle("playing", playing);
deck.classList.toggle("playing", playing);
syncCards();
updateLobby(); // duck the café-jazz bed under a playing tape, restore it when idle
}
function updateProgress() {
if (active.duration) {
const pct = `${(active.currentTime / active.duration) * 100}%`;
progressFill.style.width = pct;
deckFill.style.width = pct;
}
timeEl.textContent = fmt(active.currentTime);
deckTime.textContent = fmt(active.currentTime);
}
// both elements fire events; only react to the one the UI is tracking
for (const el of [audioA, audioB]) {
el.addEventListener("play", (e) => e.target === active && syncPlayState());
el.addEventListener("pause", (e) => e.target === active && syncPlayState());
el.addEventListener("timeupdate", (e) => {
if (e.target !== active) return;
updateProgress();
maybeAdvance();
});
}
function togglePlay() {
if (active.paused) {
active.play().catch(() => {});
} else {
cancelFade(); // a manual pause stops any in-flight crossfade too
audioA.pause();
audioB.pause();
}
}
playBtn.addEventListener("click", togglePlay);
pillToggle.addEventListener("click", togglePlay);
deckPlay.addEventListener("click", () => {
if (active.src) togglePlay();
});
// Loop toggle: lit = the current tape repeats; off = play through the shelf.
function syncLoopBtn() {
deckLoop.classList.toggle("on", loopOne);
deckLoop.setAttribute("aria-pressed", String(loopOne));
deckLoop.title = loopOne
? "looping this tape — click to play the whole shelf"
: "playing the whole shelf — click to loop this tape";
}
function setLoop(on) {
loopOne = on;
// apply to the live element(s) so the change takes effect this play, not
// just on the next track
audioA.loop = on;
audioB.loop = on;
syncLoopBtn();
}
deckLoop.addEventListener("click", () => setLoop(!loopOne));
syncLoopBtn();
const seek = (bar) => (e) => {
if (!active.duration) return;
const rect = bar.getBoundingClientRect();
active.currentTime =
((e.clientX - rect.left) / rect.width) * active.duration;
};
progress.addEventListener("click", seek(progress));
deckProgress.addEventListener("click", seek(deckProgress));
$("collection-close").addEventListener("click", () =>
onRequestCloseCollection(),
);
$("gameboy-close").addEventListener("click", () => onRequestCloseGameboy());
playWaitBtn.addEventListener("click", () => onPlayWhileWaiting());
caroPrev.addEventListener("click", () =>
centerOn(centerIndex - 1, { load: true }),
);
caroNext.addEventListener("click", () =>
centerOn(centerIndex + 1, { load: true }),
);
// --- modal controls ---------------------------------------------------------
lengthSlider.addEventListener("input", () => {
lengthValue.textContent = selectedLength().label;
});
coinBtn.addEventListener("click", startGeneration);
input.addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
startGeneration();
}
});
$("again-btn").addEventListener("click", () => {
setStage("prompt");
input.value = ""; // start fresh; the length slider keeps its last setting
input.focus();
});
$("modal-close").addEventListener("click", () => onRequestClose());
window.addEventListener("keydown", (e) => {
if (e.key === "Escape" && !modal.classList.contains("hidden")) {
onRequestClose();
}
});
// arrow keys flick through the carousel while the collection is open
window.addEventListener("keydown", (e) => {
if (!collectionOpen) return;
if (e.key === "ArrowLeft") {
e.preventDefault();
centerOn(centerIndex - 1, { load: true });
} else if (e.key === "ArrowRight") {
e.preventDefault();
centerOn(centerIndex + 1, { load: true });
}
});
function syncPill() {
// the corner pill yields to the machine modal, the collection, and the game boy
const modalHidden = modal.classList.contains("hidden");
pill.classList.toggle(
"hidden",
!(active.src && modalHidden && !collectionOpen && !gameboyOpen),
);
}
let closeTimer = null;
let gbCloseTimer = null;
return {
openModal() {
clearTimeout(closeTimer);
modal.classList.remove("hidden", "closing");
void modal.offsetWidth; // restart the entrance animation
modal.classList.add("opening");
setStage(currentStage());
syncPill();
if (!active.src && !busy) setTimeout(() => input.focus(), 600);
},
closeModal() {
if (modal.classList.contains("hidden")) return;
modal.classList.remove("opening");
modal.classList.add("closing");
clearTimeout(closeTimer);
closeTimer = setTimeout(() => {
modal.classList.add("hidden");
modal.classList.remove("closing");
syncPill();
}, 300);
syncPill();
},
openCollection,
closeCollection,
openGameboy() {
clearTimeout(gbCloseTimer);
gameboyOpen = true;
gameboyModal.classList.remove("hidden", "closing");
void gameboyModal.offsetWidth; // restart the entrance animation
gameboyModal.classList.add("opening");
if (!garden) garden = createGarden(gameboyModal);
garden.start();
syncPill();
},
closeGameboy() {
gameboyOpen = false;
garden?.stop();
if (gameboyModal.classList.contains("hidden")) return syncPill();
gameboyModal.classList.remove("opening");
gameboyModal.classList.add("closing");
clearTimeout(gbCloseTimer);
gbCloseTimer = setTimeout(() => {
gameboyModal.classList.add("hidden");
gameboyModal.classList.remove("closing");
}, 300);
syncPill();
},
};
}
|