diff --git a/app/card-images.js b/app/card-images.js index b27d3bc..4438d98 100644 --- a/app/card-images.js +++ b/app/card-images.js @@ -145,10 +145,11 @@ const deckPreloadStatus = { activeDeckId: DEFAULT_DECK_ID, selectedDeckPhase: "idle", - backgroundPhase: "idle", + selectedDeckLoadedCount: 0, + selectedDeckTotalCount: 0, + selectedDeckPercent: 0, warmedDeckIds: [] }; - let backgroundDeckWarmupPromise = null; let activeDeckId = DEFAULT_DECK_ID; function getApiBaseUrl() { @@ -288,7 +289,9 @@ const snapshot = { activeDeckId: deckPreloadStatus.activeDeckId, selectedDeckPhase: deckPreloadStatus.selectedDeckPhase, - backgroundPhase: deckPreloadStatus.backgroundPhase, + selectedDeckLoadedCount: deckPreloadStatus.selectedDeckLoadedCount, + selectedDeckTotalCount: deckPreloadStatus.selectedDeckTotalCount, + selectedDeckPercent: deckPreloadStatus.selectedDeckPercent, warmedDeckIds: [...deckPreloadStatus.warmedDeckIds], warmedDeckCount: deckPreloadStatus.warmedDeckIds.length, totalDeckCount: Object.keys(getDeckManifestSources()).length @@ -314,8 +317,25 @@ deckPreloadStatus.selectedDeckPhase = String(partialStatus.selectedDeckPhase || "idle"); } - if (Object.prototype.hasOwnProperty.call(partialStatus, "backgroundPhase")) { - deckPreloadStatus.backgroundPhase = String(partialStatus.backgroundPhase || "idle"); + if (Object.prototype.hasOwnProperty.call(partialStatus, "selectedDeckLoadedCount")) { + const nextLoadedCount = Number(partialStatus.selectedDeckLoadedCount); + deckPreloadStatus.selectedDeckLoadedCount = Number.isFinite(nextLoadedCount) && nextLoadedCount >= 0 + ? nextLoadedCount + : 0; + } + + if (Object.prototype.hasOwnProperty.call(partialStatus, "selectedDeckTotalCount")) { + const nextTotalCount = Number(partialStatus.selectedDeckTotalCount); + deckPreloadStatus.selectedDeckTotalCount = Number.isFinite(nextTotalCount) && nextTotalCount >= 0 + ? nextTotalCount + : 0; + } + + if (Object.prototype.hasOwnProperty.call(partialStatus, "selectedDeckPercent")) { + const nextPercent = Number(partialStatus.selectedDeckPercent); + deckPreloadStatus.selectedDeckPercent = Number.isFinite(nextPercent) + ? Math.max(0, Math.min(100, nextPercent)) + : 0; } if (Array.isArray(partialStatus.warmedDeckIds)) { @@ -577,11 +597,12 @@ imagePreloadCache.clear(); loadedImageCache.clear(); deckImagePreloadCache.clear(); - backgroundDeckWarmupPromise = null; setDeckPreloadStatus({ activeDeckId, selectedDeckPhase: "idle", - backgroundPhase: "idle", + selectedDeckLoadedCount: 0, + selectedDeckTotalCount: 0, + selectedDeckPercent: 0, warmedDeckIds: [] }); setActiveDeck(activeDeckId); @@ -1012,7 +1033,7 @@ return Boolean(cachedImage?.complete && cachedImage?.naturalWidth); } - async function preloadImageUrls(urls, concurrency = 6) { + async function preloadImageUrls(urls, concurrency = 6, onProgress = null) { const queue = Array.from(new Set(Array.isArray(urls) ? urls.map((entry) => String(entry || "").trim()).filter(Boolean) : [])); if (queue.length === 0) { return []; @@ -1021,12 +1042,29 @@ const limit = Math.max(1, Number.isInteger(concurrency) ? concurrency : 6); const results = []; let cursor = 0; + let completedCount = 0; + + function reportProgress(lastUrl) { + completedCount += 1; + if (typeof onProgress === "function") { + onProgress({ + completedCount, + totalCount: queue.length, + lastUrl: String(lastUrl || "").trim() + }); + } + } async function consumeQueue() { while (cursor < queue.length) { const currentIndex = cursor; cursor += 1; - results[currentIndex] = await preloadImageUrl(queue[currentIndex]); + const currentUrl = queue[currentIndex]; + try { + results[currentIndex] = await preloadImageUrl(currentUrl); + } finally { + reportProgress(currentUrl); + } } } @@ -1078,6 +1116,8 @@ function preloadDeckImages(deckId, options = {}) { const normalizedDeckId = normalizeDeckId(deckId); + const preloadUrls = buildDeckImagePreloadUrls(normalizedDeckId, options); + const totalCount = preloadUrls.length; const cacheKey = JSON.stringify({ deckId: normalizedDeckId, includeFull: options.includeFull !== false, @@ -1086,26 +1126,49 @@ }); if (!options.force && deckImagePreloadCache.has(cacheKey)) { + if (deckPreloadStatus.warmedDeckIds.includes(normalizedDeckId)) { + setDeckPreloadStatus({ + activeDeckId: normalizedDeckId, + selectedDeckPhase: "ready", + selectedDeckLoadedCount: totalCount, + selectedDeckTotalCount: totalCount, + selectedDeckPercent: totalCount > 0 ? 100 : 0 + }); + } return deckImagePreloadCache.get(cacheKey); } if (!options.background) { setDeckPreloadStatus({ activeDeckId: normalizedDeckId, - selectedDeckPhase: "loading" + selectedDeckPhase: "loading", + selectedDeckLoadedCount: 0, + selectedDeckTotalCount: totalCount, + selectedDeckPercent: 0 }); } - const preloadPromise = preloadImageUrls(buildDeckImagePreloadUrls(normalizedDeckId, options), options.concurrency) + const preloadPromise = preloadImageUrls(preloadUrls, options.concurrency, ({ completedCount, totalCount: progressTotalCount }) => { + if (!options.background) { + setDeckPreloadStatus({ + activeDeckId: normalizedDeckId, + selectedDeckPhase: "loading", + selectedDeckLoadedCount: completedCount, + selectedDeckTotalCount: progressTotalCount, + selectedDeckPercent: progressTotalCount > 0 ? Math.round((completedCount / progressTotalCount) * 100) : 0 + }); + } + }) .then((result) => { markDeckAsWarmed(normalizedDeckId); if (!options.background) { setDeckPreloadStatus({ activeDeckId: normalizedDeckId, - selectedDeckPhase: "ready" + selectedDeckPhase: "ready", + selectedDeckLoadedCount: totalCount, + selectedDeckTotalCount: totalCount, + selectedDeckPercent: totalCount > 0 ? 100 : 0 }); - } else { - emitDeckPreloadStatus(); } return result; }) @@ -1113,7 +1176,8 @@ if (!options.background) { setDeckPreloadStatus({ activeDeckId: normalizedDeckId, - selectedDeckPhase: "error" + selectedDeckPhase: "error", + selectedDeckTotalCount: totalCount }); } throw error; @@ -1141,44 +1205,6 @@ 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(); @@ -1263,12 +1289,17 @@ function setActiveDeck(deckId) { activeDeckId = normalizeDeckId(deckId); getDeckManifest(activeDeckId); + const preloadUrls = buildDeckImagePreloadUrls(activeDeckId); + const totalCount = preloadUrls.length; + const isWarmed = deckPreloadStatus.warmedDeckIds.includes(activeDeckId); setDeckPreloadStatus({ activeDeckId, - selectedDeckPhase: "idle" + selectedDeckPhase: isWarmed ? "ready" : "idle", + selectedDeckLoadedCount: isWarmed ? totalCount : 0, + selectedDeckTotalCount: totalCount, + selectedDeckPercent: isWarmed && totalCount > 0 ? 100 : 0 }); scheduleDeckImagePreload(activeDeckId); - scheduleBackgroundDeckWarmup(activeDeckId); return activeDeckId; } diff --git a/app/styles.css b/app/styles.css index b8b9058..e30559e 100644 --- a/app/styles.css +++ b/app/styles.css @@ -567,6 +567,41 @@ font-size: 12px; line-height: 1.4; } + .settings-cache-progress-wrap { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 10px; + margin-top: 2px; + } + .settings-cache-progress { + width: 100%; + height: 10px; + appearance: none; + border: none; + border-radius: 999px; + overflow: hidden; + background: rgba(39, 39, 42, 0.92); + } + .settings-cache-progress::-webkit-progress-bar { + background: rgba(39, 39, 42, 0.92); + border-radius: 999px; + } + .settings-cache-progress::-webkit-progress-value { + background: linear-gradient(90deg, #f59e0b, #facc15); + border-radius: 999px; + } + .settings-cache-progress::-moz-progress-bar { + background: linear-gradient(90deg, #f59e0b, #facc15); + border-radius: 999px; + } + .settings-cache-progress-label { + min-width: 3ch; + text-align: right; + color: #e2e8f0; + font-size: 12px; + font-variant-numeric: tabular-nums; + } .calendar-year-control { display: flex; justify-content: space-between; diff --git a/app/ui-settings.js b/app/ui-settings.js index b5692e9..baa28c9 100644 --- a/app/ui-settings.js +++ b/app/ui-settings.js @@ -35,6 +35,8 @@ timeFormatEl: document.getElementById("time-format"), birthDateEl: document.getElementById("birth-date"), tarotDeckEl: document.getElementById("tarot-deck"), + tarotDeckCacheProgressEl: document.getElementById("tarot-deck-cache-progress"), + tarotDeckCacheProgressLabelEl: document.getElementById("tarot-deck-cache-progress-label"), tarotDeckCacheStatusEl: document.getElementById("tarot-deck-cache-status"), stellariumBackgroundEl: document.getElementById("stellarium-background"), stellariumBackgroundHintEl: document.getElementById("stellarium-background-hint"), @@ -205,11 +207,13 @@ 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; + const loadedCount = Math.max(0, Number(status?.selectedDeckLoadedCount) || 0); + const totalCount = Math.max(0, Number(status?.selectedDeckTotalCount) || 0); if (status?.selectedDeckPhase === "loading") { + if (totalCount > 0) { + return `Caching selected deck images to this browser... (${loadedCount}/${totalCount})`; + } return "Caching selected deck images to this browser..."; } @@ -217,21 +221,7 @@ 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."; - } + if (status?.selectedDeckPhase === "ready") { return `Selected deck cached and ready for fullscreen use (${activeDeckId}).`; } @@ -239,12 +229,30 @@ } function syncDeckCacheStatus(status) { - const { tarotDeckCacheStatusEl } = getElements(); + const { + tarotDeckCacheStatusEl, + tarotDeckCacheProgressEl, + tarotDeckCacheProgressLabelEl + } = getElements(); if (!tarotDeckCacheStatusEl) { return; } tarotDeckCacheStatusEl.textContent = formatDeckCacheStatus(status); + + const totalCount = Math.max(0, Number(status?.selectedDeckTotalCount) || 0); + const loadedCount = Math.max(0, Number(status?.selectedDeckLoadedCount) || 0); + const derivedPercent = totalCount > 0 ? Math.round((loadedCount / totalCount) * 100) : 0; + const percent = Math.max(0, Math.min(100, Number(status?.selectedDeckPercent) || derivedPercent)); + + if (tarotDeckCacheProgressEl) { + tarotDeckCacheProgressEl.max = 100; + tarotDeckCacheProgressEl.value = percent; + } + + if (tarotDeckCacheProgressLabelEl) { + tarotDeckCacheProgressLabelEl.textContent = `${percent}%`; + } } function syncSky(geo, options) { diff --git a/index.html b/index.html index 02c9628..a5485f3 100644 --- a/index.html +++ b/index.html @@ -146,6 +146,10 @@ + Deck cache idle.