diff --git a/externs/shaka/ads.js b/externs/shaka/ads.js index 67e8a6dd91..40de84311e 100644 --- a/externs/shaka/ads.js +++ b/externs/shaka/ads.js @@ -33,6 +33,24 @@ shaka.extern.AdsStats; +/** + * @typedef {{ + * start: number, + * end: ?number + * }} + * + * @description + * Contains the times of a range of an Ad. + * + * @property {number} start + * The start time of the range, in milliseconds. + * @property {number} end + * The end time of the range, in milliseconds. + * @exportDoc + */ +shaka.extern.AdCuePoint; + + /** * An object that's responsible for all the ad-related logic * in the player. @@ -77,6 +95,11 @@ shaka.extern.IAdManager = class extends EventTarget { */ replaceServerSideAdTagParameters(adTagParameters) {} + /** + * @return {!Array.} + */ + getServerSideCuePoints() {} + /** * Get statistics for the current playback session. If the player is not * playing content, this will return an empty stats object. diff --git a/lib/ads/ad_manager.js b/lib/ads/ad_manager.js index 65ca694ac3..5fba7813b3 100644 --- a/lib/ads/ad_manager.js +++ b/lib/ads/ad_manager.js @@ -6,7 +6,6 @@ goog.provide('shaka.ads.AdManager'); -goog.provide('shaka.ads.CuePoint'); goog.require('shaka.Player'); goog.require('shaka.ads.AdsStats'); @@ -568,6 +567,22 @@ shaka.ads.AdManager = class extends shaka.util.FakeEventTarget { } + /** + * @return {!Array.} + * @override + * @export + */ + getServerSideCuePoints() { + if (!this.ssAdManager_) { + throw new shaka.util.Error( + shaka.util.Error.Severity.RECOVERABLE, + shaka.util.Error.Category.ADS, + shaka.util.Error.Code.SS_AD_MANAGER_NOT_INITIALIZED); + } + return this.ssAdManager_.getCuePoints(); + } + + /** * @return {shaka.extern.AdsStats} * @override @@ -620,20 +635,6 @@ shaka.ads.AdManager = class extends shaka.util.FakeEventTarget { } }; - -shaka.ads.CuePoint = class { - /** - * @param {number} start - * @param {?number=} end - */ - constructor(start, end = null) { - /** @public {number} */ - this.start = start; - /** @public {?number} */ - this.end = end; - } -}; - /** * The event name for when a sequence of ads has been loaded. * diff --git a/lib/ads/client_side_ad_manager.js b/lib/ads/client_side_ad_manager.js index c9e28b864a..41421f330e 100644 --- a/lib/ads/client_side_ad_manager.js +++ b/lib/ads/client_side_ad_manager.js @@ -145,10 +145,14 @@ shaka.ads.ClientSideAdManager = class { const cuePointStarts = this.imaAdsManager_.getCuePoints(); if (cuePointStarts.length) { - /** @type {!Array.} */ + /** @type {!Array.} */ const cuePoints = []; for (const start of cuePointStarts) { - const shakaCuePoint = new shaka.ads.CuePoint(start); + /** @type {shaka.extern.AdCuePoint} */ + const shakaCuePoint = { + start: start, + end: null, + }; cuePoints.push(shakaCuePoint); } diff --git a/lib/ads/server_side_ad_manager.js b/lib/ads/server_side_ad_manager.js index 191a0e5ea8..c1a6d33abe 100644 --- a/lib/ads/server_side_ad_manager.js +++ b/lib/ads/server_side_ad_manager.js @@ -62,6 +62,9 @@ shaka.ads.ServerSideAdManager = class { /** @private {string} */ this.backupUrl_ = ''; + /** @private {!Array.} */ + this.currentCuePoints_ = []; + /** @private {shaka.util.EventManager} */ this.eventManager_ = new shaka.util.EventManager(); @@ -212,6 +215,7 @@ shaka.ads.ServerSideAdManager = class { // this.streamManager_.reset(); this.backupUrl_ = ''; this.snapForwardTime_ = null; + this.currentCuePoints_ = []; } /** @@ -241,6 +245,13 @@ shaka.ads.ServerSideAdManager = class { } } + /** + * @return {!Array.} + */ + getCuePoints() { + return this.currentCuePoints_; + } + /** * If a seek jumped over the ad break, return to the start of the * ad break, then complete the seek after the ad played through. @@ -369,13 +380,19 @@ shaka.ads.ServerSideAdManager = class { onCuePointsChanged_(e) { const streamData = e.getStreamData(); - /** @type {!Array.} */ + /** @type {!Array.} */ const cuePoints = []; for (const point of streamData.cuepoints) { - const shakaCuePoint = new shaka.ads.CuePoint(point.start, point.end); + /** @type {shaka.extern.AdCuePoint} */ + const shakaCuePoint = { + start: point.start, + end: point.end, + }; cuePoints.push(shakaCuePoint); } + this.currentCuePoints_ = cuePoints; + this.onEvent_( new shaka.util.FakeEvent(shaka.ads.AdManager.CUEPOINTS_CHANGED, {'cuepoints': cuePoints})); diff --git a/lib/player.js b/lib/player.js index bf307ad1f9..21dc227813 100644 --- a/lib/player.js +++ b/lib/player.js @@ -4452,12 +4452,20 @@ shaka.Player = class extends shaka.util.FakeEventTarget { mimeType = await this.getTextMimetype_(uri); } + let adCuePoints = []; + if (this.adManager_) { + try { + adCuePoints = this.adManager_.getServerSideCuePoints(); + } catch (error) {} + } + if (this.loadMode_ == shaka.Player.LoadMode.SRC_EQUALS) { if (forced) { // See: https://github.com/whatwg/html/issues/4472 kind = 'forced'; } - await this.addSrcTrackElement_(uri, language, kind, mimeType, label); + await this.addSrcTrackElement_(uri, language, kind, mimeType, label || '', + adCuePoints); const textTracks = this.getTextTracks(); const srcTrack = textTracks.find((t) => { return t.language == language && @@ -4487,6 +4495,18 @@ shaka.Player = class extends shaka.util.FakeEventTarget { shaka.util.Error.Code.CANNOT_ADD_EXTERNAL_TEXT_TO_LIVE_STREAM); } + if (adCuePoints.length) { + goog.asserts.assert( + this.networkingEngine_, 'Need networking engine.'); + const data = await this.getTextData_(uri, + this.networkingEngine_, + this.config_.streaming.retryParameters); + const vvtText = this.convertToWebVTT_(data, mimeType, adCuePoints); + const blob = new Blob([vvtText], {type: 'text/vtt'}); + uri = shaka.media.MediaSourceEngine.createObjectURL(blob); + mimeType = 'text/vtt'; + } + /** @type {shaka.extern.Stream} */ const stream = { id: this.nextExternalStreamId_++, @@ -4559,8 +4579,14 @@ shaka.Player = class extends shaka.util.FakeEventTarget { if (!mimeType) { mimeType = await this.getTextMimetype_(uri); } + let adCuePoints = []; + if (this.adManager_) { + try { + adCuePoints = this.adManager_.getServerSideCuePoints(); + } catch (error) {} + } await this.addSrcTrackElement_(uri, language, /* kind= */ 'chapters', - mimeType); + mimeType, /* label= */ '', adCuePoints); const chaptersTracks = this.getChaptersTracks(); const chaptersTrack = chaptersTracks.find((t) => { return t.language == language; @@ -4623,17 +4649,19 @@ shaka.Player = class extends shaka.util.FakeEventTarget { * @param {string} language * @param {string} kind * @param {string} mimeType - * @param {string=} label + * @param {string} label + * @param {!Array.} adCuePoints * @private */ - async addSrcTrackElement_(uri, language, kind, mimeType, label) { - if (mimeType != 'text/vtt') { + async addSrcTrackElement_(uri, language, kind, mimeType, label, + adCuePoints) { + if (mimeType != 'text/vtt' || adCuePoints.length) { goog.asserts.assert( this.networkingEngine_, 'Need networking engine.'); const data = await this.getTextData_(uri, this.networkingEngine_, this.config_.streaming.retryParameters); - const vvtText = this.convertToWebVTT_(data, mimeType); + const vvtText = this.convertToWebVTT_(data, mimeType, adCuePoints); const blob = new Blob([vvtText], {type: 'text/vtt'}); uri = shaka.media.MediaSourceEngine.createObjectURL(blob); mimeType = 'text/vtt'; @@ -4641,7 +4669,7 @@ shaka.Player = class extends shaka.util.FakeEventTarget { const trackElement = /** @type {!HTMLTrackElement} */(document.createElement('track')); trackElement.src = uri; - trackElement.label = label || ''; + trackElement.label = label; trackElement.kind = kind; trackElement.srclang = language; // Because we're pulling in the text track file via Javascript, the @@ -4680,10 +4708,11 @@ shaka.Player = class extends shaka.util.FakeEventTarget { * * @param {BufferSource} buffer * @param {string} mimeType + * @param {!Array.} adCuePoints * @return {string} * @private */ - convertToWebVTT_(buffer, mimeType) { + convertToWebVTT_(buffer, mimeType, adCuePoints) { const factory = shaka.text.TextEngine.findParser(mimeType); if (factory) { const obj = factory(); @@ -4694,7 +4723,7 @@ shaka.Player = class extends shaka.util.FakeEventTarget { }; const data = shaka.util.BufferUtils.toUint8(buffer); const cues = obj.parseMedia(data, time); - return shaka.text.WebVttGenerator.convert(cues); + return shaka.text.WebVttGenerator.convert(cues, adCuePoints); } throw new shaka.util.Error( shaka.util.Error.Severity.CRITICAL, diff --git a/lib/text/web_vtt_generator.js b/lib/text/web_vtt_generator.js index 800dae44b5..634541349d 100644 --- a/lib/text/web_vtt_generator.js +++ b/lib/text/web_vtt_generator.js @@ -17,9 +17,10 @@ goog.require('shaka.text.Cue'); shaka.text.WebVttGenerator = class { /** * @param {!Array.} cues + * @param {!Array.} adCuePoints * @return {string} */ - static convert(cues) { + static convert(cues, adCuePoints) { // Flatten nested cue payloads recursively. If a cue has nested cues, // their contents should be combined and replace the payload of the parent. const flattenPayload = (cue) => { @@ -64,6 +65,25 @@ shaka.text.WebVttGenerator = class { } }; + const webvttTimeString = (time) => { + let newTime = time; + for (const adCuePoint of adCuePoints) { + if (adCuePoint.end && adCuePoint.start < time) { + const offset = adCuePoint.end - adCuePoint.start; + newTime += offset; + } + } + const hours = Math.floor(newTime / 3600); + const minutes = Math.floor(newTime / 60 % 60); + const seconds = Math.floor(newTime % 60); + const milliseconds = Math.floor(newTime * 1000 % 1000); + return (hours < 10 ? '0' : '') + hours + ':' + + (minutes < 10 ? '0' : '') + minutes + ':' + + (seconds < 10 ? '0' : '') + seconds + '.' + + (milliseconds < 100 ? (milliseconds < 10 ? '00' : '0') : '') + + milliseconds; + }; + // We don't want to modify the array or objects passed in, since we don't // technically own them. So we build a new array and replace certain items // in it if they need to be flattened. @@ -80,17 +100,6 @@ shaka.text.WebVttGenerator = class { let webvttString = 'WEBVTT\n\n'; for (const cue of flattenedCues) { - const webvttTimeString = (time) => { - const hours = Math.floor(time / 3600); - const minutes = Math.floor(time / 60 % 60); - const seconds = Math.floor(time % 60); - const milliseconds = Math.floor(time * 1000 % 1000); - return (hours < 10 ? '0' : '') + hours + ':' + - (minutes < 10 ? '0' : '') + minutes + ':' + - (seconds < 10 ? '0' : '') + seconds + '.' + - (milliseconds < 100 ? (milliseconds < 10 ? '00' : '0') : '') + - milliseconds; - }; const webvttSettings = (cue) => { const settings = []; const Cue = shaka.text.Cue; diff --git a/test/test/util/fake_ad_manager.js b/test/test/util/fake_ad_manager.js index 7cf9f0aa97..5cf5367810 100644 --- a/test/test/util/fake_ad_manager.js +++ b/test/test/util/fake_ad_manager.js @@ -50,6 +50,13 @@ shaka.test.FakeAdManager = class extends shaka.util.FakeEventTarget { /** @override */ replaceServerSideAdTagParameters(adTagParameters) {} + /** + * @override + */ + getServerSideCuePoints() { + return []; + } + /** @override */ getStats() { return this.stats_; diff --git a/test/text/web_vtt_generator_unit.js b/test/text/web_vtt_generator_unit.js index 184b72455d..1e2a7f7f29 100644 --- a/test/text/web_vtt_generator_unit.js +++ b/test/text/web_vtt_generator_unit.js @@ -9,7 +9,7 @@ goog.require('shaka.text.WebVttGenerator'); describe('WebVttGenerator', () => { it('supports no cues', () => { - verifyHelper([], 'WEBVTT\n\n'); + verifyHelper([], [], 'WEBVTT\n\n'); }); it('convert cues to WebVTT', () => { @@ -26,6 +26,8 @@ describe('WebVttGenerator', () => { const shakaCue5 = new shaka.text.Cue(53, 54, 'Test5'); shakaCue5.textAlign = shaka.text.Cue.textAlign.END; + const adCuePoints = []; + verifyHelper( [ shakaCue1, @@ -34,6 +36,7 @@ describe('WebVttGenerator', () => { shakaCue4, shakaCue5, ], + adCuePoints, 'WEBVTT\n\n' + '00:00:20.000 --> 00:00:40.000 align:left vertical:lr\n' + 'Test\n\n' + @@ -68,19 +71,71 @@ describe('WebVttGenerator', () => { nestedCue4.textDecoration.push(shaka.text.Cue.textDecoration.UNDERLINE); shakaCue.nestedCues = [nestedCue1, nestedCue2, nestedCue3, nestedCue4]; + + const adCuePoints = []; + verifyHelper( [shakaCue], + adCuePoints, 'WEBVTT\n\n' + '00:00:10.000 --> 00:00:20.000 align:middle\n' + 'Test1Test2Test3Test4\n\n'); }); + it('computes the time with ad cue points', () => { + const shakaCue1 = new shaka.text.Cue(20, 30, 'Test'); + shakaCue1.textAlign = shaka.text.Cue.textAlign.LEFT; + shakaCue1.writingMode = shaka.text.Cue.writingMode.VERTICAL_LEFT_TO_RIGHT; + const shakaCue2 = new shaka.text.Cue(40, 50, 'Test2'); + shakaCue2.textAlign = shaka.text.Cue.textAlign.RIGHT; + shakaCue2.writingMode = shaka.text.Cue.writingMode.VERTICAL_RIGHT_TO_LEFT; + const shakaCue3 = new shaka.text.Cue(50, 51, 'Test3'); + shakaCue3.textAlign = shaka.text.Cue.textAlign.CENTER; + const shakaCue4 = new shaka.text.Cue(52, 53, 'Test4'); + shakaCue4.textAlign = shaka.text.Cue.textAlign.START; + const shakaCue5 = new shaka.text.Cue(53, 54, 'Test5'); + shakaCue5.textAlign = shaka.text.Cue.textAlign.END; + + const adCuePoints = [ + { + start: 0, + end: 10, + }, + { + start: 35, + end: 45, + }, + ]; + + verifyHelper( + [ + shakaCue1, + shakaCue2, + shakaCue3, + shakaCue4, + shakaCue5, + ], + adCuePoints, + 'WEBVTT\n\n' + + '00:00:30.000 --> 00:00:40.000 align:left vertical:lr\n' + + 'Test\n\n' + + '00:01:00.000 --> 00:01:10.000 align:right vertical:rl\n' + + 'Test2\n\n' + + '00:01:10.000 --> 00:01:11.000 align:middle\n' + + 'Test3\n\n' + + '00:01:12.000 --> 00:01:13.000 align:start\n' + + 'Test4\n\n' + + '00:01:13.000 --> 00:01:14.000 align:end\n' + + 'Test5\n\n'); + }); + /** * @param {!Array} cues + * @param {!Array} adCuePoints * @param {string} text */ - function verifyHelper(cues, text) { - const result = shaka.text.WebVttGenerator.convert(cues); + function verifyHelper(cues, adCuePoints, text) { + const result = shaka.text.WebVttGenerator.convert(cues, adCuePoints); expect(text).toBe(result); } }); diff --git a/ui/seek_bar.js b/ui/seek_bar.js index b30662b729..c59a8e5499 100644 --- a/ui/seek_bar.js +++ b/ui/seek_bar.js @@ -15,7 +15,6 @@ goog.require('shaka.ui.RangeElement'); goog.require('shaka.ui.Utils'); goog.require('shaka.util.Dom'); goog.require('shaka.util.Timer'); -goog.requireType('shaka.ads.CuePoint'); goog.requireType('shaka.ui.Controls'); @@ -83,7 +82,7 @@ shaka.ui.SeekBar = class extends shaka.ui.RangeElement { */ this.wasPlaying_ = false; - /** @private {!Array.} */ + /** @private {!Array.} */ this.adCuePoints_ = []; this.eventManager.listen(this.localization,