1 /** 2 * @author Gillis Haasnoot <gillis.haasnoot@gmail.com> 3 * @package Banana.Controls 4 * @summary ChunkedUpload experimental control. 5 */ 6 7 goog.provide('Banana.Controls.ChunkedUpload'); 8 9 /** @namespace Banana.Controls.ChunkUpload*/ 10 namespace('Banana.Controls').ChunkedUpload = Banana.Controls.Panel.extend({ 11 /** @lends Banana.Controls.ChunkUpload.prototype */ 12 13 /** 14 * creates a chunked upload control 15 * currently only supported by limited amount of browsers: chrome and firefox. 16 * uploads file is small chunks. the upload control ensures that chunks are send to the 17 * server in the correct order. Server is responsible to reconstruct the chunks to one file 18 * during file upload the first time a chunk is uploaded we append "firstChunk" to the post params 19 * last time a chunk is uploaded we append "lastChunk" to the post params. 20 * a "uid" param is appended to identify the file serverside 21 * @constructs 22 * @extends Banana.Controls.Panel 23 */ 24 init : function() 25 { 26 this._super(); 27 28 if (!this.isSupported()) 29 { 30 this.triggerError = true; 31 log.error("chunk upload not supported by the browser"); 32 return; 33 } 34 35 this.files = []; 36 this.chunkSize = 2000000; 37 this.maxSimultaneousUpload = 2; 38 39 var form = new Banana.Controls.Form(); 40 form.setAttribute('enctype',"multipart/form-data"); 41 form.setAttribute('method',"post"); 42 43 var maxInput = new Banana.Controls.InputControl(); 44 maxInput.setAttribute('name',"MAX_FILE_SIZE"); 45 maxInput.setAttribute('value',"99999999"); 46 maxInput.setAttribute('type',"hidden"); 47 48 var fileInput = new Banana.Controls.FileInput(); 49 fileInput.bind('change',this.getProxy(function(){ 50 this.handleFilesSelected(fileInput.getFiles()); 51 })); 52 fileInput.setId('fileInput'); 53 54 form.addControl(maxInput); 55 form.addControl(fileInput); 56 57 this.addControl(form); 58 59 this.registerCustomEvents(); 60 }, 61 62 /** 63 * @ignore 64 */ 65 createComponents : function() 66 { 67 if (this.triggerError) 68 { 69 this.triggerEvent("browserNotSupported"); 70 } 71 }, 72 73 /** 74 * set the size of a chunk. the lower you set the chunk, the lower memory usuage will be, but also 75 * 76 * @param {int} chunkSize 77 * @return {this} 78 */ 79 setChunkSize : function(chunkSize) 80 { 81 this.chunkSize = chunkSize; 82 return this; 83 }, 84 85 /** 86 * max upload at the same time 87 * @param {int} maxSimultaneousUpload 88 * @return {this} 89 */ 90 setMaxSimultaneousUpload : function(maxSimultaneousUpload) 91 { 92 this.maxSimultaneousUpload = maxSimultaneousUpload; 93 return this; 94 }, 95 96 /** 97 * 98 * @param {boolean} bool when true filedialog supports multiple file selection 99 * @return {this} 100 */ 101 setMultipleFilesUpload : function(bool) 102 { 103 var control = this.findControl('fileInput'); 104 if (!control) 105 { 106 return; 107 } 108 control.setMultiple(bool); 109 110 return this; 111 }, 112 113 /** 114 * sets the url to post to 115 * @param {String} url 116 * @return {this} 117 */ 118 setPostUrl : function(url) 119 { 120 this.uploadFile = url; 121 return this; 122 }, 123 124 /** 125 * Check whether our upload control is supported by the browser 126 * @return {boolean} 127 */ 128 isSupported : function() 129 { 130 if (!window.File) 131 { 132 return false; 133 } 134 135 //check file slice 136 if (!File.prototype.webkitSlice && !File.prototype.mozSlice && !File.prototype.slice) 137 { 138 return false; 139 } 140 141 //check level 2 xhr 142 var xhr = new XMLHttpRequest(); 143 return !! (xhr && ('upload' in xhr) && ('onprogress' in xhr.upload)); 144 }, 145 146 /** 147 * Handle file selected 148 * @param {Array} files 149 */ 150 handleFilesSelected : function(files) 151 { 152 this.currentUploading = 0; 153 this.currentUploaded = 0; 154 this.currentFileCount = files.length; 155 this.formFiles = files; 156 this.files = []; 157 this.startTime = new Date().getTime(); 158 159 this.triggerEvent("filesSelected",{"files":files}); 160 }, 161 162 /** 163 * Here we listen to the fileupload event. 164 * we create a construction here to use maxSimultaneousUpload and 165 * ensure completion of all files 166 */ 167 registerCustomEvents : function() 168 { 169 this.bind('fileUploadedInternal',this.getProxy(function(e,data){ 170 171 this.currentUploaded++; 172 this.currentUploading--; 173 174 ///console.log('finished file '+data.file.name+' uploaded. currentUploaded',this.currentUploaded, 'currentUploading',this.currentUploading); 175 //all files are uploaded 176 if (this.currentUploaded == this.currentFileCount) 177 { 178 this.triggerEvent('filesUploaded',{files:this.formFiles}); 179 return; 180 } 181 182 //all files are at least busy 183 if (this.currentUploading+this.currentUploaded == this.currentFileCount) 184 { 185 return; 186 } 187 188 //all files beeing uploaded are uploaded. do we have more files to upload? 189 if (this.currentUploading < this.maxSimultaneousUpload) 190 { 191 this.uploadFiles(this.formFiles,this.currentUploaded+this.currentUploading,this.currentUploaded+this.currentUploading+this.maxSimultaneousUpload-this.currentUploading); 192 } 193 })); 194 }, 195 196 /** 197 * Executes the upload procedure 198 * @param {Array} of files 199 * @param {int} from 200 * @param {to} to 201 */ 202 uploadFiles : function(files,from,to) 203 { 204 //from and to are used to maximize simultaneous uploads 205 if (!from) 206 { 207 from = 0; 208 } 209 if (!to) 210 { 211 to = this.maxSimultaneousUpload > files.length ? files.length : this.maxSimultaneousUpload; 212 } 213 214 this.currentUploading+=to-from; 215 216 var i,len; 217 for (i=from,len=to;i < len; i++) 218 { 219 var file = files[i]; 220 file.loaded = 0; 221 file.uid = Banana.Util.generateUniqueId(); 222 file.completion = 0; 223 224 this.files[file.name] = file; 225 226 this.processFileChunkFrom(file,0,null); 227 } 228 }, 229 230 /** 231 * Processes a chunk from a file 232 * this method is recalled everytime a chunk is completed. 233 * it stops when all chunks are completed 234 * @param {Object} file 235 * @param {int} index part of the file chunk 236 * @param {function} cb called after each upload chunk completion 237 */ 238 processFileChunkFrom : function(file,index,cb) 239 { 240 var start = file.loaded; 241 var end = file.loaded+this.chunkSize; 242 243 //if we have an error in the file. we trigger event and stop processing 244 if (file.uploadError) 245 { 246 this.triggerEvent("fileUploadError",{"file":file}); 247 return; 248 } 249 250 //we always end up here when all chunks are uploaded 251 if (start >= file.size) 252 { 253 //trigger this event to make sure we always received a fileUploading event prior to fileUploaded 254 //specialy with small files the progress event is sometimes not fired by the browser 255 this.triggerEvent("fileUploading",{"file":file}); 256 257 this.triggerEvent("fileUploaded",{"file":file}); 258 this.triggerEvent("fileUploadedInternal",{"file":file}); 259 return; 260 } 261 262 //we end up here at the last chunk 263 if (end >= file.size) 264 { 265 end = file.size; 266 } 267 268 //slice method depending on the browser 269 var slice = file.webkitSlice || file.mozSlice || file.slice 270 271 //get a chunk from the file 272 var chunk = slice.call(file,start,end); 273 274 chunk.filename = file.name; 275 chunk.index = index; 276 277 //below we have a callback function encapsulated in a directly executed function 278 //we do this to ensure a closure of where the file resists in. 279 //the situation can occur that we upload 2 different files at the same time. we want the 280 //callback function access the right file always. without the closure the callback will always access the 281 //last set file, which can be wrong 282 var $this = this; 283 284 this.uploadChunk(chunk,file,function(index,file,cb) 285 { 286 var func = function(data) 287 { 288 file = this.getFile(file.name); 289 290 //we overwrite the loaded property here, because the ammount of loaded data 291 //set in the progress event is chunkdata + header data from the xhr request. 292 //the real amount of loaded bytes is index * chunksize 293 //file.loaded += (index+2)*this.chunkSize; 294 file.loaded += data.loaded; 295 296 file.completion = (file.loaded/file.size*100); 297 298 this.processFileChunkFrom(file,++index,cb); 299 } 300 301 return jQuery.proxy( func, $this); 302 //alert("yes") 303 }(index,file,cb)); 304 }, 305 306 /** 307 * @param {String} filename 308 * @return {Object} 309 */ 310 getFile : function(filename) 311 { 312 return this.files[filename]; 313 }, 314 315 /** 316 * Uploads a chunk 317 * if a chunk fails we set uploadError on file object to true 318 * @param {Object} chunk 319 * @param {Object} file 320 * @param cb fired after successfully uploading chunk 321 */ 322 uploadChunk : function(chunk,file,cb) 323 { 324 var fd = new FormData(); 325 326 fd.append("chunk",chunk); 327 fd.append("filename",chunk.filename); 328 fd.append("uid",file.uid); 329 330 if (chunk.index == 0) 331 { 332 fd.append("firstChunk",1); 333 } 334 335 if ((file.loaded +this.chunkSize) >= file.size) 336 { 337 fd.append("lastChunk",1); 338 } 339 340 var xhr = new XMLHttpRequest(); 341 342 xhr.addEventListener("error",this.getProxy(function(e) 343 { 344 file.uploadError = true; 345 })); 346 347 var $this = this; 348 349 //triggered every file state change 350 xhr.addEventListener("readystatechange",function(file){ 351 352 var func = function(e,f) 353 { 354 if (e.target.readyState == 4) 355 { 356 if (e.target.status == 200 || e.target.status == 0 ) 357 { 358 } 359 else 360 { 361 file.uploadError = true; 362 } 363 } 364 } 365 return jQuery.proxy( func, $this); 366 367 }(file)); 368 369 var startTime = this.startTime; 370 371 //triggered every few ms 372 xhr.upload.addEventListener("progress",function(file){ 373 374 var func = function(e,f) 375 { 376 file = this.getFile(file.name); 377 378 //note that this is an indicative size. loaded bytes is complete xhr request 379 //the actual chunk size is smaller. Bacause of that we set the actual correct size after each chunk completion 380 //this happends in the callback in the load event 381 var bytesLoaded = file.loaded+e.loaded; 382 383 var newPerc = (bytesLoaded / file.size) * 100; 384 file.completion = Math.max(0,Math.min(newPerc,100)); 385 386 //calculates bytes/s //calculate eta 387 var now =new Date().getTime(); 388 389 file.Bps = bytesLoaded/ ((now-this.startTime)/1000); 390 391 file.speed = this.getReadablizeBytes(file.Bps); 392 393 var eta = now - this.startTime; 394 eta = eta/file.completion * (100-file.completion); 395 396 //filter 397 if (file.eta) 398 { 399 var alpha=0.1; 400 eta = eta * alpha + file.eta*(1-alpha); 401 } 402 403 file.eta = eta; 404 405 this.triggerEvent("fileUploading",{"file":file}); 406 } 407 408 return jQuery.proxy( func, $this); 409 410 411 }(file)); 412 413 //triggered when chunk is complete uploading. here we call the complete callback 414 xhr.upload.addEventListener("load",function(chunk,cb){ 415 416 var func = function(e) 417 { 418 var data = {}; 419 data.loaded = chunk.size; 420 421 cb(data); 422 } 423 424 return jQuery.proxy( func, $this); 425 426 }(chunk,cb)); 427 428 xhr.open("POST",this.uploadFile); 429 xhr.send(fd); 430 }, 431 432 /** 433 * get a more readable string by given string of bytes 434 * @param {String} bytes 435 * @return {String} 436 */ 437 getReadablizeBytes : function(bytes) 438 { 439 var s = ['bytes', 'kb', 'Mb', 'Gb', 'Tb', 'Pb']; 440 var e = Math.floor(Math.log(bytes)/Math.log(1024)); 441 return (bytes/Math.pow(1024, Math.floor(e))).toFixed(1)+' '+s[e]; 442 } 443 });