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