Source: player.js

/**
 * @typedef {Object} HT5AP_classes - Classes added to the default elements when rendering the template.
 *                                   These classes will be added additionally to the default classes, not instead of them.
 * @property {string} [currentlyPlaying] - Class added to the elment containing the name of the currently playing song
 * @property {string} [audioController] - Class added to the wrapper element wrapping around the controls
 * @property {string} [volumeKnob] - Class added to the svg of the volume knob
 * @property {string} [timeKnob] - Class added to the svg of the time knob
 * @property {string} [prev] - Class added to the previous button
 * @property {string} [next] - Class added to the next button
 * @property {string} [playpause] - Class added to to the play/pause button
 * @property {string} [playing] - Class added to the play/pause button (only in playing state)
 * @property {string} [paused] - Class added to the play/pause button (only in paused state)
 * @property {string} [playlist] - Class added to the playlist wrapper
 * @property {string} [song] - Class added to the currently active song
 * @property {string} [songActive] - Class added to the currently active song
 */

/**
 * @typedef {Object} HT5AP_settings - Settings for the HTML5audioplayer. These have to be supplied to the
 *                                    constructor {@link HTML5audioplayer}
 * @property {Node}   element          - the audio element to use for playing the sounds
 * @property {boolean} [debug = false] - Enables the debug mode when set to true. If the debug mode is set to true,
 *                                       debug messages are printed to the console and the HTML5 audio element is
 *                                       shown with controls
 * @property {HT5AP_classes} [classes]
 * @property {string} [themeColor = '#ee0060'] - Theme color to use for the audioplayer
 * @property {string} [secondaryColor = themeColor] - Secondary color, used for hover
 */

/**
 * @typedef {Object} HT5AP_song - Representation of a song used in the HTML5audioplayer. This is used for
 *                                {@link HTML5audioplayer#addSong}
 * @property {string} name   - Name of the song
 * @property {string} [mp3]  - Path to the mp3 file
 * @property {string} [ogg]  - Path to the ogg file
 * @property {HTMLElement} element
 */

/**
 * HTML5 Audio player by NIMIUS
 * Renders a audioplayer with circular progress and volume knobs.
 *
 * @example
 * var player = new HTML5audioplayer({
 *      element: '#wrapper'
 * });
 * // note: this will replace the contents of #wrapper
 *
 * player.addSong({ name: 'Testsong', mp3: '/path/song.mp3', ogg: '/path/song.ogg' })
 * player.play();
 *
 * @param {HT5AP_settings} settings
 * @constructor
 */
function HTML5audioplayer(settings) {
    this._settings = this._prepareSettings(settings);
    this._songs = [];

    this._ident = Math.random().toString(26).substr(2,7);
    this._settings.element.setAttribute('html5audioplayer', this._ident);

    this._buildCss();
    this._buildDom();
    this._bindTimeUpdate(true);

    // set initial value for knobs and volume
    this.setVolume(.5);
    this._knobs.time.update(0);

    var self = this;
    this._buttons.play.addEventListener('click', function() {
        self.toggleplay();
    });
    this._buttons.next.addEventListener('click', function() {
        self.next();
    });
    this._buttons.prev.addEventListener('click', function() {
        self.prev();
    });
    this._knobs.volume.element.onchange = function() {
        self.setVolume(this.value);
    };
    this._knobs.time.element.onchange = function() {
        self.setTime(parseFloat(this.value) * self.player.duration);
    };
    this.player.addEventListener('ended', function() {
        self.next();
    });
}

/**
 * Identifier used for building selectors
 * @type {string}
 * @private
 */
HTML5audioplayer.prototype._ident = null;

/**
 * The settings object containing the settings that the user passed to the constructor
 *
 * @type {HT5AP_settings}
 * @private
 */
HTML5audioplayer.prototype._settings = null;

/**
 * The HTML5 audio element that is used to play the audio
 *
 * @type {HTMLElement}
 */
HTML5audioplayer.prototype.player = null;

/**
 * The buttons used for navigation in the player
 *
 * @type {Object}
 * @property {HTMLElement} next - button that is used to skip to the next song
 * @property {HTMLElement} prev - button that is used to go back to the last song
 * @property {HTMLElement} play - button that is used to toggle the play state
 * @private
 */
HTML5audioplayer.prototype._buttons = null;

/**
 * Currently playing song (index in songs array)
 *
 * @type {number}
 * @private
 */
HTML5audioplayer.prototype._position = 0;

/**
 * The HTML element containing the name of the currently playing song
 *
 * @type {HTMLElement}
 * @private
 */
HTML5audioplayer.prototype._currentlyPlaying = null;

/**
 * Array of songs that are to be played
 *
 * @type {HT5AP_song[]}
 * @private
 */
