1
\$\begingroup\$

I have some code that's part of my music-js library, and most of it is very organized, using object-oriented design for my AudioAnalyser and Visualizer classes (this code has been updated since publishing but I haven't pushed the changes for several months). There's one static function though, for generating the HTML and event listeners for the actual audio player, it's a bit long and I'm not sure how to better organize it, considering most of the functions have shared variables.

// accepts instance of AudioAnalyser and DOM element to insert player into
function makeControls(audioanalyser, container) {
    var link = document.createElement('link'),
        controls = document.createElement('div'),
        back = document.createElement('div'),
        toggle = document.createElement('div'),
        skip = document.createElement('div'),
        seekbar = document.createElement('div'),
        seekinner = document.createElement('div'),
        buffered = document.createElement('div'),
        played = document.createElement('div'),
        seekbtn = document.createElement('div'),
        time = document.createElement('div'),
        speaker = document.createElement('div'),
        volbar = document.createElement('div'),
        volinner = document.createElement('div'),
        volume = document.createElement('div'),
        volbtn = document.createElement('div'),
        // contains information about bar and button being dragged
        dragbar = null,
        dragbtn = null,
        dragging = false,
        muted = false,
        // settings is global variable to library with structure found in index.html
        lastVol = settings.volume,
        // determines whether to autoplay after seeking
        // based on whether audio was paused or not
        resume;

    // currentTime in seconds of audio playing
    function setTime(currentTime) {
        var seconds = Math.floor(currentTime),
            minutes = Math.floor(seconds / 60),
            timeStr = '';

        timeStr += minutes + ':';
        seconds -= minutes * 60;
        timeStr += ('0' + seconds).slice(-2);

        time.textContent = timeStr;
    }

    // between 0.0 and 1.0 inclusive
    function setVolume(percent) {
        // if gainNode is initialized, use it
        if (audioanalyser.gainNode) {
            audioanalyser.audio.volume = 1;
            audioanalyser.gainNode.gain.value = percent;
        // otherwise fallback to set volume directly, which affects visualization
        } else {
            audioanalyser.audio.volume = percent;
        }
    }

    // get position of event relative to top-left of specified element
    function getPos(event, element) {
        var x = event.clientX,
            y = event.clientY,
            currentElement = element;

        while (currentElement) {
            x -= currentElement.offsetLeft - currentElement.scrollLeft;
            y -= currentElement.offsetTop - currentElement.scrollTop;
            currentElement = currentElement.offsetParent;
        }

        return {
            x: x,
            y: y
        };
    }

    // xPos in pixels, DOM element bar, DOM element button
    function updatePos(xPos, bar, button) {
        var x = Math.max(Math.min(xPos, bar.offsetWidth - button.offsetWidth - 1), -1);
        button.style.left = x + 'px';
    }

    // start and end are between 0.0 and 1.0 inclusive
    // DOM element bar, DOM element range
    function updateRange(start, end, bar, range) {
        var left = Math.round(bar.clientWidth * start),
            right = Math.round(bar.clientWidth * end);

        range.style.left = left + 'px';
        range.style.width = (right - left) + 'px';
    }

    // seekbar dragging event handler (not called by listener directly)
    function seekMousemove(percent) {
        updateRange(0, (seekbtn.offsetLeft + seekbtn.offsetWidth / 2) / seekbar.clientWidth, seekinner, played);

        audioanalyser.emit('seeking', {
            currentTime: Math.floor(Math.max(Math.min(percent, 1), 0) * audioanalyser.audio.duration)
        });
    }

    // volumebar dragging event handler (not called by listener directly)
    function volumeMousemove(percent) {
        updateRange(0, (volbtn.offsetLeft + volbtn.offsetWidth / 2) / volbar.clientWidth, volinner, volume);

        setVolume(percent);

        muted = false;

        if (percent > 0.5) {
            speaker.classList.remove('icon-volume-off', 'icon-volume-down');
            speaker.classList.add('icon-volume-up');
        } else if (percent > 0) {
            speaker.classList.remove('icon-volume-off', 'icon-volume-up');
            speaker.classList.add('icon-volume-down');
        } else {
            speaker.classList.remove('icon-volume-down', 'icon-volume-up');
            speaker.classList.add('icon-volume-off');
            muted = true;
        }

        // if percent is 0, allow volume toggle to revert to full volume
        // this is how youtube volume controls behave
        lastVol = percent || 1;
    }

    // generic bar dragging event handler
    function barMousemove(event) {
        if (dragging) {
            var percent = (dragbtn.offsetLeft + 1) / (dragbar.offsetWidth - dragbtn.offsetWidth);

            updatePos(Math.round(getPos(event, dragbar).x - dragbtn.offsetWidth / 2 - 2), dragbar, dragbtn);

            if (dragbtn === seekbtn) {
                seekMousemove(percent);
            }

            if (dragbtn === volbtn) {
                volumeMousemove(percent);
            }
        }
    }

    // generic bar mousedown event handler
    function barMousedown(event) {
        dragging = true;
        dragbar = this;
        dragbtn = this.lastElementChild;

        if (dragbtn === seekbtn) {
            // resume playing after seeking if not paused
            resume = !audioanalyser.audio.paused;
        }

        barMousemove(event);

        event.preventDefault();
    }

    // generic bar mouseup event handler
    function barMouseup() {
        if (dragbtn === seekbtn) {
            audioanalyser.emit('seeked', {
                resume: resume
            });
        }

        dragging = false;
        dragbar = null;
        dragbtn = null;
    }

    // toggle playing audio
    toggle.addEventListener('click', function () {
        if (audioanalyser.audio.paused) {
            audioanalyser.play();
        } else {
            audioanalyser.pause();
        }
    });

    // seek back in playlist
    back.addEventListener('click', function () {
        audioanalyser.last();
    });

    // seek forward in playlist
    skip.addEventListener('click', function () {
        audioanalyser.next();
    });

    // handle volume toggling based on last volume
    // that was explicitly set on volume bar
    speaker.addEventListener('click', function () {
        // if muted, unmute
        if (muted) {
            updatePos(lastVol * (volbar.offsetWidth - volbtn.offsetWidth) - 1, volbar, volbtn);
            setVolume(lastVol);

            // handle icon changes
            if (lastVol > 0.5) {
                speaker.classList.remove('icon-volume-off', 'icon-volume-down');
                speaker.classList.add('icon-volume-up');
            } else {
                speaker.classList.remove('icon-volume-off', 'icon-volume-up');
                speaker.classList.add('icon-volume-down');
            }
        // else mute
        } else {
            updatePos(-1, volbar, volbtn);
            setVolume(0);
            // handle icon changes
            speaker.classList.remove('icon-volume-down', 'icon-volume-up');
            speaker.classList.add('icon-volume-off');
        }

        updateRange(0, (volbtn.offsetLeft + volbtn.offsetWidth / 2) / volbar.clientWidth, volinner, volume);
        // toggle mute flag
        muted = !muted;
    });

    // handle icon changes
    audioanalyser.on('playing', function () {
        toggle.classList.add('icon-pause');
        toggle.classList.remove('icon-play');
    });

    // handle icon changes
    audioanalyser.on('pause', function () {
        toggle.classList.add('icon-play');
        toggle.classList.remove('icon-pause');
    });

    // handle time display
    audioanalyser.on('timeupdate', function () {
        var percent = audioanalyser.audio.currentTime / audioanalyser.audio.duration,
            xPos = Math.round((seekbar.offsetWidth - seekbtn.offsetWidth) * percent - 1);

        // if audio is playing, update seek button and played range
        if (!audioanalyser.audio.paused) {
            updatePos(xPos, seekbar, seekbtn);
            updateRange(0, (seekbtn.offsetLeft + seekbtn.offsetWidth / 2) / seekbar.clientWidth, seekinner, played);
        }
        // if audio is paused, do not update these since drag handler does this already

        setTime(audioanalyser.audio.currentTime);
    });

    // handle buffered range
    audioanalyser.on('progress', function () {
        if (audioanalyser.audio.buffered.length > 0) {
            var percentStart = audioanalyser.audio.buffered.start(0) / audioanalyser.audio.duration,
                percentEnd = audioanalyser.audio.buffered.end(0) / audioanalyser.audio.duration;

            updateRange(percentStart, percentEnd, seekinner, buffered);
        }
    });

    // add event listeners
    seekbar.addEventListener('mousedown', barMousedown);

    volbar.addEventListener('mousedown', barMousedown);

    document.addEventListener('mousemove', barMousemove);

    document.addEventListener('mouseup', barMouseup);

    // preload styling
    link.setAttribute('type', 'text/css');
    link.setAttribute('rel', 'stylesheet');
    // Change this value to whatever is needed
    link.setAttribute('href', '//html5music.herokuapp.com/css/style.css');

    link.addEventListener('load', function () {
        setTime(0);

        updatePos(lastVol * (volbar.offsetWidth - volbtn.offsetWidth) - 1, volbar, volbtn);

        if (volbar.offsetWidth) {
            volumeMousemove();
        } else {
            setVolume(settings.volume);
        }

        audioanalyser.initialize();
    });

    // insert styling from player settings
    controls.setAttribute('style', settings.controls);

    // insert CSS classes
    controls.classList.add('audio');
    back.classList.add('back', 'icon-step-backward');
    toggle.classList.add('toggle', 'icon-play');
    skip.classList.add('skip', 'icon-step-forward');
    seekbar.classList.add('seekbar');
    seekinner.classList.add('innerbar');
    buffered.classList.add('buffered');
    played.classList.add('played');
    seekbtn.classList.add('seekbtn');
    time.classList.add('time');
    speaker.classList.add('speaker', 'icon-volume-up');
    volbar.classList.add('volbar');
    volinner.classList.add('innerbar');
    volume.classList.add('volume');
    volbtn.classList.add('volbtn');

    // insert elements into container element
    document.head.appendChild(link);

    controls.appendChild(back);
    controls.appendChild(toggle);
    controls.appendChild(skip);
    controls.appendChild(seekbar);
    controls.appendChild(time);
    controls.appendChild(speaker);
    controls.appendChild(volbar);

    seekbar.appendChild(seekinner);
    seekbar.appendChild(seekbtn);

    seekinner.appendChild(buffered);
    seekinner.appendChild(played);

    volbar.appendChild(volinner);
    volbar.appendChild(volbtn);

    volinner.appendChild(volume);

    container.appendChild(controls);
}
\$\endgroup\$
  • \$\begingroup\$ @Jamal was it really necessary to remove that I'd be glad to clarify global objects not defined within this context if needed? \$\endgroup\$ – Patrick Roberts Jun 26 '15 at 18:45
  • \$\begingroup\$ I did that because someone will already know to ask if something needs to be clarified. \$\endgroup\$ – Jamal Jun 26 '15 at 18:46
