How to develop your own 'Delegate'

What is a delegate?

A delegate is a JavaScript Object that links the "Scene" and the "Player".
This object listens to the Player to interact with the Scene, and it listens to the Scene to interact with the Player.
It is an indispensable object, to connect the final Interactive Experience to the player.

For more informations see this section

What does need a delegate?

A delegate needs a Player API to know which event to listen, and which method to call.
More the Player API is complete, more the user experience should be better.

For a better experience:

Player's methods (or equivalent in terms of the player concerned):

  • play()
  • pause()
  • seekTo()
  • setVolume()
  • mute/unmute()
  • enterFullscreen/exitFullscreen()
  • getDuration()
  • getVolume()
  • getMuted()
  • getStreamSize() (or getStreamRatio())
  • getPlayerSize() (getRendererSize())
  • getStreamPosition() (position relative to the player/renderer)
  • getCurrentTime()
  • getContainer() (or directly the DOM Element where the player is rendered)

Player's events (or equivalent in terms of the player concerned):

  • 'ready'
  • 'playing'
  • 'paused'
  • 'currentTimeChanged'
  • 'volumeChanged'
  • 'muted/unmuted'
  • 'seeking'
  • 'seeked'
  • 'ended'
  • 'error'
  • 'mediaChanged'
  • 'streamSizeChanged'
  • 'playerSizeChanged' ('rendererSizeChanged')
  • 'streamPositionChanged'

Nb: if the player API object has a 'getContainer' which gives a DOM Element method with real sizes, we can listen this element to get the RendererSizes and the RenderezSizes when changed.
So the getPlayerSize and the 'playerSizeChanged' event may become useless.

The minimum to run the Interactivity being:

Player's methods:

  • play()
  • pause()
  • seekTo()
  • getCurrentTime (if the 'currentTime' event is not available)
  • getContainer()

Player's events:

  • 'ready'
  • 'playing'
  • 'paused'
  • 'currentTime' ('progressTime')

Nb: with this minimum stuff, we can use the 'getContainer' method to get sizes. Considering the stream has same sizes than the player/renderer sizes.

In any case, it is required that a delegate reacts to :

  • adways.hv.events.LAYER_ADDED

and defines :

  • 'p2s.setStreamSize()'
  • 'p2s.setStreamTransform()'
  • 'p2s.setRenderSize()'
  • 'p2s.setCurrentTime()'
  • 'p2s.setDuration()' (for a better experience)

How to use the delegate to test it?

With the Experience Class, it's possible to pass your own delegate in order to test the delegate with the player concerned.
Here is an example to specify the HTML5 Video Delegate (developed by Adways) to an 'Experience' Object:

<script type="text/javascript" src="//dj5ag5n6bpdxo.cloudfront.net/libs/interactive/loader.js"></script>
<script type="text/javascript">
    var experience = new adways.interactive.Experience();
    experience.setPlayerAPI(document.getElementsByTagName('video')[0]); // getting the video DOM Element to use the JavaScript API
    experience.setDelegateBuilderURL('//dj5ag5n6bpdxo.cloudfront.net/libs/delegateBuilders/htmlvideo.js');
    experience.setDelegateBuilderClassname('adways.playerHelpers.HTMLVideoDelegateBuilder');
    experience.setPublicationID('mpNHyTH');
    experience.load();
</script>

This sample will load the publication "mpNHyTH", on top of a HTML5 Video Player, with the Delegate built by the "adways.playerHelpers.HTMLVideoDelegateBuilder" function.

Sample Delegate for HTML5 native video player, with comments

We are going to see, step by step, how to develop a delegate, based on the HTML5 native video player
There is no interface to develop a delegate, it's up to the developer to make it functional ;)

