diff --git a/index.d.ts b/index.d.ts index 2dca98b4f6..aaf8b6cf99 100644 --- a/index.d.ts +++ b/index.d.ts @@ -339,6 +339,7 @@ declare namespace dashjs { setQualityFor(type: MediaType, value: number): void; updatePortalSize(): void; enableText(enable: boolean): void; + addTextTrack(url: string, mediaInfo: MediaInfo): void; setTextTrack(idx: number): void; getTextDefaultLanguage(): string | undefined; setTextDefaultLanguage(lang: string): void; diff --git a/samples/captioning/external-caption-vtt.html b/samples/captioning/external-caption-vtt.html new file mode 100644 index 0000000000..c307ddcb08 --- /dev/null +++ b/samples/captioning/external-caption-vtt.html @@ -0,0 +1,61 @@ + + + + + WebVTT Dash Demo + + + + + + + + + + + + +
+ +
+ + + \ No newline at end of file diff --git a/samples/captioning/external-captions.vtt b/samples/captioning/external-captions.vtt new file mode 100644 index 0000000000..b299738e4d --- /dev/null +++ b/samples/captioning/external-captions.vtt @@ -0,0 +1,10 @@ +WEBVTT + +00:00:00.500 --> 00:00:04.000 +These are example captions in the VTT format + +00:00:04.500 --> 00:00:08.300 +They are not listed in the manifest file + +00:00:010.000 --> 00:00:14.000 +This is the last caption. Enjoy! \ No newline at end of file diff --git a/samples/samples.json b/samples/samples.json index 4f21963df9..197c7cc311 100644 --- a/samples/samples.json +++ b/samples/samples.json @@ -154,6 +154,11 @@ "title": "TTML EBU timed text tracks", "description": "Example showing content with TTML EBU timed text tracks.", "href": "captioning/ttml-ebutt-sample.html" + }, + { + "title": "Load external VTT captions", + "description": "Example showing how to load external VTT captions.", + "href": "captioning/external-caption-vtt.html" } ] }, diff --git a/src/core/errors/Errors.js b/src/core/errors/Errors.js index c3485b5496..a43f0a7532 100644 --- a/src/core/errors/Errors.js +++ b/src/core/errors/Errors.js @@ -112,6 +112,9 @@ class Errors extends ErrorsBase { this.REMOVE_ERROR_MESSAGE = 'buffer is not defined'; this.DATA_UPDATE_FAILED_ERROR_MESSAGE = 'Data update failed'; + this.CAPTIONS_LOADER_PARSING_FAILURE_ERROR_MESSAGE = 'caption parsing failed for '; + this.CAPTIONS_LOADER_LOADING_FAILURE_ERROR_MESSAGE = 'Failed loading captions: '; + this.CAPABILITY_MEDIASOURCE_ERROR_MESSAGE = 'mediasource is not supported'; this.CAPABILITY_MEDIAKEYS_ERROR_MESSAGE = 'mediakeys is not supported'; this.TIMED_TEXT_ERROR_MESSAGE_PARSE = 'parsing error :'; diff --git a/src/core/events/CoreEvents.js b/src/core/events/CoreEvents.js index 2579f06f80..f2f4f7eb76 100644 --- a/src/core/events/CoreEvents.js +++ b/src/core/events/CoreEvents.js @@ -64,6 +64,7 @@ class CoreEvents extends EventsBase { this.MANIFEST_UPDATED = 'manifestUpdated'; this.MEDIA_FRAGMENT_LOADED = 'mediaFragmentLoaded'; this.MEDIA_FRAGMENT_NEEDED = 'mediaFragmentNeeded'; + this.EXTERNAL_CAPTIONS_LOADED = 'externalCaptionsLoaded'; this.QUOTA_EXCEEDED = 'quotaExceeded'; this.REPRESENTATION_UPDATE_STARTED = 'representationUpdateStarted'; this.REPRESENTATION_UPDATE_COMPLETED = 'representationUpdateCompleted'; diff --git a/src/streaming/CaptionsLoader.js b/src/streaming/CaptionsLoader.js new file mode 100644 index 0000000000..c42b0d4349 --- /dev/null +++ b/src/streaming/CaptionsLoader.js @@ -0,0 +1,178 @@ +/** + * The copyright in this software is being made available under the BSD License, + * included below. This software may be subject to other third party and contributor + * rights, including patent rights, and no such rights are granted under this license. + * + * Copyright (c) 2013, Dash Industry Forum. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * * Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation and/or + * other materials provided with the distribution. + * * Neither the name of Dash Industry Forum nor the names of its + * contributors may be used to endorse or promote products derived from this software + * without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS AS IS AND ANY + * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. + * IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT + * NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR + * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, + * WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ +import Constants from './constants/Constants'; +import DashConstants from '../dash/constants/DashConstants'; +import URLLoader from './net/URLLoader'; +import URLUtils from './utils/URLUtils'; +import TextRequest from './vo/TextRequest'; +import DashJSError from './vo/DashJSError'; +import {HTTPRequest} from './vo/metrics/HTTPRequest'; +import EventBus from '../core/EventBus'; +import Events from '../core/events/Events'; +import Errors from '../core/errors/Errors'; +import FactoryMaker from '../core/FactoryMaker'; +import VTTParser from './utils/VTTParser'; + +function CaptionsLoader(config) { + + config = config || {}; + const context = this.context; + const debug = config.debug; + const eventBus = EventBus(context).getInstance(); + const urlUtils = URLUtils(context).getInstance(); + + let instance, + logger, + urlLoader, + parser; + + let mssHandler = config.mssHandler; + let errHandler = config.errHandler; + + function setup() { + logger = debug.getLogger(instance); + + urlLoader = URLLoader(context).create({ + errHandler: config.errHandler, + dashMetrics: config.dashMetrics, + mediaPlayerModel: config.mediaPlayerModel, + requestModifier: config.requestModifier, + useFetch: config.settings.get().streaming.lowLatencyEnabled, + urlUtils: urlUtils, + constants: Constants, + dashConstants: DashConstants, + errors: Errors + }); + } + + function createParser(data) { + if (data.indexOf('WEBVTT') > -1) { + return VTTParser(context).getInstance(); + } + return null; + } + + function load(url, mediaInfo) { + + const request = new TextRequest(url, HTTPRequest.MEDIA_SEGMENT_TYPE); + + urlLoader.load({ + request: request, + success: function (data, textStatus, responseURL) { + let actualUrl, + captions; + + // Handle redirects for the MPD - as per RFC3986 Section 5.1.3 + // also handily resolves relative MPD URLs to absolute + if (responseURL && responseURL !== url) { + actualUrl = responseURL; + } else { + // usually this case will be caught and resolved by + // responseURL above but it is not available for IE11 and Edge/12 and Edge/13 + if (urlUtils.isRelative(url)) { + url = urlUtils.resolve(url, window.location.href); + } + } + + // A response of no content implies in-memory is properly up to date + if (textStatus == 'No Content') { + return; + } + + // Create parser according to captions type + parser = createParser(data); + + if (parser === null) { + eventBus.trigger(Events.EXTERNAL_CAPTIONS_LOADED, { + captions: null, + error: new DashJSError( + Errors.CAPTIONS_LOADER_PARSING_FAILURE_ERROR_CODE, + Errors.CAPTIONS_LOADER_PARSING_FAILURE_ERROR_MESSAGE + `${url}` + ) + }); + return; + } + + try { + captions = parser.parse(data, 0); + } catch (e) { + errHandler.error(new DashJSError(Errors.TIMED_TEXT_ERROR_ID_PARSE_CODE, Errors.TIMED_TEXT_ERROR_MESSAGE_PARSE + e.message, data)); + } + + if (captions) { + eventBus.trigger(Events.EXTERNAL_CAPTIONS_LOADED, { captions: captions, mediaInfo: mediaInfo }); + } else { + eventBus.trigger(Events.EXTERNAL_CAPTIONS_LOADED, { + captions: null, + error: new DashJSError( + Errors.CAPTIONS_LOADER_PARSING_FAILURE_ERROR_CODE, + Errors.CAPTIONS_LOADER_PARSING_FAILURE_ERROR_MESSAGE + `${url}` + ) + }); + } + }, + error: function (request, statusText, errorText) { + eventBus.trigger(Events.EXTERNAL_CAPTIONS_LOADED, { + captions: null, + error: new DashJSError( + Errors.CAPTIONS_LOADER_LOADING_FAILURE_ERROR_CODE, + Errors.CAPTIONS_LOADER_LOADING_FAILURE_ERROR_MESSAGE + `${url}, ${errorText}` + ) + }); + } + }); + } + + function reset() { + if (urlLoader) { + urlLoader.abort(); + urlLoader = null; + } + + if (mssHandler) { + mssHandler.reset(); + } + } + + instance = { + load: load, + reset: reset + }; + + setup(); + + return instance; +} + +CaptionsLoader.__dashjs_factory_name = 'CaptionsLoader'; + +const factory = FactoryMaker.getClassFactory(CaptionsLoader); +export default factory; diff --git a/src/streaming/MediaPlayer.js b/src/streaming/MediaPlayer.js index b73cf1dc2c..8599bbe284 100644 --- a/src/streaming/MediaPlayer.js +++ b/src/streaming/MediaPlayer.js @@ -38,6 +38,7 @@ import GapController from './controllers/GapController'; import MediaController from './controllers/MediaController'; import BaseURLController from './controllers/BaseURLController'; import ManifestLoader from './ManifestLoader'; +import CaptionsLoader from './CaptionsLoader'; import ErrorHandler from './utils/ErrorHandler'; import Capabilities from './utils/Capabilities'; import CapabilitiesFilter from './utils/CapabilitiesFilter'; @@ -1288,6 +1289,33 @@ function MediaPlayer() { return textController.isTextEnabled(); } + /** + * Use this method to add a captions from an external URL (for captions not listed in the manifest). + * @param {string} url - URL of the captions file to load. Use module:MediaPlayer#dashjs.MediaPlayer.events.TEXT_TRACK_ADDED. + * @param {MediaInfo} mediaInfo - A MediaInfo object with details about the text track. + * @see {@link MediaPlayerEvents#event:TEXT_TRACK_ADDED dashjs.MediaPlayer.events.TEXT_TRACK_ADDED} + * @throws {@link module:MediaPlayer~PLAYBACK_NOT_INITIALIZED_ERROR PLAYBACK_NOT_INITIALIZED_ERROR} if called before initializePlayback function + * @memberof module:MediaPlayer + * @instance + */ + function addTextTrack(url, mediaInfo) { + if (!streamingInitialized) { + throw STREAMING_NOT_INITIALIZED_ERROR; + } + + if (textController === undefined) { + textController = TextController(context).getInstance(); + } + + let captionsLoader = createCaptionsLoader(); + + uriFragmentModel.initialize(url); + + // The event `EXTERNAL_CAPTIONS_LOADED` is handled by TextSourceBuffer + // and does the required work to add the track once it's loaded. + captionsLoader.load(url, mediaInfo); + } + /** * Use this method to change the current text track for both external time text files and fragmented text tracks. There is no need to * set the track mode on the video object to switch a track when using this method. @@ -2159,6 +2187,18 @@ function MediaPlayer() { }); } + function createCaptionsLoader() { + return CaptionsLoader(context).create({ + debug: debug, + errHandler: errHandler, + dashMetrics: dashMetrics, + mediaPlayerModel: mediaPlayerModel, + requestModifier: RequestModifier(context).getInstance(), + mssHandler: mssHandler, + settings: settings + }); + } + function detectProtection() { if (protectionController) { return protectionController; @@ -2405,6 +2445,7 @@ function MediaPlayer() { enableText: enableText, enableForcedTextStreaming: enableForcedTextStreaming, isTextEnabled: isTextEnabled, + addTextTrack: addTextTrack, setTextTrack: setTextTrack, getBitrateInfoListFor: getBitrateInfoListFor, getStreamsFromManifest: getStreamsFromManifest, diff --git a/src/streaming/text/TextSourceBuffer.js b/src/streaming/text/TextSourceBuffer.js index e6084d7ab3..04cea8a025 100644 --- a/src/streaming/text/TextSourceBuffer.js +++ b/src/streaming/text/TextSourceBuffer.js @@ -81,6 +81,8 @@ function TextSourceBuffer() { logger = Debug(context).getInstance().getLogger(instance); resetInitialSettings(); + + eventBus.on(Events.EXTERNAL_CAPTIONS_LOADED, onExternalCaptionsLoaded, instance); } function resetFragmented () { @@ -265,6 +267,18 @@ function TextSourceBuffer() { currFragmentedTrackIdx = idx; } + + function onExternalCaptionsLoaded(e) { + if (!e.error) { + // Extend `mediaInfos`-array to ensure `totalNrTracks` is increased. + mediaInfos = mediaInfos.concat([e.mediaInfo]); + createTextTrackFromMediaInfo(e.captions, e.mediaInfo); + } else { + logger.error('Unable to load external captions: ' + e.error.message); + } + eventBus.off(Events.EXTERNAL_CAPTIONS_LOADED, onExternalCaptionsLoaded, this); + } + function createTextTrackFromMediaInfo(captionData, mediaInfo) { const textTrackInfo = new TextTrackInfo(); const trackKindMap = { subtitle: 'subtitles', caption: 'captions' }; //Dash Spec has no "s" on end of KIND but HTML needs plural.