HTML5audioplayer.prototype._songs = null;

/**
 * Whether or not the player is currently playing
 *
 * @type {boolean}
 */
HTML5audioplayer.prototype.playing = false;

/**
 * @type {Object}
 * @property {HT5AP_Knob} volume - the small volume knob
 * @property {HT5AP_Knob} time   - the larger timerunner knob
 * @private
 */
HTML5audioplayer.prototype._knobs = null;

/**
 * Prepares the setting for effective use in the player.
 * Applies default and does type conversion
 *
 * @param {HT5AP_settings} settings
 * @returns {HT5AP_settings}
 * @private
 */
HTML5audioplayer.prototype._prepareSettings = function(settings) {
    settings = settings || {};
    var DEFAULTS = {
        element: document.querySelector('.html5audioplayer'),
        debug: false,
        themeColor: '#ee0060',
        classes: {
            currentlyPlaying: '',
            audioController: '',
            volumeKnob: '',
            timeKnob: '',
            prev: '',
            next: '',
            playpause: '',
            playing: '',
            paused: '',
            playlist: '',
            song: '',
            songActive: '',
        }
    };

    for (var key in DEFAULTS) {
        if (settings[key] === undefined) {
            settings[key] = DEFAULTS[key];
        }
    }
    for (var key in DEFAULTS.classes) {
        if (settings.classes[key] === undefined) {
            settings.classes[key] = DEFAULTS.classes[key];
        }
    }

    if (typeof settings.element === 'string') {
        settings.element = document.querySelector(settings.element);
    }

    // we want to ignore emptystrings here
    settings.themeColor = settings.themeColor || DEFAULTS.themeColor;
    settings.secondaryColor = settings.secondaryColor || settings.themeColor;

    return settings;
};

/**
 * Builds and applies the css for this player
 *
 * @private
 */
HTML5audioplayer.prototype._buildCss = function() {
    var pre = '[html5audioplayer="' + this._ident + '"]';
    var styles = {
        '.HT5AP__knob path:nth-child(2)': {
            fill: this._settings.themeColor
        },
        '#HT5AP-{unique} .HT5AP__knob:hover path:nth-child(2)': {
            fill: this._settings.secondaryColor
        },
        '.HT5AP__control--play--paused::before': {
            color: this._settings.themeColor
        },
        '.HT5AP__button:hover': {
            color: this._settings.themeColor,
            'border-color': this._settings.themeColor
        },
        '.HT5AP__song--active': {
            color: this._settings.themeColor
        },
        '.HT5AP__song:hover': {
            color: this._settings.themeColor
        }
    }
    var style = '';

    for (var selector in styles) {
        style += pre + ' ' + selector + ' {\n';
        for (var a in styles[selector]) {
            style += '\t' + a + ': ' + styles[selector][a] + ';\n';
        }
        style += '}\n\n';
    }

    var styleEl = document.createElement('style');
    styleEl.innerHTML = style;
    document.head.appendChild(styleEl);
};


/**
 * Simple templating used for applying the classes to the HTML
 * Uses mustache style double curly braces as template markers {{ marker }}
 *
 * @param {string} template
 * @param {Object} assignments
 * @returns {string}
 * @private
 */
HTML5audioplayer.prototype._template = function(template, assignments) {
    for (var name in assignments) {
        var regex = new RegExp('{{ ?' + name + ' ?}}', 'g');
        template = template.replace(regex, assignments[name]);
    }
    return template;
}

/**
 * Inserts the DOM that is needed in order to display the player
 *
 * @private
 */
