UNPKG

pxloader-browserify

Version:
767 lines (638 loc) 22.8 kB
/*! PxLoader v1.0.1 | https://github.com/vdaguenet/pxloader-browserify */ /*global define: true */ // Tag object to handle tag intersection; once created not meant to be changed // Performance rationale: http://jsperf.com/lists-indexof-vs-in-operator/3 function PxLoaderTags(values) { this.all = []; this.first = null; // cache the first value this.length = 0; // holds values as keys for quick lookup this.lookup = {}; if (values) { // first fill the array of all values if (Array.isArray(values)) { // copy the array of values, just to be safe this.all = values.slice(0); } else if (typeof values === 'object') { for (var key in values) { if(values.hasOwnProperty(key)) { this.all.push(key); } } } else { this.all.push(values); } // cache the length and the first value this.length = this.all.length; if (this.length > 0) { this.first = this.all[0]; } // set values as object keys for quick lookup during intersection test for (var i = 0; i < this.length; i++) { this.lookup[this.all[i]] = true; } } } // compare this object with another; return true if they share at least one value PxLoaderTags.prototype.intersects = function(other) { // handle empty values case if (this.length === 0 || other.length === 0) { return false; } // only a single value to compare? if (this.length === 1 && other.length === 1) { return this.first === other.first; } // better to loop through the smaller object if (other.length < this.length) { return other.intersects(this); } // loop through every key to see if there are any matches for (var key in this.lookup) { if (other.lookup[key]) { return true; } } return false; }; /* * PixelLab Resource Loader * Loads resources while providing progress updates. */ function PxLoader(settings) { // merge settings with defaults settings = settings || {}; this.settings = settings; // how frequently we poll resources for progress if (settings.statusInterval == null) { settings.statusInterval = 5000; // every 5 seconds by default } // delay before logging since last progress change if (settings.loggingDelay == null) { settings.loggingDelay = 20 * 1000; // log stragglers after 20 secs } // stop waiting if no progress has been made in the moving time window if (settings.noProgressTimeout == null) { settings.noProgressTimeout = Infinity; // do not stop waiting by default } var entries = [], // holds resources to be loaded with their status progressListeners = [], timeStarted, progressChanged = Date.now(); /** * The status of a resource * @enum {number} */ var ResourceState = { QUEUED: 0, WAITING: 1, LOADED: 2, ERROR: 3, TIMEOUT: 4 }; // places non-array values into an array. var ensureArray = function(val) { if (val == null) { return []; } if (Array.isArray(val)) { return val; } return [val]; }; // add an entry to the list of resources to be loaded this.add = function(resource) { // TODO: would be better to create a base class for all resources and // initialize the PxLoaderTags there rather than overwritting tags here resource.tags = new PxLoaderTags(resource.tags); // ensure priority is set if (resource.priority == null) { resource.priority = Infinity; } entries.push({ resource: resource, status: ResourceState.QUEUED }); }; this.addProgressListener = function(callback, tags) { progressListeners.push({ callback: callback, tags: new PxLoaderTags(tags) }); }; this.addCompletionListener = function(callback, tags) { progressListeners.push({ tags: new PxLoaderTags(tags), callback: function(e) { if (e.completedCount === e.totalCount) { callback(e); } } }); }; // creates a comparison function for resources var getResourceSort = function(orderedTags) { // helper to get the top tag's order for a resource orderedTags = ensureArray(orderedTags); var getTagOrder = function(entry) { var resource = entry.resource, bestIndex = Infinity; for (var i = 0; i < resource.tags.length; i++) { for (var j = 0; j < Math.min(orderedTags.length, bestIndex); j++) { if (resource.tags.all[i] === orderedTags[j] && j < bestIndex) { bestIndex = j; if (bestIndex === 0) { break; } } if (bestIndex === 0) { break; } } } return bestIndex; }; return function(a, b) { // check tag order first var aOrder = getTagOrder(a), bOrder = getTagOrder(b); if (aOrder < bOrder) { return -1; } if (aOrder > bOrder) { return 1; } // now check priority if (a.priority < b.priority) { return -1; } if (a.priority > b.priority) { return 1; } return 0; }; }; this.start = function(orderedTags) { timeStarted = Date.now(); // first order the resources var compareResources = getResourceSort(orderedTags); entries.sort(compareResources); // trigger requests for each resource for (var i = 0, len = entries.length; i < len; i++) { var entry = entries[i]; entry.status = ResourceState.WAITING; entry.resource.start(this); } // do an initial status check soon since items may be loaded from the cache setTimeout(statusCheck, 100); }; var statusCheck = function() { var checkAgain = false, noProgressTime = Date.now() - progressChanged, timedOut = (noProgressTime >= settings.noProgressTimeout), shouldLog = (noProgressTime >= settings.loggingDelay); for (var i = 0, len = entries.length; i < len; i++) { var entry = entries[i]; if (entry.status !== ResourceState.WAITING) { continue; } // see if the resource has loaded if (entry.resource.checkStatus) { entry.resource.checkStatus(); } // if still waiting, mark as timed out or make sure we check again if (entry.status === ResourceState.WAITING) { if (timedOut) { entry.resource.onTimeout(); } else { checkAgain = true; } } } // log any resources that are still pending if (shouldLog && checkAgain) { log(); } if (checkAgain) { setTimeout(statusCheck, settings.statusInterval); } }; this.isBusy = function() { for (var i = 0, len = entries.length; i < len; i++) { if (entries[i].status === ResourceState.QUEUED || entries[i].status === ResourceState.WAITING) { return true; } } return false; }; var onProgress = function(resource, statusType) { var entry = null, i, len, numResourceTags, listener, shouldCall; // find the entry for the resource for (i = 0, len = entries.length; i < len; i++) { if (entries[i].resource === resource) { entry = entries[i]; break; } } // we have already updated the status of the resource if (entry == null || entry.status !== ResourceState.WAITING) { return; } entry.status = statusType; progressChanged = Date.now(); numResourceTags = resource.tags.length; // fire callbacks for interested listeners for (i = 0, len = progressListeners.length; i < len; i++) { listener = progressListeners[i]; if (listener.tags.length === 0) { // no tags specified so always tell the listener shouldCall = true; } else { // listener only wants to hear about certain tags shouldCall = resource.tags.intersects(listener.tags); } if (shouldCall) { sendProgress(entry, listener); } } }; this.onLoad = function(resource) { onProgress(resource, ResourceState.LOADED); }; this.onError = function(resource) { onProgress(resource, ResourceState.ERROR); }; this.onTimeout = function(resource) { onProgress(resource, ResourceState.TIMEOUT); }; // sends a progress report to a listener var sendProgress = function(updatedEntry, listener) { // find stats for all the resources the caller is interested in var completed = 0, total = 0, i, len, entry, includeResource; for (i = 0, len = entries.length; i < len; i++) { entry = entries[i]; includeResource = false; if (listener.tags.length === 0) { // no tags specified so always tell the listener includeResource = true; } else { includeResource = entry.resource.tags.intersects(listener.tags); } if (includeResource) { total++; if (entry.status === ResourceState.LOADED || entry.status === ResourceState.ERROR || entry.status === ResourceState.TIMEOUT) { completed++; } } } listener.callback({ // info about the resource that changed resource: updatedEntry.resource, // should we expose StatusType instead? loaded: (updatedEntry.status === ResourceState.LOADED), error: (updatedEntry.status === ResourceState.ERROR), timeout: (updatedEntry.status === ResourceState.TIMEOUT), // updated stats for all resources completedCount: completed, totalCount: total }); }; // prints the status of each resource to the console var log = this.log = function(showAll) { if (!window.console) { return; } var elapsedSeconds = Math.round((Date.now() - timeStarted) / 1000); window.console.log('PxLoader elapsed: ' + elapsedSeconds + ' sec'); for (var i = 0, len = entries.length; i < len; i++) { var entry = entries[i]; if (!showAll && entry.status !== ResourceState.WAITING) { continue; } var message = 'PxLoader: #' + i + ' ' + entry.resource.getName(); switch(entry.status) { case ResourceState.QUEUED: message += ' (Not Started)'; break; case ResourceState.WAITING: message += ' (Waiting)'; break; case ResourceState.LOADED: message += ' (Loaded)'; break; case ResourceState.ERROR: message += ' (Error)'; break; case ResourceState.TIMEOUT: message += ' (Timeout)'; break; } if (entry.resource.tags.length > 0) { message += ' Tags: [' + entry.resource.tags.all.join(',') + ']'; } window.console.log(message); } }; } // AMD module support if (typeof define === 'function' && define.amd) { define('PxLoader', [], function() { return PxLoader; }); } // browserify support if ( typeof module === 'object' ) { module.exports = PxLoader; } // Date.now() shim for older browsers if (!Date.now) { Date.now = function now() { return new Date().getTime(); }; } // shims to ensure we have newer Array utility methods // https://developer.mozilla.org/en/JavaScript/Reference/Global_Objects/Array/isArray if (!Array.isArray) { Array.isArray = function(arg) { return Object.prototype.toString.call(arg) === '[object Array]'; }; } /*global PxLoader: true, define: true */ // PxLoader plugin to load images function PxLoaderImage(url, tags, priority, origin) { var self = this, loader = null; this.img = new Image(); if(origin !== undefined) { this.img.crossOrigin = origin; } this.tags = tags; this.priority = priority; var onReadyStateChange = function() { if (self.img.readyState === 'complete') { removeEventHandlers(); loader.onLoad(self); } }; var onLoad = function() { removeEventHandlers(); loader.onLoad(self); }; var onError = function() { removeEventHandlers(); loader.onError(self); }; var removeEventHandlers = function() { self.unbind('load', onLoad); self.unbind('readystatechange', onReadyStateChange); self.unbind('error', onError); }; this.start = function(pxLoader) { // we need the loader ref so we can notify upon completion loader = pxLoader; // NOTE: Must add event listeners before the src is set. We // also need to use the readystatechange because sometimes // load doesn't fire when an image is in the cache. self.bind('load', onLoad); self.bind('readystatechange', onReadyStateChange); self.bind('error', onError); self.img.src = url; }; // called by PxLoader to check status of image (fallback in case // the event listeners are not triggered). this.checkStatus = function() { if (self.img.complete) { removeEventHandlers(); loader.onLoad(self); } }; // called by PxLoader when it is no longer waiting this.onTimeout = function() { removeEventHandlers(); if (self.img.complete) { loader.onLoad(self); } else { loader.onTimeout(self); } }; // returns a name for the resource that can be used in logging this.getName = function() { return url; }; // cross-browser event binding this.bind = function(eventName, eventHandler) { if (self.img.addEventListener) { self.img.addEventListener(eventName, eventHandler, false); } else if (self.img.attachEvent) { self.img.attachEvent('on' + eventName, eventHandler); } }; // cross-browser event un-binding this.unbind = function(eventName, eventHandler) { if (self.img.removeEventListener) { self.img.removeEventListener(eventName, eventHandler, false); } else if (self.img.detachEvent) { self.img.detachEvent('on' + eventName, eventHandler); } }; } // add a convenience method to PxLoader for adding an image PxLoader.prototype.addImage = function(url, tags, priority, origin) { var imageLoader = new PxLoaderImage(url, tags, priority, origin); this.add(imageLoader); // return the img element to the caller return imageLoader.img; }; // AMD module support if (typeof define === 'function' && define.amd) { define('PxLoaderImage', [], function() { return PxLoaderImage; }); } /*global PxLoader: true, define: true, soundManager: true */ // PxLoader plugin to load sound using SoundManager2 function PxLoaderSound(id, url, tags, priority) { var self = this, loader = null; // For iOS and Android, soundManager2 uses a global audio object so we // can't preload multiple sounds. We'll have to hope they load quickly // when we need to play them. Unfortunately, SM2 doesn't expose // a property to indicate its using a global object. For now we'll // use the same tests they use. var isIOS = navigator.userAgent.match(/(ipad|iphone|ipod)/i), isAndroid = navigator.userAgent.match(/android/i); this.useGlobalHTML5Audio = isIOS || isAndroid; this.tags = tags; this.priority = priority; this.sound = soundManager['createSound']({ 'id': id, 'url': url, 'autoLoad': false, 'onload': function() { loader.onLoad(self); }, // HTML5-only event: Fires when a browser has chosen to stop downloading. // "The user agent is intentionally not currently fetching media data, // but does not have the entire media resource downloaded." 'onsuspend': function() { loader.onTimeout(self); }, // Fires at a regular interval when a sound is loading and new data // has been received. 'whileloading': function() { var bytesLoaded = this['bytesLoaded'], bytesTotal = this['bytesTotal']; // TODO: provide percentage complete updates to loader? // see if we have loaded the file if (bytesLoaded > 0 && (bytesLoaded === bytesTotal)) { loader.onLoad(self); } } }); this.start = function(pxLoader) { // we need the loader ref so we can notify upon completion loader = pxLoader; // can't preload when a single global audio element is used if (this.useGlobalHTML5Audio) { loader.onTimeout(self); } else { this.sound['load'](); } }; this.checkStatus = function() { switch(self.sound['readyState']) { case 0: // uninitialised break; case 1: // loading break; case 2: // failed/error loader.onError(self); break; case 3: // loaded/success loader.onLoad(self); break; } }; this.onTimeout = function() { loader.onTimeout(self); }; this.getName = function() { return url; }; } // add a convenience method to PxLoader for adding a sound PxLoader.prototype.addSound = function(id, url, tags, priority) { var soundLoader = new PxLoaderSound(id, url, tags, priority); this.add(soundLoader); return soundLoader.sound; }; // AMD module support if (typeof define === 'function' && define.amd) { define('PxLoaderSound', [], function() { return PxLoaderSound; }); } /*global PxLoader: true, define: true, Video: true */ // PxLoader plugin to load video elements function PxLoaderVideo(url, tags, priority, origin) { var self = this; var loader = null; this.readyEventName = 'canplaythrough'; try { this.vid = new Video(); } catch(e) { this.vid = document.createElement('video'); } if(origin !== undefined) { this.vid.crossOrigin = origin; } this.tags = tags; this.priority = priority; var onReadyStateChange = function() { if (self.vid.readyState !== 4) { return; } removeEventHandlers(); loader.onLoad(self); }; var onLoad = function() { removeEventHandlers(); loader.onLoad(self); }; var onError = function() { removeEventHandlers(); loader.onError(self); }; var removeEventHandlers = function() { self.unbind('load', onLoad); self.unbind(self.readyEventName, onReadyStateChange); self.unbind('error', onError); }; this.start = function(pxLoader) { // we need the loader ref so we can notify upon completion loader = pxLoader; // NOTE: Must add event listeners before the src is set. We // also need to use the readystatechange because sometimes // load doesn't fire when an video is in the cache. self.bind('load', onLoad); self.bind(self.readyEventName, onReadyStateChange); self.bind('error', onError); // sometimes the browser will intentionally stop downloading // the video. In that case we'll consider the video loaded self.bind('suspend', onLoad); self.vid.src = url; self.vid.load(); }; // called by PxLoader to check status of video (fallback in case // the event listeners are not triggered). this.checkStatus = function() { if (self.vid.readyState !== 4) { return; } removeEventHandlers(); loader.onLoad(self); }; // called by PxLoader when it is no longer waiting this.onTimeout = function() { removeEventHandlers(); if (self.vid.readyState !== 4) { loader.onLoad(self); } else { loader.onTimeout(self); } }; // returns a name for the resource that can be used in logging this.getName = function() { return url; }; // cross-browser event binding this.bind = function(eventName, eventHandler) { if (self.vid.addEventListener) { self.vid.addEventListener(eventName, eventHandler, false); } else if (self.vid.attachEvent) { self.vid.attachEvent('on' + eventName, eventHandler); } }; // cross-browser event un-binding this.unbind = function(eventName, eventHandler) { if (self.vid.removeEventListener) { self.vid.removeEventListener(eventName, eventHandler, false); } else if (self.vid.detachEvent) { self.vid.detachEvent('on' + eventName, eventHandler); } }; } // add a convenience method to PxLoader for adding an image PxLoader.prototype.addVideo = function(url, tags, priority, origin) { var videoLoader = new PxLoaderVideo(url, tags, priority, origin); this.add(videoLoader); // return the vid element to the caller return videoLoader.vid; }; // AMD module support if (typeof define === 'function' && define.amd) { define('PxLoaderVideo', [], function() { return PxLoaderVideo; }); }