AJAX Upload XHR2 and FileReader, Part 3

Photo by Joshua Sukoff on Unsplash

So, this week’s stand up meeting is finally concluded. You weren’t really paying attention – blah blah something uploader, the details are in the task, blah blah HTML5. You sit down at your station, pull up the task and – hmm, support for modern browsers, including mobile… need to show previews of certain types of files before uploading… show progress… pause and resume? You seem to remember seeing something like that on one of your favourite developer blogs…

You may have been thinking about the HTML5-based uploader and file reader I shared way back when. However, as reader RLK points out, there wasn’t really a way to pause or resume uploads previously. The logic was there… if you were willing to unwrap DeferXHR from the plugin. Oops. Let’s fix that, and add some functionality while we’re at it.

This article continues work on the plugin we’ve discussed previously in Ajax Upload Part II, and Ajax Upload XHR2, Take Two.

Pausing & Resuming

First off, we’ll need to add a couple functions to the HUp prototype so that we can call pause and resume on the enhanced element (we’ll discuss exactly how to use these functions at the end):

/**
* Pause any in progress, chunked uploads/file reads. If pauseList is specified,
* elements should be either the names of the files or the index in which they were returned in the files
* list returned from the FILE_LIST_LOADED event. Can provide only a single string or number of only a single
* upload/read needs to be paused.
* @param {Array|number|string|boolean|undefined} pauseList
*/
Hup.prototype.pause = function(pauseList){
pauseList = (!pauseList) ? false : Array.isArray(pauseList) ? pauseList : [pauseList];

this.fprocessors.forEach(function(fprocess, idx){
if (!pauseList)
{
    fprocess.pause();
    return;
}
if (pauseList.indexOf(idx) !== -1 || pauseList.indexOf(fprocess.file.name) !== -1)
{
    fprocess.pause();
}
});
};

/**
* Resume any in progress, paused, chunked uploads/file reads, following the same rules for pauseList as
* specified for pause.
* @see Hup.prototype.pause
* @param {Array|number|string|boolean|undefined} pauseList
*/
Hup.prototype.resume = function(pauseList){
    pauseList = (!pauseList) ? false : Array.isArray(pauseList) ? pauseList : [pauseList];

    this.fprocessors.forEach(function(fprocess, idx){
        if (!pauseList)
        {
            fprocess.resume();
            return;
        }
        if (pauseList.indexOf(idx) !== -1 || pauseList.indexOf(fprocess.file.name) !== -1)
        {
            fprocess.resume();
        }
});
};

You’ll notice the pauseList parameter for both functions – this is to allow us to specify only specific file upload or read processors as needing to pause. Remember that the file reading or uploading will occur asynchronously, and if you’ve allowed the user to select or drag and drop multiple files, there can be multiple file reads/uploads occuring at once. The pauseList parameter, therefore, allows, for example, an interface you’ve built on top of this plugin to give the user the ability to pause one upload out of a list of uploads.

You can see we’re being fairly permissive about what pauseList looks like. It can be a boolean false, which will indicate we want to pause/resume all file processors (a boolean true is an invalid value). It can be an array, in which case it should be an array of strings or numbers (more on that in a moment). Or it can be a single string or number. Other values are invalid and will cause errors and/or untested strangeness.

If a number or string is specified (separately, or as part of the array), it should either be the 0-indexed number of the occurrence of the file you want to pause/resume processing on in the files array that gets returned from the FILE_LIST_LOADED event:

this.input.trigger(Hup.state.FILE_LIST_LOADED, {state:Hup.state.FILE_LIST_LOADED, files:files});

Or it should be the full name of the file as a string, e.g. picture_of_my_cat.jpg. Also worth noting is that most of the events that have to do with a specific file will also include the file_name as part of the event data, for use in disambiguating which file the event has taken place on.

On the DeferXHR that processes our files for upload in chunks, we have the following:

/**
* Pause the upload - that is, after the current chunk is finished uploading, cease uploading chunks until
* resume is called. For obvious reasons, this only works with chunked uploads.
* If the state of the deferred object is not pending (that is, is either already resolved or rejected),
* return early - we won't attempt to pause an upload that's finished or failed.
*/
DeferXhr.prototype.pause = function(){
if (this.defer.state() !== 'pending' || !this.options.chunked) return;
this.paused = true;
this.defer.notify({
state:Hup.state.FILE_UPLOAD_PAUSE, file_name:this.file.name,
current_range:{start:this.start, end:this.end, total:this.file.size}
});
};

