diff --git a/app/card-images.js b/app/card-images.js index 2ecd0b1..7f1bf7e 100644 --- a/app/card-images.js +++ b/app/card-images.js @@ -106,12 +106,48 @@ fit: "inside", quality: 82 }; + const standardMajorCardNames = [ + "Fool", + "Magus", + "High Priestess", + "Empress", + "Emperor", + "Hierophant", + "Lovers", + "Chariot", + "Lust", + "Hermit", + "Fortune", + "Justice", + "Hanged Man", + "Death", + "Art", + "Devil", + "Tower", + "Star", + "Moon", + "Sun", + "Aeon", + "Universe" + ]; + const standardMinorRanks = ["Ace", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight", "Nine", "Ten", "Knight", "Queen", "Prince", "Princess"]; + const standardMinorSuits = ["Wands", "Cups", "Swords", "Disks"]; + const standardDeckCardNames = buildStandardDeckCardNames(); let deckManifestSources = buildDeckManifestSources(); const manifestCache = new Map(); const cardBackCache = new Map(); const cardBackThumbnailCache = new Map(); + const imagePreloadCache = new Map(); + const deckImagePreloadCache = new Map(); + const deckPreloadStatus = { + activeDeckId: DEFAULT_DECK_ID, + selectedDeckPhase: "idle", + backgroundPhase: "idle", + warmedDeckIds: [] + }; + let backgroundDeckWarmupPromise = null; let activeDeckId = DEFAULT_DECK_ID; function getApiBaseUrl() { @@ -219,6 +255,82 @@ return { resolvedDeckId, trumpNumber }; } + function buildStandardDeckCardNames() { + const cardNames = [...standardMajorCardNames]; + + standardMinorSuits.forEach((suit) => { + standardMinorRanks.forEach((rank) => { + cardNames.push(`${rank} of ${suit}`); + }); + }); + + return cardNames; + } + + function deferPreload(callback) { + if (typeof callback !== "function") { + return Promise.resolve([]); + } + + if (typeof window.requestIdleCallback === "function") { + return new Promise((resolve) => { + window.requestIdleCallback(() => { + Promise.resolve(callback()).then(resolve).catch(() => resolve([])); + }, { timeout: 1200 }); + }); + } + + return Promise.resolve().then(callback).catch(() => []); + } + + function emitDeckPreloadStatus() { + const snapshot = { + activeDeckId: deckPreloadStatus.activeDeckId, + selectedDeckPhase: deckPreloadStatus.selectedDeckPhase, + backgroundPhase: deckPreloadStatus.backgroundPhase, + warmedDeckIds: [...deckPreloadStatus.warmedDeckIds], + warmedDeckCount: deckPreloadStatus.warmedDeckIds.length, + totalDeckCount: Object.keys(getDeckManifestSources()).length + }; + + document.dispatchEvent(new CustomEvent("tarot:deck-cache-status", { + detail: snapshot + })); + + return snapshot; + } + + function setDeckPreloadStatus(partialStatus) { + if (!partialStatus || typeof partialStatus !== "object") { + return emitDeckPreloadStatus(); + } + + if (Object.prototype.hasOwnProperty.call(partialStatus, "activeDeckId")) { + deckPreloadStatus.activeDeckId = normalizeDeckId(partialStatus.activeDeckId); + } + + if (Object.prototype.hasOwnProperty.call(partialStatus, "selectedDeckPhase")) { + deckPreloadStatus.selectedDeckPhase = String(partialStatus.selectedDeckPhase || "idle"); + } + + if (Object.prototype.hasOwnProperty.call(partialStatus, "backgroundPhase")) { + deckPreloadStatus.backgroundPhase = String(partialStatus.backgroundPhase || "idle"); + } + + if (Array.isArray(partialStatus.warmedDeckIds)) { + deckPreloadStatus.warmedDeckIds = Array.from(new Set(partialStatus.warmedDeckIds.map((deckId) => normalizeDeckId(deckId)))); + } + + return emitDeckPreloadStatus(); + } + + function markDeckAsWarmed(deckId) { + const normalizedDeckId = normalizeDeckId(deckId); + if (!deckPreloadStatus.warmedDeckIds.includes(normalizedDeckId)) { + deckPreloadStatus.warmedDeckIds = [...deckPreloadStatus.warmedDeckIds, normalizedDeckId]; + } + } + function parseMinorCard(cardName) { const match = String(cardName || "") .trim() @@ -461,6 +573,15 @@ manifestCache.clear(); cardBackCache.clear(); cardBackThumbnailCache.clear(); + imagePreloadCache.clear(); + deckImagePreloadCache.clear(); + backgroundDeckWarmupPromise = null; + setDeckPreloadStatus({ + activeDeckId, + selectedDeckPhase: "idle", + backgroundPhase: "idle", + warmedDeckIds: [] + }); setActiveDeck(activeDeckId); } @@ -824,6 +945,212 @@ return thumbnailPath || null; } + function preloadImageUrl(url) { + const normalizedUrl = String(url || "").trim(); + if (!normalizedUrl) { + return Promise.resolve(false); + } + + if (imagePreloadCache.has(normalizedUrl)) { + return imagePreloadCache.get(normalizedUrl); + } + + const preloadPromise = new Promise((resolve) => { + const image = new Image(); + let settled = false; + + function finalize(result) { + if (settled) { + return; + } + + settled = true; + image.onload = null; + image.onerror = null; + resolve(result); + } + + image.decoding = "async"; + image.onload = () => finalize(true); + image.onerror = () => finalize(false); + image.src = normalizedUrl; + + if (image.complete) { + finalize(Boolean(image.naturalWidth)); + } + }); + + imagePreloadCache.set(normalizedUrl, preloadPromise); + return preloadPromise; + } + + async function preloadImageUrls(urls, concurrency = 6) { + const queue = Array.from(new Set(Array.isArray(urls) ? urls.map((entry) => String(entry || "").trim()).filter(Boolean) : [])); + if (queue.length === 0) { + return []; + } + + const limit = Math.max(1, Number.isInteger(concurrency) ? concurrency : 6); + const results = []; + let cursor = 0; + + async function consumeQueue() { + while (cursor < queue.length) { + const currentIndex = cursor; + cursor += 1; + results[currentIndex] = await preloadImageUrl(queue[currentIndex]); + } + } + + await Promise.all(Array.from({ length: Math.min(limit, queue.length) }, () => consumeQueue())); + return results; + } + + function buildDeckImagePreloadUrls(deckId, options = {}) { + const normalizedDeckId = normalizeDeckId(deckId); + const includeFull = options.includeFull !== false; + const includeThumbnails = options.includeThumbnails === true; + const includeCardBack = options.includeCardBack !== false; + const urls = new Set(); + + if (includeFull) { + standardDeckCardNames.forEach((cardName) => { + const imagePath = resolveWithDeck(normalizedDeckId, cardName, "full"); + if (imagePath) { + urls.add(imagePath); + } + }); + } + + if (includeThumbnails) { + standardDeckCardNames.forEach((cardName) => { + const thumbnailPath = resolveWithDeck(normalizedDeckId, cardName, "thumbnail"); + if (thumbnailPath) { + urls.add(thumbnailPath); + } + }); + } + + if (includeCardBack) { + const cardBackImage = resolveTarotCardBackImage(normalizedDeckId); + if (cardBackImage) { + urls.add(cardBackImage); + } + + if (includeThumbnails) { + const cardBackThumbnail = resolveTarotCardBackThumbnail(normalizedDeckId); + if (cardBackThumbnail) { + urls.add(cardBackThumbnail); + } + } + } + + return Array.from(urls); + } + + function preloadDeckImages(deckId, options = {}) { + const normalizedDeckId = normalizeDeckId(deckId); + const cacheKey = JSON.stringify({ + deckId: normalizedDeckId, + includeFull: options.includeFull !== false, + includeThumbnails: options.includeThumbnails === true, + includeCardBack: options.includeCardBack !== false + }); + + if (!options.force && deckImagePreloadCache.has(cacheKey)) { + return deckImagePreloadCache.get(cacheKey); + } + + if (!options.background) { + setDeckPreloadStatus({ + activeDeckId: normalizedDeckId, + selectedDeckPhase: "loading" + }); + } + + const preloadPromise = preloadImageUrls(buildDeckImagePreloadUrls(normalizedDeckId, options), options.concurrency) + .then((result) => { + markDeckAsWarmed(normalizedDeckId); + if (!options.background) { + setDeckPreloadStatus({ + activeDeckId: normalizedDeckId, + selectedDeckPhase: "ready" + }); + } else { + emitDeckPreloadStatus(); + } + return result; + }) + .catch((error) => { + if (!options.background) { + setDeckPreloadStatus({ + activeDeckId: normalizedDeckId, + selectedDeckPhase: "error" + }); + } + throw error; + }); + deckImagePreloadCache.set(cacheKey, preloadPromise); + return preloadPromise; + } + + async function preloadAllDeckImages(options = {}) { + const deckIds = Object.keys(getDeckManifestSources()); + const startDeckId = options.startDeckId ? normalizeDeckId(options.startDeckId) : ""; + const orderedDeckIds = startDeckId && deckIds.includes(startDeckId) + ? [startDeckId, ...deckIds.filter((deckId) => deckId !== startDeckId)] + : deckIds; + const results = []; + + for (const deckId of orderedDeckIds) { + results.push(await preloadDeckImages(deckId, options)); + } + + return results; + } + + function scheduleDeckImagePreload(deckId, options = {}) { + return deferPreload(() => preloadDeckImages(deckId, options)); + } + + function scheduleBackgroundDeckWarmup(deckId, options = {}) { + if (backgroundDeckWarmupPromise) { + return backgroundDeckWarmupPromise; + } + + const normalizedDeckId = normalizeDeckId(deckId); + const warmupOptions = { + ...options, + startDeckId: normalizedDeckId, + background: true, + concurrency: Number.isInteger(options.concurrency) ? options.concurrency : 2 + }; + + setDeckPreloadStatus({ + activeDeckId: normalizedDeckId, + backgroundPhase: "loading" + }); + + backgroundDeckWarmupPromise = scheduleDeckImagePreload(normalizedDeckId, warmupOptions) + .then(() => deferPreload(() => preloadAllDeckImages(warmupOptions))) + .then((result) => { + setDeckPreloadStatus({ + activeDeckId: normalizedDeckId, + backgroundPhase: "ready" + }); + return result; + }) + .catch(() => { + setDeckPreloadStatus({ + activeDeckId: normalizedDeckId, + backgroundPhase: "error" + }); + return []; + }); + + return backgroundDeckWarmupPromise; + } + function resolveDisplayNameWithDeck(deckId, cardName, trumpNumber) { const manifest = getDeckManifest(deckId); const fallbackName = String(cardName || "").trim(); @@ -908,6 +1235,12 @@ function setActiveDeck(deckId) { activeDeckId = normalizeDeckId(deckId); getDeckManifest(activeDeckId); + setDeckPreloadStatus({ + activeDeckId, + selectedDeckPhase: "idle" + }); + scheduleDeckImagePreload(activeDeckId); + scheduleBackgroundDeckWarmup(activeDeckId); return activeDeckId; } @@ -933,6 +1266,9 @@ resolveTarotCardThumbnail, resolveTarotCardBackImage, resolveTarotCardBackThumbnail, + preloadDeckImages, + preloadAllDeckImages, + getDeckPreloadStatus: () => emitDeckPreloadStatus(), getTarotCardDisplayName, getTarotCardSearchAliases, setActiveDeck, diff --git a/app/styles.css b/app/styles.css index 7334865..cb354d6 100644 --- a/app/styles.css +++ b/app/styles.css @@ -7049,6 +7049,9 @@ font-size: 12px; line-height: 1.4; } + .settings-cache-status { + min-height: 1.4em; + } .settings-actions { margin-top: 12px; display: flex; diff --git a/app/ui-settings.js b/app/ui-settings.js index c9c855e..0ff82b3 100644 --- a/app/ui-settings.js +++ b/app/ui-settings.js @@ -33,6 +33,7 @@ timeFormatEl: document.getElementById("time-format"), birthDateEl: document.getElementById("birth-date"), tarotDeckEl: document.getElementById("tarot-deck"), + tarotDeckCacheStatusEl: document.getElementById("tarot-deck-cache-status"), stellariumBackgroundEl: document.getElementById("stellarium-background"), stellariumBackgroundHintEl: document.getElementById("stellarium-background-hint"), apiBaseUrlEl: document.getElementById("api-base-url"), @@ -122,6 +123,50 @@ } } + function formatDeckCacheStatus(status) { + const activeDeckId = String(status?.activeDeckId || normalizeTarotDeck(getElements().tarotDeckEl?.value)).trim().toLowerCase(); + const totalDeckCount = Number(status?.totalDeckCount) || 0; + const warmedDeckCount = Number(status?.warmedDeckCount) || 0; + const warmedAllDecks = totalDeckCount > 0 && warmedDeckCount >= totalDeckCount; + + if (status?.selectedDeckPhase === "loading") { + return "Caching selected deck images to this browser..."; + } + + if (status?.selectedDeckPhase === "error") { + return "Selected deck cache warmup hit an error. Images will still load on demand."; + } + + if (status?.backgroundPhase === "loading") { + if (warmedDeckCount > 0 && totalDeckCount > 0) { + return `Selected deck cached. Warming remaining decks in background (${warmedDeckCount}/${totalDeckCount}).`; + } + return "Selected deck cached. Warming remaining decks in background..."; + } + + if (status?.backgroundPhase === "error") { + return "Selected deck cached. Background warmup for other decks stopped early."; + } + + if (status?.selectedDeckPhase === "ready" || warmedAllDecks) { + if (warmedAllDecks) { + return "Selected deck cached. All available decks are warmed in this browser."; + } + return `Selected deck cached and ready for fullscreen use (${activeDeckId}).`; + } + + return "Deck cache idle."; + } + + function syncDeckCacheStatus(status) { + const { tarotDeckCacheStatusEl } = getElements(); + if (!tarotDeckCacheStatusEl) { + return; + } + + tarotDeckCacheStatusEl.textContent = formatDeckCacheStatus(status); + } + function syncSky(geo, options) { if (typeof config.onSyncSkyBackground === "function") { config.onSyncSkyBackground(geo, options); @@ -373,6 +418,7 @@ if (window.TarotCardImages?.setActiveDeck) { window.TarotCardImages.setActiveDeck(normalized.tarotDeck); } + syncDeckCacheStatus(window.TarotCardImages?.getDeckPreloadStatus?.()); applyExternalSettings(normalized); return normalized; } @@ -577,6 +623,11 @@ document.addEventListener("connection:updated", () => { syncConnectionInputs(); syncTarotDeckInputOptions(); + syncDeckCacheStatus(window.TarotCardImages?.getDeckPreloadStatus?.()); + }); + + document.addEventListener("tarot:deck-cache-status", (event) => { + syncDeckCacheStatus(event?.detail); }); } diff --git a/index.html b/index.html index 0149be5..5aae648 100644 --- a/index.html +++ b/index.html @@ -125,6 +125,7 @@ + Deck cache idle.