5
\$\begingroup\$

This is one of my first JavaScript projects. It works (at least in Firefox and Chrome), but I would like to know if this code follows common best practices.

This script is supposed to look for <audio> tags with class name audioPlayer and build up a separate interface around each one. It allows for multiple audio files to be played simultaneously. In addition to giving the user the capability to play, pause, stop, and rewind, it allows for seeking via a clickable progress bar and volume adjustment.

Here's the script:

// function to build out player iunterface
var buildPlayer = function(elTag, elClass, destination) {
    var newElement = document.createElement(elTag);
    newElement.className = elClass;
    destination.appendChild(newElement);
    return newElement;
}

// format time strings
var formatTime = function(seconds) {
    minutes = Math.floor(seconds / 60);
    minutes = (minutes >= 10) ? minutes : '0' + minutes;
    seconds = Math.floor(seconds % 60);
    seconds = (seconds >= 10) ? seconds : '0' + seconds;
    return minutes + ':' + seconds;
}

// convert each <audio> element into a player
var processAudioElements = function(audio) {

    // remove default playback controls for <audio> element
    audio.removeAttribute('controls');

    // get ID and source URL
    var exerciseId = audio.id;
    var source = audio.getElementsByTagName('source')[0];
    var audioSrc = source.getAttribute('src');

    // put wrapper around <audio> elements
    var parent = audio.parentNode;
    var playerWrapper = document.createElement('div');
    parent.replaceChild(playerWrapper, audio);
    playerWrapper.appendChild(audio);
    playerWrapper.className = 'playerWrapper';
    playerWrapper.id = exerciseId;

    // check to see if player should be small version
    if (audio.classList.contains('sm')) {
        playerWrapper.classList.add('sm');
    }

    // build remaining elements for player
    var fallback = buildPlayer('p', 'fallback', audio)
    fallback.innerHTML = 'Audio player not supported...';

    var fileloading = buildPlayer('div', 'fileloading', playerWrapper)
    fileloading.innerHTML = 'loading';

    var playButton = buildPlayer('div', 'playButton', playerWrapper);
    playButton.style.display = 'none';
    playButton.innerHTML = '<img class="player-icon" src="./images/icons/media-play.svg" />';

    var pauseButton = buildPlayer('div', 'pauseButton', playerWrapper);
    pauseButton.style.display = 'none';
    pauseButton.innerHTML = '<img class="player-icon" src="./images/icons/media-pause.svg" />';

    var stopButton = buildPlayer('div', 'stopButton', playerWrapper);
    stopButton.style.display = 'none';
    stopButton.innerHTML = '<img class="player-icon" src="./images/icons/media-stop.svg" />';

    var info = buildPlayer('div', 'info', playerWrapper);

    var infocontainer = buildPlayer('div', 'infocontainer', info);

    var title = buildPlayer('span', 'title', infocontainer);
    title.innerHTML = exerciseId;

    var timing = buildPlayer('span', 'timing', infocontainer);
    timing.innerHTML = '0:00';

    var volume = buildPlayer('div', 'volume', playerWrapper);

    var volumearea = buildPlayer('div', 'volumearea', volume);

    var volumemask = buildPlayer('div', 'volumemask', volumearea);

    var rewindButton = buildPlayer('div', 'rewindButton', playerWrapper);
    rewindButton.innerHTML = '<img class="player-icon" src="./images/icons/media-step-backward.svg" />';

    var playerprogress = buildPlayer('div', 'playerprogress', playerWrapper);

    var bar = buildPlayer('div', 'bar', playerprogress);

    // functionality for play buttons
    var play = function() {
        playButton.style.display = 'none';
        pauseButton.style.display = 'block';
        audio.play();    
    };
    playButton.addEventListener('click', play, false);

    // functionality for pause buttons
    var pause = function() {
        pauseButton.style.display = 'none';
        playButton.style.display = 'block';
        audio.pause();    
    };
    pauseButton.addEventListener('click', pause, false);

    // functionality for stop buttons
    var stop = function() {
        playButton.style.display = 'block';
        pauseButton.style.display = 'none';
        audio.pause();
        audio.currentTime = 0;
    };
    stopButton.addEventListener('click', stop, false);

    // functionality for rewind buttons
    var rewind = function() {
        audio.currentTime = 0;
    };
    rewindButton.addEventListener('click', rewind, false);

    //functionality for volume sliders
    var volumeadjust = function(e) {
        var volumewidth = volumearea.offsetWidth;
        var volumeheight = volumearea.offsetHeight;
        var viewportOffset = volumearea.getBoundingClientRect();
        var left = viewportOffset.left;
        var clickPosition = (e.clientX  - left) / volumearea.offsetWidth;
        if (clickPosition > 1) {
            clickPosition = 1;
        } else if (clickPosition < 0) {
            clickPosition = 0;
        }
        function precisionRound(number, precision) {
            var factor = Math.pow(10, precision);
            return Math.round(number * factor) / factor;
        }
        clickPosition = precisionRound(clickPosition, 1);
        audio.volume = precisionRound(clickPosition, 1);
        volumearea.style.background = 'linear-gradient(to right, #999 ' + (precisionRound(clickPosition, 1) * 100) + '%, #fff ' + (precisionRound(clickPosition, 1) * 100) + '%';
    }
    volumearea.addEventListener('mousedown', function(e){
        volumeadjust(e);
        volumearea.onmousemove = function(e) {
            volumeadjust(e);
        }
    });
    document.addEventListener('mouseup', function(e){
        volumearea.onmousemove = null
    });    

    // functionality for progress bar
    var jump = function(e) {
        // get position of element in viewport
        var viewportOffset = this.getBoundingClientRect();
        var left = viewportOffset.left;

        // calculate the normalized position clicked
        var clickPosition = (e.clientX  - left) / this.offsetWidth;
        var clickTime = clickPosition * audio.duration;

        // move the playhead to the correct position
        audio.currentTime = clickTime;
    }
    playerprogress.addEventListener('click', jump, false);

    // retrieve and display duration and elapsed time
    audio.addEventListener('timeupdate', function() {
        bar.style.width = parseInt(((audio.currentTime / audio.duration) * 100), 10) + '%';
    });

    audio.addEventListener('durationchange', function() {
        title.innerHTML = exerciseId + ' - ';
        timing.innerHTML = '00:00 / ' + formatTime(audio.duration)
    });

    audio.addEventListener('timeupdate', function() {
        timing.innerHTML = formatTime(audio.currentTime) + ' / ' + formatTime(audio.duration);
    });

    // when audio is finished playing, rewind to beginning
    audio.addEventListener('ended', function() {
        audio.currentTime = 0;
        bar.style.width = '0%';
        playButton.style.display = 'block';
        pauseButton.style.display = 'none';
    });

    // check status for each <audio> element and display playback controls if it's ready 
    function displayControls() {
        fileloading.style.display = 'none';
        playButton.style.display = 'block';
        stopButton.style.display = 'block';
    }
    if (audio.paused) {
        audio.volume = 0.75;
        displayControls();
    } else {
        audio.volume = 0.75;
        audio.addEventListener('canplay', function() {
            displayControls();
        });
    }
}

