Files
KefinTweaks/pages/movieLut.js
T

888 lines
32 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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<Array<{ Id: string }>>}
*/
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
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 =
`<span class="mlut-option-letter">${LETTERS[i] || String(i + 1)})</span>` +
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');
})();