@videojs/http-streaming
Version:
Play back HLS and DASH with Video.js, even where it's not natively supported
403 lines (339 loc) • 12.8 kB
JavaScript
// TODO handle fmp4 case where the timing info is accurate and doesn't involve transmux
/**
* @file time.js
*/
import Playlist from '../playlist';
// Add 25% to the segment duration to account for small discrepencies in segment timing.
// 25% was arbitrarily chosen, and may need to be refined over time.
const SEGMENT_END_FUDGE_PERCENT = 0.25;
/**
* Converts a player time (any time that can be gotten/set from player.currentTime(),
* e.g., any time within player.seekable().start(0) to player.seekable().end(0)) to a
* program time (any time referencing the real world (e.g., EXT-X-PROGRAM-DATE-TIME)).
*
* The containing segment is required as the EXT-X-PROGRAM-DATE-TIME serves as an "anchor
* point" (a point where we have a mapping from program time to player time, with player
* time being the post transmux start of the segment).
*
* For more details, see [this doc](../../docs/program-time-from-player-time.md).
*
* @param {number} playerTime the player time
* @param {Object} segment the segment which contains the player time
* @return {Date} program time
*/
export const playerTimeToProgramTime = (playerTime, segment) => {
if (!segment.dateTimeObject) {
// Can't convert without an "anchor point" for the program time (i.e., a time that can
// be used to map the start of a segment with a real world time).
return null;
}
const transmuxerPrependedSeconds = segment.videoTimingInfo.transmuxerPrependedSeconds;
const transmuxedStart = segment.videoTimingInfo.transmuxedPresentationStart;
// get the start of the content from before old content is prepended
const startOfSegment = transmuxedStart + transmuxerPrependedSeconds;
const offsetFromSegmentStart = playerTime - startOfSegment;
return new Date(segment.dateTimeObject.getTime() + offsetFromSegmentStart * 1000);
};
export const originalSegmentVideoDuration = (videoTimingInfo) => {
return videoTimingInfo.transmuxedPresentationEnd -
videoTimingInfo.transmuxedPresentationStart -
videoTimingInfo.transmuxerPrependedSeconds;
};
/**
* Finds a segment that contains the time requested given as an ISO-8601 string. The
* returned segment might be an estimate or an accurate match.
*
* @param {string} programTime The ISO-8601 programTime to find a match for
* @param {Object} playlist A playlist object to search within
*/
export const findSegmentForProgramTime = (programTime, playlist) => {
// Assumptions:
// - verifyProgramDateTimeTags has already been run
// - live streams have been started
let dateTimeObject;
try {
dateTimeObject = new Date(programTime);
} catch (e) {
return null;
}
if (!playlist || !playlist.segments || playlist.segments.length === 0) {
return null;
}
let segment = playlist.segments[0];
if (dateTimeObject < new Date(segment.dateTimeObject)) {
// Requested time is before stream start.
return null;
}
for (let i = 0; i < playlist.segments.length - 1; i++) {
segment = playlist.segments[i];
const nextSegmentStart = new Date(playlist.segments[i + 1].dateTimeObject);
if (dateTimeObject < nextSegmentStart) {
break;
}
}
const lastSegment = playlist.segments[playlist.segments.length - 1];
const lastSegmentStart = lastSegment.dateTimeObject;
const lastSegmentDuration = lastSegment.videoTimingInfo ?
originalSegmentVideoDuration(lastSegment.videoTimingInfo) :
lastSegment.duration + lastSegment.duration * SEGMENT_END_FUDGE_PERCENT;
const lastSegmentEnd =
new Date(lastSegmentStart.getTime() + lastSegmentDuration * 1000);
if (dateTimeObject > lastSegmentEnd) {
// Beyond the end of the stream, or our best guess of the end of the stream.
return null;
}
if (dateTimeObject > new Date(lastSegmentStart)) {
segment = lastSegment;
}
return {
segment,
estimatedStart: segment.videoTimingInfo ?
segment.videoTimingInfo.transmuxedPresentationStart :
Playlist.duration(
playlist,
playlist.mediaSequence + playlist.segments.indexOf(segment)
),
// Although, given that all segments have accurate date time objects, the segment
// selected should be accurate, unless the video has been transmuxed at some point
// (determined by the presence of the videoTimingInfo object), the segment's "player
// time" (the start time in the player) can't be considered accurate.
type: segment.videoTimingInfo ? 'accurate' : 'estimate'
};
};
/**
* Finds a segment that contains the given player time(in seconds).
*
* @param {number} time The player time to find a match for
* @param {Object} playlist A playlist object to search within
*/
export const findSegmentForPlayerTime = (time, playlist) => {
// Assumptions:
// - there will always be a segment.duration
// - we can start from zero
// - segments are in time order
if (!playlist || !playlist.segments || playlist.segments.length === 0) {
return null;
}
let segmentEnd = 0;
let segment;
for (let i = 0; i < playlist.segments.length; i++) {
segment = playlist.segments[i];
// videoTimingInfo is set after the segment is downloaded and transmuxed, and
// should contain the most accurate values we have for the segment's player times.
//
// Use the accurate transmuxedPresentationEnd value if it is available, otherwise fall
// back to an estimate based on the manifest derived (inaccurate) segment.duration, to
// calculate an end value.
segmentEnd = segment.videoTimingInfo ?
segment.videoTimingInfo.transmuxedPresentationEnd : segmentEnd + segment.duration;
if (time <= segmentEnd) {
break;
}
}
const lastSegment = playlist.segments[playlist.segments.length - 1];
if (lastSegment.videoTimingInfo &&
lastSegment.videoTimingInfo.transmuxedPresentationEnd < time) {
// The time requested is beyond the stream end.
return null;
}
if (time > segmentEnd) {
// The time is within or beyond the last segment.
//
// Check to see if the time is beyond a reasonable guess of the end of the stream.
if (time > segmentEnd + (lastSegment.duration * SEGMENT_END_FUDGE_PERCENT)) {
// Technically, because the duration value is only an estimate, the time may still
// exist in the last segment, however, there isn't enough information to make even
// a reasonable estimate.
return null;
}
segment = lastSegment;
}
return {
segment,
estimatedStart: segment.videoTimingInfo ?
segment.videoTimingInfo.transmuxedPresentationStart : segmentEnd - segment.duration,
// Because videoTimingInfo is only set after transmux, it is the only way to get
// accurate timing values.
type: segment.videoTimingInfo ? 'accurate' : 'estimate'
};
};
/**
* Gives the offset of the comparisonTimestamp from the programTime timestamp in seconds.
* If the offset returned is positive, the programTime occurs after the
* comparisonTimestamp.
* If the offset is negative, the programTime occurs before the comparisonTimestamp.
*
* @param {string} comparisonTimeStamp An ISO-8601 timestamp to compare against
* @param {string} programTime The programTime as an ISO-8601 string
* @return {number} offset
*/
export const getOffsetFromTimestamp = (comparisonTimeStamp, programTime) => {
let segmentDateTime;
let programDateTime;
try {
segmentDateTime = new Date(comparisonTimeStamp);
programDateTime = new Date(programTime);
} catch (e) {
// TODO handle error
}
const segmentTimeEpoch = segmentDateTime.getTime();
const programTimeEpoch = programDateTime.getTime();
return (programTimeEpoch - segmentTimeEpoch) / 1000;
};
/**
* Checks that all segments in this playlist have programDateTime tags.
*
* @param {Object} playlist A playlist object
*/
export const verifyProgramDateTimeTags = (playlist) => {
if (!playlist.segments || playlist.segments.length === 0) {
return false;
}
for (let i = 0; i < playlist.segments.length; i++) {
const segment = playlist.segments[i];
if (!segment.dateTimeObject) {
return false;
}
}
return true;
};
/**
* Returns the programTime of the media given a playlist and a playerTime.
* The playlist must have programDateTime tags for a programDateTime tag to be returned.
* If the segments containing the time requested have not been buffered yet, an estimate
* may be returned to the callback.
*
* @param {Object} args
* @param {Object} args.playlist A playlist object to search within
* @param {number} time A playerTime in seconds
* @param {Function} callback(err, programTime)
* @return {string} err.message A detailed error message
* @return {Object} programTime
* @return {number} programTime.mediaSeconds The streamTime in seconds
* @return {string} programTime.programDateTime The programTime as an ISO-8601 String
*/
export const getProgramTime = ({
playlist,
time = undefined,
callback
}) => {
if (!callback) {
throw new Error('getProgramTime: callback must be provided');
}
if (!playlist || time === undefined) {
return callback({
message: 'getProgramTime: playlist and time must be provided'
});
}
const matchedSegment = findSegmentForPlayerTime(time, playlist);
if (!matchedSegment) {
return callback({
message: 'valid programTime was not found'
});
}
if (matchedSegment.type === 'estimate') {
return callback({
message:
'Accurate programTime could not be determined.' +
' Please seek to e.seekTime and try again',
seekTime: matchedSegment.estimatedStart
});
}
const programTimeObject = {
mediaSeconds: time
};
const programTime = playerTimeToProgramTime(time, matchedSegment.segment);
if (programTime) {
programTimeObject.programDateTime = programTime.toISOString();
}
return callback(null, programTimeObject);
};
/**
* Seeks in the player to a time that matches the given programTime ISO-8601 string.
*
* @param {Object} args
* @param {string} args.programTime A programTime to seek to as an ISO-8601 String
* @param {Object} args.playlist A playlist to look within
* @param {number} args.retryCount The number of times to try for an accurate seek. Default is 2.
* @param {Function} args.seekTo A method to perform a seek
* @param {boolean} args.pauseAfterSeek Whether to end in a paused state after seeking. Default is true.
* @param {Object} args.tech The tech to seek on
* @param {Function} args.callback(err, newTime) A callback to return the new time to
* @return {string} err.message A detailed error message
* @return {number} newTime The exact time that was seeked to in seconds
*/
export const seekToProgramTime = ({
programTime,
playlist,
retryCount = 2,
seekTo,
pauseAfterSeek = true,
tech,
callback
}) => {
if (!callback) {
throw new Error('seekToProgramTime: callback must be provided');
}
if (typeof programTime === 'undefined' || !playlist || !seekTo) {
return callback({
message: 'seekToProgramTime: programTime, seekTo and playlist must be provided'
});
}
if (!playlist.endList && !tech.hasStarted_) {
return callback({
message: 'player must be playing a live stream to start buffering'
});
}
if (!verifyProgramDateTimeTags(playlist)) {
return callback({
message: 'programDateTime tags must be provided in the manifest ' + playlist.resolvedUri
});
}
const matchedSegment = findSegmentForProgramTime(programTime, playlist);
// no match
if (!matchedSegment) {
return callback({
message: `${programTime} was not found in the stream`
});
}
const segment = matchedSegment.segment;
const mediaOffset = getOffsetFromTimestamp(
segment.dateTimeObject,
programTime
);
if (matchedSegment.type === 'estimate') {
// we've run out of retries
if (retryCount === 0) {
return callback({
message: `${programTime} is not buffered yet. Try again`
});
}
seekTo(matchedSegment.estimatedStart + mediaOffset);
tech.one('seeked', () => {
seekToProgramTime({
programTime,
playlist,
retryCount: retryCount - 1,
seekTo,
pauseAfterSeek,
tech,
callback
});
});
return;
}
// Since the segment.start value is determined from the buffered end or ending time
// of the prior segment, the seekToTime doesn't need to account for any transmuxer
// modifications.
const seekToTime = segment.start + mediaOffset;
const seekedCallback = () => {
return callback(null, tech.currentTime());
};
// listen for seeked event
tech.one('seeked', seekedCallback);
// pause before seeking as video.js will restore this state
if (pauseAfterSeek) {
tech.pause();
}
seekTo(seekToTime);
};