HTML5audioplayer.prototype._buildDom = function() {
    // TODO class prefix
    var html = '' +
        '<div class="HT5AP">' +
        '   <audio ' + (this._settings.debug ? 'controls' : '') + '></audio>' +
        '   <p class="HT5AP__currentlyPlaying {{ currentlyPlaying }}"></p>' +
        '   <ul class="HT5AP__controls {{ audioController }}">' +
        '      <li class="HT5AP__control">' +
        '          <div class="HT5AP__control knobs">' +
        '              <input ' +
        '                  class="HT5AP__knob--volume--input" type="range"' +
        '                  min="0" max="1" step=".001" ' +
        '                  data-width="200" data-height="200" ' +
        '                  data-angleOffset="0" data-angleRange="359.99999"' +
        '                  data-class="HT5AP__knob HT5AP__knob--volume {{ volumeKnob }}">' +
        '              <input ' +
        '                  class="HT5AP__knob--time--input" type="range" value="0"' +
        '                  min="0" max="1" step=".001" ' +
        '                  data-width="100" data-height="100" ' +
        '                  data-angleOffset="0" data-angleRange="359.99999"' +
        '                  data-class="HT5AP__knob HT5AP__knob--time {{ timeKnob }}">' +
        '          </div>' +
        '      </li>' +
        '      <li class="HT5AP__control HT5AP__button HT5AP__button--prev {{ prev }}"></li>' +
        '      <li class="HT5AP__control HT5AP__control--play HT5AP__control--play--paused {{ playpause }} {{ paused }}"></li>' +
        '      <li class="HT5AP__control HT5AP__button HT5AP__button--next {{ next }}"></li>' +
        '   </ul>' +
        '   <ul class="HT5AP__playlist {{ playlist }}">' +
        '       ' +
        '   </ul>' +
        '</div>';
    html = this._template(html, this._settings.classes);
    this._settings.element.innerHTML = html;
    this.player = this._settings.element.querySelector('audio');
    this._buttons = {
        next: this._settings.element.querySelector('.HT5AP__button.HT5AP__button--next'),
        prev: this._settings.element.querySelector('.HT5AP__button.HT5AP__button--prev'),
        play: this._settings.element.querySelector('.HT5AP__control--play'),
    };

    this._currentlyPlaying = this._settings.element.querySelector('.HT5AP__currentlyPlaying');
    this._knobs = {
        volume: new HT5AP_Knob({
            element: this._settings.element.querySelector('.HT5AP__knob--time--input')
        }),
        time: new HT5AP_Knob({
            element: this._settings.element.querySelector('.HT5AP__knob--volume--input')
        })
    }

    this._log('_buildDom', this._settings.element);
    this._log('_buildDom', 'knobs', this._knobs);
    this._log('_buildDom', 'buttons', this._buttons);
};

/**
 * adds a song to the player.
 * The song is added to the end of the playlist and the id of the newly added song is returned.
 *
 * @return {number} - id of the new song for usage with {@link HTML5audioplayer#setCurrentSong}
 * @param {HT5AP_song} song
 */
HTML5audioplayer.prototype.addSong = function(song) {
    this._log('addSong', song, this._songs);
    var container = this._settings.element.querySelector('.HT5AP__playlist');

    var li = document.createElement('li');
    var span = document.createElement('span');
    span.classList.add('HT5AP__song');
    span.innerHTML = song.name;

    li.appendChild(span);
    container.appendChild(li);

    song.element = span;
    var id = this._songs.length;
    this._songs.push(song);

    // if this was the first song, we load it
    if (this._songs.length === 1) {
        this.setCurrentSong(0);
    }

    // event listener for click on playlist
    var self = this;
    song.element.addEventListener('click', function() {
        self._position = id;
        self.setCurrentSong(id);
    });

    return id;
};

/**
 * Sets the current song that the player is playing.
 * The given parameter is the id in the songs array
 *
 * @param {number} id
 */
HTML5audioplayer.prototype.setCurrentSong = function(id) {
    var song = this._songs[id];
    this._log('setCurrentSong', id, song);

    while (this.player.firstChild) {
        this.player.removeChild(this.player.firstChild);
    }

    if (song.mp3) {
        var mp3 = document.createElement('source');
        mp3.setAttribute('src', song.mp3);
        mp3.setAttribute('type', 'audio/mpeg');
        mp3.setAttribute('preload', 'auto');
        this.player.appendChild(mp3);
    }
    if (song.ogg) {
        var ogg = document.createElement('source');
        ogg.setAttribute('src', song.ogg);
        ogg.setAttribute('type', 'audio/ogg');
        ogg.setAttribute('preload', 'auto');
        this.player.appendChild(ogg);
    }

    this._currentlyPlaying.innerHTML = song.name;
    this.player.load();

    this._songs.forEach(function(song) {
        song.element.classList.remove('HT5AP__song--active');
    });
    song.element.classList.add('HT5AP__song--active');

    this._log('setCurrentSong', this.player);

    // continue playback
    if (this.playing) {
        this.play();
    }
};

/**
 * Start playback of the player
 *
 */
HTML5audioplayer.prototype.play = function() {
    this._log('play');
    this.player.play();
    this.playing = true;
    this._buttons.play.classList.remove('HT5AP__control--play--paused');
    this._buttons.play.classList.add('HT5AP__control--play--playing');
    if (this._settings.classes.paused) {
        this._buttons.play.classList.remove(this._settings.classes.paused);
    }
    if (this._settings.playing) {
        this._buttons.play.classList.add(this._settings.classes.playing);
    }
};


/**
 * Stops playback of the player
 *
 */
HTML5audioplayer.prototype.pause = function() {
    this._log('pause');
    this.player.pause();
    this.playing = false;
    this._buttons.play.classList.remove('HT5AP__control--play--playing');
    this._buttons.play.classList.add('HT5AP__control--play--paused');
    if (this._settings.classes.paused) {
        this._buttons.play.classList.add(this._settings.classes.paused);
    }
    if (this._settings.playing) {
        this._buttons.play.classList.remove(this._settings.classes.playing);
    }
};

