@videojs/http-streaming
Version:
Play back HLS and DASH with Video.js, even where it's not natively supported
558 lines (470 loc) • 16.3 kB
JavaScript
/**
* @file vtt-segment-loader.js
*/
import SegmentLoader from './segment-loader';
import videojs from 'video.js';
import window from 'global/window';
import { removeCuesFromTrack, removeDuplicateCuesFromTrack } from './util/text-tracks';
import { initSegmentId } from './bin-utils';
import { uint8ToUtf8 } from './util/string';
import { REQUEST_ERRORS } from './media-segment-request';
import { ONE_SECOND_IN_TS } from 'mux.js/lib/utils/clock';
import {createTimeRanges} from './util/vjs-compat';
const VTT_LINE_TERMINATORS =
new Uint8Array('\n\n'.split('').map(char => char.charCodeAt(0)));
class NoVttJsError extends Error {
constructor() {
super('Trying to parse received VTT cues, but there is no WebVTT. Make sure vtt.js is loaded.');
}
}
/**
* An object that manages segment loading and appending.
*
* @class VTTSegmentLoader
* @param {Object} options required and optional options
* @extends videojs.EventTarget
*/
export default class VTTSegmentLoader extends SegmentLoader {
constructor(settings, options = {}) {
super(settings, options);
// SegmentLoader requires a MediaSource be specified or it will throw an error;
// however, VTTSegmentLoader has no need of a media source, so delete the reference
this.mediaSource_ = null;
this.subtitlesTrack_ = null;
this.featuresNativeTextTracks_ = settings.featuresNativeTextTracks;
this.loadVttJs = settings.loadVttJs;
// The VTT segment will have its own time mappings. Saving VTT segment timing info in
// the sync controller leads to improper behavior.
this.shouldSaveSegmentTimingInfo_ = false;
}
createTransmuxer_() {
// don't need to transmux any subtitles
return null;
}
/**
* Indicates which time ranges are buffered
*
* @return {TimeRange}
* TimeRange object representing the current buffered ranges
*/
buffered_() {
if (!this.subtitlesTrack_ || !this.subtitlesTrack_.cues || !this.subtitlesTrack_.cues.length) {
return createTimeRanges();
}
const cues = this.subtitlesTrack_.cues;
const start = cues[0].startTime;
const end = cues[cues.length - 1].startTime;
return createTimeRanges([[start, end]]);
}
/**
* Gets and sets init segment for the provided map
*
* @param {Object} map
* The map object representing the init segment to get or set
* @param {boolean=} set
* If true, the init segment for the provided map should be saved
* @return {Object}
* map object for desired init segment
*/
initSegmentForMap(map, set = false) {
if (!map) {
return null;
}
const id = initSegmentId(map);
let storedMap = this.initSegments_[id];
if (set && !storedMap && map.bytes) {
// 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 combinedByteLength = VTT_LINE_TERMINATORS.byteLength + map.bytes.byteLength;
const combinedSegment = new Uint8Array(combinedByteLength);
combinedSegment.set(map.bytes);
combinedSegment.set(VTT_LINE_TERMINATORS, map.bytes.byteLength);
this.initSegments_[id] = storedMap = {
resolvedUri: map.resolvedUri,
byterange: map.byterange,
bytes: combinedSegment
};
}
return storedMap || map;
}
/**
* Returns true if all configuration required for loading is present, otherwise false.
*
* @return {boolean} True if the all configuration is ready for loading
* @private
*/
couldBeginLoading_() {
return this.playlist_ &&
this.subtitlesTrack_ &&
!this.paused();
}
/**
* Once all the starting parameters have been specified, begin
* operation. This method should only be invoked from the INIT
* state.
*
* @private
*/
init_() {
this.state = 'READY';
this.resetEverything();
return this.monitorBuffer_();
}
/**
* Set a subtitle track on the segment loader to add subtitles to
*
* @param {TextTrack=} track
* The text track to add loaded subtitles to
* @return {TextTrack}
* Returns the subtitles track
*/
track(track) {
if (typeof track === 'undefined') {
return this.subtitlesTrack_;
}
this.subtitlesTrack_ = track;
// if we were unpaused but waiting for a sourceUpdater, start
// buffering now
if (this.state === 'INIT' && this.couldBeginLoading_()) {
this.init_();
}
return this.subtitlesTrack_;
}
/**
* Remove any data in the source buffer between start and end times
*
* @param {number} start - the start time of the region to remove from the buffer
* @param {number} end - the end time of the region to remove from the buffer
*/
remove(start, end) {
removeCuesFromTrack(start, end, this.subtitlesTrack_);
}
/**
* fill the buffer with segements unless the sourceBuffers are
* currently updating
*
* Note: this function should only ever be called by monitorBuffer_
* and never directly
*
* @private
*/
fillBuffer_() {
// see if we need to begin loading immediately
const segmentInfo = this.chooseNextRequest_();
if (!segmentInfo) {
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.
const checkTimestampOffset = () => {
this.state = 'READY';
if (!this.paused()) {
// if not paused, queue a buffer check as soon as possible
this.monitorBuffer_();
}
};
this.syncController_.one('timestampoffset', checkTimestampOffset);
this.state = 'WAITING_ON_TIMELINE';
return;
}
this.loadSegment_(segmentInfo);
}
// never set a timestamp offset for vtt segments.
timestampOffsetForSegment_() {
return null;
}
chooseNextRequest_() {
return this.skipEmptySegments_(super.chooseNextRequest_());
}
/**
* Prevents the segment loader from requesting segments we know contain no subtitles
* by walking forward until we find the next segment that we don't know whether it is
* empty or not.
*
* @param {Object} segmentInfo
* a segment info object that describes the current segment
* @return {Object}
* a segment info object that describes the current segment
*/
skipEmptySegments_(segmentInfo) {
while (segmentInfo && segmentInfo.segment.empty) {
// stop at the last possible segmentInfo
if (segmentInfo.mediaIndex + 1 >= segmentInfo.playlist.segments.length) {
segmentInfo = null;
break;
}
segmentInfo = this.generateSegmentInfo_({
playlist: segmentInfo.playlist,
mediaIndex: segmentInfo.mediaIndex + 1,
startOfSegment: segmentInfo.startOfSegment + segmentInfo.duration,
isSyncRequest: segmentInfo.isSyncRequest
});
}
return segmentInfo;
}
stopForError(error) {
this.error(error);
this.state = 'READY';
this.pause();
this.trigger('error');
}
/**
* append a decrypted segement to the SourceBuffer through a SourceUpdater
*
* @private
*/
segmentRequestFinished_(error, simpleSegment, result) {
if (!this.subtitlesTrack_) {
this.state = 'READY';
return;
}
this.saveTransferStats_(simpleSegment.stats);
// the request was aborted
if (!this.pendingSegment_) {
this.state = 'READY';
this.mediaRequestsAborted += 1;
return;
}
if (error) {
if (error.code === REQUEST_ERRORS.TIMEOUT) {
this.handleTimeout_();
}
if (error.code === REQUEST_ERRORS.ABORTED) {
this.mediaRequestsAborted += 1;
} else {
this.mediaRequestsErrored += 1;
}
this.stopForError(error);
return;
}
const segmentInfo = this.pendingSegment_;
// although the VTT segment loader bandwidth isn't really used, it's good to
// maintain functionality between segment loaders
this.saveBandwidthRelatedStats_(segmentInfo.duration, simpleSegment.stats);
// if this request included a segment key, save that data in the cache
if (simpleSegment.key) {
this.segmentKey(simpleSegment.key, true);
}
this.state = 'APPENDING';
// used for tests
this.trigger('appending');
const segment = segmentInfo.segment;
if (segment.map) {
segment.map.bytes = simpleSegment.map.bytes;
}
segmentInfo.bytes = simpleSegment.bytes;
// Make sure that vttjs has loaded, otherwise, load it and wait till it finished loading
if (typeof window.WebVTT !== 'function' && typeof this.loadVttJs === 'function') {
this.state = 'WAITING_ON_VTTJS';
// should be fine to call multiple times
// script will be loaded once but multiple listeners will be added to the queue, which is expected.
this.loadVttJs()
.then(
() => this.segmentRequestFinished_(error, simpleSegment, result),
() => this.stopForError({
message: 'Error loading vtt.js'
})
);
return;
}
segment.requested = true;
try {
this.parseVTTCues_(segmentInfo);
} catch (e) {
this.stopForError({
message: e.message,
metadata: {
errorType: videojs.Error.StreamingVttParserError,
error: e
}
});
return;
}
this.updateTimeMapping_(
segmentInfo,
this.syncController_.timelines[segmentInfo.timeline],
this.playlist_
);
if (segmentInfo.cues.length) {
segmentInfo.timingInfo = {
start: segmentInfo.cues[0].startTime,
end: segmentInfo.cues[segmentInfo.cues.length - 1].endTime
};
} else {
segmentInfo.timingInfo = {
start: segmentInfo.startOfSegment,
end: segmentInfo.startOfSegment + segmentInfo.duration
};
}
if (segmentInfo.isSyncRequest) {
this.trigger('syncinfoupdate');
this.pendingSegment_ = null;
this.state = 'READY';
return;
}
segmentInfo.byteLength = segmentInfo.bytes.byteLength;
this.mediaSecondsLoaded += segment.duration;
// Create VTTCue instances for each cue in the new segment and add them to
// the subtitle track
segmentInfo.cues.forEach((cue) => {
this.subtitlesTrack_.addCue(this.featuresNativeTextTracks_ ?
new window.VTTCue(cue.startTime, cue.endTime, cue.text) :
cue);
});
// Remove any duplicate cues from the subtitle track. The WebVTT spec allows
// cues to have identical time-intervals, but if the text is also identical
// we can safely assume it is a duplicate that can be removed (ex. when a cue
// "overlaps" VTT segments)
removeDuplicateCuesFromTrack(this.subtitlesTrack_);
this.handleAppendsDone_();
}
handleData_() {
// noop as we shouldn't be getting video/audio data captions
// that we do not support here.
}
updateTimingInfoEnd_() {
// noop
}
/**
* Uses the WebVTT parser to parse the segment response
*
* @throws NoVttJsError
*
* @param {Object} segmentInfo
* a segment info object that describes the current segment
* @private
*/
parseVTTCues_(segmentInfo) {
let decoder;
let decodeBytesToString = false;
if (typeof window.WebVTT !== 'function') {
// caller is responsible for exception handling.
throw new NoVttJsError();
}
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 = uint8ToUtf8(mapData);
}
parser.parse(mapData);
}
let segmentData = segmentInfo.bytes;
if (decodeBytesToString) {
segmentData = uint8ToUtf8(segmentData);
}
parser.parse(segmentData);
parser.flush();
}
/**
* Updates the start and end times of any cues parsed by the WebVTT parser using
* the information parsed from the X-TIMESTAMP-MAP header and a TS to media time mapping
* from the SyncController
*
* @param {Object} segmentInfo
* a segment info object that describes the current segment
* @param {Object} mappingObj
* object containing a mapping from TS to media time
* @param {Object} playlist
* the playlist object containing the segment
* @private
*/
updateTimeMapping_(segmentInfo, mappingObj, playlist) {
const segment = segmentInfo.segment;
if (!mappingObj) {
// 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 cue
// start/end times
return;
}
if (!segmentInfo.cues.length) {
// If there are no cues, we also do not have enough information to figure out
// segment timing. Mark that the segment contains no cues so we don't re-request
// an empty segment.
segment.empty = true;
return;
}
const { MPEGTS, LOCAL } = segmentInfo.timestampmap;
/**
* From the spec:
* The MPEGTS media timestamp MUST use a 90KHz timescale,
* even when non-WebVTT Media Segments use a different timescale.
*/
const mpegTsInSeconds = MPEGTS / ONE_SECOND_IN_TS;
const diff = mpegTsInSeconds - LOCAL + mappingObj.mapping;
segmentInfo.cues.forEach((cue) => {
const duration = cue.endTime - cue.startTime;
const startTime = this.handleRollover_(cue.startTime + diff, mappingObj.time);
cue.startTime = Math.max(startTime, 0);
cue.endTime = Math.max(startTime + duration, 0);
});
if (!playlist.syncInfo) {
const firstStart = segmentInfo.cues[0].startTime;
const lastStart = segmentInfo.cues[segmentInfo.cues.length - 1].startTime;
playlist.syncInfo = {
mediaSequence: playlist.mediaSequence + segmentInfo.mediaIndex,
time: Math.min(firstStart, lastStart - segment.duration)
};
}
}
/**
* MPEG-TS PES timestamps are limited to 2^33.
* Once they reach 2^33, they roll over to 0.
* mux.js handles PES timestamp rollover for the following scenarios:
* [forward rollover(right)] ->
* PES timestamps monotonically increase, and once they reach 2^33, they roll over to 0
* [backward rollover(left)] -->
* we seek back to position before rollover.
*
* According to the HLS SPEC:
* When synchronizing WebVTT with PES timestamps, clients SHOULD account
* for cases where the 33-bit PES timestamps have wrapped and the WebVTT
* cue times have not. When the PES timestamp wraps, the WebVTT Segment
* SHOULD have a X-TIMESTAMP-MAP header that maps the current WebVTT
* time to the new (low valued) PES timestamp.
*
* So we want to handle rollover here and align VTT Cue start/end time to the player's time.
*/
handleRollover_(value, reference) {
if (reference === null) {
return value;
}
let valueIn90khz = value * ONE_SECOND_IN_TS;
const referenceIn90khz = reference * ONE_SECOND_IN_TS;
let offset;
if (referenceIn90khz < valueIn90khz) {
// - 2^33
offset = -8589934592;
} else {
// + 2^33
offset = 8589934592;
}
// distance(value - reference) > 2^32
while (Math.abs(valueIn90khz - referenceIn90khz) > 4294967296) {
valueIn90khz += offset;
}
return valueIn90khz / ONE_SECOND_IN_TS;
}
}