This is initialized with a script at the bottom of each page:

// collect mp3 audio links and build players
window.onload = function() {
    var audioLinks = document.getElementsByClassName('audioPlayer');
    for (i = 0; i < audioLinks.length; i++) {
        processAudioElements(audioLinks[i]);
    }
}

The intention is to take elements like this:

<audio class="audioPlayer" id="Cole-Lewis-1904_1-01-103" controls>
    <source src="media/Cole-Lewis-1904_1-01-103-AUD.mp3" type="audio/mpeg"
</audio>

And turn them into this:

<div class="playerWrapper" id="Cole-Lewis-1904_1-01-103">
    <audio class="audioPlayer" id="Cole-Lewis-1904_1-01-103">
        <source src="media/Cole-Lewis-1904_1-01-103-AUD.mp3" type="audio/mpeg">
        <p class="fallback">Audio player not supported.</p>
    </audio>
    <div class="fileloading" style="display: none;">loading</div>
    <div class="playButton" style="display: block;">
        <img class="player-icon" src="./images/icons/media-play.svg" />
    </div>
    <div class="pauseButton" style="display: none;">
        <img class="player-icon" src="./images/icons/media-pause.svg" />
    </div>
    <div class="stopButton" style="display: block;">
        <img class="player-icon" src="./images/icons/media-stop.svg" />
    </div>
    <div class="info">
        <div class="infocontainer">
            <span class="title">Cole-Lewis-1904_1-01-103 - </span>
            <span class="timing">00:00 / 01:04</span>
        </div>
    </div>
    <div class="volume">
        <div class="volumearea">
            <div class="volumemask"></div>
        </div>
    </div>
    <div class="rewindButton">
        <img class="player-icon" src="./images/icons/media-step-backward.svg" />
    </div>
    <div class="playerprogress">
        <div class="bar"></div>
    </div>
</div>

The result is then styled with the following CSS:

