@videojs/http-streaming
Version:
Play back HLS and DASH with Video.js, even where it's not natively supported
1,160 lines (1,050 loc) • 37.2 kB
JavaScript
import videojs from 'video.js';
import { createTransferableMessage } from './bin-utils';
import { stringToArrayBuffer } from './util/string-to-array-buffer';
import { transmux } from './segment-transmuxer';
import { segmentXhrHeaders } from './xhr';
import {workerCallback} from './util/worker-callback.js';
import {
detectContainerForBytes,
isLikelyFmp4MediaSegment
} from '@videojs/vhs-utils/es/containers';
import {merge} from './util/vjs-compat';
import { getStreamingNetworkErrorMetadata } from './error-codes.js';
import { segmentInfoPayload } from './segment-loader.js';
export const REQUEST_ERRORS = {
FAILURE: 2,
TIMEOUT: -101,
ABORTED: -102
};
/**
* Abort all requests
*
* @param {Object} activeXhrs - an object that tracks all XHR requests
*/
const abortAll = (activeXhrs) => {
activeXhrs.forEach((xhr) => {
xhr.abort();
});
};
/**
* Gather important bandwidth stats once a request has completed
*
* @param {Object} request - the XHR request from which to gather stats
*/
const getRequestStats = (request) => {
return {
bandwidth: request.bandwidth,
bytesReceived: request.bytesReceived || 0,
roundTripTime: request.roundTripTime || 0
};
};
/**
* If possible gather bandwidth stats as a request is in
* progress
*
* @param {Event} progressEvent - an event object from an XHR's progress event
*/
const getProgressStats = (progressEvent) => {
const request = progressEvent.target;
const roundTripTime = Date.now() - request.requestTime;
const stats = {
bandwidth: Infinity,
bytesReceived: 0,
roundTripTime: roundTripTime || 0
};
stats.bytesReceived = progressEvent.loaded;
// This can result in Infinity if stats.roundTripTime is 0 but that is ok
// because we should only use bandwidth stats on progress to determine when
// abort a request early due to insufficient bandwidth
stats.bandwidth = Math.floor((stats.bytesReceived / stats.roundTripTime) * 8 * 1000);
return stats;
};
/**
* Handle all error conditions in one place and return an object
* with all the information
*
* @param {Error|null} error - if non-null signals an error occured with the XHR
* @param {Object} request - the XHR request that possibly generated the error
*/
const handleErrors = (error, request) => {
const { requestType } = request;
const metadata = getStreamingNetworkErrorMetadata({ requestType, request, error });
if (request.timedout) {
return {
status: request.status,
message: 'HLS request timed-out at URL: ' + request.uri,
code: REQUEST_ERRORS.TIMEOUT,
xhr: request,
metadata
};
}
if (request.aborted) {
return {
status: request.status,
message: 'HLS request aborted at URL: ' + request.uri,
code: REQUEST_ERRORS.ABORTED,
xhr: request,
metadata
};
}
if (error) {
return {
status: request.status,
message: 'HLS request errored at URL: ' + request.uri,
code: REQUEST_ERRORS.FAILURE,
xhr: request,
metadata
};
}
if (request.responseType === 'arraybuffer' && request.response.byteLength === 0) {
return {
status: request.status,
message: 'Empty HLS response at URL: ' + request.uri,
code: REQUEST_ERRORS.FAILURE,
xhr: request,
metadata
};
}
return null;
};
/**
* Handle responses for key data and convert the key data to the correct format
* for the decryption step later
*
* @param {Object} segment - a simplified copy of the segmentInfo object
* from SegmentLoader
* @param {Array} objects - objects to add the key bytes to.
* @param {Function} finishProcessingFn - a callback to execute to continue processing
* this request
*/
const handleKeyResponse = (segment, objects, finishProcessingFn, triggerSegmentEventFn) => (error, request) => {
const response = request.response;
const errorObj = handleErrors(error, request);
if (errorObj) {
return finishProcessingFn(errorObj, segment);
}
if (response.byteLength !== 16) {
return finishProcessingFn({
status: request.status,
message: 'Invalid HLS key at URL: ' + request.uri,
code: REQUEST_ERRORS.FAILURE,
xhr: request
}, segment);
}
const view = new DataView(response);
const bytes = new Uint32Array([
view.getUint32(0),
view.getUint32(4),
view.getUint32(8),
view.getUint32(12)
]);
for (let i = 0; i < objects.length; i++) {
objects[i].bytes = bytes;
}
const keyInfo = { uri: request.uri };
triggerSegmentEventFn({ type: 'segmentkeyloadcomplete', segment, keyInfo });
return finishProcessingFn(null, segment);
};
const parseInitSegment = (segment, callback) => {
const type = detectContainerForBytes(segment.map.bytes);
// TODO: We should also handle ts init segments here, but we
// only know how to parse mp4 init segments at the moment
if (type !== 'mp4') {
const uri = segment.map.resolvedUri || segment.map.uri;
const mediaType = type || 'unknown';
return callback({
internal: true,
message: `Found unsupported ${mediaType} container for initialization segment at URL: ${uri}`,
code: REQUEST_ERRORS.FAILURE,
metadata: {
mediaType
}
});
}
workerCallback({
action: 'probeMp4Tracks',
data: segment.map.bytes,
transmuxer: segment.transmuxer,
callback: ({tracks, data}) => {
// transfer bytes back to us
segment.map.bytes = data;
tracks.forEach(function(track) {
segment.map.tracks = segment.map.tracks || {};
// only support one track of each type for now
if (segment.map.tracks[track.type]) {
return;
}
segment.map.tracks[track.type] = track;
if (typeof track.id === 'number' && track.timescale) {
segment.map.timescales = segment.map.timescales || {};
segment.map.timescales[track.id] = track.timescale;
}
});
return callback(null);
}
});
};
/**
* Handle init-segment responses
*
* @param {Object} segment - a simplified copy of the segmentInfo object
* from SegmentLoader
* @param {Function} finishProcessingFn - a callback to execute to continue processing
* this request
*/
const handleInitSegmentResponse =
({segment, finishProcessingFn, triggerSegmentEventFn}) => (error, request) => {
const errorObj = handleErrors(error, request);
if (errorObj) {
return finishProcessingFn(errorObj, segment);
}
const bytes = new Uint8Array(request.response);
triggerSegmentEventFn({ type: 'segmentloaded', segment });
// init segment is encypted, we will have to wait
// until the key request is done to decrypt.
if (segment.map.key) {
segment.map.encryptedBytes = bytes;
return finishProcessingFn(null, segment);
}
segment.map.bytes = bytes;
parseInitSegment(segment, function(parseError) {
if (parseError) {
parseError.xhr = request;
parseError.status = request.status;
return finishProcessingFn(parseError, segment);
}
finishProcessingFn(null, segment);
});
};
/**
* Response handler for segment-requests being sure to set the correct
* property depending on whether the segment is encryped or not
* Also records and keeps track of stats that are used for ABR purposes
*
* @param {Object} segment - a simplified copy of the segmentInfo object
* from SegmentLoader
* @param {Function} finishProcessingFn - a callback to execute to continue processing
* this request
*/
const handleSegmentResponse = ({
segment,
finishProcessingFn,
responseType,
triggerSegmentEventFn
}) => (error, request) => {
const errorObj = handleErrors(error, request);
if (errorObj) {
return finishProcessingFn(errorObj, segment);
}
triggerSegmentEventFn({ type: 'segmentloaded', segment });
const newBytes =
// although responseText "should" exist, this guard serves to prevent an error being
// thrown for two primary cases:
// 1. the mime type override stops working, or is not implemented for a specific
// browser
// 2. when using mock XHR libraries like sinon that do not allow the override behavior
(responseType === 'arraybuffer' || !request.responseText) ?
request.response :
stringToArrayBuffer(request.responseText.substring(segment.lastReachedChar || 0));
segment.stats = getRequestStats(request);
if (segment.key) {
segment.encryptedBytes = new Uint8Array(newBytes);
} else {
segment.bytes = new Uint8Array(newBytes);
}
return finishProcessingFn(null, segment);
};
const transmuxAndNotify = ({
segment,
bytes,
trackInfoFn,
timingInfoFn,
videoSegmentTimingInfoFn,
audioSegmentTimingInfoFn,
id3Fn,
captionsFn,
isEndOfTimeline,
endedTimelineFn,
dataFn,
doneFn,
onTransmuxerLog,
triggerSegmentEventFn
}) => {
const fmp4Tracks = segment.map && segment.map.tracks || {};
const isMuxed = Boolean(fmp4Tracks.audio && fmp4Tracks.video);
// Keep references to each function so we can null them out after we're done with them.
// One reason for this is that in the case of full segments, we want to trust start
// times from the probe, rather than the transmuxer.
let audioStartFn = timingInfoFn.bind(null, segment, 'audio', 'start');
const audioEndFn = timingInfoFn.bind(null, segment, 'audio', 'end');
let videoStartFn = timingInfoFn.bind(null, segment, 'video', 'start');
const videoEndFn = timingInfoFn.bind(null, segment, 'video', 'end');
const finish = () => transmux({
bytes,
transmuxer: segment.transmuxer,
audioAppendStart: segment.audioAppendStart,
gopsToAlignWith: segment.gopsToAlignWith,
remux: isMuxed,
onData: (result) => {
result.type = result.type === 'combined' ? 'video' : result.type;
dataFn(segment, result);
},
onTrackInfo: (trackInfo) => {
if (trackInfoFn) {
if (isMuxed) {
trackInfo.isMuxed = true;
}
trackInfoFn(segment, trackInfo);
}
},
onAudioTimingInfo: (audioTimingInfo) => {
// we only want the first start value we encounter
if (audioStartFn && typeof audioTimingInfo.start !== 'undefined') {
audioStartFn(audioTimingInfo.start);
audioStartFn = null;
}
// we want to continually update the end time
if (audioEndFn && typeof audioTimingInfo.end !== 'undefined') {
audioEndFn(audioTimingInfo.end);
}
},
onVideoTimingInfo: (videoTimingInfo) => {
// we only want the first start value we encounter
if (videoStartFn && typeof videoTimingInfo.start !== 'undefined') {
videoStartFn(videoTimingInfo.start);
videoStartFn = null;
}
// we want to continually update the end time
if (videoEndFn && typeof videoTimingInfo.end !== 'undefined') {
videoEndFn(videoTimingInfo.end);
}
},
onVideoSegmentTimingInfo: (videoSegmentTimingInfo) => {
const timingInfo = {
pts: {
start: videoSegmentTimingInfo.start.presentation,
end: videoSegmentTimingInfo.end.presentation
},
dts: {
start: videoSegmentTimingInfo.start.decode,
end: videoSegmentTimingInfo.end.decode
}
};
triggerSegmentEventFn({ type: 'segmenttransmuxingtiminginfoavailable', segment, timingInfo });
videoSegmentTimingInfoFn(videoSegmentTimingInfo);
},
onAudioSegmentTimingInfo: (audioSegmentTimingInfo) => {
const timingInfo = {
pts: {
start: audioSegmentTimingInfo.start.pts,
end: audioSegmentTimingInfo.end.pts
},
dts: {
start: audioSegmentTimingInfo.start.dts,
end: audioSegmentTimingInfo.end.dts
}
};
triggerSegmentEventFn({ type: 'segmenttransmuxingtiminginfoavailable', segment, timingInfo });
audioSegmentTimingInfoFn(audioSegmentTimingInfo);
},
onId3: (id3Frames, dispatchType) => {
id3Fn(segment, id3Frames, dispatchType);
},
onCaptions: (captions) => {
captionsFn(segment, [captions]);
},
isEndOfTimeline,
onEndedTimeline: () => {
endedTimelineFn();
},
onTransmuxerLog,
onDone: (result, error) => {
if (!doneFn) {
return;
}
result.type = result.type === 'combined' ? 'video' : result.type;
triggerSegmentEventFn({ type: 'segmenttransmuxingcomplete', segment });
doneFn(error, segment, result);
},
segment,
triggerSegmentEventFn
});
// In the transmuxer, we don't yet have the ability to extract a "proper" start time.
// Meaning cached frame data may corrupt our notion of where this segment
// really starts. To get around this, probe for the info needed.
workerCallback({
action: 'probeTs',
transmuxer: segment.transmuxer,
data: bytes,
baseStartTime: segment.baseStartTime,
callback: (data) => {
segment.bytes = bytes = data.data;
const probeResult = data.result;
if (probeResult) {
trackInfoFn(segment, {
hasAudio: probeResult.hasAudio,
hasVideo: probeResult.hasVideo,
isMuxed
});
trackInfoFn = null;
}
finish();
}
});
};
const handleSegmentBytes = ({
segment,
bytes,
trackInfoFn,
timingInfoFn,
videoSegmentTimingInfoFn,
audioSegmentTimingInfoFn,
id3Fn,
captionsFn,
isEndOfTimeline,
endedTimelineFn,
dataFn,
doneFn,
onTransmuxerLog,
triggerSegmentEventFn
}) => {
let bytesAsUint8Array = new Uint8Array(bytes);
// TODO:
// We should have a handler that fetches the number of bytes required
// to check if something is fmp4. This will allow us to save bandwidth
// because we can only exclude a playlist and abort requests
// by codec after trackinfo triggers.
if (isLikelyFmp4MediaSegment(bytesAsUint8Array)) {
segment.isFmp4 = true;
const {tracks} = segment.map;
const trackInfo = {
isFmp4: true,
hasVideo: !!tracks.video,
hasAudio: !!tracks.audio
};
// if we have a audio track, with a codec that is not set to
// encrypted audio
if (tracks.audio && tracks.audio.codec && tracks.audio.codec !== 'enca') {
trackInfo.audioCodec = tracks.audio.codec;
}
// if we have a video track, with a codec that is not set to
// encrypted video
if (tracks.video && tracks.video.codec && tracks.video.codec !== 'encv') {
trackInfo.videoCodec = tracks.video.codec;
}
if (tracks.video && tracks.audio) {
trackInfo.isMuxed = true;
}
// since we don't support appending fmp4 data on progress, we know we have the full
// segment here
trackInfoFn(segment, trackInfo);
// The probe doesn't provide the segment end time, so only callback with the start
// time. The end time can be roughly calculated by the receiver using the duration.
//
// Note that the start time returned by the probe reflects the baseMediaDecodeTime, as
// that is the true start of the segment (where the playback engine should begin
// decoding).
const finishLoading = (captions, id3Frames) => {
// if the track still has audio at this point it is only possible
// for it to be audio only. See `tracks.video && tracks.audio` if statement
// above.
// we make sure to use segment.bytes here as that
dataFn(segment, {
data: bytesAsUint8Array,
type: trackInfo.hasAudio && !trackInfo.isMuxed ? 'audio' : 'video'
});
if (id3Frames && id3Frames.length) {
id3Fn(segment, id3Frames);
}
if (captions && captions.length) {
captionsFn(segment, captions);
}
doneFn(null, segment, {});
};
workerCallback({
action: 'probeMp4StartTime',
timescales: segment.map.timescales,
data: bytesAsUint8Array,
transmuxer: segment.transmuxer,
callback: ({data, startTime}) => {
// transfer bytes back to us
bytes = data.buffer;
segment.bytes = bytesAsUint8Array = data;
if (trackInfo.hasAudio && !trackInfo.isMuxed) {
timingInfoFn(segment, 'audio', 'start', startTime);
}
if (trackInfo.hasVideo) {
timingInfoFn(segment, 'video', 'start', startTime);
}
workerCallback({
action: 'probeEmsgID3',
data: bytesAsUint8Array,
transmuxer: segment.transmuxer,
offset: startTime,
callback: ({emsgData, id3Frames}) => {
// transfer bytes back to us
bytes = emsgData.buffer;
segment.bytes = bytesAsUint8Array = emsgData;
// Run through the CaptionParser in case there are captions.
// Initialize CaptionParser if it hasn't been yet
if (!tracks.video || !emsgData.byteLength || !segment.transmuxer) {
finishLoading(undefined, id3Frames);
return;
}
workerCallback({
action: 'pushMp4Captions',
endAction: 'mp4Captions',
transmuxer: segment.transmuxer,
data: bytesAsUint8Array,
timescales: segment.map.timescales,
trackIds: [tracks.video.id],
callback: (message) => {
// transfer bytes back to us
bytes = message.data.buffer;
segment.bytes = bytesAsUint8Array = message.data;
message.logs.forEach(function(log) {
onTransmuxerLog(merge(log, {stream: 'mp4CaptionParser'}));
});
finishLoading(message.captions, id3Frames);
}
});
}
});
}
});
return;
}
// VTT or other segments that don't need processing
if (!segment.transmuxer) {
doneFn(null, segment, {});
return;
}
if (typeof segment.container === 'undefined') {
segment.container = detectContainerForBytes(bytesAsUint8Array);
}
if (segment.container !== 'ts' && segment.container !== 'aac') {
trackInfoFn(segment, {hasAudio: false, hasVideo: false});
doneFn(null, segment, {});
return;
}
// ts or aac
transmuxAndNotify({
segment,
bytes,
trackInfoFn,
timingInfoFn,
videoSegmentTimingInfoFn,
audioSegmentTimingInfoFn,
id3Fn,
captionsFn,
isEndOfTimeline,
endedTimelineFn,
dataFn,
doneFn,
onTransmuxerLog,
triggerSegmentEventFn
});
};
const decrypt = function({id, key, encryptedBytes, decryptionWorker, segment, doneFn}, callback) {
const decryptionHandler = (event) => {
if (event.data.source === id) {
decryptionWorker.removeEventListener('message', decryptionHandler);
const decrypted = event.data.decrypted;
callback(new Uint8Array(
decrypted.bytes,
decrypted.byteOffset,
decrypted.byteLength
));
}
};
decryptionWorker.onerror = () => {
const message = 'An error occurred in the decryption worker';
const segmentInfo = segmentInfoPayload({segment});
const decryptError = {
message,
metadata: {
error: new Error(message),
errorType: videojs.Error.StreamingFailedToDecryptSegment,
segmentInfo,
keyInfo: {
uri: segment.key.resolvedUri || segment.map.key.resolvedUri
}
}
};
doneFn(decryptError, segment);
};
decryptionWorker.addEventListener('message', decryptionHandler);
let keyBytes;
if (key.bytes.slice) {
keyBytes = key.bytes.slice();
} else {
keyBytes = new Uint32Array(Array.prototype.slice.call(key.bytes));
}
// incrementally decrypt the bytes
decryptionWorker.postMessage(createTransferableMessage({
source: id,
encrypted: encryptedBytes,
key: keyBytes,
iv: key.iv
}), [
encryptedBytes.buffer,
keyBytes.buffer
]);
};
/**
* Decrypt the segment via the decryption web worker
*
* @param {WebWorker} decryptionWorker - a WebWorker interface to AES-128 decryption
* routines
* @param {Object} segment - a simplified copy of the segmentInfo object
* from SegmentLoader
* @param {Function} trackInfoFn - a callback that receives track info
* @param {Function} timingInfoFn - a callback that receives timing info
* @param {Function} videoSegmentTimingInfoFn
* a callback that receives video timing info based on media times and
* any adjustments made by the transmuxer
* @param {Function} audioSegmentTimingInfoFn
* a callback that receives audio timing info based on media times and
* any adjustments made by the transmuxer
* @param {boolean} isEndOfTimeline
* true if this segment represents the last segment in a timeline
* @param {Function} endedTimelineFn
* a callback made when a timeline is ended, will only be called if
* isEndOfTimeline is true
* @param {Function} dataFn - a callback that is executed when segment bytes are available
* and ready to use
* @param {Function} doneFn - a callback that is executed after decryption has completed
*/
const decryptSegment = ({
decryptionWorker,
segment,
trackInfoFn,
timingInfoFn,
videoSegmentTimingInfoFn,
audioSegmentTimingInfoFn,
id3Fn,
captionsFn,
isEndOfTimeline,
endedTimelineFn,
dataFn,
doneFn,
onTransmuxerLog,
triggerSegmentEventFn
}) => {
triggerSegmentEventFn({ type: 'segmentdecryptionstart' });
decrypt({
id: segment.requestId,
key: segment.key,
encryptedBytes: segment.encryptedBytes,
decryptionWorker,
segment,
doneFn
}, (decryptedBytes) => {
segment.bytes = decryptedBytes;
triggerSegmentEventFn({ type: 'segmentdecryptioncomplete', segment });
handleSegmentBytes({
segment,
bytes: segment.bytes,
trackInfoFn,
timingInfoFn,
videoSegmentTimingInfoFn,
audioSegmentTimingInfoFn,
id3Fn,
captionsFn,
isEndOfTimeline,
endedTimelineFn,
dataFn,
doneFn,
onTransmuxerLog,
triggerSegmentEventFn
});
});
};
/**
* This function waits for all XHRs to finish (with either success or failure)
* before continueing processing via it's callback. The function gathers errors
* from each request into a single errors array so that the error status for
* each request can be examined later.
*
* @param {Object} activeXhrs - an object that tracks all XHR requests
* @param {WebWorker} decryptionWorker - a WebWorker interface to AES-128 decryption
* routines
* @param {Function} trackInfoFn - a callback that receives track info
* @param {Function} timingInfoFn - a callback that receives timing info
* @param {Function} videoSegmentTimingInfoFn
* a callback that receives video timing info based on media times and
* any adjustments made by the transmuxer
* @param {Function} audioSegmentTimingInfoFn
* a callback that receives audio timing info based on media times and
* any adjustments made by the transmuxer
* @param {Function} id3Fn - a callback that receives ID3 metadata
* @param {Function} captionsFn - a callback that receives captions
* @param {boolean} isEndOfTimeline
* true if this segment represents the last segment in a timeline
* @param {Function} endedTimelineFn
* a callback made when a timeline is ended, will only be called if
* isEndOfTimeline is true
* @param {Function} dataFn - a callback that is executed when segment bytes are available
* and ready to use
* @param {Function} doneFn - a callback that is executed after all resources have been
* downloaded and any decryption completed
*/
const waitForCompletion = ({
activeXhrs,
decryptionWorker,
trackInfoFn,
timingInfoFn,
videoSegmentTimingInfoFn,
audioSegmentTimingInfoFn,
id3Fn,
captionsFn,
isEndOfTimeline,
endedTimelineFn,
dataFn,
doneFn,
onTransmuxerLog,
triggerSegmentEventFn
}) => {
let count = 0;
let didError = false;
return (error, segment) => {
if (didError) {
return;
}
if (error) {
didError = true;
// If there are errors, we have to abort any outstanding requests
abortAll(activeXhrs);
// Even though the requests above are aborted, and in theory we could wait until we
// handle the aborted events from those requests, there are some cases where we may
// never get an aborted event. For instance, if the network connection is lost and
// there were two requests, the first may have triggered an error immediately, while
// the second request remains unsent. In that case, the aborted algorithm will not
// trigger an abort: see https://xhr.spec.whatwg.org/#the-abort()-method
//
// We also can't rely on the ready state of the XHR, since the request that
// triggered the connection error may also show as a ready state of 0 (unsent).
// Therefore, we have to finish this group of requests immediately after the first
// seen error.
return doneFn(error, segment);
}
count += 1;
if (count === activeXhrs.length) {
const segmentFinish = function() {
if (segment.encryptedBytes) {
return decryptSegment({
decryptionWorker,
segment,
trackInfoFn,
timingInfoFn,
videoSegmentTimingInfoFn,
audioSegmentTimingInfoFn,
id3Fn,
captionsFn,
isEndOfTimeline,
endedTimelineFn,
dataFn,
doneFn,
onTransmuxerLog,
triggerSegmentEventFn
});
}
// Otherwise, everything is ready just continue
handleSegmentBytes({
segment,
bytes: segment.bytes,
trackInfoFn,
timingInfoFn,
videoSegmentTimingInfoFn,
audioSegmentTimingInfoFn,
id3Fn,
captionsFn,
isEndOfTimeline,
endedTimelineFn,
dataFn,
doneFn,
onTransmuxerLog,
triggerSegmentEventFn
});
};
// Keep track of when *all* of the requests have completed
segment.endOfAllRequests = Date.now();
if (segment.map && segment.map.encryptedBytes && !segment.map.bytes) {
triggerSegmentEventFn({ type: 'segmentdecryptionstart', segment });
return decrypt({ decryptionWorker,
// add -init to the "id" to differentiate between segment
// and init segment decryption, just in case they happen
// at the same time at some point in the future.
id: segment.requestId + '-init',
encryptedBytes: segment.map.encryptedBytes,
key: segment.map.key,
segment,
doneFn }, (decryptedBytes) => {
segment.map.bytes = decryptedBytes;
triggerSegmentEventFn({ type: 'segmentdecryptioncomplete', segment });
parseInitSegment(segment, (parseError) => {
if (parseError) {
abortAll(activeXhrs);
return doneFn(parseError, segment);
}
segmentFinish();
});
});
}
segmentFinish();
}
};
};
/**
* Calls the abort callback if any request within the batch was aborted. Will only call
* the callback once per batch of requests, even if multiple were aborted.
*
* @param {Object} loadendState - state to check to see if the abort function was called
* @param {Function} abortFn - callback to call for abort
*/
const handleLoadEnd = ({ loadendState, abortFn }) => (event) => {
const request = event.target;
if (request.aborted && abortFn && !loadendState.calledAbortFn) {
abortFn();
loadendState.calledAbortFn = true;
}
};
/**
* Simple progress event callback handler that gathers some stats before
* executing a provided callback with the `segment` object
*
* @param {Object} segment - a simplified copy of the segmentInfo object
* from SegmentLoader
* @param {Function} progressFn - a callback that is executed each time a progress event
* is received
* @param {Function} trackInfoFn - a callback that receives track info
* @param {Function} timingInfoFn - a callback that receives timing info
* @param {Function} videoSegmentTimingInfoFn
* a callback that receives video timing info based on media times and
* any adjustments made by the transmuxer
* @param {Function} audioSegmentTimingInfoFn
* a callback that receives audio timing info based on media times and
* any adjustments made by the transmuxer
* @param {boolean} isEndOfTimeline
* true if this segment represents the last segment in a timeline
* @param {Function} endedTimelineFn
* a callback made when a timeline is ended, will only be called if
* isEndOfTimeline is true
* @param {Function} dataFn - a callback that is executed when segment bytes are available
* and ready to use
* @param {Event} event - the progress event object from XMLHttpRequest
*/
const handleProgress = ({
segment,
progressFn,
trackInfoFn,
timingInfoFn,
videoSegmentTimingInfoFn,
audioSegmentTimingInfoFn,
id3Fn,
captionsFn,
isEndOfTimeline,
endedTimelineFn,
dataFn
}) => (event) => {
const request = event.target;
if (request.aborted) {
return;
}
segment.stats = merge(segment.stats, getProgressStats(event));
// record the time that we receive the first byte of data
if (!segment.stats.firstBytesReceivedAt && segment.stats.bytesReceived) {
segment.stats.firstBytesReceivedAt = Date.now();
}
return progressFn(event, segment);
};
/**
* Load all resources and does any processing necessary for a media-segment
*
* Features:
* decrypts the media-segment if it has a key uri and an iv
* aborts *all* requests if *any* one request fails
*
* The segment object, at minimum, has the following format:
* {
* resolvedUri: String,
* [transmuxer]: Object,
* [byterange]: {
* offset: Number,
* length: Number
* },
* [key]: {
* resolvedUri: String
* [byterange]: {
* offset: Number,
* length: Number
* },
* iv: {
* bytes: Uint32Array
* }
* },
* [map]: {
* resolvedUri: String,
* [byterange]: {
* offset: Number,
* length: Number
* },
* [bytes]: Uint8Array
* }
* }
* ...where [name] denotes optional properties
*
* @param {Function} xhr - an instance of the xhr wrapper in xhr.js
* @param {Object} xhrOptions - the base options to provide to all xhr requests
* @param {WebWorker} decryptionWorker - a WebWorker interface to AES-128
* decryption routines
* @param {Object} segment - a simplified copy of the segmentInfo object
* from SegmentLoader
* @param {Function} abortFn - a callback called (only once) if any piece of a request was
* aborted
* @param {Function} progressFn - a callback that receives progress events from the main
* segment's xhr request
* @param {Function} trackInfoFn - a callback that receives track info
* @param {Function} timingInfoFn - a callback that receives timing info
* @param {Function} videoSegmentTimingInfoFn
* a callback that receives video timing info based on media times and
* any adjustments made by the transmuxer
* @param {Function} audioSegmentTimingInfoFn
* a callback that receives audio timing info based on media times and
* any adjustments made by the transmuxer
* @param {Function} id3Fn - a callback that receives ID3 metadata
* @param {Function} captionsFn - a callback that receives captions
* @param {boolean} isEndOfTimeline
* true if this segment represents the last segment in a timeline
* @param {Function} endedTimelineFn
* a callback made when a timeline is ended, will only be called if
* isEndOfTimeline is true
* @param {Function} dataFn - a callback that receives data from the main segment's xhr
* request, transmuxed if needed
* @param {Function} doneFn - a callback that is executed only once all requests have
* succeeded or failed
* @return {Function} a function that, when invoked, immediately aborts all
* outstanding requests
*/
export const mediaSegmentRequest = ({
xhr,
xhrOptions,
decryptionWorker,
segment,
abortFn,
progressFn,
trackInfoFn,
timingInfoFn,
videoSegmentTimingInfoFn,
audioSegmentTimingInfoFn,
id3Fn,
captionsFn,
isEndOfTimeline,
endedTimelineFn,
dataFn,
doneFn,
onTransmuxerLog,
triggerSegmentEventFn
}) => {
const activeXhrs = [];
const finishProcessingFn = waitForCompletion({
activeXhrs,
decryptionWorker,
trackInfoFn,
timingInfoFn,
videoSegmentTimingInfoFn,
audioSegmentTimingInfoFn,
id3Fn,
captionsFn,
isEndOfTimeline,
endedTimelineFn,
dataFn,
doneFn,
onTransmuxerLog,
triggerSegmentEventFn
});
// optionally, request the decryption key
if (segment.key && !segment.key.bytes) {
const objects = [segment.key];
if (segment.map && !segment.map.bytes && segment.map.key && segment.map.key.resolvedUri === segment.key.resolvedUri) {
objects.push(segment.map.key);
}
const keyRequestOptions = merge(xhrOptions, {
uri: segment.key.resolvedUri,
responseType: 'arraybuffer',
requestType: 'segment-key'
});
const keyRequestCallback = handleKeyResponse(segment, objects, finishProcessingFn, triggerSegmentEventFn);
const keyInfo = { uri: segment.key.resolvedUri };
triggerSegmentEventFn({ type: 'segmentkeyloadstart', segment, keyInfo });
const keyXhr = xhr(keyRequestOptions, keyRequestCallback);
activeXhrs.push(keyXhr);
}
// optionally, request the associated media init segment
if (segment.map && !segment.map.bytes) {
const differentMapKey = segment.map.key && (!segment.key || segment.key.resolvedUri !== segment.map.key.resolvedUri);
if (differentMapKey) {
const mapKeyRequestOptions = merge(xhrOptions, {
uri: segment.map.key.resolvedUri,
responseType: 'arraybuffer',
requestType: 'segment-key'
});
const mapKeyRequestCallback = handleKeyResponse(segment, [segment.map.key], finishProcessingFn, triggerSegmentEventFn);
const keyInfo = { uri: segment.map.key.resolvedUri };
triggerSegmentEventFn({ type: 'segmentkeyloadstart', segment, keyInfo });
const mapKeyXhr = xhr(mapKeyRequestOptions, mapKeyRequestCallback);
activeXhrs.push(mapKeyXhr);
}
const initSegmentOptions = merge(xhrOptions, {
uri: segment.map.resolvedUri,
responseType: 'arraybuffer',
headers: segmentXhrHeaders(segment.map),
requestType: 'segment-media-initialization'
});
const initSegmentRequestCallback = handleInitSegmentResponse({segment, finishProcessingFn, triggerSegmentEventFn});
triggerSegmentEventFn({ type: 'segmentloadstart', segment });
const initSegmentXhr = xhr(initSegmentOptions, initSegmentRequestCallback);
activeXhrs.push(initSegmentXhr);
}
const segmentRequestOptions = merge(xhrOptions, {
uri: segment.part && segment.part.resolvedUri || segment.resolvedUri,
responseType: 'arraybuffer',
headers: segmentXhrHeaders(segment),
requestType: 'segment'
});
const segmentRequestCallback = handleSegmentResponse({
segment,
finishProcessingFn,
responseType: segmentRequestOptions.responseType,
triggerSegmentEventFn
});
triggerSegmentEventFn({ type: 'segmentloadstart', segment });
const segmentXhr = xhr(segmentRequestOptions, segmentRequestCallback);
segmentXhr.addEventListener(
'progress',
handleProgress({
segment,
progressFn,
trackInfoFn,
timingInfoFn,
videoSegmentTimingInfoFn,
audioSegmentTimingInfoFn,
id3Fn,
captionsFn,
isEndOfTimeline,
endedTimelineFn,
dataFn
})
);
activeXhrs.push(segmentXhr);
// since all parts of the request must be considered, but should not make callbacks
// multiple times, provide a shared state object
const loadendState = {};
activeXhrs.forEach((activeXhr) => {
activeXhr.addEventListener(
'loadend',
handleLoadEnd({ loadendState, abortFn })
);
});
return () => abortAll(activeXhrs);
};