diff --git a/docs/supported-features.md b/docs/supported-features.md index 887c333f5..cb96207a5 100644 --- a/docs/supported-features.md +++ b/docs/supported-features.md @@ -64,6 +64,7 @@ not meant serve as an exhaustive list. * In-manifest [WebVTT] subtitles are automatically translated into standard HTML5 subtitle tracks * [AES-128] segment encryption +* DASH In-manifest EventStream and Event tags are automatically translated into HTML5 metadata cues ## Notable Missing Features diff --git a/src/dash-playlist-loader.js b/src/dash-playlist-loader.js index d5f761c16..bb6523cc7 100644 --- a/src/dash-playlist-loader.js +++ b/src/dash-playlist-loader.js @@ -312,6 +312,7 @@ export default class DashPlaylistLoader extends EventTarget { this.vhs_ = vhs; this.withCredentials = withCredentials; + this.addMetadataToTextTrack = options.addMetadataToTextTrack; if (!srcUrlOrPlaylist) { throw new Error('A non-empty playlist URL or object is required'); @@ -773,6 +774,8 @@ export default class DashPlaylistLoader extends EventTarget { this.updateMinimumUpdatePeriodTimeout_(); } + this.addEventStreamToMetadataTrack_(newMain); + return Boolean(newMain); } @@ -901,4 +904,24 @@ export default class DashPlaylistLoader extends EventTarget { this.trigger('loadedplaylist'); } + + /** + * Takes eventstream data from a parsed DASH manifest and adds it to the metadata text track. + * + * @param {manifest} newMain the newly parsed manifest + */ + addEventStreamToMetadataTrack_(newMain) { + // Only add new event stream metadata if we have a new manifest. + if (newMain && this.mainPlaylistLoader_.main.eventStream) { + // convert EventStream to ID3-like data. + const metadataArray = this.mainPlaylistLoader_.main.eventStream.map((eventStreamNode) => { + return { + cueTime: eventStreamNode.start, + frames: [{ data: eventStreamNode.messageData }] + }; + }); + + this.addMetadataToTextTrack('EventStream', metadataArray, this.mainPlaylistLoader_.main.duration); + } + } } diff --git a/src/playlist-controller.js b/src/playlist-controller.js index 79f0da771..eea65ea0b 100644 --- a/src/playlist-controller.js +++ b/src/playlist-controller.js @@ -26,6 +26,7 @@ import { codecsForPlaylist, unwrapCodecList, codecCount } from './util/codecs.js import { createMediaTypes, setupMediaGroups } from './media-groups'; import logger from './util/logger'; import {merge, createTimeRanges} from './util/vjs-compat'; +import { addMetadata, createMetadataTrackIfNotExists } from './util/text-tracks'; const ABORT_EARLY_EXCLUSION_SECONDS = 60 * 2; @@ -254,7 +255,8 @@ export class PlaylistController extends videojs.EventTarget { cacheEncryptionKeys, sourceUpdater: this.sourceUpdater_, timelineChangeController: this.timelineChangeController_, - exactManifestTimings: options.exactManifestTimings + exactManifestTimings: options.exactManifestTimings, + addMetadataToTextTrack: this.addMetadataToTextTrack.bind(this) }; // The source type check not only determines whether a special DASH playlist loader @@ -262,7 +264,7 @@ export class PlaylistController extends videojs.EventTarget { // manifest object (instead of a URL). In the case of vhs-json, the default // PlaylistLoader should be used. this.mainPlaylistLoader_ = this.sourceType_ === 'dash' ? - new DashPlaylistLoader(src, this.vhs_, this.requestOptions_) : + new DashPlaylistLoader(src, this.vhs_, merge(this.requestOptions_, { addMetadataToTextTrack: this.addMetadataToTextTrack.bind(this) })) : new PlaylistLoader(src, this.vhs_, this.requestOptions_); this.setupMainPlaylistLoaderListeners_(); @@ -2009,4 +2011,19 @@ export class PlaylistController extends videojs.EventTarget { return Config.BUFFER_HIGH_WATER_LINE; } + addMetadataToTextTrack(dispatchType, metadataArray, videoDuration) { + const timestampOffset = this.sourceUpdater_.videoTimestampOffset() === null ? + this.sourceUpdater_.audioTimestampOffset() : this.sourceUpdater_.videoTimestampOffset(); + + // There's potentially an issue where we could double add metadata if there's a muxed + // audio/video source with a metadata track, and an alt audio with a metadata track. + // However, this probably won't happen, and if it does it can be handled then. + createMetadataTrackIfNotExists(this.inbandTextTracks_, dispatchType, this.tech_); + addMetadata({ + inbandTextTracks: this.inbandTextTracks_, + metadataArray, + timestampOffset, + videoDuration + }); + } } diff --git a/src/segment-loader.js b/src/segment-loader.js index 5428a8347..8015eac76 100644 --- a/src/segment-loader.js +++ b/src/segment-loader.js @@ -14,8 +14,6 @@ import logger from './util/logger'; import { concatSegments } from './util/segment'; import { createCaptionsTrackIfNotExists, - createMetadataTrackIfNotExists, - addMetadata, addCaptionData, removeCuesFromTrack } from './util/text-tracks'; @@ -563,6 +561,7 @@ export default class SegmentLoader extends videojs.EventTarget { this.useDtsForTimestampOffset_ = settings.useDtsForTimestampOffset; this.captionServices_ = settings.captionServices; this.exactManifestTimings = settings.exactManifestTimings; + this.addMetadataToTextTrack = settings.addMetadataToTextTrack; // private instance variables this.checkBufferTimeout_ = null; @@ -1872,21 +1871,7 @@ export default class SegmentLoader extends videojs.EventTarget { this.metadataQueue_.id3.push(this.handleId3_.bind(this, simpleSegment, id3Frames, dispatchType)); return; } - - const timestampOffset = this.sourceUpdater_.videoTimestampOffset() === null ? - this.sourceUpdater_.audioTimestampOffset() : - this.sourceUpdater_.videoTimestampOffset(); - - // There's potentially an issue where we could double add metadata if there's a muxed - // audio/video source with a metadata track, and an alt audio with a metadata track. - // However, this probably won't happen, and if it does it can be handled then. - createMetadataTrackIfNotExists(this.inbandTextTracks_, dispatchType, this.vhs_.tech_); - addMetadata({ - inbandTextTracks: this.inbandTextTracks_, - metadataArray: id3Frames, - timestampOffset, - videoDuration: this.duration_() - }); + this.addMetadataToTextTrack(dispatchType, id3Frames, this.duration_()); } processMetadataQueue_() { diff --git a/test/loader-common.js b/test/loader-common.js index 0016b114d..c59540d9e 100644 --- a/test/loader-common.js +++ b/test/loader-common.js @@ -52,7 +52,9 @@ export const LoaderCommonHooks = { playbackRate: () => this.playbackRate, currentTime: () => this.currentTime, textTracks: () => {}, - addRemoteTextTrack: () => {}, + addRemoteTextTrack: (track) => { + return track; + }, trigger: () => {} } }; @@ -60,10 +62,16 @@ export const LoaderCommonHooks = { this.goalBufferLength = PlaylistController.prototype.goalBufferLength.bind(this); this.mediaSource = new window.MediaSource(); - this.sourceUpdater = new SourceUpdater(this.mediaSource); + this.sourceUpdater_ = new SourceUpdater(this.mediaSource); + this.inbandTextTracks_ = { + metadataTrack_: { + addCue: () => {} + } + }; this.syncController = new SyncController(); this.decrypter = new Decrypter(); this.timelineChangeController = new TimelineChangeController(); + this.addMetadataToTextTrack = PlaylistController.prototype.addMetadataToTextTrack.bind(this); this.video = document.createElement('video'); @@ -80,7 +88,7 @@ export const LoaderCommonHooks = { this.env.restore(); this.decrypter.terminate(); - this.sourceUpdater.dispose(); + this.sourceUpdater_.dispose(); this.timelineChangeController.dispose(); } }; @@ -105,10 +113,11 @@ export const LoaderCommonSettings = function(settings) { duration: () => this.mediaSource.duration, goalBufferLength: () => this.goalBufferLength(), mediaSource: this.mediaSource, - sourceUpdater: this.sourceUpdater, + sourceUpdater: this.sourceUpdater_, syncController: this.syncController, decrypter: this.decrypter, - timelineChangeController: this.timelineChangeController + timelineChangeController: this.timelineChangeController, + addMetadataToTextTrack: this.addMetadataToTextTrack }, settings); }; diff --git a/test/manifests/eventStream.mpd b/test/manifests/eventStream.mpd new file mode 100644 index 000000000..974068d19 --- /dev/null +++ b/test/manifests/eventStream.mpd @@ -0,0 +1,39 @@ + + + https://547f72e6652371c3.mediapackage.us-east-1.amazonaws.com/out/v1/ef88d1a64fd543f4a4be9a56c913d868/ + https://dai.google.com/linear/dash/pa/event/PSzZMzAkSXCmlJOWDmRj8Q/stream/7e6b742f-0013-412c-b8a9-fd527adafc89:ATL/manifest.mpd + + https://dai.google.com/linear/pods/v1/p/PSzZMzAkSXCmlJOWDmRj8Q/7e6b742f-0013-412c-b8a9-fd527adafc89:ATL/1065804/0/0/ + + + foo + bar + foo_bar + bar_foo + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/manifests/eventStreamMessageData.mpd b/test/manifests/eventStreamMessageData.mpd new file mode 100644 index 000000000..43eee0101 --- /dev/null +++ b/test/manifests/eventStreamMessageData.mpd @@ -0,0 +1,49 @@ + + + https://547f72e6652371c3.mediapackage.us-east-1.amazonaws.com/out/v1/ef88d1a64fd543f4a4be9a56c913d868/ + https://dai.google.com/linear/dash/pa/event/PSzZMzAkSXCmlJOWDmRj8Q/stream/7e6b742f-0013-412c-b8a9-fd527adafc89:ATL/manifest.mpd + + https://dai.google.com/linear/pods/v1/p/PSzZMzAkSXCmlJOWDmRj8Q/7e6b742f-0013-412c-b8a9-fd527adafc89:ATL/1065804/0/0/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/playlist-controller.test.js b/test/playlist-controller.test.js index b70a0828d..0241a555c 100644 --- a/test/playlist-controller.test.js +++ b/test/playlist-controller.test.js @@ -340,6 +340,123 @@ QUnit.test('passes options to PlaylistLoader', function(assert) { controller.dispose(); }); +QUnit.test('addMetadataToTextTrack adds expected metadata to the metadataTrack', function(assert) { + const options = { + src: 'test.mpd', + tech: this.player.tech_, + sourceType: 'dash' + }; + + // Test messageData property manifest + let expectedCueValues = [ + { + startTime: 63857834.256000005, + data: 'google_7617584398642699833' + }, + { + startTime: 63857835.056, + data: 'google_gkmxVFMIdHz413g3pIgZtITUSFFQYDnQ421MGEkVnTA' + }, + { + startTime: 63857836.056, + data: 'google_Yl7LFi1Fh-TD39nqQzIiGLDD1lx7tYRjjmYND7tEEjM' + }, + { + startTime: 63857836.650000006, + data: 'google_5437877779805246002' + }, + { + startTime: 63857837.056, + data: 'google_8X2eBAFbC2cUJmNNHkrcDKqSJQncj2nrVoB2eIu6lrc' + }, + { + startTime: 63857838.056, + data: 'google_Qyxg2ZhKfBUls-J7oj0Re0_-gCQFviaaEMMDvIOTEWE' + }, + { + startTime: 63857838.894, + data: 'google_7174574530630198647' + }, + { + startTime: 63857839.056, + data: 'google_EFt2jovkcT9PqjuLLC5kH7gIIjWvc0iIhROFED6kqsg' + }, + { + startTime: 63857840.056, + data: 'google_eUHx4vMmAikHojJZLOTR2XZdg1A9b9A8TY7F2CVC3cA' + }, + { + startTime: 63857841.056, + data: 'google_gkmxVFMIdHz413g3pIgZtITUSFFQYDnQ421MGEkVnTA' + }, + { + startTime: 63857841.638000004, + data: 'google_1443613685977331553' + }, + { + startTime: 63857842.056, + data: 'google_Yl7LFi1Fh-TD39nqQzIiGLDD1lx7tYRjjmYND7tEEjM' + }, + { + startTime: 63857843.056, + data: 'google_8X2eBAFbC2cUJmNNHkrcDKqSJQncj2nrVoB2eIu6lrc' + }, + { + startTime: 63857843.13200001, + data: 'google_5822903356700578162' + } + ]; + + let controller = new PlaylistController(options); + + controller.mainPlaylistLoader_.mainXml_ = manifests.eventStreamMessageData; + controller.mainPlaylistLoader_.handleMain_(); + // Gather actual cues. + let actualCueValues = controller.inbandTextTracks_.metadataTrack_.cues_.map((cue) => { + return { + startTime: cue.startTime, + data: cue.value.data + }; + }); + + assert.ok(controller.mainPlaylistLoader_.addMetadataToTextTrack, 'addMetadataToTextTrack is passed to the DASH mainPlaylistLoader'); + assert.deepEqual(actualCueValues, expectedCueValues, 'expected cue values are added to the metadataTrack'); + controller.dispose(); + + // Test content manifest + expectedCueValues = [ + { + startTime: 63857834.256000005, + data: 'foo' + }, + { + startTime: 63857835.056, + data: 'bar' + }, + { + startTime: 63857836.056, + data: 'foo_bar' + }, + { + startTime: 63857836.650000006, + data: 'bar_foo' + } + ]; + + controller = new PlaylistController(options); + controller.mainPlaylistLoader_.mainXml_ = manifests.eventStream; + controller.mainPlaylistLoader_.handleMain_(); + actualCueValues = controller.inbandTextTracks_.metadataTrack_.cues_.map((cue) => { + return { + startTime: cue.startTime, + data: cue.value.data + }; + }); + + assert.deepEqual(actualCueValues, expectedCueValues, 'expected cue values are added to the metadataTrack'); + controller.dispose(); +}); + QUnit.test('obeys metadata preload option', function(assert) { this.player.preload('metadata'); // main diff --git a/test/segment-loader.test.js b/test/segment-loader.test.js index a3b6a4d39..816b4d06d 100644 --- a/test/segment-loader.test.js +++ b/test/segment-loader.test.js @@ -2421,6 +2421,9 @@ QUnit.module('SegmentLoader', function(hooks) { } }); + this.sourceUpdater_ = loader.sourceUpdater_; + this.inbandTextTracks_ = loader.inbandTextTracks_; + this.tech_ = loader.vhs_.tech_; standardXHRResponse(this.requests.shift(), muxedSegment()); }); @@ -2528,6 +2531,9 @@ QUnit.module('SegmentLoader', function(hooks) { } }); + this.sourceUpdater_ = loader.sourceUpdater_; + this.inbandTextTracks_ = loader.inbandTextTracks_; + this.tech_ = loader.vhs_.tech_; standardXHRResponse(this.requests.shift(), audioSegment()); }); });