.playerWrapper {
    display: inline-grid;
    grid-auto-columns: min-content;
    grid-template-rows: 1fr 1fr;
    grid-template-areas:
        "playpause stop info volume rewind"
        "playpause stop progress volume rewind";
    line-height: 0;
    -webkit-box-shadow: 1px 1px 5px 0px rgba(0,0,0,0.65);
    -moz-box-shadow: 1px 1px 5px 0px rgba(0,0,0,0.65);
    box-shadow: 1px 1px 5px 0px rgba(0,0,0,0.65);
    -webkit-border-radius: 4px 4px 4px 4px;
    -moz-border-radius: 4px 4px 4px 4px;
    border-radius: 4px 4px 4px 4px;
    margin-left: 1em;
    margin-top: 0.5em;
}
@keyframes activebutton {
    0% {background-color: #666;}
    50% {background-color: #999;}
    100% {background-color: #666;}
}
.playButton, .pauseButton  {
    grid-area: playpause;
    background-color: #444;
    -webkit-border-radius: 4px 0px 0px 4px;
    -moz-border-radius: 4px 0px 0px 4px;
    border-radius: 4px 0px 0px 4px;
}
.pauseButton {
    animation-name: activebutton;
    animation-duration: 1s;
    animation-iteration-count: infinite;
}
.stopButton {
    grid-area: stop;
    background-color: #444;
}
.info {
    grid-area: info;
    display: grid;
    align-items: center;
    width: 300px;
    background-color: #444;
}
.infocontainer {
    font-size: 0.6em;
    font-family: sans-serif;
    letter-spacing: 0.1em;
    height: 1em;
    color: #fff;
    line-height: 1;
}
.playerprogress {
    grid-area: progress;
    background-color: #ccc;
    cursor: pointer;
}
.bar {
    background:#999;
    width:0%;
    height: 100%;
}
.volume {
    grid-area: volume;
    background-color: #444;
}

.volumearea {
    width: 2em;
    height: 1em;
    background: linear-gradient(to right, #999 80%, #fff 20%);
    position: relative;
    margin: 0.3em;
    cursor: pointer;
}

.volumemask {
    width: 0em;
    height: 0em;
    border-top: 1em solid #444;
    border-right: 2em solid transparent;
    position: absolute;
    z-index: 1;
    bottom: 0;
}

.rewindButton {
    grid-area: rewind;
    background-color: #444;
    border-radius: 0px 4px 4px 0px;
}
.player-icon {
    height: 1em;
    margin: 0.3em;
    padding: 0em;
}

.playButton:hover, .pauseButton:hover, .stopButton:hover, .rewindButton:hover {
    background-color: #666;
    cursor: pointer;
}
.volume:hover {
    background-color: #666;
}
.volume:hover > .volumearea > .volumemask {
    border-top-color: #666; 
}

.sm > .info {
    display: none;
}
.sm > .playerprogress {
    display: none;
}

And the result should look like this:

player

The progress bar and volume triangle are clickable.

Edit with more specific questions:

  1. I'm a little shaky on how to order things. Is it properly organized to: 1) initiate the script with the window.onload call at the bottom of the html body, 2) put the small functions (buildPlayer and formatTime) at the top of the .js file? In other words, am I making any glaring newbie errors?

  2. Would it be better to put all of the arguments for the various calls to buildPlayer in an array and loop through that as opposed to doing each one individually?

  3. Am I missing anything essential regarding the loading and playing of the MP3 files? The players seem to work fine but I'm worried there's some common issue I haven't anticipated.

\$\endgroup\$

1 Answer 1

2
\$\begingroup\$

Keyboard accessibility

The player doesn’t appear to be keyboard-accessible. This can be a problem for usability as well as accessibility. It would fail WCAG 2.0 Level A (2.1.1 Keyboard).

The easiest and best way to allow keyboard use is to use semantic HTML: instead of recreating button functionality with div elements, use input elements:

This

<div class="playButton">
  <img class="player-icon" src="./images/icons/media-play.svg" />
</div>

could become this

<input class="playButton" type="image" src="./images/icons/media-play.svg" alt="Play" />

If that’s not possible for you, you should make use of WAI-ARIA roles and tabindex to recreate the button functionality. And each img needs an alt attribute.

Title of the track

You could use the cite element:

<cite class="title">Cole-Lewis-1904_1-01-103</cite> - 

instead of

<span class="title">Cole-Lewis-1904_1-01-103 - </span>

Loading

You could use the progress element:

<progress class="fileloading">loading</progress>

instead of

<div class="fileloading">loading</div>

(If you know the status, you could add the max and value attributes.)

Runtime

You could represent the current/maximum runtime with the meter element:

<meter min="0" max="64" value="0">00:00 / 01:04</meter>

instead of

<span class="timing">00:00 / 01:04</span>

(but the input-range would represent this, too, so it’s not essential)

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

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