1 // ==========================================================================
  2 // Project:   SproutCore - JavaScript Application Framework
  3 // Copyright: ©2006-2011 Strobe Inc. and contributors.
  4 //            Portions ©2008-2011 Apple Inc. All rights reserved.
  5 // License:   Licensed under MIT license (see license.js)
  6 // ==========================================================================
  7 
  8 SC.IMAGE_ABORTED_ERROR = SC.$error("SC.Image.AbortedError", "Image", -100) ;
  9 
 10 SC.IMAGE_FAILED_ERROR = SC.$error("SC.Image.FailedError", "Image", -101) ;
 11 
 12 /**
 13   @class
 14   
 15   The image queue can be used to control the order of loading images.
 16   
 17   Images queues are necessary because browsers impose strict limits on the 
 18   number of concurrent connections that can be open at any one time to any one 
 19   host. By controlling the order and timing of your loads using this image 
 20   queue, you can improve the percieved performance of your application by 
 21   ensuring the images you need most load first.
 22   
 23   Note that if you use the SC.ImageView class, it will use this image queue 
 24   for you automatically.
 25   
 26   ## Loading Images
 27   
 28   When you need to display an image, simply call the loadImage() method with 
 29   the URL of the image, along with a target/method callback. The signature of 
 30   your callback should be:
 31   
 32       imageDidLoad: function(imageUrl, imageOrError) {
 33         //...
 34       }
 35 
 36   The "imageOrError" parameter will contain either an image object or an error 
 37   object if the image could not be loaded for some reason.  If you receive an 
 38   error object, it will be one of SC.IMAGE_ABORTED_ERROR or 
 39   SC.IMAGE_FAILED_ERROR.
 40   
 41   You can also optionally specify that the image should be loaded in the 
 42   background.  Background images are loaded with a lower priority than 
 43   foreground images.
 44   
 45   ## Aborting Image Loads
 46   
 47   If you request an image load but then no longer require the image for some 
 48   reason, you should notify the imageQueue by calling the releaseImage() 
 49   method.  Pass the URL, target and method that you included in your original 
 50   loadImage() request.  
 51   
 52   If you have requested an image before, you should always call releaseImage() 
 53   when you are finished with it, even if the image has already loaded.  This 
 54   will allow the imageQueue to properly manage its own internal resources.
 55   
 56   This method may remove the image from the queue of images that need or load 
 57   or it may abort an image load in progress to make room for other images.  If 
 58   the image is already loaded, this method will have no effect.
 59   
 60   ## Reloading an Image
 61   
 62   If you have already loaded an image, the imageQueue will avoid loading the 
 63   image again.  However, if you need to force the imageQueue to reload the 
 64   image for some reason, you can do so by calling reloadImage(), passing the 
 65   URL.
 66   
 67   This will cause the image queue to attempt to load the image again the next 
 68   time you call loadImage on it.
 69   
 70   @extends SC.Object
 71   @since SproutCore 1.0
 72 */
 73 SC.imageQueue = SC.Object.create(/** @scope SC.imageQueue.prototype */ {
 74 
 75   /**
 76     The maximum number of images that can load from a single hostname at any
 77     one time.  For most browsers 4 is a reasonable number, though you may 
 78     tweak this on a browser-by-browser basis.
 79   */
 80   loadLimit: 4,
 81   
 82   /**
 83     The number of currently active requests on the queue. 
 84   */
 85   activeRequests: 0,
 86   
 87   /**
 88     Loads an image from the server, calling your target/method when complete.
 89     
 90     You should always pass at least a URL and optionally a target/method.  If 
 91     you do not pass the target/method, the image will be loaded in background 
 92     priority.  Usually, however, you will want to pass a callback to be 
 93     notified when the image has loaded.  Your callback should have a signature 
 94     like:
 95 
 96         imageDidLoad: function(imageUrl, imageOrError) { .. }
 97 
 98     If you do pass a target/method you can optionally also choose to load the 
 99     image either in the foreground or in the background.  The imageQueue 
100     prioritizes foreground images over background images.  This does not impact 
101     how many images load at one time.
102     
103     @param {String} url
104     @param {Object} target
105     @param {String|Function} method
106     @param {Boolean} isBackgroundFlag
107     @returns {SC.imageQueue} receiver
108   */
109   loadImage: function(url, target, method, isBackgroundFlag) {
110     // normalize params
111     var type = SC.typeOf(target);
112     if (SC.none(method) && SC.typeOf(target)===SC.T_FUNCTION) {
113       target = null; method = target ;
114     }
115     if (SC.typeOf(method) === SC.T_STRING) {
116       method = target[method];      
117     }
118     // if no callback is passed, assume background image.  otherwise, assume
119     // foreground image.
120     if (SC.none(isBackgroundFlag)) {
121       isBackgroundFlag = SC.none(target) && SC.none(method);
122     }
123     
124     // get image entry in queue.  If entry is loaded, just invoke callback
125     // and quit.
126     var entry = this._imageEntryFor(url) ;
127     if (entry.status === this.IMAGE_LOADED) {
128       if (method) method.call(target || entry.image, entry.url, entry.image);
129       
130     // otherwise, add to list of callbacks and queue image.
131     } else {
132       if (target || method) this._addCallback(entry, target, method);
133       entry.retainCount++; // increment retain count, regardless of callback
134       this._scheduleImageEntry(entry, isBackgroundFlag);
135     }
136   },
137   
138   /**
139     Invoke this method when you are finished with an image URL.  If you 
140     passed a target/method, you should also pass it here to remove it from
141     the list of callbacks.
142     
143     @param {String} url
144     @param {Object} target
145     @param {String|Function} method
146     @returns {SC.imageQueue} receiver
147   */
148   releaseImage: function(url, target, method) {
149     // get entry.  if there is no entry, just return as there is nothing to 
150     // do.
151     var entry = this._imageEntryFor(url, NO) ;
152     if (!entry) return this ;
153     
154     // there is an entry, decrement the retain count.  If <=0, delete!
155     if (--entry.retainCount <= 0) {
156       this._deleteEntry(entry); 
157     
158     // if >0, just remove target/method if passed
159     } else if (target || method) {
160       // normalize
161       var type = SC.typeOf(target);
162       if (SC.none(method) && SC.typeOf(target)===SC.T_FUNCTION) {
163         target = null; method = target ;
164       }
165       if (SC.typeOf(method) === SC.T_STRING) {
166         method = target[method];      
167       }
168 
169       // and remove
170       this._removeCallback(entry, target, method) ;
171     }
172   },
173 
174   /** 
175     Forces the image to reload the next time you try to load it.
176   */
177   reloadImage: function(url) {
178     var entry = this._imageEntryFor(url, NO); 
179     if (entry && entry.status===this.IMAGE_LOADED) {
180       entry.status = this.IMAGE_WAITING;
181     }
182   },
183   
184   /**
185     Initiates a load of the next image in the image queue.  Normally you will
186     not need to call this method yourself as it will be initiated 
187     automatically when the queue becomes active.
188   */
189   loadNextImage: function() {
190     var entry = null, queue;
191 
192     // only run if we don't have too many active request...
193     if (this.get('activeRequests')>=this.get('loadLimit')) return; 
194     
195     // first look in foreground queue
196     queue = this._foregroundQueue ;
197     while(queue.length>0 && !entry) entry = queue.shift();
198     
199     // then look in background queue
200     if (!entry) {
201       queue = this._backgroundQueue ;
202       while(queue.length>0 && !entry) entry = queue.shift();
203     }
204     this.set('isLoading', !!entry); // update isLoading...
205     
206     // if we have an entry, then initiate an image load with the proper 
207     // callbacks.
208     if (entry) {
209       // var img = (entry.image = new Image()) ;
210       var img = entry.image ;
211       if(!img) return;
212 
213       // Using bind here instead of setting onabort/onerror/onload directly
214       // fixes an issue with images having 0 width and height
215       $(img).bind('abort', this._imageDidAbort);
216       $(img).bind('error', this._imageDidError);
217       $(img).bind('load', this._imageDidLoad);
218       img.src = entry.url ;
219       
220       // add to loading queue.
221       this._loading.push(entry) ;
222     
223       // increment active requests and start next request until queue is empty
224       // or until load limit is reached.
225       this.incrementProperty('activeRequests');
226       this.loadNextImage();
227     } 
228   },
229   
230   // ..........................................................
231   // SUPPORT METHODS
232   // 
233 
234   /** @private Find or create an entry for the URL. */
235   _imageEntryFor: function(url, createIfNeeded) {
236     if (createIfNeeded === undefined) createIfNeeded = YES;
237     var entry = this._images[url] ;
238     if (!entry && createIfNeeded) {
239       var img = new Image() ;
240       entry = this._images[url] = { 
241         url: url, status: this.IMAGE_WAITING, callbacks: [], retainCount: 0, image: img
242       };
243       img.entry = entry ; // provide a link back to the image
244     } else if (entry && entry.image === null) {
245     	// Ensure that if we retrieve an entry that it has an associated Image,
246     	// since failed/aborted images will have had their image property nulled.
247     	entry.image = new Image();
248     	entry.image.entry = entry;
249     }
250     return entry ;
251   },
252   
253   /** @private deletes an entry from the image queue, descheduling also */
254   _deleteEntry: function(entry) {
255     this._unscheduleImageEntry(entry) ;
256     delete this._images[entry.url];    
257   },
258   
259   /** @private 
260     Add a callback to the image entry.  First search the callbacks to make
261     sure this is only added once.
262   */
263   _addCallback: function(entry, target, method) {
264     var callbacks = entry.callbacks;
265 
266     // try to find in existing array
267     var handler = callbacks.find(function(x) {
268       return x[0]===target && x[1]===method;
269     }, this);
270     
271     // not found, add...
272     if (!handler) callbacks.push([target, method]);
273     callbacks = null; // avoid memory leaks
274     return this ;
275   },
276   
277   /** @private
278     Removes a callback from the image entry.  Removing a callback just nulls
279     out that position in the array.  It will be skipped when executing.
280   */
281   _removeCallback: function(entry, target, method) {
282     var callbacks = entry.callbacks ;
283     callbacks.forEach(function(x, idx) {
284       if (x[0]===target && x[1]===method) callbacks[idx] = null;
285     }, this);
286     callbacks = null; // avoid memory leaks
287     return this ;
288   },
289   
290   /** @private 
291     Adds an entry to the foreground or background queue to load.  If the 
292     loader is not already running, start it as well.  If the entry is in the
293     queue, but it is in the background queue, possibly move it to the
294     foreground queue.
295   */
296   _scheduleImageEntry: function(entry, isBackgroundFlag) {
297 
298     var background = this._backgroundQueue ;
299     var foreground = this._foregroundQueue ;
300     
301     // if entry is loaded, nothing to do...
302     if (entry.status === this.IMAGE_LOADED) return this;
303 
304     // if image is already in background queue, but now needs to be
305     // foreground, simply remove from background queue....
306     if ((entry.status===this.IMAGE_QUEUED) && !isBackgroundFlag && entry.isBackground) {
307       background[background.indexOf(entry)] = null ;
308       entry.status = this.IMAGE_WAITING ;
309     }
310     
311     // if image is not in queue already, add to queue.
312     if (entry.status!==this.IMAGE_QUEUED) {
313       var queue = (isBackgroundFlag) ? background : foreground ;
314       queue.push(entry);
315       entry.status = this.IMAGE_QUEUED ;
316       entry.isBackground = isBackgroundFlag ;
317     }
318     
319     // if the image loader is not already running, start it...
320     if (!this.isLoading) this.invokeLater(this.loadNextImage, 100);
321     this.set('isLoading', YES);
322     
323     return this ; // done!
324   },
325   
326   /** @private
327     Removes an entry from the foreground or background queue.  
328   */
329   _unscheduleImageEntry: function(entry) {
330     // if entry is not queued, do nothing
331     if (entry.status !== this.IMAGE_QUEUED) return this ;
332     
333     var queue = entry.isBackground ? this._backgroundQueue : this._foregroundQueue ;
334     queue[queue.indexOf(entry)] = null; 
335     
336     // if entry is loading, abort it also.  Call local abort method in-case
337     // browser decides not to follow up.
338     if (this._loading.indexOf(entry) >= 0) {
339       // In some cases queue.image is undefined. Is it ever defined?
340       if (queue.image) queue.image.abort();
341       this.imageStatusDidChange(entry, this.ABORTED);
342     }
343     
344     return this ;
345   },
346   
347   /** @private invoked by Image().  Note that this is the image instance */
348   _imageDidAbort: function() {
349     SC.run(function() {
350       SC.imageQueue.imageStatusDidChange(this.entry, SC.imageQueue.ABORTED);
351     }, this);
352   },
353   
354   _imageDidError: function() {
355     SC.run(function() {
356       SC.imageQueue.imageStatusDidChange(this.entry, SC.imageQueue.ERROR);
357     }, this);
358   },
359   
360   _imageDidLoad: function() {
361     SC.run(function() {
362       SC.imageQueue.imageStatusDidChange(this.entry, SC.imageQueue.LOADED);
363     }, this);
364   },
365 
366   /** @private called whenever the image loading status changes.  Notifies
367     items in the queue and then cleans up the entry.
368   */
369   imageStatusDidChange: function(entry, status) {
370     if (!entry) return; // nothing to do...
371     
372     var url = entry.url ;
373     
374     // notify handlers.
375     var value ;
376     switch(status) {
377       case this.LOADED:
378         value = entry.image;
379         break;
380       case this.ABORTED:
381         value = SC.IMAGE_ABORTED_ERROR;
382         break;
383       case this.ERROR:
384         value = SC.IMAGE_FAILED_ERROR ;
385         break;
386       default:
387         value = SC.IMAGE_FAILED_ERROR ;
388         break;
389     }
390     entry.callbacks.forEach(function(x){
391       var target = x[0], method = x[1];
392       method.call(target, url, value);
393     },this);
394     
395     // now clear callbacks so they aren't called again.
396     entry.callbacks = [];
397     
398     // finally, if the image loaded OK, then set the status.  Otherwise
399     // set it to waiting so that further attempts will load again
400     entry.status = (status === this.LOADED) ? this.IMAGE_LOADED : this.IMAGE_WAITING ;
401     
402     // now cleanup image...
403     var image = entry.image ;
404     if (image) {
405       image.onload = image.onerror = image.onabort = null ; // no more notices
406       if (status !== this.LOADED) entry.image = null;
407     }
408 
409     // remove from loading queue and periodically compact
410     this._loading[this._loading.indexOf(entry)]=null;
411     if (this._loading.length > this.loadLimit*2) {
412       this._loading = this._loading.compact();
413     }
414     
415     this.decrementProperty('activeRequests');
416     this.loadNextImage() ;
417   },
418   
419   init: function() {
420     sc_super();
421     this._images = {};
422     this._loading = [] ;
423     this._foregroundQueue = [];
424     this._backgroundQueue = [];
425   },
426   
427   IMAGE_LOADED: "loaded",
428   IMAGE_QUEUED: "queued",
429   IMAGE_WAITING: "waiting",
430   
431   ABORTED: 'aborted',
432   ERROR: 'error',
433   LOADED: 'loaded'
434 });
435