/**
* Resume the upload if paused (works for chunked uploads only).
*/
DeferXhr.prototype.resume = function(){
    if (this.options.chunked && this.paused)
    {
        this.paused = false;
        this.defer.notify({
        state:Hup.state.FILE_UPLOAD_RESUME, file_name:this.file.name,
        current_range:{start:this.start, end:this.end, total:this.file.size}
    });
    this.upload();
    }
};

You’ll notice that we check the state of the Deferred object first – no point in attempting to pause an upload that’s already resolved or rejected. We also check that the upload is chunked – the way ‘pausing’ an upload works is to stop uploading after the current chunk is finished. If we’re not uploading in chunks (because the chunked option has been set to false, or because the file is smaller than the specified chunk_size), then calling pause on the upload has no effect.

So, now it looks like we have all the pieces for pausing and resume uploading in place – what about file reading?

Chunked File Reading

We just finished discussing how ‘pausing’ an upload works by finishing up the current chunk, and then waiting on resume to start uploading the next. Therefore, if we want to be able to pause a file read, we need to read it in chunks as well.

/**
* Read the entire file or a slice thereof, depending on the value of options.chunked and chunk_size.
*/
DeferReader.prototype.readFile = function(){
if (this.options.chunked)
{
    this.reader[this.read_method](this.file.slice(this.start, this.end));
    return;
}
this.reader[this.read_method](this.file);
};

This involves a number of changes to the structure of DeferReader, but one obvious starting point is that, if we’re chunking the file read, we follow the same general structure as we do for file uploads – use the slice method on the File to get a specified range of bytes within the file, and read that using the specified read method.

You may be wondering what the advantage is to this. After all, the file is just going to get read into memory, whether we read it all at once or in chunks, right? Is it just to have a standard api across both file processors?

On the contrary, it offers us some interesting advantages.

For one, we can potentially avoid locking the browser while loading a large file – if we have to perform the file loading on the UI thread, we can pause between chunks to perform other updates as necessary.

For another, memory isn’t the only place we could load a large file – we could instead plan to re-assemble it into IndexedDB. This would also potentially be a good place to download files to, if we needed to interact with them in the browser before serving them up to the user (say, decrypting them)… more on that in a future article.

For now, if you do decide to chunk your file read, you’ll need to re-assemble it as its read in to get the complete file. Notice the changes in DeferReader.prototype.readComplete:

/**
 * On read completion, if we're reading in chunks, if we've reached the last chunk, report on file read completion.
 * If there are remaining chunks, report on progress and read the next chunk.
 * Otherwise if we're reading the entire file in one go, report on file read completion.
 * @param event
 */
DeferReader.prototype.readComplete = function(event) {
    if (event.target.readyState == FileReader.DONE &&
        (!this.options.chunked || this.end == this.file.size)) {
        this.defer.resolve({
            state: Hup.state.FILE_READ_FINISHED,
            file_name: this.file.name,
            file_size: this.file.size,
            file_type: this.file.type,
            read_method: this.read_method,
            read_result: event.target.result
        });
        return;
    }

    this.defer.notify({
        state: Hup.state.FILE_READ_PROGRESS,
        file_name: this.file.name,
        progress: this.progress,
        read_result: (event.target.readyState == FileReader.DONE) ? event.target.result : void 0
    });

    this.start = this.end;
    this.end = Math.min(this.start + this.options.chunk_size, this.file.size);

    if (!this.paused) {
        this.readFile();
    }
};

As detailed above, whenever a chunk has finished reading in, we’ll check to see if its the last chunk. If not, we’ll trigger a FILE_READ_PROGRESS event, much like the kind that was triggered previously while a file read was ongoing. The difference here is that this version of the event will also include a read_result property, with the result of reading the file chunk. A simple test for the presence of this property can be done when listening for the event, and the read_result can be stored in a new Blob in memory or elsewhere (such as IndexedDB).

If it’s the final chunk, then we trigger a FILE_READ_FINISHED as usual, and return the final chunk in the read_result in that event. In the listener for this event, we can finish assembling the chunked file, and do what we want with it at this point.

Use

I mentioned at the beginning we’d discuss how to actually call pause or resume on your element and the associated file processor(s). This is very simple. First, you’d attach a HUp instance to an element by calling it like so:

$('#hupInput').hup({options});

Then, you can get back the HUp instance by referencing that same element and looking for ‘hup’ in the data attached to the Node. You can also save a reference to this instance – it will never change within the lifetime of the Node, so it’s safe to save.

