// Movie LUT — guess the movie from a trickplay color barcode (function () { 'use strict'; if (window.__kefinMovieLutLoaded) { console.log('[MovieLUT] Already initialized; skipping re-entry'); return; } window.__kefinMovieLutLoaded = true; const LOG = (...args) => console.log('[MovieLUT]', ...args); const WARN = (...args) => console.warn('[MovieLUT]', ...args); const SAMPLE_COUNT = 256; const RUNTIME_END_FRAC = 0.95; const TICKS_PER_MS = 10000; const MAX_ACTOR_TRIES = 24; const MAX_MOVIE_PROBES_PER_ACTOR = 40; const LETTERS = ['A', 'B', 'C']; const els = { status: document.getElementById('mlut-status'), card: document.getElementById('mlut-card'), options: document.getElementById('mlut-options'), swatch: document.getElementById('mlut-swatch'), placeholder: document.getElementById('mlut-swatch-placeholder'), prev: document.getElementById('mlut-prev'), next: document.getElementById('mlut-next'), score: document.getElementById('mlut-score'), streak: document.getElementById('mlut-streak'), reveal: document.getElementById('mlut-reveal'), revealConnectors: document.getElementById('mlut-reveal-connectors'), revealTiles: document.getElementById('mlut-reveal-tiles') }; let score = 0; let streak = 0; let currentRound = null; let roundBusy = false; /** Full session history of rounds (answered + current). */ let history = []; let historyIndex = -1; function setStatus(text, tone) { if (!els.status) return; els.status.textContent = text || ''; els.status.dataset.tone = tone || 'muted'; } function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); } function shuffle(arr) { const a = arr.slice(); for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); const t = a[i]; a[i] = a[j]; a[j] = t; } return a; } function pickRandom(arr) { if (!arr || !arr.length) return null; return arr[Math.floor(Math.random() * arr.length)]; } function getApiClient() { if (typeof ApiClient !== 'undefined' && ApiClient) return ApiClient; if (window.parent && window.parent !== window && window.parent.ApiClient) { return window.parent.ApiClient; } return null; } function getPeopleCache() { if (window.PeopleCache) return window.PeopleCache; if (window.parent && window.parent !== window && window.parent.PeopleCache) { return window.parent.PeopleCache; } return null; } function getMoviesCache() { if (window.MoviesCache) return window.MoviesCache; if (window.parent && window.parent !== window && window.parent.MoviesCache) { return window.parent.MoviesCache; } return null; } function getApiHelper() { if (window.apiHelper || window.ApiHelper) return window.apiHelper || window.ApiHelper; if (window.parent && window.parent !== window) { return window.parent.apiHelper || window.parent.ApiHelper || null; } return null; } async function waitForApiClient(maxMs) { const limit = maxMs || 15000; const start = Date.now(); while (Date.now() - start < limit) { const client = getApiClient(); if (client && typeof client.getCurrentUserId === 'function') { try { if (client.getCurrentUserId() && client.accessToken && client.accessToken()) { return client; } } catch (e) { /* not ready */ } } await sleep(150); } return getApiClient(); } async function waitForPeopleCache(maxMs) { const limit = maxMs || 60000; const start = Date.now(); setStatus('Waiting for PeopleCache…', 'muted'); while (Date.now() - start < limit) { const cache = getPeopleCache(); if (cache && typeof cache.getTopActors === 'function') { try { const actors = await cache.getTopActors(); if (Array.isArray(actors) && actors.length > 0) { return { cache, actors }; } if (typeof cache.isComplete === 'function' && cache.isComplete()) { setStatus('No movies/actors available for Movie LUT.', 'error'); return null; } setStatus('Building people cache (this may take a while on first run)…', 'muted'); } catch (e) { WARN('getTopActors failed', e); } } await sleep(500); } return null; } function getTrickplayInfoFromItem(item) { const raw = item && item.Trickplay; if (!raw || typeof raw !== 'object') return null; if (typeof raw.Width !== 'undefined' && typeof raw.Interval !== 'undefined') { return raw; } const firstKey = Object.keys(raw)[0]; if (!firstKey) return null; const inner = raw[firstKey]; if (!inner || typeof inner !== 'object') return null; if (typeof inner.Width !== 'undefined' && typeof inner.Interval !== 'undefined') { return inner; } const secondKey = Object.keys(inner)[0]; if (!secondKey) return null; const info = inner[secondKey]; if (info && typeof info.Width !== 'undefined' && typeof info.Interval !== 'undefined') { return info; } return null; } /** * Resolve movie stubs for an actor on demand (PeopleCache no longer stores item ids). * Prefers MoviesCache People arrays; falls back to Items?PersonIds=. * @returns {Promise>} */ async function actorFilmography(actor) { const actorId = actor?.Id; if (!actorId) return []; const moviesCache = getMoviesCache(); if (moviesCache?.getMovies) { try { const movies = await moviesCache.getMovies(); const fromCache = (movies || []) .filter((m) => { if (!m?.Id || !Array.isArray(m.People)) return false; return m.People.some((p) => p?.Id === actorId && (!p.Type || p.Type === 'Actor')); }) .map((m) => ({ Id: m.Id })); if (fromCache.length) return fromCache; } catch (e) { WARN('MoviesCache filmography lookup failed', e); } } const api = getApiHelper(); const apiClient = getApiClient(); if (api?.getItems) { try { const data = await api.getItems({ PersonIds: actorId, IncludeItemTypes: 'Movie', Recursive: true, Fields: '', EnableTotalRecordCount: false, Limit: 100 }, false); const items = data?.Items || []; return items.filter((m) => m?.Id).map((m) => ({ Id: m.Id })); } catch (e) { WARN('PersonIds filmography fetch failed', e); } } if (apiClient?.getItems) { try { const userId = apiClient.getCurrentUserId(); const data = await apiClient.getItems(userId, { PersonIds: actorId, IncludeItemTypes: 'Movie', Recursive: true, Fields: '', EnableTotalRecordCount: false, Limit: 100 }); const items = data?.Items || []; return items.filter((m) => m?.Id).map((m) => ({ Id: m.Id })); } catch (e) { WARN('ApiClient filmography fetch failed', e); } } return []; } function actorLikelyHasEnoughMovies(actor) { const movieCount = actor?.movies?.actorCount; if (Number.isFinite(movieCount) && movieCount >= 3) return true; const count = actor?.count; if (Number.isFinite(count) && count >= 3) return true; return false; } async function fetchFullItem(apiClient, itemId) { const userId = apiClient.getCurrentUserId(); if (!userId) return null; try { return await apiClient.getItem(userId, itemId); } catch (e) { WARN('getItem failed', itemId, e); return null; } } function yearOf(item) { if (item.ProductionYear) return item.ProductionYear; if (item.PremiereDate) { const y = new Date(item.PremiereDate).getFullYear(); if (!Number.isNaN(y)) return y; } return null; } function formatTitle(item) { const y = yearOf(item); return y != null ? `${item.Name} (${y})` : String(item.Name || 'Unknown'); } function tileRef(trickplayInfo, positionTicks) { const interval = trickplayInfo.Interval || 10000; const tileWidth = trickplayInfo.TileWidth || 10; const tileHeight = trickplayInfo.TileHeight || 10; const currentTimeMs = positionTicks / TICKS_PER_MS; const currentTile = Math.floor(currentTimeMs / interval); const tileSize = tileWidth * tileHeight; const sheetIndex = Math.floor(currentTile / tileSize); const tileOffset = currentTile % tileSize; const tileOffsetX = tileOffset % tileWidth; const tileOffsetY = Math.floor(tileOffset / tileWidth); return { sheetIndex, tileOffsetX, tileOffsetY }; } function positionTicksForStripe(item, stripeIndex) { const runEnd = Math.floor(item.RunTimeTicks * RUNTIME_END_FRAC); const t = SAMPLE_COUNT <= 1 ? 0 : stripeIndex / (SAMPLE_COUNT - 1); return Math.floor(runEnd * t); } /** * One random stripe per segment (frac of barcode width), not equal thirds — * keeps connectors over the matching full-width tile: * 0–30% / 35–65% / 70–100%. */ function pickThirdStripeIndices() { const ranges = [ [0.05, 0.3], [0.35, 0.65], [0.7, 0.95] ]; return ranges.map(([startFrac, endFrac]) => { const lo = Math.max(0, Math.floor(startFrac * SAMPLE_COUNT)); const hi = Math.min(SAMPLE_COUNT - 1, Math.ceil(endFrac * SAMPLE_COUNT) - 1); const a = Math.min(lo, hi); const b = Math.max(lo, hi); return a + Math.floor(Math.random() * (b - a + 1)); }); } function loadImage(url) { return new Promise((resolve, reject) => { const img = new Image(); img.crossOrigin = 'anonymous'; img.onload = () => resolve(img); img.onerror = () => reject(new Error('Failed to load sheet')); img.src = url; }); } function averageTileRgb(ctx, sx, sy, tw, th) { let data; try { data = ctx.getImageData(sx, sy, tw, th).data; } catch (e) { WARN('getImageData blocked (CORS?)', e); return null; } let r = 0; let g = 0; let b = 0; let n = 0; const step = Math.max(1, Math.floor((tw * th) / 400)); for (let i = 0; i < data.length; i += 4 * step) { r += data[i]; g += data[i + 1]; b += data[i + 2]; n++; } if (!n) return { r: 0, g: 0, b: 0 }; return { r: Math.round(r / n), g: Math.round(g / n), b: Math.round(b / n) }; } async function loadSheetMap(apiClient, item, sheetIndexes) { const trickplayInfo = getTrickplayInfoFromItem(item); const mediaSourceId = item.MediaSourceId || item.Id; const width = trickplayInfo.Width || 320; const sheetCanvas = new Map(); for (const sheetIndex of sheetIndexes) { if (sheetCanvas.has(sheetIndex)) continue; const imgSrc = apiClient.getUrl( 'Videos/' + item.Id + '/Trickplay/' + width + '/' + sheetIndex + '.jpg', { ApiKey: apiClient.accessToken(), MediaSourceId: mediaSourceId } ); try { const img = await loadImage(imgSrc); const c = document.createElement('canvas'); c.width = img.naturalWidth || img.width; c.height = img.naturalHeight || img.height; const ctx = c.getContext('2d', { willReadFrequently: true }); ctx.drawImage(img, 0, 0); sheetCanvas.set(sheetIndex, { canvas: c, ctx, width: c.width, height: c.height }); } catch (e) { WARN('sheet load failed', sheetIndex, e); } } return sheetCanvas; } function cropTileToCanvas(sheet, trickplayInfo, ref) { const width = trickplayInfo.Width || 320; const height = trickplayInfo.Height || 180; const tileWidth = trickplayInfo.TileWidth || 10; const tileHeight = trickplayInfo.TileHeight || 10; let sx = ref.tileOffsetX * width; let sy = ref.tileOffsetY * height; let tw = width; let th = height; if (sx + tw > sheet.width || sy + th > sheet.height) { const cellW = sheet.width / tileWidth; const cellH = sheet.height / tileHeight; sx = Math.floor(ref.tileOffsetX * cellW); sy = Math.floor(ref.tileOffsetY * cellH); tw = Math.max(1, Math.floor(cellW)); th = Math.max(1, Math.floor(cellH)); } const out = document.createElement('canvas'); out.width = tw; out.height = th; const octx = out.getContext('2d'); octx.drawImage(sheet.canvas, sx, sy, tw, th, 0, 0, tw, th); return out; } async function sampleColorsFromTrickplay(apiClient, item) { const trickplayInfo = getTrickplayInfoFromItem(item); if (!trickplayInfo || !item.RunTimeTicks) { throw new Error('No trickplay data'); } const width = trickplayInfo.Width || 320; const height = trickplayInfo.Height || 180; const samples = []; for (let i = 0; i < SAMPLE_COUNT; i++) { const positionTicks = positionTicksForStripe(item, i); const ref = tileRef(trickplayInfo, positionTicks); samples.push({ sheetIndex: ref.sheetIndex, tileOffsetX: ref.tileOffsetX, tileOffsetY: ref.tileOffsetY }); } const sheetsNeeded = [...new Set(samples.map((s) => s.sheetIndex))]; const sheetCanvas = await loadSheetMap(apiClient, item, sheetsNeeded); if (!sheetCanvas.size) { throw new Error('Could not load any trickplay sheets'); } const tileWidth = trickplayInfo.TileWidth || 10; const tileHeight = trickplayInfo.TileHeight || 10; const colors = []; for (const sample of samples) { const sheet = sheetCanvas.get(sample.sheetIndex); if (!sheet) { colors.push({ r: 20, g: 20, b: 20 }); continue; } let sx = sample.tileOffsetX * width; let sy = sample.tileOffsetY * height; let tw = width; let th = height; if (sx + tw > sheet.width || sy + th > sheet.height) { const cellW = sheet.width / tileWidth; const cellH = sheet.height / tileHeight; sx = Math.floor(sample.tileOffsetX * cellW); sy = Math.floor(sample.tileOffsetY * cellH); tw = Math.max(1, Math.floor(cellW)); th = Math.max(1, Math.floor(cellH)); } const rgb = averageTileRgb(sheet.ctx, sx, sy, tw, th); colors.push(rgb || { r: 30, g: 30, b: 30 }); } return colors; } /** * Extract visible stills for three stripe indices (one per third of runtime / barcode). */ async function extractRevealStills(apiClient, item, stripeIndices) { const trickplayInfo = getTrickplayInfoFromItem(item); if (!trickplayInfo) return []; const refs = stripeIndices.map((stripeIndex) => { const positionTicks = positionTicksForStripe(item, stripeIndex); const ref = tileRef(trickplayInfo, positionTicks); return { stripeIndex, ...ref }; }); const sheets = await loadSheetMap( apiClient, item, refs.map((r) => r.sheetIndex) ); return refs.map((ref) => { const sheet = sheets.get(ref.sheetIndex); const canvas = sheet ? cropTileToCanvas(sheet, trickplayInfo, ref) : null; const frac = SAMPLE_COUNT <= 1 ? 0.5 : (ref.stripeIndex + 0.5) / SAMPLE_COUNT; return { stripeIndex: ref.stripeIndex, frac, canvas }; }); } function paintBarcode(canvas, colors) { if (!canvas || !colors || !colors.length) return; const dpr = window.devicePixelRatio || 1; const cssW = canvas.clientWidth || 640; const cssH = canvas.clientHeight || 200; canvas.width = Math.max(1, Math.floor(cssW * dpr)); canvas.height = Math.max(1, Math.floor(cssH * dpr)); const ctx = canvas.getContext('2d'); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); const w = cssW; const h = cssH; const n = colors.length; for (let i = 0; i < n; i++) { const c = colors[i]; ctx.fillStyle = `rgb(${c.r},${c.g},${c.b})`; const x0 = (i / n) * w; const x1 = ((i + 1) / n) * w; ctx.fillRect(x0, 0, Math.ceil(x1 - x0) + 0.5, h); } } function updateNavButtons() { const round = history[historyIndex] || currentRound; if (els.prev) { els.prev.hidden = historyIndex <= 0; } if (els.next) { // Forward when current view is already answered els.next.hidden = !(round && round.guessed); } } function clearReveal() { if (els.reveal) els.reveal.hidden = true; if (els.revealConnectors) els.revealConnectors.innerHTML = ''; if (els.revealTiles) els.revealTiles.innerHTML = ''; } /** * Straight vertical lines from each stripe on the barcode down to the tile below. * Arrowheads sit on the barcode edge and point at the stripe. */ function layoutRevealConnectors(stills) { if (!els.reveal || !els.revealConnectors || !els.swatch || !els.revealTiles) return; const svg = els.revealConnectors; svg.innerHTML = ''; const revealRect = els.reveal.getBoundingClientRect(); const swatchRect = els.swatch.getBoundingClientRect(); if (!revealRect.width || !swatchRect.width) return; // Extend SVG upward so connectors can reach the swatch above the reveal block const lift = Math.max(0, revealRect.top - swatchRect.bottom + 1); const svgH = revealRect.height + lift; const svgW = revealRect.width; svg.style.top = `${-lift}px`; svg.style.height = `${svgH}px`; svg.setAttribute('viewBox', `0 0 ${svgW} ${svgH}`); svg.setAttribute('width', String(svgW)); svg.setAttribute('height', String(svgH)); const tileEls = els.revealTiles.querySelectorAll('.mlut-reveal-tile'); const NS = 'http://www.w3.org/2000/svg'; const arrowH = 10; const arrowW = 7; const originTop = revealRect.top - lift; const originLeft = revealRect.left; stills.forEach((still, i) => { const tileEl = tileEls[i]; if (!tileEl) return; const tileRect = tileEl.getBoundingClientRect(); // Pure vertical: stripe X, from barcode bottom down to tile top const x = swatchRect.left + still.frac * swatchRect.width - originLeft; const yStripe = swatchRect.bottom - originTop; const yTile = tileRect.top - originTop; const line = document.createElementNS(NS, 'line'); line.setAttribute('x1', String(x)); line.setAttribute('y1', String(yTile)); line.setAttribute('x2', String(x)); line.setAttribute('y2', String(yStripe + arrowH)); svg.appendChild(line); // Arrow pointing up at the stripe (tip on barcode, base toward tile) const poly = document.createElementNS(NS, 'polygon'); poly.setAttribute( 'points', `${x},${yStripe} ${x - arrowW},${yStripe + arrowH} ${x + arrowW},${yStripe + arrowH}` ); svg.appendChild(poly); }); } function showReveal(stills) { if (!els.reveal || !els.revealTiles) return; if (els.revealConnectors) els.revealConnectors.innerHTML = ''; els.revealTiles.innerHTML = ''; stills.forEach((still) => { const tile = document.createElement('figure'); tile.className = 'mlut-reveal-tile'; if (still.canvas) { // Clone canvas so history restore can re-append without detaching cached ref const c = document.createElement('canvas'); c.width = still.canvas.width; c.height = still.canvas.height; c.getContext('2d').drawImage(still.canvas, 0, 0); tile.appendChild(c); } else { const ph = document.createElement('div'); ph.className = 'mlut-reveal-ph'; tile.appendChild(ph); } els.revealTiles.appendChild(tile); }); els.reveal.hidden = false; requestAnimationFrame(() => { layoutRevealConnectors(stills); }); } async function gatherThreeTrickplayMovies(apiClient, actor) { const filmography = shuffle(await actorFilmography(actor)); const found = []; let probes = 0; for (const entry of filmography) { if (found.length >= 3) break; if (probes >= MAX_MOVIE_PROBES_PER_ACTOR) break; probes++; const item = await fetchFullItem(apiClient, entry.Id); if (!item || !item.RunTimeTicks || item.RunTimeTicks <= 0) continue; if (!getTrickplayInfoFromItem(item)) continue; if (found.some((f) => f.Id === item.Id)) continue; found.push(item); } return found; } async function pickRound(apiClient, actors) { const preferred = actors.filter(actorLikelyHasEnoughMovies); const actorPool = shuffle(preferred.length ? preferred : actors); let tries = 0; for (const actor of actorPool) { if (tries >= MAX_ACTOR_TRIES) break; tries++; setStatus(`Checking films for ${actor.Name || 'actor'}…`, 'muted'); const three = await gatherThreeTrickplayMovies(apiClient, actor); if (three.length < 3) continue; const answer = pickRandom(three); const options = shuffle(three); return { actor, answer, options }; } throw new Error( 'Could not find an actor with three movies that have trickplay. Generate trickplay for more titles or wait for PeopleCache to finish.' ); } function escapeHtml(str) { return String(str) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } function promptForActor(actor) { const name = (actor && actor.Name) || 'this actor'; return `Which ${name} film is this?`; } function renderOptions(round) { els.options.innerHTML = ''; round.options.forEach((item, i) => { const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'mlut-option'; btn.dataset.itemId = item.Id; btn.innerHTML = `${LETTERS[i] || String(i + 1)})` + escapeHtml(formatTitle(item)); if (round.guessed) { btn.disabled = true; if (item.Id === round.answer.Id) btn.classList.add('is-correct'); if (round.selectedItemId && item.Id === round.selectedItemId && item.Id !== round.answer.Id) { btn.classList.add('is-wrong'); } } else { btn.addEventListener('click', () => onGuess(item.Id)); } els.options.appendChild(btn); }); } function restoreRound(round) { currentRound = round; if (!round) { els.card.classList.remove('is-visible'); clearReveal(); updateNavButtons(); return; } renderOptions(round); els.card.classList.add('is-visible'); if (els.placeholder) { els.placeholder.style.display = round._colors ? 'none' : 'flex'; } requestAnimationFrame(() => { if (round._colors) paintBarcode(els.swatch, round._colors); if (round.guessed && round.stills && round.stills.length) { showReveal(round.stills); const correct = round.selectedItemId === round.answer.Id; setStatus( correct ? 'Correct!' : `Wrong — it was ${formatTitle(round.answer)}.`, correct ? 'ok' : 'error' ); } else { clearReveal(); setStatus(promptForActor(round.actor), 'muted'); } updateNavButtons(); }); } async function onGuess(itemId) { if (!currentRound || currentRound.guessed || roundBusy) return; currentRound.guessed = true; currentRound.selectedItemId = itemId; const correctId = currentRound.answer.Id; const correct = itemId === correctId; Array.from(els.options.querySelectorAll('.mlut-option')).forEach((btn) => { btn.disabled = true; const id = btn.dataset.itemId; if (id === correctId) btn.classList.add('is-correct'); if (id === itemId && !correct) btn.classList.add('is-wrong'); }); if (correct) { score += 1; streak += 1; setStatus('Correct!', 'ok'); } else { streak = 0; setStatus(`Wrong — it was ${formatTitle(currentRound.answer)}.`, 'error'); } els.score.textContent = String(score); els.streak.textContent = String(streak); updateNavButtons(); try { const apiClient = getApiClient() || currentRound.apiClient; if (apiClient) { setStatus( (correct ? 'Correct! ' : `Wrong — it was ${formatTitle(currentRound.answer)}. `) + 'Loading stills…', correct ? 'ok' : 'error' ); const stripeIndices = pickThirdStripeIndices(); const stills = await extractRevealStills(apiClient, currentRound.answer, stripeIndices); currentRound.stills = stills; // Only show if user is still on this round if (history[historyIndex] === currentRound) { showReveal(stills); setStatus( correct ? 'Correct!' : `Wrong — it was ${formatTitle(currentRound.answer)}.`, correct ? 'ok' : 'error' ); } } } catch (e) { WARN('reveal stills failed', e); setStatus( correct ? 'Correct!' : `Wrong — it was ${formatTitle(currentRound.answer)}.`, correct ? 'ok' : 'error' ); } updateNavButtons(); } async function startRound() { if (roundBusy) return; roundBusy = true; clearReveal(); if (els.placeholder) { els.placeholder.style.display = 'flex'; els.placeholder.textContent = 'Preparing swatch…'; } if (els.next) els.next.hidden = true; try { const apiClient = await waitForApiClient(15000); if (!apiClient) { throw new Error('ApiClient not available. Open this page from within Jellyfin while logged in.'); } setStatus('Loading top actors…', 'muted'); const people = await waitForPeopleCache(90000); if (!people || !people.actors.length) { throw new Error( 'You don\'t have enough movies in your library to play this game. Add more movies to your library and try again.' ); } setStatus('Picking actor and films…', 'muted'); const round = await pickRound(apiClient, people.actors); setStatus(`Building swatch from still frames…`, 'muted'); const colors = await sampleColorsFromTrickplay(apiClient, round.answer); const entry = { ...round, guessed: false, selectedItemId: null, stills: null, _colors: colors, apiClient }; // Drop any future after branching (shouldn't exist); append new history = history.slice(0, historyIndex + 1); history.push(entry); historyIndex = history.length - 1; currentRound = entry; renderOptions(entry); els.card.classList.add('is-visible'); requestAnimationFrame(() => { paintBarcode(els.swatch, colors); if (els.placeholder) els.placeholder.style.display = 'none'; }); setStatus(promptForActor(round.actor), 'muted'); updateNavButtons(); } catch (e) { WARN(e); setStatus(e && e.message ? e.message : String(e), 'error'); currentRound = history[historyIndex] || null; // Allow next only if we already finished a round before updateNavButtons(); if (!history.length) { if (els.next) els.next.hidden = false; // retry start via next when cold-fail } } finally { roundBusy = false; } } function goPrev() { if (historyIndex <= 0 || roundBusy) return; historyIndex -= 1; restoreRound(history[historyIndex]); } async function goNext() { if (roundBusy) return; const round = history[historyIndex]; if (!round || !round.guessed) return; if (historyIndex < history.length - 1) { historyIndex += 1; restoreRound(history[historyIndex]); return; } // At tip of history → load a new round await startRound(); } function wire() { if (els.prev) els.prev.addEventListener('click', () => goPrev()); if (els.next) els.next.addEventListener('click', () => goNext()); window.addEventListener('resize', () => { if (currentRound && currentRound._colors) { paintBarcode(els.swatch, currentRound._colors); } if (currentRound && currentRound.guessed && currentRound.stills) { layoutRevealConnectors(currentRound.stills); } }); startRound(); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', wire); } else { wire(); } LOG('ready'); })();