diff --git a/externs/shaka/player.js b/externs/shaka/player.js index 8e4b8c2a0a..92922d95f8 100644 --- a/externs/shaka/player.js +++ b/externs/shaka/player.js @@ -1095,3 +1095,21 @@ shaka.extern.LanguageRole; * @exportDoc */ shaka.extern.Thumbnail; + + +/** + * @typedef {{ + * title: string, + * startTime: number, + * endTime: number + * }} + * + * @property {string} title + * The title of the chapter. + * @property {number} startTime + * The time that describes the beginning of the range of the chapter. + * @property {number} endTime + * The time that describes the end of the range of chapter. + * @exportDoc + */ +shaka.extern.Chapter; diff --git a/externs/texttrack.js b/externs/texttrack.js index 884f58365a..293aaec8e5 100644 --- a/externs/texttrack.js +++ b/externs/texttrack.js @@ -20,3 +20,6 @@ TextTrack.prototype.kind; /** @type {string} */ TextTrack.prototype.label; + +/** @type {string} */ +TextTrack.prototype.language; diff --git a/lib/cast/cast_utils.js b/lib/cast/cast_utils.js index 88300b540b..91b1309deb 100644 --- a/lib/cast/cast_utils.js +++ b/lib/cast/cast_utils.js @@ -372,10 +372,13 @@ shaka.cast.CastUtils.PlayerInitAfterLoadState = [ * @const {!Array.} */ shaka.cast.CastUtils.PlayerVoidMethods = [ + 'addChaptersTrack', 'addTextTrack', 'addTextTrackAsync', 'cancelTrickPlay', 'configure', + 'getChapters', + 'getChaptersTracks', 'resetConfiguration', 'retryStreaming', 'selectAudioLanguage', diff --git a/lib/player.js b/lib/player.js index 323045b092..2bd7e2d9bc 100644 --- a/lib/player.js +++ b/lib/player.js @@ -2132,8 +2132,22 @@ shaka.Player = class extends shaka.util.FakeEventTarget { } if (this.video_.textTracks) { this.eventManager_.listen(this.video_.textTracks, 'addtrack', (e) => { - this.onTracksChanged_(); - this.processTimedMetadataSrcEqls_(/** @type {!TrackEvent} */(e)); + const trackEvent = /** @type {!TrackEvent} */(e); + if (trackEvent.track) { + const track = trackEvent.track; + goog.asserts.assert(track instanceof TextTrack, 'Wrong track type!'); + switch (track.kind) { + case 'metadata': + this.processTimedMetadataSrcEqls_(track); + break; + case 'chapters': + this.processChaptersTrack_(track); + break; + default: + this.onTracksChanged_(); + break; + } + } }); this.eventManager_.listen( this.video_.textTracks, 'removetrack', () => this.onTracksChanged_()); @@ -2306,13 +2320,10 @@ shaka.Player = class extends shaka.util.FakeEventTarget { * We're looking for metadata tracks to process id3 tags. One of the uses is * for ad info on LIVE streams * - * @param {!TrackEvent} event + * @param {!TextTrack} track * @private */ - processTimedMetadataSrcEqls_(event) { - const track = event.track; - goog.asserts.assert(track instanceof TextTrack, 'Wrong track type!'); - + processTimedMetadataSrcEqls_(track) { if (track.kind != 'metadata') { return; } @@ -2393,6 +2404,32 @@ shaka.Player = class extends shaka.util.FakeEventTarget { this.dispatchEvent(this.makeEvent_(eventName, data)); } + /** + * We're looking for chapters tracks to process the chapters. + * + * @param {?TextTrack} track + * @private + */ + processChaptersTrack_(track) { + if (!track || track.kind != 'chapters') { + return; + } + + // Hidden mode is required for the cuechange event to launch correctly and + // get the cues and the activeCues + track.mode = 'hidden'; + + // In Safari the initial assignment does not always work, so we schedule + // this process to be repeated several times to ensure that it has been put + // in the correct mode. + new shaka.util.Timer(() => { + const chaptersTracks = this.getChaptersTracks_(); + for (const chaptersTrack of chaptersTracks) { + chaptersTrack.mode = 'hidden'; + } + }).tickNow().tickAfter(/* seconds= */ 0.5); + } + /** * Take a series of variants and ensure that they only contain one type of * variant. The different options are: @@ -3761,6 +3798,51 @@ shaka.Player = class extends shaka.util.FakeEventTarget { return expected; } + /** + * Return a list of chapters tracks. + * + * @return {!Array.} + * @export + */ + getChaptersTracks() { + if (this.video_ && this.video_.src && this.video_.textTracks) { + const textTracks = this.getChaptersTracks_(); + const StreamUtils = shaka.util.StreamUtils; + return textTracks.map((text) => StreamUtils.html5TextTrackToTrack(text)); + } else { + return []; + } + } + + /** + * This returns the list of chapters. + * + * @param {string} language + * @return {!Array.} + * @export + */ + getChapters(language) { + const LanguageUtils = shaka.util.LanguageUtils; + const inputlanguage = LanguageUtils.normalize(language); + const chaptersTracks = this.getChaptersTracks_(); + const chaptersTrack = chaptersTracks + .find((t) => LanguageUtils.normalize(t.language) == inputlanguage); + if (!chaptersTrack || !chaptersTrack.cues) { + return []; + } + const chapters = []; + for (const cue of chaptersTrack.cues) { + /** @type {shaka.extern.Chapter} */ + const chapter = { + title: cue.text, + startTime: cue.startTime, + endTime: cue.endTime, + }; + chapters.push(chapter); + } + return chapters; + } + /** * Ignore the TextTracks with the 'metadata' or 'chapters' kind, or the one * generated by the SimpleTextDisplayer. @@ -3789,6 +3871,19 @@ shaka.Player = class extends shaka.util.FakeEventTarget { .filter((t) => t.kind == 'metadata'); } + /** + * Get the TextTracks with the 'chapters' kind. + * + * @return {!Array.} + * @private + */ + getChaptersTracks_() { + goog.asserts.assert(this.video_.textTracks, + 'TextTracks should be valid.'); + return Array.from(this.video_.textTracks) + .filter((t) => t.kind == 'chapters'); + } + /** * Enable or disable the text displayer. If the player is in an unloaded * state, the request will be applied next time content is loaded. @@ -4238,65 +4333,15 @@ shaka.Player = class extends shaka.util.FakeEventTarget { } if (!mimeType) { - // Try using the uri extension. - const extension = shaka.media.ManifestParser.getExtension(uri); - mimeType = shaka.Player.TEXT_EXTENSIONS_TO_MIME_TYPES_[extension]; - - if (!mimeType) { - try { - goog.asserts.assert( - this.networkingEngine_, 'Need networking engine.'); - // eslint-disable-next-line require-atomic-updates - mimeType = await shaka.media.ManifestParser.getMimeType(uri, - this.networkingEngine_, - this.config_.streaming.retryParameters); - } catch (error) {} - } - - if (!mimeType) { - shaka.log.error( - 'The mimeType has not been provided and it could not be deduced ' + - 'from its extension.'); - throw new shaka.util.Error( - shaka.util.Error.Severity.RECOVERABLE, - shaka.util.Error.Category.TEXT, - shaka.util.Error.Code.TEXT_COULD_NOT_GUESS_MIME_TYPE, - extension); - } + mimeType = await this.getTextMimetype_(uri); } if (this.loadMode_ == shaka.Player.LoadMode.SRC_EQUALS) { - if (mimeType != 'text/vtt') { - 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 blob = new Blob([vvtText], {type: 'text/vtt'}); - uri = shaka.media.MediaSourceEngine.createObjectURL(blob); - mimeType = 'text/vtt'; - } if (forced) { // See: https://github.com/whatwg/html/issues/4472 kind = 'forced'; } - const trackElement = - /** @type {!HTMLTrackElement} */(document.createElement('track')); - trackElement.src = uri; - trackElement.label = label || ''; - trackElement.kind = kind; - trackElement.srclang = language; - // Because we're pulling in the text track file via Javascript, the - // same-origin policy applies. If you'd like to have a player served - // from one domain, but the text track served from another, you'll - // need to enable CORS in order to do so. In addition to enabling CORS - // on the server serving the text tracks, you will need to add the - // crossorigin attribute to the video element itself. - if (!this.video_.getAttribute('crossorigin')) { - this.video_.setAttribute('crossorigin', 'anonymous'); - } - this.video_.appendChild(trackElement); + await this.addSrcTrackElement_(uri, language, kind, mimeType, label); const textTracks = this.getTextTracks(); const srcTrack = textTracks.find((t) => { return t.language == language && @@ -4371,6 +4416,130 @@ shaka.Player = class extends shaka.util.FakeEventTarget { return shaka.util.StreamUtils.textStreamToTrack(stream); } + /** + * Adds the given chapters track to the loaded manifest. load() + * must resolve before calling. The presentation must have a duration. + * + * This returns the created track. + * + * @param {string} uri + * @param {string} language + * @param {string=} mimeType + * @return {!Promise.} + * @export + */ + async addChaptersTrack(uri, language, mimeType) { + if (this.loadMode_ != shaka.Player.LoadMode.MEDIA_SOURCE && + this.loadMode_ != shaka.Player.LoadMode.SRC_EQUALS) { + shaka.log.error( + 'Must call load() and wait for it to resolve before adding ' + + 'chapters tracks.'); + throw new shaka.util.Error( + shaka.util.Error.Severity.RECOVERABLE, + shaka.util.Error.Category.PLAYER, + shaka.util.Error.Code.CONTENT_NOT_LOADED); + } + + if (!mimeType) { + mimeType = await this.getTextMimetype_(uri); + } + await this.addSrcTrackElement_(uri, language, /* kind= */ 'chapters', + mimeType); + const chaptersTracks = this.getChaptersTracks(); + const chaptersTrack = chaptersTracks.find((t) => { + return t.language == language; + }); + if (chaptersTrack) { + const html5ChaptersTracks = this.getChaptersTracks_(); + for (const html5ChaptersTrack of html5ChaptersTracks) { + this.processChaptersTrack_(html5ChaptersTrack); + } + return chaptersTrack; + } + // This should not happen, but there are browser implementations that may + // not support the Track element. + shaka.log.error('Cannot add this text when loaded with src='); + throw new shaka.util.Error( + shaka.util.Error.Severity.RECOVERABLE, + shaka.util.Error.Category.TEXT, + shaka.util.Error.Code.CANNOT_ADD_EXTERNAL_TEXT_TO_SRC_EQUALS); + } + + /** + * @param {string} uri + * @return {!Promise.} + * @private + */ + async getTextMimetype_(uri) { + // Try using the uri extension. + const extension = shaka.media.ManifestParser.getExtension(uri); + let mimeType = shaka.Player.TEXT_EXTENSIONS_TO_MIME_TYPES_[extension]; + + if (mimeType) { + return mimeType; + } + + try { + goog.asserts.assert( + this.networkingEngine_, 'Need networking engine.'); + // eslint-disable-next-line require-atomic-updates + mimeType = await shaka.media.ManifestParser.getMimeType(uri, + this.networkingEngine_, + this.config_.streaming.retryParameters); + } catch (error) {} + + if (mimeType) { + return mimeType; + } + + shaka.log.error( + 'The mimeType has not been provided and it could not be deduced ' + + 'from its extension.'); + throw new shaka.util.Error( + shaka.util.Error.Severity.RECOVERABLE, + shaka.util.Error.Category.TEXT, + shaka.util.Error.Code.TEXT_COULD_NOT_GUESS_MIME_TYPE, + extension); + } + + /** + * @param {string} uri + * @param {string} language + * @param {string} kind + * @param {string} mimeType + * @param {string=} label + * @private + */ + async addSrcTrackElement_(uri, language, kind, mimeType, label) { + if (mimeType != 'text/vtt') { + 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 blob = new Blob([vvtText], {type: 'text/vtt'}); + uri = shaka.media.MediaSourceEngine.createObjectURL(blob); + mimeType = 'text/vtt'; + } + const trackElement = + /** @type {!HTMLTrackElement} */(document.createElement('track')); + trackElement.src = uri; + trackElement.label = label || ''; + trackElement.kind = kind; + trackElement.srclang = language; + // Because we're pulling in the text track file via Javascript, the + // same-origin policy applies. If you'd like to have a player served + // from one domain, but the text track served from another, you'll + // need to enable CORS in order to do so. In addition to enabling CORS + // on the server serving the text tracks, you will need to add the + // crossorigin attribute to the video element itself. + if (!this.video_.getAttribute('crossorigin')) { + this.video_.setAttribute('crossorigin', 'anonymous'); + } + this.video_.appendChild(trackElement); + } + /** * @param {string} uri * @param {!shaka.net.NetworkingEngine} netEngine diff --git a/test/player_integration.js b/test/player_integration.js index 7d349b4e20..8281958386 100644 --- a/test/player_integration.js +++ b/test/player_integration.js @@ -921,4 +921,56 @@ describe('Player', () => { } }); }); // describe('unloading') + + describe('chapters', () => { + it('add external chapters in vtt format', async () => { + await player.load('test:sintel_no_text_compiled'); + const locationUri = new goog.Uri(location.href); + const partialUri = new goog.Uri('/base/test/test/assets/chapters.vtt'); + const absoluteUri = locationUri.resolve(partialUri); + await player.addChaptersTrack(absoluteUri.toString(), 'en'); + + await shaka.test.Util.delay(1.5); + + const chapters = player.getChapters('en'); + expect(chapters.length).toBe(3); + const chapter1 = chapters[0]; + expect(chapter1.title).toBe('Chapter 1'); + expect(chapter1.startTime).toBe(0); + expect(chapter1.endTime).toBe(5); + const chapter2 = chapters[1]; + expect(chapter2.title).toBe('Chapter 2'); + expect(chapter2.startTime).toBe(5); + expect(chapter2.endTime).toBe(30); + const chapter3 = chapters[2]; + expect(chapter3.title).toBe('Chapter 3'); + expect(chapter3.startTime).toBe(30); + expect(chapter3.endTime).toBe(61.349); + }); + + it('add external chapters in srt format', async () => { + await player.load('test:sintel_no_text_compiled'); + const locationUri = new goog.Uri(location.href); + const partialUri = new goog.Uri('/base/test/test/assets/chapters.srt'); + const absoluteUri = locationUri.resolve(partialUri); + await player.addChaptersTrack(absoluteUri.toString(), 'en'); + + await shaka.test.Util.delay(1.5); + + const chapters = player.getChapters('en'); + expect(chapters.length).toBe(3); + const chapter1 = chapters[0]; + expect(chapter1.title).toBe('Chapter 1'); + expect(chapter1.startTime).toBe(0); + expect(chapter1.endTime).toBe(5); + const chapter2 = chapters[1]; + expect(chapter2.title).toBe('Chapter 2'); + expect(chapter2.startTime).toBe(5); + expect(chapter2.endTime).toBe(30); + const chapter3 = chapters[2]; + expect(chapter3.title).toBe('Chapter 3'); + expect(chapter3.startTime).toBe(30); + expect(chapter3.endTime).toBe(61.349); + }); + }); // describe('chapters') }); diff --git a/test/player_src_equals_integration.js b/test/player_src_equals_integration.js index 5fb787134d..d1d7cd496a 100644 --- a/test/player_src_equals_integration.js +++ b/test/player_src_equals_integration.js @@ -326,6 +326,58 @@ describe('Player Src Equals', () => { expect(newTrack).toBeTruthy(); }); + it('add external chapters in vtt format', async () => { + await loadWithSrcEquals(SMALL_MP4_CONTENT_URI, /* startTime= */ null); + + const locationUri = new goog.Uri(location.href); + const partialUri = new goog.Uri('/base/test/test/assets/chapters.vtt'); + const absoluteUri = locationUri.resolve(partialUri); + await player.addChaptersTrack(absoluteUri.toString(), 'en'); + + await shaka.test.Util.delay(1.5); + + const chapters = player.getChapters('en'); + expect(chapters.length).toBe(3); + const chapter1 = chapters[0]; + expect(chapter1.title).toBe('Chapter 1'); + expect(chapter1.startTime).toBe(0); + expect(chapter1.endTime).toBe(5); + const chapter2 = chapters[1]; + expect(chapter2.title).toBe('Chapter 2'); + expect(chapter2.startTime).toBe(5); + expect(chapter2.endTime).toBe(30); + const chapter3 = chapters[2]; + expect(chapter3.title).toBe('Chapter 3'); + expect(chapter3.startTime).toBe(30); + expect(chapter3.endTime).toBe(61.349); + }); + + it('add external chapters in srt format', async () => { + await loadWithSrcEquals(SMALL_MP4_CONTENT_URI, /* startTime= */ null); + + const locationUri = new goog.Uri(location.href); + const partialUri = new goog.Uri('/base/test/test/assets/chapters.srt'); + const absoluteUri = locationUri.resolve(partialUri); + await player.addChaptersTrack(absoluteUri.toString(), 'en'); + + await shaka.test.Util.delay(1.5); + + const chapters = player.getChapters('en'); + expect(chapters.length).toBe(3); + const chapter1 = chapters[0]; + expect(chapter1.title).toBe('Chapter 1'); + expect(chapter1.startTime).toBe(0); + expect(chapter1.endTime).toBe(5); + const chapter2 = chapters[1]; + expect(chapter2.title).toBe('Chapter 2'); + expect(chapter2.startTime).toBe(5); + expect(chapter2.endTime).toBe(30); + const chapter3 = chapters[2]; + expect(chapter3.title).toBe('Chapter 3'); + expect(chapter3.startTime).toBe(30); + expect(chapter3.endTime).toBe(61.349); + }); + // Since we are not in-charge of streaming, calling |retryStreaming| should // have no effect. it('requesting streaming retry does nothing', async () => { diff --git a/test/test/assets/chapters.srt b/test/test/assets/chapters.srt new file mode 100644 index 0000000000..54cc9993a3 --- /dev/null +++ b/test/test/assets/chapters.srt @@ -0,0 +1,11 @@ +1 +00:00,000 --> 00:05,000 +Chapter 1 + +2 +00:05,000 --> 00:30,000 +Chapter 2 + +3 +00:30,000 --> 01:01,349 +Chapter 3 \ No newline at end of file diff --git a/test/test/assets/chapters.vtt b/test/test/assets/chapters.vtt new file mode 100644 index 0000000000..882e540053 --- /dev/null +++ b/test/test/assets/chapters.vtt @@ -0,0 +1,10 @@ +WEBVTT + +00:00.000 --> 00:05.000 +Chapter 1 + +00:05.000 --> 00:30.000 +Chapter 2 + +00:30.000 --> 01:01.349 +Chapter 3