🎬

Tashumi

v4.0

Build, rank & explore media grids and games

Selected Items

0 / 9

Edit titles or subtitles inline. Tap two cells in the live preview to swap positions, or tap an empty slot to search. Tip: paste an image with Ctrl+V, and use βœ₯ Pan & Zoom to reframe covers.

πŸ“š Saved Boards

Local browser storage

Boards are stored locally in this browser. Use Share Link in the Grid Studio to send a board to someone else.

Grid Studio & Live Preview

Tap any two cells to swap β€’ Tap empty slot to search
t> const $ = s => document.querySelector(s); const items = []; let results = []; let playMode = false; let swapIndex = null; // Stores item index when swapping two cells in Arrange mode const markedCells = new Set(); // Stores cell indices marked in Play Bingo mode let lastRenderedBlobUrl = null; let activeSourceFilter = 'All'; let coverSwapIndex = null; let dragState = null; let searchDebounceTimer = null; let selectedBoardId = ''; let namedBoards = {}; let activeSearchId = 0; // Cancels stale progressive searches const searchCache = new Map(); // Instant in-memory search cache const STATE_KEY = 'ultimate_media_grid_v3_4'; const MARKS_KEY = 'ultimate_media_grid_marks'; const searchStats = { failed: 0, total: 0 }; let quotaWarned = false; let cropMode = false; // Pan & Zoom sub-mode of Arrange let cropSel = null; // item index currently selected for cropping let suppressClickUntil = 0; // swallow the click that follows a drag const clamp = (n, a, b) => Math.min(b, Math.max(a, n)); const num = (v, d) => { const n = Number(v); return (v === undefined || v === null || v === '' || !Number.isFinite(n)) ? d : n; }; const itemFX = it => clamp(num(it.focalX, 50), 0, 100); const itemFY = it => clamp(num(it.focalY, 50), 0, 100); const itemZoom = it => clamp(num(it.zoom, 1), 1, 2.5); function persistMarks() { try { localStorage.setItem(MARKS_KEY, JSON.stringify([...markedCells])); } catch (_) {} } function loadMarks() { try { JSON.parse(localStorage.getItem(MARKS_KEY) || '[]').forEach(n => { if (Number.isInteger(n)) markedCells.add(n); }); } catch (_) {} } function sfwOn() { const el = $('#sfw'); return el ? el.checked : true; } function friendlyError(e) { if (typeof AggregateError !== 'undefined' && e instanceof AggregateError) return 'every network route failed. Check your connection and try again.'; return (e && e.message) || 'unknown error'; } /* Default subtitle: creator/series for sources where "origin" is meaningful, otherwise the media type */ const ORIGIN_SOURCES = new Set(['AniList', 'VNDB', 'Books', 'OpenLib', 'iTunes', 'Deezer', 'MusicBrainz']); function defaultSubtitle(r) { const t = r.type === 'Novels' ? 'Novel' : (r.type || ''); const o = String(r.origin || '').trim(); if (!o || /^\d{4}$/.test(o) || o === String(r.year || '')) return t; if (!ORIGIN_SOURCES.has(r.sourceBadge)) return t; if (t === 'Character' || t === 'Person') return o; if (t === 'Book' || t === 'Novel' || t === 'Music Album') return `${t} β€’ ${o}`; return t; } /* ---- Toast Notification Helper (Replaces alert/confirm) ---- */ let toastTimer = null; function showToast(msg, actionLabel = '', actionFn = null, duration = 3200) { const el = $('#toast'); let message = $('#toastMessage'); if (!message) { message = document.createElement('span'); message.id = 'toastMessage'; el.append(message); } message.textContent = msg; const oldAction = el.querySelector('.toast-action'); if (oldAction) oldAction.remove(); if (actionLabel && actionFn) { const action = document.createElement('button'); action.type = 'button'; action.className = 'toast-action btn text-xs py-1 px-2'; action.style.cssText = 'background:var(--acc);color:var(--accfg);border-color:var(--acc);flex-shrink:0'; action.textContent = actionLabel; action.onclick = () => { el.classList.add('hidden'); el.classList.remove('flex'); clearTimeout(toastTimer); actionFn(); }; el.append(action); } el.classList.remove('hidden'); el.classList.add('flex'); clearTimeout(toastTimer); toastTimer = setTimeout(() => { el.classList.add('hidden'); el.classList.remove('flex'); }, duration); } function cloneItems() { return items.map(it => ({ ...it })); } /* ---- Undo / Redo history (20 steps). Every mutation ends in saveState(), which records the change. ---- */ const HISTORY_MAX = 20; const undoStack = [], redoStack = []; let historySnap = [], historySig = '', historyAt = 0, historyBusy = false, historyLastStructural = true; function itemsSig() { return JSON.stringify(items); } function syncHistoryButtons() { const u = $('#undoBtn'), r = $('#redoBtn'); if (u) u.disabled = !undoStack.length; if (r) r.disabled = !redoStack.length; } function resetHistory() { undoStack.length = 0; redoStack.length = 0; historySnap = cloneItems(); historySig = itemsSig(); historyAt = 0; historyLastStructural = true; syncHistoryButtons(); } function trackHistory() { try { if (historyBusy) return; const sig = itemsSig(); if (sig === historySig) return; const now = Date.now(); // Order/add/remove/cover changes are always their own step; rapid text edits and drags merge into one. const structural = historySnap.length !== items.length || historySnap.some((it, i) => !items[i] || it.image !== items[i].image); // Only consecutive non-structural edits (typing, one drag/zoom gesture) merge into a single step. if (structural || historyLastStructural || !undoStack.length || now - historyAt > 900) { undoStack.push(historySnap); if (undoStack.length > HISTORY_MAX) undoStack.shift(); } redoStack.length = 0; historyLastStructural = structural; historySnap = cloneItems(); historySig = sig; historyAt = now; syncHistoryButtons(); } catch (_) {} } function restoreItems(list) { historyBusy = true; try { items.splice(0, items.length, ...list.map(it => ({ ...it }))); coverSwapIndex = null; swapIndex = null; cropSel = null; markedCells.clear(); persistMarks(); items.forEach(it => getImg(it.image)); showList(); showResults(); } finally { historyBusy = false; historySnap = cloneItems(); historySig = itemsSig(); historyAt = 0; historyLastStructural = true; syncHistoryButtons(); } } function undo() { if (!undoStack.length) { showToast('Nothing to undo.'); return; } redoStack.push(cloneItems()); restoreItems(undoStack.pop()); showToast('Undone.', redoStack.length ? 'β†· Redo' : '', redoStack.length ? redo : null, 4000); } function redo() { if (!redoStack.length) { showToast('Nothing to redo.'); return; } undoStack.push(cloneItems()); restoreItems(redoStack.pop()); showToast('Redone.', undoStack.length ? 'β†Ά Undo' : '', undoStack.length ? undo : null, 4000); } // The mutation has already been recorded by saveState(); the toast just offers a one-tap undo. function showUndoToast(msg) { showToast(msg, 'β†Ά Undo', undo, 5000); } function formatSubtitle(it) { const base = String(it.type || '').trim(); const year = $('#showYear') && $('#showYear').checked && it.year ? String(it.year).slice(0, 4) : ''; return base && year ? `${base} β€’ ${year}` : (base || year); } /* ---- Fast, Non-Blocking Network Helpers ---- */ const J = { 'Content-Type': 'application/json' }; const key = n => $('#k_' + n).value.trim(); /* ---- Configurable Search Sites Registry ---- */ const SITE_DEFS = [ { id: 'anilist', name: 'AniList', cats: 'Anime, Manga, Novels, Characters, Staff', defaultOn: true, fast: true }, { id: 'kitsu', name: 'Kitsu', cats: 'Anime, Manga, Characters', defaultOn: true, fast: true }, { id: 'shikimori', name: 'Shikimori', cats: 'Anime, Manga, Characters', defaultOn: true, fast: true }, { id: 'vndb', name: 'VNDB', cats: 'Visual Novels, Characters', defaultOn: true, fast: true }, { id: 'steam', name: 'Steam / CheapShark', cats: 'PC Games', defaultOn: true, fast: true }, { id: 'gog', name: 'GOG.com', cats: 'PC Games', defaultOn: true, fast: true }, { id: 'imdb', name: 'IMDb', cats: 'Movies, TV Shows, Games', defaultOn: true, fast: true }, { id: 'tvmaze', name: 'TVMaze', cats: 'TV Shows, Actors/Characters', defaultOn: true, fast: true }, { id: 'itunes', name: 'Apple iTunes / App', cats: 'Movies, TV, Music, Books, Podcasts, Games', defaultOn: true, fast: true }, { id: 'deezer', name: 'Deezer', cats: 'Music Albums, Artists', defaultOn: true, fast: true }, { id: 'musicbrainz', name: 'MusicBrainz', cats: 'Music Albums', defaultOn: true, fast: false }, { id: 'gbooks', name: 'Google Books', cats: 'Books & Light Novels', defaultOn: true, fast: true }, { id: 'openlib', name: 'OpenLibrary', cats: 'Books', defaultOn: true, fast: false }, { id: 'wikidata', name: 'Wikidata Commons', cats: 'Games, Movies, TV, Characters', defaultOn: true, fast: false }, { id: 'tmdb', name: 'TMDB', cats: 'Movies & TV, requires your key in Settings', defaultOn: true, fast: true }, { id: 'rawg', name: 'RAWG', cats: 'Video Games, requires your key in Settings', defaultOn: true, fast: true }, { id: 'scryfall', name: 'Scryfall', cats: 'Magic: The Gathering', defaultOn: true, fast: true }, { id: 'wikipedia', name: 'Wikipedia', cats: 'All Categories',defaultOn: true, fast: true } ]; const enabledSites = {}; SITE_DEFS.forEach(s => { enabledSites[s.id] = s.defaultOn; }); let onlySiteOverride = null; // null = follow the Source dropdown; '' = force all sources (used by demo loader) const isSiteOn = id => { const only = onlySiteOverride !== null ? onlySiteOverride : (($('#srcSel') && $('#srcSel').value) || ''); return only ? id === only : enabledSites[id] !== false; }; function renderSitesSettings() { const box = $('#sitesList'); if (!box) return; box.innerHTML = ''; SITE_DEFS.forEach(s => { const lbl = document.createElement('label'); lbl.className = 'flex items-start gap-2.5 p-2 rounded-lg cursor-pointer select-none transition-all'; lbl.style.background = 'var(--card-alt)'; lbl.style.border = '1px solid var(--line)'; const cb = document.createElement('input'); cb.type = 'checkbox'; cb.checked = isSiteOn(s.id); cb.className = 'mt-0.5'; cb.onchange = () => { enabledSites[s.id] = cb.checked; searchCache.clear(); saveState(); }; const info = document.createElement('div'); info.className = 'flex-1 min-w-0'; info.innerHTML = `
${s.name}
${s.cats}
`; lbl.append(cb, info); box.append(lbl); }); } function fetchWithTimeout(url, opts = {}, ms = 4200) { const ctrl = new AbortController(); const id = setTimeout(() => ctrl.abort(), ms); return fetch(url, { referrerPolicy: 'no-referrer', ...opts, signal: ctrl.signal }) .finally(() => clearTimeout(id)); } /* JSONP loader (