// 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 = ''; 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(); }, }; }