UNPKG

@videojs/http-streaming

Version:

Play back HLS and DASH with Video.js, even where it's not natively supported

693 lines (606 loc) 23.6 kB
/** * @file sync-controller.js */ import {sumDurations, getPartsAndSegments} from './playlist'; import videojs from 'video.js'; import logger from './util/logger'; import {MediaSequenceSync, DependantMediaSequenceSync} from './util/media-sequence-sync'; // The maximum gap allowed between two media sequence tags when trying to // synchronize expired playlist segments. // the max media sequence diff is 48 hours of live stream // content with two second segments. Anything larger than that // will likely be invalid. const MAX_MEDIA_SEQUENCE_DIFF_FOR_SYNC = 86400; export const syncPointStrategies = [ // Stategy "VOD": Handle the VOD-case where the sync-point is *always* // the equivalence display-time 0 === segment-index 0 { name: 'VOD', run: (syncController, playlist, duration, currentTimeline, currentTime) => { if (duration !== Infinity) { const syncPoint = { time: 0, segmentIndex: 0, partIndex: null }; return syncPoint; } return null; } }, { name: 'MediaSequence', /** * run media sequence strategy * * @param {SyncController} syncController * @param {Object} playlist * @param {number} duration * @param {number} currentTimeline * @param {number} currentTime * @param {string} type */ run: (syncController, playlist, duration, currentTimeline, currentTime, type) => { const mediaSequenceSync = syncController.getMediaSequenceSync(type); if (!mediaSequenceSync) { return null; } if (!mediaSequenceSync.isReliable) { return null; } const syncInfo = mediaSequenceSync.getSyncInfoForTime(currentTime); if (!syncInfo) { return null; } return { time: syncInfo.start, partIndex: syncInfo.partIndex, segmentIndex: syncInfo.segmentIndex }; } }, // Stategy "ProgramDateTime": We have a program-date-time tag in this playlist { name: 'ProgramDateTime', run: (syncController, playlist, duration, currentTimeline, currentTime) => { if (!Object.keys(syncController.timelineToDatetimeMappings).length) { return null; } let syncPoint = null; let lastDistance = null; const partsAndSegments = getPartsAndSegments(playlist); currentTime = currentTime || 0; for (let i = 0; i < partsAndSegments.length; i++) { // start from the end and loop backwards for live // or start from the front and loop forwards for non-live const index = (playlist.endList || currentTime === 0) ? i : partsAndSegments.length - (i + 1); const partAndSegment = partsAndSegments[index]; const segment = partAndSegment.segment; const datetimeMapping = syncController.timelineToDatetimeMappings[segment.timeline]; if (!datetimeMapping || !segment.dateTimeObject) { continue; } const segmentTime = segment.dateTimeObject.getTime() / 1000; let start = segmentTime + datetimeMapping; // take part duration into account. if (segment.parts && typeof partAndSegment.partIndex === 'number') { for (let z = 0; z < partAndSegment.partIndex; z++) { start += segment.parts[z].duration; } } const distance = Math.abs(currentTime - start); // Once the distance begins to increase, or if distance is 0, we have passed // currentTime and can stop looking for better candidates if (lastDistance !== null && (distance === 0 || lastDistance < distance)) { break; } lastDistance = distance; syncPoint = { time: start, segmentIndex: partAndSegment.segmentIndex, partIndex: partAndSegment.partIndex }; } return syncPoint; } }, // Stategy "Segment": We have a known time mapping for a timeline and a // segment in the current timeline with timing data { name: 'Segment', run: (syncController, playlist, duration, currentTimeline, currentTime) => { let syncPoint = null; let lastDistance = null; currentTime = currentTime || 0; const partsAndSegments = getPartsAndSegments(playlist); for (let i = 0; i < partsAndSegments.length; i++) { // start from the end and loop backwards for live // or start from the front and loop forwards for non-live const index = (playlist.endList || currentTime === 0) ? i : partsAndSegments.length - (i + 1); const partAndSegment = partsAndSegments[index]; const segment = partAndSegment.segment; const start = partAndSegment.part && partAndSegment.part.start || segment && segment.start; if (segment.timeline === currentTimeline && typeof start !== 'undefined') { const distance = Math.abs(currentTime - start); // Once the distance begins to increase, we have passed // currentTime and can stop looking for better candidates if (lastDistance !== null && lastDistance < distance) { break; } if (!syncPoint || lastDistance === null || lastDistance >= distance) { lastDistance = distance; syncPoint = { time: start, segmentIndex: partAndSegment.segmentIndex, partIndex: partAndSegment.partIndex }; } } } return syncPoint; } }, // Stategy "Discontinuity": We have a discontinuity with a known // display-time { name: 'Discontinuity', run: (syncController, playlist, duration, currentTimeline, currentTime) => { let syncPoint = null; currentTime = currentTime || 0; if (playlist.discontinuityStarts && playlist.discontinuityStarts.length) { let lastDistance = null; for (let i = 0; i < playlist.discontinuityStarts.length; i++) { const segmentIndex = playlist.discontinuityStarts[i]; const discontinuity = playlist.discontinuitySequence + i + 1; const discontinuitySync = syncController.discontinuities[discontinuity]; if (discontinuitySync) { const distance = Math.abs(currentTime - discontinuitySync.time); // Once the distance begins to increase, we have passed // currentTime and can stop looking for better candidates if (lastDistance !== null && lastDistance < distance) { break; } if (!syncPoint || lastDistance === null || lastDistance >= distance) { lastDistance = distance; syncPoint = { time: discontinuitySync.time, segmentIndex, partIndex: null }; } } } } return syncPoint; } }, // Stategy "Playlist": We have a playlist with a known mapping of // segment index to display time { name: 'Playlist', run: (syncController, playlist, duration, currentTimeline, currentTime) => { if (playlist.syncInfo) { const syncPoint = { time: playlist.syncInfo.time, segmentIndex: playlist.syncInfo.mediaSequence - playlist.mediaSequence, partIndex: null }; return syncPoint; } return null; } } ]; export default class SyncController extends videojs.EventTarget { constructor(options = {}) { super(); // ...for synching across variants this.timelines = []; this.discontinuities = []; this.timelineToDatetimeMappings = {}; // TODO: this map should be only available for HLS. Since only HLS has MediaSequence. // For some reason this map helps with syncing between quality switch for MPEG-DASH as well. // Moreover if we disable this map for MPEG-DASH - quality switch will be broken. // MPEG-DASH should have its own separate sync strategy const main = new MediaSequenceSync(); const audio = new DependantMediaSequenceSync(main); const vtt = new DependantMediaSequenceSync(main); this.mediaSequenceStorage_ = {main, audio, vtt}; this.logger_ = logger('SyncController'); } /** * * @param {string} loaderType * @return {MediaSequenceSync|null} */ getMediaSequenceSync(loaderType) { return this.mediaSequenceStorage_[loaderType] || null; } /** * Find a sync-point for the playlist specified * * A sync-point is defined as a known mapping from display-time to * a segment-index in the current playlist. * * @param {Playlist} playlist * The playlist that needs a sync-point * @param {number} duration * Duration of the MediaSource (Infinite if playing a live source) * @param {number} currentTimeline * The last timeline from which a segment was loaded * @param {number} currentTime * Current player's time * @param {string} type * Segment loader type * @return {Object} * A sync-point object */ getSyncPoint(playlist, duration, currentTimeline, currentTime, type) { // Always use VOD sync point for VOD if (duration !== Infinity) { const vodSyncPointStrategy = syncPointStrategies.find(({ name }) => name === 'VOD'); return vodSyncPointStrategy.run(this, playlist, duration); } const syncPoints = this.runStrategies_( playlist, duration, currentTimeline, currentTime, type ); if (!syncPoints.length) { // Signal that we need to attempt to get a sync-point manually // by fetching a segment in the playlist and constructing // a sync-point from that information return null; } // If we have exact match just return it instead of finding the nearest distance for (const syncPointInfo of syncPoints) { const { syncPoint, strategy } = syncPointInfo; const { segmentIndex, time } = syncPoint; if (segmentIndex < 0) { continue; } const selectedSegment = playlist.segments[segmentIndex]; const start = time; const end = start + selectedSegment.duration; this.logger_(`Strategy: ${strategy}. Current time: ${currentTime}. selected segment: ${segmentIndex}. Time: [${start} -> ${end}]}`); if (currentTime >= start && currentTime < end) { this.logger_('Found sync point with exact match: ', syncPoint); return syncPoint; } } // Now find the sync-point that is closest to the currentTime because // that should result in the most accurate guess about which segment // to fetch return this.selectSyncPoint_(syncPoints, { key: 'time', value: currentTime }); } /** * Calculate the amount of time that has expired off the playlist during playback * * @param {Playlist} playlist * Playlist object to calculate expired from * @param {number} duration * Duration of the MediaSource (Infinity if playling a live source) * @return {number|null} * The amount of time that has expired off the playlist during playback. Null * if no sync-points for the playlist can be found. */ getExpiredTime(playlist, duration) { if (!playlist || !playlist.segments) { return null; } const syncPoints = this.runStrategies_( playlist, duration, playlist.discontinuitySequence, 0 ); // Without sync-points, there is not enough information to determine the expired time if (!syncPoints.length) { return null; } const syncPoint = this.selectSyncPoint_(syncPoints, { key: 'segmentIndex', value: 0 }); // If the sync-point is beyond the start of the playlist, we want to subtract the // duration from index 0 to syncPoint.segmentIndex instead of adding. if (syncPoint.segmentIndex > 0) { syncPoint.time *= -1; } return Math.abs(syncPoint.time + sumDurations({ defaultDuration: playlist.targetDuration, durationList: playlist.segments, startIndex: syncPoint.segmentIndex, endIndex: 0 })); } /** * Runs each sync-point strategy and returns a list of sync-points returned by the * strategies * * @private * @param {Playlist} playlist * The playlist that needs a sync-point * @param {number} duration * Duration of the MediaSource (Infinity if playing a live source) * @param {number} currentTimeline * The last timeline from which a segment was loaded * @param {number} currentTime * Current player's time * @param {string} type * Segment loader type * @return {Array} * A list of sync-point objects */ runStrategies_(playlist, duration, currentTimeline, currentTime, type) { const syncPoints = []; // Try to find a sync-point in by utilizing various strategies... for (let i = 0; i < syncPointStrategies.length; i++) { const strategy = syncPointStrategies[i]; const syncPoint = strategy.run( this, playlist, duration, currentTimeline, currentTime, type ); if (syncPoint) { syncPoint.strategy = strategy.name; syncPoints.push({ strategy: strategy.name, syncPoint }); } } return syncPoints; } /** * Selects the sync-point nearest the specified target * * @private * @param {Array} syncPoints * List of sync-points to select from * @param {Object} target * Object specifying the property and value we are targeting * @param {string} target.key * Specifies the property to target. Must be either 'time' or 'segmentIndex' * @param {number} target.value * The value to target for the specified key. * @return {Object} * The sync-point nearest the target */ selectSyncPoint_(syncPoints, target) { let bestSyncPoint = syncPoints[0].syncPoint; let bestDistance = Math.abs(syncPoints[0].syncPoint[target.key] - target.value); let bestStrategy = syncPoints[0].strategy; for (let i = 1; i < syncPoints.length; i++) { const newDistance = Math.abs(syncPoints[i].syncPoint[target.key] - target.value); if (newDistance < bestDistance) { bestDistance = newDistance; bestSyncPoint = syncPoints[i].syncPoint; bestStrategy = syncPoints[i].strategy; } } this.logger_(`syncPoint for [${target.key}: ${target.value}] chosen with strategy` + ` [${bestStrategy}]: [time:${bestSyncPoint.time},` + ` segmentIndex:${bestSyncPoint.segmentIndex}` + (typeof bestSyncPoint.partIndex === 'number' ? `,partIndex:${bestSyncPoint.partIndex}` : '') + ']'); return bestSyncPoint; } /** * Save any meta-data present on the segments when segments leave * the live window to the playlist to allow for synchronization at the * playlist level later. * * @param {Playlist} oldPlaylist - The previous active playlist * @param {Playlist} newPlaylist - The updated and most current playlist */ saveExpiredSegmentInfo(oldPlaylist, newPlaylist) { const mediaSequenceDiff = newPlaylist.mediaSequence - oldPlaylist.mediaSequence; // Ignore large media sequence gaps if (mediaSequenceDiff > MAX_MEDIA_SEQUENCE_DIFF_FOR_SYNC) { videojs.log.warn(`Not saving expired segment info. Media sequence gap ${mediaSequenceDiff} is too large.`); return; } // When a segment expires from the playlist and it has a start time // save that information as a possible sync-point reference in future for (let i = mediaSequenceDiff - 1; i >= 0; i--) { const lastRemovedSegment = oldPlaylist.segments[i]; if (lastRemovedSegment && typeof lastRemovedSegment.start !== 'undefined') { newPlaylist.syncInfo = { mediaSequence: oldPlaylist.mediaSequence + i, time: lastRemovedSegment.start }; this.logger_(`playlist refresh sync: [time:${newPlaylist.syncInfo.time},` + ` mediaSequence: ${newPlaylist.syncInfo.mediaSequence}]`); this.trigger('syncinfoupdate'); break; } } } /** * Save the mapping from playlist's ProgramDateTime to display. This should only happen * before segments start to load. * * @param {Playlist} playlist - The currently active playlist */ setDateTimeMappingForStart(playlist) { // It's possible for the playlist to be updated before playback starts, meaning time // zero is not yet set. If, during these playlist refreshes, a discontinuity is // crossed, then the old time zero mapping (for the prior timeline) would be retained // unless the mappings are cleared. this.timelineToDatetimeMappings = {}; if (playlist.segments && playlist.segments.length && playlist.segments[0].dateTimeObject) { const firstSegment = playlist.segments[0]; const playlistTimestamp = firstSegment.dateTimeObject.getTime() / 1000; this.timelineToDatetimeMappings[firstSegment.timeline] = -playlistTimestamp; } } /** * Calculates and saves timeline mappings, playlist sync info, and segment timing values * based on the latest timing information. * * @param {Object} options * Options object * @param {SegmentInfo} options.segmentInfo * The current active request information * @param {boolean} options.shouldSaveTimelineMapping * If there's a timeline change, determines if the timeline mapping should be * saved for timeline mapping and program date time mappings. */ saveSegmentTimingInfo({ segmentInfo, shouldSaveTimelineMapping }) { const didCalculateSegmentTimeMapping = this.calculateSegmentTimeMapping_( segmentInfo, segmentInfo.timingInfo, shouldSaveTimelineMapping ); const segment = segmentInfo.segment; if (didCalculateSegmentTimeMapping) { this.saveDiscontinuitySyncInfo_(segmentInfo); // If the playlist does not have sync information yet, record that information // now with segment timing information if (!segmentInfo.playlist.syncInfo) { segmentInfo.playlist.syncInfo = { mediaSequence: segmentInfo.playlist.mediaSequence + segmentInfo.mediaIndex, time: segment.start }; } } const dateTime = segment.dateTimeObject; if (segment.discontinuity && shouldSaveTimelineMapping && dateTime) { this.timelineToDatetimeMappings[segment.timeline] = -(dateTime.getTime() / 1000); } } timestampOffsetForTimeline(timeline) { if (typeof this.timelines[timeline] === 'undefined') { return null; } return this.timelines[timeline].time; } mappingForTimeline(timeline) { if (typeof this.timelines[timeline] === 'undefined') { return null; } return this.timelines[timeline].mapping; } /** * Use the "media time" for a segment to generate a mapping to "display time" and * save that display time to the segment. * * @private * @param {SegmentInfo} segmentInfo * The current active request information * @param {Object} timingInfo * The start and end time of the current segment in "media time" * @param {boolean} shouldSaveTimelineMapping * If there's a timeline change, determines if the timeline mapping should be * saved in timelines. * @return {boolean} * Returns false if segment time mapping could not be calculated */ calculateSegmentTimeMapping_(segmentInfo, timingInfo, shouldSaveTimelineMapping) { // TODO: remove side effects const segment = segmentInfo.segment; const part = segmentInfo.part; let mappingObj = this.timelines[segmentInfo.timeline]; let start; let end; if (typeof segmentInfo.timestampOffset === 'number') { mappingObj = { time: segmentInfo.startOfSegment, mapping: segmentInfo.startOfSegment - timingInfo.start }; if (shouldSaveTimelineMapping) { this.timelines[segmentInfo.timeline] = mappingObj; this.trigger('timestampoffset'); this.logger_(`time mapping for timeline ${segmentInfo.timeline}: ` + `[time: ${mappingObj.time}] [mapping: ${mappingObj.mapping}]`); } start = segmentInfo.startOfSegment; end = timingInfo.end + mappingObj.mapping; } else if (mappingObj) { start = timingInfo.start + mappingObj.mapping; end = timingInfo.end + mappingObj.mapping; } else { return false; } if (part) { part.start = start; part.end = end; } // If we don't have a segment start yet or the start value we got // is less than our current segment.start value, save a new start value. // We have to do this because parts will have segment timing info saved // multiple times and we want segment start to be the earliest part start // value for that segment. if (!segment.start || start < segment.start) { segment.start = start; } segment.end = end; return true; } /** * Each time we have discontinuity in the playlist, attempt to calculate the location * in display of the start of the discontinuity and save that. We also save an accuracy * value so that we save values with the most accuracy (closest to 0.) * * @private * @param {SegmentInfo} segmentInfo - The current active request information */ saveDiscontinuitySyncInfo_(segmentInfo) { const playlist = segmentInfo.playlist; const segment = segmentInfo.segment; // If the current segment is a discontinuity then we know exactly where // the start of the range and it's accuracy is 0 (greater accuracy values // mean more approximation) if (segment.discontinuity) { this.discontinuities[segment.timeline] = { time: segment.start, accuracy: 0 }; } else if (playlist.discontinuityStarts && playlist.discontinuityStarts.length) { // Search for future discontinuities that we can provide better timing // information for and save that information for sync purposes for (let i = 0; i < playlist.discontinuityStarts.length; i++) { const segmentIndex = playlist.discontinuityStarts[i]; const discontinuity = playlist.discontinuitySequence + i + 1; const mediaIndexDiff = segmentIndex - segmentInfo.mediaIndex; const accuracy = Math.abs(mediaIndexDiff); if (!this.discontinuities[discontinuity] || this.discontinuities[discontinuity].accuracy > accuracy) { let time; if (mediaIndexDiff < 0) { time = segment.start - sumDurations({ defaultDuration: playlist.targetDuration, durationList: playlist.segments, startIndex: segmentInfo.mediaIndex, endIndex: segmentIndex }); } else { time = segment.end + sumDurations({ defaultDuration: playlist.targetDuration, durationList: playlist.segments, startIndex: segmentInfo.mediaIndex + 1, endIndex: segmentIndex }); } this.discontinuities[discontinuity] = { time, accuracy }; } } } } dispose() { this.trigger('dispose'); this.off(); } }