4
\$\begingroup\$

I have some triggers placing and sizing elements for video controls. It works well enough but mostly through trial and error and I am not sure if there is a better pattern:

The full source can be found here and webpage here.

My biggest issue I think is when is it appropriate to have an element listen (e.g. on the specific element or on window) and when should I call the reposition trigger (each time or only once at the end).

$(document).ready(function () {
    "use strict";
    var videos = $("video"), doc = document, fsVideoTime, fsVideo;

    Number.prototype.leftZeroPad = function (numZeros) {
        var n = Math.abs(this),
            zeros = Math.max(0, numZeros - Math.floor(n).toString().length),
            zeroString = Math.pow(10, zeros).toString().substr(1);
        if (this < 0) {
            zeroString = '-' + zeroString;
        }
        return zeroString + n;
    };

    function isVideoPlaying(video) {
        return !(video.paused || video.ended || video.seeking || video.readyState < video.HAVE_FUTURE_DATA);
    }

    // Pass in object of the video to play/pause and the control box associated with it
    function playPause(video) {
        var $playPauseButton = $("#playPauseButton", video.parent);
        if (isVideoPlaying(video)) {
            video.pause();
            $playPauseButton[0].src = "/static/images/smallplay.svg";
            $playPauseButton[0].alt = "Button to play the video";
            $playPauseButton[0].title = "Play";
        } else {
            video.play();
            $playPauseButton[0].src = "/static/images/smallpause.svg";
            $playPauseButton[0].alt = "Button to pause the video";
            $playPauseButton[0].title = "Pause";
        }
    }

    function rawTimeToFormattedTime(rawTime) {
        var chomped, seconds, minutes;
        chomped = Math.floor(rawTime);
        seconds = chomped % 60;
        minutes = Math.floor(chomped / 60);
        return minutes.leftZeroPad(2) + ":" + seconds.leftZeroPad(2);
    }

    $(videos).each(function () {
        var video = this,
            $videoContainer = $("#vidContainer"),
            $controlsBox = $("#videoControls"),
            $playProgress = $("#playProgress", $controlsBox),
            $poster,
            customStartPoster,
            $endPoster,
            customEndPoster,
            errorPoster,
            $currentTimeSpan = $(".currentTimeSpan", $controlsBox),
            $durationTimeSpan = $(".durationTimeSpan", $controlsBox),
            $trackMenu = $("#trackMenu"),
            $tracksButton = $("#tracksButton"),
            $loadingProgress = $("#loadProgress"),
            $playPauseButton = $("#playPauseButton", $controlsBox),
            $soundButton = $("#soundButton", $controlsBox),
            $soundVolume = $("#soundLevel", $controlsBox);

        if (this.controls) {
            this.controls = false;
        }

        $(video).on("timeupdate", function () {
            $playProgress[0].value = (video.currentTime / video.duration) * 1000;
            $currentTimeSpan.text(rawTimeToFormattedTime(video.currentTime));
        }).on("play", function () {
            if ($poster.length !== 0) {
                $poster.remove();
            }
        }).on("loadedmetadata", function () {
            var canPlayVid = false;
            $("source", $(video)).each(function () {
                if (video.canPlayType($(this).attr("type"))) {
                    canPlayVid = true;
                }
            });
            if (!canPlayVid) {
                errorPoster = "/static/images/movieerror.svg";
                $.get(errorPoster, function (svg) {
                    errorPoster = doc.importNode(svg.documentElement, true);

                    $(errorPoster).attr("class", "poster errorposter");
                    $(errorPoster).attr("height", $(video).height());
                    $(errorPoster).attr("width", $(video).width());

                    $("source", $(video)).each(function () {
                        var newText = doc.createElementNS("http://www.w3.org/2000/svg", "tspan"),
                            link = doc.createElementNS("http://www.w3.org/2000/svg", "a");

                        newText.setAttributeNS(null, "x", "50%");
                        newText.setAttributeNS(null, "dy", "1.2em");
                        link.setAttributeNS("http://www.w3.org/1999/xlink", "href", this.src);
                        link.appendChild(doc.createTextNode(this.src));
                        newText.appendChild(link);

                        $("#sorrytext", errorPoster).append(newText);
                    });

                    $videoContainer.append(errorPoster);
                    $($videoContainer).trigger("reposition");
                });
            } else {
                $($currentTimeSpan).text(rawTimeToFormattedTime(this.currentTime));
                $($durationTimeSpan).text(rawTimeToFormattedTime(this.duration));
            }

        }).on("click", function () {
            playPause(video);
        }).on("ended", function () {
            $playPauseButton[0].src = "/static/images/smallplay.svg";
            $playPauseButton[0].alt = "Button to play the video";
            $playPauseButton[0].title = "Play";
            // Poster to show at end of movie
            if (video.dataset.endposter) {
                customEndPoster = video.dataset.endposter;
            } else {
                customEndPoster = "/static/images/endofmovie.svg";  // If none supplied, use our own, generic one
            }
            // Get the poster and make it inline
            // File is SVG so usual jQuery rules may not apply
            // File needs to have at least one element with "playButton" as class
            $.get(customEndPoster, function (svg) {
                $endPoster = doc.importNode(svg.documentElement, true);
                $endPoster = $($endPoster);

                $endPoster.attr("class", "poster endposter");
                $endPoster.attr("height", $(video).height());
                $endPoster.attr("width", $(video).width());

                $("#playButton", $endPoster).on("click", function () {
                    playPause(video);
                    $endPoster.remove(); // done with poster forever
                });
                $videoContainer.append($endPoster);
                $($videoContainer).trigger("reposition");
            });
        });

        // Setup the div container for the video, controls and poster
        $videoContainer.on("reposition", function () {
            // Move posters and controls back into position after video position updated
            var $poster = $(".poster", this);

            $poster.offset({top: $("video", this).offset().top, left: $("video", this).offset().left});
            $poster.attr("height", $(video).height());
            $poster.attr("width", $(video).width());

            $trackMenu.css({top: $tracksButton.offset().top + $tracksButton.height() + "px", left: $tracksButton.offset().left + $tracksButton.width() - $trackMenu.width() + "px"});
            $soundVolume.css({top: $soundButton.offset().top - $soundVolume.height() + "px", left: $soundButton.offset().left + "px"});
            $loadingProgress.css({top: $loadingProgress.parent().offset().top + "px", left: $loadingProgress.parent().offset().left + "px"});
        });

        // Setup play/pause button
        $playPauseButton.on("click", function () {
            playPause(video);
        });

        // Setup progress bar
        $playProgress.on("change", function () {
            video.currentTime = video.duration * (this.value / 1000);
        }).on("mousedown", function () {
            video.pause();
        }).on("mouseup", function () {
            video.play();
        });

        // Full screen
        $("#fullscreenButton").on("click", function () {
            if (video.requestFullScreen) {
                video.requestFullScreen();
                fsVideo = video;
            } else if (video.webkitRequestFullScreen) {
                video.webkitRequestFullScreen();
                fsVideo = video;
            } else if (video.mozRequestFullScreen) {
                video.mozRequestFullScreen();
                fsVideo = video;
            }
        });

        // Work out if there is audio to control
        // Not the greatest way but doesnt look like there is much other option (if any?)...
        $("source", video).each(function () {
            if (this.src === video.currentSrc) {
                if (this.type.split(',').length === 1) { // Split happens on the: codecs="vp8,vorbis"' part
                    $soundButton[0].src = "/static/images/nosound.svg";
                    $soundButton[0].alt = "Icon showing no sound is available";
                    $soundButton[0].title = "No sound available";
                    $soundVolume.remove();
                } else {  // If it DOES have sound
                    $soundVolume.css({top: $soundButton.offset().top - $soundVolume.height() + "px", left: $soundButton.offset().left + "px"});
                }
                return false;
            }
            return true;
        });

        // Check if there needs to be an option menu for tracks made
        if (video.textTracks.length === 0) {
            $tracksButton.attr("src", $tracksButton.attr("src").replace("tracks.svg", "notracks.svg"));
            $tracksButton.attr("alt", "No tracks are available");
            $tracksButton.attr("Title", "No tracks are available");
        } else {
            $(video.textTracks).each(function () {
                this.mode = "hidden";
            });
        }

        if ($trackMenu.length) {
            $("li", "#trackMenu").on("click", function () {
                $(this).parent().children("li").removeClass("activeTrack");
                $(this).addClass("activeTrack");

                // 'None' <li> is clicked so disable all the tracks
                if ($(this).text().toLowerCase() === "none") {
                    $(video.textTracks).each(function () {
                        this.mode = "hidden";
                    });
                // Find the track referred to and activate it
                } else {
                    var kind = $(this).text().split(":")[0].toLowerCase(), srclang = $(this).text().split("(")[1].slice(0, -1);
                    $(video.textTracks).each(function () {
                        if (this.kind === kind && this.language === srclang) {
                            this.mode = "showing";   // change to this track
                        } else {
                            this.mode = "hidden";  // Turn off other tracks
                        }
                    });
                }
            });
            $trackMenu.css({top: $tracksButton.offset().top + $tracksButton.height() + "px", left: $tracksButton.offset().left + $tracksButton.width() - $trackMenu.width() + "px"});
        }

        $loadingProgress.width($controlsBox.width());
        $loadingProgress.css({top: $controlsBox.offset().top + "px", left: $controlsBox.offset().left + "px"});

        (function () {
            var progressInterval = setInterval(function () {
                var totalBuffered = 0, i;
                $loadingProgress[0].value = (video.duration / video.buffered.end(0)) * 100;

                for (i = 0; i < video.buffered.length; i += 1) {
                    totalBuffered += video.buffered.start(i) + video.buffered.end(i);
                }
                if (totalBuffered === video.duration) {
                    $loadingProgress.fadeOut();
                    clearInterval(progressInterval);
                }
            }, 1000);
        }());

        $soundVolume.css({top: $soundButton.offset().top - $soundVolume.height() + "px", left: $soundButton.offset().left + "px"});

        $soundVolume.on("change", function (){
           video.volume = (this.value / 100);
        });


        // Posters to show before the user plays the video
        customStartPoster = this.dataset.startposter;
        if (!customStartPoster) {
            customStartPoster = "/static/images/genericstartposter.svg";  // If none supplied, use our own, generic one
        }
        // Get the poster and make it inline
        // File is SVG so usual jQuery rules may not apply
        // File needs to have at least one element with "playButton" as class
        $.get(customStartPoster, function (svg) {
            $poster = doc.importNode(svg.documentElement, true);
            $poster = $($poster);

            $poster.attr("class", "poster");
            $poster.attr("height", $(video).height());
            $poster.attr("width", $(video).width());

            $("#playButton", $poster).on("click", function () {
                video.load();   // Initial data and metadata load events may have fired before they can be captured so manually fire them
                playPause(video);
                $poster.remove(); // done with poster forever
            });
            $videoContainer.append($poster);
            $($videoContainer).trigger("reposition");
        });

        $($videoContainer).trigger("reposition"); //Get its position right.
    });

    // Handle coming out of fullscreen
    $(doc).on("webkitfullscreenchange mozfullscreenchange fullscreenchange", function () {
        var isFullScreen = doc.fullScreen || doc.mozFullScreen || doc.webkitIsFullScreen;

        if (isFullScreen) {  // Now in full screen
            fsVideo = doc.fullscreenElement || doc.mozFullScreenElement || doc.webkitFullscreenElement;
            fsVideoTime = fsVideo.currentTime;
            $("source", fsVideo).each(function () {
                // .dataset.fullscreen is is treated a boolean, but it is just truthy string
                // This function uses a standard format of names of full screen appropriate vids as shown below:
                // original: originalvid.xyz            full screen: originalvid.fullscreen.xyz
                // N.B. Can not have period (".") in original file same except for filetype
                if (this.dataset.fullscreen) {
                    var splitSrc = this.src.split(".");
                    this.src = splitSrc[0] + "." + splitSrc[1] + "." + splitSrc[2] + ".fullscreen." + splitSrc[3];
                }
                fsVideo.load();  //Reload with the new source
            });
        } else {  // Have left full screen and need to return to lower res video
            fsVideoTime = fsVideo.currentTime;
            $("source", fsVideo).each(function () {
                // Remove the full screen and go back to the original file
                if (this.dataset.fullscreen) {
                    var splitSrc = this.src.split(".");
                    this.src = splitSrc[0] + "." + splitSrc[1] + "." + splitSrc[2] + "." + splitSrc[4];
                } // Nothing was changed if data-fullscreen is false so no need to do anything

                $(this).parent().load();  // The video
                $(this).parent().trigger("reposition");  // The video container box
            });
        }
        $(fsVideo).on("loadedmetadata", function () {
            this.currentTime = fsVideoTime;  // Skip to the time before we went full screen
            playPause(fsVideo);
        });
    });
    $(window).on("resize", function () {
        $(videos).each(function () {
            $(this).parent().trigger("reposition");
        });
    });
    $(window).trigger("reposition");
});

The second trigger comes in at the end of a big loop and the first on a specific event. Is one way better than another or are both appropriate in this situation?

This is by far the biggest JS project I have worked on so please be brutal as need be and help me make it better.

\$\endgroup\$
0

1 Answer 1

1
\$\begingroup\$

This small snippet makes it hard to review.

Your question:

  • My biggest issue I think is when is it appropriate to have an element listen (eg on the specific element or on window)

    • Let the element emit the event, the day that you need 2 video controls, you will be pleased
  • When should I call the re-position trigger (each time or only once at the end).

    • You should call trigger("reposition"); as little as possible, consider delegating the responsibility of calling it to the user of your object ?

Other than that

  • You could chain your jQuery functions:

    $poster.attr("class", "poster");
           .attr("height", $(video).height());
           .attr("width", $(video).width());
    
  • importNode is not such a commonly used function, consider passing a deepCopy = true variable which would then auto document the function.

  • Fair amount of commenting, good style.
\$\endgroup\$
2

Your Answer

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

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