// <reference path="../ScriptReference/jquery-1.7.1-vsdoc.js"/>
/// <reference path="Mediasite.Core.js"/>
/// <reference path="Mediasite.Player.Core.js"/>
/// <reference path="Mediasite.Player.Localization.js"/>
/*global Mediasite OnDSPlayStateChangeEvt : true, OnDSPositionChangeEvt : true, OnDSOpenStateChangeEvt : true, OnDSScriptCommandEvt : true, OnDSMediaErrorEvt : true */

Mediasite.namespace("MediaPlayer");
Mediasite.namespace("MediaPlayer.Views");

Mediasite.MediaPlayer.ModelHtml5 = function (viewOptions) {
    /// <summary>Media player model for HTML5 media element</summary>
    /// <param name="options" type="object">{ playbackRates: (array) }</param>
    var $ = jQuery;
    var self = this;

    var SEGMENT_LENGTH_TO_UNSAFE_SECONDS = {
        2: 9,
        4: 15,
        6: 21,
        8: 27,
        9: 30,
        10: 30
    };
    var segmentLengthSeconds = (viewOptions.selectedVideoSource.SegmentLength || 0) / 1000;
    var liveStartUnsafeSeconds = Mediasite.MediaPlayer.calculateLiveStartUnsafeWindow(SEGMENT_LENGTH_TO_UNSAFE_SECONDS, segmentLengthSeconds);

    var element;
    var mediaElement;
    var mediaSources;
    var isSetUp = false;
    var defaultPlaybackRate = 1;
    var playbackRates = viewOptions.playbackRates;
    var skipBackAmount = 3000;
    var skipForwardAmount = 10000;
    var lastSeekTime = 0;
    var lastSeekTimeDate = new Date();
    var manualSeekFlag = false;
    var _manualSeekThreshold = -500;
    var inactivityExceeded = false;
    var volume = 100;
    var isOpeningState = false;
    var liveStartTime, liveStartOffset = 0;
    var _isSeekingToEnd = false;
    var _playCount = 0;
    var _safariHlsBadStartCorrector;
    var _positionEventSimulator;
    var _hasReachedLiveEnd = false;
    var _liveStartTimeWasSetAt = 0;
    var _zeroEquivalencyThreshold = (1 / 5) * 1000 * 1.05; // 5fps + 5% slop = worst case.
    var currentPlaybackRate = 1.0;
    var canFullScreen, enterFullScreen;
    var liveMediaResetInProgress = false;
    var positionWhenPlayWasCalled = 0;
    var suppressPositionUpdatedUntilPlaying = false;

    var uniquifyCounter = 0; // The number of times we've attempted to uniquify the media URL.
    var maxUniquifications = 4; // The maximum allowed attempts to uniquify the media URL. 

    var clientBookmark = { position: 0, savedAt: 0 };
    var serverBookmark = { position: 0, savedAt: 0 };

    var logger = Mediasite.Logging.LoggerFactory("Media Html5" + ":" + viewOptions.streamType, { separator: "\n &nbsp;" });
    var _events = new Mediasite.Player.EventBundle();
    var _eventNames = Mediasite.MediaPlayer.ModelEvent;
    var mediaPlayerView;
    var state;
    var mediaWrapper;
    var liveMediaOpenReconnector = new Mediasite.MediaPlayer.LiveMediaOpenReconnector();

    var convivaConfiguration = makeConvivaConfig(viewOptions);
    var convivaTracker = Mediasite.Player.Conviva.createConvivaTracker();
    convivaTracker.configure(convivaConfiguration);

    // We will simulate position changed events for browsers that neglect to raise them.
    if (isLivePresentation() && !_positionEventSimulator) { // && Mediasite.BrowserDetect.Edge()) {
        _positionEventSimulator = new PositionUpdateSimulator();
        _positionEventSimulator.fireEventDelegate = _events.fire;
        _positionEventSimulator.getPositionDelegate = self.GetPosition;
    } else {
        _positionEventSimulator = new NullPositionUpdateSimulator();
    }


    // Let's allow derived media players to override this value.
    // By default, the value will be the constant defined above.
    this.liveStartUnsafeWindow = liveStartUnsafeSeconds * 1000;

    this.GetMediaWrapper = function () {
        return mediaWrapper;
    };

    this.SupportsPlayAsVideo = function () {
        return true;
    };

    var disableVolume = viewOptions.disableVolume == undefined ? false : viewOptions.disableVolume;

    this.AddEventHandler = function (eventName, handler) {
        _events.addHandler(eventName, handler);
    };

    function isLivePresentation() {
        return !!liveStartTime;
    }

    function detectFullScreen(videoElement) {
        /// <summary>Define the appropriate fullscreen functionality for a given browser, if available</summary>

        // This should not be checked until the media is ready (and any on-demand/live settings have already been applied), 
        // since that affects whether fullscreen functionality is enabled

        logger.Debug("Detecting fullscreen functionality...");
        canFullScreen = false; // Default state

        // TODO: see if IE9 ever exposes its own method
        if (typeof videoElement.requestFullScreen === 'function') {
            // W3C standard, Blink browsers


            enterFullScreen = function () {
                try {
                    videoElement.requestFullScreen();
                    return true;
                } catch (err) {
                    logger.Error("Fullscreen error", err);
                }
            };

            //isFullScreen = function () {
            //  use fullscreenchange event instead
            //};
            // }
        }
        else if (typeof videoElement.webkitEnterFullScreen === 'function') {
            // (Older) Webkit browsers

            if (isLivePresentation() && Mediasite.BrowserDetect.Android()) {
                // Older Android has issues resuming live streams after returning from fullscreen (in Mediasite players)

                logger.Debug("... not supporting fullscreen on Android for live stream");
            } else {

                // Safari, Mobile Safari, Android (on-demand)
                enterFullScreen = function () {
                    try {
                        videoElement.webkitEnterFullScreen();
                        return true;
                    } catch (err) {
                        logger.Error("Fullscreen error", err);
                    }
                };

                //isFullScreen = function () {
                //  return videoElement.webkitDisplayingFullscreen;
                //};
            }
        } else if (typeof videoElement.mozFullScreen === 'function') {
            // Mozilla browsers
            enterFullScreen = function () {
                try {
                    document.mozRequestFullScreen();
                    return true;
                } catch (err) {
                    logger.Error("Fullscreen error", err);
                }
            };

            //isFullScreen = function () {
            //    return document.mozFullScreen;
            //};
        }


        if (typeof enterFullScreen == "function") { // && typeof isFullScreen == "function") {
            canFullScreen = true;
        }

        logger.Debug("... can fullscreen: " + canFullScreen, enterFullScreen && enterFullScreen.toString());
    }

    function getPausedState() {
        if (isLivePresentation()) {
            return Mediasite.Player.MediaState.Stopped;
        } else {
            return Mediasite.Player.MediaState.Paused;
        }
    }

    function resetLiveMediaElement() {
        liveMediaResetInProgress = true;

        // Remember audio values from before reset.
        var volumeBefore = self.GetVolume();
        var mutedBefore = self.IsMuted();

        loadTheMediaSource(); // Load the media source. It was unloaded on Stop.
        $(mediaElement).one('canplay', function () {
            mediaElement.play();
        });

        // Restore the pre-reset audio values.
        self.SetVolume(volumeBefore);
        if (mutedBefore) {
            self.Mute();
        }

        if (disableVolume) {
            self.SetVolume(0);
        }
    }

    function reconnectOnLiveMediaOpenFailure() {
        resetLiveMediaElement();
    }

    function handleMediaError() {
        
        var attemptingChromeRecovery = recoverFromChromeNetworkError();
        if (attemptingChromeRecovery) {
            return;
        }


        var errorDescription;

        switch (mediaElement.error.code) {
            case 1: //MEDIA_ERR_ABORTED
                errorDescription = "Media aborted";
                break;
            case 2: //MEDIA_ERR_NETWORK
                errorDescription = "There was a network error loading the media";
                break;
            case 3: //MEDIA_ERR_DECODE
                errorDescription = "There was a media decoding error";
                break;
            case 4: //MEDIA_ERR_NOT_SUPPORTED
                errorDescription = "The current media is not supported";
                break;
            default:
                errorDescription = "Error" + " (" + mediaElement.error.code + ")";
                break;
        }

        self.raiseMediaError(errorDescription);
    }

    var _lastAttemptedRecovery;
    function recoverFromChromeNetworkError() {
        if (_lastAttemptedRecovery && (_lastAttemptedRecovery + 10000 < new Date())) {
            // attempted to recover recently, give up
            return;
        }

        var attemptingRecovery;

        if (Mediasite.BrowserDetect.Chrome() && !Mediasite.BrowserDetect.Android() && mediaElement.error.code === mediaElement.error.MEDIA_ERR_NETWORK) {
            var currentTime = mediaElement.currentTime;
            mediaElement.play();
            $(mediaElement).one("playing", function () {
                mediaElement.currentTime = currentTime;
                _lastAttemptedRecovery = undefined;
            });
            logger.Warning("Network error loading media; attempting to restart playback at " + currentTime);

            attemptingRecovery = true;
        }

        if (attemptingRecovery) {
            _lastAttemptedRecovery = new Date();
        }
        return attemptingRecovery;
    }


    var cannotLoadDelay = 10000;
    function handleCannotLoadMedia() {

        if (mediaElement && mediaElement.networkState == 3) {  // NETWORK_NO_SOURCE
            self.raiseMediaError(Mediasite.Player.Localization.MediaPlayer.SilverlightNetworkError);
            logger.Error("Could not load the media file. The file or server is probably unavailable.");
        }
    }

    function reportStateChanged(newState) {
        state = newState;
        _events.fire(_eventNames.StateChanged, newState);
    }

    this.reportStateChanged = reportStateChanged;


    var nudged;
    function handleMediaEvent(event) {

        if (_safariHlsBadStartCorrector) {
            _safariHlsBadStartCorrector.noteMediaEvent(event.type);
        }

        if (event.type === "loadstart") {
            isOpeningState = true;
            mediaPlayerView.MediaLoadStart();
            reportStateChanged(Mediasite.Player.MediaState.Opening);
            _events.fire(_eventNames.MediaLoadStart);
            setTimeout(handleCannotLoadMedia, cannotLoadDelay);
        }

        if (event.type === 'stalled' && Mediasite.BrowserDetect.Chrome() && !Mediasite.BrowserDetect.ChromeAndroid() && mediaElement && mediaElement.readyState === mediaElement.HAVE_NOTHING) {
            // 
            // Fix for BUG37589 (improved to be a bit more cache/CDN friendly).
            // 
            // Google Chrome can struggle to load the same source URL in two 
            // video elements at the same time, even when the video elements 
            // live in separate windows. While the video is downloading for 
            // one request, the cache item is locked for the second request. 
            // This impacts player pop-outs, stream pop-outs, and just opening 
            // multiple copies of the same presentation.
            // 
            // See https://code.google.com/p/chromium/issues/detail?id=31014
            // 
            // Chrome now blocks the second request for twenty seconds before 
            // giving up and requesting a second copy of the video file. 
            // Twenty seconds is way too long to wait, especially since it 
            // will be an additional twenty seconds each time the user seeks 
            // (while the first video request is still active).
            // 
            // This fix attempts to detect when a video request is blocked and 
            // re-request the video with a different URL. When cache blocked 
            // as described, the video element raises a 'stalled' event while 
            // the element's readyState is still equal to HAVE_NOTHING. When 
            // that condition is detected, the video URL is modified in such a 
            // way that the browser will treat it as a different asset yet  
            // the media server will respond with the same video file.
            // 
            // Making the video URLs truly unique can prevent beneficial 
            // caching and can result in excessive bandwidth usage and server 
            // load. For this reason, the video URLs are modified such that 
            // there will only exist a finite number of variations for a given 
            // video asset.
            // 

            var mediaType = viewOptions.pluginMimeType;
            var mediaSource = selectMediaSourceByMimeType(mediaSources, mediaType);

            if (canUniquifyMediaUrl(mediaSource)) {
                mediaSource = uniquifyMediaUrl(mediaSource);
                setVideoElementSource(element, mediaSource, mediaType);
                return;
            }
        }


        if (isOpeningState && (event.type === "canplay" || event.type === "suspend" || event.type === "stalled")) {
            // Invalidate the Opening state when we can play or have encountered 
            // suspend or stalled. This is intended to prevent the player from 
            // getting stuck showing "Opening" when it's really not doing so.

            if (viewOptions.isMobile) {
                state = Mediasite.Player.MediaState.Ready;
                reportStateChanged(state);
            }

            isOpeningState = false;
        }

        var metadataUpdated = false;
        if (event.type === "loadeddata" || event.type === "durationchange") {
            metadataUpdated = didMediaMetadataUpdate();
        }

        if (event.type === "loadeddata"
            || (event.type === "canplay" && Mediasite.BrowserDetect.IOS6())
            || (event.type === "durationchange" && Mediasite.BrowserDetect.Android())) {

            // In desktop browsers, this fires when the video loads on pageload and is a reliable indicator that the media metadata is available.
            // On mobile devices, this fires several times during pageload, but should only be acted on when the media is actually loaded, after user initiates playing.
            // On iOS 6, when loading a new URL in an existing media element, "loadeddata" and "canplay" fire several times, but media metadata seems to be only available after several "canplay" events.
            //
            // To indicate if media's metadata is available, check if the media has a plausible duration.
            // 
            // Before properly loading the media, various browsers report various values for media.duration:
            //      Android:       video.duration = 1
            //      Chrome Android:               = 100
            //      Blackberry:                   = Infinity

            // Do initial setup if video is available
            var isLive = isLivePresentation();
            var mediaIsReady = false;
            if (isLive) {
                mediaIsReady = true;
            } else if (mediaElement.duration > 1 && mediaElement.duration != 100) {
                mediaIsReady = true;
            }

            logger.Debug("Attempting to load media metadata.",
                "Live status? " + (isLive ? "live" : "on-demand"),
                "Media? " + (mediaIsReady ? "ready" : "not ready"),
                "media duration: " + mediaElement.duration);

            if ((metadataUpdated || !isSetUp) && mediaIsReady) {

                setTimeout(function () {
                    var mediaInfo = getMediaInfo(mediaElement);
                    mediaPlayerView.UpdateMediaInfo(mediaInfo);

                    if (!isSetUp) {
                        isSetUp = true;

                        self.SetVolume(volume);

                        _events.fire(_eventNames.MediaLoaded, mediaInfo);
                    }
                }, 100);
                
            }
        }


        if (event.type === "durationchange" && Mediasite.BrowserDetect.Android()) {
            if (isSetUp && metadataUpdated) {
                var mediaInfo = getMediaInfo();
                _events.fire(_eventNames.MediaUpdated, mediaInfo);
            }
        }

        switch (event.type) {
            case "volumechange":
                var vol = 0;
                if (!mediaElement.muted) {
                    vol = Math.round(self.GetVolume());
                }
                _events.fire(_eventNames.VolumeChanged, vol);
                break;
            case "ratechange":
                var playbackRateInfo = {
                    Min: playbackRates[0],
                    Max: playbackRates[playbackRates.length - 1],
                    Current: Math.round(self.GetPlaybackRate() * 100) / 100,
                    Rates: $.extend([], playbackRates)
                };

                logger.Debug("Playback rate now " + playbackRateInfo.Current);
                _events.fire(_eventNames.PlaybackRateChanged, playbackRateInfo);
                break;
            case "playing":
                liveMediaResetInProgress = false;

                mediaElement.playbackRate = currentPlaybackRate;
                liveMediaOpenReconnector.setMediaState(Mediasite.Player.MediaState.Playing);
                reportStateChanged(Mediasite.Player.MediaState.Playing);
                _positionEventSimulator.start();


                // HLS in iOS provides a true position, but Safari on OS X gives us no way to 
                // know where we really are in a live HLS stream. For OS X Safari, guestimate 
                // how far off the reported position is from true position. Android seems to 
                // always start playing at zero, so we need to use the position reported.
                if (isLivePresentation()) {
                    var now = new Date();
                    liveStartOffset = now - liveStartTime
                        - 39000    // Fudge it a little for lag
                        - (mediaElement.currentTime * 1000); // to account for media not resetting its current time to 0 when stopping
                    liveStartOffset = Math.max(0, liveStartOffset);
                }


                // When pausing and then playing, some clients continue playing from 
                // where they were paused. We'd them to catch up to live.
                //if (isLivePresentation() && mediaElement.seekable && mediaElement.seekable.length > 0) {
                //    var end = mediaElement.seekable.end(mediaElement.seekable.length - 1);
                //    end -= 1; // Back off from absolute end to avoid over-seeking problems.

                //    // Only attempt to seek to end/live if current time is more than a couple of seconds behind.
                //    if (end != Infinity && end - 2 > mediaElement.currentTime) {
                //        logger.Debug('Seeking closer to end.');
                //        _isSeekingToEnd = true;
                //        mediaElement.currentTime = end;
                //        return; // ### Early out that intentionally avoids loggin playback start. ###
                //    }
                //}

                // When playing begins, we expect a PositionChanged event that 
                // matches the time we were resting at when the play() was called.
                // 
                // variables `positionWhenPlayWasCalled` and `suppressPositionUpdatedUntilPlaying` 
                // help accomplish this.
                if (suppressPositionUpdatedUntilPlaying) {
                    _events.fire(_eventNames.PositionChanged, positionWhenPlayWasCalled, true);
                    suppressPositionUpdatedUntilPlaying = false;
                }

                _isSeekingToEnd = false;

                _playCount++;

                break;
            case "pause":
                _positionEventSimulator.stop();
                _events.fire(_eventNames.PositionChanged, self.GetPosition());
                reportStateChanged(getPausedState());
                break;
            case "seeking":
                _events.fire(_eventNames.SeekStarting);
                // Don't report current position at this point
                // because the media element may end up at a different position
                break;
            case "seeked":
                _events.fire(_eventNames.SeekEnding);
                _isSeekingToEnd = false;
                if (manualSeekFlag) {
                    manualSeekFlag = false;
                }
                else {
                    lastSeekTime = self.GetPosition();
                }
                break;
            case "timeupdate":
                if (_isSeekingToEnd) {
                    // We don't want to update our current position until 
                    // we've settled on where we want to play from. 
                    // (particularly when seeking ahead and coming up short)
                    return;
                }

                var currentPosition = self.GetPosition();
                //console.log('timeupdate ' + currentPosition);

                //ios 7 seeking fix - MSL
                //We noticed that in IOS 7.0.2 & 7.0.3 that after seeking, the video element
                //would occasionally report a few timeupdates with times previous to the manual seek.
                //This code seeks to alleviate those situations by monitoring timeupdates for 5 seconds after a seek
                //and skip and timeupdates that appear to erroneous
                if (viewOptions.isMainStream) {
                    var currentTimeDate = new Date();
                    var elapsedRealTime = currentTimeDate - lastSeekTimeDate;
                    var elapsedVideoTime = elapsedRealTime * currentPlaybackRate;
                    var expectedTime = lastSeekTime + elapsedVideoTime;
                    var timeVariance = Math.abs(currentPosition - expectedTime);

                    if (lastSeekTime > 0 && elapsedRealTime < 5000) {
                        if (timeVariance > 5000) {
                            return;     //currentPosition does not appear to be correct and will be skipped. 
                        }
                    }
                }
                /*
                Mediasite.Debug.Repo.Push("timeupdate", {
                currentPosition: currentPosition,
                playbackRate: currentPlaybackRate,
                streamType: viewOptions.streamType,
                timestamp: Date.now()
                });
                */

                // When playing begins, we expect a PositionChanged event that 
                // matches the time we were resting at when the play() was called.
                // 
                // variables `positionWhenPlayWasCalled` and `suppressPositionUpdatedUntilPlaying` 
                // help accomplish this.
                if (!suppressPositionUpdatedUntilPlaying) {
                    _events.fire(_eventNames.PositionChanged, currentPosition, true);
                }
                break;
            case "ended":
                convivaTracker.handleMediaEnded(isLivePresentation());

                _positionEventSimulator.stop();
                _events.fire(_eventNames.PositionChanged, self.GetPosition());
                self.Stop();
                lastSeekTime = 0;
                _events.fire(_eventNames.PositionChanged, 0);
                reportStateChanged(Mediasite.Player.MediaState.MediaEnded);
                break;
        }
    }


    var didMediaMetadataUpdate = (function () {
        var lastDuration;
        var lastAspectRatio;

        return function () {
            var updated = false;

            var currentDuration = mediaElement.duration;
            var currentAspectRatio = mediaElement.videoWidth / mediaElement.videoHeight;

            updated = updated || (currentDuration != lastDuration || currentAspectRatio != lastAspectRatio);

            lastDuration = currentDuration;
            lastAspectRatio = currentAspectRatio;

            return updated;
        }
    })();

    function getMediaInfo() {
        logger.Debug("Loading media metadata ...");
        var isLive = isLivePresentation();

        var canChangeSpeed = !(isLive || Mediasite.BrowserDetect.IOS() || Mediasite.BrowserDetect.Android());

        detectFullScreen(mediaElement);

        // Mediasite's adaptive HTML5 media is never audio-only, because the 
        // base Smooth Streaming content must include video. Only measure MP4.
        var audioOnly = (viewOptions.pluginMimeType === Mediasite.Player.MimeTypes.MP4) ? (mediaElement.videoHeight <= 0) : false;

        var aspectRatio = 0;
        if (mediaElement.videoHeight > 0 && mediaElement.videoWidth > 0) {
            aspectRatio = mediaElement.videoWidth / mediaElement.videoHeight;
        }

        var duration = self.GetDuration();

        var mediaInfo = {
            AspectRatio: aspectRatio,
            AudioOnly: audioOnly,
            Duration: duration,
            CanChangeSpeed: canChangeSpeed,
            CanFullScreen: canFullScreen,
            CurrentLanguageIndex: 0,
            Languages: {},
            StreamType: viewOptions.streamType,
            IsMainStream: viewOptions.isMainStream
        };

        logger.Debug("... finished loading media metadata.", mediaInfo);


        return mediaInfo;
    }


    /*
    On small devices like iPhone or iPod, in mobile Safari, video plays in fullscreen.
    When exiting full-screen, the video element reports timeupdate -> 0 even if the video was
    at time: X when the user exited fullscreen. (Observed on iOS7 iPod in mobile Safari.)
    This codeq intercedes: "belay that, the time was actually X" (except when rewinding/seeking).

    Played through end of presentation:

    [Log]   timeupdate    currentTime: [end of media]
    [Log]   ended
    [Log]   seeking
    [Log]   timeupdate    currentTime: 0
    [Log]   seeked
    [Log]   timeupdate    currentTime: 0

    mediaElement.currentTime:  0

    -- vs --

    Simply exiting fullscreen

    [Log]   timeupdate    currentTime: [arbitrary time not at end]
    [Log]   timeupdate    currentTime: 0
    [Log]   pause
    [Log]   webkitendfullscreen

    mediaElement.currentTime:  0
    */
    var fullscreenPosition;
    var handleFullscreenTimeEvents = (function () {
        var isFullscreen;
        var allowZeroPosition;

        return handleFullscreenTimeEvents;

        function handleFullscreenTimeEvents(event) {
            // console.log(event.type, getPosition())
            switch (event.type) {
                case "webkitendfullscreen":
                    // Belay the previous useless "timeupdate: 0" from the media element leaving fullscreen
                    _events.fire(_eventNames.PositionChanged, fullscreenPosition);
                    // console.log("webkitendfullscreen", "position:", fullscreenPosition);

                    isFullscreen = false;
                    break;
                case "webkitbeginfullscreen":
                    // Start tracking legitimate currentTimes
                    isFullscreen = true;
                    allowZeroPosition = false;
                    fullscreenPosition = getPosition();
                    return;
                case "seeked":
                    if (isFullscreen) {
                        // User/API seeked or media element is rewinding from the end
                        // Expect a timeupdate to follow. It's still legitimate if it's s 0.
                        allowZeroPosition = true;
                    }
                    // console.log("seeked", "allowZeroPosition:", allowZeroPosition);
                    break;
                case "timeupdate":
                    if (isFullscreen) {
                        //  Record the current position (if it's legitimate)
                        //  for when the media element inevitably betrays us


                        var position = getPosition();
                        if (position > 0 || allowZeroPosition) {
                            fullscreenPosition = position;
                        }

                        // Don't trust subsequent timeupdate events of currentTime=0
                        allowZeroPosition = false;
                    } else {
                        // Video is not fullscreen, assume timeupdate/currentTime is legitimate
                        fullscreenPosition = false;
                    }
                    break;
            }
        }
    })();


    function getNextPlaybackRate(faster, loop) {
        /// <summary>Select the playback rate which is after (faster or slower) than the current playback rate</summary>
        /// <param name="faster" type="bool">True for faster playrate (default), false for a slower playrate</param>
        /// <param name="loop" type="bool">True for loop-around (speed > max ==> min and vice versa) (default), false for stopping at the min/max</param>

        // Sanitize parameters
        faster = faster !== false; // cast to boolean, undefined = true
        loop = loop !== false;  // cast to boolean, undefined = true

        // Get current playback rate
        var speed = Math.round(self.GetPlaybackRate() * 100) / 100;

        // Fetch and sanitize available playback rates (non-destructively)
        var toggleSteps = $.extend([], playbackRates);
        if (toggleSteps.length == 0) {
            toggleSteps = [1];
        }

        // If selecting a slower speed, scan the list in reverse.  (Mentally flip "min" and "max")
        var sortCoefficient = (faster ? 1 : -1);
        toggleSteps.sort(function (a, b) { return sortCoefficient * (a - b); });

        // Select the next playback rate
        if (loop && 0 >= sortCoefficient * (toggleSteps[toggleSteps.length - 1] - speed)) { // speed > max, accounting for faster/slower
            // Speed > max, so loop around to min 
            speed = toggleSteps[0];
        } else {
            // Select the speed faster/slower than the current speed
            $.each(toggleSteps, function (i, thisToggleStep) {
                if (0 >= sortCoefficient * (thisToggleStep - speed) // speed >= thisToggleSpeed, accounting for faster/slower
                    && 0 < sortCoefficient * (toggleSteps[i + 1] - speed)) { // speed < toggleSteps[i + 1], same
                    speed = toggleSteps[i + 1];
                    return false;
                }
            });
        }

        return speed;
    };

    this.SetupMediaElement = function (sources) {

        mediaPlayerView = $.extend(new Mediasite.MediaPlayer.BaseView(), new Mediasite.MediaPlayer.Html5View(viewOptions));
        element = mediaPlayerView.CreateMediaElement();
        mediaWrapper = mediaPlayerView.Wrapper;
        mediaElement = element.get(0);
        mediaSources = sources;

        // Android allegedly works better if preload=none; autoplay overrides preload. (Android shouldn't autoplay anyway.)
        if (Mediasite.BrowserDetect.Android()) {
            mediaElement.setAttribute('preload', 'none');
        }

        // Set up event handling
        if (Mediasite.BrowserDetect.IOS()) {
            element.bind("webkitbeginfullscreen webkitendfullscreen seeked timeupdate", handleFullscreenTimeEvents);
        }
        element.bind("stalled canplaythrough emptied suspend waiting abort error loadeddata canplay loadstart play playing pause loadeddata ended seeking seeked ratechange durationchange volumechange timeupdate", handleMediaEvent);
        element.bind("error", handleMediaError);
        element.bind('playCoverPressed', function () { self.Play(); });
        element.bind('contextmenu', function () { return false; });

        loadTheMediaSource();

        detectFullScreen(mediaElement); // try to see if fullscreen support might be available in order to provide UI which anticipates this functionality
    };

    function loadTheMediaSource() {
        var msSinceLiveStartWasSet = (new Date()).getTime() - _liveStartTimeWasSetAt;
        var isTransitionFromWaitingToLive = msSinceLiveStartWasSet < 2 * 1000;
        var mediaSource;
        var mediaType;
        var source = viewOptions.selectedVideoSource;

        if (source.MimeType === viewOptions.pluginMimeType) {
            mediaSource = (source.Location);
            mediaType = source.MimeType;
        }


        // Sanity check media source
        if (typeof (mediaSource) === 'undefined') {
            _events.fire(_eventNames.MediaError, Mediasite.Player.Localization.MediaPlayer.NoCompatibleMediaError);
            return;
        }


        if (isTransitionFromWaitingToLive) {
            touchThePublishingPoint(mediaSource, mediaType, function () {
                setVideoElementSource(element, mediaSource, mediaType);
            });
        } else {
            setVideoElementSource(element, mediaSource, mediaType);
        }
    }

    this.SetNewVideo = function (sources, streamType) {

        //Change the video url for the Play As Video function

        var oldVideoSource = viewOptions.selectedVideoSource;
        var newVideoSource = findMediaSource(sources, oldVideoSource.MediaType, oldVideoSource.MimeType, oldVideoSource.ServerType);

        if (!newVideoSource || !newVideoSource.Location) {
            _events.fire(_eventNames.MediaError, Mediasite.Player.Localization.MediaPlayer.NoCompatibleMediaError);
            return;
        }

        // Determine which media source to use
        var mediaType = viewOptions.pluginMimeType;
        var mediaSource = newVideoSource.Location;

        if (element == undefined) {
            _events.fire(_eventNames.MediaError, Mediasite.Player.Localization.MediaPlayer.NoCompatibleMediaError);
            return;
        }

        viewOptions.streamType = streamType;
        viewOptions.selectedVideoSource = newVideoSource;
        mediaSources = sources;

        setVideoElementSource(element, mediaSource, mediaType);

        isSetUp = false;
    }

    function findMediaSource(sources, mediaType, mimeType, serverType) {
        var i, s;
        for (i = 0; i < sources.length; i += 1) {
            var s = sources[i];
            if (s.MediaType === mediaType && s.MimeType === mimeType && s.ServerType === serverType) {
                return s;
            }
        }
    }

    function selectMediaSourceByMimeType(sources, mimeType) {
        for (var i = 0; i < sources.length; i++) {
            if (sources[i].MimeType === mimeType) {
                return (sources[i].Location);
            }
        }
    }

    function setVideoElementSource(element, mediaSource, mediaType) {
        convivaTracker.handleMediaSourceLoading(mediaSource, mediaElement);

        self.setVideoElementSourceFunction(element, mediaSource, mediaType);

        logger.Info("Media element source added", mediaType, mediaSource);
    }

    this.setVideoElementSourceFunction = function (element, mediaSource, mediaType) {
        element.empty();
        var sourceElement = $('<source></source>');
        sourceElement.attr({
            'src': mediaSource,
            'type': mediaType
        });
        element.prepend(sourceElement);
        mediaElement.load();
    };

    this.raiseMediaError = function (errorDescription) {
        if (liveMediaOpenReconnector.HandleLiveMediaOpenFailure(reconnectOnLiveMediaOpenFailure)) {
            return; // The error is being handled by the LiveMediaOpenReconnector.
        }

        convivaTracker.handleMediaError(errorDescription, true);

        _events.fire(_eventNames.MediaError, errorDescription);
    };

    this.updateBitrate = function (bitrateBps) {
        convivaTracker.handleBitrateChange(bitrateBps);
    };

    
    function makeConvivaConfig(options) {
        
        var lateBoundConvivaConfigs = {
            streamType: options.streamType,
            pluginMimeType: options.pluginMimeType,
            streamUrl: viewOptions.selectedVideoSource.Location,
            mediaPlayerType: options.mediaPlayerType
        }

        return options.getConvivaConfiguration(lateBoundConvivaConfigs);
    }

    function canUniquifyMediaUrl(mediaUrl) {
        // TODO: Keep separate counters for each mediaUrl.

        return uniquifyCounter < maxUniquifications;
    }

    function uniquifyMediaUrl(mediaUrl) {
        var paramKey = 'sf-uniquify';
        var combiner = (mediaUrl.indexOf('?') === -1) ? '?' : '&';
        var value;

        uniquifyCounter += 1;

        if (uniquifyCounter > maxUniquifications) {
            throw { name: 'ExcessiveMediaUrlUniquifications', message: 'The maximum number of media URL uniquification attempts has been exceeded.' };
        }

        value = uniquifyCounter;

        return mediaUrl + combiner + paramKey + '=' + value;
    }

    function touchThePublishingPoint(mediaSource, mediaType, callback) {
        logger.Debug('Kicking the HLS publishing point to make sure it is awake.');
        var $vid = $('<video webkit-playsinline playsinline></video>');
        var vid = $vid.get(0);
        setVideoElementSource($vid, mediaSource, mediaType);
        vid.load();

        window.setTimeout(function () {
            vid.pause();
            $vid.remove();
            $vid = null;
            vid = null;

            callback();
        }, 1000);
    }

    this.UpdateOptions = function (options) {
        volume = options.Volume;

        // When we get the UpdateOptions call, we know that the 
        // controller is ready for our notifications. This is the 
        // closest thing we have to a Ready or Initialized method.

        // HLS is always significantly delayed, so we can be lazy 
        // about transitioning from the waiting to live states. 
        // If we're too eager to load an HLS stream, we can 
        // attempt to load before the publishing point is ready, 
        // causing the video element to get into an unrecoverable 
        // non-loaded state.
        _events.fire(_eventNames.EnableLiveStartDelay, self.liveStartUnsafeWindow);
    };

    this.LiveStatusChange = function (status) {
        var ps = Mediasite.Player.PresentationStatus;
        var isLively = (status == ps.Live || status == ps.LivePaused || status == ps.OpenForLive || status == ps.ScheduledForLive);

        if (isLively) {
            _events.fire(_eventNames.EnableLiveJumpBackDetection);
        }

        if (isLively && !_safariHlsBadStartCorrector && Mediasite.BrowserDetect.MacSafari() && Mediasite.BrowserDetect.MacOS10DotWhat() < 7) {
            _safariHlsBadStartCorrector = new SafariHlsBadStartCorrector(resetLiveMediaElement);
        }

        liveMediaOpenReconnector.setLiveStatus(status);

        if (status == ps.LiveEnded) {
            convivaTracker.handleMediaEnded(isLivePresentation());

            _hasReachedLiveEnd = true;
            _events.fire(_eventNames.PositionChanged, 0);
            self.Stop();
        }
    };

    this.InactivityExceeded = function () {
        inactivityExceeded = true;

        if (status === Mediasite.Player.PresentationStatus.Live || status === Mediasite.Player.PresentationStatus.LiveEnded) {
            window.location.reload();
        }
    };

    this.SetPosition = function (positionInMS) {
        if (inactivityExceeded === true) {
            window.location.reload();
            return;
        }

        var canSeek = false;
        if (mediaElement.seekable.length > 0) {
            for (var i = 0; i < mediaElement.seekable.length; i++) {
                if (positionInMS >= (mediaElement.seekable.start(i) * 1000) && positionInMS <= (mediaElement.seekable.end(i) * 1000)) {
                    canSeek = true;
                }
            }
        }

        if (canSeek) {
            manualSeekFlag = true;
            lastSeekTime = positionInMS;
            lastSeekTimeDate = new Date();

            // prevent currentTime from being set to the same value multiple times in a row - BUG41168
            if (mediaElement.currentTime != (positionInMS / 1000)) {
                mediaElement.currentTime = (positionInMS / 1000)

                // preemptively report the requested position as the "current" position and assume the media element will catch up soon
                _events.fire(_eventNames.PositionChanged, positionInMS);
            }

        }
    };

    this.PlayDefaultRate = function () {
        var playbackRate = defaultPlaybackRate;
        self.SetPlaybackRate(playbackRate);
    };

    this.PlayFaster = function (doLoop) {
        doLoop = doLoop === true; // Default behavior: do not loop
        var playbackRate = getNextPlaybackRate(true, doLoop);
        self.SetPlaybackRate(playbackRate);
    };

    this.PlaySlower = function (doLoop) {
        doLoop = doLoop === true; // Default behavior: do not loop
        var playbackRate = getNextPlaybackRate(false, doLoop);
        self.SetPlaybackRate(playbackRate);
    };

    this.SkipBack = function () {
        var newPosition = self.GetPosition() - skipBackAmount;

        if (newPosition < 0) {
            newPosition = 0;
        }

        self.SetPosition(newPosition);
    };

    this.SkipForward = function () {
        var newPosition = self.GetPosition() + skipForwardAmount;

        if (newPosition < self.GetDuration()) {
            self.SetPosition(newPosition);
        }
    };

    this.FullScreen = function () {
        if (state === Mediasite.Player.MediaState.Ready) {
            // Media player must be activated before full screen can be activated
            self.Play();
        }

        if (!canFullScreen) {
            // Double-check fullscreen capability, since we might have been asked
            // to fullscreen before running the usual capability check.
            detectFullScreen(mediaElement);
        }


        if (canFullScreen && typeof enterFullScreen == "function") {
            enterFullScreen();
        }
    };

    this.SetAudioLanguage = function (language) {
    };

    this.Pause = function () {
        mediaElement.pause();
        reportStateChanged(getPausedState());
    };

    this.Play = function () {

        if (inactivityExceeded === true) {
            window.location.reload();
            return;
        }

        if (liveMediaResetInProgress) {
            return;
        }

        if (isLivePresentation() && _playCount > 0) {
            // Most browsers play live HLS from the paused position 
            // when resuming play. This can cause the video to lag (significantly) 
            // behind live. Destroy and recreate the media element to avoid issues.
            // This approach does not work well on clients that require user interaction 
            // on first play, since the user will have to press play again.

            resetLiveMediaElement();
            return;
        }

        // When playing begins, we expect a PositionChanged event that 
        // matches the time we were resting at when the play() was called.
        // 
        // variables `positionWhenPlayWasCalled` and `suppressPositionUpdatedUntilPlaying` 
        // help accomplish this.
        positionWhenPlayWasCalled = self.GetPosition();
        if (state !== Mediasite.Player.MediaState.Playing) {
            suppressPositionUpdatedUntilPlaying = true;
        }
        _events.fire(_eventNames.PositionChanged, positionWhenPlayWasCalled, true);

        // Remember the pre-play playback rate.
        var rateBeforePlay = mediaElement.playbackRate;

        var setPlaybackRateOnPlay = function () {
            if (mediaElement.playbackRate != rateBeforePlay) {
                // Restore the pre-play rate.
                // IE9 sets the rate to 1.0 whenever play() is called, 
                // but the correct behavior for our player is to maintain 
                // playback rate. Silly IE! Note that if the user 
                // right-clicks on the video and chooses "Play" from the 
                // context menu, the playback rate will still be set to 1.0.
                mediaElement.playbackRate = rateBeforePlay;
            }
        };

        var playPromise = mediaElement.play();
        if (playPromise !== undefined) {

            playPromise.then(function () {

                // play() worked. Yay!
                setPlaybackRateOnPlay();

            }).catch(function (error) {

                // play() failed. This is likely because the browser prevented autostart. 
                // Ensure that a Stopped/Paused state event is raised to ensure that other 
                // video streams aren't left playing.
                self.Pause();

                setPlaybackRateOnPlay();

            });

        } else {
            setPlaybackRateOnPlay();
        }

    };

    this.Stop = function () {
        self.Pause();
    };


    this.trustLiveVideoElementPosition = true;

    this.getLiveMediaTimeOffset = function () {
        return liveStartOffset;
    };


    function getPosition() {
        var result = mediaElement.currentTime * 1000;
        if (!self.trustLiveVideoElementPosition) {
            result += self.getLiveMediaTimeOffset();
        }
        return result;
    }

    this.GetPosition = function () {
        var position = getPosition();

        if (fullscreenPosition && position === 0) {
            // Don't trust the media element, use the latest legitimate position
            position = fullscreenPosition;
        }


        if (position < _zeroEquivalencyThreshold && lastSeekTime == 0) {
            // In iOS, seeking back to zero may cause the video element to 
            // come to rest at a position slightly greater than zero. This 
            // seems to occur when the MP4 content was transcoded from 
            // a Smooth Streaming source using the MATCH SOURCE option. 
            // The position at which the video element rests is equal to 
            // the time between frames in the video. For example, for 25fps 
            // content, the position is 40ms. To work around this behavior, 
            // we will return zero from positions very close to zero.
            return 0;
        }

        if (_hasReachedLiveEnd) {
            // Don't listen to that silly media element. 
            // If live has ended, we're at zero.
            return 0;
        }

        if (lastSeekTime) {
            var variance = position - lastSeekTime;
            if (_manualSeekThreshold < variance && variance < 0) {
                // The media element was recently instructed to seek to a given position,
                // but the media element is reporting a time slightly before that.
                // However, for consistency, the model should report the requested position
                // and assume the media will catch up (or seek elsewhere) soon.

                return lastSeekTime;
            }
        }

        return position;
    };

    this.GetDuration = function () {
        if (mediaElement.duration == Number.POSITIVE_INFINITY) {
            return 0;
        } else if (isLivePresentation()) {
            return 0;
        }
        return mediaElement.duration * 1000;
    };

    this.SetVolume = function (val) {
        if (disableVolume) {
            mediaElement.volume = 0;
            mediaElement.muted = true;
        } else {
            mediaElement.volume = val / 100;
            mediaElement.muted = (val == 0);
            _events.fire(_eventNames.VolumeChanged, Math.round(self.GetVolume()));
        }
    };

    this.GetVolume = function () {
        return mediaElement.volume * 100;
    };

    this.StepVolumeUp = function () {
        if (!disableVolume) {
            if (mediaElement.volume <= 0.8) {
                this.SetVolume((mediaElement.volume + 0.2) * 100);
            }
            else {
                this.SetVolume(100);
            }
        }
    };

    this.StepVolumeDown = function () {
        if (!disableVolume) {
            if (mediaElement.volume > 0.2) {
                this.SetVolume((mediaElement.volume - 0.2) * 100);
            } else {
                this.SetVolume(0);
            }
        }
    };

    this.ToggleMute = function () {
        if (!disableVolume) {
            if (self.IsMuted()) {
                self.UnMute();
            } else {
                self.Mute();
            }
        }
    };

    this.Mute = function () {
        mediaElement.muted = true;

        if (self.IsMuted()) {
            _events.fire(_eventNames.VolumeChanged, 0);
        }
    };

    this.UnMute = function () {
        if (!disableVolume) {
            mediaElement.muted = false;

            if (self.GetVolume() === 0) {
                this.SetVolume(50);
            } else {
                _events.fire(_eventNames.VolumeChanged, Math.round(self.GetVolume()));
            }
        }
    };

    this.IsMuted = function () {
        return mediaElement.muted;
    };

    this.TogglePlayPause = function () {
        // In iOS 4.3 mediaElement.ended is always true after about the first three 
        // seconds of live HLS content, so only trust that during on-demand.
        if (mediaElement.paused || (!isLivePresentation() && mediaElement.ended)) {
            self.Play();
        }
        else {
            self.Pause();
        }
    };

    this.SetPlaybackRate = function (speed) {
        currentPlaybackRate = speed;

        if (mediaElement) {
            mediaElement.playbackRate = speed;
        }
    };

    this.GetPlaybackRate = function () {
        return mediaElement.playbackRate;
    };

    this.GetVideoWidth = function () {
        return mediaElement.videoWidth;
    };

    this.SetLiveStartTime = function (time) {
        liveStartTime = time;
        _liveStartTimeWasSetAt = (new Date()).getTime();
        liveMediaOpenReconnector.setLiveStartTime(time);
    };

    this.RetrieveBookmark = function (bookmark) {
        clientBookmark = bookmark;
    };


    function NullPositionUpdateSimulator() {
        this.start = function () { };
        this.stop = function () { };
    }

    function PositionUpdateSimulator() {

        var self = this;
        var theEventName = Mediasite.MediaPlayer.ModelEvent.PositionChanged;
        var delayMS = 200;
        var lastRaisedPositionValue = -1;
        var timeout;


        this.fireEventDelegate = function (eventName, eventData) { }; // Replace with useful instance at runtime.
        this.getPositionDelegate = function () { return 0; }; // Replace with useful instance at runtime.


        this.start = function () {
            cancelIt()
            raiseEvent();
            scheduleIt();
        };

        this.stop = function () {
            cancelIt();
        };


        function timeoutCallback() {
            raiseEvent();
            scheduleIt();
        }

        function scheduleIt() {
            window.setTimeout(timeoutCallback, delayMS);
        }

        function cancelIt() {
            window.clearTimeout(timeout);
            lastRaisedPositionValue = -1;
        }

        function raiseEvent() {
            var newPositionVlaue = self.getPositionDelegate();

            if (newPositionVlaue === lastRaisedPositionValue) {
                return; // No need to raise useless events.
            }

            lastRaisedPositionValue = newPositionVlaue;
            self.fireEventDelegate(theEventName, newPositionVlaue);
        }
    }


    function SafariHlsBadStartCorrector(resetFunction) {

        // Mac Safari on OS X 10.6 can fail to start live HLS 
        // playback if connecting at the beginning of the live stream. 
        // In this case the media element raises the loadstart event, 
        // but no events after that until we intervene.


        var _resetFunction = resetFunction;
        var _detectionIntervals = [5 * 1000, 10 * 1000, 20 * 1000]; // Three attempts with progressively longer delays.
        var _correctionAttempts = 0;
        var _timer = null;
        var _enabled = true;

        this.noteMediaEvent = function (eventType) {
            if (eventType == "canplay") {
                // If we see the canplay event, we're safely past the 
                // Safari HSL false start issue. Stop all detection.

                if (correctionIsScheduled()) {
                    cancelScheduledCorrection();
                }

                _enabled = false;

            } else if (eventType == "loadstart" && shouldScheduleCorrection()) {
                scheduleNextCorrection();
            }
        };


        function correctionIsScheduled() {
            return !!_timer;
        }

        function cancelScheduledCorrection() {
            window.clearTimeout(_timer);
            _timer = null;
        }

        function scheduleNextCorrection() {
            var delay = _detectionIntervals[_correctionAttempts];
            _timer = window.setTimeout(timer_tick, delay);
        }

        function timer_tick() {
            _resetFunction();
            _correctionAttempts++;
            _timer = null;
        }

        function shouldScheduleCorrection() {
            return _enabled && (_correctionAttempts < _detectionIntervals.length);
        }
    }

};