1
\$\begingroup\$

You may consider to make the elements building process more generic. Not sure it ends up with very much less code, but IMO it becomes clearer.

I imagined a solution, and applied it on a part of your code, as an example:

function makeControls(audioanalyser, container) {
    var $ = {}, // will contain the set of controls once built
        elements = {
            link: {
                tag: 'link',
                attrs: {'type': 'text/css', 'rel': 'stylesheet'},
                bind: {'load': linkLoad},
                parent: document.head
            },
            controls: {
                tag: 'div',
                attrs: {'style': settings.controls},
                classes: ['audio'],
                parent: container
            },
            back: {
                tag: 'div',
                bind: {'click': backClick},
                classes: ['back', 'icon-step-backward'],
                parent: 'controls'
            },
            toggle: {
                tag: 'div',
                bind: {'click': toggleClick},
                classes: ['toggle', 'icon-play'],
                parent: 'controls'
            },
            // and so on...
        },
        // contains information about bar and button being dragged
        dragbar = null,
        dragbtn = null,
        dragging = false,
        muted = false,
        // settings is global variable to library with structure found in index.html
        lastVol = settings.volume,
        // determines whether to autoplay after seeking
        // based on whether audio was paused or not
        resume;

    function linkLoad() {
        setTime(0);
        // note the "$." prefixes added to controls elements
        updatePos(lastVol * ($.volbar.offsetWidth - $.volbtn.offsetWidth) - 1,
          $.volbar, $.volbtn);
        if ($.volbar.offsetWidth) {
            volumeMousemove();
        } else {
            setVolume(settings.volume);
        }
        audioanalyser.initialize();
    }

    function backClick() {
        audioanalyser.last();
    }

    function toggleClick() {
        if (audioanalyser.audio.paused) {
            audioanalyser.play();
        } else {
            audioanalyser.pause();
        }
    }

    // And so on for each migrated bind functions...

    // Then all the other (previously defined) functions...

    // Here the whole stuff for building controls.
    for (var e in elements) {
      var element = elements[e];
      $[e] = document.createElement(element.tag);
      if (element.attrs) {
        for (var a in element.attrs) {
          $[e].setAttribute(a, element.attrs[a]);
        }
      }
      if (element.classes) {
        for (var c in element.classes) {
          $[e].classList.add(element.classes[c]);
        }
      }
      if (element.bind) {
        for (var b in element.bind) {
          $[e].addEventListener(b, element.bind[b]);
        }
      }
      if (typeof element.parent == 'string') {
        $[element.parent].appendChild($[e]);
      } else {
        element.parent.appendChild($[e]);
      }
    }

}

