// Vendor // @ts-ignore missing type definition import FontFaceObserver from 'fontfaceobserver-es'; // Constants import { ASSET_LOADED, ASSETS_LOADED } from '../constants'; // Events import { eventEmitter } from '../events/EventEmitter'; // Features import getBrowserType from '../features/browserFeatures/getBrowserType'; import getWebGLFeatures from '../features/browserFeatures/getWebGLFeatures'; import isImageBitmapSupported from '../features/browserFeatures/isImageBitmapSupported'; import isImageDecodeSupported from '../features/browserFeatures/isImageDecodeSupported'; import isWebAssemblySupported from '../features/browserFeatures/isWebAssemblySupported'; // Utilities import { assert, convertBlobToArrayBuffer } from '../utilities'; // Types import { TNullable, TUndefinable, TVoidable } from '../types'; import { ELoaderKey, IAssetLoaderOptions, IByDeviceTypeOptions, IBySupportedCompressedTextureOptions, ILoadItem, } from './types'; /** * Loader types and the extensions they handle * Allows the omission of the loader key for some generic extensions used on the web */ const LOADER_EXTENSIONS_MAP = new Map([ [ELoaderKey.ArrayBuffer, { extensions: ['bin'] }], [ELoaderKey.Audio, { extensions: ['mp3', 'm4a', 'ogg', 'wav', 'flac'] }], [ELoaderKey.Audiopack, { extensions: ['audiopack'] }], [ELoaderKey.Binpack, { extensions: ['binpack'] }], [ELoaderKey.Font, { extensions: ['woff2', 'woff', 'ttf', 'otf', 'eot'] }], [ELoaderKey.Image, { extensions: ['jpeg', 'jpg', 'gif', 'png', 'webp'] }], [ELoaderKey.ImageBitmap, { extensions: ['jpeg', 'jpg', 'gif', 'png', 'webp'] }], [ELoaderKey.ImageCompressed, { extensions: ['ktx'] }], [ELoaderKey.JSON, { extensions: ['json'] }], [ELoaderKey.Text, { extensions: ['txt', 'm3u8'] }], [ELoaderKey.Video, { extensions: ['webm', 'ogg', 'mp4'] }], [ELoaderKey.WebAssembly, { extensions: ['wasm', 'wat'] }], [ ELoaderKey.XML, { defaultMimeType: 'text/xml', extensions: ['xml', 'svg', 'html'], mimeType: { html: 'text/html', svg: 'image/svg+xml', xml: 'text/xml', }, }, ], ]); // Safari does not fire `canplaythrough` preventing it from resolving naturally // A workaround is to not wait for the `canplaythrough` event but rather resolve early and hope for the best const IS_MEDIA_PRELOAD_SUPPORTED = !getBrowserType.isSafari; /** * Asynchronous asset preloader */ export class AssetLoader { public assets: Map> = new Map(); private options: IAssetLoaderOptions; private domParser = new DOMParser(); constructor(options: IAssetLoaderOptions) { this.options = options; } /** * Load conditionally based on device type */ public byDeviceType = (data: IByDeviceTypeOptions): TUndefinable => data.DESKTOP && getBrowserType.isDesktop ? data.DESKTOP : data.TABLET && getBrowserType.isTablet ? data.TABLET : data.MOBILE; /** * Load conditionally based on supported compressed texture */ public bySupportedCompressedTexture = ( data: IBySupportedCompressedTextureOptions ): TUndefinable => { if (getWebGLFeatures) { return data.ASTC && getWebGLFeatures.extensions.compressedTextureASTCExtension ? data.ASTC : data.ETC && getWebGLFeatures.extensions.compressedTextureETCExtension ? data.ETC : data.PVRTC && getWebGLFeatures.extensions.compressedTexturePVRTCExtension ? data.PVRTC : data.S3TC && getWebGLFeatures.extensions.compressedTextureS3TCExtension ? data.S3TC : data.FALLBACK; } else { return data.FALLBACK; } }; /** * Load the specified manifest (array of items) * * @param items Items to load */ public loadAssets = (items: ILoadItem[]): Promise => { const loadingAssets = items .filter(item => item) .map(item => { const startTime = window.performance.now(); return new Promise((resolve): void => { const cacheHit = this.assets.get(item.src); if (cacheHit) { resolve({ fromCache: true, id: item.id || item.src, item: cacheHit, timeToLoad: window.performance.now() - startTime, }); } const loaderType = item.loader || this.getLoaderByFileExtension(item.src); let loadedItem; switch (loaderType) { case ELoaderKey.ArrayBuffer: loadedItem = this.loadArrayBuffer(item); break; case ELoaderKey.Audio: loadedItem = this.loadAudio(item); break; case ELoaderKey.Audiopack: loadedItem = this.loadAudiopack(item); break; case ELoaderKey.Binpack: loadedItem = this.loadBinpack(item); break; case ELoaderKey.Blob: loadedItem = this.loadBlob(item); break; case ELoaderKey.Font: loadedItem = this.loadFont(item); break; case ELoaderKey.Image: loadedItem = this.loadImage(item); break; case ELoaderKey.ImageBitmap: loadedItem = this.loadImageBitmap(item); break; case ELoaderKey.ImageCompressed: loadedItem = this.loadImageCompressed(item); break; case ELoaderKey.JSON: loadedItem = this.loadJSON(item); break; case ELoaderKey.Text: loadedItem = this.loadText(item); break; case ELoaderKey.Video: loadedItem = this.loadVideo(item); break; case ELoaderKey.WebAssembly: loadedItem = this.loadWebAssembly(item); break; case ELoaderKey.XML: loadedItem = this.loadXML(item); break; default: console.warn('AssetLoader -> Missing loader, falling back to loading as ArrayBuffer'); loadedItem = this.loadArrayBuffer(item); break; } loadedItem.then((asset: any) => { this.assets.set(item.src, asset); resolve({ fromCache: false, id: item.id || item.src, item: asset, loaderType, persistent: item.persistent, timeToLoad: window.performance.now() - startTime, }); }); }); }); const loadedAssets = Promise.all(loadingAssets); let progress = 0; loadingAssets.forEach((promise: Promise) => promise.then(asset => { progress++; if (asset.persistent && (!this.options || !this.options.persistentCache)) { console.warn( 'AssetLoader -> Persistent caching requires an instance of a PersistentCache to be passed to the AssetLoader constructor' ); } if (this.options && this.options.persistentCache && asset.persistent) { switch (asset.loaderType) { case ELoaderKey.ArrayBuffer: this.options.persistentCache.set(asset.id, asset.item); break; case ELoaderKey.Blob: // Safari iOS does not permit storing file blobs and must be converted to ArrayBuffers // SEE: https://developers.google.com/web/fundamentals/instant-and-offline/web-storage/indexeddb-best-practices convertBlobToArrayBuffer(asset.item).then(buffer => { if (this.options && this.options.persistentCache) { this.options.persistentCache.set(asset.id, buffer); } }); break; default: console.warn( 'AssetLoader -> Persistent caching is currently only possible with ArrayBuffer and Blob loaders' ); } } eventEmitter.emit(ASSET_LOADED, { id: asset.id, progress: `${(progress / loadingAssets.length).toFixed(2)}`, timeToLoad: `${asset.timeToLoad.toFixed(2)}ms`, }); }) ); return loadedAssets.then(assets => { const assetMap = new Map(); assets.forEach((asset: any) => { if (assetMap.get(asset.id)) { console.warn("AssetLoader -> Detected duplicate id, please use unique id's"); } assetMap.set(asset.id, asset.item); }); eventEmitter.emit(ASSETS_LOADED, { assetMap, }); return assetMap; }); }; /** * Get a file extension from a full asset path * * @param path Path to asset */ private getFileExtension = (path: string): string => { const basename = path.split(/[\\/]/).pop(); if (!basename) { return ''; } const seperator = basename.lastIndexOf('.'); if (seperator < 1) { return ''; } return basename.slice(seperator + 1); }; /** * Retrieve mime type from extension * * @param loaderKey Loader key * @param extension extension */ private getMimeType = (loaderKey: ELoaderKey, extension: string): string => { const loader: any = LOADER_EXTENSIONS_MAP.get(loaderKey); return loader.mimeType[extension] || loader.defaultMimeType; }; /** * Retrieve loader key from extension (when the loader option isn't specified) * * @param path File path */ private getLoaderByFileExtension = (path: string): string => { const fileExtension = this.getFileExtension(path); const loader = Array.from(LOADER_EXTENSIONS_MAP).find(type => type[1].extensions.includes(fileExtension) ); return loader ? loader[0] : ELoaderKey.ArrayBuffer; }; /** * Fetch wrapper for loading an item, to be processed by a specific loader afterwards * * @param item Item to fetch */ private fetchItem = (item: ILoadItem): Promise => fetch(item.src, item.options || {}); /** * Load an item and parse the Response as arrayBuffer * * @param item Item to load */ private loadArrayBuffer = (item: ILoadItem): Promise => this.fetchItem(item) .then(response => response.arrayBuffer()) .catch(err => { console.warn(err.message); }); /** * Load an item and parse the Response as