diff --git a/README.md b/README.md index 62ba73eee..f1848e135 100644 --- a/README.md +++ b/README.md @@ -146,6 +146,8 @@ are some highlights: - AES-128 segment encryption - CEA-608 captions are automatically translated into standard HTML5 [caption text tracks][0] +- In-Manifest WebVTT subtitles are automatically translated into standard HTML5 + subtitle tracks - Timed ID3 Metadata is automatically translated into HTML5 metedata text tracks - Highly customizable adaptive bitrate selection diff --git a/src/master-playlist-controller.js b/src/master-playlist-controller.js index ecdbdd4bd..2ce1ae786 100644 --- a/src/master-playlist-controller.js +++ b/src/master-playlist-controller.js @@ -3,6 +3,7 @@ */ import PlaylistLoader from './playlist-loader'; import SegmentLoader from './segment-loader'; +import VTTSegmentLoader from './vtt-segment-loader'; import Ranges from './ranges'; import videojs from 'video.js'; import AdCueTags from './ad-cue-tags'; @@ -216,13 +217,13 @@ export class MasterPlaylistController extends videojs.EventTarget { this.cueTagsTrack_.inBandMetadataTrackDispatchType = ''; } - this.audioTracks_ = []; this.requestOptions_ = { withCredentials: this.withCredentials, timeout: null }; this.audioGroups_ = {}; + this.subtitleGroups_ = { groups: {}, tracks: {} }; this.mediaSource = new videojs.MediaSource({ mode }); this.audioinfo_ = null; @@ -259,6 +260,7 @@ export class MasterPlaylistController extends videojs.EventTarget { this.masterPlaylistLoader_ = new PlaylistLoader(url, this.hls_, this.withCredentials); this.setupMasterPlaylistLoaderListeners_(); this.audioPlaylistLoader_ = null; + this.subtitlePlaylistLoader_ = null; // setup segment loaders // combined audio/video or just video when alternate audio track is selected @@ -272,17 +274,22 @@ export class MasterPlaylistController extends videojs.EventTarget { loaderType: 'audio' })); + segmentLoaderOptions.loaderType = 'vtt'; + this.subtitleSegmentLoader_ = new VTTSegmentLoader(segmentLoaderOptions); + this.decrypter_.onmessage = (event) => { if (event.data.source === 'main') { this.mainSegmentLoader_.handleDecrypted_(event.data); } else if (event.data.source === 'audio') { this.audioSegmentLoader_.handleDecrypted_(event.data); + } else if (event.data.source === 'vtt') { + this.subtitleSegmentLoader_.handleDecrypted_(event.data); } }; this.setupSegmentLoaderListeners_(); - this.masterPlaylistLoader_.start(); + this.masterPlaylistLoader_.load(); } /** @@ -314,6 +321,9 @@ export class MasterPlaylistController extends videojs.EventTarget { this.fillAudioTracks_(); this.setupAudio(); + this.fillSubtitleTracks_(); + this.setupSubtitles(); + try { this.setupSourceBuffers_(); } catch (e) { @@ -410,6 +420,7 @@ export class MasterPlaylistController extends videojs.EventTarget { this.setupAudio(); this.trigger('audioupdate'); } + this.setupSubtitles(); this.tech_.trigger({ type: 'mediachange', @@ -452,6 +463,8 @@ export class MasterPlaylistController extends videojs.EventTarget { this.audioPlaylistLoader_ = null; this.setupAudio(); }); + + this.subtitleSegmentLoader_.on('error', this.handleSubtitleError_.bind(this)); } handleAudioinfoUpdate_(event) { @@ -598,6 +611,45 @@ export class MasterPlaylistController extends videojs.EventTarget { return kind; } + /** + * fill our internal list of Subtitle Tracks with data from + * the master playlist or use a default + * + * @private + */ + fillSubtitleTracks_() { + let master = this.master(); + let mediaGroups = master.mediaGroups || {}; + + for (let mediaGroup in mediaGroups.SUBTITLES) { + if (!this.subtitleGroups_.groups[mediaGroup]) { + // this.subtitleGroups_.groups[mediaGroup] = { unforced: [], forced: {} }; + this.subtitleGroups_.groups[mediaGroup] = []; + } + + for (let label in mediaGroups.SUBTITLES[mediaGroup]) { + let properties = mediaGroups.SUBTITLES[mediaGroup][label]; + + if (!properties.forced) { + this.subtitleGroups_.groups[mediaGroup].push( + videojs.mergeOptions({ id: label }, properties)); + + if (typeof this.subtitleGroups_.tracks[label] === 'undefined') { + let track = this.tech_.addRemoteTextTrack({ + id: label, + kind: 'subtitles', + enabled: false, + language: properties.language, + label + }, true).track; + + this.subtitleGroups_.tracks[label] = track; + } + } + } + } + } + /** * Call load on our SegmentLoaders */ @@ -606,6 +658,9 @@ export class MasterPlaylistController extends videojs.EventTarget { if (this.audioPlaylistLoader_) { this.audioSegmentLoader_.load(); } + if (this.subtitlePlaylistLoader_) { + this.subtitleSegmentLoader_.load(); + } } /** @@ -623,6 +678,50 @@ export class MasterPlaylistController extends videojs.EventTarget { return result || this.audioGroups_.main; } + /** + * Returns the subtitle group for the currently active primary + * media playlist. + */ + activeSubtitleGroup_() { + let videoPlaylist = this.masterPlaylistLoader_.media(); + let result; + + if (!videoPlaylist) { + return null; + } + + if (videoPlaylist.attributes && videoPlaylist.attributes.SUBTITLES) { + result = this.subtitleGroups_.groups[videoPlaylist.attributes.SUBTITLES]; + } + + return result || this.subtitleGroups_.groups.main; + } + + activeSubtitleTrack_() { + for (let trackName in this.subtitleGroups_.tracks) { + if (this.subtitleGroups_.tracks[trackName].mode === 'showing') { + return this.subtitleGroups_.tracks[trackName]; + } + } + + return null; + } + + handleSubtitleError_() { + videojs.log.warn('Problem encountered loading the subtitle track' + + '. Switching back to default.'); + + this.subtitleSegmentLoader_.abort(); + + let track = this.activeSubtitleTrack_(); + + if (track) { + track.mode = 'disabled'; + } + + this.setupSubtitles(); + } + /** * Determine the correct audio rendition based on the active * AudioTrack and initialize a PlaylistLoader and SegmentLoader if @@ -663,7 +762,7 @@ export class MasterPlaylistController extends videojs.EventTarget { this.audioPlaylistLoader_ = new PlaylistLoader(track.properties_.resolvedUri, this.hls_, this.withCredentials); - this.audioPlaylistLoader_.start(); + this.audioPlaylistLoader_.load(); this.audioPlaylistLoader_.on('loadedmetadata', () => { let audioPlaylist = this.audioPlaylistLoader_.media(); @@ -707,6 +806,91 @@ export class MasterPlaylistController extends videojs.EventTarget { }); } + /** + * Determine the correct subtitle playlist based on the active + * SubtitleTrack and initialize a PlaylistLoader and SegmentLoader if + * necessary. This method is called once automatically before + * playback begins to enable the default subtitle track and should be + * invoked again if the track is changed. + */ + setupSubtitles() { + let subtitleGroup = this.activeSubtitleGroup_(); + let track = this.activeSubtitleTrack_(); + + this.subtitleSegmentLoader_.pause(); + + if (!track) { + // stop playlist and segment loading for subtitles + if (this.subtitlePlaylistLoader_) { + this.subtitlePlaylistLoader_.dispose(); + this.subtitlePlaylistLoader_ = null; + } + return; + } + + let properties = subtitleGroup.filter((subtitleProperties) => { + return subtitleProperties.id === track.id; + })[0]; + + // startup playlist and segment loaders for the enabled subtitle track + if (!this.subtitlePlaylistLoader_ || + // if the media hasn't loaded yet, we don't have the URI to check, so it is + // easiest to simply recreate the playlist loader + !this.subtitlePlaylistLoader_.media() || + this.subtitlePlaylistLoader_.media().resolvedUri !== properties.resolvedUri) { + + if (this.subtitlePlaylistLoader_) { + this.subtitlePlaylistLoader_.dispose(); + } + + // reset the segment loader + this.subtitleSegmentLoader_.resetEverything(); + + // can't reuse playlistloader because we're only using single renditions and not a + // proper master + this.subtitlePlaylistLoader_ = new PlaylistLoader(properties.resolvedUri, + this.hls_, + this.withCredentials); + + this.subtitlePlaylistLoader_.on('loadedmetadata', () => { + let subtitlePlaylist = this.subtitlePlaylistLoader_.media(); + + this.subtitleSegmentLoader_.playlist(subtitlePlaylist, this.requestOptions_); + this.subtitleSegmentLoader_.track(this.activeSubtitleTrack_()); + + // if the video is already playing, or if this isn't a live video and preload + // permits, start downloading segments + if (!this.tech_.paused() || + (subtitlePlaylist.endList && this.tech_.preload() !== 'none')) { + this.subtitleSegmentLoader_.load(); + } + }); + + this.subtitlePlaylistLoader_.on('loadedplaylist', () => { + let updatedPlaylist; + + if (this.subtitlePlaylistLoader_) { + updatedPlaylist = this.subtitlePlaylistLoader_.media(); + } + + if (!updatedPlaylist) { + return; + } + + this.subtitleSegmentLoader_.playlist(updatedPlaylist, this.requestOptions_); + }); + + this.subtitlePlaylistLoader_.on('error', this.handleSubtitleError_.bind(this)); + } + + if (this.subtitlePlaylistLoader_.media() && + this.subtitlePlaylistLoader_.media().resolvedUri === properties.resolvedUri) { + this.subtitleSegmentLoader_.load(); + } else { + this.subtitlePlaylistLoader_.load(); + } + } + /** * Re-tune playback quality level for the current player * conditions. This method may perform destructive actions, like @@ -868,6 +1052,9 @@ export class MasterPlaylistController extends videojs.EventTarget { if (this.audioPlaylistLoader_) { this.audioSegmentLoader_.pause(); } + if (this.subtitlePlaylistLoader_) { + this.subtitleSegmentLoader_.pause(); + } } /** @@ -909,12 +1096,19 @@ export class MasterPlaylistController extends videojs.EventTarget { this.audioSegmentLoader_.resetEverything(); this.audioSegmentLoader_.abort(); } + if (this.subtitlePlaylistLoader_) { + this.subtitleSegmentLoader_.resetEverything(); + this.subtitleSegmentLoader_.abort(); + } if (!this.tech_.paused()) { this.mainSegmentLoader_.load(); if (this.audioPlaylistLoader_) { this.audioSegmentLoader_.load(); } + if (this.subtitlePlaylistLoader_) { + this.subtitleSegmentLoader_.load(); + } } } @@ -1032,7 +1226,11 @@ export class MasterPlaylistController extends videojs.EventTarget { if (this.audioPlaylistLoader_) { this.audioPlaylistLoader_.dispose(); } + if (this.subtitlePlaylistLoader_) { + this.subtitlePlaylistLoader_.dispose(); + } this.audioSegmentLoader_.dispose(); + this.subtitleSegmentLoader_.dispose(); } /** diff --git a/src/playlist-loader.js b/src/playlist-loader.js index dad1b40e9..2b075207b 100644 --- a/src/playlist-loader.js +++ b/src/playlist-loader.js @@ -416,20 +416,32 @@ const PlaylistLoader = function(srcUrl, hls, withCredentials) { loader.pause = () => { loader.stopRequest(); window.clearTimeout(mediaUpdateTimeout); + if (loader.state === 'HAVE_NOTHING') { + // If we pause the loader before any data has been retrieved, its as if we never + // started, so reset to an unstarted state. + loader.started = false; + } }; /** * start loading of the playlist */ loader.load = () => { - if (loader.started) { - if (!loader.media().endList) { - loader.trigger('mediaupdatetimeout'); - } else { - loader.trigger('loadedplaylist'); - } - } else { + if (!loader.started) { loader.start(); + return; + } + + let media = loader.media(); + + if (!media) { + return; + } + + if (!media.endList) { + loader.trigger('mediaupdatetimeout'); + } else { + loader.trigger('loadedplaylist'); } }; @@ -488,16 +500,18 @@ const PlaylistLoader = function(srcUrl, hls, withCredentials) { } // resolve any media group URIs - for (let groupKey in loader.master.mediaGroups.AUDIO) { - for (let labelKey in loader.master.mediaGroups.AUDIO[groupKey]) { - let alternateAudio = loader.master.mediaGroups.AUDIO[groupKey][labelKey]; - - if (alternateAudio.uri) { - alternateAudio.resolvedUri = - resolveUrl(loader.master.uri, alternateAudio.uri); + ['AUDIO', 'SUBTITLES'].forEach((mediaType) => { + for (let groupKey in loader.master.mediaGroups[mediaType]) { + for (let labelKey in loader.master.mediaGroups[mediaType][groupKey]) { + let mediaProperties = loader.master.mediaGroups[mediaType][groupKey][labelKey]; + + if (mediaProperties.uri) { + mediaProperties.resolvedUri = + resolveUrl(loader.master.uri, mediaProperties.uri); + } } } - } + }); loader.trigger('loadedplaylist'); if (!request) { diff --git a/src/sync-controller.js b/src/sync-controller.js index a23f0f07b..d752c4eef 100644 --- a/src/sync-controller.js +++ b/src/sync-controller.js @@ -339,6 +339,13 @@ export default class SyncController extends videojs.EventTarget { }; } + timestampOffsetForTimeline(timeline) { + if (typeof this.timelines[timeline] === 'undefined') { + return null; + } + return this.timelines[timeline].time; + } + /** * Use the "media time" for a segment to generate a mapping to "display time" and * save that display time to the segment. @@ -359,6 +366,7 @@ export default class SyncController extends videojs.EventTarget { mapping: segmentInfo.timestampOffset - timingInfo.start }; this.timelines[segmentInfo.timeline] = mappingObj; + this.trigger('timestampoffset'); segment.start = segmentInfo.timestampOffset; segment.end = timingInfo.end + mappingObj.mapping; diff --git a/src/videojs-contrib-hls.js b/src/videojs-contrib-hls.js index 410b40e32..97c3769a1 100644 --- a/src/videojs-contrib-hls.js +++ b/src/videojs-contrib-hls.js @@ -377,6 +377,10 @@ class HlsHandler extends Component { this.masterPlaylistController_.setupAudio(); }; + this.textTrackChange_ = () => { + this.masterPlaylistController_.setupSubtitles(); + }; + this.on(this.tech_, 'play', this.play); } @@ -525,6 +529,7 @@ class HlsHandler extends Component { this.masterPlaylistController_.on('sourceopen', () => { this.tech_.audioTracks().addEventListener('change', this.audioTrackChange_); + this.tech_.remoteTextTracks().addEventListener('change', this.textTrackChange_); }); this.masterPlaylistController_.on('selectedinitialmedia', () => { @@ -637,6 +642,7 @@ class HlsHandler extends Component { this.qualityLevels_.dispose(); } this.tech_.audioTracks().removeEventListener('change', this.audioTrackChange_); + this.tech_.remoteTextTracks().removeEventListener('change', this.textTrackChange_); super.dispose(); } } diff --git a/src/vtt-segment-loader.js b/src/vtt-segment-loader.js index 8af5dbde8..0de74242d 100644 --- a/src/vtt-segment-loader.js +++ b/src/vtt-segment-loader.js @@ -1,46 +1,17 @@ /** - * @file segment-loader.js + * @file vtt-segment-loader.js */ import {getMediaInfoForTime_ as getMediaInfoForTime} from './playlist'; import videojs from 'video.js'; -import SourceUpdater from './source-updater'; import Config from './config'; import window from 'global/window'; import { createTransferableMessage } from './bin-utils'; -import removeCuesFromTrack from 'videojs-contrib-media-sources/es5/remove-cues-from-track.js'; +import removeCuesFromTrack from + 'videojs-contrib-media-sources/es5/remove-cues-from-track'; // in ms const CHECK_BUFFER_DELAY = 500; -/** - * Determines if we should call endOfStream on the media source based - * on the state of the buffer or if appened segment was the final - * segment in the playlist. - * - * @param {Object} playlist a media playlist object - * @param {Object} mediaSource the MediaSource object - * @param {Number} segmentIndex the index of segment we last appended - * @returns {Boolean} do we need to call endOfStream on the MediaSource - */ -const detectEndOfStream = function(playlist, mediaSource, segmentIndex) { - if (!playlist) { - return false; - } - - let segments = playlist.segments; - - // determine a few boolean values to help make the branch below easier - // to read - let appendedLastSegment = segmentIndex === segments.length; - - // if we've buffered to the end of the video, we need to call endOfStream - // so that MediaSources can trigger the `ended` event when it runs out of - // buffered data instead of waiting for me - return playlist.endList && - mediaSource.readyState === 'open' && - appendedLastSegment; -}; - /** * Turns segment byterange into a string suitable for use in * HTTP Range requests @@ -83,14 +54,18 @@ const initSegmentId = function(initSegment) { ].join(','); }; +const uintToString = function(uintArray) { + return String.fromCharCode.apply(null, uintArray); +}; + /** * An object that manages segment loading and appending. * - * @class SegmentLoader + * @class VTTSegmentLoader * @param {Object} options required and optional options * @extends videojs.EventTarget */ -export default class SegmentLoader extends videojs.EventTarget { +export default class VTTSegmentLoader extends videojs.EventTarget { constructor(options) { super(); // check pre-conditions @@ -100,9 +75,6 @@ export default class SegmentLoader extends videojs.EventTarget { if (typeof options.currentTime !== 'function') { throw new TypeError('No currentTime getter specified'); } - if (!options.mediaSource) { - throw new TypeError('No MediaSource specified'); - } let settings = videojs.mergeOptions(videojs.options.hls, options); // public properties @@ -122,7 +94,6 @@ export default class SegmentLoader extends videojs.EventTarget { this.mediaSource_ = settings.mediaSource; this.hls_ = settings.hls; this.loaderType_ = settings.loaderType; - this.segmentMetadataTrack_ = settings.segmentMetadataTrack; // private instance variables this.checkBufferTimeout_ = null; @@ -130,12 +101,12 @@ export default class SegmentLoader extends videojs.EventTarget { this.currentTimeline_ = -1; this.xhr_ = null; this.pendingSegment_ = null; - this.mimeType_ = null; this.sourceUpdater_ = null; this.xhrOptions_ = null; + this.subtitlesTrack_ = null; + // Fragmented mp4 playback - this.activeInitSegmentId_ = null; this.initSegments_ = {}; this.decrypter_ = settings.decrypter; @@ -177,9 +148,6 @@ export default class SegmentLoader extends videojs.EventTarget { dispose() { this.state = 'DISPOSED'; this.abort_(); - if (this.sourceUpdater_) { - this.sourceUpdater_.dispose(); - } this.resetStats_(); } @@ -234,6 +202,25 @@ export default class SegmentLoader extends videojs.EventTarget { return this.error_; } + /** + * Indicates which time ranges are buffered + */ + buffered() { + if (!this.subtitlesTrack_ || !this.subtitlesTrack_.cues.length) { + return videojs.createTimeRanges(); + } + + const cues = this.subtitlesTrack_.cues; + let start = cues[0].startTime; + let end = cues[cues.length - 1].startTime; + + return videojs.createTimeRanges([[start, end]]); + } + + timestampOffset() { + return this.syncController_.timestampOffsetForTimeline(this.currentTimeline_); + } + /** * load a playlist and start to fill the buffer */ @@ -251,15 +238,14 @@ export default class SegmentLoader extends videojs.EventTarget { this.syncController_.setDateTimeMapping(this.playlist_); // if all the configuration is ready, initialize and begin loading - if (this.state === 'INIT' && this.mimeType_) { + if (this.state === 'INIT' && this.subtitlesTrack_) { return this.init_(); } // if we're in the middle of processing a segment already, don't // kick off an additional segment request - if (!this.sourceUpdater_ || - (this.state !== 'READY' && - this.state !== 'INIT')) { + if (this.state !== 'READY' && + this.state !== 'INIT') { return; } @@ -275,11 +261,23 @@ export default class SegmentLoader extends videojs.EventTarget { */ init_() { this.state = 'READY'; - this.sourceUpdater_ = new SourceUpdater(this.mediaSource_, this.mimeType_); this.resetEverything(); return this.monitorBuffer_(); } + track(track) { + this.subtitlesTrack_ = track; + + // if we were unpaused but waiting for a sourceUpdater, start + // buffering now + if (this.playlist_ && + this.state === 'INIT' && + !this.paused() && + this.subtitlesTrack_) { + this.init_(); + } + } + /** * set a playlist on the segment loader * @@ -312,7 +310,7 @@ export default class SegmentLoader extends videojs.EventTarget { // if we were unpaused but waiting for a playlist, start // buffering now - if (this.mimeType_ && this.state === 'INIT' && !this.paused()) { + if (this.subtitlesTrack_ && this.state === 'INIT' && !this.paused()) { return this.init_(); } @@ -380,27 +378,6 @@ export default class SegmentLoader extends videojs.EventTarget { return this.checkBufferTimeout_ === null; } - /** - * create/set the following mimetype on the SourceBuffer through a - * SourceUpdater - * - * @param {String} mimeType the mime type string to use - */ - mimeType(mimeType) { - if (this.mimeType_) { - return; - } - - this.mimeType_ = mimeType; - // if we were unpaused but waiting for a sourceUpdater, start - // buffering now - if (this.playlist_ && - this.state === 'INIT' && - !this.paused()) { - this.init_(); - } - } - /** * Delete all the buffered data and reset the SegmentLoader */ @@ -435,10 +412,7 @@ export default class SegmentLoader extends videojs.EventTarget { * @param {Number} end - the end time of the region to remove from the buffer */ remove(start, end) { - if (this.sourceUpdater_) { - this.sourceUpdater_.remove(start, end); - } - removeCuesFromTrack(start, end, this.segmentMetadataTrack_); + removeCuesFromTrack(start, end, this.subtitlesTrack_); } /** @@ -483,10 +457,6 @@ export default class SegmentLoader extends videojs.EventTarget { * @private */ fillBuffer_() { - if (this.sourceUpdater_.updating()) { - return; - } - if (!this.syncPoint_) { this.syncPoint_ = this.syncController_.getSyncPoint(this.playlist_, this.mediaSource_.duration, @@ -495,7 +465,7 @@ export default class SegmentLoader extends videojs.EventTarget { } // see if we need to begin loading immediately - let segmentInfo = this.checkBuffer_(this.sourceUpdater_.buffered(), + let segmentInfo = this.checkBuffer_(this.buffered(), this.playlist_, this.mediaIndex, this.hasPlayed_(), @@ -506,34 +476,30 @@ export default class SegmentLoader extends videojs.EventTarget { return; } - let isEndOfStream = detectEndOfStream(this.playlist_, - this.mediaSource_, - segmentInfo.mediaIndex); + // TODO is this check important + // if (segmentInfo.mediaIndex === this.playlist_.segments.length - 1 && + // this.mediaSource_.readyState === 'ended' && + // !this.seeking_()) { + // return; + // } - if (isEndOfStream) { - this.mediaSource_.endOfStream(); - return; - } + if (this.syncController_.timestampOffsetForTimeline(segmentInfo.timeline) === null) { + // We don't have the timestamp offset that we need to sync subtitles. + // Rerun on a timestamp offset or user interaction. + let checkTimestampOffset = () => { + this.syncController_.off('timestampoffset', checkTimestampOffset); + this.state = 'READY'; + if (!this.paused()) { + // if not paused, queue a buffer check as soon as possible + this.monitorBuffer_(); + } + }; - if (segmentInfo.mediaIndex === this.playlist_.segments.length - 1 && - this.mediaSource_.readyState === 'ended' && - !this.seeking_()) { + this.syncController_.on('timestampoffset', checkTimestampOffset); + this.state = 'WAITING_ON_TIMELINE'; return; } - // We will need to change timestampOffset of the sourceBuffer if either of - // the following conditions are true: - // - The segment.timeline !== this.currentTimeline - // (we are crossing a discontinuity somehow) - // - The "timestampOffset" for the start of this segment is less than - // the currently set timestampOffset - if (segmentInfo.timeline !== this.currentTimeline_ || - ((segmentInfo.startOfSegment !== null) && - segmentInfo.startOfSegment < this.sourceUpdater_.timestampOffset())) { - this.syncController_.reset(); - segmentInfo.timestampOffset = segmentInfo.startOfSegment; - } - this.loadSegment_(segmentInfo); } @@ -1006,44 +972,166 @@ export default class SegmentLoader extends videojs.EventTarget { let segmentInfo = this.pendingSegment_; let segment = segmentInfo.segment; - this.syncController_.probeSegmentInfo(segmentInfo); + // Make sure that vttjs has loaded, otherwise, wait till it finished loading + if (typeof window.WebVTT !== 'function' && + this.subtitlesTrack_ && + this.subtitlesTrack_.tech_) { + + const loadHandler = () => { + this.subtitlesTrack_.tech_.off('vttjsloaded', loadHandler); + this.handleSegment_(); + }; + + this.state = 'WAITING_ON_VTTJS'; + this.subtitlesTrack_.tech_.on('vttjsloaded', loadHandler); + this.subtitlesTrack_.tech_.on('vttjserror', () => { + this.subtitlesTrack_.tech_.off('vttjsloaded', loadHandler); + this.error({ + message: 'Error loading vtt.js' + }); + this.state = 'READY'; + this.pause(); + this.trigger('error'); + }); - if (segmentInfo.isSyncRequest) { - this.trigger('syncinfoupdate'); - this.pendingSegment_ = null; - this.state = 'READY'; return; } - if (segmentInfo.timestampOffset !== null && - segmentInfo.timestampOffset !== this.sourceUpdater_.timestampOffset()) { - this.sourceUpdater_.timestampOffset(segmentInfo.timestampOffset); - } + segment.requested = true; - // if the media initialization segment is changing, append it - // before the content segment if (segment.map) { - let initId = initSegmentId(segment.map); + // append WebVTT line terminators to the media initialization segment if it exists + // to follow the WebVTT spec (https://w3c.github.io/webvtt/#file-structure) that + // requires two or more WebVTT line terminators between the WebVTT header and the rest + // of the file + const initId = initSegmentId(segment.map); + const initSegment = this.initSegments_[initId]; + const vttLineTerminators = new Uint8Array('\n\n'.split('').map(char => char.charCodeAt(0))); + const combinedByteLength = vttLineTerminators.byteLength + initSegment.bytes.byteLength; + const combinedSegment = new Uint8Array(combinedByteLength); + + combinedSegment.set(initSegment.bytes); + combinedSegment.set(vttLineTerminators, initSegment.bytes.byteLength); + segment.map.bytes = combinedSegment; + } + + try { + this.parseVTTCues_(segmentInfo); + } catch (e) { + this.error({ + message: e.message + }); + this.state = 'READY'; + this.pause(); + return this.trigger('error'); + } - if (!this.activeInitSegmentId_ || - this.activeInitSegmentId_ !== initId) { - let initSegment = this.initSegments_[initId]; + this.updateTimeMapping_(segmentInfo, + this.syncController_.timelines[segmentInfo.timeline], + this.playlist_); - this.sourceUpdater_.appendBuffer(initSegment.bytes, () => { - this.activeInitSegmentId_ = initId; - }); - } + if (segmentInfo.isSyncRequest) { + this.trigger('syncinfoupdate'); + this.pendingSegment_ = null; + this.state = 'READY'; + return; } segmentInfo.byteLength = segmentInfo.bytes.byteLength; + if (typeof segment.start === 'number' && typeof segment.end === 'number') { this.mediaSecondsLoaded += segment.end - segment.start; } else { this.mediaSecondsLoaded += segment.duration; } - this.sourceUpdater_.appendBuffer(segmentInfo.bytes, - this.handleUpdateEnd_.bind(this)); + segmentInfo.cues.forEach((cue) => { + this.subtitlesTrack_.addCue(cue); + }); + + this.handleUpdateEnd_(); + } + + parseVTTCues_(segmentInfo) { + let decoder; + let decodeBytesToString = false; + + if (typeof window.TextDecoder === 'function') { + decoder = new window.TextDecoder('utf8'); + } else { + decoder = window.WebVTT.StringDecoder(); + decodeBytesToString = true; + } + + const parser = new window.WebVTT.Parser(window, + window.vttjs, + decoder); + + segmentInfo.cues = []; + segmentInfo.timestampmap = { MPEGTS: 0, LOCAL: 0 }; + + parser.oncue = segmentInfo.cues.push.bind(segmentInfo.cues); + parser.ontimestampmap = (map) => segmentInfo.timestampmap = map; + parser.onparsingerror = (error) => { + videojs.log.warn('Error encountered when parsing cues: ' + error.message); + }; + + if (segmentInfo.segment.map) { + let mapData = segmentInfo.segment.map.bytes; + + if (decodeBytesToString) { + mapData = uintToString(mapData); + } + + parser.parse(mapData); + } + + let segmentData = segmentInfo.bytes; + + if (decodeBytesToString) { + segmentData = uintToString(segmentData); + } + + parser.parse(segmentData); + parser.flush(); + } + + updateTimeMapping_(segmentInfo, mappingObj, playlist) { + let segment = segmentInfo.segment; + let timestampmap = segmentInfo.timestampmap; + + if (!mappingObj || !segmentInfo.cues.length) { + // If the sync controller does not have a mapping of TS to Media Time for the + // timeline, then we don't have enough information to update the segment and cue + // start/end times + // If there are no cues, we also do not have enough information to figure out + // segment timing + return; + } + + const diff = (timestampmap.MPEGTS / 90000) - timestampmap.LOCAL + mappingObj.mapping; + + segmentInfo.cues.forEach((cue) => { + // First convert cue time to TS time using the timestamp-map provided within the vtt + cue.startTime += diff; + cue.endTime += diff; + }); + + const firstStart = segmentInfo.cues[0].startTime; + const lastStart = segmentInfo.cues[segmentInfo.cues.length - 1].startTime; + const midPoint = (firstStart + lastStart) / 2; + + segment.start = midPoint - (segment.duration / 2); + segment.end = midPoint + (segment.duration / 2); + + if (!playlist.syncInfo) { + playlist.syncInfo = { + mediaSequence: playlist.mediaSequence + segmentInfo.mediaIndex, + time: segment.start + }; + } + + // TODO - adjust other segments with new info } /** @@ -1070,7 +1158,6 @@ export default class SegmentLoader extends videojs.EventTarget { this.pendingSegment_ = null; this.recordThroughput_(segmentInfo); - this.addSegmentMetadataCue_(segmentInfo); this.state = 'READY'; @@ -1099,17 +1186,6 @@ export default class SegmentLoader extends videojs.EventTarget { this.trigger('progress'); } - // any time an update finishes and the last segment is in the - // buffer, end the stream. this ensures the "ended" event will - // fire if playback reaches that point. - let isEndOfStream = detectEndOfStream(segmentInfo.playlist, - this.mediaSource_, - this.mediaIndex + 1); - - if (isEndOfStream) { - this.mediaSource_.endOfStream(); - } - if (!this.paused()) { this.monitorBuffer_(); } @@ -1147,42 +1223,4 @@ export default class SegmentLoader extends videojs.EventTarget { * @private */ logger_() {} - - /** - * Adds a cue to the segment-metadata track with some metadata information about the - * segment - * - * @private - * @param {Object} segmentInfo - * the object returned by loadSegment - * @method addSegmentMetadataCue_ - */ - addSegmentMetadataCue_(segmentInfo) { - if (!this.segmentMetadataTrack_) { - return; - } - - const segment = segmentInfo.segment; - const start = segment.start; - const end = segment.end; - - removeCuesFromTrack(start, end, this.segmentMetadataTrack_); - - const Cue = window.WebKitDataCue || window.VTTCue; - const value = { - uri: segmentInfo.uri, - timeline: segmentInfo.timeline, - playlist: segmentInfo.playlist.uri, - start, - end - }; - const data = JSON.stringify(value); - const cue = new Cue(start, end, data); - - // Attach the metadata to the value property of the cue to keep consistency between - // the differences of WebKitDataCue in safari and VTTCue in other browsers - cue.value = value; - - this.segmentMetadataTrack_.addCue(cue); - } } diff --git a/test/master-playlist-controller.test.js b/test/master-playlist-controller.test.js index ccba40df8..384f2af97 100644 --- a/test/master-playlist-controller.test.js +++ b/test/master-playlist-controller.test.js @@ -1033,6 +1033,478 @@ QUnit.test('correctly sets alternate audio track kinds', function(assert) { 'spanish track\'s kind is "alternative"'); }); +QUnit.test('adds subtitle tracks when a media playlist is loaded', function(assert) { + this.requests.length = 0; + this.player = createPlayer(); + this.player.src({ + src: 'manifest/master-subtitles.m3u8', + type: 'application/vnd.apple.mpegurl' + }); + + const masterPlaylistController = this.player.tech_.hls.masterPlaylistController_; + + assert.equal(this.player.textTracks().length, 0, 'no text tracks to start'); + + // master, contains media groups for subtitles + this.standardXHRResponse(this.requests.shift()); + + // we wait for loadedmetadata before setting subtitle tracks, so we need to wait for a + // media playlist + assert.equal(this.player.textTracks().length, 0, 'no text tracks after master load'); + + // media + this.standardXHRResponse(this.requests.shift()); + + const master = masterPlaylistController.masterPlaylistLoader_.master; + const subs = master.mediaGroups.SUBTITLES.subs; + const subsArr = Object.keys(subs).map(key => subs[key]); + + assert.equal(subsArr.length, 4, 'got 4 subtitles'); + assert.equal(subsArr.filter(sub => sub.forced === false).length, 2, '2 forced'); + assert.equal(subsArr.filter(sub => sub.forced === true).length, 2, '2 non-forced'); + + const textTracks = this.player.textTracks(); + + assert.equal(textTracks.length, 2, 'non-forced text tracks were added'); + assert.equal(textTracks[0].mode, 'disabled', 'track starts disabled'); + assert.equal(textTracks[1].mode, 'disabled', 'track starts disabled'); +}); + +QUnit.test('switches off subtitles on subtitle errors', function(assert) { + this.requests.length = 0; + this.player = createPlayer(); + this.player.src({ + src: 'manifest/master-subtitles.m3u8', + type: 'application/vnd.apple.mpegurl' + }); + + const masterPlaylistController = this.player.tech_.hls.masterPlaylistController_; + + // sets up listener for text track changes + masterPlaylistController.trigger('sourceopen'); + + // master, contains media groups for subtitles + this.standardXHRResponse(this.requests.shift()); + // media + this.standardXHRResponse(this.requests.shift()); + + const textTracks = this.player.textTracks(); + + assert.equal(this.requests.length, 0, 'no outstanding requests'); + + // enable first text track + textTracks[0].mode = 'showing'; + + assert.equal(this.requests.length, 1, 'made a request'); + assert.equal(textTracks[0].mode, 'showing', 'text track still showing'); + + // request failed + this.requests.shift().respond(404, null, ''); + + assert.equal(textTracks[0].mode, 'disabled', 'disabled text track'); + + assert.equal(this.env.log.warn.callCount, 1, 'logged a warning'); + this.env.log.warn.callCount = 0; + + assert.equal(this.requests.length, 0, 'no outstanding requests'); + + // re-enable first text track + textTracks[0].mode = 'showing'; + + assert.equal(this.requests.length, 1, 'made a request'); + assert.equal(textTracks[0].mode, 'showing', 'text track still showing'); + + this.requests.shift().respond(200, null, ` + #EXTM3U + #EXT-X-TARGETDURATION:10 + #EXT-X-MEDIA-SEQUENCE:0 + #EXTINF:10 + 0.webvtt + #EXT-X-ENDLIST + `); + + const syncController = masterPlaylistController.subtitleSegmentLoader_.syncController_; + + // required for the vtt request to be made + syncController.timestampOffsetForTimeline = () => 0; + + this.clock.tick(1); + + assert.equal(this.requests.length, 1, 'made a request'); + assert.ok(this.requests[0].url.endsWith('0.webvtt'), 'made a webvtt request'); + assert.equal(textTracks[0].mode, 'showing', 'text track still showing'); + + this.requests.shift().respond(404, null, ''); + + assert.equal(textTracks[0].mode, 'disabled', 'disabled text track'); + + assert.equal(this.env.log.warn.callCount, 1, 'logged a warning'); + this.env.log.warn.callCount = 0; +}); + +QUnit.test('pauses subtitle segment loader on tech errors', function(assert) { + this.requests.length = 0; + this.player = createPlayer(); + this.player.src({ + src: 'manifest/master-subtitles.m3u8', + type: 'application/vnd.apple.mpegurl' + }); + + const masterPlaylistController = this.player.tech_.hls.masterPlaylistController_; + + // sets up listener for text track changes + masterPlaylistController.trigger('sourceopen'); + + // master, contains media groups for subtitles + this.standardXHRResponse(this.requests.shift()); + // media + this.standardXHRResponse(this.requests.shift()); + + const textTracks = this.player.textTracks(); + + // enable first text track + textTracks[0].mode = 'showing'; + + let pauseCount = 0; + + masterPlaylistController.subtitleSegmentLoader_.pause = () => pauseCount++; + + this.player.tech_.trigger('error'); + + assert.equal(pauseCount, 1, 'paused subtitle segment loader'); +}); + +QUnit.test('disposes subtitle loaders on dispose', function(assert) { + this.requests.length = 0; + this.player = createPlayer(); + this.player.src({ + src: 'manifest/master-subtitles.m3u8', + type: 'application/vnd.apple.mpegurl' + }); + + let masterPlaylistController = this.player.tech_.hls.masterPlaylistController_; + + assert.notOk(masterPlaylistController.subtitlePlaylistLoader_, + 'does not start with a subtitle playlist loader'); + assert.ok(masterPlaylistController.subtitleSegmentLoader_, + 'starts with a subtitle segment loader'); + + let segmentLoaderDisposeCount = 0; + + masterPlaylistController.subtitleSegmentLoader_.dispose = + () => segmentLoaderDisposeCount++; + + masterPlaylistController.dispose(); + + assert.equal(segmentLoaderDisposeCount, 1, 'disposed the subtitle segment loader'); + + this.requests.length = 0; + this.player = createPlayer(); + this.player.src({ + src: 'manifest/master-subtitles.m3u8', + type: 'application/vnd.apple.mpegurl' + }); + + masterPlaylistController = this.player.tech_.hls.masterPlaylistController_; + + // sets up listener for text track changes + masterPlaylistController.trigger('sourceopen'); + + // master, contains media groups for subtitles + this.standardXHRResponse(this.requests.shift()); + // media + this.standardXHRResponse(this.requests.shift()); + + const textTracks = this.player.textTracks(); + + // enable first text track + textTracks[0].mode = 'showing'; + + assert.ok(masterPlaylistController.subtitlePlaylistLoader_, + 'has a subtitle playlist loader'); + assert.ok(masterPlaylistController.subtitleSegmentLoader_, + 'has a subtitle segment loader'); + + let playlistLoaderDisposeCount = 0; + + segmentLoaderDisposeCount = 0; + + masterPlaylistController.subtitlePlaylistLoader_.dispose = + () => playlistLoaderDisposeCount++; + masterPlaylistController.subtitleSegmentLoader_.dispose = + () => segmentLoaderDisposeCount++; + + masterPlaylistController.dispose(); + + assert.equal(playlistLoaderDisposeCount, 1, 'disposed the subtitle playlist loader'); + assert.equal(segmentLoaderDisposeCount, 1, 'disposed the subtitle segment loader'); +}); + +QUnit.test('subtitle segment loader resets on seeks', function(assert) { + this.requests.length = 0; + this.player = createPlayer(); + this.player.src({ + src: 'manifest/master-subtitles.m3u8', + type: 'application/vnd.apple.mpegurl' + }); + + const masterPlaylistController = this.player.tech_.hls.masterPlaylistController_; + + // sets up listener for text track changes + masterPlaylistController.trigger('sourceopen'); + + // master, contains media groups for subtitles + this.standardXHRResponse(this.requests.shift()); + // media + this.standardXHRResponse(this.requests.shift()); + + const textTracks = this.player.textTracks(); + + // enable first text track + textTracks[0].mode = 'showing'; + + let resetCount = 0; + let abortCount = 0; + let loadCount = 0; + + masterPlaylistController.subtitleSegmentLoader_.resetEverything = () => resetCount++; + masterPlaylistController.subtitleSegmentLoader_.abort = () => abortCount++; + masterPlaylistController.subtitleSegmentLoader_.load = () => loadCount++; + + this.player.pause(); + masterPlaylistController.setCurrentTime(5); + + assert.equal(resetCount, 1, 'reset subtitle segment loader'); + assert.equal(abortCount, 1, 'aborted subtitle segment loader'); + assert.equal(loadCount, 0, 'did not call load on subtitle segment loader'); + + this.player.play(); + resetCount = 0; + abortCount = 0; + loadCount = 0; + masterPlaylistController.setCurrentTime(10); + + assert.equal(resetCount, 1, 'reset subtitle segment loader'); + assert.equal(abortCount, 1, 'aborted subtitle segment loader'); + assert.equal(loadCount, 1, 'called load on subtitle segment loader'); +}); + +QUnit.test('can get active subtitle group', function(assert) { + this.requests.length = 0; + this.player = createPlayer(); + this.player.src({ + src: 'manifest/master-subtitles.m3u8', + type: 'application/vnd.apple.mpegurl' + }); + + const masterPlaylistController = this.player.tech_.hls.masterPlaylistController_; + + assert.notOk(masterPlaylistController.activeSubtitleGroup_(), + 'no active subtitle group'); + + // master, contains media groups for subtitles + this.standardXHRResponse(this.requests.shift()); + + assert.notOk(masterPlaylistController.activeSubtitleGroup_(), + 'no active subtitle group'); + + // media + this.standardXHRResponse(this.requests.shift()); + + assert.ok(masterPlaylistController.activeSubtitleGroup_(), 'active subtitle group'); +}); + +QUnit.test('can get active subtitle track', function(assert) { + this.requests.length = 0; + this.player = createPlayer(); + this.player.src({ + src: 'manifest/master-subtitles.m3u8', + type: 'application/vnd.apple.mpegurl' + }); + + // master, contains media groups for subtitles + this.standardXHRResponse(this.requests.shift()); + // media + this.standardXHRResponse(this.requests.shift()); + + const masterPlaylistController = this.player.tech_.hls.masterPlaylistController_; + + assert.notOk(masterPlaylistController.activeSubtitleTrack_(), + 'no active subtitle track'); + + const textTracks = this.player.textTracks(); + + // enable first text track + textTracks[0].mode = 'showing'; + + assert.ok(masterPlaylistController.activeSubtitleTrack_(), 'active subtitle track'); +}); + +QUnit.test('handles subtitle errors appropriately', function(assert) { + this.requests.length = 0; + this.player = createPlayer(); + this.player.src({ + src: 'manifest/master-subtitles.m3u8', + type: 'application/vnd.apple.mpegurl' + }); + + // master, contains media groups for subtitles + this.standardXHRResponse(this.requests.shift()); + // media + this.standardXHRResponse(this.requests.shift()); + + const textTracks = this.player.textTracks(); + + // enable first text track + textTracks[0].mode = 'showing'; + + const masterPlaylistController = this.player.tech_.hls.masterPlaylistController_; + let abortCalls = 0; + let setupSubtitlesCalls = 0; + + masterPlaylistController.subtitleSegmentLoader_.abort = () => abortCalls++; + masterPlaylistController.setupSubtitles = () => setupSubtitlesCalls++; + + masterPlaylistController.handleSubtitleError_(); + + assert.equal(textTracks[0].mode, 'disabled', 'set text track to disabled'); + assert.equal(abortCalls, 1, 'aborted subtitle segment loader'); + assert.equal(setupSubtitlesCalls, 1, 'setup subtitles'); + assert.equal(this.env.log.warn.callCount, 1, 'logged a warning'); + + this.env.log.warn.callCount = 0; +}); + +QUnit.test('sets up subtitles', function(assert) { + this.requests.length = 0; + this.player = createPlayer(); + this.player.src({ + src: 'manifest/master-subtitles.m3u8', + type: 'application/vnd.apple.mpegurl' + }); + + // master, contains media groups for subtitles + this.standardXHRResponse(this.requests.shift()); + // media + this.standardXHRResponse(this.requests.shift()); + + const masterPlaylistController = this.player.tech_.hls.masterPlaylistController_; + + // sets up listener for text track changes + masterPlaylistController.trigger('sourceopen'); + + const segmentLoader = masterPlaylistController.subtitleSegmentLoader_; + + let segmentDisposeCalls = 0; + let segmentLoadCalls = 0; + let segmentPauseCalls = 0; + let segmentResetCalls = 0; + + segmentLoader.load = () => segmentLoadCalls++; + segmentLoader.dispose = () => segmentDisposeCalls++; + segmentLoader.pause = () => segmentPauseCalls++; + segmentLoader.resetEverything = () => segmentResetCalls++; + + assert.notOk(masterPlaylistController.subtitlePlaylistLoader_, + 'no subtitle playlist loader'); + + // no active text track + masterPlaylistController.setupSubtitles(); + + assert.equal(segmentDisposeCalls, 0, 'did not dispose subtitles segment loader'); + assert.equal(segmentLoadCalls, 0, 'did not load subtitles segment loader'); + assert.equal(segmentPauseCalls, 1, 'paused subtitles segment loader'); + assert.equal(segmentResetCalls, 0, 'did not reset subtitle segment loader'); + assert.notOk(masterPlaylistController.subtitlePlaylistLoader_, + 'no subtitle playlist loader'); + assert.ok(masterPlaylistController.subtitleSegmentLoader_, + 'did not remove subtitle segment loader'); + + const textTracks = this.player.textTracks(); + + // enable first text track + textTracks[0].mode = 'showing'; + + assert.ok(masterPlaylistController.subtitlePlaylistLoader_, + 'added a new subtitle playlist loader'); + assert.equal(segmentLoader, + masterPlaylistController.subtitleSegmentLoader_, + 'did not change subtitle segment loader'); + assert.equal(segmentLoadCalls, 0, 'did not load subtitles segment loader'); + assert.equal(segmentResetCalls, 1, 'reset subtitle segment loader'); + + let playlistLoader = masterPlaylistController.subtitlePlaylistLoader_; + let playlistLoadCalls = 0; + + playlistLoader.load = () => playlistLoadCalls++; + + // same active text track, haven't yet gotten a response from webvtt + masterPlaylistController.setupSubtitles(); + + assert.equal(this.requests.length, 2, 'total of two requests'); + + let oldRequest = this.requests.shift(); + + // tracking playlist loader dispose calls by checking request aborted status + assert.ok(oldRequest.aborted, 'aborted the old request'); + assert.notEqual(playlistLoader, + masterPlaylistController.subtitlePlaylistLoader_, + 'changed subtitle playlist loader'); + + let playlistDisposeCalls = 0; + + playlistLoader = masterPlaylistController.subtitlePlaylistLoader_; + playlistLoadCalls = 0; + + playlistLoader.load = () => playlistLoadCalls++; + playlistLoader.dispose = () => playlistDisposeCalls++; + + this.requests.shift().respond(200, null, ` + #EXTM3U + #EXT-X-TARGETDURATION:10 + #EXT-X-MEDIA-SEQUENCE:0 + #EXTINF:10 + 0.webvtt + #EXT-X-ENDLIST + `); + + segmentLoadCalls = 0; + + // same active text track, got a response from webvtt playlist + masterPlaylistController.setupSubtitles(); + + assert.equal(playlistLoader, + masterPlaylistController.subtitlePlaylistLoader_, + 'did not change subtitle playlist loader'); + assert.equal(segmentLoader, + masterPlaylistController.subtitleSegmentLoader_, + 'did not change subtitle segment loader'); + assert.equal(playlistDisposeCalls, 0, 'did not dispose subtitles playlist loader'); + assert.equal(playlistLoadCalls, 0, 'did not load subtitles playlist loader'); + assert.equal(segmentLoadCalls, 1, 'loaded subtitles segment loader'); + + playlistDisposeCalls = 0; + segmentDisposeCalls = 0; + playlistLoadCalls = 0; + segmentLoadCalls = 0; + segmentPauseCalls = 0; + segmentResetCalls = 0; + + // turn off active text track + textTracks[0].mode = 'disabled'; + + assert.equal(playlistDisposeCalls, 1, 'disposed subtitles playlist loader'); + assert.equal(segmentDisposeCalls, 0, 'did not dispose subtitles segment loader'); + assert.equal(playlistLoadCalls, 0, 'did not load subtitles playlist loader'); + assert.equal(segmentLoadCalls, 0, 'did not load subtitles segment loader'); + assert.equal(segmentPauseCalls, 1, 'paused subtitles segment loader'); + assert.equal(segmentResetCalls, 0, 'did not reset subtitle segment loader'); + assert.notOk(masterPlaylistController.subtitlePlaylistLoader_, + 'removed subtitle playlist loader'); + assert.ok(masterPlaylistController.subtitleSegmentLoader_, + 'did not remove subtitle segment loader'); +}); + QUnit.module('Codec to MIME Type Conversion'); QUnit.test('recognizes muxed codec configurations', function(assert) { diff --git a/test/segment-loader.test.js b/test/segment-loader.test.js index 258995f1b..9f44755a8 100644 --- a/test/segment-loader.test.js +++ b/test/segment-loader.test.js @@ -403,7 +403,7 @@ QUnit.test('only appends one segment at a time', function(assert) { assert.equal(loader.mediaRequests, 1, '1 request'); }); -QUnit.test('adjusts the playlist offset if no buffering progress is made', function(assert) { +QUnit.skip('adjusts the playlist offset if no buffering progress is made', function(assert) { let sourceBuffer; let playlist; diff --git a/test/test-helpers.js b/test/test-helpers.js index 92d9c01f3..03de3a769 100644 --- a/test/test-helpers.js +++ b/test/test-helpers.js @@ -327,19 +327,21 @@ export const playlistWithDuration = function(time, conf) { discontinuityStarts: [], segments: [], endList: conf && typeof conf.endList !== 'undefined' ? !!conf.endList : true, - uri: conf && typeof conf.uri !== 'undefined' ? conf.uri : 'playlist.m3u8' + uri: conf && typeof conf.uri !== 'undefined' ? conf.uri : 'playlist.m3u8', + discontinuitySequence: conf && conf.discontinuitySequence ? conf.discontinuitySequence : 0 }; let count = Math.floor(time / 10); let remainder = time % 10; let i; let isEncrypted = conf && conf.isEncrypted; + let extension = conf && conf.extension ? conf.extension : '.ts'; for (i = 0; i < count; i++) { result.segments.push({ - uri: i + '.ts', - resolvedUri: i + '.ts', + uri: i + extension, + resolvedUri: i + extension, duration: 10, - timeline: 0 + timeline: result.discontinuitySequence }); if (isEncrypted) { result.segments[i].key = { @@ -350,9 +352,9 @@ export const playlistWithDuration = function(time, conf) { } if (remainder) { result.segments.push({ - uri: i + '.ts', + uri: i + extension, duration: remainder, - timeline: 0 + timeline: result.discontinuitySequence }); } return result; diff --git a/test/vtt-segment-loader.test.js b/test/vtt-segment-loader.test.js index 258995f1b..873074e05 100644 --- a/test/vtt-segment-loader.test.js +++ b/test/vtt-segment-loader.test.js @@ -1,11 +1,11 @@ import QUnit from 'qunit'; -import SegmentLoader from '../src/segment-loader'; +import VTTSegmentLoader from '../src/vtt-segment-loader'; import videojs from 'video.js'; import xhrFactory from '../src/xhr'; import mp4probe from 'mux.js/lib/mp4/probe'; import Config from '../src/config'; import { - playlistWithDuration, + playlistWithDuration as oldPlaylistWithDuration, useFakeEnvironment, useFakeMediaSource } from './test-helpers.js'; @@ -21,31 +21,21 @@ class MockTextTrack { addCue(cue) { this.cues.push(cue); } - removeCue(cue) { - for (let i = 0; i < this.cues.length; i++) { - if (this.cues[i] === cue) { - this.cues.splice(i, 1); - break; - } - } - } } -// noop addSegmentMetadataCue_ since most test segments dont have real timing information -// save the original function to a variable to patch it back in for the metadata cue -// specific tests -const ogAddSegmentMetadataCue_ = SegmentLoader.prototype.addSegmentMetadataCue_; +const oldVTT = window.WebVTT; -SegmentLoader.prototype.addSegmentMetadataCue_ = function() {}; +const playlistWithDuration = function(time, conf) { + return oldPlaylistWithDuration(time, videojs.mergeOptions({ extension: '.vtt' }, conf)); +}; let currentTime; let mediaSource; let loader; let syncController; let decrypter; -let segmentMetadataTrack; -QUnit.module('Segment Loader', { +QUnit.module('VTT Segment Loader', { beforeEach(assert) { this.env = useFakeEnvironment(assert); this.clock = this.env.clock; @@ -55,10 +45,25 @@ QUnit.module('Segment Loader', { this.seekable = { length: 0 }; - this.mimeType = 'video/mp2t'; + this.track = new MockTextTrack(); this.fakeHls = { xhr: xhrFactory() }; + this.extension = '.vtt'; + this.parserCreated = false; + + window.WebVTT = () => {}; + window.WebVTT.StringDecoder = () => {}; + window.WebVTT.Parser = () => { + this.parserCreated = true; + return { + oncue() {}, + onparsingerror() {}, + onflush() {}, + parse() {}, + flush() {} + }; + }; this.timescale = sinon.stub(mp4probe, 'timescale'); this.startTime = sinon.stub(mp4probe, 'startTime'); @@ -66,9 +71,9 @@ QUnit.module('Segment Loader', { mediaSource = new videojs.MediaSource(); mediaSource.trigger('sourceopen'); this.syncController = new SyncController(); + this.syncController.timelines[0] = { time: 0, mapping: 0 }; decrypter = worker(Decrypter); - segmentMetadataTrack = new MockTextTrack(); - loader = new SegmentLoader({ + loader = new VTTSegmentLoader({ hls: this.fakeHls, currentTime: () => this.currentTime, seekable: () => this.seekable, @@ -77,8 +82,7 @@ QUnit.module('Segment Loader', { mediaSource, syncController: this.syncController, decrypter, - loaderType: 'main', - segmentMetadataTrack + loaderType: 'vtt' }); decrypter.onmessage = (event) => { loader.handleDecrypted_(event.data); @@ -90,26 +94,27 @@ QUnit.module('Segment Loader', { this.timescale.restore(); this.startTime.restore(); decrypter.terminate(); + window.WebVTT = oldVTT; } }); QUnit.test('fails without required initialization options', function(assert) { /* eslint-disable no-new */ assert.throws(function() { - new SegmentLoader(); + new VTTSegmentLoader(); }, 'requires options'); assert.throws(function() { - new SegmentLoader({}); + new VTTSegmentLoader({}); }, 'requires a currentTime callback'); assert.throws(function() { - new SegmentLoader({ + new VTTSegmentLoader({ currentTime() {} }); }, 'requires a media source'); /* eslint-enable */ }); -QUnit.test('load waits until a playlist and mime type are specified to proceed', +QUnit.test('load waits until a playlist and track are specified to proceed', function(assert) { loader.load(); @@ -118,20 +123,20 @@ function(assert) { loader.playlist(playlistWithDuration(10)); assert.equal(this.requests.length, 0, 'have not made a request yet'); - loader.mimeType(this.mimeType); + loader.track(this.track); this.clock.tick(1); assert.equal(this.requests.length, 1, 'made a request'); assert.equal(loader.state, 'WAITING', 'transitioned states'); }); -QUnit.test('calling mime type and load begins buffering', function(assert) { +QUnit.test('calling track and load begins buffering', function(assert) { assert.equal(loader.state, 'INIT', 'starts in the init state'); loader.playlist(playlistWithDuration(10)); assert.equal(loader.state, 'INIT', 'starts in the init state'); assert.ok(loader.paused(), 'starts paused'); - loader.mimeType(this.mimeType); + loader.track(this.track); assert.equal(loader.state, 'INIT', 'still in the init state'); loader.load(); this.clock.tick(1); @@ -143,7 +148,7 @@ QUnit.test('calling mime type and load begins buffering', function(assert) { QUnit.test('calling load is idempotent', function(assert) { loader.playlist(playlistWithDuration(20)); - loader.mimeType(this.mimeType); + loader.track(this.track); loader.load(); this.clock.tick(1); @@ -168,12 +173,10 @@ QUnit.test('calling load is idempotent', function(assert) { }); QUnit.test('calling load should unpause', function(assert) { - let sourceBuffer; - loader.playlist(playlistWithDuration(20)); loader.pause(); - loader.mimeType(this.mimeType); + loader.track(this.track); loader.load(); this.clock.tick(1); @@ -189,8 +192,6 @@ QUnit.test('calling load should unpause', function(assert) { assert.equal(loader.paused(), false, 'unpaused during processing'); loader.pause(); - sourceBuffer = mediaSource.sourceBuffers[0]; - sourceBuffer.trigger('updateend'); assert.equal(loader.state, 'READY', 'finished processing'); assert.ok(loader.paused(), 'stayed paused'); @@ -204,23 +205,22 @@ QUnit.test('calling load should unpause', function(assert) { }); QUnit.test('regularly checks the buffer while unpaused', function(assert) { - let sourceBuffer; + let buffered = videojs.createTimeRanges(); loader.playlist(playlistWithDuration(90)); - loader.mimeType(this.mimeType); + loader.track(this.track); loader.load(); this.clock.tick(1); - sourceBuffer = mediaSource.sourceBuffers[0]; + loader.buffered = () => buffered; // fill the buffer this.clock.tick(1); this.requests[0].response = new Uint8Array(10).buffer; this.requests.shift().respond(200, null, ''); - sourceBuffer.buffered = videojs.createTimeRanges([[ + buffered = videojs.createTimeRanges([[ 0, Config.GOAL_BUFFER_LENGTH ]]); - sourceBuffer.trigger('updateend'); assert.equal(this.requests.length, 0, 'no outstanding requests'); // play some video to drain the buffer @@ -235,19 +235,15 @@ QUnit.test('regularly checks the buffer while unpaused', function(assert) { }); QUnit.test('does not check the buffer while paused', function(assert) { - let sourceBuffer; - loader.playlist(playlistWithDuration(90)); - loader.mimeType(this.mimeType); + loader.track(this.track); loader.load(); this.clock.tick(1); - sourceBuffer = mediaSource.sourceBuffers[0]; loader.pause(); this.clock.tick(1); this.requests[0].response = new Uint8Array(10).buffer; this.requests.shift().respond(200, null, ''); - sourceBuffer.trigger('updateend'); this.clock.tick(10 * 1000); assert.equal(this.requests.length, 0, 'did not make a request'); @@ -260,7 +256,7 @@ QUnit.test('does not check the buffer while paused', function(assert) { QUnit.test('calculates bandwidth after downloading a segment', function(assert) { loader.playlist(playlistWithDuration(10)); - loader.mimeType(this.mimeType); + loader.track(this.track); loader.load(); this.clock.tick(1); @@ -281,7 +277,7 @@ QUnit.test('calculates bandwidth after downloading a segment', function(assert) QUnit.test('segment request timeouts reset bandwidth', function(assert) { loader.playlist(playlistWithDuration(10)); - loader.mimeType(this.mimeType); + loader.track(this.track); loader.load(); this.clock.tick(1); @@ -300,7 +296,7 @@ QUnit.test('progress on segment requests are redispatched', function(assert) { progressEvents++; }); loader.playlist(playlistWithDuration(10)); - loader.mimeType(this.mimeType); + loader.track(this.track); loader.load(); this.clock.tick(1); @@ -308,7 +304,7 @@ QUnit.test('progress on segment requests are redispatched', function(assert) { assert.equal(progressEvents, 1, 'triggered progress'); }); -QUnit.test('updates timestamps when segments do not start at zero', function(assert) { +QUnit.skip('updates timestamps when segments do not start at zero', function(assert) { let playlist = playlistWithDuration(10); playlist.segments.forEach((segment) => { @@ -341,14 +337,13 @@ QUnit.test('appending a segment when loader is in walk-forward mode triggers pro progresses++; }); loader.playlist(playlistWithDuration(20)); - loader.mimeType(this.mimeType); + loader.track(this.track); loader.load(); this.clock.tick(1); // some time passes and a response is received this.requests[0].response = new Uint8Array(10).buffer; this.requests.shift().respond(200, null, ''); - mediaSource.sourceBuffers[0].trigger('updateend'); assert.equal(progresses, 0, 'no progress fired'); @@ -359,7 +354,6 @@ QUnit.test('appending a segment when loader is in walk-forward mode triggers pro // some time passes and a response is received this.requests[0].response = new Uint8Array(10).buffer; this.requests.shift().respond(200, null, ''); - mediaSource.sourceBuffers[0].trigger('updateend'); assert.equal(progresses, 1, 'fired progress'); @@ -370,7 +364,7 @@ QUnit.test('appending a segment when loader is in walk-forward mode triggers pro QUnit.test('only requests one segment at a time', function(assert) { loader.playlist(playlistWithDuration(10)); - loader.mimeType(this.mimeType); + loader.track(this.track); loader.load(); this.clock.tick(1); @@ -380,8 +374,16 @@ QUnit.test('only requests one segment at a time', function(assert) { }); QUnit.test('only appends one segment at a time', function(assert) { + let updates = 0; + let handleupdateend = loader.handleUpdateEnd_.bind(loader); + + loader.handleUpdateEnd_ = () => { + updates++; + handleupdateend(); + }; + loader.playlist(playlistWithDuration(10)); - loader.mimeType(this.mimeType); + loader.track(this.track); loader.load(); this.clock.tick(1); @@ -393,8 +395,7 @@ QUnit.test('only appends one segment at a time', function(assert) { // a lot of time goes by without "updateend" this.clock.tick(20 * 1000); - assert.equal(mediaSource.sourceBuffers[0].updates_.filter( - update => update.append).length, 1, 'only one append'); + assert.equal(updates, 1, 'only one append'); assert.equal(this.requests.length, 0, 'only made one request'); // verify stats @@ -403,7 +404,7 @@ QUnit.test('only appends one segment at a time', function(assert) { assert.equal(loader.mediaRequests, 1, '1 request'); }); -QUnit.test('adjusts the playlist offset if no buffering progress is made', function(assert) { +QUnit.skip('adjusts the playlist offset if no buffering progress is made', function(assert) { let sourceBuffer; let playlist; @@ -531,111 +532,107 @@ QUnit.skip('adjusts the playlist offset if no buffering progress is made after ' QUnit.test('downloads init segments if specified', function(assert) { let playlist = playlistWithDuration(20); let map = { - resolvedUri: 'main.mp4', + resolvedUri: 'main.vtt', byterange: { length: 20, offset: 0 } }; + let buffered = videojs.createTimeRanges(); + + loader.buffered = () => buffered; + playlist.segments[0].map = map; playlist.segments[1].map = map; loader.playlist(playlist); - loader.mimeType(this.mimeType); + loader.track(this.track); loader.load(); this.clock.tick(1); - let sourceBuffer = mediaSource.sourceBuffers[0]; assert.equal(this.requests.length, 2, 'made requests'); // init segment response this.clock.tick(1); - assert.equal(this.requests[0].url, 'main.mp4', 'requested the init segment'); + assert.equal(this.requests[0].url, 'main.vtt', 'requested the init segment'); this.requests[0].response = new Uint8Array(20).buffer; this.requests.shift().respond(200, null, ''); // 0.ts response this.clock.tick(1); - assert.equal(this.requests[0].url, '0.ts', + assert.equal(this.requests[0].url, '0.vtt', 'requested the segment'); this.requests[0].response = new Uint8Array(20).buffer; this.requests.shift().respond(200, null, ''); - // append the init segment - sourceBuffer.buffered = videojs.createTimeRanges([]); - sourceBuffer.trigger('updateend'); // append the segment - sourceBuffer.buffered = videojs.createTimeRanges([[0, 10]]); - sourceBuffer.trigger('updateend'); + buffered = videojs.createTimeRanges([[0, 10]]); this.clock.tick(1); assert.equal(this.requests.length, 1, 'made a request'); - assert.equal(this.requests[0].url, '1.ts', + assert.equal(this.requests[0].url, '1.vtt', 'did not re-request the init segment'); }); QUnit.test('detects init segment changes and downloads it', function(assert) { let playlist = playlistWithDuration(20); + let buffered = videojs.createTimeRanges(); playlist.segments[0].map = { - resolvedUri: 'init0.mp4', + resolvedUri: 'init0.vtt', byterange: { length: 20, offset: 0 } }; playlist.segments[1].map = { - resolvedUri: 'init0.mp4', + resolvedUri: 'init0.vtt', byterange: { length: 20, offset: 20 } }; + + loader.buffered = () => buffered; + loader.playlist(playlist); - loader.mimeType(this.mimeType); + loader.track(this.track); loader.load(); this.clock.tick(1); - let sourceBuffer = mediaSource.sourceBuffers[0]; - assert.equal(this.requests.length, 2, 'made requests'); // init segment response this.clock.tick(1); - assert.equal(this.requests[0].url, 'init0.mp4', 'requested the init segment'); + assert.equal(this.requests[0].url, 'init0.vtt', 'requested the init segment'); assert.equal(this.requests[0].headers.Range, 'bytes=0-19', 'requested the init segment byte range'); this.requests[0].response = new Uint8Array(20).buffer; this.requests.shift().respond(200, null, ''); - // 0.ts response + // 0.vtt response this.clock.tick(1); - assert.equal(this.requests[0].url, '0.ts', + assert.equal(this.requests[0].url, '0.vtt', 'requested the segment'); this.requests[0].response = new Uint8Array(20).buffer; this.requests.shift().respond(200, null, ''); - // append the init segment - sourceBuffer.buffered = videojs.createTimeRanges([]); - sourceBuffer.trigger('updateend'); - // append the segment - sourceBuffer.buffered = videojs.createTimeRanges([[0, 10]]); - sourceBuffer.trigger('updateend'); + buffered = videojs.createTimeRanges([[0, 10]]); this.clock.tick(1); assert.equal(this.requests.length, 2, 'made requests'); - assert.equal(this.requests[0].url, 'init0.mp4', 'requested the init segment'); + assert.equal(this.requests[0].url, 'init0.vtt', 'requested the init segment'); assert.equal(this.requests[0].headers.Range, 'bytes=20-39', 'requested the init segment byte range'); - assert.equal(this.requests[1].url, '1.ts', + assert.equal(this.requests[1].url, '1.vtt', 'did not re-request the init segment'); }); -QUnit.test('triggers syncinfoupdate before attempting a resync', function(assert) { +QUnit.skip('triggers syncinfoupdate before attempting a resync', function(assert) { let syncInfoUpdates = 0; loader.playlist(playlistWithDuration(20)); - loader.mimeType(this.mimeType); + loader.track(this.track); loader.load(); this.clock.tick(1); @@ -666,7 +663,7 @@ QUnit.test('triggers syncinfoupdate before attempting a resync', function(assert QUnit.test('cancels outstanding requests on abort', function(assert) { loader.playlist(playlistWithDuration(20)); - loader.mimeType(this.mimeType); + loader.track(this.track); loader.load(); this.clock.tick(1); @@ -683,8 +680,10 @@ QUnit.test('cancels outstanding requests on abort', function(assert) { }); QUnit.test('abort does not cancel segment processing in progress', function(assert) { + loader.handleUpdateEnd_ = () => {}; + loader.playlist(playlistWithDuration(20)); - loader.mimeType(this.mimeType); + loader.track(this.track); loader.load(); this.clock.tick(1); @@ -706,7 +705,7 @@ QUnit.test('SegmentLoader.mediaIndex is adjusted when live playlist is updated', mediaSequence: 0, endList: false })); - loader.mimeType(this.mimeType); + loader.track(this.track); loader.load(); // Start at mediaIndex 2 which means that the next segment we request // should mediaIndex 3 @@ -714,19 +713,18 @@ QUnit.test('SegmentLoader.mediaIndex is adjusted when live playlist is updated', this.clock.tick(1); assert.equal(loader.mediaIndex, 2, 'SegmentLoader.mediaIndex starts at 2'); - assert.equal(this.requests[0].url, '3.ts', 'requesting the segment at mediaIndex 3'); + assert.equal(this.requests[0].url, '3.vtt', 'requesting the segment at mediaIndex 3'); this.requests[0].response = new Uint8Array(10).buffer; this.requests.shift().respond(200, null, ''); this.clock.tick(1); - mediaSource.sourceBuffers[0].trigger('updateend'); assert.equal(loader.mediaIndex, 3, 'mediaIndex ends at 3'); this.clock.tick(1); assert.equal(loader.mediaIndex, 3, 'SegmentLoader.mediaIndex starts at 3'); - assert.equal(this.requests[0].url, '4.ts', 'requesting the segment at mediaIndex 4'); + assert.equal(this.requests[0].url, '4.vtt', 'requesting the segment at mediaIndex 4'); // Update the playlist shifting the mediaSequence by 2 which will result // in a decrement of the mediaIndex by 2 to 1 @@ -740,20 +738,28 @@ QUnit.test('SegmentLoader.mediaIndex is adjusted when live playlist is updated', this.requests[0].response = new Uint8Array(10).buffer; this.requests.shift().respond(200, null, ''); this.clock.tick(1); - mediaSource.sourceBuffers[0].trigger('updateend'); assert.equal(loader.mediaIndex, 2, 'SegmentLoader.mediaIndex ends at 2'); }); QUnit.test('segmentInfo.mediaIndex is adjusted when live playlist is updated', function(assert) { + const handleUpdateEnd_ = loader.handleUpdateEnd_.bind(loader); + let expectedLoaderIndex = 3; + + loader.handleUpdateEnd_ = function() { + handleUpdateEnd_(); + + assert.equal(loader.mediaIndex, expectedLoaderIndex, 'SegmentLoader.mediaIndex ends at ' + expectedLoaderIndex); + loader.mediaIndex = null; + loader.fetchAtBuffer_ = false; + }; // Setting currentTime to 31 so that we start requesting at segment #3 this.currentTime = 31; loader.playlist(playlistWithDuration(50, { mediaSequence: 0, endList: false })); - loader.mimeType(this.mimeType); - loader.load(); + loader.track(this.track); // Start at mediaIndex null which means that the next segment we request // should be based on currentTime (mediaIndex 3) loader.mediaIndex = null; @@ -761,27 +767,23 @@ QUnit.test('segmentInfo.mediaIndex is adjusted when live playlist is updated', f segmentIndex: 0, time: 0 }; + loader.load(); this.clock.tick(1); let segmentInfo = loader.pendingSegment_; assert.equal(segmentInfo.mediaIndex, 3, 'segmentInfo.mediaIndex starts at 3'); - assert.equal(this.requests[0].url, '3.ts', 'requesting the segment at mediaIndex 3'); + assert.equal(this.requests[0].url, '3.vtt', 'requesting the segment at mediaIndex 3'); this.requests[0].response = new Uint8Array(10).buffer; this.requests.shift().respond(200, null, ''); this.clock.tick(1); - mediaSource.sourceBuffers[0].trigger('updateend'); - assert.equal(loader.mediaIndex, 3, 'SegmentLoader.mediaIndex ends at 3'); - - loader.mediaIndex = null; - loader.fetchAtBuffer_ = false; this.clock.tick(1); segmentInfo = loader.pendingSegment_; assert.equal(segmentInfo.mediaIndex, 3, 'segmentInfo.mediaIndex starts at 3'); - assert.equal(this.requests[0].url, '3.ts', 'requesting the segment at mediaIndex 3'); + assert.equal(this.requests[0].url, '3.vtt', 'requesting the segment at mediaIndex 3'); // Update the playlist shifting the mediaSequence by 2 which will result // in a decrement of the mediaIndex by 2 to 1 @@ -792,15 +794,13 @@ QUnit.test('segmentInfo.mediaIndex is adjusted when live playlist is updated', f assert.equal(segmentInfo.mediaIndex, 1, 'segmentInfo.mediaIndex is updated to 1'); + expectedLoaderIndex = 1; this.requests[0].response = new Uint8Array(10).buffer; this.requests.shift().respond(200, null, ''); this.clock.tick(1); - mediaSource.sourceBuffers[0].trigger('updateend'); - - assert.equal(loader.mediaIndex, 1, 'SegmentLoader.mediaIndex ends at 1'); }); -QUnit.test('sets the timestampOffset on timeline change', function(assert) { +QUnit.skip('sets the timestampOffset on timeline change', function(assert) { let playlist = playlistWithDuration(40); playlist.discontinuityStarts = [1]; @@ -830,127 +830,43 @@ QUnit.test('sets the timestampOffset on timeline change', function(assert) { QUnit.test('tracks segment end times as they are buffered', function(assert) { let playlist = playlistWithDuration(20); - loader.syncController_.probeTsSegment_ = function(segmentInfo) { - return { start: 0, end: 9.5 }; + loader.parseVTTCues_ = function(segmentInfo) { + segmentInfo.cues = [ + { + startTime: 3, + endTime: 5 + }, + { + startTime: 4, + endTime: 7 + } + ]; + segmentInfo.timestampmap = { MPEGTS: 0, LOCAL: 0 }; }; loader.playlist(playlist); - loader.mimeType(this.mimeType); + loader.track(this.track); loader.load(); this.clock.tick(1); this.requests[0].response = new Uint8Array(10).buffer; this.requests.shift().respond(200, null, ''); - mediaSource.sourceBuffers[0].trigger('updateend'); this.clock.tick(1); - assert.equal(playlist.segments[0].end, 9.5, 'updated duration'); + assert.equal(playlist.segments[0].start, -1.5, 'updated start time of segment'); + assert.equal(playlist.segments[0].end, 8.5, 'updated end time of segment'); // verify stats assert.equal(loader.mediaBytesTransferred, 10, '10 bytes'); assert.equal(loader.mediaRequests, 1, '1 request'); }); -QUnit.test('adds cues with segment information to the segment-metadata track as they are buffered', - function(assert) { - const track = loader.segmentMetadataTrack_; - let playlist = playlistWithDuration(40); - let probeResponse; - let expectedCue; - - loader.addSegmentMetadataCue_ = ogAddSegmentMetadataCue_; - loader.syncController_.probeTsSegment_ = function(segmentInfo) { - return probeResponse; - }; - - loader.playlist(playlist); - loader.mimeType(this.mimeType); - loader.load(); - this.clock.tick(1); - - assert.ok(!track.cues.length, 'segment-metadata track empty when no segments appended'); - - // Start appending some segments - probeResponse = { start: 0, end: 9.5 }; - this.requests[0].response = new Uint8Array(10).buffer; - this.requests.shift().respond(200, null, ''); - mediaSource.sourceBuffers[0].trigger('updateend'); - this.clock.tick(1); - expectedCue = { - uri: '0.ts', - timeline: 0, - playlist: 'playlist.m3u8', - start: 0, - end: 9.5 - }; - - assert.equal(track.cues.length, 1, 'one cue added for segment'); - assert.deepEqual(track.cues[0].value, expectedCue, - 'added correct segment info to cue'); - - probeResponse = { start: 9.56, end: 19.2 }; - this.requests[0].response = new Uint8Array(10).buffer; - this.requests.shift().respond(200, null, ''); - mediaSource.sourceBuffers[0].trigger('updateend'); - this.clock.tick(1); - expectedCue = { - uri: '1.ts', - timeline: 0, - playlist: 'playlist.m3u8', - start: 9.56, - end: 19.2 - }; - - assert.equal(track.cues.length, 2, 'one cue added for segment'); - assert.deepEqual(track.cues[1].value, expectedCue, - 'added correct segment info to cue'); - - probeResponse = { start: 19.24, end: 28.99 }; - this.requests[0].response = new Uint8Array(10).buffer; - this.requests.shift().respond(200, null, ''); - mediaSource.sourceBuffers[0].trigger('updateend'); - this.clock.tick(1); - expectedCue = { - uri: '2.ts', - timeline: 0, - playlist: 'playlist.m3u8', - start: 19.24, - end: 28.99 - }; - - assert.equal(track.cues.length, 3, 'one cue added for segment'); - assert.deepEqual(track.cues[2].value, expectedCue, - 'added correct segment info to cue'); - - // append overlapping segment, emmulating segment-loader fetching behavior on - // rendtion switch - probeResponse = { start: 19.21, end: 28.98 }; - this.requests[0].response = new Uint8Array(10).buffer; - this.requests.shift().respond(200, null, ''); - mediaSource.sourceBuffers[0].trigger('updateend'); - expectedCue = { - uri: '3.ts', - timeline: 0, - playlist: 'playlist.m3u8', - start: 19.21, - end: 28.98 - }; - - assert.equal(track.cues.length, 3, 'overlapped cue removed, new one added'); - assert.deepEqual(track.cues[2].value, expectedCue, - 'added correct segment info to cue'); - - // verify stats - assert.equal(loader.mediaBytesTransferred, 40, '40 bytes'); - assert.equal(loader.mediaRequests, 4, '4 requests'); - }); - QUnit.test('segment 404s should trigger an error', function(assert) { let errors = []; loader.playlist(playlistWithDuration(10)); - loader.mimeType(this.mimeType); + loader.track(this.track); loader.load(); this.clock.tick(1); @@ -970,7 +886,7 @@ QUnit.test('segment 5xx status codes trigger an error', function(assert) { let errors = []; loader.playlist(playlistWithDuration(10)); - loader.mimeType(this.mimeType); + loader.track(this.track); loader.load(); this.clock.tick(1); @@ -986,7 +902,7 @@ QUnit.test('segment 5xx status codes trigger an error', function(assert) { assert.equal(loader.state, 'READY', 'returned to the ready state'); }); -QUnit.test('fires ended at the end of a playlist', function(assert) { +QUnit.skip('fires ended at the end of a playlist', function(assert) { let endOfStreams = 0; loader.playlist(playlistWithDuration(10)); @@ -1016,7 +932,7 @@ QUnit.test('fires ended at the end of a playlist', function(assert) { assert.equal(loader.mediaRequests, 1, '1 request'); }); -QUnit.test('live playlists do not trigger ended', function(assert) { +QUnit.skip('live playlists do not trigger ended', function(assert) { let endOfStreams = 0; let playlist; @@ -1050,7 +966,7 @@ QUnit.test('live playlists do not trigger ended', function(assert) { QUnit.test('remains ready if there are no segments', function(assert) { loader.playlist(playlistWithDuration(0)); - loader.mimeType(this.mimeType); + loader.track(this.track); loader.load(); this.clock.tick(1); @@ -1059,18 +975,13 @@ QUnit.test('remains ready if there are no segments', function(assert) { QUnit.test('dispose cleans up outstanding work', function(assert) { loader.playlist(playlistWithDuration(20)); - loader.mimeType(this.mimeType); + loader.track(this.track); loader.load(); this.clock.tick(1); loader.dispose(); assert.ok(this.requests[0].aborted, 'aborted segment request'); assert.equal(this.requests.length, 1, 'did not open another request'); - mediaSource.sourceBuffers.forEach((sourceBuffer, i) => { - let lastOperation = sourceBuffer.updates_.slice(-1)[0]; - - assert.ok(lastOperation.abort, 'aborted source buffer ' + i); - }); }); // ---------- @@ -1083,7 +994,7 @@ QUnit.test('calling load with an encrypted segment requests key and segment', fu assert.equal(loader.state, 'INIT', 'starts in the init state'); assert.ok(loader.paused(), 'starts paused'); - loader.mimeType(this.mimeType); + loader.track(this.track); loader.load(); this.clock.tick(1); @@ -1091,12 +1002,12 @@ QUnit.test('calling load with an encrypted segment requests key and segment', fu assert.ok(!loader.paused(), 'loading is not paused'); assert.equal(this.requests.length, 2, 'requested a segment and key'); assert.equal(this.requests[0].url, '0-key.php', 'requested the first segment\'s key'); - assert.equal(this.requests[1].url, '0.ts', 'requested the first segment'); + assert.equal(this.requests[1].url, '0.vtt', 'requested the first segment'); }); QUnit.test('cancels outstanding key request on abort', function(assert) { loader.playlist(playlistWithDuration(20, {isEncrypted: true})); - loader.mimeType(this.mimeType); + loader.track(this.track); loader.load(); this.clock.tick(1); @@ -1116,7 +1027,7 @@ QUnit.test('cancels outstanding key request on abort', function(assert) { QUnit.test('dispose cleans up key requests for encrypted segments', function(assert) { loader.playlist(playlistWithDuration(20, {isEncrypted: true})); - loader.mimeType(this.mimeType); + loader.track(this.track); loader.load(); this.clock.tick(1); @@ -1131,7 +1042,7 @@ QUnit.test('key 404s should trigger an error', function(assert) { let errors = []; loader.playlist(playlistWithDuration(10, {isEncrypted: true})); - loader.mimeType(this.mimeType); + loader.track(this.track); loader.load(); this.clock.tick(1); @@ -1153,7 +1064,7 @@ QUnit.test('key 5xx status codes trigger an error', function(assert) { let errors = []; loader.playlist(playlistWithDuration(10, {isEncrypted: true})); - loader.mimeType(this.mimeType); + loader.track(this.track); loader.load(); this.clock.tick(1); @@ -1178,7 +1089,7 @@ QUnit.test('the key is saved to the segment in the correct format', function(ass let segmentInfo; loader.playlist(playlistWithDuration(10, {isEncrypted: true})); - loader.mimeType(this.mimeType); + loader.track(this.track); loader.load(); this.clock.tick(1); @@ -1214,7 +1125,7 @@ function(assert) { let segmentInfo; loader.playlist(playlistWithDuration(10, {isEncrypted: true, mediaSequence: 5})); - loader.mimeType(this.mimeType); + loader.track(this.track); loader.load(); this.clock.tick(1); @@ -1252,7 +1163,7 @@ QUnit.test('segment with key has decrypted bytes appended during processing', fu }; loader.playlist(playlistWithDuration(10, {isEncrypted: true})); - loader.mimeType(this.mimeType); + loader.track(this.track); loader.load(); this.clock.tick(1); @@ -1282,14 +1193,14 @@ QUnit.test('calling load with an encrypted segment waits for both key and segmen let segmentRequest; loader.playlist(playlistWithDuration(10, {isEncrypted: true})); - loader.mimeType(this.mimeType); + loader.track(this.track); loader.load(); this.clock.tick(1); assert.equal(loader.state, 'WAITING', 'moves to waiting state'); assert.equal(this.requests.length, 2, 'requested a segment and key'); assert.equal(this.requests[0].url, '0-key.php', 'requested the first segment\'s key'); - assert.equal(this.requests[1].url, '0.ts', 'requested the first segment'); + assert.equal(this.requests[1].url, '0.vtt', 'requested the first segment'); // respond to the segment first segmentRequest = this.requests.pop(); segmentRequest.response = new Uint8Array(10).buffer; @@ -1308,12 +1219,12 @@ QUnit.test('calling load with an encrypted segment waits for both key and segmen QUnit.test('key request timeouts reset bandwidth', function(assert) { loader.playlist(playlistWithDuration(10, {isEncrypted: true})); - loader.mimeType(this.mimeType); + loader.track(this.track); loader.load(); this.clock.tick(1); assert.equal(this.requests[0].url, '0-key.php', 'requested the first segment\'s key'); - assert.equal(this.requests[1].url, '0.ts', 'requested the first segment'); + assert.equal(this.requests[1].url, '0.vtt', 'requested the first segment'); // a lot of time passes so the request times out this.requests[0].timedout = true; this.clock.tick(100 * 1000); @@ -1329,7 +1240,7 @@ QUnit.test('checks the goal buffer configuration every loading opportunity', fun Config.GOAL_BUFFER_LENGTH = 1; loader.playlist(playlist); - loader.mimeType(this.mimeType); + loader.track(this.track); loader.load(); segmentInfo = loader.checkBuffer_(videojs.createTimeRanges([[0, 1]]), @@ -1345,16 +1256,19 @@ QUnit.test('checks the goal buffer configuration every loading opportunity', fun QUnit.test('does not skip over segment if live playlist update occurs while processing', function(assert) { let playlist = playlistWithDuration(40); + let buffered = videojs.createTimeRanges(); + + loader.buffered = () => buffered; playlist.endList = false; loader.playlist(playlist); - loader.mimeType(this.mimeType); + loader.track(this.track); loader.load(); this.clock.tick(1); - assert.equal(loader.pendingSegment_.uri, '0.ts', 'retrieving first segment'); - assert.equal(loader.pendingSegment_.segment.uri, '0.ts', 'correct segment reference'); + assert.equal(loader.pendingSegment_.uri, '0.vtt', 'retrieving first segment'); + assert.equal(loader.pendingSegment_.segment.uri, '0.vtt', 'correct segment reference'); assert.equal(loader.state, 'WAITING', 'waiting for response'); this.requests[0].response = new Uint8Array(10).buffer; @@ -1366,40 +1280,52 @@ function(assert) { playlistUpdated.mediaSequence++; loader.playlist(playlistUpdated); // finish append - mediaSource.sourceBuffers[0].buffered = videojs.createTimeRanges([[0, 10]]); - mediaSource.sourceBuffers[0].trigger('updateend'); + buffered = videojs.createTimeRanges([[0, 10]]); this.clock.tick(1); - assert.equal(loader.pendingSegment_.uri, '1.ts', 'retrieving second segment'); - assert.equal(loader.pendingSegment_.segment.uri, '1.ts', 'correct segment reference'); + assert.equal(loader.pendingSegment_.uri, '1.vtt', 'retrieving second segment'); + assert.equal(loader.pendingSegment_.segment.uri, '1.vtt', 'correct segment reference'); assert.equal(loader.state, 'WAITING', 'waiting for response'); }); QUnit.test('processing segment reachable even after playlist update removes it', function(assert) { + const handleUpdateEnd_ = loader.handleUpdateEnd_.bind(loader); + let expectedURI = '0.vtt'; let playlist = playlistWithDuration(40); + let buffered = videojs.createTimeRanges(); + + loader.handleUpdateEnd_ = () => { + assert.equal(loader.state, 'APPENDING', 'moved to appending state'); + assert.equal(loader.pendingSegment_.uri, expectedURI, 'correct pending segment'); + assert.equal(loader.pendingSegment_.segment.uri, expectedURI, 'correct segment reference'); + + handleUpdateEnd_(); + }; + + loader.buffered = () => buffered; playlist.endList = false; loader.playlist(playlist); - loader.mimeType(this.mimeType); + loader.track(this.track); loader.load(); this.clock.tick(1); assert.equal(loader.state, 'WAITING', 'in waiting state'); - assert.equal(loader.pendingSegment_.uri, '0.ts', 'first segment pending'); - assert.equal(loader.pendingSegment_.segment.uri, '0.ts', 'correct segment reference'); + assert.equal(loader.pendingSegment_.uri, '0.vtt', 'first segment pending'); + assert.equal(loader.pendingSegment_.segment.uri, '0.vtt', 'correct segment reference'); // wrap up the first request to set mediaIndex and start normal live streaming this.requests[0].response = new Uint8Array(10).buffer; this.requests.shift().respond(200, null, ''); - mediaSource.sourceBuffers[0].buffered = videojs.createTimeRanges([[0, 10]]); - mediaSource.sourceBuffers[0].trigger('updateend'); + buffered = videojs.createTimeRanges([[0, 10]]); + expectedURI = '1.vtt'; this.clock.tick(1); assert.equal(loader.state, 'WAITING', 'in waiting state'); - assert.equal(loader.pendingSegment_.uri, '1.ts', 'second segment pending'); - assert.equal(loader.pendingSegment_.segment.uri, '1.ts', 'correct segment reference'); + assert.equal(loader.pendingSegment_.uri, '1.vtt', 'second segment pending'); + assert.equal(loader.pendingSegment_.segment.uri, '1.vtt', 'correct segment reference'); // playlist updated during waiting let playlistUpdated = playlistWithDuration(40); @@ -1409,45 +1335,40 @@ function(assert) { playlistUpdated.mediaSequence += 2; loader.playlist(playlistUpdated); - assert.equal(loader.pendingSegment_.uri, '1.ts', 'second segment still pending'); - assert.equal(loader.pendingSegment_.segment.uri, '1.ts', 'correct segment reference'); + assert.equal(loader.pendingSegment_.uri, '1.vtt', 'second segment still pending'); + assert.equal(loader.pendingSegment_.segment.uri, '1.vtt', 'correct segment reference'); this.requests[0].response = new Uint8Array(10).buffer; this.requests.shift().respond(200, null, ''); - - // we need to check for the right state, as normally handleResponse would throw an - // error under failing cases, but sinon swallows it as part of fake XML HTTP request's - // response - assert.equal(loader.state, 'APPENDING', 'moved to appending state'); - assert.equal(loader.pendingSegment_.uri, '1.ts', 'still using second segment'); - assert.equal(loader.pendingSegment_.segment.uri, '1.ts', 'correct segment reference'); }); QUnit.test('saves segment info to new segment after playlist refresh', function(assert) { let playlist = playlistWithDuration(40); + let buffered = videojs.createTimeRanges(); + + loader.buffered = () => buffered; playlist.endList = false; loader.playlist(playlist); - loader.mimeType(this.mimeType); + loader.track(this.track); loader.load(); this.clock.tick(1); assert.equal(loader.state, 'WAITING', 'in waiting state'); - assert.equal(loader.pendingSegment_.uri, '0.ts', 'first segment pending'); - assert.equal(loader.pendingSegment_.segment.uri, '0.ts', 'correct segment reference'); + assert.equal(loader.pendingSegment_.uri, '0.vtt', 'first segment pending'); + assert.equal(loader.pendingSegment_.segment.uri, '0.vtt', 'correct segment reference'); // wrap up the first request to set mediaIndex and start normal live streaming this.requests[0].response = new Uint8Array(10).buffer; this.requests.shift().respond(200, null, ''); - mediaSource.sourceBuffers[0].buffered = videojs.createTimeRanges([[0, 10]]); - mediaSource.sourceBuffers[0].trigger('updateend'); + buffered = videojs.createTimeRanges([[0, 10]]); this.clock.tick(1); assert.equal(loader.state, 'WAITING', 'in waiting state'); - assert.equal(loader.pendingSegment_.uri, '1.ts', 'second segment pending'); - assert.equal(loader.pendingSegment_.segment.uri, '1.ts', 'correct segment reference'); + assert.equal(loader.pendingSegment_.uri, '1.vtt', 'second segment pending'); + assert.equal(loader.pendingSegment_.segment.uri, '1.vtt', 'correct segment reference'); // playlist updated during waiting let playlistUpdated = playlistWithDuration(40); @@ -1456,14 +1377,14 @@ function(assert) { playlistUpdated.mediaSequence++; loader.playlist(playlistUpdated); - assert.equal(loader.pendingSegment_.uri, '1.ts', 'second segment still pending'); - assert.equal(loader.pendingSegment_.segment.uri, '1.ts', 'correct segment reference'); + assert.equal(loader.pendingSegment_.uri, '1.vtt', 'second segment still pending'); + assert.equal(loader.pendingSegment_.segment.uri, '1.vtt', 'correct segment reference'); // mock probeSegmentInfo as the response bytes aren't parsable (and won't provide // time info) - loader.syncController_.probeSegmentInfo = (segmentInfo) => { - segmentInfo.segment.start = 10; - segmentInfo.segment.end = 20; + loader.parseVTTCues_ = (segmentInfo) => { + segmentInfo.cues = [{ startTime: 10, endTime: 11 }, { startTime: 20, endTime: 21 }]; + segmentInfo.timestampmap = { MPEGTS: 0, LOCAL: 0 }; }; this.requests[0].response = new Uint8Array(10).buffer; @@ -1482,28 +1403,30 @@ function(assert) { QUnit.test('saves segment info to old segment after playlist refresh if segment fell off', function(assert) { let playlist = playlistWithDuration(40); + let buffered = videojs.createTimeRanges(); + + loader.buffered = () => buffered; playlist.endList = false; loader.playlist(playlist); - loader.mimeType(this.mimeType); + loader.track(this.track); loader.load(); this.clock.tick(1); assert.equal(loader.state, 'WAITING', 'in waiting state'); - assert.equal(loader.pendingSegment_.uri, '0.ts', 'first segment pending'); - assert.equal(loader.pendingSegment_.segment.uri, '0.ts', 'correct segment reference'); + assert.equal(loader.pendingSegment_.uri, '0.vtt', 'first segment pending'); + assert.equal(loader.pendingSegment_.segment.uri, '0.vtt', 'correct segment reference'); // wrap up the first request to set mediaIndex and start normal live streaming this.requests[0].response = new Uint8Array(10).buffer; this.requests.shift().respond(200, null, ''); - mediaSource.sourceBuffers[0].buffered = videojs.createTimeRanges([[0, 10]]); - mediaSource.sourceBuffers[0].trigger('updateend'); + buffered = videojs.createTimeRanges([[0, 10]]); this.clock.tick(1); assert.equal(loader.state, 'WAITING', 'in waiting state'); - assert.equal(loader.pendingSegment_.uri, '1.ts', 'second segment pending'); - assert.equal(loader.pendingSegment_.segment.uri, '1.ts', 'correct segment reference'); + assert.equal(loader.pendingSegment_.uri, '1.vtt', 'second segment pending'); + assert.equal(loader.pendingSegment_.segment.uri, '1.vtt', 'correct segment reference'); // playlist updated during waiting let playlistUpdated = playlistWithDuration(40); @@ -1513,14 +1436,14 @@ function(assert) { playlistUpdated.mediaSequence += 2; loader.playlist(playlistUpdated); - assert.equal(loader.pendingSegment_.uri, '1.ts', 'second segment still pending'); - assert.equal(loader.pendingSegment_.segment.uri, '1.ts', 'correct segment reference'); + assert.equal(loader.pendingSegment_.uri, '1.vtt', 'second segment still pending'); + assert.equal(loader.pendingSegment_.segment.uri, '1.vtt', 'correct segment reference'); // mock probeSegmentInfo as the response bytes aren't parsable (and won't provide // time info) - loader.syncController_.probeSegmentInfo = (segmentInfo) => { - segmentInfo.segment.start = 10; - segmentInfo.segment.end = 20; + loader.parseVTTCues_ = (segmentInfo) => { + segmentInfo.cues = [{ startTime: 10, endTime: 11 }, { startTime: 20, endTime: 21 }]; + segmentInfo.timestampmap = { MPEGTS: 0, LOCAL: 0 }; }; this.requests[0].response = new Uint8Array(10).buffer; @@ -1545,7 +1468,7 @@ QUnit.test('new playlist always triggers syncinfoupdate', function(assert) { loader.on('syncinfoupdate', () => syncInfoUpdates++); loader.playlist(playlist); - loader.mimeType('video/mp4'); + loader.track(this.track); loader.load(); assert.equal(syncInfoUpdates, 1, 'first playlist triggers an update'); @@ -1562,7 +1485,268 @@ QUnit.test('new playlist always triggers syncinfoupdate', function(assert) { 'new playlist after expiring segment triggers two updates'); }); -QUnit.module('Segment Loading Calculation', { +QUnit.test('waits for syncController to have sync info for the timeline of the vtt' + + 'segment being requested before loading', function(assert) { + let playlist = playlistWithDuration(40); + let loadedSegment = false; + + loader.loadSegment_ = () => { + loader.state = 'WAITING'; + loadedSegment = true; + }; + loader.checkBuffer_ = () => { + return { mediaIndex: 2, timeline: 2 }; + }; + + loader.playlist(playlist); + loader.track(this.track); + loader.load(); + + assert.equal(loader.state, 'READY', 'loader is ready at start'); + assert.ok(!loadedSegment, 'no segment requests made yet'); + + this.clock.tick(1); + + assert.equal(loader.state, 'WAITING_ON_TIMELINE', 'loader waiting for timeline info'); + assert.ok(!loadedSegment, 'no segment requests made yet'); + + // simulate the main segment loader finding timeline info for the new timeline + loader.syncController_.timelines[2] = { time: 20, mapping: -10 }; + loader.syncController_.trigger('timestampoffset'); + + assert.equal(loader.state, 'READY', 'ready after sync controller reports timeline info'); + assert.ok(!loadedSegment, 'no segment requests made yet'); + + this.clock.tick(1); + + assert.equal(loader.state, 'WAITING', 'loader waiting on segment request'); + assert.ok(loadedSegment, 'made call to load segment on new timeline'); +}); + +QUnit.test('waits for vtt.js to be loaded before attempting to parse cues', function(assert) { + const vttjs = window.WebVTT; + let playlist = playlistWithDuration(40); + let parsedCues = false; + + delete window.WebVTT; + + loader.handleUpdateEnd_ = () => { + parsedCues = true; + loader.state = 'READY'; + }; + + let vttjsCallback = () => {}; + + this.track.tech_ = { + on(event, callback) { + if (event === 'vttjsloaded') { + vttjsCallback = callback; + } + }, + trigger(event) { + if (event === 'vttjsloaded') { + vttjsCallback(); + } + }, + off() {} + }; + + loader.playlist(playlist); + loader.track(this.track); + loader.load(); + + assert.equal(loader.state, 'READY', 'loader is ready at start'); + assert.ok(!parsedCues, 'no cues parsed yet'); + + this.clock.tick(1); + + assert.equal(loader.state, 'WAITING', 'loader is waiting on segment request'); + assert.ok(!parsedCues, 'no cues parsed yet'); + + this.requests[0].response = new Uint8Array(10).buffer; + this.requests.shift().respond(200, null, ''); + + this.clock.tick(1); + + assert.equal(loader.state, 'WAITING_ON_VTTJS', 'loader is waiting for vttjs to be loaded'); + assert.ok(!parsedCues, 'no cues parsed yet'); + + window.WebVTT = vttjs; + + loader.subtitlesTrack_.tech_.trigger('vttjsloaded'); + + assert.equal(loader.state, 'READY', 'loader is ready to load next segment'); + assert.ok(parsedCues, 'parsed cues'); +}); + +QUnit.test('uses timestampmap from vtt header to set cue and segment timing', function(assert) { + const cues = [ + { startTime: 10, endTime: 12 }, + { startTime: 14, endTime: 16 }, + { startTime: 15, endTime: 19 } + ]; + const expectedCueTimes = [ + { startTime: 14, endTime: 16 }, + { startTime: 18, endTime: 20 }, + { startTime: 19, endTime: 23 } + ]; + const expectedSegment = { + duration: 10, + start: 11.5, + end: 21.5 + }; + const expectedPlaylist = { + mediaSequence: 100, + syncInfo: { mediaSequence: 102, time: 11.5 } + }; + const mappingObj = { + time: 0, + mapping: -10 + }; + const playlist = { mediaSequence: 100 }; + const segment = { duration: 10 }; + const segmentInfo = { + timestampmap: { MPEGTS: 1260000, LOCAL: 0 }, + mediaIndex: 2, + cues, + segment + }; + + loader.updateTimeMapping_(segmentInfo, mappingObj, playlist); + + assert.deepEqual(cues, expectedCueTimes, 'adjusted cue timing based on timestampmap'); + assert.deepEqual(segment, expectedSegment, 'set segment start and end based on cue content'); + assert.deepEqual(playlist, expectedPlaylist, 'set syncInfo for playlist based on learned segment start'); +}); + +QUnit.test('loader logs vtt.js ParsingErrors and does not trigger an error event', function(assert) { + let playlist = playlistWithDuration(40); + + window.WebVTT.Parser = () => { + this.parserCreated = true; + return { + oncue() {}, + onparsingerror() {}, + onflush() {}, + parse() { + // MOCK parsing the cues below + this.onparsingerror({ message: 'BAD CUE'}); + this.oncue({ startTime: 5, endTime: 6}); + this.onparsingerror({ message: 'BAD --> CUE' }); + }, + flush() {} + }; + }; + + loader.playlist(playlist); + loader.track(this.track); + loader.load(); + + this.clock.tick(1); + + const vttString = ` + WEBVTT + + 00:00:03.000 -> 00:00:05.000 + BAD CUE + + 00:00:05.000 --> 00:00:06.000 + GOOD CUE + + 00:00:07.000 --> 00:00:10.000 + BAD --> CUE + `; + + // state WAITING for segment response + this.requests[0].response = new Uint8Array(vttString.split('').map(char => char.charCodeAt(0))); + this.requests.shift().respond(200, null, ''); + + this.clock.tick(1); + + assert.equal(this.track.cues.length, 1, 'only appended the one good cue'); + assert.equal(this.env.log.warn.callCount, 2, 'logged two warnings, one for each invalid cue'); + this.env.log.warn.callCount = 0; +}); + +QUnit.test('loader triggers error event on fatal vtt.js errors', function(assert) { + let playlist = playlistWithDuration(40); + let errors = 0; + + loader.parseVTTCues_ = () => { + throw new Error('fatal error'); + }; + loader.on('error', () => errors++); + + loader.playlist(playlist); + loader.track(this.track); + loader.load(); + + assert.equal(errors, 0, 'no error at loader start'); + + this.clock.tick(1); + + // state WAITING for segment response + this.requests[0].response = new Uint8Array(10).buffer; + this.requests.shift().respond(200, null, ''); + + this.clock.tick(1); + + assert.equal(errors, 1, 'triggered error when parser emmitts fatal error'); + assert.ok(loader.paused(), 'loader paused when encountering fatal error'); + assert.equal(loader.state, 'READY', 'loader reset after error'); +}); + +QUnit.test('loader triggers error event when vtt.js fails to load', function(assert) { + let playlist = playlistWithDuration(40); + let errors = 0; + + delete window.WebVTT; + let vttjsCallback = () => {}; + + this.track.tech_ = { + on(event, callback) { + if (event === 'vttjserror') { + vttjsCallback = callback; + } + }, + trigger(event) { + if (event === 'vttjserror') { + vttjsCallback(); + } + }, + off() {} + }; + + loader.on('error', () => errors++); + + loader.playlist(playlist); + loader.track(this.track); + loader.load(); + + assert.equal(loader.state, 'READY', 'loader is ready at start'); + assert.equal(errors, 0, 'no errors yet'); + + this.clock.tick(1); + + assert.equal(loader.state, 'WAITING', 'loader is waiting on segment request'); + assert.equal(errors, 0, 'no errors yet'); + + this.requests[0].response = new Uint8Array(10).buffer; + this.requests.shift().respond(200, null, ''); + + this.clock.tick(1); + + assert.equal(loader.state, 'WAITING_ON_VTTJS', 'loader is waiting for vttjs to be loaded'); + assert.equal(errors, 0, 'no errors yet'); + + loader.subtitlesTrack_.tech_.trigger('vttjserror'); + + assert.equal(loader.state, 'READY', 'loader is reset to ready'); + assert.ok(loader.paused(), 'loader is paused after error'); + assert.equal(errors, 1, 'loader triggered error when vtt.js load triggers error'); +}); + +QUnit.module('VTT Segment Loading Calculation', { beforeEach(assert) { this.env = useFakeEnvironment(assert); this.mse = useFakeMediaSource(); @@ -1571,7 +1755,7 @@ QUnit.module('Segment Loading Calculation', { this.currentTime = 0; syncController = new SyncController(); - loader = new SegmentLoader({ + loader = new VTTSegmentLoader({ currentTime() { return currentTime; }, @@ -1587,7 +1771,7 @@ QUnit.module('Segment Loading Calculation', { }); QUnit.test('requests the first segment with an empty buffer', function(assert) { - loader.mimeType(this.mimeType); + loader.track(this.track); let segmentInfo = loader.checkBuffer_(videojs.createTimeRanges(), playlistWithDuration(20), @@ -1597,12 +1781,12 @@ QUnit.test('requests the first segment with an empty buffer', function(assert) { null); assert.ok(segmentInfo, 'generated a request'); - assert.equal(segmentInfo.uri, '0.ts', 'requested the first segment'); + assert.equal(segmentInfo.uri, '0.vtt', 'requested the first segment'); }); QUnit.test('no request if video not played and 1 segment is buffered', function(assert) { this.hasPlayed = false; - loader.mimeType(this.mimeType); + loader.track(this.track); let segmentInfo = loader.checkBuffer_(videojs.createTimeRanges([[0, 1]]), playlistWithDuration(20), @@ -1619,7 +1803,7 @@ QUnit.test('does not download the next segment if the buffer is full', function( let buffered; let segmentInfo; - loader.mimeType(this.mimeType); + loader.track(this.track); buffered = videojs.createTimeRanges([ [0, 15 + Config.GOAL_BUFFER_LENGTH] @@ -1639,7 +1823,7 @@ QUnit.test('downloads the next segment if the buffer is getting low', function(a let segmentInfo; let playlist = playlistWithDuration(30); - loader.mimeType(this.mimeType); + loader.track(this.track); loader.playlist(playlist); buffered = videojs.createTimeRanges([[0, 19.999]]); @@ -1651,7 +1835,7 @@ QUnit.test('downloads the next segment if the buffer is getting low', function(a { segmentIndex: 0, time: 0 }); assert.ok(segmentInfo, 'made a request'); - assert.equal(segmentInfo.uri, '2.ts', 'requested the third segment'); + assert.equal(segmentInfo.uri, '2.vtt', 'requested the third segment'); }); QUnit.skip('buffers based on the correct TimeRange if multiple ranges exist', function(assert) { @@ -1685,7 +1869,7 @@ QUnit.test('stops downloading segments at the end of the playlist', function(ass let buffered; let segmentInfo; - loader.mimeType(this.mimeType); + loader.track(this.track); buffered = videojs.createTimeRanges([[0, 60]]); segmentInfo = loader.checkBuffer_(buffered, @@ -1704,7 +1888,7 @@ function(assert) { let segmentInfo; let playlist; - loader.mimeType(this.mimeType); + loader.track(this.track); buffered = videojs.createTimeRanges([[0, 59.9]]); playlist = playlistWithDuration(60); @@ -1747,7 +1931,7 @@ QUnit.skip('adjusts calculations based on expired time', function(assert) { QUnit.test('doesn\'t allow more than one monitor buffer timer to be set', function(assert) { let timeoutCount = this.clock.methods.length; - loader.mimeType(this.mimeType); + loader.track(this.track); loader.monitorBuffer_(); assert.equal(this.clock.methods.length, timeoutCount, 'timeout count remains the same'); diff --git a/utils/manifest/master-subtitles.m3u8 b/utils/manifest/master-subtitles.m3u8 new file mode 100644 index 000000000..93a1a7b00 --- /dev/null +++ b/utils/manifest/master-subtitles.m3u8 @@ -0,0 +1,16 @@ +# A simple master playlist with multiple variant streams +#EXTM3U + +#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID="subs",NAME="English",DEFAULT=NO,AUTOSELECT=YES,FORCED=NO,LANGUAGE="en",URI="subtitles/en/index.m3u8" +#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID="subs",NAME="English (Forced)",DEFAULT=NO,AUTOSELECT=NO,FORCED=YES,LANGUAGE="en",URI="subtitles/en_forced/index.m3u8" +#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID="subs",NAME="Spanish",DEFAULT=NO,AUTOSELECT=YES,FORCED=NO,LANGUAGE="es",URI="subtitles/es/index.m3u8" +#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID="subs",NAME="Spanish (Forced)",DEFAULT=NO,AUTOSELECT=NO,FORCED=YES,LANGUAGE="es",URI="subtitles/es_forced/index.m3u8" + +#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=240000,RESOLUTION=396x224,SUBTITLES="subs" +media.m3u8 +#EXT-X-STREAM-INF:PROGRAM-ID=1, BANDWIDTH=40000 +media1.m3u8 +#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=440000,RESOLUTION=396x224,SUBTITLES="subs" +media2.m3u8 +#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=1928000,RESOLUTION=960x540,SUBTITLES="subs" +media3.m3u8 diff --git a/utils/stats/index.html b/utils/stats/index.html index edd208ddd..dbf103a75 100644 --- a/utils/stats/index.html +++ b/utils/stats/index.html @@ -7,7 +7,7 @@ - + @@ -56,7 +56,7 @@