I tested this code and it works.

Here are some points of interest:

  1. obviously, since the leading idea was to define properties for the whole set of elements and loop to build them, we must have an object for the entire set --> pitfall: once built the elements don't have an associated variable referencing them.
    To create those variables while building the elements I couldn't think to better than eval(): not a good thing.
    Then I choosed to create the $ object to collect elements, so each reference to these elements, in your functions, must be prefixed by $.: hopefully this is not so painful, and doesn't harm readability.
  2. regarding event binding, I merely reported your original anonymous functions to named, independant functions
  3. when attaching element to its parent, linkand controls target an element, while all other ones target controls --> the tip is to define parent as a string when it is control, and merely as DOM or JS element otherwise.
\$\endgroup\$
  • \$\begingroup\$ This looks pretty cool, I'll try that out sometime. Expanding on your answer though (and I may consider doing this) would it be overkill to create a small HTML templating library that has just enough features to build this audio player? I've seen examples where people write their JQuery and indent it, making the structure of the code look like the resulting structure of the HTML generated and I always thought that looked very organized, even if it did have a sort of pyramid structure that most people like to avoid. In this case though I think it would apply well. Any comments? \$\endgroup\$ – Patrick Roberts Jun 27 '15 at 19:18
  • \$\begingroup\$ Actually, now that I'm looking at your answer more closely, I think you've already done what I was asking about. This looks perfect, thank you for the review. \$\endgroup\$ – Patrick Roberts Jun 27 '15 at 19:23
  • \$\begingroup\$ @PatrickRoberts. Not sure to understand what you meant with it did have a sort of pyramid structure that most people like to avoid. Anyway, what I'd proposed is based on the fact that, obviously, you wanted to not depend on any external library (or you were using jQuery), probably to be more directly usable anywhere with minimal constraints. So, as you already said, I targetted "just enough features" for what was involved here. And it might be a bit extended if some needs appear, remaining strictly "just enough". \$\endgroup\$ – cFreed Jun 27 '15 at 20:25

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.