Skip to content

Commit

Permalink
feat: Add chapters support (shaka-project#2972)
Browse files Browse the repository at this point in the history
Add the following methods:
 - addChaptersTrack
 - getChapters
 - getChaptersTracks

The following formats are supported: WebVTT and SRT
  • Loading branch information
Álvaro Velad Galván authored Jun 22, 2021
1 parent 38ce45d commit 160e36b
Show file tree
Hide file tree
Showing 8 changed files with 377 additions and 59 deletions.
18 changes: 18 additions & 0 deletions externs/shaka/player.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
3 changes: 3 additions & 0 deletions externs/texttrack.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,6 @@ TextTrack.prototype.kind;

/** @type {string} */
TextTrack.prototype.label;

/** @type {string} */
TextTrack.prototype.language;
3 changes: 3 additions & 0 deletions lib/cast/cast_utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -372,10 +372,13 @@ shaka.cast.CastUtils.PlayerInitAfterLoadState = [
* @const {!Array.<string>}
*/
shaka.cast.CastUtils.PlayerVoidMethods = [
'addChaptersTrack',
'addTextTrack',
'addTextTrackAsync',
'cancelTrickPlay',
'configure',
'getChapters',
'getChaptersTracks',
'resetConfiguration',
'retryStreaming',
'selectAudioLanguage',
Expand Down
287 changes: 228 additions & 59 deletions lib/player.js
Original file line number Diff line number Diff line change
Expand Up @@ -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_());
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -3761,6 +3798,51 @@ shaka.Player = class extends shaka.util.FakeEventTarget {
return expected;
}

/**
* Return a list of chapters tracks.
*
* @return {!Array.<shaka.extern.Track>}
* @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.<shaka.extern.Chapter>}
* @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.
Expand Down Expand Up @@ -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.<TextTrack>}
* @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.
Expand Down Expand Up @@ -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 &&
Expand Down Expand Up @@ -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. <code>load()</code>
* 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.<shaka.extern.Track>}
* @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.<string>}
* @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
Expand Down
Loading

0 comments on commit 160e36b

Please sign in to comment.