Mediasite.MediaPlayer.Html5View = function (options) {

    var element;
    var self = this;
    var wrapper;
    this.Wrapper = undefined;


    function newVideoElement() {
        if (Mediasite.Player.videoElementPool) {
            return Mediasite.Player.videoElementPool.getElement();
        } else {
            return document.createElement("video");
        }
    }


    this.CreateMediaElement = function () {

        options.mediaContainer.find('.MediaWrapper').remove();

        wrapper = $('<div id="MediaWrapper" class="MediaWrapper"></div>').appendTo(options.mediaContainer);
        this.Wrapper = wrapper;

        var vid = newVideoElement();
        vid.setAttribute("id", "MediaElement");
        vid.setAttribute("webkit-playsinline", "webkit-playsinline");
        vid.setAttribute("playsinline", "playsinline");
        vid.innerHTML = "This browser does not support HTML5 video";

        element = $(vid).appendTo(wrapper);
        element.bind('focus', function (e) {
            // In some browsers (I'm looking at you IE9+!) focused video elements act on 
            // some keyboard input. For example, the space key toggles play/pause. This 
            // behavior can interfere with our application's keyboard shorcuts, so let's 
            // sidestep the issue by preventing the video element form maintaining focus.
            element.parent().focus();
        });

        if (options.height && options.width) {
            element.css({ height: options.height, width: options.width });
        }

        if (options.isMobile && options.isMainStream) {
            var playCover = $('<div class="iosPlayCover"></div>');
            var playButton = $('<button></button>')
            .text(options.playButtonText)
            .appendTo(playCover);

            playCover.appendTo(wrapper);
            playCover.bind('click', function (e) { e.stopPropagation(); });
            playButton.bind({
                'click': function (e) {
                    e.stopPropagation();

                    if (element.length > 0 && element.get(0).play) {
                        element.trigger('playCoverPressed');
                    }
                },
                'mousemove': function (e) {
                    // Must stop propagation of event so that mousemove handlers 
                    // on stage elements don't interfere with out click handler.
                    // Touch is funny that way.
                    e.stopPropagation();
                }
            });

            var showPlayCover = function () {
                if (options.playCoverOnPause) {
                    playCover.show();
                }
            };

            // stalled media handling
            var isStallingTimerId = null;
            var isStalling = false;
            var negateMediaStalling = function () {

                clearTimeout(isStallingTimerId);
                isStalling = false;
                isStallingTimerId = setTimeout(showPlayCover, 1500);

            };

            element.bind({
                'timeupdate': negateMediaStalling,
                'play': function (e) {
                    playCover.hide();
                },
                'suspend': showPlayCover,
                'pause': showPlayCover,
                'stalled': function () { isStalling = true; }
            });
        }

        // Hack in a way for model to destroy and recreate the media element.
        element.get(0).Rebuild = function () {
            wrapper.remove();

            var newElement = self.CreateMediaElement();

            return newElement;
        };

        // Hack in a way for player view to determine position of media content
        options.mediaContainer.get(0).GetContentDimensions = $.proxy(this.GetContentDimensions, this);

        return element;
    };


    this.GetContentDimensions = function () {
        // Bounding box
        var dimensions = element.offset();
        containerWidth = element.width();
        containerHeight = element.width();

        // Adjust for content letterboxing
        var nativeWidth = element[0].videoWidth;
        var nativeHeight = element[0].videoHeight;

        var nativeAspectRatio = nativeWidth / nativeHeight;
        var displayAspectRatio = containerWidth / containerHeight;

        if (nativeAspectRatio > displayAspectRatio) {
            // Letterbox bars above/below content
            dimensions.width = containerWidth;
            dimensions.height = containerWidth / nativeAspectRatio;
            dimensions.top += ((containerHeight - dimensions.height) / 2);
        } else if (nativeAspectRatio < displayAspectRatio) {
            // Letterbox bars left/right of content
            dimensions.height = containerHeight;
            dimensions.width = containerHeight * nativeAspectRatio;
            dimensions.left += ((containerWidth - dimensions.width) / 2);
        } else {
            // Content fills bounding box, so don't touch anything
        }

        return dimensions;
    };
};