// we check if the Delegate is not already defined
if (typeof HTMLVideoDelegate != "function") {

// the constructor needs at least "Player2Scene SceneControllerWrapper" (P2S)
// For more informations see this section
// and  "Scene2Player SceneControllerWrapper" (S2P) to communicate with the interactive engine.
// And the JavaScript Player Object to communicate with the player API.
HTMLVideoDelegate = function(p2s, s2p, playerAPI) {

    // we keep each arguments to local attributes
    this.playerAPI = playerAPI;
    this.p2s = p2s;
    this.s2p = s2p;

    // the default base z-index for layers which will receive the enrichments
    this.zIndexLayers = 1;

    // the 3 default DOM Elements: "Layers"
    this.hotspotDiv = null;
    this.controllerDiv = null;
    this.popupDiv = null;

    var that = this;

    // callback functions called by the different events attached to the playerAPI
    this.videoDurationListener = function() {
        // when receiving this event, we notify the P2S the new duration number
        if(that.playerAPI.duration!==0) that.p2s.setDuration(that.playerAPI.duration);
    };
    // we call this callback once at the beginning to initialize the duration
    this.videoDurationListener();

    // we can use the same protocol for each callback
    // link: "setter" on the scene with a "getter" from the player
    // (duration, currentTime, volume, mute, etc...)
    this.videoPlaybackRateListener = function() {
        that.p2s.setPlaybackRate(that.playerAPI.playbackRate);
    };
    this.videoPlaybackRateListener();

    this.videoTimeUpdateListener = function() {
        that.p2s.setCurrentTime(that.playerAPI.currentTime);
    };
    this.videoTimeUpdateListener();

    this.videoPlayStateListener = function() {
        if (that.playerAPI.paused)
            that.p2s.pause();
        else
            that.p2s.play();
    };
    this.videoPlayStateListener();

    this.isSeeking = false;
    this.videoSeekingListener = function() {
        if (!that.isSeeking) {
            that.isSeeking = true;
            that.savePlayState = that.playerAPI.paused;
            that.p2s.pause();
        }
    };

    this.videoSeekedListener = function() {
        that.isSeeking = false;
        if (that.savePlayState)
            that.p2s.pause();
        else
            that.p2s.play();
        if (!that.savePlayState)
            that.p2s.play();
    };

    this.videoEndedListener = function() {
        if (that.playerAPI.ended)
            that.p2s.end();
    };
    this.videoEndedListener();

    this.videoVolumeListener = function() {
        if (that.playerAPI.muted)
            that.p2s.mute();
        else
            that.p2s.unmute();
        that.p2s.setVolume(that.playerAPI.volume);
    };
    this.videoVolumeListener();

    // 
    this.sizeWatcherListener = function() {
        if (that.p2s.setRenderSize(new Array(that.playerAPI.offsetWidth, that.playerAPI.offsetHeight)) > 0) {
            that.updateStreamRect();
        }
        that.updateLayersPosition();
    };
    this.sizeWatcherListener();

    this.videoFullscreenListener = function() {
        // with the HTML5 player case, Scene Layers are not embed with the player when entering fullscreen
        // 
        var fullscreenElement = document.fullscreenElement || document.mozFullScreenElement || document.webkitFullscreenElement || document.msFullscreenElement;
        if (fullscreenElement === that.playerAPI.parentNode)
            that.p2s.enterFullscreen();
        else
            that.p2s.exitFullscreen();
    };

    // we notify the P2S what the player is able to change
    this.p2s.setCanChangeVolume(true);
    this.p2s.setCanChangeFullscreen(true);
    this.p2s.setCanChangePlaybackRate(true);
    this.p2s.setCanChangePlayState(true);
    this.p2s.setCanChangeCurrentTime(true);

    // we attach to the playerAPI all events available we need for the engine
    adways.misc.html.addEventListener(this.playerAPI, "durationchange", this.videoDurationListener, true);
    adways.misc.html.addEventListener(this.playerAPI, "ratechange", this.videoPlaybackRateListener, true);
    adways.misc.html.addEventListener(this.playerAPI, "timeupdate", this.videoTimeUpdateListener, true);
    adways.misc.html.addEventListener(this.playerAPI, "play", this.videoPlayStateListener, true);
    adways.misc.html.addEventListener(this.playerAPI, "pause", this.videoPlayStateListener, true);
    adways.misc.html.addEventListener(this.playerAPI, "ended", this.videoEndedListener, true);
    adways.misc.html.addEventListener(this.playerAPI, "seeking", this.videoSeekingListener, true);
    adways.misc.html.addEventListener(this.playerAPI, "seeked", this.videoSeekedListener, true);
    adways.misc.html.addEventListener(this.playerAPI, "volumechange", this.videoVolumeListener, true);

    adways.misc.html.addEventListener(document, "fullscreenchange", this.videoFullscreenListener, true);
    adways.misc.html.addEventListener(document, "mozfullscreenchange", this.videoFullscreenListener, true);
    adways.misc.html.addEventListener(document, "webkitfullscreenchange", this.videoFullscreenListener, true);
    adways.misc.html.addEventListener(document, "msfullscreenchange", this.videoFullscreenListener, true);

    // we attach to the S2P all events available we need for the player
    // see adways.resource.events namespace for more informations
    this.s2p.addEventListener(adways.resource.events.PLAY_STATE_CHANGED, this.s2pPlayStateChangedListener, this);
    this.s2p.addEventListener(adways.resource.events.VOLUME_CHANGED, this.s2pVolumeChangedListener, this);
    this.s2p.addEventListener(adways.resource.events.MUTED_CHANGED, this.s2pMutedChangedListener, this);
    this.s2p.addEventListener(adways.resource.events.CURRENT_TIME_CHANGED, this.s2pCurrentTimeChangedListener, this);
    this.s2p.addEventListener(adways.resource.events.PLAYBACK_RATE_CHANGED, this.s2pPlaybackRateChangedListener, this);
    this.s2p.addEventListener(adways.resource.events.FULLSCREEN_CHANGED, this.s2pFullscreenChangedListener, this);
    this.s2p.addEventListener(adways.hv.events.LAYER_ADDED, this.s2pLayerAddedListener, this);
    this.s2p.addEventListener(adways.hv.events.LAYER_REMOVED, this.s2pLayerRemovedListener, this);

    // as we don't have an event to listen the resize of the player
    // we launch a timer to check every 250ms the Player/renderer sizes
    this.sizeWatcherTimer = setInterval(this.sizeWatcherListener, 250);

    // at the beginning, if the scene already has its layers
    // we call the "s2pAddLayer" method to create the DOM Elements.
    // otherwise, the callback "s2pLayerAddedListener" will be called when LAYER_ADDED event is dispatched
    var layers = this.s2p.layersToArray();
    for (var i = 0; i < layers.length; ++i) {
        this.s2pAddLayer(layers[i]);
    }
};

// called when the Experience is destroyed, from the clearAll() method for example
HTMLVideoDelegate.prototype.destroy = function() {

    // we detach from the S2P all events available we needed for the player
    this.s2p.removeEventListener(adways.resource.events.PLAY_STATE_CHANGED, this.s2pPlayStateChangedListener, this);
    this.s2p.removeEventListener(adways.resource.events.VOLUME_CHANGED, this.s2pVolumeChangedListener, this);
    this.s2p.removeEventListener(adways.resource.events.MUTED_CHANGED, this.s2pMutedChangedListener, this);
    this.s2p.removeEventListener(adways.resource.events.CURRENT_TIME_CHANGED, this.s2pCurrentTimeChangedListener, this);
    this.s2p.removeEventListener(adways.resource.events.PLAYBACK_RATE_CHANGED, this.s2pPlaybackRateChangedListener, this);
    this.s2p.removeEventListener(adways.resource.events.FULLSCREEN_CHANGED, this.s2pFullscreenChangedListener, this);
    this.s2p.removeEventListener(adways.hv.events.LAYER_ADDED, this.s2pLayerAddedListener, this);
    this.s2p.removeEventListener(adways.hv.events.LAYER_REMOVED, this.s2pLayerRemovedListener, this);

    // we detach from the playerAPI all events available we needed for the P2S
    adways.misc.html.removeEventListener(this.playerAPI, "durationchange", this.videoDurationListener, true);
    adways.misc.html.removeEventListener(this.playerAPI, "ratechange", this.videoPlaybackRateListener, true);
    adways.misc.html.removeEventListener(this.playerAPI, "timeupdate", this.videoTimeUpdateListener, true);
    adways.misc.html.removeEventListener(this.playerAPI, "play", this.videoPlayStateListener, true);
    adways.misc.html.removeEventListener(this.playerAPI, "pause", this.videoPlayStateListener, true);
    adways.misc.html.removeEventListener(this.playerAPI, "ended", this.videoEndedListener, true);
    adways.misc.html.removeEventListener(this.playerAPI, "seeking", this.videoSeekingListener, true);
    adways.misc.html.removeEventListener(this.playerAPI, "seeked", this.videoSeekedListener, true);
    adways.misc.html.removeEventListener(this.playerAPI, "volumechange", this.videoVolumeListener, true);

    adways.misc.html.removeEventListener(document, "fullscreenchange", this.videoFullscreenListener, true);
    adways.misc.html.removeEventListener(document, "mozfullscreenchange", this.videoFullscreenListener, true);
    adways.misc.html.removeEventListener(document, "webkitfullscreenchange", this.videoFullscreenListener, true);
    adways.misc.html.removeEventListener(document, "msfullscreenchange", this.videoFullscreenListener, true);

    // clearing the interval for the size watcher
    clearInterval(this.sizeWatcherTimer);
};

// the scene notify the player its play state
HTMLVideoDelegate.prototype.s2pPlayStateChangedListener = function() {
    switch (this.s2p.getPlayState().valueOf()) {
        // PLAYING case from the adways.resource.playStates namespace, so the method "play" of the player is called
        case adways.resource.playStates.PLAYING:
            this.playerAPI.play();
            break;
        // PAUSED case from the adways.resource.playStates namespace, so the method "pause" of the player is called
        case adways.resource.playStates.PAUSED:
            this.playerAPI.pause();
            break;
    }
};

// the scene changed, so we notify the player its volume has changed
// we can use the same protocol for each callback
// link: "setter" on the player with a "getter" from the scene
// (duration, currentTime, volume, mute, etc...)
HTMLVideoDelegate.prototype.s2pVolumeChangedListener = function() {
    if (!isNaN(this.s2p.getVolume().valueOf()))
        this.playerAPI.volume = this.s2p.getVolume().valueOf();
};

HTMLVideoDelegate.prototype.s2pMutedChangedListener = function() {
    this.playerAPI.muted = this.s2p.getMuted().valueOf();
};

HTMLVideoDelegate.prototype.s2pCurrentTimeChangedListener = function() {
    if (!isNaN(this.s2p.getCurrentTime().valueOf()))
        this.playerAPI.currentTime = this.s2p.getCurrentTime().valueOf();
};

HTMLVideoDelegate.prototype.s2pPlaybackRateChangedListener = function() {
    if (!isNaN(this.s2p.getPlaybackRate().valueOf()))
        this.playerAPI.playbackRate = this.s2p.getPlaybackRate().valueOf();
};

HTMLVideoDelegate.prototype.s2pFullscreenChangedListener = function() {
    if (this.s2p.getFullscreen().valueOf()) {
        var element = this.playerAPI.parentNode;
        if (element.requestFullscreen)
            element.requestFullscreen();
        else if (element.mozRequestFullscreen)
            element.mozRequestFullscreen();
        else if (element.webkitRequestFullscreen) {
            element.webkitRequestFullScreen(Element.ALLOW_KEYBOARD_INPUT);
            if (!document.webkitCurrentFullScreenElement)
                element.webkitRequestFullScreen();
        }
        else if (element.msRequestFullscreen)
            element.msRequestFullscreen();
    }
    else {
        if (document.exitFullscreen)
            document.exitFullscreen();
        else if (document.cancelFullscreen)
            document.cancelFullscreen();
        else if (document.mozExitFullscreen)
            document.mozExitFullscreen();
        else if (document.mozCancelFullscreen)
            document.mozCancelFullscreen();
        else if (document.webkitExitFullscreen)
            document.webkitExitFullscreen();
        else if (document.webkitCancelFullscreen)
            document.webkitCancelFullscreen();
        else if (document.msExitFullscreen)
            document.msExitFullscreen();
        else if (document.msCancelFullscreen)
            document.msCancelFullscreen();
    }
};

// this callback notify the player a layer has to be added
// "evt" contains a layer object from "getLayer" method, with the DOM element from the "getDomElement" method
HTMLVideoDelegate.prototype.s2pLayerAddedListener = function(evt) {
    this.s2pAddLayer(evt.getLayer());
};

HTMLVideoDelegate.prototype.s2pAddLayer = function(layer) {
    var d = layer.getDomElement();
    // absolute style on the DOM Element to prevent position issue
    d.style.position = "absolute";
    // getting the parent node to append the layer
    var parent = this.video.offsetParent;
    if (parent === null)
        parent = this.video.parentNode;

    // we need to check which kind of layer we got to place them in the right order
    // Hotspot layer: behind others layers, end behind the native controller DOM Element of the player (if possible)
    // Popup layer: on top of others layers and the controller DOM Element of the player.
    //      Up to the developers to place it on top of all player elements.
    // Controller layer: on top of the Hotspot layer, behind the Popup layer.
    if (layer.getLayerId() == adways.hv.layerIds.HOTSPOT) {
        this.hotspotDiv = d;
        this.hotspotDiv.style.zIndex = this.zIndexLayers;
        if (this.controllerDiv != null)
            parent.insertBefore(d, this.controllerDiv);
        else
            parent.appendChild(d);
    } else if (layer.getLayerId() == adways.hv.layerIds.CONTROLLER) {
        this.controllerDiv = d;
        this.controllerDiv.style.zIndex = this.zIndexLayers + 1;
        if (this.popupDiv != null)
            parent.insertBefore(d, this.popupDiv);
        else
            parent.appendChild(d);
    } else if (layer.getLayerId() == adways.hv.layerIds.POPUP) {
        this.popupDiv = d;
        this.popupDiv.style.zIndex = this.zIndexLayers + 2;
        parent.appendChild(d);
    } else
        parent.appendChild(d);

    this.updateLayersPosition();
};

// method called to replace the layers if needed (player resize, playlist panel for example...)
HTMLVideoDelegate.prototype.updateLayersPosition = function() {
    // getting already added layers
    var layers = this.s2p.layersToArray();
    for (var i = 0; i < layers.length; ++i) {
        var d = layers[i].getDomElement();
        d.style.top = (this.playerAPI.offsetTop/* +top*/) + "px";
        d.style.left = (this.playerAPI.offsetLeft/*+left*/) + "px";
    }
};

// callback to remove a layer by id (adways.hv.layerIds namespace)
HTMLVideoDelegate.prototype.s2pLayerRemovedListener = function(evt) { 
    var layer = evt.getLayer();
    var d = layer.getDomElement();
    d.parentNode.removeChild(d);
    switch (layer.getLayerId()) {
        case adways.hv.layerIds.HOTSPOT:
            this.hotspotDiv = null;
            break;
        case adways.hv.layerIds.POPUP:
            this.popupDiv = null;
            break;
        case adways.hv.layerIds.CONTROLLER:
            this.controllerDiv = null;
            break;
    }
};

// The engine need the stream sizes and the stream position inside the renderer
// this method has to call "setStreamSize" and "setStreamTransform".
// We can use adways.geom.evaluateContentRect (as a helper) See adways.geom
HTMLVideoDelegate.prototype.updateStreamRect = function() {
    if (!isNaN(this.playerAPI.videoWidth) &&
            !isNaN(this.playerAPI.videoHeight) &&
            !isNaN(this.playerAPI.offsetWidth) &&
            !isNaN(this.playerAPI.offsetHeight)) {
        var streamSize = new Array(this.playerAPI.videoWidth, this.playerAPI.videoHeight);
        var tmp = adways.geom.evaluateContentRect(streamSize, new Array(this.playerAPI.offsetWidth, this.playerAPI.offsetHeight));
        this.p2s.setStreamSize(new Array(tmp[2], tmp[3]));
        this.p2s.setStreamTransform(new Array(1, 0, 0, 1, tmp[0], tmp[1]));
    }
    else {
        if (!isNaN(this.playerAPI.videoWidth) && !isNaN(this.playerAPI.videoHeight)) {
            console.warn("Not able to determine the container size, positions and sizes may be wrong");
            this.p2s.setStreamSize(new Array(this.playerAPI.videoWidth, this.playerAPI.videoHeight));
        }
        else {
            console.warn("Not able to determine the native stream size, positions and sizes may be wrong");
            this.p2s.setStreamSize(new Array(this.playerAPI.offsetWidth, this.playerAPI.offsetHeight)); // we can set (NaN,NaN) but setting the offsets is preferable
        }
        this.p2s.setStreamTransform(new Array(1, 0, 0, 1, 0, 0));
    }
};
}

// on this sample, we place the DelegateBuilder at the end of the Delegate, in the same file.
// we can imagine to append (to the body) the Delegate Script fom the DelegateBuilder to separate the files (DelegateBuilder.js / Delegate.js).
// So if the user sets -on the Experience object- "setDelegateBuilderURL" with the URL of this file,
// and "setDelegateBuilderClassname" with "adways.playerHelpers.HTMLVideoDelegateBuilder"
// this delegate will be built and plugged to the player given
adways.playerHelpers.HTMLVideoDelegateBuilder = function(experience) {
    var delegate = new HTMLVideoDelegate(experience.getP2S(), experience.getS2P(), experience.getPlayerAPI());
    experience.delegateReady(delegate);
};