aboutsummaryrefslogtreecommitdiff
path: root/webAO/utils
diff options
context:
space:
mode:
authorstonedDiscord <Tukz@gmx.de>2026-04-06 20:38:41 +0200
committerGitHub <noreply@github.com>2026-04-06 20:38:41 +0200
commit4be4f4665fe03a0267ac88c36f0e3b73d8fc2d48 (patch)
treeb76ef7b627523a8daebe0beb59e404d3da82d04e /webAO/utils
parent815f56add06b92a48b964cb1343f70c86ea36435 (diff)
parent20810aa0d3dfac49e1f43fe84634f74f56374fcd (diff)
Merge pull request #301 from AttorneyOnline/rendering-fix
Fix IC rendering race conditions with asset preloading
Diffstat (limited to 'webAO/utils')
-rw-r--r--webAO/utils/assetCache.ts129
-rw-r--r--webAO/utils/fileExists.ts18
2 files changed, 140 insertions, 7 deletions
diff --git a/webAO/utils/assetCache.ts b/webAO/utils/assetCache.ts
new file mode 100644
index 0000000..7504207
--- /dev/null
+++ b/webAO/utils/assetCache.ts
@@ -0,0 +1,129 @@
+/**
+ * Unified asset resolution and preloading cache.
+ *
+ * Every resolve/preload function caches its promise by URL, so:
+ * - Concurrent calls for the same URL share one in-flight request
+ * - Subsequent calls return instantly from cache
+ * - Results are cached for the entire session (CDN assets don't change mid-session)
+ *
+ * fileExists.ts handles HEAD-request caching independently (same pattern).
+ * This module handles everything above that: URL resolution, image/audio
+ * preloading, and animation duration calculation.
+ */
+
+import findImgSrc from "./findImgSrc";
+import fileExists from "./fileExists";
+import getAnimLength from "./getAnimLength";
+import transparentPng from "../constants/transparentPng";
+
+const PRELOAD_TIMEOUT_MS = 5000;
+const AUDIO_PRELOAD_TIMEOUT_MS = 3000;
+
+/** All cached promises, keyed by a type-prefixed key */
+const cache = new Map<string, Promise<any>>();
+
+// ── Core cache helper ───────────────────────────────────────────────
+
+function cached<T>(key: string, factory: () => Promise<T>): Promise<T> {
+ const existing = cache.get(key);
+ if (existing !== undefined) return existing;
+ const promise = factory();
+ cache.set(key, promise);
+ return promise;
+}
+
+// ── Public API ──────────────────────────────────────────────────────
+
+/**
+ * Resolves which URL from a candidate list exists, then preloads the
+ * image into the browser cache. Returns the resolved URL or transparentPng.
+ *
+ * Cached by the candidate list (stringified), so the same set of
+ * candidates always returns the same resolved+preloaded URL.
+ */
+export function resolveAndPreloadImage(urls: string[]): Promise<string> {
+ const key = `img:${urls.join("|")}`;
+ return cached(key, async () => {
+ const url = await findImgSrc(urls);
+ if (url && url !== transparentPng) {
+ await doPreloadImage(url);
+ }
+ return url;
+ });
+}
+
+/**
+ * Checks if an audio URL exists, then preloads it into the browser cache.
+ * Returns the URL if it exists, or null.
+ */
+export function resolveAndPreloadAudio(url: string): Promise<string | null> {
+ const key = `audio:${url}`;
+ return cached(key, async () => {
+ const exists = await fileExists(url);
+ if (!exists) return null;
+ await doPreloadAudio(url);
+ return url;
+ });
+}
+
+/**
+ * Preloads an audio URL that is already known to exist (no HEAD check).
+ * Useful when the URL comes from a trusted source like client.resources.
+ */
+export function preloadKnownAudio(url: string): Promise<string> {
+ const key = `audio:${url}`;
+ return cached(key, async () => {
+ await doPreloadAudio(url);
+ return url;
+ });
+}
+
+/**
+ * Gets the animation duration for a base URL (tries .gif, .webp, .apng).
+ * Downloads the file to count frames, which also primes the browser cache.
+ */
+export function getAnimDuration(baseUrl: string): Promise<number> {
+ const key = `animdur:${baseUrl}`;
+ return cached(key, () => getAnimLength(baseUrl));
+}
+
+// ── Internal preloaders ─────────────────────────────────────────────
+
+function doPreloadImage(url: string): Promise<void> {
+ return new Promise<void>((resolve) => {
+ const img = new Image();
+ let settled = false;
+
+ const timer = setTimeout(() => {
+ if (!settled) { settled = true; resolve(); }
+ }, PRELOAD_TIMEOUT_MS);
+
+ img.onload = () => {
+ if (!settled) { settled = true; clearTimeout(timer); resolve(); }
+ };
+ img.onerror = () => {
+ if (!settled) { settled = true; clearTimeout(timer); resolve(); }
+ };
+ img.src = url;
+ });
+}
+
+function doPreloadAudio(url: string): Promise<void> {
+ return new Promise<void>((resolve) => {
+ const audio = new Audio();
+ let settled = false;
+
+ const timer = setTimeout(() => {
+ if (!settled) { settled = true; resolve(); }
+ }, AUDIO_PRELOAD_TIMEOUT_MS);
+
+ audio.oncanplaythrough = () => {
+ if (!settled) { settled = true; clearTimeout(timer); resolve(); }
+ };
+ audio.onerror = () => {
+ if (!settled) { settled = true; clearTimeout(timer); resolve(); }
+ };
+ audio.preload = "auto";
+ audio.src = url;
+ });
+}
diff --git a/webAO/utils/fileExists.ts b/webAO/utils/fileExists.ts
index 1dceb72..748bc1f 100644
--- a/webAO/utils/fileExists.ts
+++ b/webAO/utils/fileExists.ts
@@ -1,14 +1,15 @@
-export default async function fileExists(url: string): Promise<boolean> {
- return new Promise((resolve) => {
+const cache = new Map<string, Promise<boolean>>();
+
+export default function fileExists(url: string): Promise<boolean> {
+ const cached = cache.get(url);
+ if (cached !== undefined) return cached;
+
+ const promise = new Promise<boolean>((resolve) => {
const xhr = new XMLHttpRequest();
xhr.open("HEAD", url);
xhr.onload = function checkLoad() {
if (xhr.readyState === 4) {
- if (xhr.status === 200) {
- resolve(true);
- } else {
- resolve(false);
- }
+ resolve(xhr.status === 200);
}
};
xhr.onerror = function checkError() {
@@ -16,4 +17,7 @@ export default async function fileExists(url: string): Promise<boolean> {
};
xhr.send(null);
});
+
+ cache.set(url, promise);
+ return promise;
}