var aHupInstance = $('#hupInput').data('hup');

Then, you could call aHupInstance.pause() or aHupInstance.resume(). You could also skip saving a reference and address it directly:

=$('#hupInput').data('hup').pause([1, 'file_name.ext']);

Plugin

Thanks again to RLK for noticing that pause and resume weren’t really there yet! The updated plugin code is below – for details on use see the Github repository.

/**
 * Copyright (c) 2013 Christopher Keefer. All Rights Reserved.
 * See https://github.com/SaneMethod/HUp/
 * jQuery plugin for reading in files or uploading them with the HTML5 file api and xhr2.
 */
"use strict";
(function($){
    var filters = {},
        fileTypes = [];
    /**
     * Populate the filters and fileTypes object and array, with the former containing a mapping between
     * file extensions and their mime types, and the latter the mimetypes themselves.
     */
    (function(mimetypes){
        var mimes = mimetypes.split(/,/),
            exts = [];

        for (var i=0, len=mimes.length; i < len; i+=2)
        {
            fileTypes.push(mimes[i]);
            exts = mimes[i+1].split(/ /);
            for (var j=0, jlen = exts.length; j < jlen; j++)
            {
                filters[exts[j]] = mimes[i];
            }
        }
    })(
            "application/msword,doc dot,application/pdf,pdf,application/pgp-signature,pgp,application/postscript," +
            "ps ai eps,application/rtf,rtf,application/vnd.ms-excel,xls xlb,application/vnd.ms-powerpoint," +
            "ppt pps pot,application/zip,zip,application/x-shockwave-flash,swf swfl,application/x-javascript,js," +
            "application/json,json,audio/mpeg,mpga mpega mp2 mp3,audio/x-wav,wav,audio/mp4,m4a,image/bmp,bmp," +
            "image/gif,gif,image/jpeg,jpeg jpg jpe,image/photoshop,psd,image/png,png,image/svg+xml,svg svgz," +
            "image/tiff,tiff tif,text/plain,asc txt text diff log,text/html,htm html xhtml,text/css,css,text/csv," +
            "csv,text/rtf,rtf,video/mpeg,mpeg mpg mpe m2v,video/quicktime,qt mov,video/mp4,mp4,video/x-m4v,m4v," +
            "video/x-flv,flv,video/x-ms-wmv,wmv,video/avi,avi,video/webm,webm,video/3gpp,3gp,video/3gpp2,3g2," +
            "application/octet-stream,exe"
        );
    /**
     * Construct html5 reader/uploader.
     * @param {Object} options
     * @constructor
     */
    function Hup(options){
        this.init(options);
    }

    /**
     * Set options, listen for events on input element that indicate we should read/upload selected file(s).
     * @param options
     */
    Hup.prototype.init = function(options)
    {
        var that = this;

        this.options = $.extend({
            accept:[], // A string or array of extensions or mime-types to accept for reading/uploading
            async:true, // Whether to send file(s) asynchronously
            chunked:true, // Whether to send or read the file(s) in chunks
            chunk_size:1048576, // Size of each chunk (default 1024*1024, 1 MiB)
            input:'', // Input element - this is set automatically when using HUp in its jQuery plugin form.
            make_dnd:false, // Whether to make the input element handle drag and drop - auto-true if not file input
            max_file_size:0,// Max file size - 0 means no max size
            read_method:'readAsDataURL', // the read method to use for reading in the file(s) - one of
            // readAsText, readAsBinaryString, readAsDataURL or readAsArrayBuffer
            type:'PUT', // Type of request to use for uploading
            url:false // Url endpoint to send file to - if not specified or false, we read and return the file(s)
        }, options);

        this.input = $(this.options.input);
        this.options.accept = this.acceptFilters(this.options.accept);

        if (this.options.make_dnd || !this.isFileInput(this.input))
        {
            this.options.make_dnd = true;
            this.input.off('dragover').on('dragover', function(event){
                event = event.originalEvent;
                that.handleDragover(event);
            });
        }
        this.input.off('drop change').on('drop change', function(event){
            event = event.originalEvent;
            that.handleSelect(event);
        });
    };

    /**
     * Translate the accept string or array into an array of mime types, based on the mime types in filters.
     * Input should look like the expected extensions:
     * "swf, wmv, mp4" or ['swf', 'wmv', 'mp4']
     * Or like mime type categories, or the mime types themselves:
     * "application/*, application/pdf" or ['image/*', 'plain/text']
     * @param accept
     */
    Hup.prototype.acceptFilters = function(accept){
        var mimes = [],
            mime,
            fileType;

        // Ensure accept is an array of extensions or mime types
        if (typeof accept === 'string' || accept instanceof String)
        {
            accept = accept.split(/,/);
        }
        for (var i=0, len = accept.length; i < len; i++)
        {
            mime = accept[i].trim().split(/\//);
            if (mime.length > 1)
            {
                if (mime[1] === '*')
                {
                    // Every mime-type that begins with mime[0] now needs to be pushed into the mimes array
                    for (var j=0, jlen = fileTypes.length; j < jlen; j++)
                    {
                        fileType = fileTypes[j].split(/\//);
                        if (mime[0] === fileType[0]) mimes.push(fileTypes[j]);
                    }
                } else {
                    // Pass the mime type through unmolested
                    mimes.push(mime.join('/'));
                }
            } else {
                // Only an extension has been specified - map to the mime type
                if (mime[0] in filters) mimes.push(filters[mime[0]]);
            }
        }
        return mimes;
    };

    /**
     * Return whether the passed element is an input of type file.
     * @param input Element to check.
     * @returns {boolean}
     */
    Hup.prototype.isFileInput = function(input){
        return (input[0].tagName === 'INPUT' && /file/i.test(input[0].getAttribute('type')));
    };

    /**
     * Handle the dragging of file(s) to a target, preventing the rejection of the dragover.
     * @param event
     */
    Hup.prototype.handleDragover = function(event){
        event.preventDefault();
        event.stopPropagation();
        event.dataTransfer.dropEffect = 'copy';
    };

    /**
     * Handle the selection of files to upload via an input or drag and drop to a target.
     * @param event
     */
    Hup.prototype.handleSelect = function(event){
        var files;

        if (this.options.make_dnd)
        {
            event.preventDefault();
            event.stopPropagation();
            files = event.dataTransfer.files;
        }
        else
        {
            files = event.target.files;
        }
        if (!files.length)
        {
            this.input.trigger(Hup.state.FILE_LIST_ERROR, {
                state:Hup.state.FILE_LIST_ERROR,
                error:'No files found in file list; no files were selected.'
            });
            return;
        }
        this.input.trigger(Hup.state.FILE_LIST_LOADED, {state:Hup.state.FILE_LIST_LOADED, files:files});

        this.processFiles(files, this.options.url);
    };

    /**
     * Process the files in the fileList, uploading them if a url is specified, otherwise reading them into
     * memory and passing them on to be used in the browser.
     * @param files
     * @param upload
     */
    Hup.prototype.processFiles = function(files, upload){
        var that = this,
            processed = 0,
            accept = this.options.accept,
            accepted = false,
            maxSize = this.options.max_file_size,
            fprocess;

        this.fprocessors = [];

        for (var i=0, len = files.length; i < len; i++)
        {
            // Check file against mime accept restrictions if any restrictions are set
            if (accept.length)
            {
                accepted = false;
                for (var j=0, jlen = accept.length; j < jlen; j++)
                {
                    accepted = (files[i].type === accept[j]);
                    if (accepted) break;
                }
                if (!accepted)
                {
                    this.input.trigger(Hup.state.FILE_TYPE_ERROR, {
                        state:Hup.state.FILE_TYPE_ERROR,
                        file_name:files[i].name,
                        error:'File type is '+files[i].type+', accepted types are '+accept.join(',')+'.'
                    });
                    continue;
                }
            }
            // Check file against size restrictions
            if (maxSize && files[i].size > maxSize)
            {
                this.input.trigger(Hup.state.FILE_SIZE_ERROR, {
                    state:Hup.state.FILE_SIZE_ERROR,
                    file_name:files[i].name,
                    error:'File size is '+files[i].size+', max file size is '+maxSize+'.'
                });
                continue;
            }
            // Create new DeferXhr or DeferReader and listen on its progression and completion to fire the appropriate
            // events for interested listeners on our input
            fprocess = (upload) ? new DeferXhr(this.options, files[i]) :
                new DeferReader(this.options, files[i]);

            fprocess.promise.progress(function(progress){
                that.input.trigger(progress.state, progress);
            }).done(function(res){
                that.input.trigger(res.state, res);
                processed++;
                if (processed >= len)
                {
                    if (upload)
                    {
                        that.input.trigger(Hup.state.FILE_UPLOAD_ALL, {state:Hup.state.FILE_UPLOAD_ALL, files:len});
                        return;
                    }
                    that.input.trigger(Hup.state.FILE_READ_ALL, {state:Hup.state.FILE_READ_ALL, files:len});
                }
            }).fail(function(res)
            {
                that.input.trigger(res.state, res);
            });
            this.fprocessors.push(fprocess);
        }
    };

    /**
     * Pause any in progress, chunked uploads/file reads. If pauseList is specified,
     * elements should be either the names of the files or the index in which they were returned in the files
     * list returned from the FILE_LIST_LOADED event. Can provide only a single string or number of only a single
     * upload/read needs to be paused.
     * @param {Array|number|string|boolean|undefined} pauseList
     */
    Hup.prototype.pause = function(pauseList){
        pauseList = (!pauseList) ? false : Array.isArray(pauseList) ? pauseList : [pauseList];

        this.fprocessors.forEach(function(fprocess, idx){
            if (!pauseList)
            {
                fprocess.pause();
                return;
            }
            if (pauseList.indexOf(idx) !== -1 || pauseList.indexOf(fprocess.file.name) !== -1)
            {
                fprocess.pause();
            }
        });
    };

    /**
     * Resume any in progress, paused, chunked uploads/file reads, following the same rules for pauseList as
     * specified for pause.
     * @see Hup.prototype.pause
     * @param {Array|number|string|boolean|undefined} pauseList
     */
    Hup.prototype.resume = function(pauseList){
        pauseList = (!pauseList) ? false : Array.isArray(pauseList) ? pauseList : [pauseList];

        this.fprocessors.forEach(function(fprocess, idx){
            if (!pauseList)
            {
                fprocess.resume();
                return;
            }
            if (pauseList.indexOf(idx) !== -1 || pauseList.indexOf(fprocess.file.name) !== -1)
            {
                fprocess.resume();
            }
        });
    };

    /**
     * Custom events we'll trigger on our input element at the appropriate times.
     * @type {{FILE_LIST_ERROR: string, FILE_LIST_LOADED: string, FILE_TYPE_ERROR: string, FILE_SIZE_ERROR: string,
     * FILE_READ_ERROR: string, FILE_READ_PROGRESS: string, FILE_READ_FINISHED: string, FILE_READ_ALL: string,
     * FILE_UPLOAD_ERROR: string, FILE_UPLOAD_PROGRESS: string, FILE_UPLOAD_PAUSE: string,
     * FILE_UPLOAD_RESUME: string, FILE_UPLOAD_FINISHED: string, FILE_UPLOAD_ALL: string}}
     */
    Hup.state = {
        FILE_LIST_ERROR:'fileListError',
        FILE_LIST_LOADED:'fileListLoaded',
        FILE_TYPE_ERROR:'fileTypeError',
        FILE_SIZE_ERROR:'fileSizeError',
        FILE_READ_ERROR:'fileReadError',
        FILE_READ_PROGRESS:'fileReadProgress',
        FILE_READ_FINISHED:'fileReadFinished',
        FILE_READ_PAUSE:'fileReadPause',
        FILE_READ_RESUME:'fileReadResume',
        FILE_READ_ALL:'fileReadAll',
        FILE_UPLOAD_ERROR:'fileUploadError',
        FILE_UPLOAD_PROGRESS:'fileUploadProgress',
        FILE_UPLOAD_PAUSE:'fileUploadPause',
        FILE_UPLOAD_RESUME:'fileUploadResume',
        FILE_UPLOAD_FINISHED:'fileUploadFinished',
        FILE_UPLOAD_ALL:'fileUploadAll'
    };

    /**
     * Deferred wrapper for xhr upload.
     * @param options
     * @param file
     * @returns {Object} promise The deferred promise object.
     * @constructor
     */
    function DeferXhr(options, file){
        this.defer = $.Deferred();
        this.promise = this.defer.promise();
        this.file = file;
        this.options = options;
        this.paused = false;
        this.progress = 0;
        this.time = {start:0, end:0, speed:0}; // Speed is measured in bytes per second
        this.xhr = new XMLHttpRequest();

        if (this.options.chunked)
        {
            this.start = 0;
            this.end = Math.min(this.start+this.options.chunk_size, this.file.size);
        }

        this.xhr.addEventListener('load', this.complete.bind(this), false);
        this.xhr.upload.addEventListener('progress', this.uploadProgress.bind(this), false);
        this.xhr.upload.addEventListener('error', this.uploadError.bind(this), false);

        this.upload();

        return this;
    }

    /**
     * Carry out the xhr upload, optionally chunked.
     */
    DeferXhr.prototype.upload = function(){
        this.time.start = +new Date();

        this.xhr.open(this.options.type, this.options.url, this.options.async);
        this.xhr.setRequestHeader('Accept', 'application/json');
        this.xhr.setRequestHeader('X-File-Name', this.file.name);
        this.xhr.setRequestHeader('X-File-Type', this.file.type);

        if (this.options.chunked)
        {
            this.xhr.overrideMimeType('application/octet-stream');
            this.xhr.setRequestHeader('Content-Range', 'bytes '+this.start+"-"+this.end+"/"+this.file.size);
            this.xhr.send(this.file.slice(this.start, this.end));
        }
        else
        {
            this.xhr.overrideMimeType((this.file.type || 'application/octet-stream'));
            this.xhr.send(this.file);
        }
    };

    /**
     * Report on the upload progress, as a number between 0 and 1, modifying the progress if we're uploading a
     * file in chunks to report on the progress as a percentage of file upload and total chunks uploaded.
     * @param event
     */
    DeferXhr.prototype.uploadProgress = function(event){
        if (event.lengthComputable)
        {
            this.progress = (event.loaded/event.total);
            if (this.options.chunked)
            {
                this.progress *= (this.end/this.file.size);
            }
            this.time.end = +new Date();
            this.time.speed = (this.file.size*this.progress)/(this.time.end-this.time.start)*1000;
            console.log('time:', this.time.end-this.time.start, 'speed:', this.time.speed, 'progress:', this.progress);
            this.defer.notify({state:Hup.state.FILE_UPLOAD_PROGRESS, file_name:this.file.name, speed:this.time.speed,
                progress:this.progress});
        }
    };

    /**
     * Call reject on the defer for this DeferXhr object, passing the details back to any subscribed event listeners.
     * @param event
     */
    DeferXhr.prototype.uploadError = function(event){
        this.defer.reject({state:Hup.state.FILE_UPLOAD_ERROR, file_name:this.file.name, error:event});
    };

    /**
     * Called when we've completed an upload (full file or chunk). If full file, or we've reached the last chunk,
     * the upload is complete. Otherwise, we calculate the next chunk offsets and, if the upload isn't paused,
     * upload it.
     */
    DeferXhr.prototype.complete = function(){
        this.time.end = +new Date();
        if (!this.options.chunked || this.end == this.file.size)
        {
            this.uploadComplete();
            return;
        }

        this.defer.notify({state:Hup.state.FILE_UPLOAD_PROGRESS, file_name:this.file.name,
            response:this.parseResponse(), progress:this.progress});

        this.start = this.end;
        this.end = Math.min(this.start+this.options.chunk_size, this.file.size);

        if (!this.paused)
        {
            this.upload();
        }
    };

    /**
     * Called when the full file has been uploaded.
     */
    DeferXhr.prototype.uploadComplete = function(){
        this.defer.resolve({state:Hup.state.FILE_UPLOAD_FINISHED, file_name:this.file.name,
            file_size:this.file.size, file_type:this.file.type,
            response:this.parseResponse()});
    };

    /**
     * Try to parse the response as a JSON, and on failure return the error and the plaintext.
     * @returns {Object}
     */
    DeferXhr.prototype.parseResponse = function()
    {
        var response;
        try{
            response = JSON.parse(this.xhr.responseText);
        }catch(e){
            response = {error:e, text:this.xhr.responseText};
        }
        return response;
    };

    /**
     * Pause the upload - that is, after the current chunk is finished uploading, cease uploading chunks until
     * resume is called. For obvious reasons, this only works with chunked uploads.
     * If the state of the deferred object is not pending (that is, is either already resolved or rejected),
     * return early - we won't attempt to pause an upload that's finished or failed.
     */
    DeferXhr.prototype.pause = function(){
        if (this.defer.state() !== 'pending' || !this.options.chunked) return;
        this.paused = true;
        this.defer.notify({
            state:Hup.state.FILE_UPLOAD_PAUSE, file_name:this.file.name,
            current_range:{start:this.start, end:this.end, total:this.file.size}
        });
    };

    /**
     * Resume the upload if paused (works for chunked uploads only).
     */
    DeferXhr.prototype.resume = function(){
        if (this.options.chunked && this.paused)
        {
            this.paused = false;
            this.defer.notify({
                state:Hup.state.FILE_UPLOAD_RESUME, file_name:this.file.name,
                current_range:{start:this.start, end:this.end, total:this.file.size}
            });
            this.upload();
        }
    };

    /**
     * Deferred wrapper for file reader.
     * @param {Object} options
     * @param {File|Blob} file
     * @returns {Object} promise The Deferred promise object
     * @constructor
     */
    function DeferReader(options, file){
        this.options = options;
        this.defer = $.Deferred();
        this.promise = this.defer.promise();
        this.reader = new FileReader();
        this.file = file;
        this.read_method = this.options.read_method;
        this.paused = false;
        this.progress = 0;

        if (this.options.chunked)
        {
            this.start = 0;
            this.end = Math.min(this.start+this.options.chunk_size, this.file.size);
        }

        this.listen();
        this.readFile();

        return this;
    }

    /**
     * Read the entire file or a slice thereof, depending on the value of options.chunked and chunk_size.
     */
    DeferReader.prototype.readFile = function(){
        if (this.options.chunked)
        {
            this.reader[this.read_method](this.file.slice(this.start, this.end));
            return;
        }
        this.reader[this.read_method](this.file);
    };

    /**
     * Report on the file read progress, as a number between 0 and 1, modifying the progress if we're reading a
     * file in chunks to ensure that we're reporting the total percentage of the file read, not just the percentage
     * of the current chunk read (see also readComplete).
     * @param event
     */
    DeferReader.prototype.readProgress = function(event){
        var progress = this.progress;

        if (event.lengthComputable)
        {
            progress = event.loaded/event.total;
            if (this.options.chunked)
            {
                progress *= (this.end/this.file.size);
            }
            this.defer.notify({state:Hup.state.FILE_READ_PROGRESS, file_name:this.file.name, progress:progress});
        }
        this.progress = progress;
    };

    /**
     * On read completion, if we're reading in chunks, if we've reached the last chunk, report on file read completion.
     * If there are remaining chunks, report on progress and read the next chunk.
     * Otherwise if we're reading the entire file in one go, report on file read completion.
     * @param event
     */
    DeferReader.prototype.readComplete = function(event){
        if (event.target.readyState == FileReader.DONE && (!this.options.chunked || this.end == this.file.size))
        {
            this.defer.resolve({
                state:Hup.state.FILE_READ_FINISHED, file_name:this.file.name, file_size:this.file.size,
                file_type:this.file.type, read_method:this.read_method, read_result:event.target.result
            });
            return;
        }

        this.defer.notify({
            state:Hup.state.FILE_READ_PROGRESS, file_name:this.file.name, progress:this.progress,
            read_result:(event.target.readyState == FileReader.DONE) ? event.target.result : void 0
        });

        this.start = this.end;
        this.end = Math.min(this.start+this.options.chunk_size, this.file.size);

        if (!this.paused)
        {
            this.readFile();
        }
    };

    /**
     * On file read error, attempt to create a meaningful error string, and return alongside the error code, reader
     * state and the name of the file on which this error occurred.
     * @param event
     */
    DeferReader.prototype.readError = function(event){
        var err = event.target.error,
                errCode = event.target.error.code,
                errMsg = 'Error attempting to read file "'+this.file.name+'": ';

            switch(errCode)
            {
                case err.NOT_FOUND_ERR:
                    errMsg += "File could not be found.";
                    break;
                case err.NOT_READABLE_ERR:
                    errMsg += "File is not readable.";
                    break;
                case err.ABORT_ERR:
                    errMsg += "File read was aborted.";
                    break;
                default:
                    errMsg += "An unexpected error occurred.";
                    break;
            }
            this.defer.reject({state:Hup.state.FILE_READ_ERROR, file_name:this.file.name, error:errMsg, code:errCode});
    };

    /**
     * Listen for the various events of interest on the file reader, and return notification or resolution
     * to deferred as appropriate.
     */
    DeferReader.prototype.listen = function(){
        this.reader.addEventListener('error', this.readError.bind(this), false);

        this.reader.addEventListener('progress', this.readProgress.bind(this), false);

        this.reader.addEventListener('loadend', this.readComplete.bind(this), false);
    };

    /**
     * Pause this file reader if chunked by ceasing to read the file in after the current chunk is completed.
     * If the defer has already been resolved or rejected, we return, making no attempt to pause a file
     * read that has already finished or been rejected.
     */
    DeferReader.prototype.pause = function(){
        if (this.defer.state() !== 'pending' || !this.options.chunked) return;
        this.paused = true;
        this.defer.notify({
            state:Hup.state.FILE_READ_PAUSE, file_name:this.file.name,
            current_range:{start:this.start, end:this.end, total:this.file.size}});
    };

    /**
     * Resume this file reader from the next chunk if it was previously paused and chunked.
     */
    DeferReader.prototype.resume = function(){
        if (this.options.chunked && this.paused)
        {
            this.paused = false;
            this.defer.notify({
                state:Hup.state.FILE_READ_RESUME, file_name:this.file.name,
                current_range:{start:this.start, end:this.end, total:this.file.size}
            });
            this.readFile();
        }
    };

    /**
     * Entry point for calling the reader/uploader, with the element to be used as input specified.
     * Usage:
     * $('#input').hup({options}).on('events') --OR--
     * $('.inputs').hup({options}).on('events')
     * @param options
     * @returns {Object} jQuery object reference for the given elements.
     */
    $.fn.hup = function(options){
        options = options || {};
        return this.each(function(){
            options.input = this;
            var $this = $(this),
                hup = $this.data('hup');
            if (!hup)
            {
                $this.data('hup', new Hup(options));
            }
            else if (hup instanceof Hup)
            {
                hup.init(options);
            }
        });
    };
})(jQuery);

Write Once, Debug Everywhere

html5 logo

Or Why We Still Have To Test In Every Browser, Web Standards Notwithstanding

It’s pretty seldom that anyone mentions web pages these days, other than in historical reference to days long gone by (yes, a whole few years ago). Web sites, sure, but not if what is really wanted is to replace something that, not so long ago, would have been some native code for a smartphone (or a little further back still, a desktop computer). Generally speaking, the most common term tripping from client’s lips these days is ‘web applications’ – or webapps, because who has time for spaces and proper spelling, amirite?

Of course, the client in question is almost certainly not interested in a webapp because they’re hoping to take advantage of the unique properties and capabilities of any particular browser (unless they’re looking for a line-of-business intranet application in IE8, in which case, you have both my scorn and my pity). Everyone wants their app to have the potential to reach the widest audience possible – and that means supporting all the mainstream browsers.

Oh, and all of the mainstream mobile devices, and their browsers; and maybe smart TVs, consoles, and I hear they’re coming out with, like, iWatches and stuff, can we target those too? (more…)

Semantic HTML

Software screen capture

The above diagram shows two ways to place a grid on an HTML page. The <TABLE> version on the left is the old school way of managing layout. The web was positively littered with such code before widespread use of CSS (and browser manufacturer adoption of standards), which freed designers from use of tables or framesets for managing layout. The <DIV> version on the right is a sample of modern accepted practice, specifically in this example, using Bootstrap 3 styling.

You will find no one suggesting using tables for HTML layout (except when it comes to formatting HTML email. It’s ugly out there) today. Many a rant exists on the web exhorting all to separate presentation from structure, yet aren’t the two examples shockingly similar? Can the <DIV> version be that much better, when it looks like a one-for-one mapping of one element to another?

To answer the questions asked in the image, yes, the HTML on the left is bad layout and the sample on the right is OK. The reasons behind the answers come with an understanding of semantic HTML.

(more…)

Recording Audio & Video with HTML5 (co-starring Meteor)

Image of sign, Filming in ProgressA few weeks ago I got a germ of an idea in my head for a personal web-application that required recording and playing video, something with which I have had very little experience. I have seen how effortless is it to play video with HTML5 so I thought this would be simple. After searching countless sites looking for the HTML5 magic bullet for recording both audio & video, I had pretty much given up.

If you have stumbled upon this article also looking for a way to record audio & video together, you can stop searching now. I can say with fairly strong confidence that such a mechanism does not yet exist (as of the publish date of this article). However, I believe I have a workable solution for the time being.

The full source for the example application is on github.

(more…)

Realtime Video Effects in the Browser? Seriously?

This just came through the wire — it works great for me here (Chrome 29 on OS X) but I’ve heard of other browsers (or perhaps video hardware) having some issues with it.

Seriously.js is a real-time, node-based video compositor for the web. Inspired by professional software such as After Effects and Nuke, Seriously.js renders high-quality video effects, but allows them to be dynamic and interactive.

The site at seriouslyjs.org plays a music video by OK Go (who I think are now required by law to be involved with any cool new video on the internet stuff) where the green screen background can be replaced live with four different video effects inside the browser:

Seriously3

(more…)