UNPKG

shaka-player

Version:
347 lines (324 loc) • 10.8 kB
/*! @license * Shaka Player * Copyright 2016 Google LLC * SPDX-License-Identifier: Apache-2.0 */ goog.provide('shaka.ads.InterstitialAdSession'); goog.require('shaka.ads.Utils'); goog.require('shaka.util.Dom'); goog.require('shaka.util.EventManager'); goog.require('shaka.util.IReleasable'); goog.requireType('shaka.ads.InterstitialAd'); goog.requireType('shaka.net.NetworkingEngine'); /** * Owns the playback clock and measurement state of one interstitial. State * belongs to the session, so a callback from an earlier ad cannot track the * next ad in a pod. Format specific measurement is delegated to an optional * tracker. * * @implements {shaka.util.IReleasable} */ shaka.ads.InterstitialAdSession = class { /** * @param {!HTMLMediaElement} video * @param {!shaka.extern.AdInterstitial} interstitial * @param {!shaka.ads.InterstitialAd} ad * @param {function(string)} onEvent * @param {function():number=} contentPosition */ constructor(video, interstitial, ad, onEvent, contentPosition = () => video.currentTime) { /** @private {!HTMLMediaElement} */ this.video_ = video; /** @private {!shaka.extern.AdInterstitial} */ this.interstitial_ = interstitial; /** @private {!shaka.ads.InterstitialAd} */ this.ad_ = ad; /** @private {function(string)} */ this.onEvent_ = onEvent; /** @private {function():number} */ this.contentPosition_ = contentPosition; /** @private {?shaka.ads.InterstitialAdSession.Tracker} */ this.tracker_ = null; /** @private {boolean} */ this.canSkip_ = false; /** @private {!Array<{start: number, end: number}>} */ this.playedRanges_ = []; /** @private {!Set<string>} */ this.quartiles_ = new Set(); /** @private {?number} */ this.lastPosition_ = null; /** @private {boolean} */ this.started_ = false; /** @private {boolean} */ this.paused_ = false; /** @private {boolean} */ this.muted_ = video.muted; /** @private {boolean} */ this.completed_ = false; /** @private {boolean} */ this.seeked_ = false; /** @private {boolean} */ this.released_ = false; /** @private {!shaka.util.EventManager} */ this.eventManager_ = new shaka.util.EventManager(); this.eventManager_.listen(video, 'seeking', () => { // A seek establishes a new clock origin; it never represents watched // media between the old and new playhead positions. this.lastPosition_ = null; this.completed_ = false; this.seeked_ = true; }); this.eventManager_.listen(video, 'timeupdate', () => this.update()); this.eventManager_.listen(video, 'playing', () => this.update()); } /** * Attaches the format specific tracker, if any, and resolves its deferred * data. Data that becomes available later is delivered without replaying * events that were already sent. * * @param {?shaka.ads.InterstitialAdSession.Tracker} tracker */ setTracker(tracker) { this.tracker_ = tracker; if (!tracker) { return; } shaka.util.Dom.listenForPlayerExpansion(this.eventManager_, this.video_, (expanded) => { this.notify(expanded ? 'playerExpand' : 'playerCollapse'); }); tracker.resolve().then(() => this.refresh()); } /** @override */ release() { this.released_ = true; this.eventManager_.release(); if (this.tracker_) { this.tracker_.release(); this.tracker_ = null; } } /** @return {!shaka.util.EventManager} */ getEventManager() { return this.eventManager_; } /** * The playhead position relative to the start of the interstitial. * * @return {number} */ getPosition() { return this.interstitial_.playbackMode == shaka.ads.Utils.EMBEDDED ? this.video_.currentTime - this.interstitial_.startTime : this.video_.currentTime; } /** * Whether the user seeked during the interstitial. Browsers fire * timeupdate before seeked, so the seeked event alone is not enough to tell * a skip from the natural end of the interstitial. * * @return {boolean} */ hasSeeked() { return this.seeked_; } /** @return {boolean} */ canComplete() { this.update(); return this.completed_; } /** * Advances measurement only over media played at normal speed. Quartiles * and offset events crossed by seeks or fast playback are not backfilled. */ update() { if (this.released_ || this.video_.readyState < 2 || this.ad_.getDuration() <= 0) { return; } const position = this.getPosition(); const duration = this.ad_.getDuration(); if (this.video_.seeking || (this.video_.paused && !this.video_.ended && position < duration)) { this.lastPosition_ = null; return; } const Utils = shaka.ads.Utils; if (!this.started_) { this.started_ = true; if (position < 0.1) { this.lastPosition_ = 0; } this.notify(Utils.AD_LOADED); this.notify(Utils.AD_IMPRESSION); this.notify(Utils.AD_STARTED); } if (this.interstitial_.playbackMode == shaka.ads.Utils.EMBEDDED && this.canSkip_ != this.ad_.canSkipNow()) { this.canSkip_ = this.ad_.canSkipNow(); this.onEvent_(Utils.AD_SKIP_STATE_CHANGED); } if (this.video_.playbackRate != 1) { this.lastPosition_ = null; return; } const previous = this.lastPosition_; this.lastPosition_ = position; if (previous == null || position < previous) { return; } const lastRange = this.playedRanges_[this.playedRanges_.length - 1]; if (lastRange && lastRange.end == previous) { lastRange.end = position; } else { this.playedRanges_.push({start: previous, end: position}); } const quartiles = [ {fraction: 0.25, type: Utils.AD_FIRST_QUARTILE}, {fraction: 0.5, type: Utils.AD_MIDPOINT}, {fraction: 0.75, type: Utils.AD_THIRD_QUARTILE}, ]; for (const quartile of quartiles) { const threshold = duration * quartile.fraction; if (previous <= threshold && position >= threshold && !this.quartiles_.has(quartile.type)) { this.quartiles_.add(quartile.type); this.onEvent_(quartile.type); } } if (this.tracker_) { this.tracker_.onProgress(previous, position); } // Reaching the end of the media while measuring is a completion, even // when the media is slightly shorter than the signaled duration. this.completed_ = this.completed_ || this.video_.ended || (position >= duration && previous < duration); } /** * Forwards an ad event to the tracker. Events describing playback are only * forwarded once playback has actually started. * * @param {string} type */ notify(type) { const Utils = shaka.ads.Utils; if (!this.tracker_ || this.released_) { return; } // AD_STARTED announces the ad object before external media has loaded. // Start and impression are measured on actual playback instead. if (!this.started_ && type != Utils.AD_LOADED && type != Utils.AD_ERROR) { return; } if (type == Utils.AD_PAUSED) { this.paused_ = true; } else if (type == Utils.AD_RESUMED) { if (!this.paused_) { return; } this.paused_ = false; } else if (type == Utils.AD_MUTED || type == Utils.AD_VOLUME_CHANGED) { if (this.video_.muted == this.muted_) { return; } this.muted_ = this.video_.muted; } this.tracker_.onEvent(type); } /** * Delivers data that became valid while it was being resolved, without * replaying events that were already delivered. */ refresh() { if (this.released_) { return; } if (this.started_ && this.tracker_) { const Utils = shaka.ads.Utils; this.tracker_.onEvent(Utils.AD_LOADED); this.tracker_.onEvent(Utils.AD_IMPRESSION); this.tracker_.onEvent(Utils.AD_STARTED); for (const type of this.quartiles_) { this.tracker_.onEvent(type); } for (const range of this.playedRanges_) { this.tracker_.onProgress(range.start, range.end); } } this.update(); } /** * Expands known VAST macros and uses the VAST unavailable value for context * the player cannot supply. Apps may still add context via request filters. * * @param {string} uri * @return {string} */ expandUri(uri) { const pad = (value) => (value < 10 ? '0' : '') + value; const time = (seconds) => { const value = Math.max(0, seconds); return pad(Math.floor(value / 3600)) + ':' + pad(Math.floor(value / 60 % 60)) + ':' + (value % 60 < 10 ? '0' : '') + (value % 60).toFixed(3); }; // Eight random digits, keeping the leading zeros. const random = String(1e8 + Math.floor(Math.random() * 1e8)).slice(1); const macros = { 'CACHEBUSTING': random, 'RANDOM': random, 'TIMESTAMP': new Date().toISOString(), 'ADPLAYHEAD': time(this.getPosition()), 'CONTENTPLAYHEAD': time(this.contentPosition_()), 'ASSETURI': this.interstitial_.uri || this.video_.currentSrc, 'ADTYPE': this.ad_.isLinear() ? '1' : '2', 'ADID': this.ad_.getAdId(), 'PLAYERSIZE': this.video_.clientWidth + ',' + this.video_.clientHeight, 'PLAYERSTATE': this.video_.paused ? 'paused' : 'playing', }; return uri.replace(/\[([A-Z0-9_]+)\]|%5B([A-Z0-9_]+)%5D/gi, (match, plain, encoded) => encodeURIComponent( macros[(plain || encoded).toUpperCase()] || '-1')); } }; /** * Format specific measurement of one interstitial. * * @interface * @extends {shaka.util.IReleasable} */ shaka.ads.InterstitialAdSession.Tracker = class { /** * Called for every ad event of the interstitial. May be called again with * the same event when deferred data is resolved. * * @param {string} type */ onEvent(type) {} /** * Called when media between two positions (relative to the start of the * interstitial) was played at normal speed. * * @param {number} previous * @param {number} position */ onProgress(previous, position) {} /** * Resolves data required for measurement that is not available yet. * * @return {!Promise} */ resolve() {} }; /** * Creates the tracker of an interstitial, or null if it has nothing to track. * It is given a function that sends a tracking beacon, expanding its macros, * and a function that fetches a resource. * * @typedef {function(!shaka.extern.AdInterstitial, * !shaka.ads.InterstitialAdSession, function(string), * function(string):!shaka.net.NetworkingEngine.PendingRequest): * ?shaka.ads.InterstitialAdSession.Tracker} */ shaka.ads.InterstitialAdSession.TrackerFactory;