/**
 * Toggles the playback state of the player.
 * Starts playing, if it is currently not playing and pauses, if it is.
 *
 */
HTML5audioplayer.prototype.toggleplay = function() {
    this._log('toggleplay');
    if (this.player.paused) {
        this.play();
    } else {
        this.pause();
    }
};

/**
 * Skips to the next song. If the current song is the last song in the playlist, the first song is played
 */
HTML5audioplayer.prototype.next = function() {
    this._log('next');
    this._position = (this._position + 1) % this._songs.length;
    this.setCurrentSong(this._position);
};

/**
 * Goes to the last song. If the current song is the first song in the playlist, the last song is played
 */
HTML5audioplayer.prototype.prev = function() {
    this._log('prev');
    this._position--;
    if (this._position < 0) {
        this._position = this._songs.length - 1;
    }
    this.setCurrentSong(this._position);
};

/**
 * sets the current playback time of the player.
 * Use this for skipping
 *
 * @param {number} time - time in seconds
 */
HTML5audioplayer.prototype.setTime = function (time) {
    this._log('setTime', time);
    if (time < this.player.seekable.end(0)) {
        this.player.currentTime = time;
    }
};

/**
 * sets the volume of the player.
 *
 * @param {number} vol - between 0 and 1
 */
HTML5audioplayer.prototype.setVolume = function (vol) {
    this._log('setVolume', vol);
    this.player.volume = vol;
};

/**
 * Updates the progress of the time knob to the current progress
 *
 * @private
 */
HTML5audioplayer.prototype._updateTime = function () {
    //this._log('_updateTime');
    if (!this.player.paused && this._getProgress() != 0) {
        this._knobs.time.update(
            this._getProgress()
        );
    }
};

/**
 * Gets the relative progress of the current song where 0 is the beginning of the song
 * and 1 is the end of the song.
 *
 * @returns {number}
 * @private
 */
HTML5audioplayer.prototype._getProgress = function () {
   // this._log('_getProgress');
    return this.player.currentTime / (this.player.duration || 0);
};

/**
 * Binds or unbinds the periodic update of the timerunner knob
 * If the parameter is true, the update is being set, if it is false, it is cleared
 *
 * @param {boolean} bind
 * @private
 */
HTML5audioplayer.prototype._bindTimeUpdate = function (bind) {
    this._log('bindTimeUpdate', bind);
    if (bind) {
        this.timeUpdateInterval = setInterval(this._updateTime.bind(this), 100);
    } else {
        clearInterval(this.timeUpdateInterval);
    }
};

/**
 * Helper function that logs information inside the player, but only if settings.debug is set to true
 *
 * @param {...*} - messages that should be logged
 * @private
 */
HTML5audioplayer.prototype._log = function() {
    if (!this._settings.debug) {
        return;
    }
    var args = Array.slice(arguments);
    args.unshift("HTML5audioplayer");
    console.log.apply(console, args);
};

/**
 * @typedef {Object} HT5AP_KnobSettings
 * @property {HTMLElement} element - the Input element to build the knob out of.
 *                                   This must be a input[type="range"] Element that has the jim-knopf attributes set
 * @property {number} [thickness = 15] - thickness of the arc in px
 */

/**
 * Small abstraction over the jim-knopf constructor that builds an arc-style knob for us
 *
 * @param {HT5AP_KnobSettings} inp
 * @constructor
 */
function HT5AP_Knob(inp) {

    this.element = inp.element;

    /**
     * default for thickness
     */
    if (!inp.thickness) {
        inp.thickness = 15;
    }

    P3 = function() {};
    P3.prototype = Object.create(Ui.prototype);

    P3.prototype.createElement = function() {
        Ui.prototype.createElement.apply(this, arguments);
        this.addComponent(new Ui.Arc({
            arcWidth: this.width/inp.thickness
        }));
        this.merge(this.options, {arcWidth: this.width/inp.thickness});
        var arc = new Ui.El.Arc(this.options);
        arc.setAngle(this.options.anglerange);
        this.el.node.appendChild(arc.node);
    };


    this.jim = new Knob(inp.element, new P3());
}

/**
 * The input element that the knob is build for
 * @type {HTMLElement}
 */
HT5AP_Knob.prototype.element = null;

/**
 * Jim Knopf instance
 * @see https://github.com/eskimoblood/jim-knopf
 * @type {Knob}
 */
HT5AP_Knob.prototype.jim = null;

/**
 * update the state of the knob
 * @param {number} num either a float between 0.0 - 1.0
 */
HT5AP_Knob.prototype.update = function(num) {
    this.jim.ui.update(num);
};