Spaces:
Running on Zero
Running on Zero
| // 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(); | |
| }, | |
| }; | |
| } | |